@anionex/dsh-vision-toolkit 0.1.39 → 0.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +10 -2
  3. package/README.zh.md +10 -2
  4. package/assets/logo_eapi_dark.png +0 -0
  5. package/docs/aihubmix-gemini-vision.i18n.yaml +2 -2
  6. package/docs/aihubmix-gemini-vision.md +2 -2
  7. package/docs/aihubmix-gemini-vision.zh.md +2 -2
  8. package/docs/requirements-traceability/README.i18n.yaml +2 -2
  9. package/docs/requirements-traceability/README.md +1 -1
  10. package/docs/requirements-traceability/README.zh.md +1 -1
  11. package/lib/artifact-access.js +20 -2
  12. package/lib/artifact-access.js.map +1 -1
  13. package/lib/client.js +11 -3
  14. package/lib/client.js.map +1 -1
  15. package/lib/config.js +46 -2
  16. package/lib/config.js.map +1 -1
  17. package/lib/evidence-cache.js +2 -1
  18. package/lib/evidence-cache.js.map +1 -1
  19. package/lib/image-input-variants.js +61 -26
  20. package/lib/image-input-variants.js.map +1 -1
  21. package/lib/index.js +53 -6
  22. package/lib/index.js.map +1 -1
  23. package/lib/paste-images.js +67 -19
  24. package/lib/paste-images.js.map +1 -1
  25. package/lib/paths.js +214 -28
  26. package/lib/paths.js.map +1 -1
  27. package/lib/runtime-install.js +147 -4
  28. package/lib/runtime-install.js.map +1 -1
  29. package/lib/runtime-manager.js +76 -10
  30. package/lib/runtime-manager.js.map +1 -1
  31. package/lib/runtime.js +16 -10
  32. package/lib/runtime.js.map +1 -1
  33. package/lib/storage-history.js +154 -0
  34. package/lib/storage-history.js.map +1 -0
  35. package/lib/types/artifact-access.d.ts.map +1 -1
  36. package/lib/types/client/index.d.ts +6 -1
  37. package/lib/types/client/index.d.ts.map +1 -1
  38. package/lib/types/config.d.ts +24 -1
  39. package/lib/types/config.d.ts.map +1 -1
  40. package/lib/types/evidence-cache.d.ts +1 -1
  41. package/lib/types/evidence-cache.d.ts.map +1 -1
  42. package/lib/types/image-input-variants.d.ts +7 -4
  43. package/lib/types/image-input-variants.d.ts.map +1 -1
  44. package/lib/types/index.d.ts.map +1 -1
  45. package/lib/types/paste-images.d.ts +12 -4
  46. package/lib/types/paste-images.d.ts.map +1 -1
  47. package/lib/types/paths.d.ts +31 -5
  48. package/lib/types/paths.d.ts.map +1 -1
  49. package/lib/types/runtime-install.d.ts +8 -0
  50. package/lib/types/runtime-install.d.ts.map +1 -1
  51. package/lib/types/runtime-manager.d.ts +28 -4
  52. package/lib/types/runtime-manager.d.ts.map +1 -1
  53. package/lib/types/runtime.d.ts +4 -1
  54. package/lib/types/runtime.d.ts.map +1 -1
  55. package/lib/types/storage-history.d.ts +63 -0
  56. package/lib/types/storage-history.d.ts.map +1 -0
  57. package/lib/types/web.d.ts.map +1 -1
  58. package/lib/web.js +9 -3
  59. package/lib/web.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/artifact-access.ts +22 -2
  62. package/src/client/index.tsx +18 -3
  63. package/src/config.ts +76 -2
  64. package/src/evidence-cache.ts +2 -1
  65. package/src/image-input-variants.ts +61 -20
  66. package/src/index.ts +65 -6
  67. package/src/paste-images.ts +81 -19
  68. package/src/paths.ts +249 -28
  69. package/src/runtime-install.ts +168 -5
  70. package/src/runtime-manager.ts +93 -10
  71. package/src/runtime.ts +15 -9
  72. package/src/storage-history.ts +172 -0
  73. package/src/web.ts +9 -2
