@anionex/dsh-vision-toolkit 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +383 -0
  4. package/README.zh.md +383 -0
  5. package/assets/dsh-conversation-artifact.png +0 -0
  6. package/assets/dsh-conversation-image-qa-top.png +0 -0
  7. package/assets/dsh-conversation-image-qa.png +0 -0
  8. package/assets/dsh-conversation-pixel-diff.png +0 -0
  9. package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
  10. package/assets/dsh-conversation-screenshot-debugging.png +0 -0
  11. package/assets/dsh-conversation-tool-call.png +0 -0
  12. package/assets/dsh-conversation-vision-trace.png +0 -0
  13. package/assets/hero.png +0 -0
  14. package/assets/social-preview.png +0 -0
  15. package/assets/upstream/README.md +16 -0
  16. package/assets/upstream/image-qa.webp +0 -0
  17. package/assets/upstream/infographic-reference.webp +0 -0
  18. package/assets/upstream/infographic-result.webp +0 -0
  19. package/assets/upstream/screenshot-debugging.webp +0 -0
  20. package/assets/upstream/ui-result.webp +0 -0
  21. package/assets/upstream/ui-sketch.webp +0 -0
  22. package/assets/vision-settings.png +0 -0
  23. package/cordis.patch.yml +6 -0
  24. package/docs/assets/vision-settings.png +0 -0
  25. package/docs/requirements-traceability/README.i18n.yaml +6 -0
  26. package/docs/requirements-traceability/README.md +75 -0
  27. package/docs/requirements-traceability/README.zh.md +75 -0
  28. package/examples/ui-restoration/README.i18n.yaml +6 -0
  29. package/examples/ui-restoration/README.md +70 -0
  30. package/examples/ui-restoration/README.zh.md +70 -0
  31. package/examples/ui-restoration/assets/final-heatmap.png +0 -0
  32. package/examples/ui-restoration/assets/final-report.json +83 -0
  33. package/examples/ui-restoration/assets/implementation.png +0 -0
  34. package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
  35. package/examples/ui-restoration/assets/initial-report.json +83 -0
  36. package/examples/ui-restoration/assets/initial.png +0 -0
  37. package/examples/ui-restoration/assets/metrics.json +12 -0
  38. package/examples/ui-restoration/assets/reference.png +0 -0
  39. package/examples/ui-restoration/implementation.html +94 -0
  40. package/examples/ui-restoration/initial.html +57 -0
  41. package/lib/artifact-access.js +369 -0
  42. package/lib/artifact-access.js.map +1 -0
  43. package/lib/artifacts.js +56 -0
  44. package/lib/artifacts.js.map +1 -0
  45. package/lib/client.js +952 -0
  46. package/lib/client.js.map +1 -0
  47. package/lib/config.js +117 -0
  48. package/lib/config.js.map +1 -0
  49. package/lib/errors.js +56 -0
  50. package/lib/errors.js.map +1 -0
  51. package/lib/exposure.js +213 -0
  52. package/lib/exposure.js.map +1 -0
  53. package/lib/index.js +97 -0
  54. package/lib/index.js.map +1 -0
  55. package/lib/paste-images.js +199 -0
  56. package/lib/paste-images.js.map +1 -0
  57. package/lib/paths.js +325 -0
  58. package/lib/paths.js.map +1 -0
  59. package/lib/runtime-install.js +601 -0
  60. package/lib/runtime-install.js.map +1 -0
  61. package/lib/runtime-manager.js +126 -0
  62. package/lib/runtime-manager.js.map +1 -0
  63. package/lib/runtime.js +1344 -0
  64. package/lib/runtime.js.map +1 -0
  65. package/lib/skill.js +139 -0
  66. package/lib/skill.js.map +1 -0
  67. package/lib/tools.js +528 -0
  68. package/lib/tools.js.map +1 -0
  69. package/lib/types/artifact-access.d.ts +61 -0
  70. package/lib/types/artifact-access.d.ts.map +1 -0
  71. package/lib/types/artifacts.d.ts +42 -0
  72. package/lib/types/artifacts.d.ts.map +1 -0
  73. package/lib/types/client/index.d.ts +179 -0
  74. package/lib/types/client/index.d.ts.map +1 -0
  75. package/lib/types/client/paste-images.d.ts +57 -0
  76. package/lib/types/client/paste-images.d.ts.map +1 -0
  77. package/lib/types/config.d.ts +73 -0
  78. package/lib/types/config.d.ts.map +1 -0
  79. package/lib/types/errors.d.ts +35 -0
  80. package/lib/types/errors.d.ts.map +1 -0
  81. package/lib/types/exposure.d.ts +40 -0
  82. package/lib/types/exposure.d.ts.map +1 -0
  83. package/lib/types/index.d.ts +18 -0
  84. package/lib/types/index.d.ts.map +1 -0
  85. package/lib/types/paste-images.d.ts +21 -0
  86. package/lib/types/paste-images.d.ts.map +1 -0
  87. package/lib/types/paths.d.ts +107 -0
  88. package/lib/types/paths.d.ts.map +1 -0
  89. package/lib/types/runtime-install.d.ts +49 -0
  90. package/lib/types/runtime-install.d.ts.map +1 -0
  91. package/lib/types/runtime-manager.d.ts +60 -0
  92. package/lib/types/runtime-manager.d.ts.map +1 -0
  93. package/lib/types/runtime.d.ts +389 -0
  94. package/lib/types/runtime.d.ts.map +1 -0
  95. package/lib/types/skill.d.ts +15 -0
  96. package/lib/types/skill.d.ts.map +1 -0
  97. package/lib/types/tools.d.ts +22 -0
  98. package/lib/types/tools.d.ts.map +1 -0
  99. package/lib/types/upstream.d.ts +207 -0
  100. package/lib/types/upstream.d.ts.map +1 -0
  101. package/lib/types/version.d.ts +15 -0
  102. package/lib/types/version.d.ts.map +1 -0
  103. package/lib/types/web-request.d.ts +4 -0
  104. package/lib/types/web-request.d.ts.map +1 -0
  105. package/lib/types/web.d.ts +74 -0
  106. package/lib/types/web.d.ts.map +1 -0
  107. package/lib/upstream.js +675 -0
  108. package/lib/upstream.js.map +1 -0
  109. package/lib/version.js +18 -0
  110. package/lib/version.js.map +1 -0
  111. package/lib/web-request.js +20 -0
  112. package/lib/web-request.js.map +1 -0
  113. package/lib/web.js +244 -0
  114. package/lib/web.js.map +1 -0
  115. package/package.json +139 -0
  116. package/runtime/requirements.lock +3 -0
  117. package/src/artifact-access.ts +386 -0
  118. package/src/artifacts.ts +85 -0
  119. package/src/client/index.tsx +866 -0
  120. package/src/client/paste-images.tsx +426 -0
  121. package/src/config.ts +177 -0
  122. package/src/errors.ts +62 -0
  123. package/src/exposure.ts +227 -0
  124. package/src/index.ts +122 -0
  125. package/src/paste-images.ts +234 -0
  126. package/src/paths.ts +348 -0
  127. package/src/runtime-install.ts +723 -0
  128. package/src/runtime-manager.ts +166 -0
  129. package/src/runtime.ts +1783 -0
  130. package/src/skill.ts +143 -0
  131. package/src/tools.ts +668 -0
  132. package/src/upstream.ts +861 -0
  133. package/src/version.ts +37 -0
  134. package/src/web-request.ts +17 -0
  135. package/src/web.ts +329 -0
  136. package/vendor/agent-vision-toolkit/CHANGELOG.md +16 -0
  137. package/vendor/agent-vision-toolkit/LICENSE +21 -0
  138. package/vendor/agent-vision-toolkit/README.md +399 -0
  139. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +89 -0
  140. package/vendor/agent-vision-toolkit/bin/crop +90 -0
  141. package/vendor/agent-vision-toolkit/bin/detect +13 -0
  142. package/vendor/agent-vision-toolkit/bin/glance +93 -0
  143. package/vendor/agent-vision-toolkit/bin/ground +13 -0
  144. package/vendor/agent-vision-toolkit/bin/trace +129 -0
  145. package/vendor/agent-vision-toolkit/detect.py +56 -0
  146. package/vendor/agent-vision-toolkit/ground.py +216 -0
  147. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
  148. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
  149. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +108 -0
  150. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  151. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
  152. package/vendor/agent-vision-toolkit/vision_client.py +156 -0
