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.
Files changed (132) hide show
  1. package/README.md +16 -7
  2. package/README.zh.md +16 -7
  3. package/docs/architecture/2.3.0-compat-retirement.md +147 -0
  4. package/docs/architecture/2.3.0-execution-plan.md +410 -0
  5. package/docs/architecture/2.3.0-final-audit.md +279 -0
  6. package/docs/architecture/2.3.0-reopened-plan.md +720 -0
  7. package/docs/architecture/3.0-architecture-plan.md +252 -0
  8. package/docs/architecture/3.0-r0-contract-ledger.md +137 -0
  9. package/docs/architecture/3.0-r1-source-artifact-boundary.md +98 -0
  10. package/docs/architecture/3.0-r10-final-correctness-closure.md +207 -0
  11. package/docs/architecture/3.0-r11-adversarial-review.md +441 -0
  12. package/docs/architecture/3.0-r5-browser-go-no-go.md +165 -0
  13. package/docs/architecture/3.0-r5-browser-typescript-gate.md +105 -0
  14. package/docs/architecture/3.0-r6-compat-retirement.md +203 -0
  15. package/docs/architecture/3.0-r7-public-api.md +151 -0
  16. package/docs/architecture/3.0-r8-ci-release.md +248 -0
  17. package/docs/architecture/3.0-r9-final-audit.md +370 -0
  18. package/docs/architecture/compat-inventory.md +26 -34
  19. package/docs/architecture/dsh-compatibility-matrix.md +65 -54
  20. package/docs/architecture/dsh-support-window.md +30 -22
  21. package/docs/architecture/final-closure-audit.md +2 -0
  22. package/docs/releases/v2.3.0.md +53 -0
  23. package/docs/releases/v3.0.0.md +85 -0
  24. package/entry.js +9 -57
  25. package/index.js +268 -761
  26. package/lib/agent-request-route-authority.js +1 -20
  27. package/lib/catalog-corrections.js +1 -2
  28. package/lib/client-maintenance-actions.js +145 -0
  29. package/lib/client-presentation-boundary-main.js +75 -17
  30. package/lib/client-presentation-boundary.js +53 -28
  31. package/lib/client.js +246 -144
  32. package/lib/core-apply-lifecycle.js +41 -0
  33. package/lib/core-cache.js +87 -0
  34. package/lib/core-primitives.js +91 -278
  35. package/lib/core-vision-surface-domain.js +63 -0
  36. package/lib/core-vision-surface.js +11 -77
  37. package/lib/delegated-call-config.js +15 -24
  38. package/lib/desktop-screenshot-tool.js +262 -0
  39. package/lib/doctor-cli.js +0 -0
  40. package/lib/doctor-runtime.js +4 -1
  41. package/lib/dsh-contract-compat.js +37 -256
  42. package/lib/dsh-host-capabilities.js +3 -9
  43. package/lib/dsh-settings-017-compat.js +17 -10
  44. package/lib/dsh-support-window.js +3 -3
  45. package/lib/file-logger.js +8 -3
  46. package/lib/grounding-coordinate-runtime.js +1 -2
  47. package/lib/http-body-limit.js +97 -79
  48. package/lib/http-compat.js +1 -2
  49. package/lib/image-offload-compat.js +30 -21
  50. package/lib/image-resource-governor.js +210 -228
  51. package/lib/legacy-global-proxy-boundary.js +46 -3
  52. package/lib/live-model-client-prelude.js +12 -4
  53. package/lib/live-model-discovery.js +134 -43
  54. package/lib/local-remote-settings-permission.js +33 -7
  55. package/lib/local-vision-stabilizer.js +31 -17
  56. package/lib/maintenance-routes.js +134 -0
  57. package/lib/native-image-coexistence.js +15 -3
  58. package/lib/ollama-cold-start.js +87 -23
  59. package/lib/pi-ai-bridge-wire-compat.js +3 -2
  60. package/lib/proxy-dispatcher-pool.js +151 -92
  61. package/lib/proxy-routing.js +75 -51
  62. package/lib/proxy-url-compat.js +3 -10
  63. package/lib/public-config.js +36 -0
  64. package/lib/public-entry.d.ts +148 -0
  65. package/lib/public-entry.js +19 -11
  66. package/lib/remote-settings-bridge.js +11 -0
  67. package/lib/remote-settings-risk-client-boundary.js +155 -0
  68. package/lib/remote-settings-risk-confirmation.js +17 -105
  69. package/lib/replay-delegation.js +12 -7
  70. package/lib/replay-envelope-v2-compat.js +12 -7
  71. package/lib/runtime-composition.js +127 -34
  72. package/lib/runtime-i18n-boundary.js +10 -11
  73. package/lib/screenshot-source-boundary.js +2 -2
  74. package/lib/session-affinity-runtime.js +113 -97
  75. package/lib/session-affinity.js +89 -76
  76. package/lib/session-event-history-compat.js +30 -0
  77. package/lib/session-message-source-compat.js +7 -3
  78. package/lib/session-query-readers.js +263 -0
  79. package/lib/session-surface-compat.js +30 -31
  80. package/lib/session-surface-policy-domain.js +85 -0
  81. package/lib/session-surface-policy.js +2 -111
  82. package/lib/session-turn-resolver.js +2 -6
  83. package/lib/session-vision-attachment-index.js +143 -0
  84. package/lib/session-vision-delegation-boundary.js +34 -0
  85. package/lib/session-vision-delegation.js +22 -0
  86. package/lib/session-vision-index.js +179 -116
  87. package/lib/session-vision-mode-authority.js +229 -192
  88. package/lib/session-vision-mode-boundary.js +65 -22
  89. package/lib/session-vision-policy-domain.js +304 -0
  90. package/lib/session-vision-policy.js +104 -0
  91. package/lib/session-vision-state.js +368 -378
  92. package/lib/settings-client-017-compat.js +9 -7
  93. package/lib/settings-client-loader-lifecycle.js +129 -0
  94. package/lib/sharp-runtime.js +0 -7
  95. package/lib/structured-flow-hardening.js +25 -6
  96. package/lib/tesseract-exec-compat.js +9 -1
  97. package/lib/turn-budget-context.js +47 -41
  98. package/lib/update-check.js +11 -2
  99. package/lib/vision-attachment-handle-runtime.js +176 -156
  100. package/lib/vision-backend-runtime-policy.js +19 -2
  101. package/lib/vision-background-benchmark.js +41 -4
  102. package/lib/vision-background-failure-policy.js +41 -41
  103. package/lib/vision-background-stop-store-core.js +217 -0
  104. package/lib/vision-background-stop-store.js +8 -179
  105. package/lib/vision-capability-benchmark-service.js +1 -0
  106. package/lib/vision-capability-benchmark.js +11 -61
  107. package/lib/vision-capability-evidence.js +35 -0
  108. package/lib/vision-capability-fingerprint.js +72 -0
  109. package/lib/vision-capability-identity.js +116 -61
  110. package/lib/vision-capability-router.js +13 -33
  111. package/lib/vision-chunk-assembler.js +95 -0
  112. package/lib/vision-diagnostics-routes.js +246 -0
  113. package/lib/vision-evidence-guidance.js +3 -1
  114. package/lib/vision-execution-order-apply.js +45 -55
  115. package/lib/vision-execution-order.js +19 -19
  116. package/lib/vision-execution-policy.js +12 -7
  117. package/lib/vision-model-registry.js +30 -17
  118. package/lib/vision-provider-transport.js +137 -176
  119. package/lib/vision-routing-authority.js +47 -48
  120. package/lib/vision-routing-product.js +26 -29
  121. package/lib/vision-routing-settings-prelude.js +8 -3
  122. package/lib/vision-runtime-performance-store.js +166 -0
  123. package/lib/vision-runtime-performance.js +13 -142
  124. package/lib/vision-toggle-root-hardening.js +22 -5
  125. package/lib/vision-tool-runtime-boundary.js +104 -31
  126. package/lib/web/dsh-settings-017-root-transport.js +27 -12
  127. package/lib/web/remote-settings-client.js +6 -9
  128. package/lib/web-capability-boundary.js +17 -7
  129. package/lib/wrapper-directory.js +14 -23
  130. package/package.json +56 -20
  131. package/lib/android-attachment-compat.js +0 -244
  132. 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 (!settings || typeof settings.get !== 'function') return { authoritative: false, providers: {} }
