dsh-vision-router 1.7.1 → 1.7.4

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/cordis.patch.yml CHANGED
@@ -4,9 +4,9 @@
4
4
  # without any manual cordis.patch.yml edits. Later layers (the profile's own
5
5
  # cordis.patch.yml, --patch overlays) override these rows by id.
6
6
 
7
- # 纯增量补丁(issue #34):不碰核心行。DSH rc.6仍保留旧版隐身接管兼容路径;
8
- # DSH rc.7则始终由宿主拥有deepseek-official,插件只提供「+ 自动识图」包装路由。
9
- # 因此这里永远不默认禁用官方llm-deepseek行。
7
+ # 纯增量补丁(issue #34):不碰核心行。最低支持 Host 仍保留旧兼容路径;
8
+ # batch-attachment 合同的 Host 始终拥有 deepseek-official,插件只提供
9
+ # 「+ 自动识图」包装路由,因此这里永远不默认禁用官方 llm-deepseek 行。
10
10
 
11
11
  # 挂载插件行。默认保持完整视觉工具表常驻(issue #81):虽然渐进挂载可以
12
12
  # 少发一小段工具 schema,但图片轮首次扩展工具列表会改变请求前缀,可能让
@@ -18,14 +18,27 @@
18
18
  config:
19
19
  progressiveTools: false
20
20
 
21
- # 放宽附件图片限制(宿主默认 5MB / 4000 万像素 → 20MB / 1 亿像素),
22
- # 超长聊天记录截图(vision_long_screenshot_ocr 主场景)与大尺寸设计稿/
23
- # 扫描图可过宿主准入。rc6 与 rc7 的 @deepseek-ai/dsh-attachment-local
24
- # 均接受这两个可选字段(0.1.0-rc.6 / 0.1.0-rc.7 schema 已核对),且两边
25
- # dsh-base bundle 都存在 `- id: attachment-local` 行,因此该补丁在两个
26
- # 运行时上都有效、不破坏 host-neutral 原则;字段可选,不需要可在 profile
27
- # 补丁层覆写。
21
+ # Vision Router 的附件产品契约:Host attachment store 负责保存用户提交的
22
+ # durable 原件,具体视觉后端负责本次推理所需的 resize/tile/request budget。
23
+ # 因此存储准入保持 20MiB / 1 亿像素,同时在 DSH rc.8 新增的单边限制上
24
+ # 显式选择 10000px:覆盖 4K/手机原图和常见长截图,但不把 Host 的全局
25
+ # 极端长边安全闸无限放开。该值作用于整个 attachment-local(包括原生视觉
26
+ # provider),所以这里刻意不用 32768/无限;更大的部署需求仍可由后续
27
+ # profile/--patch 层按实际 provider 能力覆盖。
28
+ #
29
+ # DSH 的 patch 是“整段 config 替换”而非深度合并。旧版 Vision Router 曾在
30
+ # profile 自己的 cordis.patch.yml 写入同一行的 20MiB/100MP 两个字段;这层
31
+ # 比 bundle 晚,会把这里新加的 maxImageDimension 擦掉,让 rc.8 又落回 2000。
32
+ # 1.7.2 起运行时只对“20MiB + 100MP + rc.8 默认 2000px”这一历史指纹做一次
33
+ # 内存态迁移到 10000px;显式自定义过 dimension 或其他附件策略一律不碰。
34
+ # 这样升级旧 profile 不要求用户手工清理配置,同时后续 profile/--patch 的
35
+ # 明确部署策略仍保持最终所有权。
36
+ #
37
+ # rc.6/rc.7 没有 maxImageDimension;发布合同 CI 会用真实旧包验证额外配置键
38
+ # 不会破坏最低支持 Host。若旧 Host 开始严格拒绝未知键,此处必须改为按能力
39
+ # 生成配置,而不是靠版本字符串分支。
28
40
  - id: attachment-local
29
41
  config:
30
42
  maxImageBytes: 20971520
31
43
  maxImagePixels: 100000000
44
+ maxImageDimension: 10000
package/entry.js CHANGED
@@ -15,6 +15,7 @@ import { contextWithVisionExecutionPolicy } from './lib/vision-execution-policy.
15
15
  import { installLiveModelDiscovery } from './lib/live-model-discovery.js'
