@mengruo/dsh-vision-toolkit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +356 -0
  4. package/README.zh.md +354 -0
  5. package/assets/community-group-qr.png +0 -0
  6. package/assets/dsh-conversation-artifact.png +0 -0
  7. package/assets/dsh-conversation-image-qa-top.png +0 -0
  8. package/assets/dsh-conversation-image-qa.png +0 -0
  9. package/assets/dsh-conversation-pixel-diff.png +0 -0
  10. package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
  11. package/assets/dsh-conversation-screenshot-debugging.png +0 -0
  12. package/assets/dsh-conversation-tool-call.png +0 -0
  13. package/assets/dsh-conversation-vision-trace.png +0 -0
  14. package/assets/dsh-view-example.png +0 -0
  15. package/assets/hero-v2.png +0 -0
  16. package/assets/logo_aihubmix.png +0 -0
  17. package/assets/python-bootstrap.json +48 -0
  18. package/assets/skill/SKILL.md +329 -0
  19. package/assets/skill/UPSTREAM.json +71 -0
  20. package/assets/skill/references/gui.md +88 -0
  21. package/assets/skill/references/long-screenshot-ocr.md +77 -0
  22. package/assets/skill/references/restore-graphic.md +84 -0
  23. package/assets/skill/references/restore-structure.md +45 -0
  24. package/assets/skill/references/restore-ui.md +202 -0
  25. package/assets/social-preview.png +0 -0
  26. package/assets/upstream/README.md +18 -0
  27. package/assets/upstream/focus-hint-comparison-1.webp +0 -0
  28. package/assets/upstream/focus-hint-comparison-2.webp +0 -0
  29. package/assets/upstream/infographic-reference.webp +0 -0
  30. package/assets/upstream/infographic-result.webp +0 -0
  31. package/assets/upstream/ui-fast-restore-reference.webp +0 -0
  32. package/assets/upstream/ui-fast-restore-result.webp +0 -0
  33. package/assets/upstream/ui-result.webp +0 -0
  34. package/assets/upstream/ui-sketch.webp +0 -0
  35. package/assets/vision-model-test.png +0 -0
  36. package/assets/vision-settings.png +0 -0
  37. package/assets/wechat-reward.png +0 -0
  38. package/cordis.patch.yml +6 -0
  39. package/docs/aihubmix-gemini-vision.i18n.yaml +6 -0
  40. package/docs/aihubmix-gemini-vision.md +138 -0
  41. package/docs/aihubmix-gemini-vision.zh.md +138 -0
  42. package/docs/assets/aihubmix-api-keys.png +0 -0
  43. package/docs/assets/aihubmix-create-key.png +0 -0
  44. package/docs/assets/aihubmix-free-vision-model.png +0 -0
  45. package/docs/assets/aihubmix-home.png +0 -0
  46. package/docs/assets/aihubmix-sign-up.png +0 -0
  47. package/docs/assets/groq-console-home.png +0 -0
  48. package/docs/assets/groq-console-keys-login.png +0 -0
  49. package/docs/assets/groq-docs-quickstart.png +0 -0
  50. package/docs/assets/groq-docs-qwen3.6.png +0 -0
  51. package/docs/assets/groq-docs-vision.png +0 -0
  52. package/docs/assets/vision-settings.png +0 -0
  53. package/docs/dsh-desktop-install.md +67 -0
  54. package/docs/dsh-desktop-install.zh.md +67 -0
  55. package/docs/groq-qwen3.6-vision.i18n.yaml +6 -0
  56. package/docs/groq-qwen3.6-vision.md +236 -0
  57. package/docs/groq-qwen3.6-vision.zh.md +236 -0
  58. package/docs/python-runtime.i18n.yaml +6 -0
  59. package/docs/python-runtime.md +89 -0
  60. package/docs/python-runtime.zh.md +89 -0
  61. package/docs/requirements-traceability/README.i18n.yaml +6 -0
  62. package/docs/requirements-traceability/README.md +75 -0
  63. package/docs/requirements-traceability/README.zh.md +75 -0
  64. package/examples/ui-restoration/README.i18n.yaml +6 -0
  65. package/examples/ui-restoration/README.md +70 -0
  66. package/examples/ui-restoration/README.zh.md +70 -0
  67. package/examples/ui-restoration/assets/final-heatmap.png +0 -0
  68. package/examples/ui-restoration/assets/final-report.json +83 -0
  69. package/examples/ui-restoration/assets/implementation.png +0 -0
  70. package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
  71. package/examples/ui-restoration/assets/initial-report.json +83 -0
  72. package/examples/ui-restoration/assets/initial.png +0 -0
  73. package/examples/ui-restoration/assets/metrics.json +12 -0
  74. package/examples/ui-restoration/assets/reference.png +0 -0
  75. package/examples/ui-restoration/implementation.html +94 -0
  76. package/examples/ui-restoration/initial.html +57 -0
  77. package/lib/artifact-access.js +369 -0
  78. package/lib/artifact-access.js.map +1 -0
  79. package/lib/artifacts.js +56 -0
  80. package/lib/artifacts.js.map +1 -0
  81. package/lib/client.js +2099 -0
  82. package/lib/client.js.map +1 -0
  83. package/lib/config.js +172 -0
  84. package/lib/config.js.map +1 -0
  85. package/lib/defaults.js +6 -0
  86. package/lib/defaults.js.map +1 -0
  87. package/lib/errors.js +56 -0
  88. package/lib/errors.js.map +1 -0
  89. package/lib/evidence-cache.js +350 -0
  90. package/lib/evidence-cache.js.map +1 -0
  91. package/lib/exposure.js +247 -0
  92. package/lib/exposure.js.map +1 -0
  93. package/lib/image-input-variants.js +882 -0
  94. package/lib/image-input-variants.js.map +1 -0
  95. package/lib/index.js +105 -0
  96. package/lib/index.js.map +1 -0
  97. package/lib/paste-images.js +217 -0
  98. package/lib/paste-images.js.map +1 -0
  99. package/lib/paths.js +354 -0
  100. package/lib/paths.js.map +1 -0
  101. package/lib/plugin-update.js +1003 -0
  102. package/lib/plugin-update.js.map +1 -0
  103. package/lib/runtime-install.js +964 -0
  104. package/lib/runtime-install.js.map +1 -0
  105. package/lib/runtime-manager.js +133 -0
  106. package/lib/runtime-manager.js.map +1 -0
  107. package/lib/runtime.js +1678 -0
  108. package/lib/runtime.js.map +1 -0
  109. package/lib/skill.js +25 -0
  110. package/lib/skill.js.map +1 -0
  111. package/lib/tools.js +549 -0
  112. package/lib/tools.js.map +1 -0
  113. package/lib/types/artifact-access.d.ts +61 -0
  114. package/lib/types/artifact-access.d.ts.map +1 -0
  115. package/lib/types/artifacts.d.ts +42 -0
  116. package/lib/types/artifacts.d.ts.map +1 -0
  117. package/lib/types/client/display-config.d.ts +24 -0
  118. package/lib/types/client/display-config.d.ts.map +1 -0
  119. package/lib/types/client/index.d.ts +350 -0
  120. package/lib/types/client/index.d.ts.map +1 -0
  121. package/lib/types/client/model-variants-hider.d.ts +40 -0
  122. package/lib/types/client/model-variants-hider.d.ts.map +1 -0
  123. package/lib/types/client/paste-images.d.ts +126 -0
  124. package/lib/types/client/paste-images.d.ts.map +1 -0
  125. package/lib/types/config.d.ts +123 -0
  126. package/lib/types/config.d.ts.map +1 -0
  127. package/lib/types/defaults.d.ts +6 -0
  128. package/lib/types/defaults.d.ts.map +1 -0
  129. package/lib/types/errors.d.ts +35 -0
  130. package/lib/types/errors.d.ts.map +1 -0
  131. package/lib/types/evidence-cache.d.ts +91 -0
  132. package/lib/types/evidence-cache.d.ts.map +1 -0
  133. package/lib/types/exposure.d.ts +50 -0
  134. package/lib/types/exposure.d.ts.map +1 -0
  135. package/lib/types/image-input-variants.d.ts +144 -0
  136. package/lib/types/image-input-variants.d.ts.map +1 -0
  137. package/lib/types/index.d.ts +19 -0
  138. package/lib/types/index.d.ts.map +1 -0
  139. package/lib/types/paste-images.d.ts +73 -0
  140. package/lib/types/paste-images.d.ts.map +1 -0
  141. package/lib/types/paths.d.ts +117 -0
  142. package/lib/types/paths.d.ts.map +1 -0
  143. package/lib/types/plugin-update.d.ts +119 -0
  144. package/lib/types/plugin-update.d.ts.map +1 -0
  145. package/lib/types/runtime-install.d.ts +92 -0
  146. package/lib/types/runtime-install.d.ts.map +1 -0
  147. package/lib/types/runtime-manager.d.ts +60 -0
  148. package/lib/types/runtime-manager.d.ts.map +1 -0
  149. package/lib/types/runtime.d.ts +412 -0
  150. package/lib/types/runtime.d.ts.map +1 -0
  151. package/lib/types/skill.d.ts +14 -0
  152. package/lib/types/skill.d.ts.map +1 -0
  153. package/lib/types/tools.d.ts +35 -0
  154. package/lib/types/tools.d.ts.map +1 -0
  155. package/lib/types/upstream.d.ts +233 -0
  156. package/lib/types/upstream.d.ts.map +1 -0
  157. package/lib/types/version.d.ts +15 -0
  158. package/lib/types/version.d.ts.map +1 -0
  159. package/lib/types/web-request.d.ts +11 -0
  160. package/lib/types/web-request.d.ts.map +1 -0
  161. package/lib/types/web.d.ts +118 -0
  162. package/lib/types/web.d.ts.map +1 -0
  163. package/lib/upstream.js +860 -0
  164. package/lib/upstream.js.map +1 -0
  165. package/lib/version.js +18 -0
  166. package/lib/version.js.map +1 -0
  167. package/lib/web-request.js +29 -0
  168. package/lib/web-request.js.map +1 -0
  169. package/lib/web.js +462 -0
  170. package/lib/web.js.map +1 -0
  171. package/package.json +149 -0
  172. package/patches/vision-tools-dsh.patch +970 -0
  173. package/runtime/requirements.lock +3 -0
  174. package/src/artifact-access.ts +386 -0
  175. package/src/artifacts.ts +85 -0
  176. package/src/client/display-config.ts +62 -0
  177. package/src/client/index.tsx +1580 -0
  178. package/src/client/model-variants-hider.ts +159 -0
  179. package/src/client/paste-images.tsx +747 -0
  180. package/src/config.ts +290 -0
  181. package/src/defaults.ts +5 -0
  182. package/src/errors.ts +62 -0
  183. package/src/evidence-cache.ts +418 -0
  184. package/src/exposure.ts +265 -0
  185. package/src/image-input-variants.ts +996 -0
  186. package/src/index.ts +141 -0
  187. package/src/paste-images.ts +286 -0
  188. package/src/paths.ts +383 -0
  189. package/src/plugin-update.ts +1143 -0
  190. package/src/runtime-install.ts +1161 -0
  191. package/src/runtime-manager.ts +174 -0
  192. package/src/runtime.ts +2189 -0
  193. package/src/skill.ts +35 -0
  194. package/src/tools.ts +691 -0
  195. package/src/upstream.ts +1078 -0
  196. package/src/version.ts +37 -0
  197. package/src/web-request.ts +27 -0
  198. package/src/web.ts +594 -0
  199. package/vendor/agent-vision-toolkit/CHANGELOG.md +30 -0
  200. package/vendor/agent-vision-toolkit/LICENSE +21 -0
  201. package/vendor/agent-vision-toolkit/README.md +405 -0
  202. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +94 -0
  203. package/vendor/agent-vision-toolkit/bin/crop +90 -0
  204. package/vendor/agent-vision-toolkit/bin/detect +13 -0
  205. package/vendor/agent-vision-toolkit/bin/glance +93 -0
  206. package/vendor/agent-vision-toolkit/bin/ground +13 -0
  207. package/vendor/agent-vision-toolkit/bin/trace +129 -0
  208. package/vendor/agent-vision-toolkit/detect.py +60 -0
  209. package/vendor/agent-vision-toolkit/ground.py +267 -0
  210. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
  211. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
  212. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +420 -0
  213. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  214. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
  215. package/vendor/agent-vision-toolkit/tests/test_vision_client.py +450 -0
  216. package/vendor/agent-vision-toolkit/vision_client.py +310 -0
