dsh-vision-router 1.7.3 → 1.7.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/docs/doctor.md CHANGED
@@ -1,10 +1,14 @@
1
1
  # Vision Router doctor / repair
2
2
 
3
- Vision Router ships a small standalone diagnostic CLI. It does not need DSH to boot first, so it can still run when DSH exits while parsing a broken profile manifest or when an older Vision Router build left one conversation unable to cold-resume.
3
+ Vision Router ships a standalone diagnostic CLI. It can inspect a broken DSH profile even when DSH cannot boot, and it can optionally probe a running Web instance without executing any Vision Router action.
4
4
 
5
- ## Normal installation stays unchanged
5
+ The commands have a deliberately conservative split:
6
+
7
+ - `doctor` is read-only by default;
8
+ - `repair` changes only the two known profile-level faults described below;
9
+ - `repair-sessions` changes only exact, known Vision Router session-corruption signatures and always makes a byte-for-byte backup first.
6
10
 
7
- Use DSH's own plugin command:
11
+ ## Normal installation stays unchanged
8
12
 
9
13
  ```sh
10
14
  npx @deepseek-ai/dsh plugin --profile web add dsh-vision-router
@@ -20,19 +24,90 @@ pnpm dsh web
20
24
 
21
25
  The doctor is a recovery/diagnostic tool, not a replacement installer.
22
26
 
23
- ## Diagnose profiles
27
+ ## Doctor v2
28
+
29
+ Run the normal health check:
24
30
 
25
31
  ```sh
26
32
  npx dsh-vision-router doctor
27
33
  ```
28
34
 
29
- To inspect only the Web profile:
35
+ Or inspect one profile explicitly:
30
36
 
31
37
  ```sh
32
38
  npx dsh-vision-router doctor --profile web
