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 +120 -27
- package/entry.js +46 -9
- package/lib/adversarial-hardening.js +36 -5
- package/lib/artifact-boundary.js +45 -6
- package/lib/artifact-retention.js +124 -0
- package/lib/client-presentation-boundary.js +72 -0
- package/lib/doctor-cli.js +162 -69
- package/lib/doctor-runtime.js +385 -0
- package/lib/doctor.js +394 -33
- package/lib/legacy-session-repair.js +204 -49
- package/lib/live-model-client-prelude.js +7 -1
- package/lib/native-image-coexistence.js +185 -0
- package/lib/pi-ai-bridge-wire-compat.js +258 -0
- package/lib/repetition-guard.js +57 -36
- package/lib/runtime-reliability.js +261 -0
- package/lib/settings-client-rc8-lifecycle.js +383 -0
- package/lib/turn-budget-context.js +36 -2
- package/lib/vision-backend-smoke-test-client.js +228 -0
- package/lib/vision-backend-smoke-test.js +449 -0
- package/lib/vision-tool-runtime-boundary.js +449 -0
- package/lib/web-capability-boundary.js +7 -0
- package/package.json +9 -19
package/docs/doctor.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# Vision Router doctor / repair
|
|
2
2
|
|
|
3
|
-
Vision Router ships a
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
131
|
+
## Repair a stale release-age exemption (the "update does nothing" gate)
|
|
59
132
|
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
147
|
+
### Legacy missing message id
|
|
73
148
|
|
|
74
|
-
A very early
|
|
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
|
-
|
|
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
|
-
-
|
|
89
|
-
-
|
|
90
|
-
-
|
|
91
|
-
-
|
|
92
|
-
-
|
|
93
|
-
-
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
167
|
-
//
|
|
168
|
-
//
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/lib/artifact-boundary.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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) {
|