@mengruo/dsh-vision-toolkit 0.1.3 → 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.
- package/README.i18n.yaml +2 -2
- package/README.md +15 -70
- package/README.zh.md +15 -69
- package/docs/requirements-traceability/README.i18n.yaml +2 -2
- package/docs/requirements-traceability/README.md +1 -1
- package/docs/requirements-traceability/README.zh.md +1 -1
- package/lib/artifact-access.js +20 -2
- package/lib/artifact-access.js.map +1 -1
- package/lib/client.js +128 -12
- package/lib/client.js.map +1 -1
- package/lib/config.js +69 -0
- package/lib/config.js.map +1 -1
- package/lib/errors.js +25 -2
- package/lib/errors.js.map +1 -1
- package/lib/evidence-cache.js +2 -1
- package/lib/evidence-cache.js.map +1 -1
- package/lib/exposure.js +14 -1
- package/lib/exposure.js.map +1 -1
- package/lib/image-input-variants.js +22 -12
- package/lib/image-input-variants.js.map +1 -1
- package/lib/index.js +53 -6
- package/lib/index.js.map +1 -1
- package/lib/object-storage.js +141 -0
- package/lib/object-storage.js.map +1 -0
- package/lib/paste-images.js +67 -19
- package/lib/paste-images.js.map +1 -1
- package/lib/paths.js +214 -28
- package/lib/paths.js.map +1 -1
- package/lib/runtime-manager.js +76 -10
- package/lib/runtime-manager.js.map +1 -1
- package/lib/runtime.js +219 -38
- package/lib/runtime.js.map +1 -1
- package/lib/storage-history.js +154 -0
- package/lib/storage-history.js.map +1 -0
- package/lib/types/artifact-access.d.ts.map +1 -1
- package/lib/types/client/index.d.ts +45 -2
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/paste-images.d.ts +2 -0
- package/lib/types/client/paste-images.d.ts.map +1 -1
- package/lib/types/config.d.ts +55 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/errors.d.ts +18 -2
- package/lib/types/errors.d.ts.map +1 -1
- package/lib/types/evidence-cache.d.ts +1 -1
- package/lib/types/evidence-cache.d.ts.map +1 -1
- package/lib/types/exposure.d.ts.map +1 -1
- package/lib/types/image-input-variants.d.ts +5 -3
- package/lib/types/image-input-variants.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/object-storage.d.ts +54 -0
- package/lib/types/object-storage.d.ts.map +1 -0
- package/lib/types/paste-images.d.ts +12 -4
- package/lib/types/paste-images.d.ts.map +1 -1
- package/lib/types/paths.d.ts +31 -5
- package/lib/types/paths.d.ts.map +1 -1
- package/lib/types/runtime-manager.d.ts +28 -4
- package/lib/types/runtime-manager.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +31 -1
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/storage-history.d.ts +63 -0
- package/lib/types/storage-history.d.ts.map +1 -0
- package/lib/types/upstream.d.ts +1 -0
- package/lib/types/upstream.d.ts.map +1 -1
- package/lib/types/web.d.ts +7 -0
- package/lib/types/web.d.ts.map +1 -1
- package/lib/upstream.js +34 -8
- package/lib/upstream.js.map +1 -1
- package/lib/web.js +44 -9
- package/lib/web.js.map +1 -1
- package/package.json +3 -1
- package/src/artifact-access.ts +22 -2
- package/src/client/index.tsx +169 -9
- package/src/client/paste-images.tsx +14 -4
- package/src/config.ts +128 -0
- package/src/errors.ts +25 -2
- package/src/evidence-cache.ts +2 -1
- package/src/exposure.ts +16 -2
- package/src/image-input-variants.ts +21 -6
- package/src/index.ts +65 -6
- package/src/object-storage.ts +174 -0
- package/src/paste-images.ts +81 -19
- package/src/paths.ts +249 -28
- package/src/runtime-manager.ts +93 -10
- package/src/runtime.ts +215 -36
- package/src/storage-history.ts +172 -0
- package/src/upstream.ts +36 -7
- package/src/web.ts +54 -9
- package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +11 -11
- package/vendor/agent-vision-toolkit/__pycache__/detect.cpython-314.pyc +0 -0
- package/vendor/agent-vision-toolkit/__pycache__/ground.cpython-314.pyc +0 -0
- package/vendor/agent-vision-toolkit/__pycache__/vision_client.cpython-314.pyc +0 -0
- package/vendor/agent-vision-toolkit/bin/__pycache__/glancecpython-314.pyc +0 -0
- package/vendor/agent-vision-toolkit/bin/glance +8 -1
- package/vendor/agent-vision-toolkit/detect.py +13 -7
- package/vendor/agent-vision-toolkit/ground.py +43 -18
- package/vendor/agent-vision-toolkit/tests/test_vision_client.py +88 -0
- package/vendor/agent-vision-toolkit/vision_client.py +84 -6
- package/assets/community-group-qr.png +0 -0
- package/assets/logo_aihubmix.png +0 -0
- package/assets/logo_eapi_dark.png +0 -0
- package/assets/wechat-reward.png +0 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal S3-compatible object storage bridge used by the URL image-transfer
|
|
3
|
+
* path. It uploads one image, resolves a model-reachable URL (a configured
|
|
4
|
+
* public base URL or a temporary presigned URL), and deletes the object after
|
|
5
|
+
* the vision operation settles. It also provides the Settings "test storage"
|
|
6
|
+
* probe (upload → head → delete).
|
|
7
|
+
* @module dsh-vision-toolkit/object-storage
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
11
|
+
import { readFile } from 'node:fs/promises'
|
|
12
|
+
import { basename } from 'node:path'
|
|
13
|
+
import {
|
|
14
|
+
DeleteObjectCommand,
|
|
15
|
+
GetObjectCommand,
|
|
16
|
+
HeadObjectCommand,
|
|
17
|
+
PutObjectCommand,
|
|
18
|
+
S3Client,
|
|
19
|
+
type S3ClientConfig,
|
|
20
|
+
} from '@aws-sdk/client-s3'
|
|
21
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
|
22
|
+
import { VisionToolkitError } from './errors.ts'
|
|
23
|
+
|
|
24
|
+
/** Fully resolved object-storage connection settings (secrets already filled). */
|
|
25
|
+
export interface ObjectStorageSettings {
|
|
26
|
+
endpoint: string
|
|
27
|
+
bucket: string
|
|
28
|
+
accessKeyId: string
|
|
29
|
+
secretAccessKey: string
|
|
30
|
+
publicBase?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether the required connection fields are present enough to attempt a request. */
|
|
34
|
+
export function isObjectStorageConfigured(settings: ObjectStorageSettings): boolean {
|
|
35
|
+
return settings.endpoint.length > 0
|
|
36
|
+
&& settings.bucket.length > 0
|
|
37
|
+
&& settings.accessKeyId.length > 0
|
|
38
|
+
&& settings.secretAccessKey.length > 0
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Stable object-key prefix so every upload lives under one deletable namespace. */
|
|
42
|
+
const OBJECT_KEY_PREFIX = 'dsh-vision-toolkit'
|
|
43
|
+
|
|
44
|
+
function encodeKey(key: string): string {
|
|
45
|
+
return key.split('/').map(encodeURIComponent).join('/')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function clientFor(settings: ObjectStorageSettings): S3Client {
|
|
49
|
+
const config: S3ClientConfig = {
|
|
50
|
+
region: 'auto',
|
|
51
|
+
forcePathStyle: true,
|
|
52
|
+
credentials: {
|
|
53
|
+
accessKeyId: settings.accessKeyId,
|
|
54
|
+
secretAccessKey: settings.secretAccessKey,
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
if (settings.endpoint.length > 0) config.endpoint = settings.endpoint
|
|
58
|
+
return new S3Client(config)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function publicError(error: unknown): string {
|
|
62
|
+
if (error instanceof Error) return error.message
|
|
63
|
+
return String(error)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One upload's worth of bookkeeping: the object key and the URL handed to the
|
|
68
|
+
* model. The key is returned to the runtime so it can delete the object after
|
|
69
|
+
* the operation settles.
|
|
70
|
+
*/
|
|
71
|
+
export interface UploadedObject {
|
|
72
|
+
key: string
|
|
73
|
+
url: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A small S3-compatible object store bound to one bucket and credential. */
|
|
77
|
+
export class ObjectStorageClient {
|
|
78
|
+
private client?: S3Client
|
|
79
|
+
|
|
80
|
+
constructor(private readonly settings: ObjectStorageSettings) {}
|
|
81
|
+
|
|
82
|
+
private requireClient(): S3Client {
|
|
83
|
+
if (this.client === undefined) this.client = clientFor(this.settings)
|
|
84
|
+
return this.client
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Upload one local image file and resolve its model-reachable URL. */
|
|
88
|
+
async uploadImage(localPath: string, contentType: string): Promise<UploadedObject> {
|
|
89
|
+
const body = await readFile(localPath)
|
|
90
|
+
const digest = createHash('sha256').update(body).digest('hex').slice(0, 12)
|
|
91
|
+
const name = basename(localPath).replace(/[^A-Za-z0-9._-]/g, '_')
|
|
92
|
+
const key = `${OBJECT_KEY_PREFIX}/${randomUUID()}-${digest}-${name}`
|
|
93
|
+
try {
|
|
94
|
+
await this.requireClient().send(new PutObjectCommand({
|
|
95
|
+
Bucket: this.settings.bucket,
|
|
96
|
+
Key: key,
|
|
97
|
+
Body: body,
|
|
98
|
+
ContentType: contentType,
|
|
99
|
+
}))
|
|
100
|
+
} catch (error) {
|
|
101
|
+
throw new VisionToolkitError('service', `object storage upload failed: ${publicError(error)}`, { cause: error })
|
|
102
|
+
}
|
|
103
|
+
return { key, url: await this.urlFor(key) }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Resolve the model-reachable URL: public base URL when set, else presigned. */
|
|
107
|
+
async urlFor(key: string): Promise<string> {
|
|
108
|
+
if (this.settings.publicBase !== undefined && this.settings.publicBase.length > 0) {
|
|
109
|
+
return `${this.settings.publicBase}/${encodeKey(key)}`
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
return await getSignedUrl(
|
|
113
|
+
this.requireClient(),
|
|
114
|
+
new GetObjectCommand({ Bucket: this.settings.bucket, Key: key }),
|
|
115
|
+
{ expiresIn: 3600 },
|
|
116
|
+
)
|
|
117
|
+
} catch (error) {
|
|
118
|
+
throw new VisionToolkitError('service', `object storage presign failed: ${publicError(error)}`, { cause: error })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Delete one uploaded object; failures are logged, never fatal to the call. */
|
|
123
|
+
async deleteObject(key: string): Promise<void> {
|
|
124
|
+
try {
|
|
125
|
+
await this.requireClient().send(new DeleteObjectCommand({
|
|
126
|
+
Bucket: this.settings.bucket,
|
|
127
|
+
Key: key,
|
|
128
|
+
}))
|
|
129
|
+
} catch {
|
|
130
|
+
// Best-effort cleanup: a failed delete must not mask the vision result.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Settings "test storage" probe: upload a tiny object, head it, then delete it. */
|
|
135
|
+
async test(): Promise<{ detail: string }> {
|
|
136
|
+
if (!isObjectStorageConfigured(this.settings)) {
|
|
137
|
+
throw new VisionToolkitError('config', 'object storage is not fully configured (endpoint, bucket, access key id, and secret access key are required)')
|
|
138
|
+
}
|
|
139
|
+
const key = `${OBJECT_KEY_PREFIX}/.connection-test-${randomUUID()}`
|
|
140
|
+
const marker = `dsh-vision-toolkit object storage test ${Date.now()}`
|
|
141
|
+
try {
|
|
142
|
+
await this.requireClient().send(new PutObjectCommand({
|
|
143
|
+
Bucket: this.settings.bucket,
|
|
144
|
+
Key: key,
|
|
145
|
+
Body: marker,
|
|
146
|
+
ContentType: 'text/plain',
|
|
147
|
+
}))
|
|
148
|
+
await this.requireClient().send(new HeadObjectCommand({ Bucket: this.settings.bucket, Key: key }))
|
|
149
|
+
await this.requireClient().send(new DeleteObjectCommand({ Bucket: this.settings.bucket, Key: key }))
|
|
150
|
+
} catch (error) {
|
|
151
|
+
throw new VisionToolkitError('service', `object storage test failed: ${publicError(error)}`, { cause: error })
|
|
152
|
+
}
|
|
153
|
+
const urlMode = this.settings.publicBase !== undefined && this.settings.publicBase.length > 0
|
|
154
|
+
? `public base ${this.settings.publicBase}`
|
|
155
|
+
: 'presigned URL'
|
|
156
|
+
return { detail: `bucket ${this.settings.bucket} reachable; model URLs will use ${urlMode}` }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Split a credential value of the form `accessKeyId:secretAccessKey` into its
|
|
162
|
+
* two parts. The access key id never contains a colon, so splitting on the
|
|
163
|
+
* first colon is safe.
|
|
164
|
+
*/
|
|
165
|
+
export function splitObjectStorageCredential(value: string): { accessKeyId: string; secretAccessKey: string } {
|
|
166
|
+
const index = value.indexOf(':')
|
|
167
|
+
if (index <= 0) {
|
|
168
|
+
throw new VisionToolkitError('config', 'object storage credential must be "accessKeyId:secretAccessKey"')
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
accessKeyId: value.slice(0, index),
|
|
172
|
+
secretAccessKey: value.slice(index + 1),
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/paste-images.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Plugin-managed storage for images pasted into the DSH Web composer. */
|
|
2
2
|
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto'
|
|
4
|
-
import {
|
|
4
|
+
import { constants as fsConstants } from 'node:fs'
|
|
5
|
+
import { copyFile, lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises'
|
|
5
6
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
6
7
|
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
7
8
|
import type { Context } from '@deepseek-ai/cordis'
|
|
8
9
|
import type {} from '@deepseek-ai/dsh-session'
|
|
10
|
+
import { resolveWorkspaceStorage } from './paths.ts'
|
|
9
11
|
import { sameOriginPost } from './web-request.ts'
|
|
10
12
|
|
|
11
13
|
/** Exact route used by the browser paste integration. */
|
|
@@ -51,6 +53,7 @@ export interface PasteVerdict {
|
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
const MAX_NAME_BYTES = 180
|
|
56
|
+
const MAX_STORAGE_GENERATION_RETRIES = 4
|
|
54
57
|
|
|
55
58
|
/**
|
|
56
59
|
* Hard per-image upload ceiling for pastes. Files between the configured
|
|
@@ -151,7 +154,7 @@ export function safePastedImageName(raw: string, mediaType: string): string {
|
|
|
151
154
|
export function ensurePathInside(root: string, target: string): void {
|
|
152
155
|
const rel = relative(root, target)
|
|
153
156
|
if (rel !== '' && (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))) {
|
|
154
|
-
throw new Error(`resolved pasted-image path escapes its
|
|
157
|
+
throw new Error(`resolved pasted-image path escapes its managed root: ${target}`)
|
|
155
158
|
}
|
|
156
159
|
}
|
|
157
160
|
|
|
@@ -168,7 +171,7 @@ async function ensureManagedDirectory(workspace: string, path: string): Promise<
|
|
|
168
171
|
}
|
|
169
172
|
const entry = await lstat(path)
|
|
170
173
|
if (entry.isSymbolicLink()) {
|
|
171
|
-
throw new Error(`resolved pasted-image path escapes its
|
|
174
|
+
throw new Error(`resolved pasted-image path escapes its managed root: symbolic link ${path}`)
|
|
172
175
|
}
|
|
173
176
|
if (!entry.isDirectory()) throw new Error(`pasted-image path is not a directory: ${path}`)
|
|
174
177
|
const canonical = await realpath(path)
|
|
@@ -178,29 +181,33 @@ async function ensureManagedDirectory(workspace: string, path: string): Promise<
|
|
|
178
181
|
|
|
179
182
|
/**
|
|
180
183
|
* Resolve the managed per-session image directory used by both browser pastes
|
|
181
|
-
* and native attachment bridging.
|
|
182
|
-
*
|
|
184
|
+
* and native attachment bridging. A configured shared storage root receives a
|
|
185
|
+
* stable workspace-specific child, keeping projects isolated without writing
|
|
186
|
+
* plugin files into the project directory.
|
|
183
187
|
*/
|
|
184
|
-
export async function sessionPasteRoot(
|
|
188
|
+
export async function sessionPasteRoot(
|
|
189
|
+
ctx: Context,
|
|
190
|
+
sessionId: string,
|
|
191
|
+
storageDir?: string,
|
|
192
|
+
): Promise<PasteRoot> {
|
|
185
193
|
const session = ctx.sessions.get(sessionId as never)
|
|
186
194
|
if (session === undefined) throw new Error(`live Session not found: ${sessionId}`)
|
|
187
195
|
const cwd = session.header.cwd
|
|
188
196
|
if (cwd === undefined || !isAbsolute(cwd)) throw new Error(`Session has no absolute workspace: ${sessionId}`)
|
|
189
197
|
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
const pluginRoot = join(visibleWorkspace, '.dsh-vision-toolkit')
|
|
193
|
-
await ensureManagedDirectory(workspace, pluginRoot)
|
|
198
|
+
const storage = await resolveWorkspaceStorage(resolve(cwd), storageDir)
|
|
199
|
+
const pluginRoot = storage.root
|
|
194
200
|
const temporaryRoot = join(pluginRoot, 'tmp')
|
|
195
|
-
await ensureManagedDirectory(
|
|
201
|
+
await ensureManagedDirectory(pluginRoot, temporaryRoot)
|
|
196
202
|
const requestedRoot = join(temporaryRoot, 'pasted-images')
|
|
197
|
-
const root = await ensureManagedDirectory(
|
|
203
|
+
const root = await ensureManagedDirectory(temporaryRoot, requestedRoot)
|
|
204
|
+
const visibleRoot = join(storage.visibleRoot, 'tmp', 'pasted-images')
|
|
198
205
|
|
|
199
206
|
const sessionKey = createHash('sha256').update(sessionId).digest('hex').slice(0, 20)
|
|
200
207
|
const requestedSessionRoot = join(requestedRoot, sessionKey)
|
|
201
208
|
const sessionRoot = await ensureManagedDirectory(root, requestedSessionRoot)
|
|
202
209
|
ensurePathInside(root, sessionRoot)
|
|
203
|
-
return { writeRoot: sessionRoot, visibleRoot:
|
|
210
|
+
return { writeRoot: sessionRoot, visibleRoot: join(visibleRoot, sessionKey) }
|
|
204
211
|
}
|
|
205
212
|
|
|
206
213
|
async function writeImage(
|
|
@@ -240,9 +247,38 @@ async function writeImage(
|
|
|
240
247
|
}
|
|
241
248
|
}
|
|
242
249
|
|
|
250
|
+
async function copyImage(
|
|
251
|
+
source: string,
|
|
252
|
+
directory: string,
|
|
253
|
+
filename: string,
|
|
254
|
+
): Promise<string> {
|
|
255
|
+
const id = randomUUID()
|
|
256
|
+
const finalPath = join(directory, `${id}-${filename}`)
|
|
257
|
+
const stagingPath = join(directory, `.${id}.partial`)
|
|
258
|
+
ensurePathInside(directory, finalPath)
|
|
259
|
+
ensurePathInside(directory, stagingPath)
|
|
260
|
+
try {
|
|
261
|
+
await copyFile(source, stagingPath, fsConstants.COPYFILE_EXCL)
|
|
262
|
+
await rename(stagingPath, finalPath)
|
|
263
|
+
return finalPath
|
|
264
|
+
} catch (error) {
|
|
265
|
+
await rm(stagingPath, { force: true }).catch(() => {})
|
|
266
|
+
throw error
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface PasteStorageGeneration {
|
|
271
|
+
generation: number
|
|
272
|
+
storageDir?: string
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
class PasteStorageChangedError extends Error {}
|
|
276
|
+
|
|
243
277
|
/** Runtime limit face kept separate for focused backend tests. */
|
|
244
278
|
export interface PasteImageRuntime {
|
|
245
279
|
maxUploadBytes(): number
|
|
280
|
+
storageDirectory?(): string | undefined
|
|
281
|
+
storageGeneration?(): PasteStorageGeneration
|
|
246
282
|
}
|
|
247
283
|
|
|
248
284
|
/** Same-origin, live-Session-bound image upload endpoint. */
|
|
@@ -252,6 +288,16 @@ export class PastedImageBackend {
|
|
|
252
288
|
private readonly runtime: PasteImageRuntime,
|
|
253
289
|
) {}
|
|
254
290
|
|
|
291
|
+
private storageGeneration(): PasteStorageGeneration {
|
|
292
|
+
const current = this.runtime.storageGeneration?.()
|
|
293
|
+
if (current !== undefined) return current
|
|
294
|
+
const storageDir = this.runtime.storageDirectory?.()
|
|
295
|
+
return {
|
|
296
|
+
generation: 0,
|
|
297
|
+
...(storageDir === undefined ? {} : { storageDir }),
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
255
301
|
async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
256
302
|
if (req.method !== 'POST') {
|
|
257
303
|
res.setHeader('Allow', 'POST')
|
|
@@ -263,6 +309,7 @@ export class PastedImageBackend {
|
|
|
263
309
|
return
|
|
264
310
|
}
|
|
265
311
|
|
|
312
|
+
let managedPath: string | undefined
|
|
266
313
|
try {
|
|
267
314
|
const url = new URL(req.url ?? PASTE_IMAGES_ROUTE, 'http://dsh.internal')
|
|
268
315
|
const sessionId = singleQuery(url, 'sessionId')
|
|
@@ -273,12 +320,27 @@ export class PastedImageBackend {
|
|
|
273
320
|
if (contentLength !== undefined && Number(contentLength) !== size) {
|
|
274
321
|
throw new TypeError('Content-Length does not match the declared size')
|
|
275
322
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
323
|
+
let storage = this.storageGeneration()
|
|
324
|
+
let directory = await sessionPasteRoot(this.ctx, sessionId, storage.storageDir)
|
|
325
|
+
managedPath = await writeImage(req, directory.writeRoot, filename, size, this.runtime.maxUploadBytes())
|
|
326
|
+
for (let attempt = 0; attempt < MAX_STORAGE_GENERATION_RETRIES; attempt += 1) {
|
|
327
|
+
const current = this.storageGeneration()
|
|
328
|
+
if (current.generation === storage.generation && current.storageDir === storage.storageDir) {
|
|
329
|
+
const absolutePath = join(directory.visibleRoot, basename(managedPath))
|
|
330
|
+
responseJson(res, 201, { ok: true, value: { absolutePath, filename, bytes: size } })
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
const nextDirectory = await sessionPasteRoot(this.ctx, sessionId, current.storageDir)
|
|
334
|
+
const migratedPath = await copyImage(managedPath, nextDirectory.writeRoot, filename)
|
|
335
|
+
await rm(managedPath, { force: true }).catch(() => {})
|
|
336
|
+
managedPath = migratedPath
|
|
337
|
+
directory = nextDirectory
|
|
338
|
+
storage = current
|
|
339
|
+
}
|
|
340
|
+
throw new PasteStorageChangedError('Vision Toolkit settings changed repeatedly during image copy; retry the paste')
|
|
280
341
|
} catch (error) {
|
|
281
|
-
|
|
342
|
+
if (managedPath !== undefined) await rm(managedPath, { force: true }).catch(() => {})
|
|
343
|
+
const status = error instanceof PasteStorageChangedError ? 409 : error instanceof RangeError ? 413 : 400
|
|
282
344
|
this.ctx.logger.warn('dsh-vision-toolkit pasted image rejected: %s', message(error))
|
|
283
345
|
requestError(res, status, 'paste-image-rejected', message(error))
|
|
284
346
|
}
|
package/src/paths.ts
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* @module dsh-vision-toolkit/paths
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { randomUUID } from 'node:crypto'
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
9
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
10
|
+
import type { Stats } from 'node:fs'
|
|
11
|
+
import { cp, link, lstat, mkdir, mkdtemp, readdir, realpath, rename, rm, stat } from 'node:fs/promises'
|
|
12
|
+
import { dirname, extname, isAbsolute, join, relative, resolve, sep, win32 } from 'node:path'
|
|
12
13
|
import { homedir, tmpdir } from 'node:os'
|
|
13
14
|
import { VisionToolkitError } from './errors.ts'
|
|
14
15
|
|
|
@@ -23,10 +24,22 @@ export interface PathPolicy {
|
|
|
23
24
|
tempDir: string
|
|
24
25
|
/** Real allowed roots: workspace, platform temp, and configured directories. */
|
|
25
26
|
allowedDirs: string[]
|
|
27
|
+
/** Real plugin-managed root containing artifacts and transient files. */
|
|
28
|
+
storageRoot: string
|
|
26
29
|
/** Real plugin-managed output directory inside the fence. */
|
|
27
30
|
outputDir: string
|
|
28
31
|
}
|
|
29
32
|
|
|
33
|
+
/** Resolved plugin storage for one workspace. */
|
|
34
|
+
export interface WorkspaceStorage {
|
|
35
|
+
/** Real workspace root. */
|
|
36
|
+
workspace: string
|
|
37
|
+
/** Real plugin-managed root for this workspace. */
|
|
38
|
+
root: string
|
|
39
|
+
/** Absolute user-visible spelling of the same managed root. */
|
|
40
|
+
visibleRoot: string
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
/** Whether `child` equals or lies under `parent` on the same path root. */
|
|
31
44
|
export function isWithin(parent: string, child: string): boolean {
|
|
32
45
|
const rel = relative(parent, child)
|
|
@@ -65,26 +78,231 @@ export function normalizePlatformTempPath(
|
|
|
65
78
|
return win32.join(tempDirectory, raw.slice('/tmp/'.length))
|
|
66
79
|
}
|
|
67
80
|
|
|
81
|
+
/** Stable opaque per-user workspace id used below a shared storage root. */
|
|
82
|
+
export function workspaceStorageId(
|
|
83
|
+
workspace: string,
|
|
84
|
+
userIdentity: string = typeof process.geteuid === 'function'
|
|
85
|
+
? `uid:${process.geteuid()}`
|
|
86
|
+
: `home:${homedir()}`,
|
|
87
|
+
): string {
|
|
88
|
+
return createHash('sha256')
|
|
89
|
+
.update(userIdentity)
|
|
90
|
+
.update('\0')
|
|
91
|
+
.update(workspace)
|
|
92
|
+
.digest('hex')
|
|
93
|
+
.slice(0, 20)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function currentPosixUid(): number {
|
|
97
|
+
if (typeof process.geteuid !== 'function') {
|
|
98
|
+
throw new VisionToolkitError(
|
|
99
|
+
'path',
|
|
100
|
+
'configured storage directory is not supported on this platform because ownership and permissions cannot be verified',
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
return process.geteuid()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function assertSecureWorkspaceStorage(info: Stats, requested: string): void {
|
|
107
|
+
const currentUid = currentPosixUid()
|
|
108
|
+
if (info.uid !== currentUid || (info.mode & 0o777) !== 0o700) {
|
|
109
|
+
throw new VisionToolkitError('path', `workspace storage directory must be owned by the current user with mode 0700: ${requested}`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Resolve a shared base and prove every POSIX ancestor is protected from replacement. */
|
|
114
|
+
export async function assertSecureSharedStorageBase(requested: string): Promise<string> {
|
|
115
|
+
const currentUid = currentPosixUid()
|
|
116
|
+
const requestedPath = resolve(requested)
|
|
117
|
+
const requestedChain: string[] = []
|
|
118
|
+
let requestedCurrent = requestedPath
|
|
119
|
+
while (true) {
|
|
120
|
+
requestedChain.push(requestedCurrent)
|
|
121
|
+
const parent = dirname(requestedCurrent)
|
|
122
|
+
if (parent === requestedCurrent) break
|
|
123
|
+
requestedCurrent = parent
|
|
124
|
+
}
|
|
125
|
+
for (const component of requestedChain.reverse()) {
|
|
126
|
+
const info = await lstat(component)
|
|
127
|
+
if (info.isSymbolicLink()) {
|
|
128
|
+
if (info.uid !== 0) {
|
|
129
|
+
throw new VisionToolkitError('path', `configured storage directory contains an untrusted symbolic link: ${component}`)
|
|
130
|
+
}
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (!info.isDirectory()) {
|
|
134
|
+
throw new VisionToolkitError('path', `configured storage path component is not a directory: ${component}`)
|
|
135
|
+
}
|
|
136
|
+
const writableByOthers = (info.mode & 0o022) !== 0
|
|
137
|
+
const sticky = (info.mode & 0o1000) !== 0
|
|
138
|
+
if (
|
|
139
|
+
(info.uid !== currentUid && info.uid !== 0)
|
|
140
|
+
|| (writableByOthers && !sticky)
|
|
141
|
+
) throw new VisionToolkitError('path', `configured storage directory has an untrusted path component: ${component}`)
|
|
142
|
+
}
|
|
143
|
+
const canonical = await realpath(requestedPath)
|
|
144
|
+
let current = canonical
|
|
145
|
+
while (true) {
|
|
146
|
+
const info = await lstat(current)
|
|
147
|
+
const writableByOthers = (info.mode & 0o022) !== 0
|
|
148
|
+
const sticky = (info.mode & 0o1000) !== 0
|
|
149
|
+
if (
|
|
150
|
+
info.isSymbolicLink()
|
|
151
|
+
|| !info.isDirectory()
|
|
152
|
+
|| (info.uid !== currentUid && info.uid !== 0)
|
|
153
|
+
|| (writableByOthers && !sticky)
|
|
154
|
+
) throw new VisionToolkitError('path', `configured storage directory has an untrusted path component: ${current}`)
|
|
155
|
+
const parent = dirname(current)
|
|
156
|
+
if (parent === current) return canonical
|
|
157
|
+
current = parent
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function requestedSharedStorageBase(storageDirRaw: string): string {
|
|
162
|
+
currentPosixUid()
|
|
163
|
+
const configured = normalizePlatformTempPath(expandUserHome(storageDirRaw.trim()))
|
|
164
|
+
if (!isAbsolute(configured)) {
|
|
165
|
+
throw new VisionToolkitError('path', `configured storage directory must be an absolute path: ${storageDirRaw}`)
|
|
166
|
+
}
|
|
167
|
+
return resolve(configured)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function ensureSharedStorageBase(storageDirRaw: string): Promise<{ requestedBase: string; base: string }> {
|
|
171
|
+
const requestedBase = requestedSharedStorageBase(storageDirRaw)
|
|
172
|
+
try {
|
|
173
|
+
await mkdir(requestedBase, { recursive: true, mode: 0o700 })
|
|
174
|
+
} catch (error) {
|
|
175
|
+
throw new VisionToolkitError('path', `configured storage directory is not writable: ${requestedBase}`, { cause: error })
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
return { requestedBase, base: await assertSecureSharedStorageBase(requestedBase) }
|
|
179
|
+
} catch (error) {
|
|
180
|
+
throw new VisionToolkitError('path', `configured storage directory is not accessible: ${requestedBase}`, { cause: error })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Validate and write-probe a configured shared root before Settings activation. */
|
|
185
|
+
export async function preflightSharedStorageBase(storageDirRaw: string): Promise<string> {
|
|
186
|
+
const { base } = await ensureSharedStorageBase(storageDirRaw)
|
|
187
|
+
let probe: string | undefined
|
|
188
|
+
let failure: unknown
|
|
189
|
+
try {
|
|
190
|
+
probe = await mkdtemp(join(base, '.dsh-vision-toolkit-preflight-'))
|
|
191
|
+
assertSecureWorkspaceStorage(await lstat(probe), probe)
|
|
192
|
+
} catch (error) {
|
|
193
|
+
failure = error
|
|
194
|
+
}
|
|
195
|
+
if (probe !== undefined) {
|
|
196
|
+
try {
|
|
197
|
+
await rm(probe, { recursive: true, force: true })
|
|
198
|
+
} catch (error) {
|
|
199
|
+
failure ??= error
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (failure !== undefined) {
|
|
203
|
+
throw new VisionToolkitError('path', `configured storage directory failed its write preflight: ${base}`, { cause: failure })
|
|
204
|
+
}
|
|
205
|
+
return base
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function managedDirectory(
|
|
209
|
+
parent: string,
|
|
210
|
+
requested: string,
|
|
211
|
+
label: string,
|
|
212
|
+
secureWorkspaceStorage = false,
|
|
213
|
+
): Promise<string> {
|
|
214
|
+
try {
|
|
215
|
+
await mkdir(requested, { mode: 0o700 })
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) {
|
|
218
|
+
throw new VisionToolkitError('path', `${label} is not writable: ${requested}`, { cause: error })
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
let info
|
|
222
|
+
try {
|
|
223
|
+
info = await lstat(requested)
|
|
224
|
+
} catch (error) {
|
|
225
|
+
throw new VisionToolkitError('path', `${label} is not accessible: ${requested}`, { cause: error })
|
|
226
|
+
}
|
|
227
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
228
|
+
throw new VisionToolkitError('path', `${label} must be a real directory: ${requested}`)
|
|
229
|
+
}
|
|
230
|
+
if (secureWorkspaceStorage) assertSecureWorkspaceStorage(info, requested)
|
|
231
|
+
const canonical = await realpath(requested)
|
|
232
|
+
if (!isWithin(parent, canonical)) {
|
|
233
|
+
throw new VisionToolkitError('path', `${label} escaped its configured root: ${requested}`)
|
|
234
|
+
}
|
|
235
|
+
return canonical
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resolve the plugin-managed root for one workspace. Blank configuration keeps
|
|
240
|
+
* the legacy workspace-local `.dsh-vision-toolkit` directory. A configured
|
|
241
|
+
* shared root receives one stable, automatically generated workspace child.
|
|
242
|
+
*/
|
|
243
|
+
export async function resolveWorkspaceStorage(
|
|
244
|
+
workspaceRaw: string,
|
|
245
|
+
storageDirRaw?: string,
|
|
246
|
+
): Promise<WorkspaceStorage> {
|
|
247
|
+
const expandedWorkspace = expandUserHome(workspaceRaw)
|
|
248
|
+
const visibleWorkspace = resolve(expandedWorkspace)
|
|
249
|
+
let workspace: string
|
|
250
|
+
try {
|
|
251
|
+
workspace = await realpath(visibleWorkspace)
|
|
252
|
+
} catch (error) {
|
|
253
|
+
throw new VisionToolkitError('path', `workspace is not accessible: ${workspaceRaw}`, { cause: error })
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (storageDirRaw === undefined || storageDirRaw.trim().length === 0) {
|
|
257
|
+
const visibleRoot = join(visibleWorkspace, '.dsh-vision-toolkit')
|
|
258
|
+
const root = await managedDirectory(workspace, visibleRoot, 'plugin storage directory')
|
|
259
|
+
return { workspace, root, visibleRoot }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const { requestedBase, base } = await ensureSharedStorageBase(storageDirRaw)
|
|
263
|
+
const id = workspaceStorageId(workspace)
|
|
264
|
+
const visibleRoot = join(requestedBase, id)
|
|
265
|
+
const root = await managedDirectory(base, join(base, id), 'workspace storage directory', true)
|
|
266
|
+
return { workspace, root, visibleRoot }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function resolveReadableWorkspaceStorageRoot(
|
|
270
|
+
workspace: string,
|
|
271
|
+
storageDirRaw: string,
|
|
272
|
+
): Promise<string> {
|
|
273
|
+
const requestedBase = requestedSharedStorageBase(storageDirRaw)
|
|
274
|
+
const base = await assertSecureSharedStorageBase(requestedBase)
|
|
275
|
+
const requestedRoot = join(base, workspaceStorageId(workspace))
|
|
276
|
+
const info = await lstat(requestedRoot)
|
|
277
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
278
|
+
throw new VisionToolkitError('path', `historical workspace storage must be a real directory: ${requestedRoot}`)
|
|
279
|
+
}
|
|
280
|
+
assertSecureWorkspaceStorage(info, requestedRoot)
|
|
281
|
+
const root = await realpath(requestedRoot)
|
|
282
|
+
if (!isWithin(base, root)) {
|
|
283
|
+
throw new VisionToolkitError('path', `historical workspace storage escaped its configured root: ${requestedRoot}`)
|
|
284
|
+
}
|
|
285
|
+
return root
|
|
286
|
+
}
|
|
287
|
+
|
|
68
288
|
/**
|
|
69
|
-
* Build the per-invocation path policy:
|
|
70
|
-
*
|
|
71
|
-
*
|
|
289
|
+
* Build the per-invocation path policy: resolve workspace storage, realpath the
|
|
290
|
+
* platform temp directory and allowed directories, and create the artifact
|
|
291
|
+
* directory inside the managed root.
|
|
72
292
|
* @param workspaceRaw - session workspace (or process cwd fallback).
|
|
73
293
|
* @param allowedDirs - configured extra allowed roots.
|
|
74
|
-
* @param
|
|
294
|
+
* @param storageDirRaw - optional shared storage root.
|
|
295
|
+
* @param readableStorageDirs - previously validated shared roots retained for persisted input paths.
|
|
75
296
|
* @returns the resolved policy.
|
|
76
297
|
*/
|
|
77
298
|
export async function createPathPolicy(
|
|
78
299
|
workspaceRaw: string,
|
|
79
300
|
allowedDirs: readonly string[],
|
|
80
|
-
|
|
301
|
+
storageDirRaw?: string,
|
|
302
|
+
readableStorageDirs: readonly string[] = [],
|
|
81
303
|
): Promise<PathPolicy> {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
workspace = await realpath(expandUserHome(workspaceRaw))
|
|
85
|
-
} catch (error) {
|
|
86
|
-
throw new VisionToolkitError('path', `workspace is not accessible: ${workspaceRaw}`, { cause: error })
|
|
87
|
-
}
|
|
304
|
+
const storage = await resolveWorkspaceStorage(workspaceRaw, storageDirRaw)
|
|
305
|
+
const { workspace } = storage
|
|
88
306
|
let tempDir: string
|
|
89
307
|
const tempDirectoryRaw = platformTempDirectory()
|
|
90
308
|
try {
|
|
@@ -92,7 +310,16 @@ export async function createPathPolicy(
|
|
|
92
310
|
} catch (error) {
|
|
93
311
|
throw new VisionToolkitError('path', `platform temporary directory is not accessible: ${tempDirectoryRaw}`, { cause: error })
|
|
94
312
|
}
|
|
95
|
-
const roots = [workspace, tempDir]
|
|
313
|
+
const roots = [workspace, tempDir, storage.root]
|
|
314
|
+
for (const raw of readableStorageDirs) {
|
|
315
|
+
if (raw === storageDirRaw) continue
|
|
316
|
+
try {
|
|
317
|
+
roots.push(await resolveReadableWorkspaceStorageRoot(workspace, raw))
|
|
318
|
+
} catch {
|
|
319
|
+
// Historical roots are read-only compatibility fences. Missing or newly
|
|
320
|
+
// unsafe roots stay unauthorized without breaking the active runtime.
|
|
321
|
+
}
|
|
322
|
+
}
|
|
96
323
|
for (const raw of allowedDirs) {
|
|
97
324
|
const candidate = expandUserHome(raw)
|
|
98
325
|
const target = isAbsolute(candidate) ? candidate : resolve(workspace, candidate)
|
|
@@ -102,20 +329,14 @@ export async function createPathPolicy(
|
|
|
102
329
|
throw new VisionToolkitError('path', `allowedDirs entry is not accessible: ${raw}`, { cause: error })
|
|
103
330
|
}
|
|
104
331
|
}
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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 })
|
|
332
|
+
const outputDir = await managedDirectory(storage.root, join(storage.root, 'artifacts'), 'artifact directory')
|
|
333
|
+
return {
|
|
334
|
+
workspace,
|
|
335
|
+
tempDir,
|
|
336
|
+
allowedDirs: [...new Set(roots)],
|
|
337
|
+
storageRoot: storage.root,
|
|
338
|
+
outputDir,
|
|
117
339
|
}
|
|
118
|
-
return { workspace, tempDir, allowedDirs: [...new Set(roots)], outputDir }
|
|
119
340
|
}
|
|
120
341
|
|
|
121
342
|
/**
|