169
- const value = settings.get('llm-pi-ai')
170
- const providers = value?.providers
171
- if (!providers || typeof providers !== 'object' || Array.isArray(providers)) {
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
- return { authoritative: false, providers: {} }
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
- const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch
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 response = await fetchImpl(listingURL(plan.baseURL), {
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
- ctx?.inject?.(['webServer'], (webCtx) => {
677
- webCtx.effect(() => webCtx.webServer.register({
678
- kind: 'exact',
679
- path: LIVE_MODELS_PATH,
680
- handler: async (req, res) => {
681
- if (req.method !== 'GET') {
682
- res.setHeader('Allow', 'GET')
683
- sendJson(res, 405, { ok: false, error: 'method not allowed' })
684
- return
685
- }
686
- try {
687
- const url = new URL(req.url ?? LIVE_MODELS_PATH, 'http://localhost')
688
- // The snapshot itself is safe for an authenticated remote Settings UI,
689
- // but refresh drives Host-side provider I/O and may resolve stored
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
- lifecycleDisposed = true
704
- if (startupTimer !== undefined) clearTimeout(startupTimer)
705
- for (const dispose of disposers) {
706
- try { dispose() } catch { /* best effort */ }
707
- }
708
- void manager.dispose()
709
- }, 'vision-router: live model discovery lifecycle')
710
- } catch {
711
- // Host service disposal still tears down the registered route.
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
- export const LOCAL_PERMISSION_CLIENT_PRELUDE = String.raw`(function(){
177
- 'use strict';
178
- var ENDPOINT = '/_dsh/vision-router/remote-settings-permission';
179
- var FIELD = 'allowRemoteSettings';
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 fetch !== 'function') throw new Error('Vision Router local settings transport is unavailable');
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 fetch(ENDPOINT, {
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
- const attachments = ctx.get('attachments')
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
- captureConfig(undefined, scope)
384
- syncScreenshot()
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
- /* lifecycle hardening must not block Settings registration */
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
- /* parent stabilizer cleanup remains a final fallback */
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
- /* cleanup registration is best effort */
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 === 'vision-router') state.settingsScope = scope
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