@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
package/src/paths.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path fence shared by every tool: inputs must live in the workspace, the
|
|
3
|
+
* platform temporary directory, or an explicitly authorized directory;
|
|
4
|
+
* outputs stay inside the plugin-managed output directory, and a symbolic
|
|
5
|
+
* link is allowed only when its real target stays inside the fence.
|
|
6
|
+
* @module dsh-vision-toolkit/paths
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from 'node:crypto'
|
|
10
|
+
import { cp, link, lstat, mkdir, readdir, realpath, rename, rm, stat } from 'node:fs/promises'
|
|
11
|
+
import { extname, isAbsolute, join, relative, resolve, sep, win32 } from 'node:path'
|
|
12
|
+
import { homedir, tmpdir } from 'node:os'
|
|
13
|
+
import { VisionToolkitError } from './errors.ts'
|
|
14
|
+
|
|
15
|
+
/** Supported input image extensions (the upstream client's allowlist). */
|
|
16
|
+
export const SUPPORTED_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp'] as const
|
|
17
|
+
|
|
18
|
+
/** Resolved path policy for one tool invocation. */
|
|
19
|
+
export interface PathPolicy {
|
|
20
|
+
/** Real workspace root. */
|
|
21
|
+
workspace: string
|
|
22
|
+
/** Real platform temporary directory, authorized for transient inputs. */
|
|
23
|
+
tempDir: string
|
|
24
|
+
/** Real allowed roots: workspace, platform temp, and configured directories. */
|
|
25
|
+
allowedDirs: string[]
|
|
26
|
+
/** Real plugin-managed output directory inside the fence. */
|
|
27
|
+
outputDir: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether `child` equals or lies under `parent` on the same path root. */
|
|
31
|
+
export function isWithin(parent: string, child: string): boolean {
|
|
32
|
+
const rel = relative(parent, child)
|
|
33
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function expandUserHome(raw: string): string {
|
|
37
|
+
if (raw === '~') return homedir()
|
|
38
|
+
if (raw.startsWith('~/') || raw.startsWith(`~${sep}`)) return join(homedir(), raw.slice(2))
|
|
39
|
+
return raw
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Current platform temporary directory before realpath canonicalization. */
|
|
43
|
+
export function platformTempDirectory(
|
|
44
|
+
platform: NodeJS.Platform = process.platform,
|
|
45
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
46
|
+
): string {
|
|
47
|
+
if (platform !== 'win32') return '/tmp'
|
|
48
|
+
const configured = environment.TEMP?.trim() || environment.TMP?.trim()
|
|
49
|
+
return configured === undefined || configured.length === 0 ? tmpdir() : configured
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Translate the POSIX-shaped `/tmp/...` paths commonly emitted by models to
|
|
54
|
+
* the actual Windows temporary directory. Other paths and platforms are left
|
|
55
|
+
* unchanged, and the normal realpath fence still validates the result.
|
|
56
|
+
*/
|
|
57
|
+
export function normalizePlatformTempPath(
|
|
58
|
+
raw: string,
|
|
59
|
+
platform: NodeJS.Platform = process.platform,
|
|
60
|
+
tempDirectory: string = platformTempDirectory(platform),
|
|
61
|
+
): string {
|
|
62
|
+
if (platform !== 'win32') return raw
|
|
63
|
+
if (raw === '/tmp') return tempDirectory
|
|
64
|
+
if (!raw.startsWith('/tmp/')) return raw
|
|
65
|
+
return win32.join(tempDirectory, raw.slice('/tmp/'.length))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build the per-invocation path policy: realpath the workspace, resolve and
|
|
70
|
+
* realpath the platform temp directory and allowed directories, and create
|
|
71
|
+
* the output directory inside the fence.
|
|
72
|
+
* @param workspaceRaw - session workspace (or process cwd fallback).
|
|
73
|
+
* @param allowedDirs - configured extra allowed roots.
|
|
74
|
+
* @param outputDirRaw - configured output directory (default `.dsh-vision-toolkit/artifacts`).
|
|
75
|
+
* @returns the resolved policy.
|
|
76
|
+
*/
|
|
77
|
+
export async function createPathPolicy(
|
|
78
|
+
workspaceRaw: string,
|
|
79
|
+
allowedDirs: readonly string[],
|
|
80
|
+
outputDirRaw?: string,
|
|
81
|
+
): Promise<PathPolicy> {
|
|
82
|
+
let workspace: string
|
|
83
|
+
try {
|
|
84
|
+
workspace = await realpath(expandUserHome(workspaceRaw))
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw new VisionToolkitError('path', `workspace is not accessible: ${workspaceRaw}`, { cause: error })
|
|
87
|
+
}
|
|
88
|
+
let tempDir: string
|
|
89
|
+
const tempDirectoryRaw = platformTempDirectory()
|
|
90
|
+
try {
|
|
91
|
+
tempDir = await realpath(tempDirectoryRaw)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new VisionToolkitError('path', `platform temporary directory is not accessible: ${tempDirectoryRaw}`, { cause: error })
|
|
94
|
+
}
|
|
95
|
+
const roots = [workspace, tempDir]
|
|
96
|
+
for (const raw of allowedDirs) {
|
|
97
|
+
const candidate = expandUserHome(raw)
|
|
98
|
+
const target = isAbsolute(candidate) ? candidate : resolve(workspace, candidate)
|
|
99
|
+
try {
|
|
100
|
+
roots.push(await realpath(target))
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw new VisionToolkitError('path', `allowedDirs entry is not accessible: ${raw}`, { cause: error })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const outputRaw = outputDirRaw === undefined || outputDirRaw.trim().length === 0
|
|
106
|
+
? join(workspace, '.dsh-vision-toolkit', 'artifacts')
|
|
107
|
+
: resolve(workspace, expandUserHome(outputDirRaw))
|
|
108
|
+
if (!roots.some(root => isWithin(root, outputRaw))) {
|
|
109
|
+
throw new VisionToolkitError('path', 'output directory must stay inside the workspace or an allowedDirs entry')
|
|
110
|
+
}
|
|
111
|
+
let outputDir: string
|
|
112
|
+
try {
|
|
113
|
+
await mkdir(outputRaw, { recursive: true })
|
|
114
|
+
outputDir = await realpath(outputRaw)
|
|
115
|
+
} catch (error) {
|
|
116
|
+
throw new VisionToolkitError('path', `output directory is not writable: ${outputRaw}`, { cause: error })
|
|
117
|
+
}
|
|
118
|
+
return { workspace, tempDir, allowedDirs: [...new Set(roots)], outputDir }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Validate one input image path and return its fence-checked absolute path
|
|
123
|
+
* and byte size.
|
|
124
|
+
* @param raw - image path, resolved against the workspace.
|
|
125
|
+
* @param policy - active path fence.
|
|
126
|
+
* @returns absolute path and file size.
|
|
127
|
+
*/
|
|
128
|
+
export async function resolveInputFile(raw: string, policy: PathPolicy): Promise<{ path: string; bytes: number }> {
|
|
129
|
+
return resolveAuthorizedFile(raw, policy, SUPPORTED_IMAGE_EXTENSIONS, 'image')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Validate one authorized regular file against an explicit extension set.
|
|
134
|
+
* Realpath fencing makes local HTML and future non-image inputs follow the
|
|
135
|
+
* same symlink-safe policy as images.
|
|
136
|
+
* @param raw - path resolved against the workspace.
|
|
137
|
+
* @param policy - active path fence.
|
|
138
|
+
* @param extensions - accepted lowercase extensions including the leading dot.
|
|
139
|
+
* @param kind - user-facing noun used in stable errors.
|
|
140
|
+
* @returns absolute real path and file size.
|
|
141
|
+
*/
|
|
142
|
+
export async function resolveAuthorizedFile(
|
|
143
|
+
raw: string,
|
|
144
|
+
policy: PathPolicy,
|
|
145
|
+
extensions: readonly string[],
|
|
146
|
+
kind: string,
|
|
147
|
+
): Promise<{ path: string; bytes: number }> {
|
|
148
|
+
const candidate = expandUserHome(normalizePlatformTempPath(raw, process.platform, policy.tempDir))
|
|
149
|
+
const target = isAbsolute(candidate) ? candidate : resolve(policy.workspace, candidate)
|
|
150
|
+
let real: string
|
|
151
|
+
try {
|
|
152
|
+
real = await realpath(target)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw new VisionToolkitError('input', `${kind} not found: ${raw}`, { cause: error })
|
|
155
|
+
}
|
|
156
|
+
if (!policy.allowedDirs.some(root => isWithin(root, real))) {
|
|
157
|
+
throw new VisionToolkitError('path', `${kind} escapes the allowed directories: ${raw}`)
|
|
158
|
+
}
|
|
159
|
+
let info
|
|
160
|
+
try {
|
|
161
|
+
info = await stat(real)
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw new VisionToolkitError('input', `${kind} is not readable: ${raw}`, { cause: error })
|
|
164
|
+
}
|
|
165
|
+
if (!info.isFile()) throw new VisionToolkitError('input', `${kind} is not a regular file: ${raw}`)
|
|
166
|
+
const extension = real.slice(real.lastIndexOf('.')).toLowerCase()
|
|
167
|
+
if (!extensions.includes(extension)) {
|
|
168
|
+
throw new VisionToolkitError(
|
|
169
|
+
'input',
|
|
170
|
+
`unsupported ${kind} format "${extension || '(none)'}"; supported: ${extensions.join(', ')}`,
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
return { path: real, bytes: info.size }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Validate a local HTML document; URL and data-URI inputs never reach Chrome. */
|
|
177
|
+
export function resolveHtmlFile(raw: string, policy: PathPolicy): Promise<{ path: string; bytes: number }> {
|
|
178
|
+
return resolveAuthorizedFile(raw, policy, ['.html', '.htm'], 'HTML source')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Resolve an optional user-supplied output filename inside the plugin output
|
|
183
|
+
* directory. Absolute paths, `..` segments, and wrong extensions are rejected.
|
|
184
|
+
* @param raw - output filename (workspace/outputDir-relative).
|
|
185
|
+
* @param policy - active path fence.
|
|
186
|
+
* @param defaultName - generated default filename.
|
|
187
|
+
* @param extensions - allowed extensions for this output kind.
|
|
188
|
+
* @returns absolute output path (not yet created).
|
|
189
|
+
*/
|
|
190
|
+
export function resolveOutputFile(
|
|
191
|
+
raw: string | undefined,
|
|
192
|
+
policy: PathPolicy,
|
|
193
|
+
defaultName: string,
|
|
194
|
+
extensions: readonly string[],
|
|
195
|
+
): string {
|
|
196
|
+
const name = raw === undefined || raw.trim().length === 0 ? defaultName : raw.trim()
|
|
197
|
+
const expanded = expandUserHome(name)
|
|
198
|
+
if (isAbsolute(expanded)) throw new VisionToolkitError('path', 'output must be a filename, not an absolute path')
|
|
199
|
+
const segments = expanded.split(/[\\/]/)
|
|
200
|
+
if (segments.length !== 1 || segments[0] === '' || segments[0] === '.' || segments[0] === '..') {
|
|
201
|
+
throw new VisionToolkitError('path', 'output must be one filename inside the output directory')
|
|
202
|
+
}
|
|
203
|
+
const extension = expanded.slice(expanded.lastIndexOf('.')).toLowerCase()
|
|
204
|
+
if (!extensions.includes(extension)) {
|
|
205
|
+
throw new VisionToolkitError('output', `output must use one of: ${extensions.join(', ')}`)
|
|
206
|
+
}
|
|
207
|
+
const target = resolve(policy.outputDir, expanded)
|
|
208
|
+
if (!isWithin(policy.outputDir, target)) {
|
|
209
|
+
throw new VisionToolkitError('path', 'output must stay inside the output directory')
|
|
210
|
+
}
|
|
211
|
+
return target
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Reserve a random, non-user-controlled staging path inside the real output
|
|
216
|
+
* directory. Upstream writes here so an existing destination symlink can
|
|
217
|
+
* never redirect the write outside the fence.
|
|
218
|
+
* @param policy - active path fence.
|
|
219
|
+
* @param extension - output extension including the leading dot.
|
|
220
|
+
* @returns absent staging path inside {@link PathPolicy.outputDir}.
|
|
221
|
+
*/
|
|
222
|
+
export function createStagedOutput(policy: PathPolicy, extension: string): string {
|
|
223
|
+
if (extension !== extname(`file${extension}`) || !/^\.[a-z0-9]+$/i.test(extension)) {
|
|
224
|
+
throw new VisionToolkitError('output', `invalid staging extension: ${extension}`)
|
|
225
|
+
}
|
|
226
|
+
return join(policy.outputDir, `.vision-toolkit-${randomUUID()}${extension}`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Resolve one direct child directory of the managed artifact root. */
|
|
230
|
+
export function resolveOutputDirectory(raw: string | undefined, policy: PathPolicy, defaultName: string): string {
|
|
231
|
+
const name = raw === undefined || raw.trim().length === 0 ? defaultName : raw.trim()
|
|
232
|
+
const expanded = expandUserHome(name)
|
|
233
|
+
if (isAbsolute(expanded)) throw new VisionToolkitError('path', 'artifact directory must not be an absolute path')
|
|
234
|
+
const segments = expanded.split(/[\\/]/)
|
|
235
|
+
if (
|
|
236
|
+
segments.length !== 1
|
|
237
|
+
|| segments[0] === ''
|
|
238
|
+
|| segments[0] === '.'
|
|
239
|
+
|| segments[0] === '..'
|
|
240
|
+
|| expanded.startsWith('.vision-toolkit-')
|
|
241
|
+
) {
|
|
242
|
+
throw new VisionToolkitError('path', 'artifact directory must be one visible directory name inside the output directory')
|
|
243
|
+
}
|
|
244
|
+
const target = resolve(policy.outputDir, expanded)
|
|
245
|
+
if (!isWithin(policy.outputDir, target)) {
|
|
246
|
+
throw new VisionToolkitError('path', 'artifact directory must stay inside the output directory')
|
|
247
|
+
}
|
|
248
|
+
return target
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Create a random staging directory that no upstream command can choose. */
|
|
252
|
+
export async function createStagedDirectory(policy: PathPolicy): Promise<string> {
|
|
253
|
+
const path = join(policy.outputDir, `.vision-toolkit-${randomUUID()}`)
|
|
254
|
+
await mkdir(path)
|
|
255
|
+
return path
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function assertSafeDirectoryTree(root: string, current = root): Promise<void> {
|
|
259
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
260
|
+
const path = join(current, entry.name)
|
|
261
|
+
const info = await lstat(path)
|
|
262
|
+
if (info.isSymbolicLink()) {
|
|
263
|
+
throw new VisionToolkitError('path', `managed artifact directory contains a symbolic link: ${entry.name}`)
|
|
264
|
+
}
|
|
265
|
+
if (info.isDirectory()) {
|
|
266
|
+
await assertSafeDirectoryTree(root, path)
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
if (!info.isFile()) {
|
|
270
|
+
throw new VisionToolkitError('path', `managed artifact directory contains a non-regular entry: ${entry.name}`)
|
|
271
|
+
}
|
|
272
|
+
const real = await realpath(path)
|
|
273
|
+
if (!isWithin(root, real)) {
|
|
274
|
+
throw new VisionToolkitError('path', `managed artifact entry escaped its directory: ${entry.name}`)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Copy an existing managed run into staging for an explicit resume operation.
|
|
281
|
+
* A missing destination is a normal first run; non-directory or symlink state
|
|
282
|
+
* fails closed instead of giving the upstream script an ambiguous workspace.
|
|
283
|
+
*/
|
|
284
|
+
export async function seedStagedDirectory(finalPath: string, staged: string, policy: PathPolicy): Promise<boolean> {
|
|
285
|
+
let info
|
|
286
|
+
try {
|
|
287
|
+
info = await lstat(finalPath)
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
|
290
|
+
throw new VisionToolkitError('path', 'existing artifact directory is not accessible', { cause: error })
|
|
291
|
+
}
|
|
292
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
293
|
+
throw new VisionToolkitError('path', 'resume target must be a real managed artifact directory')
|
|
294
|
+
}
|
|
295
|
+
const real = await realpath(finalPath)
|
|
296
|
+
if (!isWithin(policy.outputDir, real)) {
|
|
297
|
+
throw new VisionToolkitError('path', 'resume target escaped the managed output directory')
|
|
298
|
+
}
|
|
299
|
+
await assertSafeDirectoryTree(real)
|
|
300
|
+
await cp(real, staged, { recursive: true, force: true })
|
|
301
|
+
await assertSafeDirectoryTree(staged)
|
|
302
|
+
return true
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Atomically replace one managed artifact directory, restoring the previous
|
|
307
|
+
* complete run if the final rename fails. The upstream only ever writes the
|
|
308
|
+
* random staging path.
|
|
309
|
+
*/
|
|
310
|
+
export async function commitStagedDirectory(staged: string, finalPath: string, policy: PathPolicy): Promise<void> {
|
|
311
|
+
const stagedReal = await realpath(staged).catch((error: unknown) => {
|
|
312
|
+
throw new VisionToolkitError('output', 'upstream did not create the expected artifact directory', { cause: error })
|
|
313
|
+
})
|
|
314
|
+
if (!isWithin(policy.outputDir, stagedReal)) {
|
|
315
|
+
throw new VisionToolkitError('path', 'staged artifact directory escaped the managed output directory')
|
|
316
|
+
}
|
|
317
|
+
const stagedInfo = await lstat(stagedReal)
|
|
318
|
+
if (stagedInfo.isSymbolicLink() || !stagedInfo.isDirectory()) {
|
|
319
|
+
throw new VisionToolkitError('output', 'staged artifact output is not a real directory')
|
|
320
|
+
}
|
|
321
|
+
await assertSafeDirectoryTree(stagedReal)
|
|
322
|
+
const backup = join(policy.outputDir, `.vision-toolkit-backup-${randomUUID()}`)
|
|
323
|
+
let movedPrevious = false
|
|
324
|
+
try {
|
|
325
|
+
try {
|
|
326
|
+
await rename(finalPath, backup)
|
|
327
|
+
movedPrevious = true
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
await rename(stagedReal, finalPath)
|
|
333
|
+
} catch (error) {
|
|
334
|
+
if (movedPrevious) await rename(backup, finalPath).catch(() => {})
|
|
335
|
+
throw error
|
|
336
|
+
}
|
|
337
|
+
if (movedPrevious) await rm(backup, { recursive: true, force: true })
|
|
338
|
+
} catch (error) {
|
|
339
|
+
throw new VisionToolkitError('output', 'could not commit the managed artifact directory', { cause: error })
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Validate a staged regular file and atomically place it at the resolved final
|
|
345
|
+
* filename. Replacing an existing symlink replaces the link itself; upstream
|
|
346
|
+
* never opens the user-selected destination.
|
|
347
|
+
* @param staged - random staging path returned by {@link createStagedOutput}.
|
|
348
|
+
* @param finalPath - final path returned by {@link resolveOutputFile}.
|
|
349
|
+
* @param policy - active path fence.
|
|
350
|
+
*/
|
|
351
|
+
export async function commitStagedOutput(staged: string, finalPath: string, policy: PathPolicy): Promise<void> {
|
|
352
|
+
const real = await realpath(staged).catch((error: unknown) => {
|
|
353
|
+
throw new VisionToolkitError('output', 'upstream did not create the expected output file', { cause: error })
|
|
354
|
+
})
|
|
355
|
+
if (!isWithin(policy.outputDir, real)) {
|
|
356
|
+
throw new VisionToolkitError('path', 'staged output escaped the managed output directory')
|
|
357
|
+
}
|
|
358
|
+
const info = await stat(real)
|
|
359
|
+
if (!info.isFile()) throw new VisionToolkitError('output', 'upstream output is not a regular file')
|
|
360
|
+
try {
|
|
361
|
+
await rename(real, finalPath)
|
|
362
|
+
} catch (error) {
|
|
363
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
364
|
+
if (code !== 'EEXIST' && code !== 'EPERM') throw error
|
|
365
|
+
await rm(finalPath, { force: true })
|
|
366
|
+
try {
|
|
367
|
+
await link(real, finalPath)
|
|
368
|
+
} catch (linkError) {
|
|
369
|
+
if ((linkError as NodeJS.ErrnoException).code === 'EEXIST') {
|
|
370
|
+
throw new VisionToolkitError('path', 'output destination changed while the staged file was being committed', { cause: linkError })
|
|
371
|
+
}
|
|
372
|
+
throw linkError
|
|
373
|
+
}
|
|
374
|
+
await rm(real, { force: true })
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Reject an output that would overwrite its own input file. */
|
|
379
|
+
export function assertDistinctOutput(input: string, output: string): void {
|
|
380
|
+
if (input === output) {
|
|
381
|
+
throw new VisionToolkitError('input', 'output would overwrite the input image')
|
|
382
|
+
}
|
|
383
|
+
}
|