dsh-vision-router 2.1.6 → 2.2.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 (49) hide show
  1. package/README.md +8 -14
  2. package/README.zh.md +8 -14
  3. package/cordis.patch.yml +14 -0
  4. package/docs/architecture/compat-inventory.md +1 -1
  5. package/docs/architecture/dsh-compatibility-matrix.md +4 -1
  6. package/docs/architecture/dsh-support-window.md +1 -2
  7. package/docs/releases/v2.1.7.md +13 -0
  8. package/docs/releases/v2.2.0.md +25 -0
  9. package/docs/remote-settings.md +2 -0
  10. package/entry.js +2 -0
  11. package/index.js +387 -77
  12. package/lib/artifact-boundary.js +11 -0
  13. package/lib/artifact-io.js +60 -7
  14. package/lib/catalog-corrections.js +5 -0
  15. package/lib/client.js +293 -48
  16. package/lib/core-primitives.js +122 -7
  17. package/lib/degraded-local-evidence.js +39 -0
  18. package/lib/dsh-contract-compat.js +202 -0
  19. package/lib/dsh-support-window.js +1 -1
  20. package/lib/image-offload-compat.js +38 -0
  21. package/lib/live-model-discovery.js +125 -15
  22. package/lib/local-vision-stabilizer.js +15 -6
  23. package/lib/official-deepseek-catalog.js +120 -0
  24. package/lib/ollama-cold-start.js +3 -15
  25. package/lib/remote-settings-bridge.js +28 -5
  26. package/lib/replay-delegation.js +68 -42
  27. package/lib/runtime-composition.js +15 -2
  28. package/lib/runtime-config-normalizer.js +271 -1
  29. package/lib/runtime-i18n-boundary.js +4 -13
  30. package/lib/runtime-i18n.js +2 -2
  31. package/lib/session-surface-compat.js +40 -0
  32. package/lib/session-turn-resolver.js +57 -0
  33. package/lib/session-vision-index.js +206 -93
  34. package/lib/session-vision-mode-boundary.js +83 -6
  35. package/lib/session-vision-runtime.js +8 -2
  36. package/lib/session-vision-state.js +0 -13
  37. package/lib/tesseract-exec-compat.js +16 -2
  38. package/lib/twin-image-capability-fallback.js +2 -11
  39. package/lib/vision-artifact-store.js +16 -3
  40. package/lib/vision-attachment-handle-runtime.js +9 -21
  41. package/lib/vision-backend-runtime-policy.js +7 -6
  42. package/lib/vision-background-stop-store.js +30 -20
  43. package/lib/vision-breaker-shadow-health.js +15 -5
  44. package/lib/vision-capability-probe.js +24 -14
  45. package/lib/vision-evidence-guidance.js +39 -0
  46. package/lib/vision-image-input-verdict.js +22 -7
  47. package/lib/vision-resilience.js +73 -1
  48. package/lib/web-capability-boundary.js +85 -22
  49. package/package.json +14 -7
@@ -5,6 +5,7 @@ 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 { isLocalUiRequest } from './web-capability-boundary.js'
8
9
 
9
10
  export const LIVE_MODELS_PATH = '/_dsh/vision-router/live-models'
10
11
  export const LIVE_MODEL_CACHE_VERSION = 2
@@ -161,17 +162,34 @@ async function saveCache(file, providers, fsOps) {
161
162
  await fsOps.rename(temporary, file)
162
163
  }
163
164
 
