@anionex/dsh-vision-toolkit 0.1.5

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 (152) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +383 -0
  4. package/README.zh.md +383 -0
  5. package/assets/dsh-conversation-artifact.png +0 -0
  6. package/assets/dsh-conversation-image-qa-top.png +0 -0
  7. package/assets/dsh-conversation-image-qa.png +0 -0
  8. package/assets/dsh-conversation-pixel-diff.png +0 -0
  9. package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
  10. package/assets/dsh-conversation-screenshot-debugging.png +0 -0
  11. package/assets/dsh-conversation-tool-call.png +0 -0
  12. package/assets/dsh-conversation-vision-trace.png +0 -0
  13. package/assets/hero.png +0 -0
  14. package/assets/social-preview.png +0 -0
  15. package/assets/upstream/README.md +16 -0
  16. package/assets/upstream/image-qa.webp +0 -0
  17. package/assets/upstream/infographic-reference.webp +0 -0
  18. package/assets/upstream/infographic-result.webp +0 -0
  19. package/assets/upstream/screenshot-debugging.webp +0 -0
  20. package/assets/upstream/ui-result.webp +0 -0
  21. package/assets/upstream/ui-sketch.webp +0 -0
  22. package/assets/vision-settings.png +0 -0
  23. package/cordis.patch.yml +6 -0
  24. package/docs/assets/vision-settings.png +0 -0
  25. package/docs/requirements-traceability/README.i18n.yaml +6 -0
  26. package/docs/requirements-traceability/README.md +75 -0
  27. package/docs/requirements-traceability/README.zh.md +75 -0
  28. package/examples/ui-restoration/README.i18n.yaml +6 -0
  29. package/examples/ui-restoration/README.md +70 -0
  30. package/examples/ui-restoration/README.zh.md +70 -0
  31. package/examples/ui-restoration/assets/final-heatmap.png +0 -0
  32. package/examples/ui-restoration/assets/final-report.json +83 -0
  33. package/examples/ui-restoration/assets/implementation.png +0 -0
  34. package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
  35. package/examples/ui-restoration/assets/initial-report.json +83 -0
  36. package/examples/ui-restoration/assets/initial.png +0 -0
  37. package/examples/ui-restoration/assets/metrics.json +12 -0
  38. package/examples/ui-restoration/assets/reference.png +0 -0
  39. package/examples/ui-restoration/implementation.html +94 -0
  40. package/examples/ui-restoration/initial.html +57 -0
  41. package/lib/artifact-access.js +369 -0
  42. package/lib/artifact-access.js.map +1 -0
  43. package/lib/artifacts.js +56 -0
  44. package/lib/artifacts.js.map +1 -0
  45. package/lib/client.js +952 -0
  46. package/lib/client.js.map +1 -0
  47. package/lib/config.js +117 -0
  48. package/lib/config.js.map +1 -0
  49. package/lib/errors.js +56 -0
  50. package/lib/errors.js.map +1 -0
  51. package/lib/exposure.js +213 -0
  52. package/lib/exposure.js.map +1 -0
  53. package/lib/index.js +97 -0
  54. package/lib/index.js.map +1 -0
  55. package/lib/paste-images.js +199 -0
  56. package/lib/paste-images.js.map +1 -0
  57. package/lib/paths.js +325 -0
  58. package/lib/paths.js.map +1 -0
  59. package/lib/runtime-install.js +601 -0
  60. package/lib/runtime-install.js.map +1 -0
  61. package/lib/runtime-manager.js +126 -0
  62. package/lib/runtime-manager.js.map +1 -0
  63. package/lib/runtime.js +1344 -0
  64. package/lib/runtime.js.map +1 -0
  65. package/lib/skill.js +139 -0
  66. package/lib/skill.js.map +1 -0
  67. package/lib/tools.js +528 -0
  68. package/lib/tools.js.map +1 -0
  69. package/lib/types/artifact-access.d.ts +61 -0
  70. package/lib/types/artifact-access.d.ts.map +1 -0
  71. package/lib/types/artifacts.d.ts +42 -0
  72. package/lib/types/artifacts.d.ts.map +1 -0
  73. package/lib/types/client/index.d.ts +179 -0
  74. package/lib/types/client/index.d.ts.map +1 -0
  75. package/lib/types/client/paste-images.d.ts +57 -0
  76. package/lib/types/client/paste-images.d.ts.map +1 -0
  77. package/lib/types/config.d.ts +73 -0
  78. package/lib/types/config.d.ts.map +1 -0
  79. package/lib/types/errors.d.ts +35 -0
  80. package/lib/types/errors.d.ts.map +1 -0
  81. package/lib/types/exposure.d.ts +40 -0
  82. package/lib/types/exposure.d.ts.map +1 -0
  83. package/lib/types/index.d.ts +18 -0
  84. package/lib/types/index.d.ts.map +1 -0
  85. package/lib/types/paste-images.d.ts +21 -0
  86. package/lib/types/paste-images.d.ts.map +1 -0
  87. package/lib/types/paths.d.ts +107 -0
  88. package/lib/types/paths.d.ts.map +1 -0
  89. package/lib/types/runtime-install.d.ts +49 -0
  90. package/lib/types/runtime-install.d.ts.map +1 -0
  91. package/lib/types/runtime-manager.d.ts +60 -0
  92. package/lib/types/runtime-manager.d.ts.map +1 -0
  93. package/lib/types/runtime.d.ts +389 -0
  94. package/lib/types/runtime.d.ts.map +1 -0
  95. package/lib/types/skill.d.ts +15 -0
  96. package/lib/types/skill.d.ts.map +1 -0
  97. package/lib/types/tools.d.ts +22 -0
  98. package/lib/types/tools.d.ts.map +1 -0
  99. package/lib/types/upstream.d.ts +207 -0
  100. package/lib/types/upstream.d.ts.map +1 -0
  101. package/lib/types/version.d.ts +15 -0
  102. package/lib/types/version.d.ts.map +1 -0
  103. package/lib/types/web-request.d.ts +4 -0
  104. package/lib/types/web-request.d.ts.map +1 -0
  105. package/lib/types/web.d.ts +74 -0
  106. package/lib/types/web.d.ts.map +1 -0
  107. package/lib/upstream.js +675 -0
  108. package/lib/upstream.js.map +1 -0
  109. package/lib/version.js +18 -0
  110. package/lib/version.js.map +1 -0
  111. package/lib/web-request.js +20 -0
  112. package/lib/web-request.js.map +1 -0
  113. package/lib/web.js +244 -0
  114. package/lib/web.js.map +1 -0
  115. package/package.json +139 -0
  116. package/runtime/requirements.lock +3 -0
  117. package/src/artifact-access.ts +386 -0
  118. package/src/artifacts.ts +85 -0
  119. package/src/client/index.tsx +866 -0
  120. package/src/client/paste-images.tsx +426 -0
  121. package/src/config.ts +177 -0
  122. package/src/errors.ts +62 -0
  123. package/src/exposure.ts +227 -0
  124. package/src/index.ts +122 -0
  125. package/src/paste-images.ts +234 -0
  126. package/src/paths.ts +348 -0
  127. package/src/runtime-install.ts +723 -0
  128. package/src/runtime-manager.ts +166 -0
  129. package/src/runtime.ts +1783 -0
  130. package/src/skill.ts +143 -0
  131. package/src/tools.ts +668 -0
  132. package/src/upstream.ts +861 -0
  133. package/src/version.ts +37 -0
  134. package/src/web-request.ts +17 -0
  135. package/src/web.ts +329 -0
  136. package/vendor/agent-vision-toolkit/CHANGELOG.md +16 -0
  137. package/vendor/agent-vision-toolkit/LICENSE +21 -0
  138. package/vendor/agent-vision-toolkit/README.md +399 -0
  139. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +89 -0
  140. package/vendor/agent-vision-toolkit/bin/crop +90 -0
  141. package/vendor/agent-vision-toolkit/bin/detect +13 -0
  142. package/vendor/agent-vision-toolkit/bin/glance +93 -0
  143. package/vendor/agent-vision-toolkit/bin/ground +13 -0
  144. package/vendor/agent-vision-toolkit/bin/trace +129 -0
  145. package/vendor/agent-vision-toolkit/detect.py +56 -0
  146. package/vendor/agent-vision-toolkit/ground.py +216 -0
  147. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
  148. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
  149. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +108 -0
  150. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  151. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
  152. package/vendor/agent-vision-toolkit/vision_client.py +156 -0