33
39
  ```
34
40
 
35
- The command locates the DSH home (`$DSH_HOME` when set, otherwise `~/.dsh`), scans profile `package.json` files, reports UTF-8 BOM bytes, validates the JSON after ignoring a leading BOM for diagnosis, reports whether `dsh-vision-router` is present as a profile dependency and bundle layer, and flags version-pinned `minimumReleaseAgeExclude` entries in the profile's `pnpm-workspace.yaml` that would hold back the next release.
41
+ The doctor now separates **"the profile JSON parses"** from **"Vision Router is actually healthy"**. A requested profile is unhealthy when the package is missing, declared but not installed, installed but not mounted, registered twice, or otherwise cannot form one valid activation path.
42
+
43
+ It recognizes both supported profile shapes:
44
+
45
+ - **bundle mode** — `dsh-vision-router` is declared in `dependencies` and registered once in `dsh.profile.bundles`;
46
+ - **manual mode** — the dependency is declared and the profile's `cordis.patch.yml` contains one Vision Router row.
47
+
48
+ It reports a hard failure for a bundle+manual double registration, duplicate bundle/manual rows, a missing installed package, or an installed/declared package with no activation row. Profiles that do not use Vision Router are shown as unrelated when scanning all profiles; they do not make another healthy Vision Router profile fail.
49
+
50
+ The offline profile check also keeps the existing diagnostics for:
51
+
52
+ - a leading UTF-8 BOM or otherwise invalid profile JSON;
53
+ - stale version-pinned `minimumReleaseAgeExclude` entries;
54
+ - coexisting vision plugins;
55
+ - legacy profile patches that statically disable `llm-deepseek`;
56
+ - recent structured `settings save failed field=... operation=... reason=...` records from the Vision Router log. Raw log lines, API keys and arbitrary values are not copied into the report.
57
+
58
+ ### Running-DSH route probe
59
+
60
+ When DSH Web is reachable, doctor checks that the Vision Router exact routes are really registered. This catches the class of regressions where the plugin partly loads but routes such as `update-check` or `model-capabilities` silently disappear and the SPA fallback returns HTML instead.
61
+
62
+ The probe sends an intentionally unsupported **DELETE** request to each known route. Every current Vision Router handler rejects that method with `405 Method Not Allowed` and its exact `Allow` contract **before** executing GET/POST behavior. Doctor verifies that exact method contract, so it does **not** run an update check, model discovery, connection test, settings mutation, self-update or log-opening action. Using the expected `Allow` set also prevents a generic SPA/unknown-route response from being mistaken for a registered Vision Router route.
63
+
64
+ Default target:
65
+
66
+ ```text
67
+ http://127.0.0.1:3080
68
+ ```
69
+
70
+ Override it with:
71
+
72
+ ```sh
73
+ npx dsh-vision-router doctor --runtime-url http://127.0.0.1:4000
74
+ ```
75
+
76
+ or `DSH_WEB_URL`. To perform offline checks only:
77
+
78
+ ```sh
79
+ npx dsh-vision-router doctor --no-runtime
80
+ ```
81
+
82
+ An unreachable DSH process is advisory rather than a doctor failure; offline checks still complete. When `--profile` is used against a reachable runtime, doctor does not attribute green route health to that profile unless the runtime exposes a verified Vision Router profile identity. If ownership cannot be proven, the human report shows `? runtime profile ownership unknown` instead of a false green binding.
83
+
84
+ ### Local capability diagnostics
85
+
86
+ Doctor reports the local platform, Node version, and whether it can find:
87
+
88
+ - Tesseract (PATH plus the common Windows install location);
89
+ - Chromium / Chrome / Edge (PATH plus common Windows and macOS locations);
90
+ - `sharp` and selected DSH host package versions when they are present inside the inspected profile.
91
+
92
+ Tesseract, Chromium and profile-local Sharp are advisory because the corresponding optional tool may be unused or supplied by the host through another resolution path.
93
+
94
+ ### Scan historical sessions
95
+
96
+ Session scanning is opt-in because a large session store can take longer to read/decompress:
97
+
98
+ ```sh
99
+ npx dsh-vision-router doctor --sessions
100
+ ```
101
+
102
+ This is read-only. It looks only for the exact known Vision Router corruption signatures supported by `repair-sessions`.
103
+
104
+ ### Shareable JSON report
105
+
106
+ ```sh
107
+ npx dsh-vision-router doctor --profile web --sessions --json
108
+ ```
109
+
110
+ The JSON report is schema-versioned and includes the running Doctor/Vision Router version. It is intentionally minimized for issue reports: it does not contain the raw profile manifest, raw log lines, API keys, raw dependency specs, local dependency paths, URL credentials/query strings/fragments, session ids, or raw runtime error text. It keeps only structured diagnostic facts such as install mode, installed version, route status, failure counts, optional capability versions and known session-repair kinds.
36
111
 
37
112
  ## Repair the UTF-8 BOM startup failure
38
113
 
@@ -51,33 +126,47 @@ npx dsh-vision-router repair --profile web
51
126
 
52
127
  `repair` removes only the three-byte UTF-8 BOM prefix (`EF BB BF`) when it is present, then validates the remaining JSON. It does not reformat, regenerate, or otherwise rewrite the profile contents. If JSON is still invalid for another reason, the command reports that and stops rather than guessing a repair.
53
128
 
54
- ## Repair a stale release-age exemption (the "update does nothing" gate)
55
-
56
- pnpm v11 defaults `minimumReleaseAge` to 1440 minutes: a version published less than 24 hours ago is not resolved, so `dsh plugin update` silently keeps the previous version and prints `downloaded 0 / added 0`. An exemption entry that pins a version — `dsh-vision-router@1.2.0` — only exempts that one version and goes stale on the next release, which is why "a new release is out but the update does nothing" keeps recurring.
129
+ `doctor --fix` remains accepted for backward compatibility, but new troubleshooting instructions should prefer the explicit `repair` command so that ordinary doctor runs stay obviously read-only.
57
130
 
58
- The doctor flags version-pinned entries for `dsh-vision-router` and the `@deepseek-ai/*` host packages:
131
+ ## Repair a stale release-age exemption (the "update does nothing" gate)
59
132
 
60
- ```text
61
- ✗ web — … — release-age exemption version-pinned (dsh-vision-router@1.2.0) — releases younger than 24h will not be picked up
62
- ```
133
+ pnpm v11 defaults `minimumReleaseAge` to 1440 minutes. A version-pinned exemption such as `dsh-vision-router@1.2.0` exempts only that version and goes stale on the next release.
63
134
 
64
- Run:
135
+ Doctor flags version-pinned entries for `dsh-vision-router` and `@deepseek-ai/*`. Run:
65
136
 
66
137
  ```sh
67
138
  npx dsh-vision-router repair --profile web
68
139
  ```
69
140
 
70
- to rewrite them to bare names (`dsh-vision-router`, `@deepseek-ai/*`), which exempt every future version, so upgrades take effect immediately again. Unrelated entries and the rest of the file are left untouched.
141
+ to rewrite only those stale targets to bare names (`dsh-vision-router`, `@deepseek-ai/*`). Unrelated entries and the rest of the workspace file are left untouched.
142
+
143
+ ## Repair conversations that only break after restarting DSH
144
+
145
+ Two historical Vision Router defects can leave already-persisted sessions unable to cold-resume even though current builds no longer create the bad events.
71
146
 
72
- ## Repair a conversation that only breaks after restarting DSH
147
+ ### Legacy missing message id
73
148
 
74
- A very early Vision Router build briefly persisted the automatic vision-tool mount reminder as a `user/message` without a message `id`. The conversation could keep working in the live process, but after DSH restarted the stricter cold-resume validator could reject that stored event with an error containing:
149
+ A very early build persisted the automatic vision-tool mount reminder as a `user/message` without an `id`. DSH can later reject it with an error containing:
75
150
 
76
151
  ```text
77
152
  lacks an identified message
78
153
  ```
79
154
 
80
- Current Vision Router builds no longer create that malformed event. To recover an already-affected conversation, **stop DSH first**, then run:
155
+ ### Duplicate structured guard-stop id
156
+
157
+ Older structured-flow builds could persist the same exact guard message more than once in one turn, for example:
158
+
159
+ ```text
160
+ vision-router-structured-guard-stop-1
161
+ ```
162
+
163
+ DSH then sees more than one `input-message...` start when the conversation is reloaded and can fail with an error containing:
164
+
165
+ ```text
166
+ received more than one start Match
167
+ ```
168
+
169
+ To repair either known case, **stop DSH first**, then run:
81
170
 
82
171
  ```sh
83
172
  npx dsh-vision-router repair-sessions
@@ -85,12 +174,16 @@ npx dsh-vision-router repair-sessions
85
174
 
86
175
  The repair is intentionally narrow and fail-closed:
87
176
 
88
- - it scans `$DSH_HOME/sessions` for the exact historical Vision Router auto-mount reminder signature only;
89
- - unrelated malformed messages are not changed;
90
- - both raw `session.jsonl` and DSH's default checksummed `session.jsonl.zstd` format are supported;
91
- - unchanged Zstandard frames stay byte-for-byte identical;
92
- - torn/incomplete logs are refused so DSH can perform its own crash recovery first;
93
- - the source file identity is checked again immediately before replacement, so a live writer causes the operation to abort instead of racing;
94
- - every changed log receives a byte-for-byte backup next to the original before replacement, and the repaired log is re-read and verified before success is reported.
95
-
96
- After the command reports a repaired session, restart DSH and reopen the conversation. Running `repair-sessions` again is idempotent: already-repaired logs are left untouched.
177
+ - the old missing-id repair still matches only the exact historical Vision Router auto-mount reminder;
178
+ - duplicate guard repair recognizes only Vision Router `user/message` events with the exact historical guard id shape, plugin source and one of the exact known budget/depth exhaustion texts;
179
+ - the first legitimate guard is preserved; later exact duplicates keep their original event and `seq`, but receive deterministic unique recovery ids so the durable event stream is not shortened;
180
+ - if the same guard id appears with a different/near-miss shape or text, automatic repair aborts instead of deleting data;
181
+ - raw `session.jsonl` and DSH's checksummed `session.jsonl.zstd` are supported;
182
+ - duplicate detection is preserved across separate Zstandard frames, and packed chunk rows are understood when validating the contiguous durable `seq` stream;
183
+ - unaffected Zstandard frames remain byte-for-byte identical;
184
+ - mutation refuses torn/incomplete logs so DSH can perform its own crash recovery first; read-only `doctor --sessions` treats only an incomplete live tail as advisory while committed structural corruption remains a failure;
185
+ - source file identity is rechecked immediately before replacement so a live writer aborts the operation;
186
+ - every changed log receives a byte-for-byte backup next to the original before replacement;
187
+ - the repaired file is re-read and verified before success is reported.
188
+
189
+ After repair, restart DSH and reopen the affected conversation. Running `repair-sessions` again is idempotent.
package/entry.js CHANGED
@@ -12,9 +12,13 @@ import { installVisionRouterFileLogging } from './lib/file-logger.js'
12
12
  import { contextWithDelegatedReplay } from './lib/replay-delegation.js'
13
13
  import { contextWithReplayEnvelopeV2Compat } from './lib/replay-envelope-v2-compat.js'
14
14
  import { contextWithVisionExecutionPolicy } from './lib/vision-execution-policy.js'
15
+ import { contextWithNativeImageCoexistence } from './lib/native-image-coexistence.js'
16
+ import { installPiAiBridgeWireCompat } from './lib/pi-ai-bridge-wire-compat.js'
15
17
  import { installLiveModelDiscovery } from './lib/live-model-discovery.js'
16
18
  import { installVisionModelRegistry } from './lib/vision-model-registry.js'
17
19
  import { installLiveModelClientPrelude } from './lib/live-model-client-prelude.js'
20
+ import { installExactVisionTestClient } from './lib/vision-backend-smoke-test-client.js'
21
+ import { installVisionBackendSmokeTest } from './lib/vision-backend-smoke-test.js'
18
22
  import { installClientPresentationBoundary } from './lib/client-presentation-boundary.js'
19
23
  import { installAdversarialHardening } from './lib/adversarial-hardening.js'
20
24
  import { installOllamaColdStartGuard } from './lib/ollama-cold-start.js'
@@ -25,16 +29,18 @@ import { contextWithCoalescedAdapterUpdates } from './lib/adapter-update-coalesc
25
29
  import { installTesseractExecFileCompat } from './lib/tesseract-exec-compat.js'
26
30
  import { installLocalMutationRouteBoundary } from './lib/web-capability-boundary.js'
27
31
  import { installScreenshotSourceBoundary } from './lib/screenshot-source-boundary.js'
32
+ import { installVisionToolRuntimeBoundary } from './lib/vision-tool-runtime-boundary.js'
28
33
  import { installVisionRouterRemoteSettingsBridge } from './lib/remote-settings-bridge.js'
34
+ import { installSettingsRc8ClientLifecycle } from './lib/settings-client-rc8-lifecycle.js'
29
35
  import {
30
36
  installStructuredFlowHardening,
31
37
  normalizeGuidanceOverrides,
32
38
  } from './lib/structured-flow-hardening.js'
33
39
  import {
34
40
  attachmentContextForContract,
35
- ensureVisionAttachmentAdmissionPolicy,
36
41
  hasBatchAttachmentContract,
37
42
  installHostSettingsCompatibility,
43
+ installVisionAttachmentAdmissionPolicy,
38
44
  protectHostProviderOwnership,
39
45
  } from './lib/dsh-contract-compat.js'
40
46
 
@@ -84,6 +90,7 @@ export {
84
90
  ensureVisionAttachmentAdmissionPolicy,
85
91
  hasBatchAttachmentContract,
86
92
  installHostSettingsCompatibility,
93
+ installVisionAttachmentAdmissionPolicy,
87
94
  protectHostProviderOwnership,
88
95
  // Transitional public aliases retained for callers/tests written during the
89
96
  // rc.7 compatibility pass. Runtime code below no longer branches on names.
@@ -163,18 +170,20 @@ export function apply(ctx, config = {}) {
163
170
  // generation. Keep the branch named after that observable capability rather
164
171
  // than a release number so rc.8+ naturally follows the same public contract.
165
172
  const batchAttachmentHost = hasBatchAttachmentContract(stabilizedCtx)
166
- // DSH profile overlays replace an attachment-local config object wholesale.
167
- // A stale pre-rc.8 Vision Router profile row can therefore erase the newer
168
- // bundle's maxImageDimension and silently restore rc.8's 2000px default even
169
- // after the plugin package itself has updated. Repair only that historical
170
- // 20MiB/100MP fingerprint; explicit deployment policies remain authoritative.
173
+ // DSH may reconstruct attachment-local after a profile/home patch reload.
174
+ // Keep the historical rc.8 overlay migration attached to that service
175
+ // lifecycle instead of healing only the instance present during apply().
171
176
  if (batchAttachmentHost) {
172
- ensureVisionAttachmentAdmissionPolicy(stabilizedCtx, logging.logger)
177
+ installVisionAttachmentAdmissionPolicy(stabilizedCtx, logging.logger)
173
178
  }
174
179
  // The remote settings bridge uses DSH Connection's trusted-host carrier
175
180
  // fence and its own safe-field capability allow-list. Main's local Web
176
181
  // mutation boundary continues to protect the independent /_dsh write routes.
177
182
  installVisionRouterRemoteSettingsBridge(stabilizedCtx, logging.logger)
183
+ // rc.8 swaps ModuleLoader.load() while entering live mode. The older local
184
+ // permission/risk shims still own rc.6/rc.7; this narrow lifecycle shim
185
+ // re-installs both contexts after rc.8's queue -> live transition.
186
+ installSettingsRc8ClientLifecycle(stabilizedCtx)
178
187
  const ownershipCtx = batchAttachmentHost
179
188
  ? protectHostProviderOwnership(stabilizedCtx)
180
189
  : stabilizedCtx
@@ -191,11 +200,22 @@ export function apply(ctx, config = {}) {
191
200
  const attachmentCompatCtx = attachmentContextForContract(settingsCtx, logging.logger, {
192
201
  installAndroidAttachmentCompat,
193
202
  })
203
+ // Put per-tool cwd/cancellation/cache policy AFTER Host settings compatibility
204
+ // so rc.7/rc.8's synthetic settings injection is visible to the boundary.
205
+ // The secure screenshot renderer owns its exact FsTarget and active browser
206
+ // cancellation directly, so it does not depend on this placement.
207
+ const toolRuntimeCtx = installVisionToolRuntimeBoundary(attachmentCompatCtx)
208
+ // DSH 0.1.1 publishes an exact native image-capable DeepSeek model. Do not
209
+ // put it ahead of Vision Router's own configured chain: only when the user
210
+ // has explicitly selected any Host-native image route, preserve raw pixels
211
+ // and skip the hidden instant-local caption pass for that turn. The override
212
+ // is AsyncLocalStorage-scoped and never mutates settings or provider order.
213
+ const nativeImageCompat = contextWithNativeImageCoexistence(toolRuntimeCtx, runtimeConfig)
194
214
  // Final structured-flow guard sits closest to core.apply so it sees the
195
215
  // actual tool registrations and pre-step listener. It makes bootstrap
196
216
  // one-shot, enforces fast/standard/deep/custom quotas, tracks mixed branches,
197
217
  // rejects empty/non-evidence results, and applies one shared visual deadline.
198
- const structuredCtx = installStructuredFlowHardening(attachmentCompatCtx, runtimeConfig)
218
+ const structuredCtx = installStructuredFlowHardening(nativeImageCompat.ctx, nativeImageCompat.config)
199
219
  // Newer DSH releases publish llm/adapters-updated synchronously from inside
200
220
  // registerAdapter(). Coalesce only Vision Router's listener: nested events
201
221
  // mark the topology dirty and the outer pass reruns to a fixed point, so we
@@ -230,6 +250,15 @@ export function apply(ctx, config = {}) {
230
250
  // ordinary chat model picker). The existing classic client bundle stays the
231
251
  // DSH module-system artifact, including HMR/source-map behavior.
232
252
  installLiveModelClientPrelude(reconciledCtx)
253
+ // #266: 1.7.x gets one exact, no-fallback image smoke test per visible row.
254
+ // Keep it out of the controlled React form so the v2 capability-benchmark
255
+ // client can take ownership later without forking the stable settings UI.
256
+ installExactVisionTestClient(reconciledCtx)
257
+ // DSH 0.1.1 adds per-route/model pi-ai wire compatibility. The legacy
258
+ // direct image bridge is non-streaming and predates that surface, so preserve
259
+ // maxTokensField + route headers at its final fetch boundary. Ordinary DSH
260
+ // streams and unrelated Vision Router HTTP providers remain byte-identical.
261
+ installPiAiBridgeWireCompat(reconciledCtx, logging.logger)
233
262
  // Direct compatibility bridging is allowed only after DSH/pi-ai's exact
234
263
  // pre-wire image-capability admission rejection, or a local UNKNOWN_MODEL
235
264
  // backed by exact private-registry evidence. Record the same provenance in
@@ -241,6 +270,14 @@ export function apply(ctx, config = {}) {
241
270
  evidenceSource: (provider, model) => liveDiscovery.evidenceSource?.(provider, model),
242
271
  logger: logging.logger,
243
272
  })
273
+ // The smoke-test route sends only a built-in probe image to the exact selected
274
+ // backend. It never walks the configured fallback chain, so a healthy OVH
275
+ // fallback can no longer make a broken custom model look healthy. Its narrow
276
+ // compatibility bridge uses the same live-discovery evidence gate as runtime.
277
+ installVisionBackendSmokeTest(executionCtx, runtimeConfig, core, {
278
+ logger: logging.logger,
279
+ isBridgeEvidence: (provider, model) => liveDiscovery.hasModel(provider, model),
280
+ })
244
281
  // index.js historically passes image bytes as `options.input` to the async
245
282
  // execFile API. That option is not fed into child stdin, so Tesseract waits
246
283
  // for data until the OCR slice expires. Materialize only this exact
@@ -267,7 +304,7 @@ export function apply(ctx, config = {}) {
267
304
  /* diagnostics must never break apply */
268
305
  }
269
306
  try {
270
- const result = core.apply(executionCtx, runtimeConfig)
307
+ const result = core.apply(executionCtx, nativeImageCompat.config)
271
308
  // On newer Hosts the Settings -> Models surface is backed by the
272
309
  // configurable-provider directory, not by the live adapter registry alone.
273
310
  // Publish the main DeepSeek + 自动识图 route as a derived alias of official
@@ -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) {