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