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