package/src/paths.ts CHANGED
@@ -6,9 +6,10 @@
6
6
  * @module dsh-vision-toolkit/paths
7
7
  */
8
8
 
9
- import { randomUUID } from 'node:crypto'
10
- import { cp, link, lstat, mkdir, readdir, realpath, rename, rm, stat } from 'node:fs/promises'
11
- import { extname, isAbsolute, join, relative, resolve, sep, win32 } from 'node:path'
9
+ import { createHash, randomUUID } from 'node:crypto'
10
+ import type { Stats } from 'node:fs'
11
+ import { cp, link, lstat, mkdir, mkdtemp, readdir, realpath, rename, rm, stat } from 'node:fs/promises'
12
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep, win32 } from 'node:path'
12
13
  import { homedir, tmpdir } from 'node:os'
13
14
  import { VisionToolkitError } from './errors.ts'
14
15
 
@@ -23,10 +24,22 @@ export interface PathPolicy {
23
24
  tempDir: string
24
25
  /** Real allowed roots: workspace, platform temp, and configured directories. */
25
26
  allowedDirs: string[]
27
+ /** Real plugin-managed root containing artifacts and transient files. */
28
+ storageRoot: string
26
29
  /** Real plugin-managed output directory inside the fence. */
27
30
  outputDir: string
28
31
  }
29
32
 
33
+ /** Resolved plugin storage for one workspace. */
34
+ export interface WorkspaceStorage {
35
+ /** Real workspace root. */
36
+ workspace: string
37
+ /** Real plugin-managed root for this workspace. */
38
+ root: string
39
+ /** Absolute user-visible spelling of the same managed root. */
40
+ visibleRoot: string
41
+ }
42
+
30
43
  /** Whether `child` equals or lies under `parent` on the same path root. */