164
- function rawPiProfiles(ctx) {
165
+ function piProfilesState(ctx) {
165
166
  try {
166
167
  const settings = ctx?.get?.('settings')
167
- const value = settings?.get?.('llm-pi-ai')
168
+ if (!settings || typeof settings.get !== 'function') return { authoritative: false, providers: {} }
169
+ const value = settings.get('llm-pi-ai')
168
170
  const providers = value?.providers
169
- return providers && typeof providers === 'object' && !Array.isArray(providers) ? providers : {}
171
+ if (!providers || typeof providers !== 'object' || Array.isArray(providers)) {
172
+ return { authoritative: false, providers: {} }
173
+ }
174
+ return { authoritative: true, providers }
170
175
  } catch {
171
- return {}
176
+ return { authoritative: false, providers: {} }
172
177
  }
173
178
  }
174
179
 
180
+ function rawPiProfiles(ctx) {
181
+ return piProfilesState(ctx).providers
182
+ }
183
+
184
+ function providerConfigurationToken(provider, raw) {
185
+ return JSON.stringify([
186
+ provider,
187
+ normalizeBaseURL(raw?.baseURL) ?? '',
188
+ nonEmpty(raw?.api) ?? '',
189
+ nonEmpty(raw?.apiKeyEnv) ?? '',
190
+ ])
191
+ }
192
+
175
193
  function visionProviderPriority(ctx, fallbackConfig = {}) {
176
194
  const preferred = new Set()
177
195
  const collect = (value) => {
@@ -349,15 +367,18 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
349
367
  let providers = new Map()
350
368
  let version = 0
351
369
  let evidenceGeneration = 0
370
+ let configuredProviders
371
+ let configuredTokens = new Map()
372
+ let providerGenerations = new Map()
373
+ let providerGenerationSequence = 0
352
374
  let active = 0
353
375
  let disposed = false
354
376
  let saveTail = Promise.resolve()
355
377
  const inflight = new Map()
378
+ const inflightAuthority = new Map()
356
379
  const queued = new Map()
357
380
  const backoffUntil = new Map()
358
- const cacheReady = loadCache(cacheFile, fsOps).then((loaded) => {
359
- if (!disposed) providers = loaded
360
- })
381
+ const activeControllers = new Map()
361
382
 
362
383
  const persist = () => {
363
384
  saveTail = saveTail
@@ -366,6 +387,76 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
366
387
  return saveTail
367
388
  }
368
389
 
390
+ const pruneUnconfiguredState = () => {
391
+ if (!(configuredProviders instanceof Set)) return false
392
+ let providerCacheChanged = false
393
+ for (const provider of [...providers.keys()]) {
394
+ if (configuredProviders.has(provider)) continue
395
+ providers.delete(provider)
396
+ providerCacheChanged = true
397
+ }
398
+ for (const provider of [...queued.keys()]) {
399
+ if (!configuredProviders.has(provider)) queued.delete(provider)
400
+ }
401
+ for (const provider of [...backoffUntil.keys()]) {
402
+ if (!configuredProviders.has(provider)) backoffUntil.delete(provider)
403
+ }
404
+ for (const [provider, controller] of activeControllers.entries()) {
405
+ if (configuredProviders.has(provider)) continue
406
+ try { controller.abort() } catch {}
407
+ }
408
+ if (providerCacheChanged) version += 1
409
+ return providerCacheChanged
410
+ }
411
+
412
+ const reconcileConfiguredProviders = () => {
413
+ const state = piProfilesState(ctx)
414
+ if (!state.authoritative) return state.providers
415
+ const next = new Set(Object.keys(state.providers))
416
+ const nextTokens = new Map([...next].map((provider) => [
417
+ provider,
418
+ providerConfigurationToken(provider, state.providers[provider]),
419
+ ]))
420
+ const nextGenerations = new Map()
421
+ for (const provider of next) {
422
+ const unchanged = configuredProviders instanceof Set &&
423
+ configuredProviders.has(provider) &&
424
+ configuredTokens.get(provider) === nextTokens.get(provider)
425
+ if (unchanged && providerGenerations.has(provider)) {
426
+ nextGenerations.set(provider, providerGenerations.get(provider))
427
+ } else {
428
+ providerGenerationSequence += 1
429
+ nextGenerations.set(provider, providerGenerationSequence)
430
+ }
431
+ }
432
+ configuredProviders = next
433
+ configuredTokens = nextTokens
434
+ providerGenerations = nextGenerations
435
+ if (pruneUnconfiguredState()) void persist()
436
+ return state.providers
437
+ }
438
+
439
+ const currentDiscoveryAuthority = (provider) => ({
440
+ providerGeneration: providerGenerations.get(provider) ?? 0,
441
+ evidenceGeneration,
442
+ })
443
+
444
+ const sameDiscoveryAuthority = (left, right) => (
445
+ left?.providerGeneration === right?.providerGeneration &&
446
+ left?.evidenceGeneration === right?.evidenceGeneration
447
+ )
448
+
449
+ const discoveryStillCurrent = (provider, authority) => (
450
+ sameDiscoveryAuthority(authority, currentDiscoveryAuthority(provider)) &&
451
+ (!(configuredProviders instanceof Set) || configuredProviders.has(provider))
452
+ )
453
+
454
+ const cacheReady = loadCache(cacheFile, fsOps).then((loaded) => {
455
+ if (disposed) return
456
+ providers = loaded
457
+ reconcileConfiguredProviders()
458
+ })
459
+
369
460
  const visibleEntry = (entry, at = now()) => {
370
461
  if (!entry || entry.routeMismatch === true || at - entry.discoveredAt > staleMs) return undefined
371
462
  const currentEvidence = entry.evidenceGeneration === evidenceGeneration
@@ -391,10 +482,10 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
391
482
  }
392
483
  }
393
484
 
394
- const discover = async (provider) => {
485
+ const discover = async (provider, authorityAtStart) => {
395
486
  const plan = await providerPlan(ctx, provider)
396
- if (!plan.ok) return
397
- const generationAtStart = evidenceGeneration
487
+ if (!plan.ok || disposed || !discoveryStillCurrent(provider, authorityAtStart)) return
488
+ const generationAtStart = authorityAtStart.evidenceGeneration
398
489
  const previous = providers.get(provider)
399
490
  const at = now()
400
491
  const sameRoute = previous?.fingerprint === plan.fingerprint
@@ -414,6 +505,7 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
414
505
  }
415
506
 
416
507
  const controller = new AbortController()
508
+ activeControllers.set(provider, controller)
417
509
  const timer = setTimeout(() => controller.abort(), timeoutMs)
418
510
  try {
419
511
  const headers = { accept: 'application/json' }
@@ -432,6 +524,7 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
432
524
  label: `${provider} model listing`,
433
525
  })
434
526
  const models = normalizeOpenAIModelListing(body, { maxModels: options.maxModels })
527
+ if (disposed || !discoveryStillCurrent(provider, authorityAtStart)) return
435
528
  providers.set(provider, {
436
529
  provider,
437
530
  fingerprint: plan.fingerprint,
@@ -444,7 +537,7 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
444
537
  version += 1
445
538
  void persist()
446
539
  } catch (error) {
447
- if (disposed) return
540
+ if (disposed || !discoveryStillCurrent(provider, authorityAtStart)) return
448
541
  const current = providers.get(provider)
449
542
  const message = boundedError(error)
450
543
  if (current) {
@@ -455,6 +548,7 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
455
548
  logger?.debug?.('vision-router: live model discovery failed for %s: %s', provider, message)
456
549
  } finally {
457
550
  clearTimeout(timer)
551
+ if (activeControllers.get(provider) === controller) activeControllers.delete(provider)
458
552
  }
459
553
  }
460
554
 
@@ -470,14 +564,17 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
470
564
  if (!picked) return
471
565
  queued.delete(picked.provider)
472
566
  active += 1
473
- const task = discover(picked.provider)
567
+ const authorityAtStart = currentDiscoveryAuthority(picked.provider)
568
+ const task = discover(picked.provider, authorityAtStart)
474
569
  .catch(() => {})
475
570
  .finally(() => {
476
571
  active = Math.max(0, active - 1)
477
572
  inflight.delete(picked.provider)
573
+ inflightAuthority.delete(picked.provider)
478
574
  pump()
479
575
  })
480
576
  inflight.set(picked.provider, task)
577
+ inflightAuthority.set(picked.provider, authorityAtStart)
481
578
  }
482
579
  }
483
580
 
@@ -491,9 +588,13 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
491
588
  }