16
16
  import { installVisionModelRegistry } from './lib/vision-model-registry.js'
17
17
  import { installLiveModelClientPrelude } from './lib/live-model-client-prelude.js'
18
+ import { installClientPresentationBoundary } from './lib/client-presentation-boundary.js'
18
19
  import { installAdversarialHardening } from './lib/adversarial-hardening.js'
19
20
  import { installOllamaColdStartGuard } from './lib/ollama-cold-start.js'
20
21
  import { installLocalVisionStabilizer } from './lib/local-vision-stabilizer.js'
@@ -24,16 +25,19 @@ import { contextWithCoalescedAdapterUpdates } from './lib/adapter-update-coalesc
24
25
  import { installTesseractExecFileCompat } from './lib/tesseract-exec-compat.js'
25
26
  import { installLocalMutationRouteBoundary } from './lib/web-capability-boundary.js'
26
27
  import { installScreenshotSourceBoundary } from './lib/screenshot-source-boundary.js'
28
+ import { installVisionToolRuntimeBoundary } from './lib/vision-tool-runtime-boundary.js'
27
29
  import { installVisionRouterRemoteSettingsBridge } from './lib/remote-settings-bridge.js'
30
+ import { installSettingsRc8ClientLifecycle } from './lib/settings-client-rc8-lifecycle.js'
28
31
  import {
29
32
  installStructuredFlowHardening,
30
33
  normalizeGuidanceOverrides,
31
34
  } from './lib/structured-flow-hardening.js'
32
35
  import {
33
36
  attachmentContextForContract,
34
- installRc7SettingsCompatibility,
35
- isRc7ContractRuntime,
36
- protectRc7ProviderOwnership,
37
+ hasBatchAttachmentContract,
38
+ installHostSettingsCompatibility,
39
+ installVisionAttachmentAdmissionPolicy,
40
+ protectHostProviderOwnership,
37
41
  } from './lib/dsh-contract-compat.js'
38
42
 
39
43
  // Increment whenever the browser-visible settings contract gains a field whose
