@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.
- package/README.i18n.yaml +2 -2
- package/README.md +10 -2
- package/README.zh.md +10 -2
- package/assets/logo_eapi_dark.png +0 -0
- package/docs/aihubmix-gemini-vision.i18n.yaml +2 -2
- package/docs/aihubmix-gemini-vision.md +2 -2
- package/docs/aihubmix-gemini-vision.zh.md +2 -2
- package/docs/requirements-traceability/README.i18n.yaml +2 -2
- package/docs/requirements-traceability/README.md +1 -1
- package/docs/requirements-traceability/README.zh.md +1 -1
- package/lib/artifact-access.js +20 -2
- package/lib/artifact-access.js.map +1 -1
- package/lib/client.js +11 -3
- package/lib/client.js.map +1 -1
- package/lib/config.js +46 -2
- package/lib/config.js.map +1 -1
- package/lib/evidence-cache.js +2 -1
- package/lib/evidence-cache.js.map +1 -1
- package/lib/image-input-variants.js +61 -26
- package/lib/image-input-variants.js.map +1 -1
- package/lib/index.js +53 -6
- package/lib/index.js.map +1 -1
- package/lib/paste-images.js +67 -19
- package/lib/paste-images.js.map +1 -1
- package/lib/paths.js +214 -28
- package/lib/paths.js.map +1 -1
- package/lib/runtime-install.js +147 -4
- package/lib/runtime-install.js.map +1 -1
- package/lib/runtime-manager.js +76 -10
- package/lib/runtime-manager.js.map +1 -1
- package/lib/runtime.js +16 -10
- package/lib/runtime.js.map +1 -1
- package/lib/storage-history.js +154 -0
- package/lib/storage-history.js.map +1 -0
- package/lib/types/artifact-access.d.ts.map +1 -1
- package/lib/types/client/index.d.ts +6 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/config.d.ts +24 -1
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/evidence-cache.d.ts +1 -1
- package/lib/types/evidence-cache.d.ts.map +1 -1
- package/lib/types/image-input-variants.d.ts +7 -4
- package/lib/types/image-input-variants.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/paste-images.d.ts +12 -4
- package/lib/types/paste-images.d.ts.map +1 -1
- package/lib/types/paths.d.ts +31 -5
- package/lib/types/paths.d.ts.map +1 -1
- package/lib/types/runtime-install.d.ts +8 -0
- package/lib/types/runtime-install.d.ts.map +1 -1
- package/lib/types/runtime-manager.d.ts +28 -4
- package/lib/types/runtime-manager.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +4 -1
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/storage-history.d.ts +63 -0
- package/lib/types/storage-history.d.ts.map +1 -0
- package/lib/types/web.d.ts.map +1 -1
- package/lib/web.js +9 -3
- package/lib/web.js.map +1 -1
- package/package.json +1 -1
- package/src/artifact-access.ts +22 -2
- package/src/client/index.tsx +18 -3
- package/src/config.ts +76 -2
- package/src/evidence-cache.ts +2 -1
- package/src/image-input-variants.ts +61 -20
- package/src/index.ts +65 -6
- package/src/paste-images.ts +81 -19
- package/src/paths.ts +249 -28
- package/src/runtime-install.ts +168 -5
- package/src/runtime-manager.ts +93 -10
- package/src/runtime.ts +15 -9
- package/src/storage-history.ts +172 -0
- 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 {
|
|
11
|
-
import {
|
|
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:
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
|
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
|
-
|
|
301
|
+
storageDirRaw?: string,
|
|
302
|
+
readableStorageDirs: readonly string[] = [],
|
|
81
303
|
): Promise<PathPolicy> {
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
/**
|
package/src/runtime-install.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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))
|