492
589
 
493
590
  function queueConfigured() {
494
- const raw = rawPiProfiles(ctx)
591
+ const raw = reconcileConfiguredProviders()
495
592
  const preferred = visionProviderPriority(ctx, fallbackConfig)
496
- for (const provider of Object.keys(raw)) queue(provider, preferred.has(provider) ? 0 : 10)
593
+ for (const provider of Object.keys(raw)) {
594
+ const activeAuthority = inflightAuthority.get(provider)
595
+ if (activeAuthority && sameDiscoveryAuthority(activeAuthority, currentDiscoveryAuthority(provider))) continue
596
+ queue(provider, preferred.has(provider) ? 0 : 10)
597
+ }
497
598
  }
498
599
 
499
600
  const hasModel = (provider, model) => {
@@ -524,7 +625,12 @@ export function createLiveModelDiscoveryManager(ctx, options = {}) {
524
625
  async dispose() {
525
626
  disposed = true
526
627
  queued.clear()
628
+ for (const controller of activeControllers.values()) {
629
+ try { controller.abort() } catch {}
630
+ }
527
631
  await Promise.allSettled([...inflight.values(), cacheReady, saveTail])
632
+ activeControllers.clear()
633
+ inflightAuthority.clear()
528
634
  },
529
635
  }
530
636
  }
@@ -579,7 +685,11 @@ export function installLiveModelDiscovery(ctx, options = {}) {
579
685
  }
