forked from sanbuphy/learn-coding-agent
-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathzipCache.ts
More file actions
406 lines (374 loc) · 12.9 KB
/
zipCache.ts
File metadata and controls
406 lines (374 loc) · 12.9 KB
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
/**
* 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)
}