@mengruo/dsh-vision-toolkit 0.0.1

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 (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +356 -0
  4. package/README.zh.md +354 -0
  5. package/assets/community-group-qr.png +0 -0
  6. package/assets/dsh-conversation-artifact.png +0 -0
  7. package/assets/dsh-conversation-image-qa-top.png +0 -0
  8. package/assets/dsh-conversation-image-qa.png +0 -0
  9. package/assets/dsh-conversation-pixel-diff.png +0 -0
  10. package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
  11. package/assets/dsh-conversation-screenshot-debugging.png +0 -0
  12. package/assets/dsh-conversation-tool-call.png +0 -0
  13. package/assets/dsh-conversation-vision-trace.png +0 -0
  14. package/assets/dsh-view-example.png +0 -0
  15. package/assets/hero-v2.png +0 -0
  16. package/assets/logo_aihubmix.png +0 -0
  17. package/assets/python-bootstrap.json +48 -0
  18. package/assets/skill/SKILL.md +329 -0
  19. package/assets/skill/UPSTREAM.json +71 -0
  20. package/assets/skill/references/gui.md +88 -0
  21. package/assets/skill/references/long-screenshot-ocr.md +77 -0
  22. package/assets/skill/references/restore-graphic.md +84 -0
  23. package/assets/skill/references/restore-structure.md +45 -0
  24. package/assets/skill/references/restore-ui.md +202 -0
  25. package/assets/social-preview.png +0 -0
  26. package/assets/upstream/README.md +18 -0
  27. package/assets/upstream/focus-hint-comparison-1.webp +0 -0
  28. package/assets/upstream/focus-hint-comparison-2.webp +0 -0
  29. package/assets/upstream/infographic-reference.webp +0 -0
  30. package/assets/upstream/infographic-result.webp +0 -0
  31. package/assets/upstream/ui-fast-restore-reference.webp +0 -0
  32. package/assets/upstream/ui-fast-restore-result.webp +0 -0
  33. package/assets/upstream/ui-result.webp +0 -0
  34. package/assets/upstream/ui-sketch.webp +0 -0
  35. package/assets/vision-model-test.png +0 -0
  36. package/assets/vision-settings.png +0 -0
  37. package/assets/wechat-reward.png +0 -0
  38. package/cordis.patch.yml +6 -0
  39. package/docs/aihubmix-gemini-vision.i18n.yaml +6 -0
  40. package/docs/aihubmix-gemini-vision.md +138 -0
  41. package/docs/aihubmix-gemini-vision.zh.md +138 -0
  42. package/docs/assets/aihubmix-api-keys.png +0 -0
  43. package/docs/assets/aihubmix-create-key.png +0 -0
  44. package/docs/assets/aihubmix-free-vision-model.png +0 -0
  45. package/docs/assets/aihubmix-home.png +0 -0
  46. package/docs/assets/aihubmix-sign-up.png +0 -0
  47. package/docs/assets/groq-console-home.png +0 -0
  48. package/docs/assets/groq-console-keys-login.png +0 -0
  49. package/docs/assets/groq-docs-quickstart.png +0 -0
  50. package/docs/assets/groq-docs-qwen3.6.png +0 -0
  51. package/docs/assets/groq-docs-vision.png +0 -0
  52. package/docs/assets/vision-settings.png +0 -0
  53. package/docs/dsh-desktop-install.md +67 -0
  54. package/docs/dsh-desktop-install.zh.md +67 -0
  55. package/docs/groq-qwen3.6-vision.i18n.yaml +6 -0
  56. package/docs/groq-qwen3.6-vision.md +236 -0
  57. package/docs/groq-qwen3.6-vision.zh.md +236 -0
  58. package/docs/python-runtime.i18n.yaml +6 -0
  59. package/docs/python-runtime.md +89 -0
  60. package/docs/python-runtime.zh.md +89 -0
  61. package/docs/requirements-traceability/README.i18n.yaml +6 -0
  62. package/docs/requirements-traceability/README.md +75 -0
  63. package/docs/requirements-traceability/README.zh.md +75 -0
  64. package/examples/ui-restoration/README.i18n.yaml +6 -0
  65. package/examples/ui-restoration/README.md +70 -0
  66. package/examples/ui-restoration/README.zh.md +70 -0
  67. package/examples/ui-restoration/assets/final-heatmap.png +0 -0
  68. package/examples/ui-restoration/assets/final-report.json +83 -0
  69. package/examples/ui-restoration/assets/implementation.png +0 -0
  70. package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
  71. package/examples/ui-restoration/assets/initial-report.json +83 -0
  72. package/examples/ui-restoration/assets/initial.png +0 -0
  73. package/examples/ui-restoration/assets/metrics.json +12 -0
  74. package/examples/ui-restoration/assets/reference.png +0 -0
  75. package/examples/ui-restoration/implementation.html +94 -0
  76. package/examples/ui-restoration/initial.html +57 -0
  77. package/lib/artifact-access.js +369 -0
  78. package/lib/artifact-access.js.map +1 -0
  79. package/lib/artifacts.js +56 -0
  80. package/lib/artifacts.js.map +1 -0
  81. package/lib/client.js +2099 -0
  82. package/lib/client.js.map +1 -0
  83. package/lib/config.js +172 -0
  84. package/lib/config.js.map +1 -0
  85. package/lib/defaults.js +6 -0
  86. package/lib/defaults.js.map +1 -0
  87. package/lib/errors.js +56 -0
  88. package/lib/errors.js.map +1 -0
  89. package/lib/evidence-cache.js +350 -0
  90. package/lib/evidence-cache.js.map +1 -0
  91. package/lib/exposure.js +247 -0
  92. package/lib/exposure.js.map +1 -0
  93. package/lib/image-input-variants.js +882 -0
  94. package/lib/image-input-variants.js.map +1 -0
  95. package/lib/index.js +105 -0
  96. package/lib/index.js.map +1 -0
  97. package/lib/paste-images.js +217 -0
  98. package/lib/paste-images.js.map +1 -0
  99. package/lib/paths.js +354 -0
  100. package/lib/paths.js.map +1 -0
  101. package/lib/plugin-update.js +1003 -0
  102. package/lib/plugin-update.js.map +1 -0
  103. package/lib/runtime-install.js +964 -0
  104. package/lib/runtime-install.js.map +1 -0
  105. package/lib/runtime-manager.js +133 -0
  106. package/lib/runtime-manager.js.map +1 -0
  107. package/lib/runtime.js +1678 -0
  108. package/lib/runtime.js.map +1 -0
  109. package/lib/skill.js +25 -0
  110. package/lib/skill.js.map +1 -0
  111. package/lib/tools.js +549 -0
  112. package/lib/tools.js.map +1 -0
  113. package/lib/types/artifact-access.d.ts +61 -0
  114. package/lib/types/artifact-access.d.ts.map +1 -0
  115. package/lib/types/artifacts.d.ts +42 -0
  116. package/lib/types/artifacts.d.ts.map +1 -0
  117. package/lib/types/client/display-config.d.ts +24 -0
  118. package/lib/types/client/display-config.d.ts.map +1 -0
  119. package/lib/types/client/index.d.ts +350 -0
  120. package/lib/types/client/index.d.ts.map +1 -0
  121. package/lib/types/client/model-variants-hider.d.ts +40 -0
  122. package/lib/types/client/model-variants-hider.d.ts.map +1 -0
  123. package/lib/types/client/paste-images.d.ts +126 -0
  124. package/lib/types/client/paste-images.d.ts.map +1 -0
  125. package/lib/types/config.d.ts +123 -0
  126. package/lib/types/config.d.ts.map +1 -0
  127. package/lib/types/defaults.d.ts +6 -0
  128. package/lib/types/defaults.d.ts.map +1 -0
  129. package/lib/types/errors.d.ts +35 -0
  130. package/lib/types/errors.d.ts.map +1 -0
  131. package/lib/types/evidence-cache.d.ts +91 -0
  132. package/lib/types/evidence-cache.d.ts.map +1 -0
  133. package/lib/types/exposure.d.ts +50 -0
  134. package/lib/types/exposure.d.ts.map +1 -0
  135. package/lib/types/image-input-variants.d.ts +144 -0
  136. package/lib/types/image-input-variants.d.ts.map +1 -0
  137. package/lib/types/index.d.ts +19 -0
  138. package/lib/types/index.d.ts.map +1 -0
  139. package/lib/types/paste-images.d.ts +73 -0
  140. package/lib/types/paste-images.d.ts.map +1 -0
  141. package/lib/types/paths.d.ts +117 -0
  142. package/lib/types/paths.d.ts.map +1 -0
  143. package/lib/types/plugin-update.d.ts +119 -0
  144. package/lib/types/plugin-update.d.ts.map +1 -0
  145. package/lib/types/runtime-install.d.ts +92 -0
  146. package/lib/types/runtime-install.d.ts.map +1 -0
  147. package/lib/types/runtime-manager.d.ts +60 -0
  148. package/lib/types/runtime-manager.d.ts.map +1 -0
  149. package/lib/types/runtime.d.ts +412 -0
  150. package/lib/types/runtime.d.ts.map +1 -0
  151. package/lib/types/skill.d.ts +14 -0
  152. package/lib/types/skill.d.ts.map +1 -0
  153. package/lib/types/tools.d.ts +35 -0
  154. package/lib/types/tools.d.ts.map +1 -0
  155. package/lib/types/upstream.d.ts +233 -0
  156. package/lib/types/upstream.d.ts.map +1 -0
  157. package/lib/types/version.d.ts +15 -0
  158. package/lib/types/version.d.ts.map +1 -0
  159. package/lib/types/web-request.d.ts +11 -0
  160. package/lib/types/web-request.d.ts.map +1 -0
  161. package/lib/types/web.d.ts +118 -0
  162. package/lib/types/web.d.ts.map +1 -0
  163. package/lib/upstream.js +860 -0
  164. package/lib/upstream.js.map +1 -0
  165. package/lib/version.js +18 -0
  166. package/lib/version.js.map +1 -0
  167. package/lib/web-request.js +29 -0
  168. package/lib/web-request.js.map +1 -0
  169. package/lib/web.js +462 -0
  170. package/lib/web.js.map +1 -0
  171. package/package.json +149 -0
  172. package/patches/vision-tools-dsh.patch +970 -0
  173. package/runtime/requirements.lock +3 -0
  174. package/src/artifact-access.ts +386 -0
  175. package/src/artifacts.ts +85 -0
  176. package/src/client/display-config.ts +62 -0
  177. package/src/client/index.tsx +1580 -0
  178. package/src/client/model-variants-hider.ts +159 -0
  179. package/src/client/paste-images.tsx +747 -0
  180. package/src/config.ts +290 -0
  181. package/src/defaults.ts +5 -0
  182. package/src/errors.ts +62 -0
  183. package/src/evidence-cache.ts +418 -0
  184. package/src/exposure.ts +265 -0
  185. package/src/image-input-variants.ts +996 -0
  186. package/src/index.ts +141 -0
  187. package/src/paste-images.ts +286 -0
  188. package/src/paths.ts +383 -0
  189. package/src/plugin-update.ts +1143 -0
  190. package/src/runtime-install.ts +1161 -0
  191. package/src/runtime-manager.ts +174 -0
  192. package/src/runtime.ts +2189 -0
  193. package/src/skill.ts +35 -0
  194. package/src/tools.ts +691 -0
  195. package/src/upstream.ts +1078 -0
  196. package/src/version.ts +37 -0
  197. package/src/web-request.ts +27 -0
  198. package/src/web.ts +594 -0
  199. package/vendor/agent-vision-toolkit/CHANGELOG.md +30 -0
  200. package/vendor/agent-vision-toolkit/LICENSE +21 -0
  201. package/vendor/agent-vision-toolkit/README.md +405 -0
  202. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +94 -0
  203. package/vendor/agent-vision-toolkit/bin/crop +90 -0
  204. package/vendor/agent-vision-toolkit/bin/detect +13 -0
  205. package/vendor/agent-vision-toolkit/bin/glance +93 -0
  206. package/vendor/agent-vision-toolkit/bin/ground +13 -0
  207. package/vendor/agent-vision-toolkit/bin/trace +129 -0
  208. package/vendor/agent-vision-toolkit/detect.py +60 -0
  209. package/vendor/agent-vision-toolkit/ground.py +267 -0
  210. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
  211. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
  212. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +420 -0
  213. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  214. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
  215. package/vendor/agent-vision-toolkit/tests/test_vision_client.py +450 -0
  216. package/vendor/agent-vision-toolkit/vision_client.py +310 -0
@@ -0,0 +1,1161 @@
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
+ access,
12
+ chmod,
13
+ lstat,
14
+ mkdir,
15
+ mkdtemp,
16
+ readFile,
17
+ readdir,
18
+ realpath,
19
+ rename,
20
+ rm,
21
+ stat,
22
+ utimes,
23
+ writeFile,
24
+ } from 'node:fs/promises'
25
+ import { createWriteStream } from 'node:fs'
26
+ import { homedir } from 'node:os'
27
+ import { dirname, join, resolve } from 'node:path'
28
+ import { Transform } from 'node:stream'
29
+ import { pipeline } from 'node:stream/promises'
30
+ import { fileURLToPath } from 'node:url'
31
+ import type { Context } from '@deepseek-ai/cordis'
32
+ import { x as extractTar } from 'tar'
33
+ import { EnvHttpProxyAgent } from 'undici'
34
+ import { request as undiciRequest } from 'undici'
35
+ import type { ResolvedVisionToolkitConfig } from './config.ts'
36
+ import { VisionToolkitError } from './errors.ts'
37
+ import { UPSTREAM_COMMIT, UPSTREAM_REPOSITORY, UPSTREAM_VERSION } from './version.ts'
38
+
39
+ /** One executable plus fixed prefix arguments (for example Windows `py -3`). */
40
+ export interface RuntimeCommand {
41
+ program: string
42
+ prefix: string[]
43
+ display: string
44
+ }
45
+
46
+ /** Prepared source and interpreter facts consumed by the upstream adapter. */
47
+ export interface PreparedUpstreamRuntime {
48
+ source: 'managed' | 'external'
49
+ root: string
50
+ python: RuntimeCommand
51
+ cleanHome: string
52
+ pythonVersion: string
53
+ dependencies: Record<string, string>
54
+ }
55
+
56
+ interface CommandResult {
57
+ stdout: string
58
+ stderr: string
59
+ exitCode: number | null
60
+ timedOut: boolean
61
+ }
62
+
63
+ interface UpstreamManifest {
64
+ schemaVersion: number
65
+ repository: string
66
+ version: string
67
+ commit: string
68
+ contentSha256: string
69
+ files: Array<{ path: string; bytes: number; sha256: string }>
70
+ }
71
+
72
+ interface RuntimeMarker {
73
+ schemaVersion: 1
74
+ upstreamCommit: string
75
+ upstreamContentSha256: string
76
+ requirementsSha256: string
77
+ pythonVersion: string
78
+ dependencies: Record<string, string>
79
+ manager: 'uv' | 'venv-pip'
80
+ }
81
+
82
+ const PACKAGE_ROOT = dirname(fileURLToPath(new URL('../package.json', import.meta.url)))
83
+ const BUNDLED_ROOT = join(PACKAGE_ROOT, 'vendor', 'agent-vision-toolkit')
84
+ const MANIFEST_PATH = join(BUNDLED_ROOT, 'UPSTREAM_MANIFEST.json')
85
+ const REQUIREMENTS_PATH = join(PACKAGE_ROOT, 'runtime', 'requirements.lock')
86
+ const PREPARE_TIMEOUT_MS = 10 * 60 * 1000
87
+ const PYPI_MIRROR_BASE_URL = 'https://mirrors.cloud.tencent.com/pypi/simple'
88
+ const PROBE_TIMEOUT_MS = 30_000
89
+ const LOCK_STALE_MS = 15 * 60 * 1000
90
+ const LOCK_HEARTBEAT_MS = 5_000
91
+
92
+ /** Absolute root of the packaged upstream snapshot. */
93
+ export function bundledUpstreamRoot(): string {
94
+ return BUNDLED_ROOT
95
+ }
96
+
97
+ /** Convert one command into a user-facing executable string. */
98
+ export function displayCommand(command: RuntimeCommand): string {
99
+ return [command.program, ...command.prefix].join(' ')
100
+ }
101
+
102
+ function sha256(bytes: string | Buffer): string {
103
+ return createHash('sha256').update(bytes).digest('hex')
104
+ }
105
+
106
+ export function isolatedPythonEnvironment(home: string): NodeJS.ProcessEnv {
107
+ return {
108
+ HOME: home,
109
+ USERPROFILE: home,
110
+ LOCALAPPDATA: home,
111
+ PYTHONHOME: undefined,
112
+ PYTHONPATH: undefined,
113
+ VIRTUAL_ENV: undefined,
114
+ PYTHONDONTWRITEBYTECODE: '1',
115
+ PYTHONIOENCODING: 'utf-8',
116
+ PYTHONNOUSERSITE: '1',
117
+ PYTHONUTF8: '1',
118
+ }
119
+ }
120
+
121
+ async function runCollected(
122
+ ctx: Context,
123
+ argv: readonly string[],
124
+ cwd: string,
125
+ options: { timeoutMs?: number; env?: NodeJS.ProcessEnv } = {},
126
+ ): Promise<CommandResult> {
127
+ const controller = new AbortController()
128
+ let timedOut = false
129
+ const timer = setTimeout(() => {
130
+ timedOut = true
131
+ controller.abort()
132
+ }, options.timeoutMs ?? PROBE_TIMEOUT_MS)
133
+ try {
134
+ const handle = ctx.subprocess.spawn({
135
+ argv,
136
+ cwd,
137
+ stdio: {
138
+ stdin: 'ignore',
139
+ stdout: { maxBytes: 256 * 1024 },
140
+ stderr: { maxBytes: 256 * 1024 },
141
+ },
142
+ graceMs: 2000,
143
+ signal: controller.signal,
144
+ ...(options.env === undefined ? {} : { env: options.env }),
145
+ })
146
+ const outcome = await handle.done
147
+ return {
148
+ stdout: handle.collected.stdout?.readFrom(0).text ?? '',
149
+ stderr: handle.collected.stderr?.readFrom(0).text ?? '',
150
+ exitCode: outcome.exitCode,
151
+ timedOut,
152
+ }
153
+ } catch (error) {
154
+ if (timedOut) return { stdout: '', stderr: '', exitCode: null, timedOut: true }
155
+ throw error
156
+ } finally {
157
+ clearTimeout(timer)
158
+ }
159
+ }
160
+
161
+ async function installDependenciesWithFallback(
162
+ ctx: Context,
163
+ argv: readonly string[],
164
+ stateRoot: string,
165
+ env: NodeJS.ProcessEnv,
166
+ label: string,
167
+ ): Promise<void> {
168
+ let lastResult: CommandResult | undefined
169
+ for (const indexUrl of [PYPI_MIRROR_BASE_URL, undefined]) {
170
+ const indexArgs = indexUrl === undefined ? [] : ['--index-url', indexUrl]
171
+ const result = await runCollected(ctx, [...argv, ...indexArgs], stateRoot, { timeoutMs: PREPARE_TIMEOUT_MS, env })
172
+ if (result.exitCode === 0 && !result.timedOut) return
173
+ lastResult = result
174
+ }
175
+ throw new VisionToolkitError(
176
+ 'runtime',
177
+ `${label} failed to install managed runtime dependencies: ${(lastResult?.stderr ?? '').trim()}`,
178
+ )
179
+ }
180
+
181
+ async function readManifest(path = MANIFEST_PATH): Promise<UpstreamManifest> {
182
+ let parsed: unknown
183
+ try {
184
+ parsed = JSON.parse(await readFile(path, 'utf8'))
185
+ } catch (error) {
186
+ throw new VisionToolkitError('runtime', `upstream manifest is unreadable: ${path}`, { cause: error })
187
+ }
188
+ if (typeof parsed !== 'object' || parsed === null) {
189
+ throw new VisionToolkitError('runtime', `upstream manifest is not an object: ${path}`)
190
+ }
191
+ const manifest = parsed as Partial<UpstreamManifest>
192
+ if (
193
+ manifest.schemaVersion !== 1
194
+ || manifest.repository !== UPSTREAM_REPOSITORY
195
+ || manifest.version !== UPSTREAM_VERSION
196
+ || manifest.commit !== UPSTREAM_COMMIT
197
+ || !/^[a-f0-9]{64}$/.test(manifest.contentSha256 ?? '')
198
+ || typeof manifest.contentSha256 !== 'string'
199
+ || !Array.isArray(manifest.files)
200
+ || manifest.files.length === 0
201
+ ) {
202
+ throw new VisionToolkitError('runtime', `upstream manifest identity does not match the packaged pin: ${path}`)
203
+ }
204
+ const seen = new Set<string>()
205
+ let previous = ''
206
+ for (const entry of manifest.files) {
207
+ if (
208
+ typeof entry !== 'object'
209
+ || entry === null
210
+ || typeof entry.path !== 'string'
211
+ || entry.path.length === 0
212
+ || entry.path.includes('\\')
213
+ || entry.path.startsWith('/')
214
+ || entry.path.split('/').some(segment => segment.length === 0 || segment === '.' || segment === '..')
215
+ || !Number.isInteger(entry.bytes)
216
+ || entry.bytes < 0
217
+ || !/^[a-f0-9]{64}$/.test(entry.sha256)
218
+ || seen.has(entry.path)
219
+ || (previous.length > 0 && previous >= entry.path)
220
+ ) {
221
+ throw new VisionToolkitError('runtime', `upstream manifest contains an invalid file entry: ${path}`)
222
+ }
223
+ seen.add(entry.path)
224
+ previous = entry.path
225
+ }
226
+ return manifest as UpstreamManifest
227
+ }
228
+
229
+ /** Verify every packaged upstream file against the committed content manifest. */
230
+ export async function verifyBundledUpstream(): Promise<UpstreamManifest> {
231
+ const manifest = await readManifest()
232
+ const rows: string[] = []
233
+ for (const entry of manifest.files) {
234
+ const path = join(BUNDLED_ROOT, ...entry.path.split('/'))
235
+ let bytes: Buffer
236
+ try {
237
+ const info = await lstat(path)
238
+ if (!info.isFile() || info.isSymbolicLink()) {
239
+ throw new VisionToolkitError('runtime', `packaged upstream entry is not a regular file: ${entry.path}`)
240
+ }
241
+ bytes = await readFile(path)
242
+ } catch (error) {
243
+ if (error instanceof VisionToolkitError) throw error
244
+ throw new VisionToolkitError('runtime', `packaged upstream file is missing: ${entry.path}`, { cause: error })
245
+ }
246
+ const digest = sha256(bytes)
247
+ if (bytes.length !== entry.bytes || digest !== entry.sha256) {
248
+ throw new VisionToolkitError('runtime', `packaged upstream file failed its hash check: ${entry.path}`)
249
+ }
250
+ rows.push(`${entry.path}\0${digest}\n`)
251
+ }
252
+ if (sha256(rows.join('')) !== manifest.contentSha256) {
253
+ throw new VisionToolkitError('runtime', 'packaged upstream aggregate hash does not match its manifest')
254
+ }
255
+ return manifest
256
+ }
257
+
258
+ async function pythonMetadata(
259
+ ctx: Context,
260
+ command: RuntimeCommand,
261
+ cwd: string,
262
+ ): Promise<{ version: string; major: number; minor: number } | undefined> {
263
+ const script = 'import json,sys; print(json.dumps({"version":sys.version.split()[0],"major":sys.version_info[0],"minor":sys.version_info[1]}))'
264
+ let result: CommandResult
265
+ try {
266
+ result = await runCollected(ctx, [command.program, ...command.prefix, '-c', script], cwd, {
267
+ env: isolatedPythonEnvironment(cwd),
268
+ })
269
+ } catch {
270
+ return undefined
271
+ }
272
+ if (result.exitCode !== 0 || result.timedOut) return undefined
273
+ try {
274
+ const parsed = JSON.parse(result.stdout) as { version?: unknown; major?: unknown; minor?: unknown }
275
+ if (typeof parsed.version !== 'string' || typeof parsed.major !== 'number' || typeof parsed.minor !== 'number') {
276
+ return undefined
277
+ }
278
+ return { version: parsed.version, major: parsed.major, minor: parsed.minor }
279
+ } catch {
280
+ return undefined
281
+ }
282
+ }
283
+
284
+ interface PythonBootstrapArtifact {
285
+ url: string
286
+ sha256: string
287
+ size: number
288
+ }
289
+
290
+ interface PythonBootstrapManifest {
291
+ schemaVersion: 1
292
+ pythonVersion: string
293
+ buildTag: string
294
+ /** Optional domestic mirror base that replaces the GitHub download prefix. */
295
+ mirrorBaseUrl?: string
296
+ artifacts: Record<string, PythonBootstrapArtifact>
297
+ }
298
+
299
+ const PYTHON_BOOTSTRAP_MANIFEST_PATH = join(PACKAGE_ROOT, 'assets', 'python-bootstrap.json')
300
+ const GITHUB_PYTHON_DOWNLOAD_PREFIX = 'https://github.com/astral-sh/python-build-standalone/releases/download'
301
+ const PYTHON_MIRROR_BASE_URL = 'https://dsh-vision-python-bootstrap-1317715800.cos.ap-guangzhou.myqcloud.com'
302
+ const PYTHON_DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000
303
+ const PYTHON_DOWNLOAD_ATTEMPTS = 3
304
+
305
+ async function readPythonBootstrapManifest(): Promise<PythonBootstrapManifest> {
306
+ let parsed: unknown
307
+ try {
308
+ parsed = JSON.parse(await readFile(PYTHON_BOOTSTRAP_MANIFEST_PATH, 'utf8'))
309
+ } catch (error) {
310
+ throw new VisionToolkitError('runtime', `python bootstrap manifest is unreadable: ${PYTHON_BOOTSTRAP_MANIFEST_PATH}`, { cause: error })
311
+ }
312
+ if (typeof parsed !== 'object' || parsed === null) {
313
+ throw new VisionToolkitError('runtime', 'python bootstrap manifest is not an object')
314
+ }
315
+ const manifest = parsed as Partial<PythonBootstrapManifest>
316
+ if (
317
+ manifest.schemaVersion !== 1
318
+ || typeof manifest.pythonVersion !== 'string'
319
+ || !/^3\.\d+\.\d+$/u.test(manifest.pythonVersion)
320
+ || typeof manifest.buildTag !== 'string'
321
+ || !/^\d{8}$/u.test(manifest.buildTag)
322
+ || (manifest.mirrorBaseUrl !== undefined && manifest.mirrorBaseUrl !== PYTHON_MIRROR_BASE_URL)
323
+ || typeof manifest.artifacts !== 'object'
324
+ || manifest.artifacts === null
325
+ ) {
326
+ throw new VisionToolkitError('runtime', 'python bootstrap manifest is invalid')
327
+ }
328
+ for (const [target, artifact] of Object.entries(manifest.artifacts)) {
329
+ if (
330
+ typeof artifact !== 'object'
331
+ || artifact === null
332
+ || typeof artifact.url !== 'string'
333
+ || !artifact.url.startsWith(`${GITHUB_PYTHON_DOWNLOAD_PREFIX}/`)
334
+ || typeof artifact.sha256 !== 'string'
335
+ || !/^[a-f0-9]{64}$/u.test(artifact.sha256)
336
+ || !Number.isInteger(artifact.size)
337
+ || artifact.size <= 0
338
+ ) {
339
+ throw new VisionToolkitError('runtime', `python bootstrap manifest has an invalid artifact: ${target}`)
340
+ }
341
+ }
342
+ return manifest as PythonBootstrapManifest
343
+ }
344
+
345
+ /** Map Node platform/arch to the pinned artifact name, including musl Linux. */
346
+ export function pythonBootstrapTarget(platform: string, arch: string, musl: boolean): string {
347
+ return platform === 'linux' && musl ? `${platform}-${arch}-musl` : `${platform}-${arch}`
348
+ }
349
+
350
+ async function runningMusl(): Promise<boolean> {
351
+ if (process.platform !== 'linux') return false
352
+ try {
353
+ await access(join('/', 'etc', 'alpine-release'))
354
+ return true
355
+ } catch {
356
+ // Fall through to the loader-name probe.
357
+ }
358
+ const loader = process.arch === 'arm64' ? 'aarch64' : 'x86_64'
359
+ try {
360
+ await access(join('/', 'lib', `ld-musl-${loader}.so.1`))
361
+ return true
362
+ } catch {
363
+ return false
364
+ }
365
+ }
366
+
367
+ interface DownloadResponse {
368
+ statusCode: number
369
+ headers: Record<string, string | string[] | undefined>
370
+ body: NodeJS.ReadableStream
371
+ close: () => Promise<void>
372
+ }
373
+
374
+ type DownloadRequest = (url: string, signal: AbortSignal) => Promise<DownloadResponse>
375
+
376
+ const DOWNLOAD_HOSTS = new Set([
377
+ 'github.com',
378
+ 'objects.githubusercontent.com',
379
+ 'release-assets.githubusercontent.com',
380
+ 'dsh-vision-python-bootstrap-1317715800.cos.ap-guangzhou.myqcloud.com',
381
+ ])
382
+
383
+ async function defaultDownloadRequest(url: string, signal: AbortSignal): Promise<DownloadResponse> {
384
+ const dispatcher = new EnvHttpProxyAgent()
385
+ let current = url
386
+ try {
387
+ for (let redirects = 0; ; redirects++) {
388
+ const host = new URL(current).hostname
389
+ if (!DOWNLOAD_HOSTS.has(host)) throw new Error(`download redirected outside the allowlist: ${host}`)
390
+ const response = await undiciRequest(current, {
391
+ dispatcher,
392
+ signal,
393
+ headers: { 'user-agent': 'dsh-vision-toolkit' },
394
+ })
395
+ if (response.statusCode >= 300 && response.statusCode < 400) {
396
+ const location = response.headers.location
397
+ await response.body.dump().catch(() => {})
398
+ if (typeof location !== 'string' || location.length === 0 || redirects >= 5) {
399
+ throw new Error('download redirected too many times or without a Location header')
400
+ }
401
+ current = new URL(location, current).toString()
402
+ continue
403
+ }
404
+ return {
405
+ statusCode: response.statusCode,
406
+ headers: response.headers,
407
+ body: response.body,
408
+ close: () => dispatcher.close().catch(() => {}),
409
+ }
410
+ }
411
+ } catch (error) {
412
+ await dispatcher.close().catch(() => {})
413
+ throw error
414
+ }
415
+ }
416
+
417
+ async function downloadBundledPythonOnce(
418
+ url: string,
419
+ artifact: PythonBootstrapArtifact,
420
+ destination: string,
421
+ requestImpl: DownloadRequest = defaultDownloadRequest,
422
+ ): Promise<void> {
423
+ const signal = AbortSignal.timeout(PYTHON_DOWNLOAD_TIMEOUT_MS)
424
+ let response: DownloadResponse
425
+ try {
426
+ response = await requestImpl(url, signal)
427
+ } catch (error) {
428
+ throw new Error(`download request failed: ${error instanceof Error ? error.message : String(error)}`)
429
+ }
430
+ try {
431
+ if (response.statusCode !== 200) throw new Error(`download returned HTTP ${response.statusCode}`)
432
+ const hash = createHash('sha256')
433
+ let bytes = 0
434
+ const hasher = new Transform({
435
+ transform(chunk, _encoding, callback) {
436
+ hash.update(chunk as Buffer)
437
+ bytes += (chunk as Buffer).length
438
+ callback(null, chunk)
439
+ },
440
+ })
441
+ await pipeline(response.body, hasher, createWriteStream(destination))
442
+ if (bytes !== artifact.size) throw new Error(`size mismatch: expected ${artifact.size}, received ${bytes}`)
443
+ if (hash.digest('hex') !== artifact.sha256) throw new Error('sha256 mismatch')
444
+ } finally {
445
+ await response.close()
446
+ }
447
+ }
448
+
449
+ async function downloadBundledPython(
450
+ manifest: PythonBootstrapManifest,
451
+ artifact: PythonBootstrapArtifact,
452
+ destination: string,
453
+ requestImpl: DownloadRequest,
454
+ ): Promise<void> {
455
+ const sources = [
456
+ ...(manifest.mirrorBaseUrl === undefined
457
+ ? []
458
+ : [artifact.url.replace(GITHUB_PYTHON_DOWNLOAD_PREFIX, manifest.mirrorBaseUrl)]),
459
+ artifact.url,
460
+ ]
461
+ let lastError: unknown
462
+ for (let attempt = 0; attempt < PYTHON_DOWNLOAD_ATTEMPTS; attempt++) {
463
+ const source = sources[Math.min(attempt, sources.length - 1)] ?? artifact.url
464
+ try {
465
+ await downloadBundledPythonOnce(source, artifact, destination, requestImpl)
466
+ return
467
+ } catch (error) {
468
+ lastError = error
469
+ if (attempt + 1 < PYTHON_DOWNLOAD_ATTEMPTS) {
470
+ await new Promise(resolveWait => setTimeout(resolveWait, 500 * 2 ** attempt))
471
+ }
472
+ }
473
+ }
474
+ throw lastError
475
+ }
476
+
477
+ /**
478
+ * Cross-process directory lock with a stale-lock timeout and heartbeat,
479
+ * matching the managed-runtime lock semantics.
480
+ */
481
+ async function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>): Promise<T> {
482
+ const owner = randomUUID()
483
+ let acquired = false
484
+ await mkdir(dirname(lockPath), { recursive: true })
485
+ try {
486
+ await mkdir(lockPath, { recursive: false })
487
+ acquired = true
488
+ await writeFile(join(lockPath, 'owner'), `${owner}\n`, { flag: 'wx' })
489
+ } catch (error) {
490
+ if (acquired) {
491
+ await rm(lockPath, { recursive: true, force: true }).catch(() => {})
492
+ throw error
493
+ }
494
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
495
+ const started = Date.now()
496
+ while (Date.now() - started < PREPARE_TIMEOUT_MS) {
497
+ try {
498
+ const info = await stat(lockPath)
499
+ if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
500
+ await rm(lockPath, { recursive: true, force: true })
501
+ return withDirectoryLock(lockPath, fn)
502
+ }
503
+ } catch {
504
+ return withDirectoryLock(lockPath, fn)
505
+ }
506
+ await new Promise(resolveWait => setTimeout(resolveWait, 250))
507
+ }
508
+ throw new VisionToolkitError('runtime', 'timed out waiting for another process to prepare the bundled Python')
509
+ }
510
+ const heartbeat = setInterval(() => {
511
+ const now = new Date()
512
+ void utimes(lockPath, now, now).catch(() => {})
513
+ }, LOCK_HEARTBEAT_MS)
514
+ heartbeat.unref()
515
+ try {
516
+ return await fn()
517
+ } finally {
518
+ clearInterval(heartbeat)
519
+ try {
520
+ if ((await readFile(join(lockPath, 'owner'), 'utf8')).trim() === owner) {
521
+ await rm(lockPath, { recursive: true, force: true })
522
+ }
523
+ } catch {
524
+ // The lock was already removed or replaced.
525
+ }
526
+ }
527
+ }
528
+
529
+ export async function acquireBundledPython(
530
+ ctx: Context,
531
+ stateRoot: string,
532
+ cwd: string,
533
+ manifestOverride?: PythonBootstrapManifest,
534
+ requestImpl?: DownloadRequest,
535
+ ): Promise<{ command: RuntimeCommand; version: string }> {
536
+ const manifest = manifestOverride ?? await readPythonBootstrapManifest()
537
+ const target = pythonBootstrapTarget(process.platform, process.arch, await runningMusl())
538
+ const artifact = manifest.artifacts[target]
539
+ if (artifact === undefined) {
540
+ throw new VisionToolkitError(
541
+ 'runtime',
542
+ `no bundled Python ${manifest.pythonVersion} artifact for ${target}; install Python 3.11+ or configure runtime.python`,
543
+ )
544
+ }
545
+ const root = join(stateRoot, 'python-bootstrap', `${manifest.pythonVersion}-${target}`)
546
+ const interpreter = process.platform === 'win32' ? join(root, 'python.exe') : join(root, 'bin', 'python3')
547
+ const command: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
548
+ const cached = await pythonMetadata(ctx, command, cwd)
549
+ if (cached !== undefined) return { command, version: cached.version }
550
+ await withDirectoryLock(`${root}.lock`, async () => {
551
+ const ready = await pythonMetadata(ctx, command, cwd)
552
+ if (ready !== undefined) return
553
+ const parent = dirname(root)
554
+ await mkdir(parent, { recursive: true })
555
+ await rm(root, { recursive: true, force: true })
556
+ const work = await mkdtemp(join(parent, '.python-bootstrap-'))
557
+ try {
558
+ const archive = join(work, 'python.tar.gz')
559
+ const extractDir = join(work, 'extract')
560
+ await mkdir(extractDir, { recursive: true })
561
+ try {
562
+ await downloadBundledPython(manifest, artifact, archive, requestImpl ?? defaultDownloadRequest)
563
+ } catch (error) {
564
+ throw new VisionToolkitError(
565
+ 'runtime',
566
+ `bundled Python ${manifest.pythonVersion} could not be downloaded for ${target} (${error instanceof Error ? error.message : String(error)}); install Python 3.11+ or configure runtime.python`,
567
+ { cause: error },
568
+ )
569
+ }
570
+ try {
571
+ await extractTar({ file: archive, cwd: extractDir, strip: 1 })
572
+ } catch (error) {
573
+ throw new VisionToolkitError(
574
+ 'runtime',
575
+ `bundled Python ${manifest.pythonVersion} could not be extracted for ${target}`,
576
+ { cause: error },
577
+ )
578
+ }
579
+ const extractedInterpreter = process.platform === 'win32'
580
+ ? join(extractDir, 'python.exe')
581
+ : join(extractDir, 'bin', 'python3')
582
+ try {
583
+ await access(extractedInterpreter)
584
+ } catch (error) {
585
+ throw new VisionToolkitError(
586
+ 'runtime',
587
+ `bundled Python ${manifest.pythonVersion} for ${target} is missing its interpreter`,
588
+ { cause: error },
589
+ )
590
+ }
591
+ if (process.platform !== 'win32') await chmod(extractedInterpreter, 0o755)
592
+ await rename(extractDir, root)
593
+ } finally {
594
+ await rm(work, { recursive: true, force: true })
595
+ }
596
+ })
597
+ const metadata = await pythonMetadata(ctx, command, cwd)
598
+ if (metadata === undefined) {
599
+ throw new VisionToolkitError('runtime', 'bundled Python did not start after extraction')
600
+ }
601
+ return { command, version: metadata.version }
602
+ }
603
+
604
+ export async function resolveBootstrapPython(
605
+ ctx: Context,
606
+ configured: string | undefined,
607
+ cwd: string,
608
+ manifestOverride?: PythonBootstrapManifest,
609
+ requestImpl?: DownloadRequest,
610
+ ): Promise<{ command: RuntimeCommand; version: string; major: number; minor: number }> {
611
+ const candidates: RuntimeCommand[] = configured === undefined
612
+ ? process.platform === 'win32'
613
+ ? [
614
+ { program: 'python', prefix: [], display: 'python' },
615
+ { program: 'py', prefix: ['-3'], display: 'py -3' },
616
+ { program: 'python3', prefix: [], display: 'python3' },
617
+ ]
618
+ : [
619
+ { program: 'python3', prefix: [], display: 'python3' },
620
+ { program: 'python', prefix: [], display: 'python' },
621
+ ]
622
+ : [{ program: configured, prefix: [], display: configured }]
623
+ for (const command of candidates) {
624
+ const metadata = await pythonMetadata(ctx, command, cwd)
625
+ if (metadata !== undefined && (metadata.major > 3 || metadata.major === 3 && metadata.minor >= 11)) {
626
+ return { command, ...metadata }
627
+ }
628
+ }
629
+ if (configured !== undefined) {
630
+ throw new VisionToolkitError('runtime', `vision-toolkit requires Python 3.11 or newer: ${configured}`)
631
+ }
632
+ try {
633
+ const stateRoot = visionToolkitStateRoot()
634
+ await mkdir(stateRoot, { recursive: true })
635
+ const bundled = await acquireBundledPython(ctx, stateRoot, cwd, manifestOverride, requestImpl)
636
+ return {
637
+ command: bundled.command,
638
+ version: bundled.version,
639
+ major: Number.parseInt(bundled.version.split('.')[0] ?? '', 10),
640
+ minor: Number.parseInt(bundled.version.split('.')[1] ?? '', 10),
641
+ }
642
+ } catch (error) {
643
+ if (error instanceof VisionToolkitError) throw error
644
+ throw new VisionToolkitError(
645
+ 'runtime',
646
+ 'vision-toolkit requires Python 3.11 or newer; tried python3, python, and the Windows py launcher, and automatic bundled-Python preparation failed',
647
+ { cause: error },
648
+ )
649
+ }
650
+ }
651
+
652
+ /** Persistent per-DSH-home cache root shared by runtime and Web support files. */
653
+ export function visionToolkitStateRoot(): string {
654
+ const dshHome = process.env.DSH_HOME?.trim()
655
+ const base = dshHome === undefined || dshHome.length === 0 ? join(homedir(), '.dsh') : resolve(dshHome)
656
+ return join(base, 'cache', 'dsh-vision-toolkit')
657
+ }
658
+
659
+ function expandHome(path: string): string {
660
+ if (path === '~') return homedir()
661
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
662
+ return path
663
+ }
664
+
665
+ function venvPython(root: string): string {
666
+ return process.platform === 'win32' ? join(root, 'Scripts', 'python.exe') : join(root, 'bin', 'python')
667
+ }
668
+
669
+ /**
670
+ * Rewrite a staged venv's `pyvenv.cfg` `home`/`executable` to point at a given
671
+ * base directory (the app execution alias directory for the Microsoft Store
672
+ * Python). Pure helper so the transformation is testable cross-platform.
673
+ */
674
+ export function rewriteVenvConfig(cfg: string, homeDir: string): string {
675
+ return cfg
676
+ .replace(/^home = .*$/m, `home = ${homeDir}`)
677
+ .replace(/^executable = .*$/m, `executable = ${homeDir}\\python.exe`)
678
+ }
679
+
680
+ /**
681
+ * Build the Microsoft Store probe environment while preserving Python-variable
682
+ * tombstones; only the user-directory variables must fall back to the host.
683
+ */
684
+ export function storePythonProbeEnvironment(installEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
685
+ return Object.fromEntries(
686
+ Object.entries(installEnv).filter(([key]) => key !== 'HOME' && key !== 'USERPROFILE' && key !== 'LOCALAPPDATA'),
687
+ )
688
+ }
689
+
690
+ /**
691
+ * Windows-only workaround for the Microsoft Store Python. `python -m venv`
692
+ * records `home = C:\Program Files\WindowsApps\...` in the new venv, but the
693
+ * venv launcher (venvlauncher.exe) cannot CreateProcess that `python.exe`
694
+ * directly — the AppModel package execution restriction denies it (error 5) —
695
+ * so the venv's pip bootstrap exits 101. Rewrite the staged venv's pyvenv.cfg
696
+ * to the app execution alias directory, whose `python.exe` is launchable
697
+ * through Store activation.
698
+ */
699
+ async function rewriteStorePythonVenvHome(
700
+ ctx: Context,
701
+ bootstrap: { command: RuntimeCommand },
702
+ staging: string,
703
+ installEnv: NodeJS.ProcessEnv,
704
+ cwd: string,
705
+ ): Promise<void> {
706
+ if (process.platform !== 'win32') return
707
+ // The HOME/USERPROFILE/LOCALAPPDATA overrides make the Store alias resolve to
708
+ // the real Program Files\WindowsApps path; without them sys.executable points
709
+ // back at the alias directory that venvlauncher can launch.
710
+ const probeEnv = storePythonProbeEnvironment(installEnv)
711
+ const probe = await runCollected(
712
+ ctx,
713
+ [bootstrap.command.program, ...bootstrap.command.prefix, '-c', 'import os,sys; print(os.path.dirname(sys.executable))'],
714
+ cwd,
715
+ { env: probeEnv },
716
+ )
717
+ if (probe.exitCode !== 0 || probe.timedOut) return
718
+ const aliasDir = probe.stdout.trim().split(/\r?\n/)[0]
719
+ if (aliasDir === undefined || aliasDir.length === 0) return
720
+ const cfgPath = join(staging, 'pyvenv.cfg')
721
+ let cfg: string
722
+ try {
723
+ cfg = await readFile(cfgPath, 'utf8')
724
+ } catch {
725
+ return
726
+ }
727
+ // Only touch the known-broken layout; other interpreters are left untouched.
728
+ if (!/^home = .*Program Files\\WindowsApps.*$/m.test(cfg)) return
729
+ await writeFile(cfgPath, rewriteVenvConfig(cfg, aliasDir))
730
+ }
731
+
732
+ async function dependencyVersions(
733
+ ctx: Context,
734
+ python: RuntimeCommand,
735
+ cwd: string,
736
+ ): Promise<Record<string, string>> {
737
+ const script = [
738
+ 'import json',
739
+ 'from importlib.metadata import version',
740
+ 'import PIL',
741
+ 'import numpy',
742
+ 'import vtracer',
743
+ 'print(json.dumps({"pillow":version("pillow"),"numpy":version("numpy"),"vtracer":version("vtracer")}))',
744
+ ].join(';')
745
+ let result: CommandResult
746
+ try {
747
+ result = await runCollected(ctx, [python.program, ...python.prefix, '-c', script], cwd, {
748
+ env: isolatedPythonEnvironment(cwd),
749
+ })
750
+ } catch (error) {
751
+ throw new VisionToolkitError('runtime', `failed to start ${displayCommand(python)}`, { cause: error })
752
+ }
753
+ if (result.exitCode !== 0 || result.timedOut) {
754
+ throw new VisionToolkitError(
755
+ 'runtime',
756
+ `vision-toolkit Python dependencies are unavailable in ${displayCommand(python)}: ${result.stderr.trim() || 'probe failed'}`,
757
+ )
758
+ }
759
+ try {
760
+ const parsed = JSON.parse(result.stdout) as Record<string, unknown>
761
+ if (Object.values(parsed).some(value => typeof value !== 'string')) throw new Error('non-string dependency version')
762
+ return parsed as Record<string, string>
763
+ } catch (error) {
764
+ throw new VisionToolkitError('runtime', 'vision-toolkit dependency probe returned invalid JSON', { cause: error })
765
+ }
766
+ }
767
+
768
+ function parseLockedDependencies(requirements: Buffer): Record<string, string> {
769
+ const dependencies: Record<string, string> = {}
770
+ for (const line of requirements.toString('utf8').split(/\r?\n/)) {
771
+ const trimmed = line.trim()
772
+ if (trimmed.length === 0 || trimmed.startsWith('#')) continue
773
+ const match = /^([A-Za-z0-9_.-]+)==([^\s]+)$/.exec(trimmed)
774
+ if (match === null) {
775
+ throw new VisionToolkitError('runtime', `runtime/requirements.lock contains an unsupported entry: ${trimmed}`)
776
+ }
777
+ dependencies[(match[1] ?? '').toLowerCase()] = match[2] ?? ''
778
+ }
779
+ if (Object.keys(dependencies).length === 0) {
780
+ throw new VisionToolkitError('runtime', 'runtime/requirements.lock contains no dependencies')
781
+ }
782
+ return dependencies
783
+ }
784
+
785
+ function assertLockedDependencies(actual: Record<string, string>, expected: Record<string, string>): void {
786
+ for (const [name, version] of Object.entries(expected)) {
787
+ if (actual[name] !== version) {
788
+ throw new VisionToolkitError(
789
+ 'runtime',
790
+ `vision-toolkit Python dependency ${name} must be ${version}, received ${actual[name] ?? 'missing'}`,
791
+ )
792
+ }
793
+ }
794
+ }
795
+
796
+ async function readRuntimeMarker(path: string): Promise<RuntimeMarker | undefined> {
797
+ try {
798
+ const parsed = JSON.parse(await readFile(path, 'utf8')) as Partial<RuntimeMarker>
799
+ if (
800
+ parsed.schemaVersion !== 1
801
+ || parsed.upstreamCommit !== UPSTREAM_COMMIT
802
+ || typeof parsed.upstreamContentSha256 !== 'string'
803
+ || typeof parsed.requirementsSha256 !== 'string'
804
+ || typeof parsed.pythonVersion !== 'string'
805
+ || typeof parsed.dependencies !== 'object'
806
+ || parsed.dependencies === null
807
+ || (parsed.manager !== 'uv' && parsed.manager !== 'venv-pip')
808
+ ) return undefined
809
+ return parsed as RuntimeMarker
810
+ } catch {
811
+ return undefined
812
+ }
813
+ }
814
+
815
+ async function waitForManagedRuntime(
816
+ markerPath: string,
817
+ lockPath: string,
818
+ expected: Pick<RuntimeMarker, 'upstreamContentSha256' | 'requirementsSha256'>,
819
+ ): Promise<RuntimeMarker | undefined> {
820
+ const started = Date.now()
821
+ while (Date.now() - started < PREPARE_TIMEOUT_MS) {
822
+ const marker = await readRuntimeMarker(markerPath)
823
+ if (
824
+ marker?.upstreamContentSha256 === expected.upstreamContentSha256
825
+ && marker.requirementsSha256 === expected.requirementsSha256
826
+ ) return marker
827
+ try {
828
+ const info = await stat(lockPath)
829
+ if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
830
+ await rm(lockPath, { recursive: true, force: true })
831
+ return undefined
832
+ }
833
+ } catch {
834
+ return undefined
835
+ }
836
+ await new Promise(resolveWait => setTimeout(resolveWait, 250))
837
+ }
838
+ throw new VisionToolkitError('runtime', 'timed out waiting for another process to prepare the managed vision runtime')
839
+ }
840
+
841
+ async function releaseManagedLock(lockPath: string, owner: string): Promise<void> {
842
+ try {
843
+ if ((await readFile(join(lockPath, 'owner'), 'utf8')).trim() !== owner) return
844
+ } catch {
845
+ return
846
+ }
847
+ await rm(lockPath, { recursive: true, force: true })
848
+ }
849
+
850
+ async function prepareManaged(
851
+ ctx: Context,
852
+ config: ResolvedVisionToolkitConfig,
853
+ manifest: UpstreamManifest,
854
+ ): Promise<PreparedUpstreamRuntime> {
855
+ const stateRoot = visionToolkitStateRoot()
856
+ await mkdir(stateRoot, { recursive: true })
857
+ const cleanHome = join(stateRoot, 'home')
858
+ await mkdir(cleanHome, { recursive: true })
859
+ const bootstrap = await resolveBootstrapPython(ctx, config.runtime.python, cleanHome)
860
+ const requirements = await readFile(REQUIREMENTS_PATH)
861
+ const requirementsSha256 = sha256(requirements)
862
+ const expectedDependencies = parseLockedDependencies(requirements)
863
+ const runtimeId = [
864
+ manifest.contentSha256.slice(0, 16),
865
+ requirementsSha256.slice(0, 16),
866
+ `py${String(bootstrap.major)}${String(bootstrap.minor)}`,
867
+ process.platform,
868
+ process.arch,
869
+ ].join('-')
870
+ const finalRoot = join(stateRoot, 'python', runtimeId)
871
+ const parent = dirname(finalRoot)
872
+ await mkdir(parent, { recursive: true })
873
+ const markerPath = join(finalRoot, 'runtime.json')
874
+ const existing = await readRuntimeMarker(markerPath)
875
+ const interpreter = venvPython(finalRoot)
876
+ if (
877
+ existing?.upstreamContentSha256 === manifest.contentSha256
878
+ && existing.requirementsSha256 === requirementsSha256
879
+ ) {
880
+ const python: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
881
+ const metadata = await pythonMetadata(ctx, python, cleanHome)
882
+ if (metadata !== undefined) {
883
+ try {
884
+ const dependencies = await dependencyVersions(ctx, python, cleanHome)
885
+ assertLockedDependencies(dependencies, expectedDependencies)
886
+ return { source: 'managed', root: BUNDLED_ROOT, python, cleanHome, pythonVersion: metadata.version, dependencies }
887
+ } catch {
888
+ // A stale/corrupt environment is rebuilt below without disturbing it until the replacement is ready.
889
+ }
890
+ }
891
+ }
892
+
893
+ const lockPath = `${finalRoot}.lock`
894
+ const lockOwner = randomUUID()
895
+ let lockAcquired = false
896
+ try {
897
+ await mkdir(lockPath, { recursive: false })
898
+ lockAcquired = true
899
+ await writeFile(join(lockPath, 'owner'), `${lockOwner}\n`, { flag: 'wx' })
900
+ } catch (error) {
901
+ if (lockAcquired) {
902
+ await rm(lockPath, { recursive: true, force: true })
903
+ throw error
904
+ }
905
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
906
+ const completed = await waitForManagedRuntime(markerPath, lockPath, {
907
+ upstreamContentSha256: manifest.contentSha256,
908
+ requirementsSha256,
909
+ })
910
+ if (completed !== undefined) {
911
+ const python: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
912
+ const metadata = await pythonMetadata(ctx, python, cleanHome)
913
+ if (metadata !== undefined) {
914
+ try {
915
+ const dependencies = await dependencyVersions(ctx, python, cleanHome)
916
+ assertLockedDependencies(dependencies, expectedDependencies)
917
+ return { source: 'managed', root: BUNDLED_ROOT, python, cleanHome, pythonVersion: metadata.version, dependencies }
918
+ } catch {
919
+ // The completed marker is unusable; reacquire the lock and rebuild it.
920
+ }
921
+ }
922
+ }
923
+ return prepareManaged(ctx, config, manifest)
924
+ }
925
+
926
+ const staging = await mkdtemp(join(parent, '.prepare-'))
927
+ const installEnv: NodeJS.ProcessEnv = {
928
+ ...isolatedPythonEnvironment(cleanHome),
929
+ UV_CACHE_DIR: join(stateRoot, 'uv-cache'),
930
+ }
931
+ const heartbeat = setInterval(() => {
932
+ const now = new Date()
933
+ void utimes(lockPath, now, now).catch(() => {})
934
+ }, LOCK_HEARTBEAT_MS)
935
+ heartbeat.unref()
936
+ try {
937
+ let manager: RuntimeMarker['manager'] = 'venv-pip'
938
+ let created = false
939
+ if (bootstrap.command.prefix.length === 0) {
940
+ try {
941
+ const uv = await runCollected(ctx, ['uv', '--version'], stateRoot, { env: installEnv })
942
+ if (uv.exitCode === 0 && !uv.timedOut) {
943
+ const executableEnv = Object.fromEntries(
944
+ Object.entries(installEnv).filter((entry): entry is [string, string] => entry[1] !== undefined),
945
+ )
946
+ const interpreter = await ctx.subprocess.resolveExecutable(bootstrap.command.program, executableEnv)
947
+ const create = await runCollected(
948
+ ctx,
949
+ ['uv', 'venv', '--python', interpreter, staging],
950
+ stateRoot,
951
+ { timeoutMs: PREPARE_TIMEOUT_MS, env: installEnv },
952
+ )
953
+ if (create.exitCode !== 0 || create.timedOut) {
954
+ throw new VisionToolkitError('runtime', `uv failed to create the managed runtime: ${create.stderr.trim()}`)
955
+ }
956
+ await installDependenciesWithFallback(
957
+ ctx,
958
+ ['uv', 'pip', 'install', '--python', venvPython(staging), '--requirement', REQUIREMENTS_PATH],
959
+ stateRoot,
960
+ installEnv,
961
+ 'uv',
962
+ )
963
+ manager = 'uv'
964
+ created = true
965
+ }
966
+ } catch (error) {
967
+ if (error instanceof VisionToolkitError) throw error
968
+ }
969
+ }
970
+ if (!created) {
971
+ const create = await runCollected(
972
+ ctx,
973
+ [bootstrap.command.program, ...bootstrap.command.prefix, '-m', 'venv', '--without-pip', staging],
974
+ stateRoot,
975
+ { timeoutMs: PREPARE_TIMEOUT_MS, env: installEnv },
976
+ )
977
+ if (create.exitCode !== 0 || create.timedOut) {
978
+ throw new VisionToolkitError('runtime', `Python failed to create the managed runtime: ${create.stderr.trim()}`)
979
+ }
980
+ // `python -m venv` normally bootstraps pip internally; with the Microsoft
981
+ // Store Python that step exits 101 (see rewriteStorePythonVenvHome), so pip
982
+ // is installed explicitly after the venv configuration is repaired.
983
+ await rewriteStorePythonVenvHome(ctx, bootstrap, staging, installEnv, stateRoot)
984
+ const pipBootstrap = await runCollected(
985
+ ctx,
986
+ [venvPython(staging), '-m', 'ensurepip', '--upgrade', '--default-pip'],
987
+ stateRoot,
988
+ { timeoutMs: PREPARE_TIMEOUT_MS, env: installEnv },
989
+ )
990
+ if (pipBootstrap.exitCode !== 0 || pipBootstrap.timedOut) {
991
+ throw new VisionToolkitError('runtime', `Python failed to bootstrap pip in the managed runtime: ${pipBootstrap.stderr.trim()}`)
992
+ }
993
+ await installDependenciesWithFallback(
994
+ ctx,
995
+ [venvPython(staging), '-m', 'pip', 'install', '--disable-pip-version-check', '--no-input', '-r', REQUIREMENTS_PATH],
996
+ stateRoot,
997
+ installEnv,
998
+ 'pip',
999
+ )
1000
+ }
1001
+ const stagedPython: RuntimeCommand = { program: venvPython(staging), prefix: [], display: venvPython(staging) }
1002
+ const metadata = await pythonMetadata(ctx, stagedPython, cleanHome)
1003
+ if (metadata === undefined) throw new VisionToolkitError('runtime', 'managed Python runtime did not start after installation')
1004
+ const dependencies = await dependencyVersions(ctx, stagedPython, cleanHome)
1005
+ assertLockedDependencies(dependencies, expectedDependencies)
1006
+ const marker: RuntimeMarker = {
1007
+ schemaVersion: 1,
1008
+ upstreamCommit: UPSTREAM_COMMIT,
1009
+ upstreamContentSha256: manifest.contentSha256,
1010
+ requirementsSha256,
1011
+ pythonVersion: metadata.version,
1012
+ dependencies,
1013
+ manager,
1014
+ }
1015
+ await writeFile(join(staging, 'runtime.json'), `${JSON.stringify(marker, null, 2)}\n`)
1016
+ const quarantine = `${finalRoot}.replaced-${randomUUID()}`
1017
+ let quarantined = false
1018
+ try {
1019
+ await rename(finalRoot, quarantine)
1020
+ quarantined = true
1021
+ } catch (error) {
1022
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
1023
+ }
1024
+ try {
1025
+ await rename(staging, finalRoot)
1026
+ } catch (error) {
1027
+ if (quarantined) {
1028
+ try {
1029
+ await rename(quarantine, finalRoot)
1030
+ } catch (restoreError) {
1031
+ throw new VisionToolkitError(
1032
+ 'runtime',
1033
+ `managed runtime replacement failed and the prior runtime could not be restored; recovery copy: ${quarantine}`,
1034
+ { cause: new AggregateError([error, restoreError]) },
1035
+ )
1036
+ }
1037
+ }
1038
+ throw error
1039
+ }
1040
+ await rm(quarantine, { recursive: true, force: true })
1041
+ const python: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
1042
+ return { source: 'managed', root: BUNDLED_ROOT, python, cleanHome, pythonVersion: metadata.version, dependencies }
1043
+ } finally {
1044
+ clearInterval(heartbeat)
1045
+ await rm(staging, { recursive: true, force: true })
1046
+ await releaseManagedLock(lockPath, lockOwner)
1047
+ }
1048
+ }
1049
+
1050
+ async function snapshotFiles(root: string): Promise<string[]> {
1051
+ const result: string[] = []
1052
+ async function visit(directory: string, prefix: string): Promise<void> {
1053
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
1054
+ const relativePath = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`
1055
+ if (entry.isDirectory()) await visit(join(directory, entry.name), relativePath)
1056
+ else if (entry.isFile()) result.push(relativePath)
1057
+ else throw new VisionToolkitError('runtime', `external snapshot contains a non-regular entry: ${relativePath}`)
1058
+ }
1059
+ }
1060
+ await visit(root, '')
1061
+ return result.sort()
1062
+ }
1063
+
1064
+ async function externalMatchesBundledSnapshot(root: string, expected: UpstreamManifest): Promise<boolean> {
1065
+ const path = join(root, 'UPSTREAM_MANIFEST.json')
1066
+ try {
1067
+ const manifest = await readManifest(path)
1068
+ if (
1069
+ manifest.contentSha256 !== expected.contentSha256
1070
+ || JSON.stringify(manifest.files) !== JSON.stringify(expected.files)
1071
+ ) return false
1072
+ const expectedFiles = [...expected.files.map(entry => entry.path), 'UPSTREAM_MANIFEST.json'].sort()
1073
+ if (JSON.stringify(await snapshotFiles(root)) !== JSON.stringify(expectedFiles)) return false
1074
+ for (const entry of expected.files) {
1075
+ const target = join(root, ...entry.path.split('/'))
1076
+ const info = await lstat(target)
1077
+ if (!info.isFile() || info.isSymbolicLink()) return false
1078
+ const bytes = await readFile(target)
1079
+ if (bytes.length !== entry.bytes || sha256(bytes) !== entry.sha256) return false
1080
+ }
1081
+ return true
1082
+ } catch {
1083
+ return false
1084
+ }
1085
+ }
1086
+
1087
+ async function verifyExternalCheckout(ctx: Context, root: string, expected: UpstreamManifest): Promise<void> {
1088
+ const exactSnapshot = await externalMatchesBundledSnapshot(root, expected)
1089
+ if (exactSnapshot) return
1090
+ let head: CommandResult
1091
+ try {
1092
+ head = await runCollected(ctx, ['git', '-C', root, 'rev-parse', 'HEAD'], root)
1093
+ } catch (error) {
1094
+ throw new VisionToolkitError(
1095
+ 'runtime',
1096
+ `external agent-vision-toolkit must be the clean pinned commit ${UPSTREAM_COMMIT} or an exact exported snapshot`,
1097
+ { cause: error },
1098
+ )
1099
+ }
1100
+ if (head.exitCode !== 0 || head.stdout.trim() !== UPSTREAM_COMMIT) {
1101
+ throw new VisionToolkitError('runtime', `external agent-vision-toolkit must be pinned at commit ${UPSTREAM_COMMIT}`)
1102
+ }
1103
+ const topLevel = await runCollected(ctx, ['git', '-C', root, 'rev-parse', '--show-toplevel'], root)
1104
+ let resolvedTopLevel: string | undefined
1105
+ try {
1106
+ resolvedTopLevel = topLevel.exitCode === 0 ? await realpath(topLevel.stdout.trim()) : undefined
1107
+ } catch {
1108
+ resolvedTopLevel = undefined
1109
+ }
1110
+ if (resolvedTopLevel !== root) {
1111
+ throw new VisionToolkitError('runtime', 'external agent-vision-toolkit path must be the checkout root')
1112
+ }
1113
+ const statusResult = await runCollected(ctx, ['git', '-C', root, 'status', '--porcelain=v1', '--untracked-files=all'], root)
1114
+ if (statusResult.exitCode !== 0 || statusResult.stdout.trim().length > 0) {
1115
+ throw new VisionToolkitError('runtime', 'external agent-vision-toolkit checkout has modified tracked files; use managed mode or a clean pinned checkout')
1116
+ }
1117
+ }
1118
+
1119
+ async function prepareExternal(
1120
+ ctx: Context,
1121
+ config: ResolvedVisionToolkitConfig,
1122
+ manifest: UpstreamManifest,
1123
+ ): Promise<PreparedUpstreamRuntime> {
1124
+ const configured = config.runtime.agentVisionToolkitPath
1125
+ if (configured === undefined) {
1126
+ throw new VisionToolkitError('config', 'runtime.agentVisionToolkitPath is required when runtime.mode is external')
1127
+ }
1128
+ let root: string
1129
+ try {
1130
+ root = await realpath(expandHome(configured))
1131
+ } catch (error) {
1132
+ throw new VisionToolkitError('runtime', `external agent-vision-toolkit checkout is not accessible: ${configured}`, { cause: error })
1133
+ }
1134
+ await verifyExternalCheckout(ctx, root, manifest)
1135
+ const stateRoot = visionToolkitStateRoot()
1136
+ await mkdir(stateRoot, { recursive: true })
1137
+ const cleanHome = join(stateRoot, 'home')
1138
+ await mkdir(cleanHome, { recursive: true })
1139
+ const bootstrap = await resolveBootstrapPython(ctx, config.runtime.python, cleanHome)
1140
+ const dependencies = await dependencyVersions(ctx, bootstrap.command, cleanHome)
1141
+ assertLockedDependencies(dependencies, parseLockedDependencies(await readFile(REQUIREMENTS_PATH)))
1142
+ return {
1143
+ source: 'external',
1144
+ root,
1145
+ python: bootstrap.command,
1146
+ cleanHome,
1147
+ pythonVersion: bootstrap.version,
1148
+ dependencies,
1149
+ }
1150
+ }
1151
+
1152
+ /** Prepare the configured pinned runtime without making any vision API call. */
1153
+ export async function prepareUpstreamRuntime(
1154
+ ctx: Context,
1155
+ config: ResolvedVisionToolkitConfig,
1156
+ ): Promise<PreparedUpstreamRuntime> {
1157
+ const manifest = await verifyBundledUpstream()
1158
+ return config.runtime.mode === 'managed'
1159
+ ? prepareManaged(ctx, config, manifest)
1160
+ : prepareExternal(ctx, config, manifest)
1161
+ }