@@ -0,0 +1,723 @@
1
+ /**
2
+ * Reproducible upstream runtime preparation. Managed mode uses the packaged,
3
+ * hash-verified agent-vision-toolkit snapshot plus an atomic isolated Python
4
+ * environment; external mode accepts only the pinned clean Git commit or an
5
+ * exact exported copy of the packaged snapshot.
6
+ * @module dsh-vision-toolkit/runtime-install
7
+ */
8
+
9
+ import { createHash, randomUUID } from 'node:crypto'
10
+ import {
11
+ lstat,
12
+ mkdir,
13
+ mkdtemp,
14
+ readFile,
15
+ readdir,
16
+ realpath,
17
+ rename,
18
+ rm,
19
+ stat,
20
+ utimes,
21
+ writeFile,
22
+ } from 'node:fs/promises'
23
+ import { homedir } from 'node:os'
24
+ import { dirname, join, resolve } from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+ import type { Context } from '@deepseek-ai/cordis'
27
+ import type { ResolvedVisionToolkitConfig } from './config.ts'
28
+ import { VisionToolkitError } from './errors.ts'
29
+ import { UPSTREAM_COMMIT, UPSTREAM_REPOSITORY, UPSTREAM_VERSION } from './version.ts'
30
+
31
+ /** One executable plus fixed prefix arguments (for example Windows `py -3`). */
32
+ export interface RuntimeCommand {
33
+ program: string
34
+ prefix: string[]
35
+ display: string
36
+ }
37
+
38
+ /** Prepared source and interpreter facts consumed by the upstream adapter. */
39
+ export interface PreparedUpstreamRuntime {
40
+ source: 'managed' | 'external'
41
+ root: string
42
+ python: RuntimeCommand
43
+ cleanHome: string
44
+ pythonVersion: string
45
+ dependencies: Record<string, string>
46
+ }
47
+
48
+ interface CommandResult {
49
+ stdout: string
50
+ stderr: string
51
+ exitCode: number | null
52
+ timedOut: boolean
53
+ }
54
+
55
+ interface UpstreamManifest {
56
+ schemaVersion: number
57
+ repository: string
58
+ version: string
59
+ commit: string
60
+ contentSha256: string
61
+ files: Array<{ path: string; bytes: number; sha256: string }>
62
+ }
63
+
64
+ interface RuntimeMarker {
65
+ schemaVersion: 1
66
+ upstreamCommit: string
67
+ upstreamContentSha256: string
68
+ requirementsSha256: string
69
+ pythonVersion: string
70
+ dependencies: Record<string, string>
71
+ manager: 'uv' | 'venv-pip'
72
+ }
73
+
74
+ const PACKAGE_ROOT = dirname(fileURLToPath(new URL('../package.json', import.meta.url)))
75
+ const BUNDLED_ROOT = join(PACKAGE_ROOT, 'vendor', 'agent-vision-toolkit')
76
+ const MANIFEST_PATH = join(BUNDLED_ROOT, 'UPSTREAM_MANIFEST.json')
77
+ const REQUIREMENTS_PATH = join(PACKAGE_ROOT, 'runtime', 'requirements.lock')
78
+ const PREPARE_TIMEOUT_MS = 10 * 60 * 1000
79
+ const PROBE_TIMEOUT_MS = 30_000
80
+ const LOCK_STALE_MS = 15 * 60 * 1000
81
+ const LOCK_HEARTBEAT_MS = 5_000
82
+
83
+ /** Absolute root of the packaged upstream snapshot. */
84
+ export function bundledUpstreamRoot(): string {
85
+ return BUNDLED_ROOT
86
+ }
87
+
88
+ /** Convert one command into a user-facing executable string. */
89
+ export function displayCommand(command: RuntimeCommand): string {
90
+ return [command.program, ...command.prefix].join(' ')
91
+ }
92
+
93
+ function sha256(bytes: string | Buffer): string {
94
+ return createHash('sha256').update(bytes).digest('hex')
95
+ }
96
+
97
+ export function isolatedPythonEnvironment(home: string): NodeJS.ProcessEnv {
98
+ return {
99
+ HOME: home,
100
+ USERPROFILE: home,
101
+ LOCALAPPDATA: home,
102
+ PYTHONHOME: undefined,
103
+ PYTHONPATH: undefined,
104
+ VIRTUAL_ENV: undefined,
105
+ PYTHONDONTWRITEBYTECODE: '1',
106
+ PYTHONIOENCODING: 'utf-8',
107
+ PYTHONNOUSERSITE: '1',
108
+ PYTHONUTF8: '1',
109
+ }
110
+ }
111
+
112
+ async function runCollected(
113
+ ctx: Context,
114
+ argv: readonly string[],
115
+ cwd: string,
116
+ options: { timeoutMs?: number; env?: NodeJS.ProcessEnv } = {},
117
+ ): Promise<CommandResult> {
118
+ const controller = new AbortController()
119
+ let timedOut = false
120
+ const timer = setTimeout(() => {
121
+ timedOut = true
122
+ controller.abort()
123
+ }, options.timeoutMs ?? PROBE_TIMEOUT_MS)
124
+ try {
125
+ const handle = ctx.subprocess.spawn({
126
+ argv,
127
+ cwd,
128
+ stdio: {
129
+ stdin: 'ignore',
130
+ stdout: { maxBytes: 256 * 1024 },
131
+ stderr: { maxBytes: 256 * 1024 },
132
+ },
133
+ graceMs: 2000,
134
+ signal: controller.signal,
135
+ ...(options.env === undefined ? {} : { env: options.env }),
136
+ })
137
+ const outcome = await handle.done
138
+ return {
139
+ stdout: handle.collected.stdout?.readFrom(0).text ?? '',
140
+ stderr: handle.collected.stderr?.readFrom(0).text ?? '',
141
+ exitCode: outcome.exitCode,
142
+ timedOut,
143
+ }
144
+ } catch (error) {
145
+ if (timedOut) return { stdout: '', stderr: '', exitCode: null, timedOut: true }
146
+ throw error
147
+ } finally {
148
+ clearTimeout(timer)
149
+ }
150
+ }
151
+
152
+ async function readManifest(path = MANIFEST_PATH): Promise<UpstreamManifest> {
153
+ let parsed: unknown
154
+ try {
155
+ parsed = JSON.parse(await readFile(path, 'utf8'))
156
+ } catch (error) {
157
+ throw new VisionToolkitError('runtime', `upstream manifest is unreadable: ${path}`, { cause: error })
158
+ }
159
+ if (typeof parsed !== 'object' || parsed === null) {
160
+ throw new VisionToolkitError('runtime', `upstream manifest is not an object: ${path}`)
161
+ }
162
+ const manifest = parsed as Partial<UpstreamManifest>
163
+ if (
164
+ manifest.schemaVersion !== 1
165
+ || manifest.repository !== UPSTREAM_REPOSITORY
166
+ || manifest.version !== UPSTREAM_VERSION
167
+ || manifest.commit !== UPSTREAM_COMMIT
168
+ || !/^[a-f0-9]{64}$/.test(manifest.contentSha256 ?? '')
169
+ || typeof manifest.contentSha256 !== 'string'
170
+ || !Array.isArray(manifest.files)
171
+ || manifest.files.length === 0
172
+ ) {
173
+ throw new VisionToolkitError('runtime', `upstream manifest identity does not match the packaged pin: ${path}`)
174
+ }
175
+ const seen = new Set<string>()
176
+ let previous = ''
177
+ for (const entry of manifest.files) {
178
+ if (
179
+ typeof entry !== 'object'
180
+ || entry === null
181
+ || typeof entry.path !== 'string'
182
+ || entry.path.length === 0
183
+ || entry.path.includes('\\')
184
+ || entry.path.startsWith('/')
185
+ || entry.path.split('/').some(segment => segment.length === 0 || segment === '.' || segment === '..')
186
+ || !Number.isInteger(entry.bytes)
187
+ || entry.bytes < 0
188
+ || !/^[a-f0-9]{64}$/.test(entry.sha256)
189
+ || seen.has(entry.path)
190
+ || (previous.length > 0 && previous >= entry.path)
191
+ ) {
192
+ throw new VisionToolkitError('runtime', `upstream manifest contains an invalid file entry: ${path}`)
193
+ }
194
+ seen.add(entry.path)
195
+ previous = entry.path
196
+ }
197
+ return manifest as UpstreamManifest
198
+ }
199
+
200
+ /** Verify every packaged upstream file against the committed content manifest. */
201
+ export async function verifyBundledUpstream(): Promise<UpstreamManifest> {
202
+ const manifest = await readManifest()
203
+ const rows: string[] = []
204
+ for (const entry of manifest.files) {
205
+ const path = join(BUNDLED_ROOT, ...entry.path.split('/'))
206
+ let bytes: Buffer
207
+ try {
208
+ const info = await lstat(path)
209
+ if (!info.isFile() || info.isSymbolicLink()) {
210
+ throw new VisionToolkitError('runtime', `packaged upstream entry is not a regular file: ${entry.path}`)
211
+ }
212
+ bytes = await readFile(path)
213
+ } catch (error) {
214
+ if (error instanceof VisionToolkitError) throw error
215
+ throw new VisionToolkitError('runtime', `packaged upstream file is missing: ${entry.path}`, { cause: error })
216
+ }
217
+ const digest = sha256(bytes)
218
+ if (bytes.length !== entry.bytes || digest !== entry.sha256) {
219
+ throw new VisionToolkitError('runtime', `packaged upstream file failed its hash check: ${entry.path}`)
220
+ }
221
+ rows.push(`${entry.path}\0${digest}\n`)
222
+ }
223
+ if (sha256(rows.join('')) !== manifest.contentSha256) {
224
+ throw new VisionToolkitError('runtime', 'packaged upstream aggregate hash does not match its manifest')
225
+ }
226
+ return manifest
227
+ }
228
+
229
+ async function pythonMetadata(
230
+ ctx: Context,
231
+ command: RuntimeCommand,
232
+ cwd: string,
233
+ ): Promise<{ version: string; major: number; minor: number } | undefined> {
234
+ const script = 'import json,sys; print(json.dumps({"version":sys.version.split()[0],"major":sys.version_info[0],"minor":sys.version_info[1]}))'
235
+ let result: CommandResult
236
+ try {
237
+ result = await runCollected(ctx, [command.program, ...command.prefix, '-c', script], cwd, {
238
+ env: isolatedPythonEnvironment(cwd),
239
+ })
240
+ } catch {
241
+ return undefined
242
+ }
243
+ if (result.exitCode !== 0 || result.timedOut) return undefined
244
+ try {
245
+ const parsed = JSON.parse(result.stdout) as { version?: unknown; major?: unknown; minor?: unknown }
246
+ if (typeof parsed.version !== 'string' || typeof parsed.major !== 'number' || typeof parsed.minor !== 'number') {
247
+ return undefined
248
+ }
249
+ return { version: parsed.version, major: parsed.major, minor: parsed.minor }
250
+ } catch {
251
+ return undefined
252
+ }
253
+ }
254
+
255
+ async function resolveBootstrapPython(
256
+ ctx: Context,
257
+ configured: string | undefined,
258
+ cwd: string,
259
+ ): Promise<{ command: RuntimeCommand; version: string; major: number; minor: number }> {
260
+ const candidates: RuntimeCommand[] = configured === undefined
261
+ ? process.platform === 'win32'
262
+ ? [
263
+ { program: 'python', prefix: [], display: 'python' },
264
+ { program: 'py', prefix: ['-3'], display: 'py -3' },
265
+ { program: 'python3', prefix: [], display: 'python3' },
266
+ ]
267
+ : [
268
+ { program: 'python3', prefix: [], display: 'python3' },
269
+ { program: 'python', prefix: [], display: 'python' },
270
+ ]
271
+ : [{ program: configured, prefix: [], display: configured }]
272
+ for (const command of candidates) {
273
+ const metadata = await pythonMetadata(ctx, command, cwd)
274
+ if (metadata !== undefined && (metadata.major > 3 || metadata.major === 3 && metadata.minor >= 11)) {
275
+ return { command, ...metadata }
276
+ }
277
+ }
278
+ throw new VisionToolkitError(
279
+ 'runtime',
280
+ configured === undefined
281
+ ? 'vision-toolkit requires Python 3.11 or newer; tried python3, python, and the Windows py launcher'
282
+ : `vision-toolkit requires Python 3.11 or newer: ${configured}`,
283
+ )
284
+ }
285
+
286
+ /** Persistent per-DSH-home cache root shared by runtime and Web support files. */
287
+ export function visionToolkitStateRoot(): string {
288
+ const dshHome = process.env.DSH_HOME?.trim()
289
+ const base = dshHome === undefined || dshHome.length === 0 ? join(homedir(), '.dsh') : resolve(dshHome)
290
+ return join(base, 'cache', 'dsh-vision-toolkit')
291
+ }
292
+
293
+ function expandHome(path: string): string {
294
+ if (path === '~') return homedir()
295
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
296
+ return path
297
+ }
298
+
299
+ function venvPython(root: string): string {
300
+ return process.platform === 'win32' ? join(root, 'Scripts', 'python.exe') : join(root, 'bin', 'python')
301
+ }
302
+
303
+ async function dependencyVersions(
304
+ ctx: Context,
305
+ python: RuntimeCommand,
306
+ cwd: string,
307
+ ): Promise<Record<string, string>> {
308
+ const script = [
309
+ 'import json',
310
+ 'from importlib.metadata import version',
311
+ 'import PIL',
312
+ 'import numpy',
313
+ 'import vtracer',
314
+ 'print(json.dumps({"pillow":version("pillow"),"numpy":version("numpy"),"vtracer":version("vtracer")}))',
315
+ ].join(';')
316
+ let result: CommandResult
317
+ try {
318
+ result = await runCollected(ctx, [python.program, ...python.prefix, '-c', script], cwd, {
319
+ env: isolatedPythonEnvironment(cwd),
320
+ })
321
+ } catch (error) {
322
+ throw new VisionToolkitError('runtime', `failed to start ${displayCommand(python)}`, { cause: error })
323
+ }
324
+ if (result.exitCode !== 0 || result.timedOut) {
325
+ throw new VisionToolkitError(
326
+ 'runtime',
327
+ `vision-toolkit Python dependencies are unavailable in ${displayCommand(python)}: ${result.stderr.trim() || 'probe failed'}`,
328
+ )
329
+ }
330
+ try {
331
+ const parsed = JSON.parse(result.stdout) as Record<string, unknown>
332
+ if (Object.values(parsed).some(value => typeof value !== 'string')) throw new Error('non-string dependency version')
333
+ return parsed as Record<string, string>
334
+ } catch (error) {
335
+ throw new VisionToolkitError('runtime', 'vision-toolkit dependency probe returned invalid JSON', { cause: error })
336
+ }
337
+ }
338
+
339
+ function parseLockedDependencies(requirements: Buffer): Record<string, string> {
340
+ const dependencies: Record<string, string> = {}
341
+ for (const line of requirements.toString('utf8').split(/\r?\n/)) {
342
+ const trimmed = line.trim()
343
+ if (trimmed.length === 0 || trimmed.startsWith('#')) continue
344
+ const match = /^([A-Za-z0-9_.-]+)==([^\s]+)$/.exec(trimmed)
345
+ if (match === null) {
346
+ throw new VisionToolkitError('runtime', `runtime/requirements.lock contains an unsupported entry: ${trimmed}`)
347
+ }
348
+ dependencies[(match[1] ?? '').toLowerCase()] = match[2] ?? ''
349
+ }
350
+ if (Object.keys(dependencies).length === 0) {
351
+ throw new VisionToolkitError('runtime', 'runtime/requirements.lock contains no dependencies')
352
+ }
353
+ return dependencies
354
+ }
355
+
356
+ function assertLockedDependencies(actual: Record<string, string>, expected: Record<string, string>): void {
357
+ for (const [name, version] of Object.entries(expected)) {
358
+ if (actual[name] !== version) {
359
+ throw new VisionToolkitError(
360
+ 'runtime',
361
+ `vision-toolkit Python dependency ${name} must be ${version}, received ${actual[name] ?? 'missing'}`,
362
+ )
363
+ }
364
+ }
365
+ }
366
+
367
+ async function readRuntimeMarker(path: string): Promise<RuntimeMarker | undefined> {
368
+ try {
369
+ const parsed = JSON.parse(await readFile(path, 'utf8')) as Partial<RuntimeMarker>
370
+ if (
371
+ parsed.schemaVersion !== 1
372
+ || parsed.upstreamCommit !== UPSTREAM_COMMIT
373
+ || typeof parsed.upstreamContentSha256 !== 'string'
374
+ || typeof parsed.requirementsSha256 !== 'string'
375
+ || typeof parsed.pythonVersion !== 'string'
376
+ || typeof parsed.dependencies !== 'object'
377
+ || parsed.dependencies === null
378
+ || (parsed.manager !== 'uv' && parsed.manager !== 'venv-pip')
379
+ ) return undefined
380
+ return parsed as RuntimeMarker
381
+ } catch {
382
+ return undefined
383
+ }
384
+ }
385
+
386
+ async function waitForManagedRuntime(
387
+ markerPath: string,
388
+ lockPath: string,
389
+ expected: Pick<RuntimeMarker, 'upstreamContentSha256' | 'requirementsSha256'>,
390
+ ): Promise<RuntimeMarker | undefined> {
391
+ const started = Date.now()
392
+ while (Date.now() - started < PREPARE_TIMEOUT_MS) {
393
+ const marker = await readRuntimeMarker(markerPath)
394
+ if (
395
+ marker?.upstreamContentSha256 === expected.upstreamContentSha256
396
+ && marker.requirementsSha256 === expected.requirementsSha256
397
+ ) return marker
398
+ try {
399
+ const info = await stat(lockPath)
400
+ if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
401
+ await rm(lockPath, { recursive: true, force: true })
402
+ return undefined
403
+ }
404
+ } catch {
405
+ return undefined
406
+ }
407
+ await new Promise(resolveWait => setTimeout(resolveWait, 250))
408
+ }
409
+ throw new VisionToolkitError('runtime', 'timed out waiting for another process to prepare the managed vision runtime')
410
+ }
411
+
412
+ async function releaseManagedLock(lockPath: string, owner: string): Promise<void> {
413
+ try {
414
+ if ((await readFile(join(lockPath, 'owner'), 'utf8')).trim() !== owner) return
415
+ } catch {
416
+ return
417
+ }
418
+ await rm(lockPath, { recursive: true, force: true })
419
+ }
420
+
421
+ async function prepareManaged(
422
+ ctx: Context,
423
+ config: ResolvedVisionToolkitConfig,
424
+ manifest: UpstreamManifest,
425
+ ): Promise<PreparedUpstreamRuntime> {
426
+ const stateRoot = visionToolkitStateRoot()
427
+ await mkdir(stateRoot, { recursive: true })
428
+ const cleanHome = join(stateRoot, 'home')
429
+ await mkdir(cleanHome, { recursive: true })
430
+ const bootstrap = await resolveBootstrapPython(ctx, config.runtime.python, cleanHome)
431
+ const requirements = await readFile(REQUIREMENTS_PATH)
432
+ const requirementsSha256 = sha256(requirements)
433
+ const expectedDependencies = parseLockedDependencies(requirements)
434
+ const runtimeId = [
435
+ manifest.contentSha256.slice(0, 16),
436
+ requirementsSha256.slice(0, 16),
437
+ `py${String(bootstrap.major)}${String(bootstrap.minor)}`,
438
+ process.platform,
439
+ process.arch,
440
+ ].join('-')
441
+ const finalRoot = join(stateRoot, 'python', runtimeId)
442
+ const parent = dirname(finalRoot)
443
+ await mkdir(parent, { recursive: true })
444
+ const markerPath = join(finalRoot, 'runtime.json')
445
+ const existing = await readRuntimeMarker(markerPath)
446
+ const interpreter = venvPython(finalRoot)
447
+ if (
448
+ existing?.upstreamContentSha256 === manifest.contentSha256
449
+ && existing.requirementsSha256 === requirementsSha256
450
+ ) {
451
+ const python: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
452
+ const metadata = await pythonMetadata(ctx, python, cleanHome)
453
+ if (metadata !== undefined) {
454
+ try {
455
+ const dependencies = await dependencyVersions(ctx, python, cleanHome)
456
+ assertLockedDependencies(dependencies, expectedDependencies)
457
+ return { source: 'managed', root: BUNDLED_ROOT, python, cleanHome, pythonVersion: metadata.version, dependencies }
458
+ } catch {
459
+ // A stale/corrupt environment is rebuilt below without disturbing it until the replacement is ready.
460
+ }
461
+ }
462
+ }
463
+
464
+ const lockPath = `${finalRoot}.lock`
465
+ const lockOwner = randomUUID()
466
+ let lockAcquired = false
467
+ try {
468
+ await mkdir(lockPath, { recursive: false })
469
+ lockAcquired = true
470
+ await writeFile(join(lockPath, 'owner'), `${lockOwner}\n`, { flag: 'wx' })
471
+ } catch (error) {
472
+ if (lockAcquired) {
473
+ await rm(lockPath, { recursive: true, force: true })
474
+ throw error
475
+ }
476
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
477
+ const completed = await waitForManagedRuntime(markerPath, lockPath, {
478
+ upstreamContentSha256: manifest.contentSha256,
479
+ requirementsSha256,
480
+ })
481
+ if (completed !== undefined) {
482
+ const python: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
483
+ const metadata = await pythonMetadata(ctx, python, cleanHome)
484
+ if (metadata !== undefined) {
485
+ try {
486
+ const dependencies = await dependencyVersions(ctx, python, cleanHome)
487
+ assertLockedDependencies(dependencies, expectedDependencies)
488
+ return { source: 'managed', root: BUNDLED_ROOT, python, cleanHome, pythonVersion: metadata.version, dependencies }
489
+ } catch {
490
+ // The completed marker is unusable; reacquire the lock and rebuild it.
491
+ }
492
+ }
493
+ }
494
+ return prepareManaged(ctx, config, manifest)
495
+ }
496
+
497
+ const staging = await mkdtemp(join(parent, '.prepare-'))
498
+ const installEnv: NodeJS.ProcessEnv = {
499
+ ...isolatedPythonEnvironment(cleanHome),
500
+ UV_CACHE_DIR: join(stateRoot, 'uv-cache'),
501
+ }
502
+ const heartbeat = setInterval(() => {
503
+ const now = new Date()
504
+ void utimes(lockPath, now, now).catch(() => {})
505
+ }, LOCK_HEARTBEAT_MS)
506
+ heartbeat.unref()
507
+ try {
508
+ let manager: RuntimeMarker['manager'] = 'venv-pip'
509
+ let created = false
510
+ if (bootstrap.command.prefix.length === 0) {
511
+ try {
512
+ const uv = await runCollected(ctx, ['uv', '--version'], stateRoot, { env: installEnv })
513
+ if (uv.exitCode === 0 && !uv.timedOut) {
514
+ const executableEnv = Object.fromEntries(
515
+ Object.entries(installEnv).filter((entry): entry is [string, string] => entry[1] !== undefined),
516
+ )
517
+ const interpreter = await ctx.subprocess.resolveExecutable(bootstrap.command.program, executableEnv)
518
+ const create = await runCollected(
519
+ ctx,
520
+ ['uv', 'venv', '--python', interpreter, staging],
521
+ stateRoot,
522
+ { timeoutMs: PREPARE_TIMEOUT_MS, env: installEnv },
523
+ )
524
+ if (create.exitCode !== 0 || create.timedOut) {
525
+ throw new VisionToolkitError('runtime', `uv failed to create the managed runtime: ${create.stderr.trim()}`)
526
+ }
527
+ const install = await runCollected(
528
+ ctx,
529
+ ['uv', 'pip', 'install', '--python', venvPython(staging), '--requirement', REQUIREMENTS_PATH],
530
+ stateRoot,
531
+ { timeoutMs: PREPARE_TIMEOUT_MS, env: installEnv },
532
+ )
533
+ if (install.exitCode !== 0 || install.timedOut) {
534
+ throw new VisionToolkitError('runtime', `uv failed to install managed runtime dependencies: ${install.stderr.trim()}`)
535
+ }
536
+ manager = 'uv'
537
+ created = true
538
+ }
539
+ } catch (error) {
540
+ if (error instanceof VisionToolkitError) throw error
541
+ }
542
+ }
543
+ if (!created) {
544
+ const create = await runCollected(
545
+ ctx,
546
+ [bootstrap.command.program, ...bootstrap.command.prefix, '-m', 'venv', staging],
547
+ stateRoot,
548
+ { timeoutMs: PREPARE_TIMEOUT_MS, env: installEnv },
549
+ )
550
+ if (create.exitCode !== 0 || create.timedOut) {
551
+ throw new VisionToolkitError('runtime', `Python failed to create the managed runtime: ${create.stderr.trim()}`)
552
+ }
553
+ const install = await runCollected(
554
+ ctx,
555
+ [venvPython(staging), '-m', 'pip', 'install', '--disable-pip-version-check', '--no-input', '-r', REQUIREMENTS_PATH],
556
+ stateRoot,
557
+ { timeoutMs: PREPARE_TIMEOUT_MS, env: installEnv },
558
+ )
559
+ if (install.exitCode !== 0 || install.timedOut) {
560
+ throw new VisionToolkitError('runtime', `pip failed to install managed runtime dependencies: ${install.stderr.trim()}`)
561
+ }
562
+ }
563
+ const stagedPython: RuntimeCommand = { program: venvPython(staging), prefix: [], display: venvPython(staging) }
564
+ const metadata = await pythonMetadata(ctx, stagedPython, cleanHome)
565
+ if (metadata === undefined) throw new VisionToolkitError('runtime', 'managed Python runtime did not start after installation')
566
+ const dependencies = await dependencyVersions(ctx, stagedPython, cleanHome)
567
+ assertLockedDependencies(dependencies, expectedDependencies)
568
+ const marker: RuntimeMarker = {
569
+ schemaVersion: 1,
570
+ upstreamCommit: UPSTREAM_COMMIT,
571
+ upstreamContentSha256: manifest.contentSha256,
572
+ requirementsSha256,
573
+ pythonVersion: metadata.version,
574
+ dependencies,
575
+ manager,
576
+ }
577
+ await writeFile(join(staging, 'runtime.json'), `${JSON.stringify(marker, null, 2)}\n`)
578
+ const quarantine = `${finalRoot}.replaced-${randomUUID()}`
579
+ let quarantined = false
580
+ try {
581
+ await rename(finalRoot, quarantine)
582
+ quarantined = true
583
+ } catch (error) {
584
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
585
+ }
586
+ try {
587
+ await rename(staging, finalRoot)
588
+ } catch (error) {
589
+ if (quarantined) {
590
+ try {
591
+ await rename(quarantine, finalRoot)
592
+ } catch (restoreError) {
593
+ throw new VisionToolkitError(
594
+ 'runtime',
595
+ `managed runtime replacement failed and the prior runtime could not be restored; recovery copy: ${quarantine}`,
596
+ { cause: new AggregateError([error, restoreError]) },
597
+ )
598
+ }
599
+ }
600
+ throw error
601
+ }
602
+ await rm(quarantine, { recursive: true, force: true })
603
+ const python: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
604
+ return { source: 'managed', root: BUNDLED_ROOT, python, cleanHome, pythonVersion: metadata.version, dependencies }
605
+ } finally {
606
+ clearInterval(heartbeat)
607
+ await rm(staging, { recursive: true, force: true })
608
+ await releaseManagedLock(lockPath, lockOwner)
609
+ }
610
+ }
611
+
612
+ async function snapshotFiles(root: string): Promise<string[]> {
613
+ const result: string[] = []
614
+ async function visit(directory: string, prefix: string): Promise<void> {
615
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
616
+ const relativePath = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`
617
+ if (entry.isDirectory()) await visit(join(directory, entry.name), relativePath)
618
+ else if (entry.isFile()) result.push(relativePath)
619
+ else throw new VisionToolkitError('runtime', `external snapshot contains a non-regular entry: ${relativePath}`)
620
+ }
621
+ }
622
+ await visit(root, '')
623
+ return result.sort()
624
+ }
625
+
626
+ async function externalMatchesBundledSnapshot(root: string, expected: UpstreamManifest): Promise<boolean> {
627
+ const path = join(root, 'UPSTREAM_MANIFEST.json')
628
+ try {
629
+ const manifest = await readManifest(path)
630
+ if (
631
+ manifest.contentSha256 !== expected.contentSha256
632
+ || JSON.stringify(manifest.files) !== JSON.stringify(expected.files)
633
+ ) return false
634
+ const expectedFiles = [...expected.files.map(entry => entry.path), 'UPSTREAM_MANIFEST.json'].sort()
635
+ if (JSON.stringify(await snapshotFiles(root)) !== JSON.stringify(expectedFiles)) return false
636
+ for (const entry of expected.files) {
637
+ const target = join(root, ...entry.path.split('/'))
638
+ const info = await lstat(target)
639
+ if (!info.isFile() || info.isSymbolicLink()) return false
640
+ const bytes = await readFile(target)
641
+ if (bytes.length !== entry.bytes || sha256(bytes) !== entry.sha256) return false
642
+ }
643
+ return true
644
+ } catch {
645
+ return false
646
+ }
647
+ }
648
+
649
+ async function verifyExternalCheckout(ctx: Context, root: string, expected: UpstreamManifest): Promise<void> {
650
+ const exactSnapshot = await externalMatchesBundledSnapshot(root, expected)
651
+ if (exactSnapshot) return
652
+ let head: CommandResult
653
+ try {
654
+ head = await runCollected(ctx, ['git', '-C', root, 'rev-parse', 'HEAD'], root)
655
+ } catch (error) {
656
+ throw new VisionToolkitError(
657
+ 'runtime',
658
+ `external agent-vision-toolkit must be the clean pinned commit ${UPSTREAM_COMMIT} or an exact exported snapshot`,
659
+ { cause: error },
660
+ )
661
+ }
662
+ if (head.exitCode !== 0 || head.stdout.trim() !== UPSTREAM_COMMIT) {
663
+ throw new VisionToolkitError('runtime', `external agent-vision-toolkit must be pinned at commit ${UPSTREAM_COMMIT}`)
664
+ }
665
+ const topLevel = await runCollected(ctx, ['git', '-C', root, 'rev-parse', '--show-toplevel'], root)
666
+ let resolvedTopLevel: string | undefined
667
+ try {
668
+ resolvedTopLevel = topLevel.exitCode === 0 ? await realpath(topLevel.stdout.trim()) : undefined
669
+ } catch {
670
+ resolvedTopLevel = undefined
671
+ }
672
+ if (resolvedTopLevel !== root) {
673
+ throw new VisionToolkitError('runtime', 'external agent-vision-toolkit path must be the checkout root')
674
+ }
675
+ const statusResult = await runCollected(ctx, ['git', '-C', root, 'status', '--porcelain=v1', '--untracked-files=all'], root)
676
+ if (statusResult.exitCode !== 0 || statusResult.stdout.trim().length > 0) {
677
+ throw new VisionToolkitError('runtime', 'external agent-vision-toolkit checkout has modified tracked files; use managed mode or a clean pinned checkout')
678
+ }
679
+ }
680
+
681
+ async function prepareExternal(
682
+ ctx: Context,
683
+ config: ResolvedVisionToolkitConfig,
684
+ manifest: UpstreamManifest,
685
+ ): Promise<PreparedUpstreamRuntime> {
686
+ const configured = config.runtime.agentVisionToolkitPath
687
+ if (configured === undefined) {
688
+ throw new VisionToolkitError('config', 'runtime.agentVisionToolkitPath is required when runtime.mode is external')
689
+ }
690
+ let root: string
691
+ try {
692
+ root = await realpath(expandHome(configured))
693
+ } catch (error) {
694
+ throw new VisionToolkitError('runtime', `external agent-vision-toolkit checkout is not accessible: ${configured}`, { cause: error })
695
+ }
696
+ await verifyExternalCheckout(ctx, root, manifest)
697
+ const stateRoot = visionToolkitStateRoot()
698
+ await mkdir(stateRoot, { recursive: true })
699
+ const cleanHome = join(stateRoot, 'home')
700
+ await mkdir(cleanHome, { recursive: true })
701
+ const bootstrap = await resolveBootstrapPython(ctx, config.runtime.python, cleanHome)
702
+ const dependencies = await dependencyVersions(ctx, bootstrap.command, cleanHome)
703
+ assertLockedDependencies(dependencies, parseLockedDependencies(await readFile(REQUIREMENTS_PATH)))
704
+ return {
705
+ source: 'external',
706
+ root,
707
+ python: bootstrap.command,
708
+ cleanHome,
709
+ pythonVersion: bootstrap.version,
710
+ dependencies,
711
+ }
712
+ }
713
+
714
+ /** Prepare the configured pinned runtime without making any vision API call. */
715
+ export async function prepareUpstreamRuntime(
716
+ ctx: Context,
717
+ config: ResolvedVisionToolkitConfig,
718
+ ): Promise<PreparedUpstreamRuntime> {
719
+ const manifest = await verifyBundledUpstream()
720
+ return config.runtime.mode === 'managed'
721
+ ? prepareManaged(ctx, config, manifest)
722
+ : prepareExternal(ctx, config, manifest)
723
+ }