dsh-vision-router 2.2.8 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -7
- package/README.zh.md +16 -7
- package/docs/architecture/2.3.0-compat-retirement.md +147 -0
- package/docs/architecture/2.3.0-execution-plan.md +410 -0
- package/docs/architecture/2.3.0-final-audit.md +279 -0
- package/docs/architecture/2.3.0-reopened-plan.md +720 -0
- package/docs/architecture/3.0-architecture-plan.md +252 -0
- package/docs/architecture/3.0-r0-contract-ledger.md +137 -0
- package/docs/architecture/3.0-r1-source-artifact-boundary.md +98 -0
- package/docs/architecture/3.0-r10-final-correctness-closure.md +207 -0
- package/docs/architecture/3.0-r11-adversarial-review.md +441 -0
- package/docs/architecture/3.0-r5-browser-go-no-go.md +165 -0
- package/docs/architecture/3.0-r5-browser-typescript-gate.md +105 -0
- package/docs/architecture/3.0-r6-compat-retirement.md +203 -0
- package/docs/architecture/3.0-r7-public-api.md +151 -0
- package/docs/architecture/3.0-r8-ci-release.md +248 -0
- package/docs/architecture/3.0-r9-final-audit.md +370 -0
- package/docs/architecture/compat-inventory.md +26 -34
- package/docs/architecture/dsh-compatibility-matrix.md +65 -54
- package/docs/architecture/dsh-support-window.md +30 -22
- package/docs/architecture/final-closure-audit.md +2 -0
- package/docs/releases/v2.3.0.md +53 -0
- package/docs/releases/v3.0.0.md +85 -0
- package/entry.js +9 -57
- package/index.js +268 -761
- package/lib/agent-request-route-authority.js +1 -20
- package/lib/catalog-corrections.js +1 -2
- package/lib/client-maintenance-actions.js +145 -0
- package/lib/client-presentation-boundary-main.js +75 -17
- package/lib/client-presentation-boundary.js +53 -28
- package/lib/client.js +246 -144
- package/lib/core-apply-lifecycle.js +41 -0
- package/lib/core-cache.js +87 -0
- package/lib/core-primitives.js +91 -278
- package/lib/core-vision-surface-domain.js +63 -0
- package/lib/core-vision-surface.js +11 -77
- package/lib/delegated-call-config.js +15 -24
- package/lib/desktop-screenshot-tool.js +262 -0
- package/lib/doctor-cli.js +0 -0
- package/lib/doctor-runtime.js +4 -1
- package/lib/dsh-contract-compat.js +37 -256
- package/lib/dsh-host-capabilities.js +3 -9
- package/lib/dsh-settings-017-compat.js +17 -10
- package/lib/dsh-support-window.js +3 -3
- package/lib/file-logger.js +8 -3
- package/lib/grounding-coordinate-runtime.js +1 -2
- package/lib/http-body-limit.js +97 -79
- package/lib/http-compat.js +1 -2
- package/lib/image-offload-compat.js +30 -21
- package/lib/image-resource-governor.js +210 -228
- package/lib/legacy-global-proxy-boundary.js +46 -3
- package/lib/live-model-client-prelude.js +12 -4
- package/lib/live-model-discovery.js +134 -43
- package/lib/local-remote-settings-permission.js +33 -7
- package/lib/local-vision-stabilizer.js +31 -17
- package/lib/maintenance-routes.js +134 -0
- package/lib/native-image-coexistence.js +15 -3
- package/lib/ollama-cold-start.js +87 -23
- package/lib/pi-ai-bridge-wire-compat.js +3 -2
- package/lib/proxy-dispatcher-pool.js +151 -92
- package/lib/proxy-routing.js +75 -51
- package/lib/proxy-url-compat.js +3 -10
- package/lib/public-config.js +36 -0
- package/lib/public-entry.d.ts +148 -0
- package/lib/public-entry.js +19 -11
- package/lib/remote-settings-bridge.js +11 -0
- package/lib/remote-settings-risk-client-boundary.js +155 -0
- package/lib/remote-settings-risk-confirmation.js +17 -105
- package/lib/replay-delegation.js +12 -7
- package/lib/replay-envelope-v2-compat.js +12 -7
- package/lib/runtime-composition.js +127 -34
- package/lib/runtime-i18n-boundary.js +10 -11
- package/lib/screenshot-source-boundary.js +2 -2
- package/lib/session-affinity-runtime.js +113 -97
- package/lib/session-affinity.js +89 -76
- package/lib/session-event-history-compat.js +30 -0
- package/lib/session-message-source-compat.js +7 -3
- package/lib/session-query-readers.js +263 -0
- package/lib/session-surface-compat.js +30 -31
- package/lib/session-surface-policy-domain.js +85 -0
- package/lib/session-surface-policy.js +2 -111
- package/lib/session-turn-resolver.js +2 -6
- package/lib/session-vision-attachment-index.js +143 -0
- package/lib/session-vision-delegation-boundary.js +34 -0
- package/lib/session-vision-delegation.js +22 -0
- package/lib/session-vision-index.js +179 -116
- package/lib/session-vision-mode-authority.js +229 -192
- package/lib/session-vision-mode-boundary.js +65 -22
- package/lib/session-vision-policy-domain.js +304 -0
- package/lib/session-vision-policy.js +104 -0
- package/lib/session-vision-state.js +368 -378
- package/lib/settings-client-017-compat.js +9 -7
- package/lib/settings-client-loader-lifecycle.js +129 -0
- package/lib/sharp-runtime.js +0 -7
- package/lib/structured-flow-hardening.js +25 -6
- package/lib/tesseract-exec-compat.js +9 -1
- package/lib/turn-budget-context.js +47 -41
- package/lib/update-check.js +11 -2
- package/lib/vision-attachment-handle-runtime.js +176 -156
- package/lib/vision-backend-runtime-policy.js +19 -2
- package/lib/vision-background-benchmark.js +41 -4
- package/lib/vision-background-failure-policy.js +41 -41
- package/lib/vision-background-stop-store-core.js +217 -0
- package/lib/vision-background-stop-store.js +8 -179
- package/lib/vision-capability-benchmark-service.js +1 -0
- package/lib/vision-capability-benchmark.js +11 -61
- package/lib/vision-capability-evidence.js +35 -0
- package/lib/vision-capability-fingerprint.js +72 -0
- package/lib/vision-capability-identity.js +116 -61
- package/lib/vision-capability-router.js +13 -33
- package/lib/vision-chunk-assembler.js +95 -0
- package/lib/vision-diagnostics-routes.js +246 -0
- package/lib/vision-evidence-guidance.js +3 -1
- package/lib/vision-execution-order-apply.js +45 -55
- package/lib/vision-execution-order.js +19 -19
- package/lib/vision-execution-policy.js +12 -7
- package/lib/vision-model-registry.js +30 -17
- package/lib/vision-provider-transport.js +137 -176
- package/lib/vision-routing-authority.js +47 -48
- package/lib/vision-routing-product.js +26 -29
- package/lib/vision-routing-settings-prelude.js +8 -3
- package/lib/vision-runtime-performance-store.js +166 -0
- package/lib/vision-runtime-performance.js +13 -142
- package/lib/vision-toggle-root-hardening.js +22 -5
- package/lib/vision-tool-runtime-boundary.js +104 -31
- package/lib/web/dsh-settings-017-root-transport.js +27 -12
- package/lib/web/remote-settings-client.js +6 -9
- package/lib/web-capability-boundary.js +17 -7
- package/lib/wrapper-directory.js +14 -23
- package/package.json +56 -20
- package/lib/android-attachment-compat.js +0 -244
- package/lib/settings-client-rc8-lifecycle.js +0 -397
|
@@ -5,6 +5,11 @@ import process from 'node:process'
|
|
|
5
5
|
import { resolveDshHome } from './doctor.js'
|
|
6
6
|
import { MODEL_RESPONSE_MAX_BYTES, readResponseJsonBounded } from './http-body-limit.js'
|
|
7
7
|
import { stripTrailingSlashes } from './string-normalization.js'
|
|
8
|
+
import {
|
|
9
|
+
currentLegacyGlobalProxyFetch,
|
|
10
|
+
legacyGlobalProxyPairFor,
|
|
11
|
+
runWithLegacyGlobalProxyScope,
|
|
12
|
+
} from './legacy-global-proxy-boundary.js'
|
|
8
13
|
import { isLocalUiRequest } from './web-capability-boundary.js'
|
|
9
14
|
|
|
10
15
|
export const LIVE_MODELS_PATH = '/_dsh/vision-router/live-models'
|
|
@@ -17,6 +22,10 @@ export const DEFAULT_MAX_MODELS_PER_PROVIDER = 2_000
|
|
|
17
22
|
|
|
18
23
|
const SUPPORTED_DISCOVERY_PROTOCOLS = new Set(['openai-completions', 'openai-responses'])
|
|
19
24
|
|
|
25
|
+
const moduleFetch = typeof globalThis.fetch === 'function'
|
|
26
|
+
? globalThis.fetch.bind(globalThis)
|
|
27
|
+
: undefined
|
|
28
|
+
|
|
20
29
|
function nonEmpty(value) {
|
|
21
30
|
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
22
31
|
}
|
|
@@ -162,19 +171,57 @@ async function saveCache(file, providers, fsOps) {
|
|
|
162
171
|
await fsOps.rename(temporary, file)
|
|
163
172
|
}
|
|
164
173
|
|
|
174
|
+
function plainProviderMap(value) {
|
|
175
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Host-side provider profiles have two possible homes on a real DSH host:
|
|
180
|
+
* - the settings service (some Host lines mirror a plugin's config there), and
|
|
181
|
+
* - the profile configuration owned by the Host config editor, which is where
|
|
182
|
+
* DSH 0.2.0-rc.2 keeps `llm-pi-ai.providers` (the Models page writes them
|
|
183
|
+
* back to the profile patch, never to a settings section).
|
|
184
|
+
* Reading only the settings section made live discovery a dead path on that
|
|
185
|
+
* Host: `configuredProviderTransports()` returned `[]` and no `/models` probe
|
|
186
|
+
* could ever run.
|
|
187
|
+
*/
|
|
188
|
+
function configEditorProviders(ctx) {
|
|
189
|
+
try {
|
|
190
|
+
const editor = ctx?.get?.('configEditor')
|
|
191
|
+
if (editor === undefined || typeof editor.configuration !== 'function') return undefined
|
|
192
|
+
const rows = editor.configuration()
|
|
193
|
+
if (!Array.isArray(rows)) return undefined
|
|
194
|
+
const row = rows.find((candidate) => {
|
|
195
|
+
const options = candidate?.entry?.options
|
|
196
|
+
return options?.id === 'llm-pi-ai' || options?.name === '@deepseek-ai/dsh-llm-pi-ai'
|
|
197
|
+
})
|
|
198
|
+
if (row === undefined) return undefined
|
|
199
|
+
// The row exists, so an absent/empty providers map means "no providers are
|
|
200
|
+
// configured" — authoritative, which lets the cache drop removed entries.
|
|
201
|
+
// Only a missing row or an unavailable editor stays non-authoritative, so a
|
|
202
|
+
// transient read can never delete evidence.
|
|
203
|
+
const inherited = plainProviderMap(row.inherited?.providers) ?? {}
|
|
204
|
+
const override = plainProviderMap(row.override?.providers) ?? {}
|
|
205
|
+
return { ...inherited, ...override }
|
|
206
|
+
} catch {
|
|
207
|
+
return undefined
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
165
211
|
function piProfilesState(ctx) {
|
|
166
212
|
try {
|
|
167
213
|
const settings = ctx?.get?.('settings')
|
|
168
|
-
if (
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return { authoritative: false, providers: {} }
|
|
214
|
+
if (settings && typeof settings.get === 'function') {
|
|
215
|
+
const value = settings.get('llm-pi-ai')
|
|
216
|
+
const providers = plainProviderMap(value?.providers)
|
|
217
|
+
if (providers !== undefined) return { authoritative: true, providers }
|
|
173
218
|
}
|
|
174
|
-
return { authoritative: true, providers }
|
|
175
219
|
} catch {
|
|
176
|
-
|
|
220
|
+
// Fall through to the profile-configuration source below.
|
|
177
221
|
}
|
|
222
|
+
const fromEditor = configEditorProviders(ctx)
|
|
223
|
+
if (fromEditor !== undefined) return { authoritative: true, providers: fromEditor }
|
|
224
|
+
return { authoritative: false, providers: {} }
|
|
178
225
|
}
|
|
179
226
|
|
|
180
227
|
function rawPiProfiles(ctx) {
|
|
@@ -190,6 +237,21 @@ function providerConfigurationToken(provider, raw) {
|
|
|
190
237
|
])
|
|
191
238
|
}
|
|
192
239
|
|
|
240
|
+
// The boundary resolves its pair list from the live config; read the same shape so
|
|
241
|
+
// the scope we open is one the boundary will accept.
|
|
242
|
+
function liveProxyConfig(ctx, fallbackConfig = {}) {
|
|
243
|
+
try {
|
|
244
|
+
const settings = ctx?.get?.('settings')
|
|
245
|
+
const live = settings?.get?.('vision-router')
|
|
246
|
+
if (live !== null && typeof live === 'object' && !Array.isArray(live)) {
|
|
247
|
+
return { ...fallbackConfig, ...live }
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
// Fall back to the composition config.
|
|
251
|
+
}
|
|
252
|
+
return fallbackConfig
|
|
253
|
+
}
|
|
254
|
+
|
|
193
255
|
function visionProviderPriority(ctx, fallbackConfig = {}) {
|
|
194
256
|
const preferred = new Set()
|
|
195
257
|
const collect = (value) => {
|
|
@@ -350,7 +412,13 @@ function boundedError(error) {
|
|
|
350
412
|
|
|
351
413
|
export function createLiveModelDiscoveryManager(ctx, options = {}) {
|
|
352
414
|
const now = typeof options.now === 'function' ? options.now : Date.now
|
|
353
|
-
|
|
415
|
+
// The proxy boundary installs its wrapper after apply() returns, so a capture
|
|
416
|
+
// taken here would keep the raw host fetch and silently ignore a configured
|
|
417
|
+
// `proxy` in the probes below. Ask the boundary for the fetch it currently owns
|
|
418
|
+
// instead of reading the process global at call time (L1-sealed-process-global);
|
|
419
|
+
// the module-load capture stays as the last-resort fallback.
|
|
420
|
+
const injectedFetch = typeof options.fetchImpl === 'function' ? options.fetchImpl : undefined
|
|
421
|
+
const resolveFetch = () => injectedFetch ?? currentLegacyGlobalProxyFetch() ?? moduleFetch
|
|
354
422
|
const freshMs = Math.max(1_000, Number(options.freshMs) || DEFAULT_LIVE_MODEL_FRESH_MS)
|
|
355
423
|
const staleMs = Math.max(freshMs, Number(options.staleMs) || DEFAULT_LIVE_MODEL_STALE_MS)
|
|
356
424
|
const timeoutMs = Math.max(500, Number(options.timeoutMs) || DEFAULT_LIVE_MODEL_TIMEOUT_MS)
|
|
@@ -510,11 +578,15 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
|
|
|
510
578
|
try {
|
|
511
579
|
const headers = { accept: 'application/json' }
|
|
512
580
|
if (plan.apiKey !== undefined) headers.authorization = `Bearer ${plan.apiKey}`
|
|
513
|
-
const
|
|
581
|
+
const probe = () => resolveFetch()(listingURL(plan.baseURL), {
|
|
514
582
|
method: 'GET',
|
|
515
583
|
headers,
|
|
516
584
|
signal: controller.signal,
|
|
517
585
|
})
|
|
586
|
+
const proxyPair = legacyGlobalProxyPairFor(liveProxyConfig(ctx, fallbackConfig), provider)
|
|
587
|
+
const response = proxyPair === undefined
|
|
588
|
+
? await probe()
|
|
589
|
+
: await runWithLegacyGlobalProxyScope(proxyPair.provider, proxyPair.model, probe)
|
|
518
590
|
if (!response?.ok) {
|
|
519
591
|
const error = new Error(`provider model listing answered HTTP ${response?.status ?? 'unknown'}`)
|
|
520
592
|
error.status = response?.status
|
|
@@ -673,42 +745,61 @@ export function installLiveModelDiscovery(ctx, options = {}) {
|
|
|
673
745
|
// Event forwarding is an optimization; the request path still refreshes.
|
|
674
746
|
}
|
|
675
747
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
// credentials. Preserve local ?refresh=1 behavior while making every
|
|
691
|
-
// remote GET a passive read of already-owned discovery state.
|
|
692
|
-
const schedule = isLocalUiRequest(req) && url.searchParams.get('refresh') !== '0'
|
|
693
|
-
sendJson(res, 200, await manager.snapshot({ schedule }))
|
|
694
|
-
} catch (error) {
|
|
695
|
-
sendJson(res, 500, { ok: false, error: boundedError(error) })
|
|
696
|
-
}
|
|
697
|
-
},
|
|
698
|
-
}), 'vision-router: live provider model discovery')
|
|
699
|
-
})
|
|
748
|
+
let disposePromise
|
|
749
|
+
const disposeLifecycle = () => {
|
|
750
|
+
if (disposePromise) return disposePromise
|
|
751
|
+
lifecycleDisposed = true
|
|
752
|
+
if (startupTimer !== undefined) {
|
|
753
|
+
clearTimeout(startupTimer)
|
|
754
|
+
startupTimer = undefined
|
|
755
|
+
}
|
|
756
|
+
for (const dispose of disposers.splice(0)) {
|
|
757
|
+
try { dispose() } catch { /* best effort */ }
|
|
758
|
+
}
|
|
759
|
+
disposePromise = Promise.resolve(manager.dispose())
|
|
760
|
+
return disposePromise
|
|
761
|
+
}
|
|
700
762
|
|
|
763
|
+
// Own every timer/listener/manager resource before any later Host seam can
|
|
764
|
+
// throw. This keeps injection failure from stranding a live generation.
|
|
701
765
|
try {
|
|
702
|
-
ctx?.effect?.(
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
766
|
+
ctx?.effect?.(
|
|
767
|
+
() => disposeLifecycle,
|
|
768
|
+
'vision-router: live model discovery lifecycle',
|
|
769
|
+
)
|
|
770
|
+
} catch (error) {
|
|
771
|
+
void disposeLifecycle().catch(() => {})
|
|
772
|
+
throw error
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
try {
|
|
776
|
+
ctx?.inject?.(['webServer'], (webCtx) => {
|
|
777
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
778
|
+
kind: 'exact',
|
|
779
|
+
path: LIVE_MODELS_PATH,
|
|
780
|
+
handler: async (req, res) => {
|
|
781
|
+
if (req.method !== 'GET') {
|
|
782
|
+
res.setHeader('Allow', 'GET')
|
|
783
|
+
sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
try {
|
|
787
|
+
const url = new URL(req.url ?? LIVE_MODELS_PATH, 'http://localhost')
|
|
788
|
+
// The snapshot itself is safe for an authenticated remote Settings UI,
|
|
789
|
+
// but refresh drives Host-side provider I/O and may resolve stored
|
|
790
|
+
// credentials. Preserve local ?refresh=1 behavior while making every
|
|
791
|
+
// remote GET a passive read of already-owned discovery state.
|
|
792
|
+
const schedule = isLocalUiRequest(req) && url.searchParams.get('refresh') !== '0'
|
|
793
|
+
sendJson(res, 200, await manager.snapshot({ schedule }))
|
|
794
|
+
} catch (error) {
|
|
795
|
+
sendJson(res, 500, { ok: false, error: boundedError(error) })
|
|
796
|
+
}
|
|
797
|
+
},
|
|
798
|
+
}), 'vision-router: live provider model discovery')
|
|
799
|
+
})
|
|
800
|
+
} catch (error) {
|
|
801
|
+
void disposeLifecycle().catch(() => {})
|
|
802
|
+
throw error
|
|
712
803
|
}
|
|
713
804
|
return manager
|
|
714
805
|
}
|
|
@@ -173,10 +173,16 @@ export function createVisionRouterLocalPermissionHttpHandler(settings, logger) {
|
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Current-product browser boundary for the local allowRemoteSettings control.
|
|
178
|
+
* Host loader lifecycle compatibility stays outside this owner.
|
|
179
|
+
*/
|
|
180
|
+
export function createLocalRemoteSettingsPermissionClientBoundary(options = {}) {
|
|
181
|
+
var fetchImpl = options.fetchImpl
|
|
182
|
+
var ENDPOINT = options.endpoint
|
|
183
|
+
var FIELD = options.field
|
|
184
|
+
if (typeof ENDPOINT !== 'string' || ENDPOINT === '') throw new TypeError('local permission boundary requires endpoint')
|
|
185
|
+
if (typeof FIELD !== 'string' || FIELD === '') throw new TypeError('local permission boundary requires field')
|
|
180
186
|
var scopeCache = typeof WeakMap === 'function' ? new WeakMap() : undefined;
|
|
181
187
|
var binderCache = typeof WeakMap === 'function' ? new WeakMap() : undefined;
|
|
182
188
|
var contextCache = typeof WeakMap === 'function' ? new WeakMap() : undefined;
|
|
@@ -189,11 +195,11 @@ export const LOCAL_PERMISSION_CLIENT_PRELUDE = String.raw`(function(){
|
|
|
189
195
|
}
|
|
190
196
|
|
|
191
197
|
async function writePermission(operation, value, revision) {
|
|
192
|
-
if (typeof
|
|
198
|
+
if (typeof fetchImpl !== 'function') throw new Error('Vision Router local settings transport is unavailable');
|
|
193
199
|
var payload = { operation: operation };
|
|
194
200
|
if (operation === 'set') payload.value = value;
|
|
195
201
|
if (Number.isInteger(revision) && revision >= 0) payload.expectedRevision = revision;
|
|
196
|
-
var response = await
|
|
202
|
+
var response = await fetchImpl(ENDPOINT, {
|
|
197
203
|
method: 'POST',
|
|
198
204
|
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
199
205
|
body: JSON.stringify(payload)
|
|
@@ -366,6 +372,26 @@ export const LOCAL_PERMISSION_CLIENT_PRELUDE = String.raw`(function(){
|
|
|
366
372
|
return wrapped;
|
|
367
373
|
}
|
|
368
374
|
|
|
375
|
+
|
|
376
|
+
return { wrapContext: wrapContext }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export const LOCAL_PERMISSION_CLIENT_BOUNDARY_SOURCE =
|
|
380
|
+
'var createLocalRemoteSettingsPermissionClientBoundary = (' +
|
|
381
|
+
createLocalRemoteSettingsPermissionClientBoundary.toString() +
|
|
382
|
+
');'
|
|
383
|
+
|
|
384
|
+
export const LOCAL_PERMISSION_CLIENT_PRELUDE = String.raw`(function(){
|
|
385
|
+
'use strict';
|
|
386
|
+
${LOCAL_PERMISSION_CLIENT_BOUNDARY_SOURCE}
|
|
387
|
+
var localPermissionBoundary = createLocalRemoteSettingsPermissionClientBoundary({
|
|
388
|
+
endpoint: '${LOCAL_REMOTE_SETTINGS_PERMISSION_PATH}',
|
|
389
|
+
field: '${LOCAL_REMOTE_SETTINGS_PERMISSION_FIELD}',
|
|
390
|
+
fetchImpl: typeof fetch === 'function'
|
|
391
|
+
? function(url, options) { return fetch(url, options); }
|
|
392
|
+
: undefined
|
|
393
|
+
});
|
|
394
|
+
|
|
369
395
|
function patchLoader(loader) {
|
|
370
396
|
if (!loader || typeof loader.load !== 'function' || loader.load.__visionRouterLocalPermission) return;
|
|
371
397
|
var original = loader.load;
|
|
@@ -379,7 +405,7 @@ export const LOCAL_PERMISSION_CLIENT_PRELUDE = String.raw`(function(){
|
|
|
379
405
|
var apply = exports.apply;
|
|
380
406
|
var wrappedApply = function(ctx) {
|
|
381
407
|
var rest = Array.prototype.slice.call(arguments, 1);
|
|
382
|
-
return apply.apply(exports, [wrapContext(ctx)].concat(rest));
|
|
408
|
+
return apply.apply(exports, [localPermissionBoundary.wrapContext(ctx)].concat(rest));
|
|
383
409
|
};
|
|
384
410
|
Object.defineProperty(wrappedApply, '__visionRouterLocalPermission', { value: true });
|
|
385
411
|
exports.apply = wrappedApply;
|
|
@@ -180,7 +180,12 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
const localMessages = async (options) => {
|
|
183
|
-
|
|
183
|
+
let attachments
|
|
184
|
+
try {
|
|
185
|
+
attachments = typeof ctx?.get === 'function' ? ctx.get('attachments') : undefined
|
|
186
|
+
} catch {
|
|
187
|
+
attachments = undefined
|
|
188
|
+
}
|
|
184
189
|
const current = actualConfig()
|
|
185
190
|
const messages = []
|
|
186
191
|
for (const message of options.messages ?? []) {
|
|
@@ -380,11 +385,8 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
|
|
|
380
385
|
const scope = target.register(namespace, schema, fixedOptions)
|
|
381
386
|
if (namespace === 'vision-router') {
|
|
382
387
|
rawScope = scope
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
// The Settings service is dynamic. Never keep its scope after the
|
|
386
|
-
// owning injection fiber unloads; during the gap fall back to the
|
|
387
|
-
// composition config, then bind the new scope on service restore.
|
|
388
|
+
// The Settings service is dynamic. Establish ownership before the
|
|
389
|
+
// scope can change screenshot state or install wrapped watchers.
|
|
388
390
|
try {
|
|
389
391
|
ownerCtx?.effect?.(
|
|
390
392
|
() => () => {
|
|
@@ -396,8 +398,12 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
|
|
|
396
398
|
'vision-router: local settings scope lifecycle',
|
|
397
399
|
)
|
|
398
400
|
} catch {
|
|
399
|
-
|
|
401
|
+
if (rawScope === scope) rawScope = undefined
|
|
402
|
+
resolvedConfig = config
|
|
403
|
+
return scope
|
|
400
404
|
}
|
|
405
|
+
captureConfig(undefined, scope)
|
|
406
|
+
syncScreenshot()
|
|
401
407
|
return wrapScope(scope)
|
|
402
408
|
}
|
|
403
409
|
return scope
|
|
@@ -459,7 +465,10 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
|
|
|
459
465
|
'vision-router: screenshot permission route lifecycle',
|
|
460
466
|
)
|
|
461
467
|
} catch {
|
|
462
|
-
|
|
468
|
+
// webServer.register() is caller-owned in supported DSH Hosts. If this
|
|
469
|
+
// injected child cannot own the disposer, withdraw the route now rather
|
|
470
|
+
// than keeping it alive until the parent DVR generation eventually exits.
|
|
471
|
+
releaseScreenshotPermissionRoute(webServer, handle)
|
|
463
472
|
}
|
|
464
473
|
}
|
|
465
474
|
|
|
@@ -511,20 +520,25 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
|
|
|
511
520
|
}
|
|
512
521
|
: undefined
|
|
513
522
|
|
|
523
|
+
const disposeLifecycle = () => {
|
|
524
|
+
rawScope = undefined
|
|
525
|
+
unmountScreenshot()
|
|
526
|
+
for (const [webServer, handle] of screenshotPermissionRoutes) {
|
|
527
|
+
releaseScreenshotPermissionRoute(webServer, handle)
|
|
528
|
+
}
|
|
529
|
+
screenshotPermissionRoutes.clear()
|
|
530
|
+
}
|
|
531
|
+
|
|
514
532
|
try {
|
|
515
533
|
ctx.effect?.(
|
|
516
|
-
() =>
|
|
517
|
-
rawScope = undefined
|
|
518
|
-
unmountScreenshot()
|
|
519
|
-
for (const [webServer, handle] of screenshotPermissionRoutes) {
|
|
520
|
-
releaseScreenshotPermissionRoute(webServer, handle)
|
|
521
|
-
}
|
|
522
|
-
screenshotPermissionRoutes.clear()
|
|
523
|
-
},
|
|
534
|
+
() => disposeLifecycle,
|
|
524
535
|
'vision-router: local vision stabilizer',
|
|
525
536
|
)
|
|
526
537
|
} catch {
|
|
527
|
-
|
|
538
|
+
// Routes/settings state may already have been admitted above. Roll them
|
|
539
|
+
// back immediately and do not expose an unowned wrapper to this generation.
|
|
540
|
+
disposeLifecycle()
|
|
541
|
+
return { ctx, bootConfig }
|
|
528
542
|
}
|
|
529
543
|
|
|
530
544
|
const stabilizedCtx = new Proxy(ctx, {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { createCachedUpdateChecker } from './update-check.js'
|
|
3
|
+
import { detectDshSelfUpdatePlan, runDshPluginUpdate } from './self-update.js'
|
|
4
|
+
|
|
5
|
+
function json(res, status, body, headers = {}) {
|
|
6
|
+
res.writeHead(status, {
|
|
7
|
+
'content-type': 'application/json',
|
|
8
|
+
...headers,
|
|
9
|
+
})
|
|
10
|
+
res.end(JSON.stringify(body))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Product-maintenance Web routes kept outside the visual Core.
|
|
15
|
+
*
|
|
16
|
+
* The supplied context must already carry DVR's Web capability/auth boundary;
|
|
17
|
+
* this installer deliberately owns only update-product state and the narrow
|
|
18
|
+
* per-route CSRF/replay checks that existed in Core before extraction.
|
|
19
|
+
*/
|
|
20
|
+
export function installVisionMaintenanceRoutes(ctx, options = {}) {
|
|
21
|
+
if (!ctx || typeof ctx.inject !== 'function') return
|
|
22
|
+
const logger = options.logger ?? ctx.logger
|
|
23
|
+
const updateChecker = options.updateChecker ?? createCachedUpdateChecker()
|
|
24
|
+
const selfUpdatePlan = options.selfUpdatePlan ?? detectDshSelfUpdatePlan()
|
|
25
|
+
const updateRunner = options.runUpdate ?? runDshPluginUpdate
|
|
26
|
+
const tokenFactory = options.tokenFactory ?? (() => randomBytes(24).toString('base64url'))
|
|
27
|
+
let selfUpdateToken = tokenFactory()
|
|
28
|
+
let selfUpdateInFlight
|
|
29
|
+
|
|
30
|
+
const updateResultForClient = (result) => ({
|
|
31
|
+
...result,
|
|
32
|
+
autoUpdate: {
|
|
33
|
+
supported: selfUpdatePlan.available === true,
|
|
34
|
+
method: selfUpdatePlan.available === true ? selfUpdatePlan.method : undefined,
|
|
35
|
+
profile: selfUpdatePlan.profile,
|
|
36
|
+
reason: selfUpdatePlan.available === true ? undefined : selfUpdatePlan.reason,
|
|
37
|
+
token:
|
|
38
|
+
selfUpdatePlan.available === true &&
|
|
39
|
+
result &&
|
|
40
|
+
result.ok === true &&
|
|
41
|
+
result.updateAvailable === true
|
|
42
|
+
? selfUpdateToken
|
|
43
|
+
: undefined,
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
void updateChecker.check(false).then((result) => {
|
|
48
|
+
if (result && result.ok === true && result.updateAvailable === true) {
|
|
49
|
+
logger?.info?.(
|
|
50
|
+
'vision-router: update available %s -> %s',
|
|
51
|
+
result.currentVersion,
|
|
52
|
+
result.latestVersion,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
58
|
+
webCtx.effect(
|
|
59
|
+
() => webCtx.webServer.register({
|
|
60
|
+
kind: 'exact',
|
|
61
|
+
path: '/_dsh/vision-router/update-check',
|
|
62
|
+
handler: async (req, res) => {
|
|
63
|
+
if (req.method !== 'GET') {
|
|
64
|
+
res.setHeader('Allow', 'GET')
|
|
65
|
+
res.writeHead(405)
|
|
66
|
+
res.end()
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
const force = /(?:[?&])force=1(?:&|$)/.test(String(req.url ?? ''))
|
|
70
|
+
const result = await updateChecker.check(force)
|
|
71
|
+
json(res, 200, updateResultForClient(result), { 'cache-control': 'no-store' })
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
'vision-router: update-check route',
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
webCtx.effect(
|
|
78
|
+
() => webCtx.webServer.register({
|
|
79
|
+
kind: 'exact',
|
|
80
|
+
path: '/_dsh/vision-router/self-update',
|
|
81
|
+
handler: async (req, res) => {
|
|
82
|
+
if (req.method !== 'POST') {
|
|
83
|
+
res.setHeader('Allow', 'POST')
|
|
84
|
+
res.writeHead(405)
|
|
85
|
+
res.end()
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
const fetchSite = String(req.headers?.['sec-fetch-site'] ?? '')
|
|
89
|
+
if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') {
|
|
90
|
+
json(res, 403, { ok: false, error: 'cross-origin update request rejected' })
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
const token = String(req.headers?.['x-dsh-vision-router-update-token'] ?? '')
|
|
94
|
+
if (!token || token !== selfUpdateToken) {
|
|
95
|
+
json(res, 403, { ok: false, error: 'invalid update token' })
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
if (selfUpdatePlan.available !== true) {
|
|
99
|
+
json(res, 409, { ok: false, error: 'automatic update is not safe for this DSH launch' })
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const fresh = await updateChecker.check(true)
|
|
104
|
+
if (!fresh || fresh.ok !== true) {
|
|
105
|
+
json(res, 502, { ok: false, error: fresh?.error || 'could not refresh update metadata' })
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (fresh.updateAvailable !== true) {
|
|
109
|
+
json(res, 409, { ok: false, error: 'no newer version is currently available' })
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (!selfUpdateInFlight) {
|
|
113
|
+
const pending = updateRunner(selfUpdatePlan, { targetVersion: fresh.latestVersion })
|
|
114
|
+
selfUpdateInFlight = pending
|
|
115
|
+
void pending.then(
|
|
116
|
+
() => { if (selfUpdateInFlight === pending) selfUpdateInFlight = undefined },
|
|
117
|
+
() => { if (selfUpdateInFlight === pending) selfUpdateInFlight = undefined },
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
const result = await selfUpdateInFlight
|
|
121
|
+
selfUpdateToken = tokenFactory()
|
|
122
|
+
json(res, 200, result, { 'cache-control': 'no-store' })
|
|
123
|
+
} catch (error) {
|
|
124
|
+
json(res, 500, {
|
|
125
|
+
ok: false,
|
|
126
|
+
error: error && error.message ? error.message : String(error),
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
'vision-router: self-update route',
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
}
|
|
@@ -385,7 +385,7 @@ export function currentSessionVisionPolicy() {
|
|
|
385
385
|
return imageTurn.getStore()?.policy
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
-
function settingsServiceView(settings, state) {
|
|
388
|
+
function settingsServiceView(settings, state, ownerCtx) {
|
|
389
389
|
if (!isObject(settings)) return settings
|
|
390
390
|
return new Proxy(settings, {
|
|
391
391
|
get(target, property) {
|
|
@@ -394,7 +394,19 @@ function settingsServiceView(settings, state) {
|
|
|
394
394
|
if (typeof register !== 'function') return register
|
|
395
395
|
return (namespace, ...args) => {
|
|
396
396
|
const scope = register.call(target, namespace, ...args)
|
|
397
|
-
if (namespace
|
|
397
|
+
if (namespace !== 'vision-router') return scope
|
|
398
|
+
try {
|
|
399
|
+
if (typeof ownerCtx?.effect !== 'function') return scope
|
|
400
|
+
ownerCtx.effect(
|
|
401
|
+
() => () => {
|
|
402
|
+
if (state.settingsScope === scope) state.settingsScope = undefined
|
|
403
|
+
},
|
|
404
|
+
'vision-router: native image settings lifecycle',
|
|
405
|
+
)
|
|
406
|
+
} catch {
|
|
407
|
+
return scope
|
|
408
|
+
}
|
|
409
|
+
state.settingsScope = scope
|
|
398
410
|
return scope
|
|
399
411
|
}
|
|
400
412
|
}
|
|
@@ -406,7 +418,7 @@ function settingsServiceView(settings, state) {
|
|
|
406
418
|
|
|
407
419
|
function injectedContextView(child, state) {
|
|
408
420
|
if (!isObject(child)) return child
|
|
409
|
-
const settings = settingsServiceView(child.settings, state)
|
|
421
|
+
const settings = settingsServiceView(child.settings, state, child)
|
|
410
422
|
return new Proxy(child, {
|
|
411
423
|
get(target, property) {
|
|
412
424
|
if (property === 'settings') return settings
|