@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.
Files changed (101) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +15 -70
  3. package/README.zh.md +15 -69
  4. package/docs/requirements-traceability/README.i18n.yaml +2 -2
  5. package/docs/requirements-traceability/README.md +1 -1
  6. package/docs/requirements-traceability/README.zh.md +1 -1
  7. package/lib/artifact-access.js +20 -2
  8. package/lib/artifact-access.js.map +1 -1
  9. package/lib/client.js +128 -12
  10. package/lib/client.js.map +1 -1
  11. package/lib/config.js +69 -0
  12. package/lib/config.js.map +1 -1
  13. package/lib/errors.js +25 -2
  14. package/lib/errors.js.map +1 -1
  15. package/lib/evidence-cache.js +2 -1
  16. package/lib/evidence-cache.js.map +1 -1
  17. package/lib/exposure.js +14 -1
  18. package/lib/exposure.js.map +1 -1
  19. package/lib/image-input-variants.js +22 -12
  20. package/lib/image-input-variants.js.map +1 -1
  21. package/lib/index.js +53 -6
  22. package/lib/index.js.map +1 -1
  23. package/lib/object-storage.js +141 -0
  24. package/lib/object-storage.js.map +1 -0
  25. package/lib/paste-images.js +67 -19
  26. package/lib/paste-images.js.map +1 -1
  27. package/lib/paths.js +214 -28
  28. package/lib/paths.js.map +1 -1
  29. package/lib/runtime-manager.js +76 -10
  30. package/lib/runtime-manager.js.map +1 -1
  31. package/lib/runtime.js +219 -38
  32. package/lib/runtime.js.map +1 -1
  33. package/lib/storage-history.js +154 -0
  34. package/lib/storage-history.js.map +1 -0
  35. package/lib/types/artifact-access.d.ts.map +1 -1
  36. package/lib/types/client/index.d.ts +45 -2
  37. package/lib/types/client/index.d.ts.map +1 -1
  38. package/lib/types/client/paste-images.d.ts +2 -0
  39. package/lib/types/client/paste-images.d.ts.map +1 -1
  40. package/lib/types/config.d.ts +55 -0
  41. package/lib/types/config.d.ts.map +1 -1
  42. package/lib/types/errors.d.ts +18 -2
  43. package/lib/types/errors.d.ts.map +1 -1
  44. package/lib/types/evidence-cache.d.ts +1 -1
  45. package/lib/types/evidence-cache.d.ts.map +1 -1
  46. package/lib/types/exposure.d.ts.map +1 -1
  47. package/lib/types/image-input-variants.d.ts +5 -3
  48. package/lib/types/image-input-variants.d.ts.map +1 -1
  49. package/lib/types/index.d.ts.map +1 -1
  50. package/lib/types/object-storage.d.ts +54 -0
  51. package/lib/types/object-storage.d.ts.map +1 -0
  52. package/lib/types/paste-images.d.ts +12 -4
  53. package/lib/types/paste-images.d.ts.map +1 -1
  54. package/lib/types/paths.d.ts +31 -5
  55. package/lib/types/paths.d.ts.map +1 -1
  56. package/lib/types/runtime-manager.d.ts +28 -4
  57. package/lib/types/runtime-manager.d.ts.map +1 -1
  58. package/lib/types/runtime.d.ts +31 -1
  59. package/lib/types/runtime.d.ts.map +1 -1
  60. package/lib/types/storage-history.d.ts +63 -0
  61. package/lib/types/storage-history.d.ts.map +1 -0
  62. package/lib/types/upstream.d.ts +1 -0
  63. package/lib/types/upstream.d.ts.map +1 -1
  64. package/lib/types/web.d.ts +7 -0
  65. package/lib/types/web.d.ts.map +1 -1
  66. package/lib/upstream.js +34 -8
  67. package/lib/upstream.js.map +1 -1
  68. package/lib/web.js +44 -9
  69. package/lib/web.js.map +1 -1
  70. package/package.json +3 -1
  71. package/src/artifact-access.ts +22 -2
  72. package/src/client/index.tsx +169 -9
  73. package/src/client/paste-images.tsx +14 -4
  74. package/src/config.ts +128 -0
  75. package/src/errors.ts +25 -2
  76. package/src/evidence-cache.ts +2 -1
  77. package/src/exposure.ts +16 -2
  78. package/src/image-input-variants.ts +21 -6
  79. package/src/index.ts +65 -6
  80. package/src/object-storage.ts +174 -0
  81. package/src/paste-images.ts +81 -19
  82. package/src/paths.ts +249 -28
  83. package/src/runtime-manager.ts +93 -10
  84. package/src/runtime.ts +215 -36
  85. package/src/storage-history.ts +172 -0
  86. package/src/upstream.ts +36 -7
  87. package/src/web.ts +54 -9
  88. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +11 -11
  89. package/vendor/agent-vision-toolkit/__pycache__/detect.cpython-314.pyc +0 -0
  90. package/vendor/agent-vision-toolkit/__pycache__/ground.cpython-314.pyc +0 -0
  91. package/vendor/agent-vision-toolkit/__pycache__/vision_client.cpython-314.pyc +0 -0
  92. package/vendor/agent-vision-toolkit/bin/__pycache__/glancecpython-314.pyc +0 -0
  93. package/vendor/agent-vision-toolkit/bin/glance +8 -1
  94. package/vendor/agent-vision-toolkit/detect.py +13 -7
  95. package/vendor/agent-vision-toolkit/ground.py +43 -18
  96. package/vendor/agent-vision-toolkit/tests/test_vision_client.py +88 -0
  97. package/vendor/agent-vision-toolkit/vision_client.py +84 -6
  98. package/assets/community-group-qr.png +0 -0
  99. package/assets/logo_aihubmix.png +0 -0
  100. package/assets/logo_eapi_dark.png +0 -0
  101. 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