package/src/paths.ts ADDED
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Path fence shared by every tool: inputs must live in the workspace or an
3
+ * explicitly authorized directory, outputs stay inside the plugin-managed
4
+ * output directory, and a symbolic link is allowed only when its real target
5
+ * 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 } from 'node:path'
12
+ import { homedir } 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 allowed roots: workspace plus configured extra directories. */
23
+ allowedDirs: string[]
24
+ /** Real plugin-managed output directory inside the fence. */
25
+ outputDir: string
26
+ }
27
+
28
+ /** Whether `child` equals or lies under `parent` on the same path root. */
29
+ export function isWithin(parent: string, child: string): boolean {
30
+ const rel = relative(parent, child)
31
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
32
+ }
33
+
34
+ function expandUserHome(raw: string): string {
35
+ if (raw === '~') return homedir()
36
+ if (raw.startsWith('~/') || raw.startsWith(`~${sep}`)) return join(homedir(), raw.slice(2))
37
+ return raw
38
+ }
39
+
40
+ /**
41
+ * Build the per-invocation path policy: realpath the workspace, resolve and
42
+ * realpath allowed directories, and create the output directory inside the
43
+ * fence.
44
+ * @param workspaceRaw - session workspace (or process cwd fallback).
45
+ * @param allowedDirs - configured extra allowed roots.
46
+ * @param outputDirRaw - configured output directory (default `.dsh-vision-toolkit/artifacts`).
47
+ * @returns the resolved policy.
48
+ */
49
+ export async function createPathPolicy(
50
+ workspaceRaw: string,
51
+ allowedDirs: readonly string[],
52
+ outputDirRaw?: string,
53
+ ): Promise<PathPolicy> {
54
+ let workspace: string
55
+ try {
56
+ workspace = await realpath(expandUserHome(workspaceRaw))
57
+ } catch (error) {
58
+ throw new VisionToolkitError('path', `workspace is not accessible: ${workspaceRaw}`, { cause: error })
59
+ }
60
+ const roots = [workspace]
61
+ for (const raw of allowedDirs) {
62
+ const candidate = expandUserHome(raw)
63
+ const target = isAbsolute(candidate) ? candidate : resolve(workspace, candidate)
64
+ try {
65
+ roots.push(await realpath(target))
66
+ } catch (error) {
67
+ throw new VisionToolkitError('path', `allowedDirs entry is not accessible: ${raw}`, { cause: error })
68
+ }
69
+ }
70
+ const outputRaw = outputDirRaw === undefined || outputDirRaw.trim().length === 0
71
+ ? join(workspace, '.dsh-vision-toolkit', 'artifacts')
72
+ : resolve(workspace, expandUserHome(outputDirRaw))
73
+ if (!roots.some(root => isWithin(root, outputRaw))) {
74
+ throw new VisionToolkitError('path', 'output directory must stay inside the workspace or an allowedDirs entry')
75
+ }
76
+ let outputDir: string
77
+ try {
78
+ await mkdir(outputRaw, { recursive: true })
79
+ outputDir = await realpath(outputRaw)
80
+ } catch (error) {
81
+ throw new VisionToolkitError('path', `output directory is not writable: ${outputRaw}`, { cause: error })
82
+ }
83
+ return { workspace, allowedDirs: roots, outputDir }
84
+ }
85
+
86
+ /**
87
+ * Validate one input image path and return its fence-checked absolute path
88
+ * and byte size.
89
+ * @param raw - image path, resolved against the workspace.
90
+ * @param policy - active path fence.
91
+ * @returns absolute path and file size.
92
+ */
93
+ export async function resolveInputFile(raw: string, policy: PathPolicy): Promise<{ path: string; bytes: number }> {
94
+ return resolveAuthorizedFile(raw, policy, SUPPORTED_IMAGE_EXTENSIONS, 'image')
95
+ }
96
+
97
+ /**
98
+ * Validate one authorized regular file against an explicit extension set.
99
+ * Realpath fencing makes local HTML and future non-image inputs follow the
100
+ * same symlink-safe policy as images.
101
+ * @param raw - path resolved against the workspace.
102
+ * @param policy - active path fence.
103
+ * @param extensions - accepted lowercase extensions including the leading dot.
104
+ * @param kind - user-facing noun used in stable errors.
105
+ * @returns absolute real path and file size.
106
+ */
107
+ export async function resolveAuthorizedFile(
108
+ raw: string,
109
+ policy: PathPolicy,
110
+ extensions: readonly string[],
111
+ kind: string,
112
+ ): Promise<{ path: string; bytes: number }> {
113
+ const candidate = expandUserHome(raw)
114
+ const target = isAbsolute(candidate) ? candidate : resolve(policy.workspace, candidate)
115
+ let real: string
116
+ try {
117
+ real = await realpath(target)
118
+ } catch (error) {
119
+ throw new VisionToolkitError('input', `${kind} not found: ${raw}`, { cause: error })
120
+ }
121
+ if (!policy.allowedDirs.some(root => isWithin(root, real))) {
122
+ throw new VisionToolkitError('path', `${kind} escapes the allowed directories: ${raw}`)
123
+ }
124
+ let info
125
+ try {
126
+ info = await stat(real)
127
+ } catch (error) {
128
+ throw new VisionToolkitError('input', `${kind} is not readable: ${raw}`, { cause: error })
129
+ }
130
+ if (!info.isFile()) throw new VisionToolkitError('input', `${kind} is not a regular file: ${raw}`)
131
+ const extension = real.slice(real.lastIndexOf('.')).toLowerCase()
132
+ if (!extensions.includes(extension)) {
133
+ throw new VisionToolkitError(
134
+ 'input',
135
+ `unsupported ${kind} format "${extension || '(none)'}"; supported: ${extensions.join(', ')}`,
136
+ )
137
+ }
138
+ return { path: real, bytes: info.size }
139
+ }
140
+
141
+ /** Validate a local HTML document; URL and data-URI inputs never reach Chrome. */
142
+ export function resolveHtmlFile(raw: string, policy: PathPolicy): Promise<{ path: string; bytes: number }> {
143
+ return resolveAuthorizedFile(raw, policy, ['.html', '.htm'], 'HTML source')
144
+ }
145
+
146
+ /**
147
+ * Resolve an optional user-supplied output filename inside the plugin output
148
+ * directory. Absolute paths, `..` segments, and wrong extensions are rejected.
149
+ * @param raw - output filename (workspace/outputDir-relative).
150
+ * @param policy - active path fence.
151
+ * @param defaultName - generated default filename.
152
+ * @param extensions - allowed extensions for this output kind.
153
+ * @returns absolute output path (not yet created).
154
+ */
155
+ export function resolveOutputFile(
156
+ raw: string | undefined,
157
+ policy: PathPolicy,
158
+ defaultName: string,
159
+ extensions: readonly string[],
160
+ ): string {
161
+ const name = raw === undefined || raw.trim().length === 0 ? defaultName : raw.trim()
162
+ const expanded = expandUserHome(name)
163
+ if (isAbsolute(expanded)) throw new VisionToolkitError('path', 'output must be a filename, not an absolute path')
164
+ const segments = expanded.split(/[\\/]/)
165
+ if (segments.length !== 1 || segments[0] === '' || segments[0] === '.' || segments[0] === '..') {
166
+ throw new VisionToolkitError('path', 'output must be one filename inside the output directory')
167
+ }
168
+ const extension = expanded.slice(expanded.lastIndexOf('.')).toLowerCase()
169
+ if (!extensions.includes(extension)) {
170
+ throw new VisionToolkitError('output', `output must use one of: ${extensions.join(', ')}`)
171
+ }
172
+ const target = resolve(policy.outputDir, expanded)
173
+ if (!isWithin(policy.outputDir, target)) {
174
+ throw new VisionToolkitError('path', 'output must stay inside the output directory')
175
+ }
176
+ return target
177
+ }
178
+
179
+ /**
180
+ * Reserve a random, non-user-controlled staging path inside the real output
181
+ * directory. Upstream writes here so an existing destination symlink can
182
+ * never redirect the write outside the fence.
183
+ * @param policy - active path fence.
184
+ * @param extension - output extension including the leading dot.
185
+ * @returns absent staging path inside {@link PathPolicy.outputDir}.
186
+ */
187
+ export function createStagedOutput(policy: PathPolicy, extension: string): string {
188
+ if (extension !== extname(`file${extension}`) || !/^\.[a-z0-9]+$/i.test(extension)) {
189
+ throw new VisionToolkitError('output', `invalid staging extension: ${extension}`)
190
+ }
191
+ return join(policy.outputDir, `.vision-toolkit-${randomUUID()}${extension}`)
192
+ }
193
+
194
+ /** Resolve one direct child directory of the managed artifact root. */
195
+ export function resolveOutputDirectory(raw: string | undefined, policy: PathPolicy, defaultName: string): 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', 'artifact directory must not be an absolute path')
199
+ const segments = expanded.split(/[\\/]/)
200
+ if (
201
+ segments.length !== 1
202
+ || segments[0] === ''
203
+ || segments[0] === '.'
204
+ || segments[0] === '..'
205
+ || expanded.startsWith('.vision-toolkit-')
206
+ ) {
207
+ throw new VisionToolkitError('path', 'artifact directory must be one visible directory name inside the output directory')
208
+ }
209
+ const target = resolve(policy.outputDir, expanded)
210
+ if (!isWithin(policy.outputDir, target)) {
211
+ throw new VisionToolkitError('path', 'artifact directory must stay inside the output directory')
212
+ }
213
+ return target
214
+ }
215
+
216
+ /** Create a random staging directory that no upstream command can choose. */
217
+ export async function createStagedDirectory(policy: PathPolicy): Promise<string> {
218
+ const path = join(policy.outputDir, `.vision-toolkit-${randomUUID()}`)
219
+ await mkdir(path)
220
+ return path
221
+ }
222
+
223
+ async function assertSafeDirectoryTree(root: string, current = root): Promise<void> {
224
+ for (const entry of await readdir(current, { withFileTypes: true })) {
225
+ const path = join(current, entry.name)
226
+ const info = await lstat(path)
227
+ if (info.isSymbolicLink()) {
228
+ throw new VisionToolkitError('path', `managed artifact directory contains a symbolic link: ${entry.name}`)
229
+ }
230
+ if (info.isDirectory()) {
231
+ await assertSafeDirectoryTree(root, path)
232
+ continue
233
+ }
234
+ if (!info.isFile()) {
235
+ throw new VisionToolkitError('path', `managed artifact directory contains a non-regular entry: ${entry.name}`)
236
+ }
237
+ const real = await realpath(path)
238
+ if (!isWithin(root, real)) {
239
+ throw new VisionToolkitError('path', `managed artifact entry escaped its directory: ${entry.name}`)
240
+ }
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Copy an existing managed run into staging for an explicit resume operation.
246
+ * A missing destination is a normal first run; non-directory or symlink state
247
+ * fails closed instead of giving the upstream script an ambiguous workspace.
248
+ */
249
+ export async function seedStagedDirectory(finalPath: string, staged: string, policy: PathPolicy): Promise<boolean> {
250
+ let info
251
+ try {
252
+ info = await lstat(finalPath)
253
+ } catch (error) {
254
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
255
+ throw new VisionToolkitError('path', 'existing artifact directory is not accessible', { cause: error })
256
+ }
257
+ if (info.isSymbolicLink() || !info.isDirectory()) {
258
+ throw new VisionToolkitError('path', 'resume target must be a real managed artifact directory')
259
+ }
260
+ const real = await realpath(finalPath)
261
+ if (!isWithin(policy.outputDir, real)) {
262
+ throw new VisionToolkitError('path', 'resume target escaped the managed output directory')
263
+ }
264
+ await assertSafeDirectoryTree(real)
265
+ await cp(real, staged, { recursive: true, force: true })
266
+ await assertSafeDirectoryTree(staged)
267
+ return true
268
+ }
269
+
270
+ /**
271
+ * Atomically replace one managed artifact directory, restoring the previous
272
+ * complete run if the final rename fails. The upstream only ever writes the
273
+ * random staging path.
274
+ */
275
+ export async function commitStagedDirectory(staged: string, finalPath: string, policy: PathPolicy): Promise<void> {
276
+ const stagedReal = await realpath(staged).catch((error: unknown) => {
277
+ throw new VisionToolkitError('output', 'upstream did not create the expected artifact directory', { cause: error })
278
+ })
279
+ if (!isWithin(policy.outputDir, stagedReal)) {
280
+ throw new VisionToolkitError('path', 'staged artifact directory escaped the managed output directory')
281
+ }
282
+ const stagedInfo = await lstat(stagedReal)
283
+ if (stagedInfo.isSymbolicLink() || !stagedInfo.isDirectory()) {
284
+ throw new VisionToolkitError('output', 'staged artifact output is not a real directory')
285
+ }
286
+ await assertSafeDirectoryTree(stagedReal)
287
+ const backup = join(policy.outputDir, `.vision-toolkit-backup-${randomUUID()}`)
288
+ let movedPrevious = false
289
+ try {
290
+ try {
291
+ await rename(finalPath, backup)
292
+ movedPrevious = true
293
+ } catch (error) {
294
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
295
+ }
296
+ try {
297
+ await rename(stagedReal, finalPath)
298
+ } catch (error) {
299
+ if (movedPrevious) await rename(backup, finalPath).catch(() => {})
300
+ throw error
301
+ }
302
+ if (movedPrevious) await rm(backup, { recursive: true, force: true })
303
+ } catch (error) {
304
+ throw new VisionToolkitError('output', 'could not commit the managed artifact directory', { cause: error })
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Validate a staged regular file and atomically place it at the resolved final
310
+ * filename. Replacing an existing symlink replaces the link itself; upstream
311
+ * never opens the user-selected destination.
312
+ * @param staged - random staging path returned by {@link createStagedOutput}.
313
+ * @param finalPath - final path returned by {@link resolveOutputFile}.
314
+ * @param policy - active path fence.
315
+ */
316
+ export async function commitStagedOutput(staged: string, finalPath: string, policy: PathPolicy): Promise<void> {
317
+ const real = await realpath(staged).catch((error: unknown) => {
318
+ throw new VisionToolkitError('output', 'upstream did not create the expected output file', { cause: error })
319
+ })
320
+ if (!isWithin(policy.outputDir, real)) {
321
+ throw new VisionToolkitError('path', 'staged output escaped the managed output directory')
322
+ }
323
+ const info = await stat(real)
324
+ if (!info.isFile()) throw new VisionToolkitError('output', 'upstream output is not a regular file')
325
+ try {
326
+ await rename(real, finalPath)
327
+ } catch (error) {
328
+ const code = (error as NodeJS.ErrnoException).code
329
+ if (code !== 'EEXIST' && code !== 'EPERM') throw error
330
+ await rm(finalPath, { force: true })
331
+ try {
332
+ await link(real, finalPath)
333
+ } catch (linkError) {
334
+ if ((linkError as NodeJS.ErrnoException).code === 'EEXIST') {
335
+ throw new VisionToolkitError('path', 'output destination changed while the staged file was being committed', { cause: linkError })
336
+ }
337
+ throw linkError
338
+ }
339
+ await rm(real, { force: true })
340
+ }
341
+ }
342
+
343
+ /** Reject an output that would overwrite its own input file. */
344
+ export function assertDistinctOutput(input: string, output: string): void {
345
+ if (input === output) {
346
+ throw new VisionToolkitError('input', 'output would overwrite the input image')
347
+ }
348
+ }