/ utils / plugins / zipCache.ts
zipCache.ts
  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  
 32  import { randomBytes } from 'crypto'
 33  import {
 34    chmod,
 35    lstat,
 36    readdir,
 37    readFile,
 38    rename,
 39    rm,
 40    stat,
 41    writeFile,
 42  } from 'fs/promises'
 43  import { tmpdir } from 'os'
 44  import { basename, dirname, join } from 'path'
 45  import { logForDebugging } from '../debug.js'
 46  import { parseZipModes, unzipFile } from '../dxt/zip.js'
 47  import { isEnvTruthy } from '../envUtils.js'
 48  import { getFsImplementation } from '../fsOperations.js'
 49  import { expandTilde } from '../permissions/pathValidation.js'
 50  import type { MarketplaceSource } from './schemas.js'
 51  
 52  /**
 53   * Check if the plugin zip cache mode is enabled.
 54   */
 55  export 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   */
 64  export 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   */
 75  export 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   */
 86  export 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   */
 97  export 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   */
108  export 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.
118  let sessionPluginCachePath: string | null = null
119  let 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   */
125  export 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   */
146  export 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   */
166  export 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   */
175  export 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.
205  type 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   */
216  export 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   */
235  async 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   */
331  export 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   */
371  export 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   */
384  export 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   */
402  export function isMarketplaceSourceSupportedByZipCache(
403    source: MarketplaceSource,
404  ): boolean {
405    return ['github', 'git', 'url', 'settings'].includes(source.source)
406  }