+ }
@@ -1,11 +1,13 @@
1
- /** Workspace-local storage for images pasted into the DSH Web composer. */
1
+ /** Plugin-managed storage for images pasted into the DSH Web composer. */
2
2
 
3
3
  import { createHash, randomUUID } from 'node:crypto'
4
- import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises'
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 workspace root: ${target}`)
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 workspace root: symbolic link ${path}`)
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. Keeping both flows under the same workspace
182
- * root makes the resulting absolute path valid for the model's visual tools.
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(ctx: Context, sessionId: string): Promise<PasteRoot> {
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 visibleWorkspace = resolve(cwd)
191
- const workspace = await realpath(visibleWorkspace)
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(workspace, temporaryRoot)
201
+ await ensureManagedDirectory(pluginRoot, temporaryRoot)
196
202
  const requestedRoot = join(temporaryRoot, 'pasted-images')
197
- const root = await ensureManagedDirectory(workspace, requestedRoot)
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: requestedSessionRoot }
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
- const directory = await sessionPasteRoot(this.ctx, sessionId)
277
- const writtenPath = await writeImage(req, directory.writeRoot, filename, size, this.runtime.maxUploadBytes())
278
- const absolutePath = join(directory.visibleRoot, basename(writtenPath))
279
- responseJson(res, 201, { ok: true, value: { absolutePath, filename, bytes: size } })
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
- const status = error instanceof RangeError ? 413 : 400
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 { cp, link, lstat, mkdir, readdir, realpath, rename, rm, stat } from 'node:fs/promises'
11
- import { extname, isAbsolute, join, relative, resolve, sep, win32 } from 'node:path'
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: realpath the workspace, resolve and
70
- * realpath the platform temp directory and allowed directories, and create
71
- * the output directory inside the fence.
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 outputDirRaw - configured output directory (default `.dsh-vision-toolkit/artifacts`).
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
- outputDirRaw?: string,
301
+ storageDirRaw?: string,
302
+ readableStorageDirs: readonly string[] = [],
81
303
  ): Promise<PathPolicy> {
82
- let workspace: string
83
- try {
84
- workspace = await realpath(expandUserHome(workspaceRaw))
85
- } catch (error) {
86
- throw new VisionToolkitError('path', `workspace is not accessible: ${workspaceRaw}`, { cause: error })
87
- }
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 outputRaw = outputDirRaw === undefined || outputDirRaw.trim().length === 0
106
- ? join(workspace, '.dsh-vision-toolkit', 'artifacts')
107
- : resolve(workspace, expandUserHome(outputDirRaw))
108
- if (!roots.some(root => isWithin(root, outputRaw))) {
109
- throw new VisionToolkitError('path', 'output directory must stay inside the workspace or an allowedDirs entry')
110
- }
111
- let outputDir: string
112
- try {
113
- await mkdir(outputRaw, { recursive: true })
114
- outputDir = await realpath(outputRaw)
115
- } catch (error) {
116
- throw new VisionToolkitError('path', `output directory is not writable: ${outputRaw}`, { cause: error })
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
  /**