source dump of claude code
at main 406 lines 13 kB view raw
1/** 2 * Plugin Zip Cache Module 3 * 4 * Manages plugins as ZIP archives in a mounted directory (e.g., Filestore). 5 * When CLAUDE_CODE_PLUGIN_USE_ZIP_CACHE is enabled and CLAUDE_CODE_PLUGIN_CACHE_DIR 6 * is set, plugins are stored as ZIPs in that directory and extracted to a 7 * session-local temp directory at startup. 8 * 9 * Limitations: 10 * - Only headless mode is supported 11 * - All settings sources are used (same as normal plugin flow) 12 * - Only github, git, and url marketplace sources are supported 13 * - Only strict:true marketplace entries are supported 14 * - Auto-update is non-blocking (background, does not affect current session) 15 * 16 * Directory structure of the zip cache: 17 * /mnt/plugins-cache/ 18 * ├── known_marketplaces.json 19 * ├── installed_plugins.json 20 * ├── marketplaces/ 21 * │ ├── official-marketplace.json 22 * │ └── company-marketplace.json 23 * └── plugins/ 24 * ├── official-marketplace/ 25 * │ └── plugin-a/ 26 * │ └── 1.0.0.zip 27 * └── company-marketplace/ 28 * └── plugin-b/ 29 * └── 2.1.3.zip 30 */ 31 32import { randomBytes } from 'crypto' 33import { 34 chmod, 35 lstat, 36 readdir, 37 readFile, 38 rename, 39 rm, 40 stat, 41 writeFile, 42} from 'fs/promises' 43import { tmpdir } from 'os' 44import { basename, dirname, join } from 'path' 45import { logForDebugging } from '../debug.js' 46import { parseZipModes, unzipFile } from '../dxt/zip.js' 47import { isEnvTruthy } from '../envUtils.js' 48import { getFsImplementation } from '../fsOperations.js' 49import { expandTilde } from '../permissions/pathValidation.js' 50import type { MarketplaceSource } from './schemas.js' 51 52/** 53 * Check if the plugin zip cache mode is enabled. 54 */ 55export function isPluginZipCacheEnabled(): boolean { 56 return isEnvTruthy(process.env.CLAUDE_CODE_PLUGIN_USE_ZIP_CACHE) 57} 58 59/** 60 * Get the path to the zip cache directory. 61 * Requires CLAUDE_CODE_PLUGIN_CACHE_DIR to be set. 62 * Returns undefined if zip cache is not enabled. 63 */ 64export function getPluginZipCachePath(): string | undefined { 65 if (!isPluginZipCacheEnabled()) { 66 return undefined 67 } 68 const dir = process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR 69 return dir ? expandTilde(dir) : undefined 70} 71 72/** 73 * Get the path to known_marketplaces.json in the zip cache. 74 */ 75export function getZipCacheKnownMarketplacesPath(): string { 76 const cachePath = getPluginZipCachePath() 77 if (!cachePath) { 78 throw new Error('Plugin zip cache is not enabled') 79 } 80 return join(cachePath, 'known_marketplaces.json') 81} 82 83/** 84 * Get the path to installed_plugins.json in the zip cache. 85 */ 86export function getZipCacheInstalledPluginsPath(): string { 87 const cachePath = getPluginZipCachePath() 88 if (!cachePath) { 89 throw new Error('Plugin zip cache is not enabled') 90 } 91 return join(cachePath, 'installed_plugins.json') 92} 93 94/** 95 * Get the marketplaces directory within the zip cache. 96 */ 97export function getZipCacheMarketplacesDir(): string { 98 const cachePath = getPluginZipCachePath() 99 if (!cachePath) { 100 throw new Error('Plugin zip cache is not enabled') 101 } 102 return join(cachePath, 'marketplaces') 103} 104 105/** 106 * Get the plugins directory within the zip cache. 107 */ 108export function getZipCachePluginsDir(): string { 109 const cachePath = getPluginZipCachePath() 110 if (!cachePath) { 111 throw new Error('Plugin zip cache is not enabled') 112 } 113 return join(cachePath, 'plugins') 114} 115 116// Session plugin cache: a temp directory on local disk (NOT in the mounted zip cache) 117// that holds extracted plugins for the duration of the session. 118let sessionPluginCachePath: string | null = null 119let sessionPluginCachePromise: Promise<string> | null = null 120 121/** 122 * Get or create the session plugin cache directory. 123 * This is a temp directory on local disk where plugins are extracted for the session. 124 */ 125export async function getSessionPluginCachePath(): Promise<string> { 126 if (sessionPluginCachePath) { 127 return sessionPluginCachePath 128 } 129 if (!sessionPluginCachePromise) { 130 sessionPluginCachePromise = (async () => { 131 const suffix = randomBytes(8).toString('hex') 132 const dir = join(tmpdir(), `claude-plugin-session-${suffix}`) 133 await getFsImplementation().mkdir(dir) 134 sessionPluginCachePath = dir 135 logForDebugging(`Created session plugin cache at ${dir}`) 136 return dir 137 })() 138 } 139 return sessionPluginCachePromise 140} 141 142/** 143 * Clean up the session plugin cache directory. 144 * Should be called when the session ends. 145 */ 146export async function cleanupSessionPluginCache(): Promise<void> { 147 if (!sessionPluginCachePath) { 148 return 149 } 150 try { 151 await rm(sessionPluginCachePath, { recursive: true, force: true }) 152 logForDebugging( 153 `Cleaned up session plugin cache at ${sessionPluginCachePath}`, 154 ) 155 } catch (error) { 156 logForDebugging(`Failed to clean up session plugin cache: ${error}`) 157 } finally { 158 sessionPluginCachePath = null 159 sessionPluginCachePromise = null 160 } 161} 162 163/** 164 * Reset the session plugin cache path (for testing). 165 */ 166export function resetSessionPluginCache(): void { 167 sessionPluginCachePath = null 168 sessionPluginCachePromise = null 169} 170 171/** 172 * Write data to a file in the zip cache atomically. 173 * Writes to a temp file in the same directory, then renames. 174 */ 175export async function atomicWriteToZipCache( 176 targetPath: string, 177 data: string | Uint8Array, 178): Promise<void> { 179 const dir = dirname(targetPath) 180 await getFsImplementation().mkdir(dir) 181 182 const tmpName = `.${basename(targetPath)}.tmp.${randomBytes(4).toString('hex')}` 183 const tmpPath = join(dir, tmpName) 184 185 try { 186 if (typeof data === 'string') { 187 await writeFile(tmpPath, data, { encoding: 'utf-8' }) 188 } else { 189 await writeFile(tmpPath, data) 190 } 191 await rename(tmpPath, targetPath) 192 } catch (error) { 193 // Clean up tmp file on failure 194 try { 195 await rm(tmpPath, { force: true }) 196 } catch { 197 // ignore cleanup errors 198 } 199 throw error 200 } 201} 202 203// fflate's ZippableFile tuple form: [data, opts]. Using the tuple lets us 204// store {os, attrs} so parseZipModes can recover exec bits on extraction. 205type ZipEntry = [Uint8Array, { os: number; attrs: number }] 206 207/** 208 * Create a ZIP archive from a directory. 209 * Resolves symlinks to actual file contents (replaces symlinks with real data). 210 * Stores Unix mode bits in external_attr so extractZipToDirectory can restore 211 * +x — otherwise the round-trip (git clone → zip → extract) loses exec bits. 212 * 213 * @param sourceDir - Directory to zip 214 * @returns ZIP file as Uint8Array 215 */ 216export async function createZipFromDirectory( 217 sourceDir: string, 218): Promise<Uint8Array> { 219 const files: Record<string, ZipEntry> = {} 220 const visited = new Set<string>() 221 await collectFilesForZip(sourceDir, '', files, visited) 222 223 const { zipSync } = await import('fflate') 224 const zipData = zipSync(files, { level: 6 }) 225 logForDebugging( 226 `Created ZIP from ${sourceDir}: ${Object.keys(files).length} files, ${zipData.length} bytes`, 227 ) 228 return zipData 229} 230 231/** 232 * Recursively collect files from a directory for zipping. 233 * Uses lstat to detect symlinks and tracks visited inodes for cycle detection. 234 */ 235async function collectFilesForZip( 236 baseDir: string, 237 relativePath: string, 238 files: Record<string, ZipEntry>, 239 visited: Set<string>, 240): Promise<void> { 241 const currentDir = relativePath ? join(baseDir, relativePath) : baseDir 242 let entries: string[] 243 try { 244 entries = await readdir(currentDir) 245 } catch { 246 return 247 } 248 249 // Track visited directories by dev+ino to detect symlink cycles. 250 // bigint: true is required — on Windows NTFS, the file index packs a 16-bit 251 // sequence number into the high bits. Once that sequence exceeds ~32 (very 252 // common on a busy CI runner that churns through temp files), the value 253 // exceeds Number.MAX_SAFE_INTEGER and two adjacent directories round to the 254 // same JS number, causing subdirs to be silently skipped as "cycles". This 255 // broke the round-trip test on Windows CI when sharding shuffled which tests 256 // ran first and pushed MFT sequence numbers over the precision cliff. 257 // See also: markdownConfigLoader.ts getFileIdentity, anthropics/claude-code#13893 258 try { 259 const dirStat = await stat(currentDir, { bigint: true }) 260 // ReFS (Dev Drive), NFS, some FUSE mounts report dev=0 and ino=0 for 261 // everything. Fail open: skip cycle detection rather than skip the 262 // directory. We already skip symlinked directories unconditionally below, 263 // so the only cycle left here is a bind mount, which we accept. 264 if (dirStat.dev !== 0n || dirStat.ino !== 0n) { 265 const key = `${dirStat.dev}:${dirStat.ino}` 266 if (visited.has(key)) { 267 logForDebugging(`Skipping symlink cycle at ${currentDir}`) 268 return 269 } 270 visited.add(key) 271 } 272 } catch { 273 return 274 } 275 276 for (const entry of entries) { 277 // Skip hidden files that are git-related 278 if (entry === '.git') { 279 continue 280 } 281 282 const fullPath = join(currentDir, entry) 283 const relPath = relativePath ? `${relativePath}/${entry}` : entry 284 285 let fileStat 286 try { 287 fileStat = await lstat(fullPath) 288 } catch { 289 continue 290 } 291 292 // Skip symlinked directories (follow symlinked files) 293 if (fileStat.isSymbolicLink()) { 294 try { 295 const targetStat = await stat(fullPath) 296 if (targetStat.isDirectory()) { 297 continue 298 } 299 // Symlinked file — read its contents below 300 fileStat = targetStat 301 } catch { 302 continue // broken symlink 303 } 304 } 305 306 if (fileStat.isDirectory()) { 307 await collectFilesForZip(baseDir, relPath, files, visited) 308 } else if (fileStat.isFile()) { 309 try { 310 const content = await readFile(fullPath) 311 // os=3 (Unix) + st_mode in high 16 bits of external_attr — this is 312 // what parseZipModes reads back on extraction. fileStat is already 313 // in hand from the lstat/stat above, so no extra syscall. 314 files[relPath] = [ 315 new Uint8Array(content), 316 { os: 3, attrs: (fileStat.mode & 0xffff) << 16 }, 317 ] 318 } catch (error) { 319 logForDebugging(`Failed to read file for zip: ${relPath}: ${error}`) 320 } 321 } 322 } 323} 324 325/** 326 * Extract a ZIP file to a target directory. 327 * 328 * @param zipPath - Path to the ZIP file 329 * @param targetDir - Directory to extract into 330 */ 331export async function extractZipToDirectory( 332 zipPath: string, 333 targetDir: string, 334): Promise<void> { 335 const zipBuf = await getFsImplementation().readFileBytes(zipPath) 336 const files = await unzipFile(zipBuf) 337 // fflate doesn't surface external_attr — parse the central directory so 338 // exec bits survive extraction (hooks/scripts need +x to run via `sh -c`). 339 const modes = parseZipModes(zipBuf) 340 341 await getFsImplementation().mkdir(targetDir) 342 343 for (const [relPath, data] of Object.entries(files)) { 344 // Skip directory entries (trailing slash) 345 if (relPath.endsWith('/')) { 346 await getFsImplementation().mkdir(join(targetDir, relPath)) 347 continue 348 } 349 350 const fullPath = join(targetDir, relPath) 351 await getFsImplementation().mkdir(dirname(fullPath)) 352 await writeFile(fullPath, data) 353 const mode = modes[relPath] 354 if (mode && mode & 0o111) { 355 // Swallow EPERM/ENOTSUP (NFS root_squash, some FUSE mounts) — losing +x 356 // is the pre-PR behavior and better than aborting mid-extraction. 357 await chmod(fullPath, mode & 0o777).catch(() => {}) 358 } 359 } 360 361 logForDebugging( 362 `Extracted ZIP to ${targetDir}: ${Object.keys(files).length} entries`, 363 ) 364} 365 366/** 367 * Convert a plugin directory to a ZIP in-place: zip → atomic write → delete dir. 368 * Both call sites (cacheAndRegisterPlugin, copyPluginToVersionedCache) need the 369 * same sequence; getting it wrong (non-atomic write, forgetting rm) corrupts cache. 370 */ 371export async function convertDirectoryToZipInPlace( 372 dirPath: string, 373 zipPath: string, 374): Promise<void> { 375 const zipData = await createZipFromDirectory(dirPath) 376 await atomicWriteToZipCache(zipPath, zipData) 377 await rm(dirPath, { recursive: true, force: true }) 378} 379 380/** 381 * Get the relative path for a marketplace JSON file within the zip cache. 382 * Format: marketplaces/{marketplace-name}.json 383 */ 384export function getMarketplaceJsonRelativePath( 385 marketplaceName: string, 386): string { 387 const sanitized = marketplaceName.replace(/[^a-zA-Z0-9\-_]/g, '-') 388 return join('marketplaces', `${sanitized}.json`) 389} 390 391/** 392 * Check if a marketplace source type is supported by zip cache mode. 393 * 394 * Supported sources write to `join(cacheDir, name)` — syncMarketplacesToZipCache 395 * reads marketplace.json from that installLocation, source-type-agnostic. 396 * - github/git/url: clone to temp, rename into cacheDir 397 * - settings: write synthetic marketplace.json directly to cacheDir (no fetch) 398 * 399 * Excluded: file/directory (installLocation is the user's path OUTSIDE cacheDir — 400 * nonsensical in ephemeral containers), npm (node_modules bloat on Filestore mount). 401 */ 402export function isMarketplaceSourceSupportedByZipCache( 403 source: MarketplaceSource, 404): boolean { 405 return ['github', 'git', 'url', 'settings'].includes(source.source) 406}