580
686
  try {
581
687
  const url = new URL(req.url ?? LIVE_MODELS_PATH, 'http://localhost')
582
- const schedule = url.searchParams.get('refresh') !== '0'
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'
583
693
  sendJson(res, 200, await manager.snapshot({ schedule }))
584
694
  } catch (error) {
585
695
  sendJson(res, 500, { ok: false, error: boundedError(error) })
@@ -5,6 +5,7 @@ import path from 'node:path'
5
5
  import { promisify } from 'node:util'
6
6
  import { sameOriginRequest } from './adversarial-hardening.js'
7
7
  import { normalizeRuntimeVisionConfig } from './runtime-config-normalizer.js'
8
+ import { isOffloadedImageBlock, offloadedImagePlaceholder } from './image-offload-compat.js'
8
9
 
9
10
  /**
10
11
  * Narrow runtime guard around the local-vision features merged in #141.
@@ -177,6 +178,10 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
177
178
  const content = []
178
179
  for (const block of message.content) {
179
180
  if (block && block.type === 'image' && block.attachment) {
181
+ if (isOffloadedImageBlock(block)) {
182
+ content.push({ type: 'text', text: offloadedImagePlaceholder(block) })
183
+ continue
184
+ }
180
185
  if (!attachments || typeof attachments.readImage !== 'function') continue
181
186
  try {
182
187
  const stored = await attachments.readImage(block.attachment)
@@ -200,12 +205,16 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
200
205
  if (nested && nested.type === 'text' && typeof nested.text === 'string') {
201
206
  parts.push(nested.text)
202
207
  } else if (nested && nested.type === 'image') {
203
- const attachment = nested.attachment || {}
204
- const id = attachment.attachmentId || attachment.id || 'unknown'
205
- parts.push(
206
- `[attached image: ${id}] this tool result contained an image; ` +
207
- 'inspect it with vision_describe (or re-read it with read_image)',
208
- )
208
+ if (isOffloadedImageBlock(nested)) {
209
+ parts.push(offloadedImagePlaceholder(nested))
210
+ } else {
211
+ const attachment = nested.attachment || {}
212
+ const id = attachment.attachmentId || attachment.id || 'unknown'
213
+ parts.push(
214
+ `[attached image: ${id}] this tool result contained an image; ` +
215
+ 'inspect it with vision_describe (or re-read it with read_image)',
216
+ )
217
+ }
209
218
  }
210
219
  }
211
220
  if (parts.length > 0) {
@@ -0,0 +1,120 @@
1
+ const catalogStateByAdapter = new WeakMap()
2
+
3
+ export const OFFICIAL_DEEPSEEK_CATALOG_FRESH_MS = 30_000
4
+ export const OFFICIAL_DEEPSEEK_CATALOG_STALE_IF_ERROR_MS = 10 * 60_000
5
+ export const OFFICIAL_DEEPSEEK_CATALOG_FAILURE_BACKOFF_MS = 5_000
6
+
7
+ function catalogUnavailable(cause) {
8
+ const error = new Error('vision-router: the official DeepSeek catalog is temporarily unavailable')
9
+ error.code = 'OFFICIAL_CATALOG_UNAVAILABLE'
10
+ if (cause !== undefined) error.cause = cause
11
+ return error
12
+ }
13
+
14
+ function normalizedModels(listed) {
15
+ if (!Array.isArray(listed)) {
16
+ throw new TypeError('official DeepSeek adapter listModels() did not return an array')
17
+ }
18
+ return Object.freeze(
19
+ listed
20
+ .filter((entry) => entry && typeof entry.id === 'string' && entry.id !== '')
21
+ .map((entry) => Object.freeze({ ...entry })),
22
+ )
23
+ }
24
+
25
+ /**
26
+ * Process-local authority for the live official DeepSeek catalog.
27
+ *
28
+ * The special DeepSeek vision wrapper must not infer product identity from an
29
+ * adapter's permissive resolveModel() behavior. Membership therefore remains
30
+ * fail-closed to a catalog actually observed from the official adapter. A
31
+ * short fresh cache coalesces duplicate Core/replay reads; a bounded
32
+ * stale-if-error window keeps transient endpoint failures from taking down a
33
+ * long-lived session. Cold start and expired-cache failures stay fail-closed.
34
+ *
35
+ * State is keyed by adapter identity so Host adapter replacement cannot inherit
36
+ * another registration's catalog. Nothing is persisted across process restart:
37
+ * a previous process is not durable authority for a newly started Host.
38
+ */
39
+ export async function getOfficialDeepSeekCatalog(adapter, {
40
+ provider = 'deepseek-official',
41
+ now = Date.now,
42
+ freshMs = OFFICIAL_DEEPSEEK_CATALOG_FRESH_MS,
43
+ staleIfErrorMs = OFFICIAL_DEEPSEEK_CATALOG_STALE_IF_ERROR_MS,
44
+ failureBackoffMs = OFFICIAL_DEEPSEEK_CATALOG_FAILURE_BACKOFF_MS,
45
+ } = {}) {
46
+ if (!adapter || typeof adapter.listModels !== 'function') {
47
+ throw catalogUnavailable()
48
+ }
49
+
50
+ let state = catalogStateByAdapter.get(adapter)
51
+ if (state === undefined) {
52
+ state = {
53
+ models: undefined,
54
+ fetchedAt: 0,
55
+ inFlight: undefined,
56
+ failure: undefined,
57
+ failedAt: 0,
58
+ }
59
+ catalogStateByAdapter.set(adapter, state)
60
+ }
61
+
62
+ const currentTime = Number(now())
63
+ const age =
64
+ state.models === undefined
65
+ ? Number.POSITIVE_INFINITY
66
+ : Math.max(0, currentTime - state.fetchedAt)
67
+ if (state.models !== undefined && age <= Math.max(0, Number(freshMs) || 0)) {
68
+ return state.models
69
+ }
70
+ if (state.inFlight !== undefined) return state.inFlight
71
+
72
+ const failureAge =
73
+ state.failure === undefined
74
+ ? Number.POSITIVE_INFINITY
75
+ : Math.max(0, currentTime - state.failedAt)
76
+ if (failureAge <= Math.max(0, Number(failureBackoffMs) || 0)) {
77
+ if (
78
+ state.models !== undefined
79
+ && age <= Math.max(0, Number(staleIfErrorMs) || 0)
80
+ ) {
81
+ return state.models
82
+ }
83
+ throw state.failure
84
+ }
85
+
86
+ const refresh = (async () => {
87
+ try {
88
+ const listed = await adapter.listModels(provider)
89
+ const models = normalizedModels(listed)
90
+ state.models = models
91
+ state.fetchedAt = Number(now())
92
+ state.failure = undefined
93
+ state.failedAt = 0
94
+ return models
95
+ } catch (cause) {
96
+ const failureTime = Number(now())
97
+ const error = catalogUnavailable(cause)
98
+ state.failure = error
99
+ state.failedAt = failureTime
100
+ const staleAge =
101
+ state.models === undefined
102
+ ? Number.POSITIVE_INFINITY
103
+ : Math.max(0, failureTime - state.fetchedAt)
104
+ if (
105
+ state.models !== undefined
106
+ && staleAge <= Math.max(0, Number(staleIfErrorMs) || 0)
107
+ ) {
108
+ return state.models
109
+ }
110
+ throw error
111
+ }
112
+ })()
113
+
114
+ state.inFlight = refresh
115
+ try {
116
+ return await refresh
117
+ } finally {
118
+ if (state.inFlight === refresh) state.inFlight = undefined
119
+ }
120
+ }
@@ -4,6 +4,7 @@ import {
4
4
  readResponseJsonBounded,
5
5
  } from './http-body-limit.js'
6
6
  import { stripTrailingSlashes } from './string-normalization.js'
7
+ import { blocksHaveRetainedImage } from './image-offload-compat.js'
7
8
 
8
9
  export const OLLAMA_WARMUP_KEEP_ALIVE = '30m'
9
10
  export const OLLAMA_WARMUP_TIMEOUT_MS = 120000
@@ -250,22 +251,9 @@ export function createOllamaWarmupManager({
250
251
  return { ensure, background, dispose }
251
252
  }
252
253
 
253
- function messagesContainImage(messages, core) {
254
+ function messagesContainImage(messages, _core) {
254
255
  if (!Array.isArray(messages)) return false
255
- return messages.some((message) => {
256
- const content = message && Array.isArray(message.content) ? message.content : []
257
- if (typeof core?.blocksHaveImage === 'function') {
258
- try { return core.blocksHaveImage(content) } catch { /* fall through */ }
259
- }
260
- const stack = [...content]
261
- while (stack.length > 0) {
262
- const block = stack.pop()
263
- if (!block || typeof block !== 'object') continue
264
- if (block.type === 'image') return true
265
- if (Array.isArray(block.content)) stack.push(...block.content)
266
- }
267
- return false
268
- })
256
+ return messages.some((message) => blocksHaveRetainedImage(message?.content))
269
257
  }
270
258
 
271
259
  function configuredRows(config) {
@@ -3,6 +3,7 @@ import { installRemoteSettingsRiskConfirmationBridge } from './remote-settings-r
3
3
  import { installSettingsIaClientPrelude } from './settings-ia-client-prelude.js'
4
4
  import { installLegacySettingsMigration } from './settings-migration.js'
5
5
  import { installV2SettingsIaIntegration } from './v2-settings-ia-integration.js'
6
+ import { remoteConfigFieldAdmissionError } from './runtime-config-normalizer.js'
6
7
 
7
8
  const SETTINGS_NS = 'vision-router'
8
9
 
@@ -107,6 +108,10 @@ function validateMutation(payload, descriptor) {
107
108
  if (!mutable.has(field)) return { error: 'Vision Router setting ' + JSON.stringify(field) + ' is local-only' }
108
109
  const value = descriptor && descriptor.value
109
110
  if (typeof value !== 'object' || value === null || Array.isArray(value) || !Object.prototype.hasOwnProperty.call(value, field)) return { error: 'unknown Vision Router settings field ' + JSON.stringify(field) }
111
+ if (op.op === 'set') {
112
+ const budgetError = remoteConfigFieldAdmissionError(field, op.value)
113
+ if (budgetError) return { error: budgetError }
114
+ }
110
115
  return { ops: [op], expectedRevision }
111
116
  }
112
117
 
@@ -171,6 +176,24 @@ export async function authorizeRemoteSettingsAfterRiskConfirmation(settings, pay
171
176
  }
172
177
  }
173
178
 
179
+ /**
180
+ * Register the dedicated settings channel against the active DSH Connection
181
+ * trust contract. rc.8 requires a per-channel `trusted-host` policy. Newer
182
+ * Connection carriers expose `requestRejection()` and authenticate every
183
+ * dedicated RPC route before dispatch, while `rpc.handle()` accepts only the
184
+ * channel and handler. Feature-detect the carrier-owned trust fence rather
185
+ * than passing a security-looking argument that modern Hosts do not consume.
186
+ */
187
+ export function registerVisionRouterRemoteSettingsChannel(connection, handler) {
188
+ if (!connection || typeof connection !== 'object' || !connection.rpc || typeof connection.rpc.handle !== 'function') {
189
+ throw new TypeError('Vision Router remote settings requires DSH Connection RPC handle()')
190
+ }
191
+ if (typeof connection.requestRejection === 'function') {
192
+ return connection.rpc.handle(REMOTE_SETTINGS_CHANNEL, handler)
193
+ }
194
+ return connection.rpc.handle(REMOTE_SETTINGS_CHANNEL, handler, { authority: 'trusted-host' })
195
+ }
196
+
174
197
  export function createVisionRouterRemoteSettingsHandler(settings, logger) {
175
198
  return async (endpoint, payload) => {
176
199
  if (endpoint === 'describe') {
@@ -213,14 +236,14 @@ export function createVisionRouterRemoteSettingsHandler(settings, logger) {
213
236
  }
214
237
 
215
238
  export function installVisionRouterRemoteSettingsBridge(ctx, logger) {
216
- // DSH client-connection scopes rpc.handle() registrations to the caller
217
- // Context and mounts their WebRoute through owner.webServer. Keep webServer
218
- // on this exact injected fiber so strict Cordis service access in 0.1.5+
219
- // cannot turn remote-settings registration into a startup error.
239
+ // Declare the services this caller uses directly. Current DSH Connection
240
+ // accessors additionally trace their route owner through the Connection
241
+ // provider fiber; cordis.patch.yml therefore keeps that provider activated
242
+ // on webServer as a separate compatibility boundary (#478).
220
243
  ctx.inject(['settings', 'connection', 'webServer'], (remoteCtx) => {
221
244
  const handler = createVisionRouterRemoteSettingsHandler(remoteCtx.settings, logger ?? remoteCtx.logger)
222
245
  remoteCtx.effect(
223
- () => remoteCtx.connection.rpc.handle(REMOTE_SETTINGS_CHANNEL, handler, { authority: 'trusted-host' }),
246
+ () => registerVisionRouterRemoteSettingsChannel(remoteCtx.connection, handler),
224
247
  'vision-router: opt-in remote settings channel',
225
248
  )
226
249
  })