@@ -0,0 +1,3 @@
1
+ pillow==12.3.0
2
+ numpy==2.4.6
3
+ vtracer==0.6.15
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Capability-gated HTTP delivery for managed Vision Toolkit artifacts.
3
+ * Signed tokens are durable across process restarts, expose no secret, and
4
+ * are accepted only for the exact artifact facts projected into a tool result.
5
+ * @module dsh-vision-toolkit/artifact-access
6
+ */
7
+
8
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
9
+ import { constants as fsConstants } from 'node:fs'
10
+ import type { Stats } from 'node:fs'
11
+ import { chmod, lstat, mkdir, open, readFile, realpath, writeFile } from 'node:fs/promises'
12
+ import type { FileHandle } from 'node:fs/promises'
13
+ import type { IncomingMessage, ServerResponse } from 'node:http'
14
+ import { basename, dirname, extname, isAbsolute, join, relative, sep } from 'node:path'
15
+ import type { JsonValue } from '@deepseek-ai/dsh-tools'
16
+ import type { ArtifactDescriptor, ArtifactKind } from './artifacts.ts'
17
+ import { isWithin } from './paths.ts'
18
+ import { visionToolkitStateRoot } from './runtime-install.ts'
19
+
20
+ /** Prefix owned by the plugin's artifact capability route. */
21
+ export const ARTIFACT_ROUTE_PREFIX = '/_dsh/vision-toolkit/artifacts'
22
+
23
+ /** Presentation metadata key reserved by the browser half of this package. */
24
+ export const PRESENTATION_META_KEY = '$dshVisionToolkit'
25
+
26
+ interface ArtifactTokenPayload {
27
+ v: 1
28
+ path: string
29
+ filename: string
30
+ mimeType: string
31
+ kind: ArtifactKind
32
+ bytes: number
33
+ }
34
+
35
+ /** Browser-only access grant paired to one model-visible artifact descriptor. */
36
+ export interface ArtifactAccessGrant {
37
+ path: string
38
+ previewUrl: string
39
+ downloadUrl: string
40
+ }
41
+
42
+ interface ArtifactPresentationEnvelope {
43
+ schemaVersion: 1
44
+ artifacts: ArtifactAccessGrant[]
45
+ }
46
+
47
+ const KEY_BYTES = 32
48
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/
49
+ const MAX_TOKEN_LENGTH = 16 * 1024
50
+
51
+ const MIME_BY_EXTENSION = new Map<string, { mimeType: string; kind: ArtifactKind }>([
52
+ ['.png', { mimeType: 'image/png', kind: 'image' }],
53
+ ['.jpg', { mimeType: 'image/jpeg', kind: 'image' }],
54
+ ['.jpeg', { mimeType: 'image/jpeg', kind: 'image' }],
55
+ ['.gif', { mimeType: 'image/gif', kind: 'image' }],
56
+ ['.webp', { mimeType: 'image/webp', kind: 'image' }],
57
+ ['.svg', { mimeType: 'image/svg+xml', kind: 'svg' }],
58
+ ['.md', { mimeType: 'text/markdown', kind: 'markdown' }],
59
+ ['.json', { mimeType: 'application/json', kind: 'json' }],
60
+ ])
61
+
62
+ function isRecord(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
64
+ }
65
+
66
+ function isArtifactKind(value: unknown): value is ArtifactKind {
67
+ return value === 'image' || value === 'svg' || value === 'markdown' || value === 'json'
68
+ }
69
+
70
+ function artifactFrom(value: unknown): ArtifactDescriptor | undefined {
71
+ if (!isRecord(value)) return undefined
72
+ if (
73
+ typeof value.path !== 'string'
74
+ || typeof value.filename !== 'string'
75
+ || typeof value.mimeType !== 'string'
76
+ || !isArtifactKind(value.kind)
77
+ || typeof value.description !== 'string'
78
+ || typeof value.sourceTool !== 'string'
79
+ || (value.previewIntent !== 'image' && value.previewIntent !== 'svg' && value.previewIntent !== 'text' && value.previewIntent !== 'download')
80
+ || typeof value.bytes !== 'number'
81
+ || !Number.isSafeInteger(value.bytes)
82
+ || value.bytes < 0
83
+ ) return undefined
84
+ return value as unknown as ArtifactDescriptor
85
+ }
86
+
87
+ function collectArtifacts(value: unknown, found: Map<string, ArtifactDescriptor>, depth = 0): void {
88
+ if (depth > 16) return
89
+ const artifact = artifactFrom(value)
90
+ if (artifact !== undefined) {
91
+ found.set(artifact.path, artifact)
92
+ return
93
+ }
94
+ if (Array.isArray(value)) {
95
+ for (const entry of value) collectArtifacts(entry, found, depth + 1)
96
+ return
97
+ }
98
+ if (!isRecord(value)) return
99
+ for (const entry of Object.values(value)) collectArtifacts(entry, found, depth + 1)
100
+ }
101
+
102
+ function parsePayload(value: unknown): ArtifactTokenPayload | undefined {
103
+ if (!isRecord(value)) return undefined
104
+ if (
105
+ value.v !== 1
106
+ || typeof value.path !== 'string'
107
+ || !isAbsolute(value.path)
108
+ || typeof value.filename !== 'string'
109
+ || basename(value.path) !== value.filename
110
+ || typeof value.mimeType !== 'string'
111
+ || !isArtifactKind(value.kind)
112
+ || typeof value.bytes !== 'number'
113
+ || !Number.isSafeInteger(value.bytes)
114
+ || value.bytes < 0
115
+ ) return undefined
116
+ const expected = MIME_BY_EXTENSION.get(extname(value.path).toLowerCase())
117
+ if (expected === undefined || expected.mimeType !== value.mimeType || expected.kind !== value.kind) return undefined
118
+ return value as unknown as ArtifactTokenPayload
119
+ }
120
+
121
+ function mac(key: Buffer, payload: string): Buffer {
122
+ return createHmac('sha256', key).update(payload).digest()
123
+ }
124
+
125
+ function safeEqual(left: Buffer, right: Buffer): boolean {
126
+ return left.length === right.length && timingSafeEqual(left, right)
127
+ }
128
+
129
+ async function readKey(path: string): Promise<Buffer> {
130
+ const info = await lstat(path)
131
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error('artifact access key is not a regular file')
132
+ const key = await readFile(path)
133
+ if (key.length !== KEY_BYTES) throw new Error('artifact access key has an invalid length')
134
+ await chmod(path, 0o600)
135
+ return key
136
+ }
137
+
138
+ /**
139
+ * Load or atomically create the per-DSH-home signing key.
140
+ * @param root - state root override used by tests; defaults to the plugin cache.
141
+ * @returns the 32-byte signing key.
142
+ */
143
+ export async function prepareArtifactAccessKey(root = visionToolkitStateRoot()): Promise<Buffer> {
144
+ await mkdir(root, { recursive: true, mode: 0o700 })
145
+ const path = join(root, 'artifact-access.key')
146
+ try {
147
+ return await readKey(path)
148
+ } catch (error) {
149
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
150
+ }
151
+ const candidate = randomBytes(KEY_BYTES)
152
+ try {
153
+ await writeFile(path, candidate, { flag: 'wx', mode: 0o600 })
154
+ return candidate
155
+ } catch (error) {
156
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
157
+ return readKey(path)
158
+ }
159
+ }
160
+
161
+ function artifactRoot(path: string): string | undefined {
162
+ let current = dirname(path)
163
+ while (true) {
164
+ if (basename(current) === 'artifacts' && basename(dirname(current)) === '.dsh-vision-toolkit') return current
165
+ const parent = dirname(current)
166
+ if (parent === current) return undefined
167
+ current = parent
168
+ }
169
+ }
170
+
171
+ async function assertNoSymlinkPath(root: string, path: string): Promise<void> {
172
+ const rootInfo = await lstat(root)
173
+ if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) throw new Error('artifact root is not a real directory')
174
+ const rel = relative(root, path)
175
+ if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
176
+ throw new Error('artifact path escaped its managed root')
177
+ }
178
+ let current = root
179
+ const parts = rel.split(sep)
180
+ for (let index = 0; index < parts.length; index += 1) {
181
+ const part = parts[index]
182
+ if (part === undefined || part.length === 0 || part === '.' || part === '..') throw new Error('artifact path is malformed')
183
+ current = join(current, part)
184
+ const info = await lstat(current)
185
+ if (info.isSymbolicLink()) throw new Error('artifact path contains a symbolic link')
186
+ const final = index === parts.length - 1
187
+ if (final ? !info.isFile() : !info.isDirectory()) throw new Error('artifact path contains an unexpected entry type')
188
+ }
189
+ const [realRoot, realFile] = await Promise.all([realpath(root), realpath(path)])
190
+ if (!isWithin(realRoot, realFile)) throw new Error('artifact path escaped its managed root')
191
+ }
192
+
193
+ function sameFile(opened: Stats, current: Stats): boolean {
194
+ if (opened.dev === 0 || current.dev === 0 || opened.ino === 0 || current.ino === 0) return true
195
+ return opened.dev === current.dev && opened.ino === current.ino
196
+ }
197
+
198
+ async function openVerifiedArtifact(payload: ArtifactTokenPayload): Promise<{ handle: FileHandle; info: Stats }> {
199
+ const root = artifactRoot(payload.path)
200
+ if (root === undefined) throw new Error('artifact path is outside the managed delivery tree')
201
+ await assertNoSymlinkPath(root, payload.path)
202
+ const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0
203
+ const handle = await open(payload.path, fsConstants.O_RDONLY | noFollow)
204
+ try {
205
+ const info = await handle.stat()
206
+ if (!info.isFile() || info.size !== payload.bytes) throw new Error('artifact no longer matches its delivered descriptor')
207
+ const current = await lstat(payload.path)
208
+ if (current.isSymbolicLink() || !current.isFile() || !sameFile(info, current)) {
209
+ throw new Error('artifact changed while it was being opened')
210
+ }
211
+ await assertNoSymlinkPath(root, payload.path)
212
+ return { handle, info }
213
+ } catch (error) {
214
+ await handle.close().catch(() => {})
215
+ throw error
216
+ }
217
+ }
218
+
219
+ function asciiFilename(filename: string): string {
220
+ const fallback = filename.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 160)
221
+ return fallback.length === 0 ? 'artifact' : fallback
222
+ }
223
+
224
+ function contentDisposition(filename: string, download: boolean): string {
225
+ const mode = download ? 'attachment' : 'inline'
226
+ return `${mode}; filename="${asciiFilename(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`
227
+ }
228
+
229
+ function securityHeaders(res: ServerResponse, payload: ArtifactTokenPayload, download: boolean): void {
230
+ res.setHeader('Content-Type', payload.mimeType)
231
+ res.setHeader('Content-Length', String(payload.bytes))
232
+ res.setHeader('Content-Disposition', contentDisposition(payload.filename, download))
233
+ res.setHeader('X-Content-Type-Options', 'nosniff')
234
+ res.setHeader('Cache-Control', 'private, no-store, max-age=0')
235
+ res.setHeader('Referrer-Policy', 'no-referrer')
236
+ res.setHeader('Cross-Origin-Resource-Policy', 'same-origin')
237
+ res.setHeader('Content-Security-Policy', payload.kind === 'svg'
238
+ ? "sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'"
239
+ : "sandbox; default-src 'none'")
240
+ }
241
+
242
+ /** Signed-capability encoder and safe Artifact route handler. */
243
+ export class ArtifactAccessController {
244
+ private routeCount = 0
245
+
246
+ constructor(private readonly key: Buffer) {
247
+ if (key.length !== KEY_BYTES) throw new TypeError(`artifact access key must be ${KEY_BYTES} bytes`)
248
+ }
249
+
250
+ /** Whether at least one HTTP carrier currently owns the route. */
251
+ get routeAvailable(): boolean {
252
+ return this.routeCount > 0
253
+ }
254
+
255
+ /** Mark one route attachment; the returned disposer removes that attachment. */
256
+ attachRoute(): () => void {
257
+ this.routeCount += 1
258
+ let active = true
259
+ return () => {
260
+ if (!active) return
261
+ active = false
262
+ this.routeCount -= 1
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Purely enrich a canonical tool-result value with browser access grants.
268
+ * @param value - schema-validated tool result.
269
+ * @returns the unchanged value when no route/artifact exists, otherwise a detached metadata envelope.
270
+ */
271
+ presentationMeta(value: JsonValue): JsonValue {
272
+ if (!this.routeAvailable || !isRecord(value)) return value
273
+ const artifacts = new Map<string, ArtifactDescriptor>()
274
+ collectArtifacts(value, artifacts)
275
+ if (artifacts.size === 0) return value
276
+ const grants = [...artifacts.values()].map((artifact): ArtifactAccessGrant => {
277
+ const token = this.sign(artifact)
278
+ const previewUrl = `${ARTIFACT_ROUTE_PREFIX}/${token}`
279
+ return { path: artifact.path, previewUrl, downloadUrl: `${previewUrl}?download=1` }
280
+ })
281
+ const envelope: ArtifactPresentationEnvelope = { schemaVersion: 1, artifacts: grants }
282
+ return { ...value, [PRESENTATION_META_KEY]: envelope } as unknown as JsonValue
283
+ }
284
+
285
+ /** Mint a deterministic, tamper-evident capability for one descriptor. */
286
+ sign(artifact: ArtifactDescriptor): string {
287
+ const payload: ArtifactTokenPayload = {
288
+ v: 1,
289
+ path: artifact.path,
290
+ filename: artifact.filename,
291
+ mimeType: artifact.mimeType,
292
+ kind: artifact.kind,
293
+ bytes: artifact.bytes,
294
+ }
295
+ const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
296
+ return `${encoded}.${mac(this.key, encoded).toString('base64url')}`
297
+ }
298
+
299
+ /** Verify and decode one capability without touching the filesystem. */
300
+ verify(token: string): ArtifactTokenPayload | undefined {
301
+ if (token.length === 0 || token.length > MAX_TOKEN_LENGTH || !TOKEN_PATTERN.test(token)) return undefined
302
+ const [encoded, signature] = token.split('.')
303
+ if (encoded === undefined || signature === undefined) return undefined
304
+ let supplied: Buffer
305
+ try {
306
+ supplied = Buffer.from(signature, 'base64url')
307
+ } catch {
308
+ return undefined
309
+ }
310
+ if (!safeEqual(mac(this.key, encoded), supplied)) return undefined
311
+ try {
312
+ return parsePayload(JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')))
313
+ } catch {
314
+ return undefined
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Serve one GET/HEAD capability request with MIME, CSP, and symlink checks.
320
+ * @param req - Node HTTP request matched under {@link ARTIFACT_ROUTE_PREFIX}.
321
+ * @param res - response owned by this handler.
322
+ */
323
+ async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
324
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
325
+ res.setHeader('Allow', 'GET, HEAD')
326
+ res.writeHead(405)
327
+ res.end()
328
+ return
329
+ }
330
+ let url: URL
331
+ try {
332
+ url = new URL(req.url ?? '/', 'http://dsh.local')
333
+ } catch {
334
+ res.writeHead(400)
335
+ res.end()
336
+ return
337
+ }
338
+ const prefix = `${ARTIFACT_ROUTE_PREFIX}/`
339
+ if (!url.pathname.startsWith(prefix) || url.pathname.slice(prefix.length).includes('/')) {
340
+ res.writeHead(404)
341
+ res.end()
342
+ return
343
+ }
344
+ let token: string
345
+ try {
346
+ token = decodeURIComponent(url.pathname.slice(prefix.length))
347
+ } catch {
348
+ res.writeHead(404)
349
+ res.end()
350
+ return
351
+ }
352
+ const payload = this.verify(token)
353
+ if (payload === undefined) {
354
+ res.writeHead(404)
355
+ res.end()
356
+ return
357
+ }
358
+ const downloadValue = url.searchParams.get('download')
359
+ if ([...url.searchParams.keys()].some(key => key !== 'download') || (downloadValue !== null && downloadValue !== '1')) {
360
+ res.writeHead(400)
361
+ res.end()
362
+ return
363
+ }
364
+ let opened: { handle: FileHandle; info: Stats }
365
+ try {
366
+ opened = await openVerifiedArtifact(payload)
367
+ } catch {
368
+ res.writeHead(404)
369
+ res.end()
370
+ return
371
+ }
372
+ securityHeaders(res, payload, downloadValue === '1')
373
+ res.writeHead(200)
374
+ if (req.method === 'HEAD') {
375
+ await opened.handle.close().catch(() => {})
376
+ res.end()
377
+ return
378
+ }
379
+ const stream = opened.handle.createReadStream({ autoClose: true })
380
+ stream.on('error', () => {
381
+ if (!res.headersSent) res.writeHead(500)
382
+ res.destroy()
383
+ })
384
+ stream.pipe(res)
385
+ }
386
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Stable file-delivery descriptors owned by the plugin. DSH Core currently
3
+ * has no reusable Artifact service, so every file-producing tool returns this
4
+ * lossless contract and keeps the file inside the plugin-managed artifact
5
+ * directory for Web, Headless, and later tool calls alike.
6
+ * @module dsh-vision-toolkit/artifacts
7
+ */
8
+
9
+ import { lstat, realpath, stat } from 'node:fs/promises'
10
+ import { basename } from 'node:path'
11
+ import { VisionToolkitError } from './errors.ts'
12
+ import { isWithin, type PathPolicy } from './paths.ts'
13
+
14
+ /** Artifact payload family used by clients to select a safe renderer. */
15
+ export type ArtifactKind = 'image' | 'svg' | 'markdown' | 'json'
16
+
17
+ /** Intended default client action for one artifact. */
18
+ export type ArtifactPreviewIntent = 'image' | 'svg' | 'text' | 'download'
19
+
20
+ /** Stable descriptor returned by every file-producing Vision Toolkit tool. */
21
+ export interface ArtifactDescriptor {
22
+ path: string
23
+ filename: string
24
+ mimeType: string
25
+ kind: ArtifactKind
26
+ description: string
27
+ sourceTool: string
28
+ previewIntent: ArtifactPreviewIntent
29
+ bytes: number
30
+ }
31
+
32
+ /** Metadata needed to describe an already committed managed artifact. */
33
+ export interface ArtifactDescription {
34
+ mimeType: string
35
+ kind: ArtifactKind
36
+ description: string
37
+ sourceTool: string
38
+ previewIntent: ArtifactPreviewIntent
39
+ }
40
+
41
+ /**
42
+ * Validate and describe one committed regular file under the active artifact
43
+ * root. Symbolic links are rejected even when their targets remain in-bounds,
44
+ * so a later preview/download can never be redirected after delivery.
45
+ * @param path - final managed artifact path.
46
+ * @param policy - active workspace path policy.
47
+ * @param description - stable type and presentation facts.
48
+ * @returns a complete descriptor with the committed byte size.
49
+ */
50
+ export async function describeArtifact(
51
+ path: string,
52
+ policy: PathPolicy,
53
+ description: ArtifactDescription,
54
+ ): Promise<ArtifactDescriptor> {
55
+ let linkInfo
56
+ try {
57
+ linkInfo = await lstat(path)
58
+ } catch (error) {
59
+ throw new VisionToolkitError('output', `artifact was not created: ${basename(path)}`, { cause: error })
60
+ }
61
+ if (linkInfo.isSymbolicLink()) {
62
+ throw new VisionToolkitError('path', `artifact must not be a symbolic link: ${basename(path)}`)
63
+ }
64
+ let real: string
65
+ try {
66
+ real = await realpath(path)
67
+ } catch (error) {
68
+ throw new VisionToolkitError('output', `artifact is not accessible: ${basename(path)}`, { cause: error })
69
+ }
70
+ if (!isWithin(policy.outputDir, real)) {
71
+ throw new VisionToolkitError('path', `artifact escaped the managed output directory: ${basename(path)}`)
72
+ }
73
+ const info = await stat(real)
74
+ if (!info.isFile()) throw new VisionToolkitError('output', `artifact is not a regular file: ${basename(path)}`)
75
+ return {
76
+ path,
77
+ filename: basename(path),
78
+ mimeType: description.mimeType,
79
+ kind: description.kind,
80
+ description: description.description,
81
+ sourceTool: description.sourceTool,
82
+ previewIntent: description.previewIntent,
83
+ bytes: info.size,
84
+ }
85
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Browser-side display-mode flags for transparent variant routing. The paste
3
+ * integration uses a short-lived cache so it does not hammer the same-origin
4
+ * route on every paste. The model selector itself decides purely from DOM
5
+ * display names and does not read this route.
6
+ * @module dsh-vision-toolkit/display-config
7
+ */
8
+
9
+ export const DISPLAY_CONFIG_ROUTE = '/_dsh/vision-toolkit/display-config'
10
+
11
+ const CONFIG_TTL_MS = 10_000
12
+
13
+ interface CachedDisplayConfig {
14
+ hidden: boolean
15
+ at: number
16
+ }
17
+
18
+ let cached: CachedDisplayConfig | undefined
19
+ let cacheEpoch = 0
20
+
21
+ /**
22
+ * Resolve the current transparent-routing flag, failing closed to non-hidden
23
+ * (explicit sibling entries) when the route is unreachable or the payload is
24
+ * malformed.
25
+ * @returns the display-mode flags observed from the host.
26
+ */
27
+ export async function readDisplayConfig(): Promise<{ hidden: boolean }> {
28
+ for (;;) {
29
+ const now = Date.now()
30
+ if (cached !== undefined && now - cached.at < CONFIG_TTL_MS) {
31
+ return { hidden: cached.hidden }
32
+ }
33
+ const epoch = cacheEpoch
34
+ try {
35
+ const response = await fetch(DISPLAY_CONFIG_ROUTE, { cache: 'no-store' })
36
+ if (epoch !== cacheEpoch) continue
37
+ const body = await response.json() as { ok?: boolean; value?: { hidden?: unknown } }
38
+ if (epoch !== cacheEpoch) continue
39
+ if (body.ok !== true || typeof body.value?.hidden !== 'boolean') {
40
+ throw new Error('malformed display-config payload')
41
+ }
42
+ cached = { hidden: body.value.hidden, at: now }
43
+ return { hidden: body.value.hidden }
44
+ } catch {
45
+ if (epoch !== cacheEpoch) continue
46
+ // Transparent routing is an enhancement: an unreachable config must never
47
+ // hide anything or change paste behavior.
48
+ cached = { hidden: false, at: now }
49
+ return { hidden: false }
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Drop the cached flag and invalidate in-flight responses (test seams,
56
+ * Settings saves, and connection-reset handling). An older request that
57
+ * resolves afterwards must not repopulate the cache with a stale flag.
58
+ */
59
+ export function resetDisplayConfigCache(): void {
60
+ cached = undefined
61
+ cacheEpoch += 1
62
+ }