@@ -56,7 +60,7 @@ core.Config.set('visionTurnBudgetMs', z.number().step(1000).min(10000).max(60000
56
60
  // Both visible entry points — Settings > Vision Router and the legacy
57
61
  // Settings > Plugins compatibility card — edit the same Host-owned namespace.
58
62
  // Keep the depth enum and custom cap on this final public contract so either
59
- // entry serializes exactly the same shape and rc.7 Host persistence accepts it.
63
+ // entry serializes exactly the same shape on every supported Host generation.
60
64
  core.Config.set('visionDepth', z.union(['fast', 'standard', 'deep', 'custom']).default('standard'))
61
65
  core.Config.set('visionDepthMaxCalls', z.number().step(1).min(0).max(100).default(0))
62
66
 
@@ -79,6 +83,13 @@ core.Config.set(
79
83
  export * from './index.js'
80
84
  export {
81
85
  attachmentContextForContract,
86
+ ensureVisionAttachmentAdmissionPolicy,
87
+ hasBatchAttachmentContract,
88
+ installHostSettingsCompatibility,
89
+ installVisionAttachmentAdmissionPolicy,
90
+ protectHostProviderOwnership,
91
+ // Transitional public aliases retained for callers/tests written during the
92
+ // rc.7 compatibility pass. Runtime code below no longer branches on names.
82
93
  installRc7SettingsCompatibility,
83
94
  isRc7ContractRuntime,
84
95
  protectRc7ProviderOwnership,
@@ -106,7 +117,7 @@ export function apply(ctx, config = {}) {
106
117
  : 'deepseek-vision',
107
118
  visionConfig: config,
108
119
  })
109
- // DSH rc.7 pi-ai replay v2 stores the real producer under
120
+ // Newer pi-ai replay envelopes store the real producer under
110
121
  // replayState.response.{provider,model}; the older delegated-replay shim
111
122
  // recognizes the pre-v2 top-level shape. Layer a narrow private compatibility
112
123
  // view so resumed wrapper history keeps provider-native replay metadata rather
@@ -150,31 +161,52 @@ export function apply(ctx, config = {}) {
150
161
  ? Number(bootConfig.visionTurnBudgetMs)
151
162
  : 90000,
152
163
  }
153
- const rc7 = isRc7ContractRuntime(stabilizedCtx)
164
+ // The batch-attachment API is the released, non-incidental discriminator
165
+ // between the minimum Host contract and the newer Host-owned integration
166
+ // generation. Keep the branch named after that observable capability rather
167
+ // than a release number so rc.8+ naturally follows the same public contract.
168
+ const batchAttachmentHost = hasBatchAttachmentContract(stabilizedCtx)
169
+ // DSH may reconstruct attachment-local after a profile/home patch reload.
170
+ // Keep the historical rc.8 overlay migration attached to that service
171
+ // lifecycle instead of healing only the instance present during apply().
172
+ if (batchAttachmentHost) {
173
+ installVisionAttachmentAdmissionPolicy(stabilizedCtx, logging.logger)
174
+ }
154
175
  // The remote settings bridge uses DSH Connection's trusted-host carrier
155
176
  // fence and its own safe-field capability allow-list. Main's local Web
156
177
  // mutation boundary continues to protect the independent /_dsh write routes.
157
178
  installVisionRouterRemoteSettingsBridge(stabilizedCtx, logging.logger)
158
- const ownershipCtx = rc7 ? protectRc7ProviderOwnership(stabilizedCtx) : stabilizedCtx
159
- const settingsCtx = rc7
160
- ? installRc7SettingsCompatibility(ownershipCtx, { ...runtimeConfig, stealth: false }, {
179
+ // rc.8 swaps ModuleLoader.load() while entering live mode. The older local
180
+ // permission/risk shims still own rc.6/rc.7; this narrow lifecycle shim
181
+ // re-installs both contexts after rc.8's queue -> live transition.
182
+ installSettingsRc8ClientLifecycle(stabilizedCtx)
183
+ const ownershipCtx = batchAttachmentHost
184
+ ? protectHostProviderOwnership(stabilizedCtx)
185
+ : stabilizedCtx
186
+ const settingsCtx = batchAttachmentHost
187
+ ? installHostSettingsCompatibility(ownershipCtx, { ...runtimeConfig, stealth: false }, {
161
188
  namespace: 'vision-router',
162
189
  Config: core.Config,
163
190
  })
164
191
  : ownershipCtx
165
- // rc.6/Termux keeps the narrow process-local fallback that was required by
166
- // the old attachment-local durability walk. rc.7 formalizes AttachmentId as
167
- // store-owned, so never synthesize one there: host persistence errors remain
192
+ // The minimum Host keeps the narrow process-local fallback required by the
193
+ // old attachment-local durability walk. Batch-capable hosts keep AttachmentId
194
+ // store-owned, so never fabricate one there: host persistence errors remain
168
195
  // authoritative and diagnosable instead of creating a false durable ref.
169
196
  const attachmentCompatCtx = attachmentContextForContract(settingsCtx, logging.logger, {
170
197
  installAndroidAttachmentCompat,
171
198
  })
199
+ // Put per-tool cwd/cancellation/cache policy AFTER Host settings compatibility
200
+ // so rc.7/rc.8's synthetic settings injection is visible to the boundary.
201
+ // The secure screenshot renderer owns its exact FsTarget and active browser
202
+ // cancellation directly, so it does not depend on this placement.
203
+ const toolRuntimeCtx = installVisionToolRuntimeBoundary(attachmentCompatCtx)
172
204
  // Final structured-flow guard sits closest to core.apply so it sees the
173
205
  // actual tool registrations and pre-step listener. It makes bootstrap
174
206
  // one-shot, enforces fast/standard/deep/custom quotas, tracks mixed branches,
175
207
  // rejects empty/non-evidence results, and applies one shared visual deadline.
176
- const structuredCtx = installStructuredFlowHardening(attachmentCompatCtx, runtimeConfig)
177
- // DSH rc.7 publishes llm/adapters-updated synchronously from inside
208
+ const structuredCtx = installStructuredFlowHardening(toolRuntimeCtx, runtimeConfig)
209
+ // Newer DSH releases publish llm/adapters-updated synchronously from inside
178
210
  // registerAdapter(). Coalesce only Vision Router's listener: nested events
179
211
  // mark the topology dirty and the outer pass reruns to a fixed point, so we
180
212
  // neither double-register a twin nor lose a provider added mid-pass.
@@ -196,6 +228,12 @@ export function apply(ctx, config = {}) {
196
228
  // source strictly for diagnostics (`known` vs `live`) without changing the
197
229
  // admission decision.
198
230
  installVisionModelRegistry(reconciledCtx, liveDiscovery, { config: runtimeConfig })
231
+ // rc.8 turns ui-attachment into a dynamic presentation plugin and no longer
232
+ // exports its React implementation as package values. Install a narrowly
233
+ // scoped browser boundary that supplies Vision Router's own lightweight
234
+ // gallery to the legacy 1.7.x client factory, so the official package is
235
+ // never value-required at runtime and remains free to evolve independently.
236
+ installClientPresentationBoundary(reconciledCtx)
199
237
  // Keep endpoint-discovered ids private to Vision Router's settings client:
200
238
  // the prelude wraps this package's browser context rather than changing the
201
239
  // global llm.models response (which would expose UNKNOWN_MODEL entries in the
@@ -229,7 +267,7 @@ export function apply(ctx, config = {}) {
229
267
  const lms = c.localLmStudio && typeof c.localLmStudio === 'object' ? c.localLmStudio : {}
230
268
  logging.logger.info(
231
269
  'vision-router: base config summary — contract=%s instantDescribe=%s localDescribeStyle=%s localOllama=%s localLmStudio=%s',
232
- rc7 ? 'rc7' : 'rc6',
270
+ batchAttachmentHost ? 'batch-attachments' : 'single-attachment',
233
271
  c.instantDescribe === true ? 'on' : 'off',
234
272
  c.localDescribeStyle === 'structured' ? 'structured' : 'plain',
235
273
  local.enabled === true ? 'on' : 'off',
@@ -240,11 +278,10 @@ export function apply(ctx, config = {}) {
240
278
  }
241
279
  try {
242
280
  const result = core.apply(executionCtx, runtimeConfig)
243
- // DSH rc.7's Settings -> Models surface is backed by the configurable
244
- // provider directory, not by the live adapter registry alone. Publish the
245
- // main DeepSeek + 自动识图 route as a derived alias of official DeepSeek so
246
- // a reinstall restores the expected model-group row without making an
247
- // arbitrary textProvider look like DeepSeek. On rc.6 the helper is inert.
281
+ // On newer Hosts the Settings -> Models surface is backed by the
282
+ // configurable-provider directory, not by the live adapter registry alone.
283
+ // Publish the main DeepSeek + 自动识图 route as a derived alias of official
284
+ // DeepSeek. On older Hosts the helper feature-detects and stays inert.
248
285
  installWrapperDirectoryAlias(attachmentCompatCtx, runtimeConfig, logging.logger)
249
286
  if (result && typeof result.then === 'function') {
250
287
  return result.catch((error) => {
@@ -3,6 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url'
5
5
  import { writeArtifactFile } from './artifact-boundary.js'
6
+ import { assertScreenshotSourceInWorkspace } from './screenshot-source-boundary.js'
6
7
 
7
8
  const DEFAULT_ARTIFACTS_DIR = '.dsh-vision-router/artifacts'
8
9
  const MAX_VIEWPORT_WIDTH = 4096
@@ -217,7 +218,7 @@ export async function wakePageForFullCaptureBounded(
217
218
  }
218
219
 
219
220
  function screenshotAbortError() {
220
- const error = new Error('vision_html_screenshot: browser slot wait aborted')
221
+ const error = new Error('vision_html_screenshot: browser work aborted')
221
222
  error.name = 'AbortError'
222
223
  error.code = 'ABORT_ERR'
223
224
  return error
@@ -299,18 +300,26 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
299
300
 
300
301
  return async (args, exec) => {
301
302
  const source = String(args?.source ?? '')
303
+ const signal = exec?.signal
304
+ if (signal?.aborted) throw screenshotAbortError()
302
305
  if (!/\.(html?|htm)$/i.test(source)) {
303
306
  throw new Error('vision_html_screenshot: source must be a local .html/.htm file')
304
307
  }
305
308
  const fsService = ctx.get('fs')
306
309
  if (fsService === undefined) throw new Error('vision_html_screenshot: the fs service is not available')
307
- const resolved = await fsService.resolve(source)
310
+ // Authorize the exact FsTarget that will be rendered. Do not validate one
311
+ // cwd interpretation and then resolve the same string again under the
312
+ // provider default cwd.
313
+ const resolved = await assertScreenshotSourceInWorkspace(ctx, core, source, exec, { realpathSync: realpath })
308
314
  const targetPath = core.toRealPath(fsService, resolved)
309
315
  if (!fileExists(targetPath)) throw new Error(`vision_html_screenshot: file not found: ${source}`)
310
316
 
311
317
  const targetReal = realpath(targetPath)
312
318
  const workspace = realpathOrResolve(workspaceOf(exec), realpath)
313
- const sourceRoot = isPathInside(workspace, targetReal) ? workspace : realpath(path.dirname(targetReal))
319
+ if (!isPathInside(workspace, targetReal)) {
320
+ throw new Error('vision_html_screenshot: source must stay inside the session workspace')
321
+ }
322
+ const sourceRoot = workspace
314
323
 
315
324
  const width = safeViewportDimension(args?.width, 1200, MAX_VIEWPORT_WIDTH, 'width')
316
325
  const height = safeViewportDimension(args?.height, 720, MAX_VIEWPORT_HEIGHT, 'height')
@@ -336,13 +345,22 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
336
345
  )
337
346
  }
338
347
 
339
- const releaseBrowserSlot = await browserGovernor.acquire({ signal: exec?.signal })
348
+ const releaseBrowserSlot = await browserGovernor.acquire({ signal })
340
349
  const launchArgs = ['--disable-gpu', '--hide-scrollbars', '--incognito']
341
350
  if (fullPage) launchArgs.push('--blink-settings=imagesLazyLoadingEnabled=false')
342
351
  const launcher = puppeteer.default ?? puppeteer
343
352
  let browser
353
+ let browserClosePromise
354
+ let abortHandler
344
355
  let png
345
356
  let pageHeight
357
+ const closeBrowser = () => {
358
+ if (!browser) return Promise.resolve()
359
+ if (!browserClosePromise) {
360
+ browserClosePromise = Promise.resolve(browser.close()).catch(() => undefined)
361
+ }
362
+ return browserClosePromise
363
+ }
346
364
  try {
347
365
  try {
348
366
  browser = await launcher.launch({ executablePath, headless: true, args: launchArgs })
@@ -350,6 +368,11 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
350
368
  const detail = error && error.message ? error.message : String(error)
351
369
  throw new Error(`vision_html_screenshot: secure Chrome sandbox launch failed: ${detail}`)
352
370
  }
371
+ if (signal) {
372
+ abortHandler = () => { void closeBrowser() }
373
+ signal.addEventListener('abort', abortHandler, { once: true })
374
+ }
375
+ if (signal?.aborted) throw screenshotAbortError()
353
376
 
354
377
  const page = await browser.newPage()
355
378
  await page.setViewport({ width, height })
@@ -362,6 +385,7 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
362
385
  await page.setOfflineMode(true)
363
386
  await wrapRequestInterception(page, sourceRoot)
364
387
  await page.goto(pathToFileURL(targetReal).href, { waitUntil: 'networkidle0', timeout: 30000 })
388
+ if (signal?.aborted) throw screenshotAbortError()
365
389
 
366
390
  if (fullPage) {
367
391
  pageHeight = await wakePageForFullCaptureBounded(page, height, width, {
@@ -371,13 +395,19 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
371
395
  maxWakeMs: deps.maxWakeMs,
372
396
  })
373
397
  }
398
+ if (signal?.aborted) throw screenshotAbortError()
374
399
 
375
400
  png = fullPage
376
401
  ? await page.screenshot({ type: 'png', fullPage: true })
377
402
  : await page.screenshot({ type: 'png' })
403
+ if (signal?.aborted) throw screenshotAbortError()
404
+ } catch (error) {
405
+ if (signal?.aborted && error?.code !== 'ABORT_ERR') throw screenshotAbortError()
406
+ throw error
378
407
  } finally {
408
+ if (signal && abortHandler) signal.removeEventListener('abort', abortHandler)
379
409
  try {
380
- if (browser) await browser.close()
410
+ await closeBrowser()
381
411
  } finally {
382
412
  releaseBrowserSlot()
383
413
  }
@@ -385,6 +415,7 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
385
415
 
386
416
  // The heavyweight Chrome slot is released before filesystem artifact IO;
387
417
  // slow antivirus/indexing must not unnecessarily serialize later captures.
418
+ if (signal?.aborted) throw screenshotAbortError()
388
419
  const stem = fullPage ? `shot-${width}x${height}-fullpage` : `shot-${width}x${height}`
389
420
  const fileName = `${core.artifactStemOf(source, stem)}.png`
390
421
  let target
@@ -1,6 +1,14 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
  import { lstat, mkdir, realpath, rename, unlink, writeFile } from 'node:fs/promises'
3
3
  import path from 'node:path'
4
+ import {
5
+ currentVisionTurnBudget,
6
+ currentVisionTurnBudgetSignal,
7
+ } from './turn-budget-context.js'
8
+ import {
9
+ isManagedArtifactRunName,
10
+ scheduleArtifactRetention,
11
+ } from './artifact-retention.js'
4
12
 
5
13
  export const DEFAULT_ARTIFACTS_DIR = '.dsh-vision-router/artifacts'
6
14
 
@@ -13,6 +21,22 @@ function isMissing(error) {
13
21
  return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')
14
22
  }
15
23
 
24
+ function artifactAbortError() {
25
+ const error = new Error('vision-router: artifact publication aborted')
26
+ error.name = 'AbortError'
27
+ error.code = 'ABORT_ERR'
28
+ return error
29
+ }
30
+
31
+ function throwIfVisionAborted() {
32
+ if (currentVisionTurnBudgetSignal()?.aborted) throw artifactAbortError()
33
+ }
34
+
35
+ function currentArtifactRunId() {
36
+ const value = currentVisionTurnBudget()?.artifactRunId
37
+ return isManagedArtifactRunName(value) ? value : undefined
38
+ }
39
+
16
40
  export function normalizeArtifactsDir(value) {
17
41
  if (typeof value !== 'string' || value.trim() === '') return DEFAULT_ARTIFACTS_DIR
18
42
  const raw = value.trim()
@@ -69,18 +93,22 @@ async function safeLstat(target, lstatImpl) {
69
93
  /**
70
94
  * Resolve an artifact target without treating lexical workspace containment as
71
95
  * authority. Existing ancestors are canonicalized before mkdir runs, then the
72
- * completed parent is canonicalized again. This prevents an artifactsDir (or
73
- * nested OCR directory) symlink from turning a workspace-looking path into an
74
- * outside write.
96
+ * completed parent and artifact root are canonicalized again.
75
97
  */
76
98
  export async function resolveArtifactTarget(workspace, artifactsDir, relativePath, deps = {}) {
77
99
  const realpathImpl = deps.realpath ?? realpath
78
100
  const mkdirImpl = deps.mkdir ?? mkdir
79
101
  const lstatImpl = deps.lstat ?? lstat
80
102
 
103
+ throwIfVisionAborted()
81
104
  const workspaceReal = await realpathImpl(path.resolve(String(workspace ?? '')))
105
+ throwIfVisionAborted()
82
106
  const relativeBase = normalizeArtifactsDir(artifactsDir)
83
- const relativeTarget = normalizeRelativeArtifactPath(relativePath)
107
+ const runId = currentArtifactRunId()
108
+ const requestedTarget = normalizeRelativeArtifactPath(relativePath)
109
+ const relativeTarget = runId
110
+ ? normalizeRelativeArtifactPath(path.join(runId, requestedTarget))
111
+ : requestedTarget
84
112
  const lexicalBase = path.resolve(workspaceReal, relativeBase)
85
113
  if (!isPathInside(workspaceReal, lexicalBase)) {
86
114
  throw new Error('vision-router: artifactsDir must stay inside the session workspace')
@@ -92,22 +120,27 @@ export async function resolveArtifactTarget(workspace, artifactsDir, relativePat
92
120
 
93
121
  const lexicalParent = path.dirname(lexicalTarget)
94
122
  const existingAncestorReal = await realpathNearestExisting(lexicalParent, realpathImpl)
123
+ throwIfVisionAborted()
95
124
  if (!isPathInside(workspaceReal, existingAncestorReal)) {
96
125
  throw new Error('vision-router: artifact parent escapes the session workspace through a symlink')
97
126
  }
98
127
 
99
128
  await mkdirImpl(lexicalParent, { recursive: true })
129
+ throwIfVisionAborted()
100
130
  const parentReal = await realpathImpl(lexicalParent)
101
- if (!isPathInside(workspaceReal, parentReal)) {
131
+ const artifactsBaseReal = await realpathImpl(lexicalBase)
132
+ throwIfVisionAborted()
133
+ if (!isPathInside(workspaceReal, parentReal) || !isPathInside(workspaceReal, artifactsBaseReal)) {
102
134
  throw new Error('vision-router: artifact parent escapes the session workspace through a symlink')
103
135
  }
104
136
 
105
137
  const target = path.join(parentReal, path.basename(lexicalTarget))
106
138
  const existing = await safeLstat(target, lstatImpl)
139
+ throwIfVisionAborted()
107
140
  if (existing?.isDirectory?.()) {
108
141
  throw new Error('vision-router: artifact target is a directory')
109
142
  }
110
- return { target, parentReal, existing }
143
+ return { target, parentReal, artifactsBaseReal, existing, runId }
111
144
  }
112
145
 
113
146
  /**
@@ -126,16 +159,22 @@ export async function writeArtifactFile(workspace, artifactsDir, relativePath, d
126
159
  )
127
160
  let published = false
128
161
  try {
162
+ throwIfVisionAborted()
129
163
  await writeFileImpl(temp, data, { mode: 0o600 })
164
+ throwIfVisionAborted()
130
165
  if (resolved.existing !== undefined) {
131
166
  try {
132
167
  await unlinkImpl(resolved.target)
133
168
  } catch (error) {
134
169
  if (!isMissing(error)) throw error
135
170
  }
171
+ throwIfVisionAborted()
136
172
  }
137
173
  await renameImpl(temp, resolved.target)
138
174
  published = true
175
+ if (resolved.runId) {
176
+ scheduleArtifactRetention(resolved.artifactsBaseReal, { protectRunId: resolved.runId })
177
+ }
139
178
  return resolved.target
140
179
  } finally {
141
180
  if (!published) {
@@ -0,0 +1,124 @@
1
+ import { lstat, readdir, rm } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ export const ARTIFACT_RUN_PREFIX = '.vision-run-'
5
+ export const DEFAULT_ARTIFACT_TTL_MS = 30 * 24 * 60 * 60 * 1000
6
+ export const DEFAULT_ARTIFACT_MAX_BYTES = 2 * 1024 * 1024 * 1024
7
+ export const DEFAULT_ARTIFACT_MAX_RUNS = 512
8
+ export const DEFAULT_ARTIFACT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000
9
+
10
+ const cleanupState = new Map()
11
+ const MAX_TRACKED_ROOTS = 64
12
+
13
+ export function isManagedArtifactRunName(name) {
14
+ return typeof name === 'string' && /^\.vision-run-[A-Za-z0-9._-]+$/.test(name)
15
+ }
16
+
17
+ async function treeBytes(target) {
18
+ let info
19
+ try { info = await lstat(target) } catch { return 0 }
20
+ if (info.isSymbolicLink()) return 0
21
+ if (!info.isDirectory()) return Number(info.size) || 0
22
+ let total = 0
23
+ let entries
24
+ try { entries = await readdir(target, { withFileTypes: true }) } catch { return 0 }
25
+ for (const entry of entries) {
26
+ total += await treeBytes(path.join(target, entry.name))
27
+ }
28
+ return total
29
+ }
30
+
31
+ function finitePositive(value, fallback) {
32
+ const number = Number(value)
33
+ return Number.isFinite(number) && number > 0 ? number : fallback
34
+ }
35
+
36
+ /**
37
+ * Delete only Vision Router run directories. Unknown files, legacy artifacts,
38
+ * symlinks and user-created directories are intentionally outside this policy.
39
+ */
40
+ export async function cleanupArtifactRuns(root, options = {}) {
41
+ const ttlMs = finitePositive(options.ttlMs, DEFAULT_ARTIFACT_TTL_MS)
42
+ const maxBytes = finitePositive(options.maxBytes, DEFAULT_ARTIFACT_MAX_BYTES)
43
+ const maxRuns = Math.max(1, Math.floor(finitePositive(options.maxRuns, DEFAULT_ARTIFACT_MAX_RUNS)))
44
+ const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now()
45
+ const protect = typeof options.protectRunId === 'string' ? options.protectRunId : undefined
46
+
47
+ let entries
48
+ try { entries = await readdir(root, { withFileTypes: true }) } catch { return { scanned: 0, removed: 0, bytes: 0 } }
49
+ const runs = []
50
+ for (const entry of entries) {
51
+ if (!entry.isDirectory() || !isManagedArtifactRunName(entry.name)) continue
52
+ const target = path.join(root, entry.name)
53
+ let info
54
+ try { info = await lstat(target) } catch { continue }
55
+ if (!info.isDirectory() || info.isSymbolicLink()) continue
56
+ runs.push({
57
+ name: entry.name,
58
+ target,
59
+ mtimeMs: Number(info.mtimeMs) || 0,
60
+ bytes: await treeBytes(target),
61
+ protected: entry.name === protect,
62
+ })
63
+ }
64
+
65
+ runs.sort((a, b) => b.mtimeMs - a.mtimeMs)
66
+ const remove = new Set()
67
+ for (const run of runs) {
68
+ if (!run.protected && now - run.mtimeMs >= ttlMs) remove.add(run)
69
+ }
70
+
71
+ const survivors = () => runs.filter((run) => !remove.has(run))
72
+ let live = survivors()
73
+ while (live.length > maxRuns) {
74
+ const candidate = [...live].reverse().find((run) => !run.protected)
75
+ if (!candidate) break
76
+ remove.add(candidate)
77
+ live = survivors()
78
+ }
79
+
80
+ let totalBytes = live.reduce((sum, run) => sum + run.bytes, 0)
81
+ while (totalBytes > maxBytes) {
82
+ const candidate = [...live].reverse().find((run) => !run.protected)
83
+ if (!candidate) break
84
+ remove.add(candidate)
85
+ totalBytes -= candidate.bytes
86
+ live = survivors()
87
+ }
88
+
89
+ let removed = 0
90
+ for (const run of remove) {
91
+ try {
92
+ await rm(run.target, { recursive: true, force: true })
93
+ removed += 1
94
+ } catch {
95
+ // Retention is best-effort and must never make the foreground tool fail.
96
+ }
97
+ }
98
+ return { scanned: runs.length, removed, bytes: Math.max(0, totalBytes) }
99
+ }
100
+
101
+ /** Coalesce cleanup work per artifact root so foreground tools only schedule it. */
102
+ export function scheduleArtifactRetention(root, options = {}) {
103
+ if (typeof root !== 'string' || root === '') return
104
+ const now = Date.now()
105
+ let state = cleanupState.get(root)
106
+ if (!state) {
107
+ state = { lastStartedAt: 0, promise: undefined }
108
+ cleanupState.set(root, state)
109
+ }
110
+ const intervalMs = finitePositive(options.intervalMs, DEFAULT_ARTIFACT_CLEANUP_INTERVAL_MS)
111
+ if (state.promise || now - state.lastStartedAt < intervalMs) return
112
+ state.lastStartedAt = now
113
+ state.promise = cleanupArtifactRuns(root, options)
114
+ .catch(() => undefined)
115
+ .finally(() => { state.promise = undefined })
116
+
117
+ cleanupState.delete(root)
118
+ cleanupState.set(root, state)
119
+ while (cleanupState.size > MAX_TRACKED_ROOTS) {
120
+ const oldest = cleanupState.keys().next().value
121
+ if (oldest === undefined) break
122
+ cleanupState.delete(oldest)
123
+ }
124
+ }