@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.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +356 -0
- package/README.zh.md +354 -0
- package/assets/community-group-qr.png +0 -0
- package/assets/dsh-conversation-artifact.png +0 -0
- package/assets/dsh-conversation-image-qa-top.png +0 -0
- package/assets/dsh-conversation-image-qa.png +0 -0
- package/assets/dsh-conversation-pixel-diff.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging.png +0 -0
- package/assets/dsh-conversation-tool-call.png +0 -0
- package/assets/dsh-conversation-vision-trace.png +0 -0
- package/assets/dsh-view-example.png +0 -0
- package/assets/hero-v2.png +0 -0
- package/assets/logo_aihubmix.png +0 -0
- package/assets/python-bootstrap.json +48 -0
- package/assets/skill/SKILL.md +329 -0
- package/assets/skill/UPSTREAM.json +71 -0
- package/assets/skill/references/gui.md +88 -0
- package/assets/skill/references/long-screenshot-ocr.md +77 -0
- package/assets/skill/references/restore-graphic.md +84 -0
- package/assets/skill/references/restore-structure.md +45 -0
- package/assets/skill/references/restore-ui.md +202 -0
- package/assets/social-preview.png +0 -0
- package/assets/upstream/README.md +18 -0
- package/assets/upstream/focus-hint-comparison-1.webp +0 -0
- package/assets/upstream/focus-hint-comparison-2.webp +0 -0
- package/assets/upstream/infographic-reference.webp +0 -0
- package/assets/upstream/infographic-result.webp +0 -0
- package/assets/upstream/ui-fast-restore-reference.webp +0 -0
- package/assets/upstream/ui-fast-restore-result.webp +0 -0
- package/assets/upstream/ui-result.webp +0 -0
- package/assets/upstream/ui-sketch.webp +0 -0
- package/assets/vision-model-test.png +0 -0
- package/assets/vision-settings.png +0 -0
- package/assets/wechat-reward.png +0 -0
- package/cordis.patch.yml +6 -0
- package/docs/aihubmix-gemini-vision.i18n.yaml +6 -0
- package/docs/aihubmix-gemini-vision.md +138 -0
- package/docs/aihubmix-gemini-vision.zh.md +138 -0
- package/docs/assets/aihubmix-api-keys.png +0 -0
- package/docs/assets/aihubmix-create-key.png +0 -0
- package/docs/assets/aihubmix-free-vision-model.png +0 -0
- package/docs/assets/aihubmix-home.png +0 -0
- package/docs/assets/aihubmix-sign-up.png +0 -0
- package/docs/assets/groq-console-home.png +0 -0
- package/docs/assets/groq-console-keys-login.png +0 -0
- package/docs/assets/groq-docs-quickstart.png +0 -0
- package/docs/assets/groq-docs-qwen3.6.png +0 -0
- package/docs/assets/groq-docs-vision.png +0 -0
- package/docs/assets/vision-settings.png +0 -0
- package/docs/dsh-desktop-install.md +67 -0
- package/docs/dsh-desktop-install.zh.md +67 -0
- package/docs/groq-qwen3.6-vision.i18n.yaml +6 -0
- package/docs/groq-qwen3.6-vision.md +236 -0
- package/docs/groq-qwen3.6-vision.zh.md +236 -0
- package/docs/python-runtime.i18n.yaml +6 -0
- package/docs/python-runtime.md +89 -0
- package/docs/python-runtime.zh.md +89 -0
- package/docs/requirements-traceability/README.i18n.yaml +6 -0
- package/docs/requirements-traceability/README.md +75 -0
- package/docs/requirements-traceability/README.zh.md +75 -0
- package/examples/ui-restoration/README.i18n.yaml +6 -0
- package/examples/ui-restoration/README.md +70 -0
- package/examples/ui-restoration/README.zh.md +70 -0
- package/examples/ui-restoration/assets/final-heatmap.png +0 -0
- package/examples/ui-restoration/assets/final-report.json +83 -0
- package/examples/ui-restoration/assets/implementation.png +0 -0
- package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
- package/examples/ui-restoration/assets/initial-report.json +83 -0
- package/examples/ui-restoration/assets/initial.png +0 -0
- package/examples/ui-restoration/assets/metrics.json +12 -0
- package/examples/ui-restoration/assets/reference.png +0 -0
- package/examples/ui-restoration/implementation.html +94 -0
- package/examples/ui-restoration/initial.html +57 -0
- package/lib/artifact-access.js +369 -0
- package/lib/artifact-access.js.map +1 -0
- package/lib/artifacts.js +56 -0
- package/lib/artifacts.js.map +1 -0
- package/lib/client.js +2099 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +172 -0
- package/lib/config.js.map +1 -0
- package/lib/defaults.js +6 -0
- package/lib/defaults.js.map +1 -0
- package/lib/errors.js +56 -0
- package/lib/errors.js.map +1 -0
- package/lib/evidence-cache.js +350 -0
- package/lib/evidence-cache.js.map +1 -0
- package/lib/exposure.js +247 -0
- package/lib/exposure.js.map +1 -0
- package/lib/image-input-variants.js +882 -0
- package/lib/image-input-variants.js.map +1 -0
- package/lib/index.js +105 -0
- package/lib/index.js.map +1 -0
- package/lib/paste-images.js +217 -0
- package/lib/paste-images.js.map +1 -0
- package/lib/paths.js +354 -0
- package/lib/paths.js.map +1 -0
- package/lib/plugin-update.js +1003 -0
- package/lib/plugin-update.js.map +1 -0
- package/lib/runtime-install.js +964 -0
- package/lib/runtime-install.js.map +1 -0
- package/lib/runtime-manager.js +133 -0
- package/lib/runtime-manager.js.map +1 -0
- package/lib/runtime.js +1678 -0
- package/lib/runtime.js.map +1 -0
- package/lib/skill.js +25 -0
- package/lib/skill.js.map +1 -0
- package/lib/tools.js +549 -0
- package/lib/tools.js.map +1 -0
- package/lib/types/artifact-access.d.ts +61 -0
- package/lib/types/artifact-access.d.ts.map +1 -0
- package/lib/types/artifacts.d.ts +42 -0
- package/lib/types/artifacts.d.ts.map +1 -0
- package/lib/types/client/display-config.d.ts +24 -0
- package/lib/types/client/display-config.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +350 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/model-variants-hider.d.ts +40 -0
- package/lib/types/client/model-variants-hider.d.ts.map +1 -0
- package/lib/types/client/paste-images.d.ts +126 -0
- package/lib/types/client/paste-images.d.ts.map +1 -0
- package/lib/types/config.d.ts +123 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/defaults.d.ts +6 -0
- package/lib/types/defaults.d.ts.map +1 -0
- package/lib/types/errors.d.ts +35 -0
- package/lib/types/errors.d.ts.map +1 -0
- package/lib/types/evidence-cache.d.ts +91 -0
- package/lib/types/evidence-cache.d.ts.map +1 -0
- package/lib/types/exposure.d.ts +50 -0
- package/lib/types/exposure.d.ts.map +1 -0
- package/lib/types/image-input-variants.d.ts +144 -0
- package/lib/types/image-input-variants.d.ts.map +1 -0
- package/lib/types/index.d.ts +19 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/paste-images.d.ts +73 -0
- package/lib/types/paste-images.d.ts.map +1 -0
- package/lib/types/paths.d.ts +117 -0
- package/lib/types/paths.d.ts.map +1 -0
- package/lib/types/plugin-update.d.ts +119 -0
- package/lib/types/plugin-update.d.ts.map +1 -0
- package/lib/types/runtime-install.d.ts +92 -0
- package/lib/types/runtime-install.d.ts.map +1 -0
- package/lib/types/runtime-manager.d.ts +60 -0
- package/lib/types/runtime-manager.d.ts.map +1 -0
- package/lib/types/runtime.d.ts +412 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/skill.d.ts +14 -0
- package/lib/types/skill.d.ts.map +1 -0
- package/lib/types/tools.d.ts +35 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/upstream.d.ts +233 -0
- package/lib/types/upstream.d.ts.map +1 -0
- package/lib/types/version.d.ts +15 -0
- package/lib/types/version.d.ts.map +1 -0
- package/lib/types/web-request.d.ts +11 -0
- package/lib/types/web-request.d.ts.map +1 -0
- package/lib/types/web.d.ts +118 -0
- package/lib/types/web.d.ts.map +1 -0
- package/lib/upstream.js +860 -0
- package/lib/upstream.js.map +1 -0
- package/lib/version.js +18 -0
- package/lib/version.js.map +1 -0
- package/lib/web-request.js +29 -0
- package/lib/web-request.js.map +1 -0
- package/lib/web.js +462 -0
- package/lib/web.js.map +1 -0
- package/package.json +149 -0
- package/patches/vision-tools-dsh.patch +970 -0
- package/runtime/requirements.lock +3 -0
- package/src/artifact-access.ts +386 -0
- package/src/artifacts.ts +85 -0
- package/src/client/display-config.ts +62 -0
- package/src/client/index.tsx +1580 -0
- package/src/client/model-variants-hider.ts +159 -0
- package/src/client/paste-images.tsx +747 -0
- package/src/config.ts +290 -0
- package/src/defaults.ts +5 -0
- package/src/errors.ts +62 -0
- package/src/evidence-cache.ts +418 -0
- package/src/exposure.ts +265 -0
- package/src/image-input-variants.ts +996 -0
- package/src/index.ts +141 -0
- package/src/paste-images.ts +286 -0
- package/src/paths.ts +383 -0
- package/src/plugin-update.ts +1143 -0
- package/src/runtime-install.ts +1161 -0
- package/src/runtime-manager.ts +174 -0
- package/src/runtime.ts +2189 -0
- package/src/skill.ts +35 -0
- package/src/tools.ts +691 -0
- package/src/upstream.ts +1078 -0
- package/src/version.ts +37 -0
- package/src/web-request.ts +27 -0
- package/src/web.ts +594 -0
- package/vendor/agent-vision-toolkit/CHANGELOG.md +30 -0
- package/vendor/agent-vision-toolkit/LICENSE +21 -0
- package/vendor/agent-vision-toolkit/README.md +405 -0
- package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +94 -0
- package/vendor/agent-vision-toolkit/bin/crop +90 -0
- package/vendor/agent-vision-toolkit/bin/detect +13 -0
- package/vendor/agent-vision-toolkit/bin/glance +93 -0
- package/vendor/agent-vision-toolkit/bin/ground +13 -0
- package/vendor/agent-vision-toolkit/bin/trace +129 -0
- package/vendor/agent-vision-toolkit/detect.py +60 -0
- package/vendor/agent-vision-toolkit/ground.py +267 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +420 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
- package/vendor/agent-vision-toolkit/tests/test_vision_client.py +450 -0
- 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
|
+
}
|