31
44
  export function isWithin(parent: string, child: string): boolean {
32
45
  const rel = relative(parent, child)
@@ -65,26 +78,231 @@ export function normalizePlatformTempPath(
65
78
  return win32.join(tempDirectory, raw.slice('/tmp/'.length))
66
79
  }
67
80
 
81
+ /** Stable opaque per-user workspace id used below a shared storage root. */
82
+ export function workspaceStorageId(
83
+ workspace: string,
84
+ userIdentity: string = typeof process.geteuid === 'function'
85
+ ? `uid:${process.geteuid()}`
86
+ : `home:${homedir()}`,
87
+ ): string {
88
+ return createHash('sha256')
89
+ .update(userIdentity)
90
+ .update('\0')
91
+ .update(workspace)
92
+ .digest('hex')
93
+ .slice(0, 20)
94
+ }
95
+
96
+ function currentPosixUid(): number {
97
+ if (typeof process.geteuid !== 'function') {
98
+ throw new VisionToolkitError(
99
+ 'path',
100
+ 'configured storage directory is not supported on this platform because ownership and permissions cannot be verified',
101
+ )
102
+ }
103
+ return process.geteuid()
104
+ }
105
+
106
+ export function assertSecureWorkspaceStorage(info: Stats, requested: string): void {
107
+ const currentUid = currentPosixUid()
108
+ if (info.uid !== currentUid || (info.mode & 0o777) !== 0o700) {
109
+ throw new VisionToolkitError('path', `workspace storage directory must be owned by the current user with mode 0700: ${requested}`)
110
+ }
111
+ }
112
+
113
+ /** Resolve a shared base and prove every POSIX ancestor is protected from replacement. */
114
+ export async function assertSecureSharedStorageBase(requested: string): Promise<string> {
115
+ const currentUid = currentPosixUid()
116
+ const requestedPath = resolve(requested)
117
+ const requestedChain: string[] = []
118
+ let requestedCurrent = requestedPath
119
+ while (true) {
120
+ requestedChain.push(requestedCurrent)
121
+ const parent = dirname(requestedCurrent)
122
+ if (parent === requestedCurrent) break
123
+ requestedCurrent = parent
124
+ }
125
+ for (const component of requestedChain.reverse()) {
126
+ const info = await lstat(component)
127
+ if (info.isSymbolicLink()) {
128
+ if (info.uid !== 0) {
129
+ throw new VisionToolkitError('path', `configured storage directory contains an untrusted symbolic link: ${component}`)
130
+ }
131
+ continue
132
+ }
133
+ if (!info.isDirectory()) {
134
+ throw new VisionToolkitError('path', `configured storage path component is not a directory: ${component}`)
135
+ }
136
+ const writableByOthers = (info.mode & 0o022) !== 0
137
+ const sticky = (info.mode & 0o1000) !== 0
138
+ if (
139
+ (info.uid !== currentUid && info.uid !== 0)
140
+ || (writableByOthers && !sticky)
141
+ ) throw new VisionToolkitError('path', `configured storage directory has an untrusted path component: ${component}`)
142
+ }
143
+ const canonical = await realpath(requestedPath)
144
+ let current = canonical
145
+ while (true) {
146
+ const info = await lstat(current)
147
+ const writableByOthers = (info.mode & 0o022) !== 0
148
+ const sticky = (info.mode & 0o1000) !== 0
149
+ if (
150
+ info.isSymbolicLink()
151
+ || !info.isDirectory()
152
+ || (info.uid !== currentUid && info.uid !== 0)
153
+ || (writableByOthers && !sticky)
154
+ ) throw new VisionToolkitError('path', `configured storage directory has an untrusted path component: ${current}`)
155
+ const parent = dirname(current)
156
+ if (parent === current) return canonical
157
+ current = parent
158
+ }
159
+ }
160
+
161
+ function requestedSharedStorageBase(storageDirRaw: string): string {
162
+ currentPosixUid()
163
+ const configured = normalizePlatformTempPath(expandUserHome(storageDirRaw.trim()))
164
+ if (!isAbsolute(configured)) {
165
+ throw new VisionToolkitError('path', `configured storage directory must be an absolute path: ${storageDirRaw}`)
166
+ }
167
+ return resolve(configured)
168
+ }
169
+
170
+ async function ensureSharedStorageBase(storageDirRaw: string): Promise<{ requestedBase: string; base: string }> {
171
+ const requestedBase = requestedSharedStorageBase(storageDirRaw)
172
+ try {
173
+ await mkdir(requestedBase, { recursive: true, mode: 0o700 })
174
+ } catch (error) {
175
+ throw new VisionToolkitError('path', `configured storage directory is not writable: ${requestedBase}`, { cause: error })
176
+ }
177
+ try {
178
+ return { requestedBase, base: await assertSecureSharedStorageBase(requestedBase) }
179
+ } catch (error) {
180
+ throw new VisionToolkitError('path', `configured storage directory is not accessible: ${requestedBase}`, { cause: error })
181
+ }
182
+ }
183
+
184
+ /** Validate and write-probe a configured shared root before Settings activation. */
185
+ export async function preflightSharedStorageBase(storageDirRaw: string): Promise<string> {
186
+ const { base } = await ensureSharedStorageBase(storageDirRaw)
187
+ let probe: string | undefined
188
+ let failure: unknown
189
+ try {
190
+ probe = await mkdtemp(join(base, '.dsh-vision-toolkit-preflight-'))
191
+ assertSecureWorkspaceStorage(await lstat(probe), probe)
192
+ } catch (error) {
193
+ failure = error
194
+ }
195
+ if (probe !== undefined) {
196
+ try {
197
+ await rm(probe, { recursive: true, force: true })
198
+ } catch (error) {
199
+ failure ??= error
200
+ }
201
+ }
202
+ if (failure !== undefined) {
203
+ throw new VisionToolkitError('path', `configured storage directory failed its write preflight: ${base}`, { cause: failure })
204
+ }
205
+ return base
206
+ }
207
+
208
+ async function managedDirectory(
209
+ parent: string,
210
+ requested: string,
211
+ label: string,
212
+ secureWorkspaceStorage = false,
213
+ ): Promise<string> {
214
+ try {
215
+ await mkdir(requested, { mode: 0o700 })
216
+ } catch (error) {
217
+ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) {
218
+ throw new VisionToolkitError('path', `${label} is not writable: ${requested}`, { cause: error })
219
+ }
220
+ }
221
+ let info
222
+ try {
223
+ info = await lstat(requested)
224
+ } catch (error) {
225
+ throw new VisionToolkitError('path', `${label} is not accessible: ${requested}`, { cause: error })
226
+ }
227
+ if (info.isSymbolicLink() || !info.isDirectory()) {
228
+ throw new VisionToolkitError('path', `${label} must be a real directory: ${requested}`)
229
+ }
230
+ if (secureWorkspaceStorage) assertSecureWorkspaceStorage(info, requested)
231
+ const canonical = await realpath(requested)
232
+ if (!isWithin(parent, canonical)) {
233
+ throw new VisionToolkitError('path', `${label} escaped its configured root: ${requested}`)
234
+ }
235
+ return canonical
236
+ }
237
+
238
+ /**
239
+ * Resolve the plugin-managed root for one workspace. Blank configuration keeps
240
+ * the legacy workspace-local `.dsh-vision-toolkit` directory. A configured
241
+ * shared root receives one stable, automatically generated workspace child.
242
+ */
243
+ export async function resolveWorkspaceStorage(
244
+ workspaceRaw: string,
245
+ storageDirRaw?: string,
246
+ ): Promise<WorkspaceStorage> {
247
+ const expandedWorkspace = expandUserHome(workspaceRaw)
248
+ const visibleWorkspace = resolve(expandedWorkspace)
249
+ let workspace: string
250
+ try {
251
+ workspace = await realpath(visibleWorkspace)
252
+ } catch (error) {
253
+ throw new VisionToolkitError('path', `workspace is not accessible: ${workspaceRaw}`, { cause: error })
254
+ }
255
+
256
+ if (storageDirRaw === undefined || storageDirRaw.trim().length === 0) {
257
+ const visibleRoot = join(visibleWorkspace, '.dsh-vision-toolkit')
258
+ const root = await managedDirectory(workspace, visibleRoot, 'plugin storage directory')
259
+ return { workspace, root, visibleRoot }
260
+ }
261
+
262
+ const { requestedBase, base } = await ensureSharedStorageBase(storageDirRaw)
263
+ const id = workspaceStorageId(workspace)
264
+ const visibleRoot = join(requestedBase, id)
265
+ const root = await managedDirectory(base, join(base, id), 'workspace storage directory', true)
266
+ return { workspace, root, visibleRoot }
267
+ }
268
+
269
+ async function resolveReadableWorkspaceStorageRoot(
270
+ workspace: string,
271
+ storageDirRaw: string,
272
+ ): Promise<string> {
273
+ const requestedBase = requestedSharedStorageBase(storageDirRaw)
274
+ const base = await assertSecureSharedStorageBase(requestedBase)
275
+ const requestedRoot = join(base, workspaceStorageId(workspace))
276
+ const info = await lstat(requestedRoot)
277
+ if (info.isSymbolicLink() || !info.isDirectory()) {
278
+ throw new VisionToolkitError('path', `historical workspace storage must be a real directory: ${requestedRoot}`)
279
+ }
280
+ assertSecureWorkspaceStorage(info, requestedRoot)
281
+ const root = await realpath(requestedRoot)
282
+ if (!isWithin(base, root)) {
283
+ throw new VisionToolkitError('path', `historical workspace storage escaped its configured root: ${requestedRoot}`)
284
+ }
285
+ return root
286
+ }
287
+
68
288
  /**
69
- * Build the per-invocation path policy: realpath the workspace, resolve and
70
- * realpath the platform temp directory and allowed directories, and create
71
- * the output directory inside the fence.
289
+ * Build the per-invocation path policy: resolve workspace storage, realpath the
290
+ * platform temp directory and allowed directories, and create the artifact
291
+ * directory inside the managed root.
72
292
  * @param workspaceRaw - session workspace (or process cwd fallback).
73
293
  * @param allowedDirs - configured extra allowed roots.
74
- * @param outputDirRaw - configured output directory (default `.dsh-vision-toolkit/artifacts`).
294
+ * @param storageDirRaw - optional shared storage root.
295
+ * @param readableStorageDirs - previously validated shared roots retained for persisted input paths.
75
296
  * @returns the resolved policy.
76
297
  */
