@mengruo/dsh-vision-toolkit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +356 -0
  4. package/README.zh.md +354 -0
  5. package/assets/community-group-qr.png +0 -0
  6. package/assets/dsh-conversation-artifact.png +0 -0
  7. package/assets/dsh-conversation-image-qa-top.png +0 -0
  8. package/assets/dsh-conversation-image-qa.png +0 -0
  9. package/assets/dsh-conversation-pixel-diff.png +0 -0
  10. package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
  11. package/assets/dsh-conversation-screenshot-debugging.png +0 -0
  12. package/assets/dsh-conversation-tool-call.png +0 -0
  13. package/assets/dsh-conversation-vision-trace.png +0 -0
  14. package/assets/dsh-view-example.png +0 -0
  15. package/assets/hero-v2.png +0 -0
  16. package/assets/logo_aihubmix.png +0 -0
  17. package/assets/python-bootstrap.json +48 -0
  18. package/assets/skill/SKILL.md +329 -0
  19. package/assets/skill/UPSTREAM.json +71 -0
  20. package/assets/skill/references/gui.md +88 -0
  21. package/assets/skill/references/long-screenshot-ocr.md +77 -0
  22. package/assets/skill/references/restore-graphic.md +84 -0
  23. package/assets/skill/references/restore-structure.md +45 -0
  24. package/assets/skill/references/restore-ui.md +202 -0
  25. package/assets/social-preview.png +0 -0
  26. package/assets/upstream/README.md +18 -0
  27. package/assets/upstream/focus-hint-comparison-1.webp +0 -0
  28. package/assets/upstream/focus-hint-comparison-2.webp +0 -0
  29. package/assets/upstream/infographic-reference.webp +0 -0
  30. package/assets/upstream/infographic-result.webp +0 -0
  31. package/assets/upstream/ui-fast-restore-reference.webp +0 -0
  32. package/assets/upstream/ui-fast-restore-result.webp +0 -0
  33. package/assets/upstream/ui-result.webp +0 -0
  34. package/assets/upstream/ui-sketch.webp +0 -0
  35. package/assets/vision-model-test.png +0 -0
  36. package/assets/vision-settings.png +0 -0
  37. package/assets/wechat-reward.png +0 -0
  38. package/cordis.patch.yml +6 -0
  39. package/docs/aihubmix-gemini-vision.i18n.yaml +6 -0
  40. package/docs/aihubmix-gemini-vision.md +138 -0
  41. package/docs/aihubmix-gemini-vision.zh.md +138 -0
  42. package/docs/assets/aihubmix-api-keys.png +0 -0
  43. package/docs/assets/aihubmix-create-key.png +0 -0
  44. package/docs/assets/aihubmix-free-vision-model.png +0 -0
  45. package/docs/assets/aihubmix-home.png +0 -0
  46. package/docs/assets/aihubmix-sign-up.png +0 -0
  47. package/docs/assets/groq-console-home.png +0 -0
  48. package/docs/assets/groq-console-keys-login.png +0 -0
  49. package/docs/assets/groq-docs-quickstart.png +0 -0
  50. package/docs/assets/groq-docs-qwen3.6.png +0 -0
  51. package/docs/assets/groq-docs-vision.png +0 -0
  52. package/docs/assets/vision-settings.png +0 -0
  53. package/docs/dsh-desktop-install.md +67 -0
  54. package/docs/dsh-desktop-install.zh.md +67 -0
  55. package/docs/groq-qwen3.6-vision.i18n.yaml +6 -0
  56. package/docs/groq-qwen3.6-vision.md +236 -0
  57. package/docs/groq-qwen3.6-vision.zh.md +236 -0
  58. package/docs/python-runtime.i18n.yaml +6 -0
  59. package/docs/python-runtime.md +89 -0
  60. package/docs/python-runtime.zh.md +89 -0
  61. package/docs/requirements-traceability/README.i18n.yaml +6 -0
  62. package/docs/requirements-traceability/README.md +75 -0
  63. package/docs/requirements-traceability/README.zh.md +75 -0
  64. package/examples/ui-restoration/README.i18n.yaml +6 -0
  65. package/examples/ui-restoration/README.md +70 -0
  66. package/examples/ui-restoration/README.zh.md +70 -0
  67. package/examples/ui-restoration/assets/final-heatmap.png +0 -0
  68. package/examples/ui-restoration/assets/final-report.json +83 -0
  69. package/examples/ui-restoration/assets/implementation.png +0 -0
  70. package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
  71. package/examples/ui-restoration/assets/initial-report.json +83 -0
  72. package/examples/ui-restoration/assets/initial.png +0 -0
  73. package/examples/ui-restoration/assets/metrics.json +12 -0
  74. package/examples/ui-restoration/assets/reference.png +0 -0
  75. package/examples/ui-restoration/implementation.html +94 -0
  76. package/examples/ui-restoration/initial.html +57 -0
  77. package/lib/artifact-access.js +369 -0
  78. package/lib/artifact-access.js.map +1 -0
  79. package/lib/artifacts.js +56 -0
  80. package/lib/artifacts.js.map +1 -0
  81. package/lib/client.js +2099 -0
  82. package/lib/client.js.map +1 -0
  83. package/lib/config.js +172 -0
  84. package/lib/config.js.map +1 -0
  85. package/lib/defaults.js +6 -0
  86. package/lib/defaults.js.map +1 -0
  87. package/lib/errors.js +56 -0
  88. package/lib/errors.js.map +1 -0
  89. package/lib/evidence-cache.js +350 -0
  90. package/lib/evidence-cache.js.map +1 -0
  91. package/lib/exposure.js +247 -0
  92. package/lib/exposure.js.map +1 -0
  93. package/lib/image-input-variants.js +882 -0
  94. package/lib/image-input-variants.js.map +1 -0
  95. package/lib/index.js +105 -0
  96. package/lib/index.js.map +1 -0
  97. package/lib/paste-images.js +217 -0
  98. package/lib/paste-images.js.map +1 -0
  99. package/lib/paths.js +354 -0
  100. package/lib/paths.js.map +1 -0
  101. package/lib/plugin-update.js +1003 -0
  102. package/lib/plugin-update.js.map +1 -0
  103. package/lib/runtime-install.js +964 -0
  104. package/lib/runtime-install.js.map +1 -0
  105. package/lib/runtime-manager.js +133 -0
  106. package/lib/runtime-manager.js.map +1 -0
  107. package/lib/runtime.js +1678 -0
  108. package/lib/runtime.js.map +1 -0
  109. package/lib/skill.js +25 -0
  110. package/lib/skill.js.map +1 -0
  111. package/lib/tools.js +549 -0
  112. package/lib/tools.js.map +1 -0
  113. package/lib/types/artifact-access.d.ts +61 -0
  114. package/lib/types/artifact-access.d.ts.map +1 -0
  115. package/lib/types/artifacts.d.ts +42 -0
  116. package/lib/types/artifacts.d.ts.map +1 -0
  117. package/lib/types/client/display-config.d.ts +24 -0
  118. package/lib/types/client/display-config.d.ts.map +1 -0
  119. package/lib/types/client/index.d.ts +350 -0
  120. package/lib/types/client/index.d.ts.map +1 -0
  121. package/lib/types/client/model-variants-hider.d.ts +40 -0
  122. package/lib/types/client/model-variants-hider.d.ts.map +1 -0
  123. package/lib/types/client/paste-images.d.ts +126 -0
  124. package/lib/types/client/paste-images.d.ts.map +1 -0
  125. package/lib/types/config.d.ts +123 -0
  126. package/lib/types/config.d.ts.map +1 -0
  127. package/lib/types/defaults.d.ts +6 -0
  128. package/lib/types/defaults.d.ts.map +1 -0
  129. package/lib/types/errors.d.ts +35 -0
  130. package/lib/types/errors.d.ts.map +1 -0
  131. package/lib/types/evidence-cache.d.ts +91 -0
  132. package/lib/types/evidence-cache.d.ts.map +1 -0
  133. package/lib/types/exposure.d.ts +50 -0
  134. package/lib/types/exposure.d.ts.map +1 -0
  135. package/lib/types/image-input-variants.d.ts +144 -0
  136. package/lib/types/image-input-variants.d.ts.map +1 -0
  137. package/lib/types/index.d.ts +19 -0
  138. package/lib/types/index.d.ts.map +1 -0
  139. package/lib/types/paste-images.d.ts +73 -0
  140. package/lib/types/paste-images.d.ts.map +1 -0
  141. package/lib/types/paths.d.ts +117 -0
  142. package/lib/types/paths.d.ts.map +1 -0
  143. package/lib/types/plugin-update.d.ts +119 -0
  144. package/lib/types/plugin-update.d.ts.map +1 -0
  145. package/lib/types/runtime-install.d.ts +92 -0
  146. package/lib/types/runtime-install.d.ts.map +1 -0
  147. package/lib/types/runtime-manager.d.ts +60 -0
  148. package/lib/types/runtime-manager.d.ts.map +1 -0
  149. package/lib/types/runtime.d.ts +412 -0
  150. package/lib/types/runtime.d.ts.map +1 -0
  151. package/lib/types/skill.d.ts +14 -0
  152. package/lib/types/skill.d.ts.map +1 -0
  153. package/lib/types/tools.d.ts +35 -0
  154. package/lib/types/tools.d.ts.map +1 -0
  155. package/lib/types/upstream.d.ts +233 -0
  156. package/lib/types/upstream.d.ts.map +1 -0
  157. package/lib/types/version.d.ts +15 -0
  158. package/lib/types/version.d.ts.map +1 -0
  159. package/lib/types/web-request.d.ts +11 -0
  160. package/lib/types/web-request.d.ts.map +1 -0
  161. package/lib/types/web.d.ts +118 -0
  162. package/lib/types/web.d.ts.map +1 -0
  163. package/lib/upstream.js +860 -0
  164. package/lib/upstream.js.map +1 -0
  165. package/lib/version.js +18 -0
  166. package/lib/version.js.map +1 -0
  167. package/lib/web-request.js +29 -0
  168. package/lib/web-request.js.map +1 -0
  169. package/lib/web.js +462 -0
  170. package/lib/web.js.map +1 -0
  171. package/package.json +149 -0
  172. package/patches/vision-tools-dsh.patch +970 -0
  173. package/runtime/requirements.lock +3 -0
  174. package/src/artifact-access.ts +386 -0
  175. package/src/artifacts.ts +85 -0
  176. package/src/client/display-config.ts +62 -0
  177. package/src/client/index.tsx +1580 -0
  178. package/src/client/model-variants-hider.ts +159 -0
  179. package/src/client/paste-images.tsx +747 -0
  180. package/src/config.ts +290 -0
  181. package/src/defaults.ts +5 -0
  182. package/src/errors.ts +62 -0
  183. package/src/evidence-cache.ts +418 -0
  184. package/src/exposure.ts +265 -0
  185. package/src/image-input-variants.ts +996 -0
  186. package/src/index.ts +141 -0
  187. package/src/paste-images.ts +286 -0
  188. package/src/paths.ts +383 -0
  189. package/src/plugin-update.ts +1143 -0
  190. package/src/runtime-install.ts +1161 -0
  191. package/src/runtime-manager.ts +174 -0
  192. package/src/runtime.ts +2189 -0
  193. package/src/skill.ts +35 -0
  194. package/src/tools.ts +691 -0
  195. package/src/upstream.ts +1078 -0
  196. package/src/version.ts +37 -0
  197. package/src/web-request.ts +27 -0
  198. package/src/web.ts +594 -0
  199. package/vendor/agent-vision-toolkit/CHANGELOG.md +30 -0
  200. package/vendor/agent-vision-toolkit/LICENSE +21 -0
  201. package/vendor/agent-vision-toolkit/README.md +405 -0
  202. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +94 -0
  203. package/vendor/agent-vision-toolkit/bin/crop +90 -0
  204. package/vendor/agent-vision-toolkit/bin/detect +13 -0
  205. package/vendor/agent-vision-toolkit/bin/glance +93 -0
  206. package/vendor/agent-vision-toolkit/bin/ground +13 -0
  207. package/vendor/agent-vision-toolkit/bin/trace +129 -0
  208. package/vendor/agent-vision-toolkit/detect.py +60 -0
  209. package/vendor/agent-vision-toolkit/ground.py +267 -0
  210. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
  211. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
  212. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +420 -0
  213. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  214. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
  215. package/vendor/agent-vision-toolkit/tests/test_vision_client.py +450 -0
  216. package/vendor/agent-vision-toolkit/vision_client.py +310 -0
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
+ }