77
298
  export async function createPathPolicy(
78
299
  workspaceRaw: string,
79
300
  allowedDirs: readonly string[],
80
- outputDirRaw?: string,
301
+ storageDirRaw?: string,
302
+ readableStorageDirs: readonly string[] = [],
81
303
  ): Promise<PathPolicy> {
82
- let workspace: string
83
- try {
84
- workspace = await realpath(expandUserHome(workspaceRaw))
85
- } catch (error) {
86
- throw new VisionToolkitError('path', `workspace is not accessible: ${workspaceRaw}`, { cause: error })
87
- }
304
+ const storage = await resolveWorkspaceStorage(workspaceRaw, storageDirRaw)
305
+ const { workspace } = storage
88
306
  let tempDir: string
89
307
  const tempDirectoryRaw = platformTempDirectory()
90
308
  try {
@@ -92,7 +310,16 @@ export async function createPathPolicy(
92
310
  } catch (error) {
93
311
  throw new VisionToolkitError('path', `platform temporary directory is not accessible: ${tempDirectoryRaw}`, { cause: error })
94
312
  }
95
- const roots = [workspace, tempDir]
313
+ const roots = [workspace, tempDir, storage.root]
314
+ for (const raw of readableStorageDirs) {
315
+ if (raw === storageDirRaw) continue
316
+ try {
317
+ roots.push(await resolveReadableWorkspaceStorageRoot(workspace, raw))
318
+ } catch {
319
+ // Historical roots are read-only compatibility fences. Missing or newly
320
+ // unsafe roots stay unauthorized without breaking the active runtime.
321
+ }
322
+ }
96
323
  for (const raw of allowedDirs) {
97
324
  const candidate = expandUserHome(raw)
98
325
  const target = isAbsolute(candidate) ? candidate : resolve(workspace, candidate)
@@ -102,20 +329,14 @@ export async function createPathPolicy(
102
329
  throw new VisionToolkitError('path', `allowedDirs entry is not accessible: ${raw}`, { cause: error })
103
330
  }
104
331
  }
105
- const outputRaw = outputDirRaw === undefined || outputDirRaw.trim().length === 0
106
- ? join(workspace, '.dsh-vision-toolkit', 'artifacts')
107
- : resolve(workspace, expandUserHome(outputDirRaw))
108
- if (!roots.some(root => isWithin(root, outputRaw))) {
109
- throw new VisionToolkitError('path', 'output directory must stay inside the workspace or an allowedDirs entry')
110
- }
111
- let outputDir: string
112
- try {
113
- await mkdir(outputRaw, { recursive: true })
114
- outputDir = await realpath(outputRaw)
115
- } catch (error) {
116
- throw new VisionToolkitError('path', `output directory is not writable: ${outputRaw}`, { cause: error })
332
+ const outputDir = await managedDirectory(storage.root, join(storage.root, 'artifacts'), 'artifact directory')
333
+ return {
334
+ workspace,
335
+ tempDir,
336
+ allowedDirs: [...new Set(roots)],
337
+ storageRoot: storage.root,
338
+ outputDir,
117
339
  }
118
- return { workspace, tempDir, allowedDirs: [...new Set(roots)], outputDir }
119
340
  }
120
341
 
121
342
  /**
@@ -22,9 +22,9 @@ import {
22
22
  utimes,
23
23
  writeFile,
24
24
  } from 'node:fs/promises'
25
- import { createWriteStream } from 'node:fs'
25
+ import { createWriteStream, type Dirent } from 'node:fs'
26
26
  import { homedir } from 'node:os'
27
- import { dirname, join, resolve } from 'node:path'
27
+ import { basename, dirname, join, resolve } from 'node:path'
28
28
  import { Transform } from 'node:stream'
29
29
  import { pipeline } from 'node:stream/promises'
30
30
  import { fileURLToPath } from 'node:url'
@@ -90,6 +90,7 @@ const LOCK_STALE_MS = 15 * 60 * 1000
90
90
  const LOCK_HEARTBEAT_MS = 5_000
91
91
  const WINDOWS_FILE_RETRY_ATTEMPTS = 5
92
92
  const WINDOWS_FILE_RETRY_DELAY_MS = 250
93
+ const LEGACY_RUNTIME_GC_STALE_MS = 24 * 60 * 60 * 1000
93
94
 
94
95
  /** Absolute root of the packaged upstream snapshot. */
95
96
  export function bundledUpstreamRoot(): string {
@@ -146,6 +147,166 @@ export async function ignoreCleanupFailure(ctx: Context, label: string, path: st
146
147
  }
147
148
  }
148
149
 
150
+ type RuntimeGarbageKind = 'managed runtime staging' | 'managed runtime quarantine' | 'bundled Python staging'
151
+
152
+ interface RuntimeGarbageCandidate {
153
+ kind: RuntimeGarbageKind
154
+ lockName?: string
155
+ lockToken?: string
156
+ minimumAgeMs?: number
157
+ createdAtMs?: number
158
+ observationMarker?: string
159
+ }
160
+
161
+ function runtimeGarbageLockToken(lockBase: string): string {
162
+ return sha256(lockBase).slice(0, 12)
163
+ }
164
+
165
+ function managedRuntimeGarbage(name: string): RuntimeGarbageCandidate | undefined {
166
+ if (name.startsWith('.prepare-')) {
167
+ const current = /^\.prepare-([a-f0-9]{12})-[^/]{6}$/.exec(name)
168
+ return { kind: 'managed runtime staging', ...(current === null ? {} : { lockToken: current[1] }) }
169
+ }
170
+ const replaced = name.indexOf('.replaced-')
171
+ if (replaced > 0) {
172
+ const stamped = /^(\d{13})-/.exec(name.slice(replaced + '.replaced-'.length))
173
+ return {
174
+ kind: 'managed runtime quarantine',
175
+ lockName: `${name.slice(0, replaced)}.lock`,
176
+ minimumAgeMs: LEGACY_RUNTIME_GC_STALE_MS,
177
+ ...(stamped === null
178
+ ? { observationMarker: '.dsh-vision-toolkit-gc-observed' }
179
+ : { createdAtMs: Number(stamped[1]) }),
180
+ }
181
+ }
182
+ return undefined
183
+ }
184
+
185
+ function bundledPythonGarbage(name: string): RuntimeGarbageCandidate | undefined {
186
+ if (!name.startsWith('.python-bootstrap-')) return undefined
187
+ const current = /^\.python-bootstrap-([a-f0-9]{12})-[^/]{6}$/.exec(name)
188
+ return { kind: 'bundled Python staging', ...(current === null ? {} : { lockToken: current[1] }) }
189
+ }
190
+
191
+ async function runtimeLockIsActive(lockPath: string, now: number): Promise<boolean> {
192
+ try {
193
+ const info = await stat(lockPath)
194
+ return now - info.mtimeMs <= LOCK_STALE_MS
195
+ } catch (error) {
196
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
197
+ return true
198
+ }
199
+ }
200
+
201
+ async function garbageCollectDirectory(
202
+ ctx: Context,
203
+ parent: string,
204
+ classify: (name: string) => RuntimeGarbageCandidate | undefined,
205
+ now: number,
206
+ ): Promise<void> {
207
+ let entries: Dirent[]
208
+ try {
209
+ entries = await readdir(parent, { withFileTypes: true })
210
+ } catch (error) {
211
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
212
+ ctx.logger.warn(
213
+ 'dsh-vision-toolkit: runtime garbage collection scan failed for %s: %s',
214
+ parent,
215
+ error instanceof Error ? error.message : String(error),
216
+ )
217
+ return
218
+ }
219
+ let activeLockTokens: Set<string> | undefined
220
+ const getActiveLockTokens = async (): Promise<Set<string>> => {
221
+ if (activeLockTokens !== undefined) return activeLockTokens
222
+ activeLockTokens = new Set<string>()
223
+ for (const lock of entries) {
224
+ if (
225
+ lock.isDirectory()
226
+ && lock.name.endsWith('.lock')
227
+ && await runtimeLockIsActive(join(parent, lock.name), now)
228
+ ) {
229
+ activeLockTokens.add(runtimeGarbageLockToken(lock.name.slice(0, -'.lock'.length)))
230
+ }
231
+ }
232
+ return activeLockTokens
233
+ }
234
+ let legacyCollectionSafe: boolean | undefined
235
+ for (const entry of entries) {
236
+ if (!entry.isDirectory()) continue
237
+ const candidate = classify(entry.name)
238
+ if (candidate === undefined) continue
239
+ const path = join(parent, entry.name)
240
+ if (candidate.lockName !== undefined) {
241
+ if (await runtimeLockIsActive(join(parent, candidate.lockName), now)) continue
242
+ } else if (candidate.lockToken !== undefined) {
243
+ if ((await getActiveLockTokens()).has(candidate.lockToken)) continue
244
+ } else {
245
+ if (legacyCollectionSafe === undefined) {
246
+ legacyCollectionSafe = (await getActiveLockTokens()).size === 0
247
+ }
248
+ if (!legacyCollectionSafe) continue
249
+ try {
250
+ const info = await stat(path)
251
+ if (now - info.mtimeMs <= LEGACY_RUNTIME_GC_STALE_MS) continue
252
+ } catch (error) {
253
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
254
+ ctx.logger.warn(
255
+ 'dsh-vision-toolkit: runtime garbage collection inspection failed for %s: %s',
256
+ path,
257
+ error instanceof Error ? error.message : String(error),
258
+ )
259
+ continue
260
+ }
261
+ }
262
+ if (candidate.minimumAgeMs !== undefined) {
263
+ let createdAt = candidate.createdAtMs
264
+ if (createdAt === undefined && candidate.observationMarker !== undefined) {
265
+ const marker = join(path, candidate.observationMarker)
266
+ try {
267
+ createdAt = (await stat(marker)).mtimeMs
268
+ } catch (error) {
269
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
270
+ await writeFile(marker, `${now}\n`, { flag: 'wx' }).catch(() => {})
271
+ }
272
+ continue
273
+ }
274
+ }
275
+ if (createdAt === undefined) {
276
+ try {
277
+ createdAt = (await stat(path)).mtimeMs
278
+ } catch (error) {
279
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
280
+ ctx.logger.warn(
281
+ 'dsh-vision-toolkit: runtime garbage collection inspection failed for %s: %s',
282
+ path,
283
+ error instanceof Error ? error.message : String(error),
284
+ )
285
+ continue
286
+ }
287
+ }
288
+ if (now - createdAt <= candidate.minimumAgeMs) continue
289
+ }
290
+ await ignoreCleanupFailure(ctx, `stale ${candidate.kind}`, path)
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Opportunistically remove abandoned runtime staging and quarantine trees.
296
+ * Current names encode their owning runtime lock, so live preparation is
297
+ * skipped. Quarantines retain a 24-hour recovery window; legacy names use the
298
+ * same grace period and are collected only when no runtime lock is active
299
+ * because they cannot be associated with a specific lock.
300
+ */
301
+ export async function garbageCollectRuntimeCache(
302
+ ctx: Context,
303
+ stateRoot: string,
304
+ now: number = Date.now(),
305
+ ): Promise<void> {
306
+ await garbageCollectDirectory(ctx, join(stateRoot, 'python'), managedRuntimeGarbage, now)
307
+ await garbageCollectDirectory(ctx, join(stateRoot, 'python-bootstrap'), bundledPythonGarbage, now)
308
+ }
309
+
149
310
  export function isolatedPythonEnvironment(home: string): NodeJS.ProcessEnv {
150
311
  return {
151
312
  HOME: home,
@@ -596,7 +757,7 @@ export async function acquireBundledPython(
596
757
  const parent = dirname(root)
597
758
  await mkdir(parent, { recursive: true })
598
759
  await withWindowsTransientRetry(() => rm(root, { recursive: true, force: true }))
599
- const work = await mkdtemp(join(parent, '.python-bootstrap-'))
760
+ const work = await mkdtemp(join(parent, `.python-bootstrap-${runtimeGarbageLockToken(basename(root))}-`))
600
761
  try {
601
762
  const archive = join(work, 'python.tar.gz')
602
763
  const extractDir = join(work, 'extract')
@@ -675,6 +836,7 @@ export async function resolveBootstrapPython(
675
836
  try {
676
837
  const stateRoot = visionToolkitStateRoot()
677
838
  await mkdir(stateRoot, { recursive: true })
839
+ await garbageCollectRuntimeCache(ctx, stateRoot)
678
840
  const bundled = await acquireBundledPython(ctx, stateRoot, cwd, manifestOverride, requestImpl)
679
841
  return {
680
842
  command: bundled.command,
@@ -897,6 +1059,7 @@ async function prepareManaged(
897
1059
  ): Promise<PreparedUpstreamRuntime> {
898
1060
  const stateRoot = visionToolkitStateRoot()
899
1061
  await mkdir(stateRoot, { recursive: true })
1062
+ await garbageCollectRuntimeCache(ctx, stateRoot)
900
1063
  const cleanHome = join(stateRoot, 'home')
901
1064
  await mkdir(cleanHome, { recursive: true })
902
1065
  const bootstrap = await resolveBootstrapPython(ctx, config.runtime.python, cleanHome)
@@ -966,7 +1129,7 @@ async function prepareManaged(
966
1129
  return prepareManaged(ctx, config, manifest)
967
1130
  }
968
1131
 
969
- const staging = await mkdtemp(join(parent, '.prepare-'))
1132
+ const staging = await mkdtemp(join(parent, `.prepare-${runtimeGarbageLockToken(runtimeId)}-`))
970
1133
  const installEnv: NodeJS.ProcessEnv = {
971
1134
  ...isolatedPythonEnvironment(cleanHome),
972
1135
  UV_CACHE_DIR: join(stateRoot, 'uv-cache'),
@@ -1056,7 +1219,7 @@ async function prepareManaged(
1056
1219
  manager,
1057
1220
  }
1058
1221
  await writeFile(join(staging, 'runtime.json'), `${JSON.stringify(marker, null, 2)}\n`)
1059
- const quarantine = `${finalRoot}.replaced-${randomUUID()}`
1222
+ const quarantine = `${finalRoot}.replaced-${Date.now()}-${randomUUID()}`
1060
1223
  let quarantined = false
1061
1224
  try {
1062
1225
  await withWindowsTransientRetry(() => rename(finalRoot, quarantine))