dsh-vision-router 3.0.2 → 3.0.4

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.
@@ -10,6 +10,7 @@ import { resolveVisionRoutingAuthority } from './vision-routing-authority.js'
10
10
  import { redactDiagnosticText } from './diagnostic-redaction.js'
11
11
  import { installVisionExactCheckClient } from './vision-exact-check-client.js'
12
12
  import { createImageInputVerdictStore } from './vision-image-input-verdict.js'
13
+ import { hostImageDeliveryFromInfo, localOnlyVisionCallAllowed, resolvedHostInfo } from './vision-backend-runtime-policy.js'
13
14
  import { createBackgroundBenchmarkStopStore } from './vision-background-stop-store.js'
14
15
  import { backgroundFailurePolicy } from './vision-background-failure-policy.js'
15
16
  import { isLocalUiRequest } from './web-capability-boundary.js'
@@ -288,9 +289,18 @@ function candidateBlockedByFailure(backoff, candidate, at) {
288
289
  return false
289
290
  }
290
291
 
291
- async function imageVerdictFor(imageVerdictStore, candidate) {
292
+ async function currentHostImageDelivery(ctx, provider, model) {
293
+ if (provider === 'vision-http') return 'native-image'
294
+ return hostImageDeliveryFromInfo(await resolvedHostInfo(ctx?.llm, { provider, model }))
295
+ }
296
+
297
+ async function imageVerdictFor(imageVerdictStore, candidate, ctx) {
292
298
  if (!candidate?.endpointFingerprint || typeof imageVerdictStore?.get !== 'function') return undefined
293
- try { return await imageVerdictStore.get(candidate.endpointFingerprint) } catch { return undefined }
299
+ try {
300
+ const verdict = await imageVerdictStore.get(candidate.endpointFingerprint)
301
+ if (!verdict || verdict.hostMode !== 'native-image') return undefined
302
+ return await currentHostImageDelivery(ctx, candidate.provider, candidate.model) === 'native-image' ? verdict : undefined
303
+ } catch { return undefined }
294
304
  }
295
305
 
296
306
  async function chooseNextWork({ ctx, config, core, store, now, backoff, excluded, imageVerdictStore, backgroundStopStore }) {
@@ -302,7 +312,7 @@ async function chooseNextWork({ ctx, config, core, store, now, backoff, excluded
302
312
  await restorePersistentStops(backoff, collected, backgroundStopStore, ctx, now)
303
313
  const candidates = []
304
314
  for (const candidate of collected) {
305
- const verdict = await imageVerdictFor(imageVerdictStore, candidate)
315
+ const verdict = await imageVerdictFor(imageVerdictStore, candidate, ctx)
306
316
  if (verdict?.state === 'unsupported') {
307
317
  excluded.set(candidate.key, { key: candidate.key, reason: 'measured-text-only' })
308
318
  continue
@@ -470,6 +480,13 @@ export async function runExactVisionCheck({ ctx, config, core, store, provider,
470
480
  const wantedModel = typeof model === 'string' ? model.trim() : ''
471
481
  if (!wantedProvider || !wantedModel) throw Object.assign(new Error('provider and model are required'), { code: 'VISION_CHECK_BACKEND_REQUIRED' })
472
482
  const current = activeSettings(ctx, config)
483
+ if (!localOnlyVisionCallAllowed(core, current, wantedProvider, wantedModel)) {
484
+ const error = new Error('local-only vision policy prevents testing a nonlocal backend')
485
+ error.code = 'VISION_LOCAL_ONLY_POLICY'
486
+ error.benchmarkClass = 'host-configuration'
487
+ throw error
488
+ }
489
+ const declaredBefore = await currentHostImageDelivery(ctx, wantedProvider, wantedModel)
473
490
  const candidates = await collectVisionRoutingCandidates(ctx, current, core, store)
474
491
  const candidate = candidates.find((entry) => entry?.provider === wantedProvider && entry?.model === wantedModel)
475
492
  ?? exactAdapterCandidate(ctx, core, wantedProvider, wantedModel)
@@ -513,6 +530,12 @@ export async function runExactVisionCheck({ ctx, config, core, store, provider,
513
530
  remaining(),
514
531
  'exact image check timed out',
515
532
  )
533
+ if (declaredBefore !== await currentHostImageDelivery(ctx, wantedProvider, wantedModel)) {
534
+ const error = new Error('Host image-input declaration changed during the test; retest the model')
535
+ error.code = 'VISION_CHECK_STALE_CONFIG'
536
+ error.benchmarkClass = 'host-configuration'
537
+ throw error
538
+ }
516
539
  return {
517
540
  ok: true,
518
541
  key: candidate.key,
@@ -602,13 +625,15 @@ export function createBackgroundCapabilityProfiler({
602
625
  excluded.set(candidate.key, { key: candidate.key, reason: 'measured-text-only' })
603
626
  clearDeferredForCandidate(candidate)
604
627
  await clearPersistentStopsForCandidate(candidate)
605
- if (candidate.endpointFingerprint && typeof imageVerdictStore?.markUnsupported === 'function') {
628
+ if (candidate.endpointFingerprint && typeof imageVerdictStore?.markUnsupported === 'function'
629
+ && await currentHostImageDelivery(ctx, candidate.provider, candidate.model) === 'native-image') {
606
630
  try {
607
631
  await imageVerdictStore.markUnsupported({
608
632
  fingerprint: candidate.endpointFingerprint,
609
633
  key: candidate.key,
610
634
  provider: candidate.provider,
611
635
  model: candidate.model,
636
+ hostMode: 'native-image',
612
637
  measuredAt: Number(now()),
613
638
  })
614
639
  } catch (error) {
@@ -1140,7 +1165,9 @@ export function installBackgroundCapabilityProfiling(ctx, config, core, store, o
1140
1165
  const timedOut = error?.name === 'TimeoutError' || error?.name === 'AbortError' || /timed out|timeout|deadline/i.test(String(error?.message ?? ''))
1141
1166
  const failureClass = error?.benchmarkClass ?? classifyCapabilityBenchmarkFailure(error)
1142
1167
  const unsupportedImage = !timedOut && failureClass === 'unsupported-image'
1143
- if (unsupportedImage) await profiler.recordImageUnsupported(body.provider, body.model)
1168
+ if (unsupportedImage && await currentHostImageDelivery(ctx, body.provider, body.model) === 'native-image') {
1169
+ await profiler.recordImageUnsupported(body.provider, body.model)
1170
+ }
1144
1171
  const code = timedOut
1145
1172
  ? 'VISION_CHECK_TIMEOUT'
1146
1173
  : unsupportedImage
@@ -1151,6 +1178,7 @@ export function installBackgroundCapabilityProfiling(ctx, config, core, store, o
1151
1178
  'VISION_CHECK_INVALID_JSON',
1152
1179
  'VISION_CHECK_BACKEND_REQUIRED',
1153
1180
  'VISION_CHECK_BACKEND_STALE',
1181
+ 'VISION_CHECK_STALE_CONFIG',
1154
1182
  ]).has(code)
1155
1183
  sendJson(res, unsupportedImage ? 422 : clientError ? 400 : 502, {
1156
1184
  ok: false,
@@ -14,6 +14,8 @@ import { resolveVisionCredential } from './vision-capability-identity.js'
14
14
  import { redactDiagnosticText } from './diagnostic-redaction.js'
15
15
  import { wireSessionAffinityId } from './session-affinity.js'
16
16
  import { streamWithVisionSessionAffinity } from './session-affinity-runtime.js'
17
+ import { hostImageDeliveryFromInfo, resolvedHostInfo } from './vision-backend-runtime-policy.js'
18
+ import { isLocalImageCapabilityAdmissionFailure } from './vision-execution-policy.js'
17
19
  import { streamWithLegacyGlobalProxyScope } from './legacy-global-proxy-boundary.js'
18
20
  import {
19
21
  hardenCapabilityBenchmarkFixture,
@@ -205,13 +207,28 @@ function failureError(failure, fallback) {
205
207
  async function collectStreamText(streamLike) {
206
208
  const stream = await streamLike
207
209
  let text = ''
208
- for await (const chunk of stream) {
209
- if (chunk && typeof chunk.text === 'string') text += chunk.text
210
- if (chunk?.type === 'finish') {
211
- const kind = chunk.reason?.kind
212
- if (kind === 'error' || kind === 'aborted') throw failureError(chunk.reason?.failure, kind)
210
+ let observedOutput = false
211
+ try {
212
+ for await (const chunk of stream) {
213
+ // Any non-terminal output proves that the adapter began responding.
214
+ // Retrying through a second transport could duplicate a billed call.
215
+ if (chunk && chunk.type !== 'finish' && chunk.type !== 'error' && chunk.type !== 'aborted') {
216
+ observedOutput = true
217
+ }
218
+ if (chunk && typeof chunk.text === 'string') text += chunk.text
219
+ if (chunk?.type === 'finish') {
220
+ const kind = chunk.reason?.kind
221
+ if (kind === 'error' || kind === 'aborted') throw failureError(chunk.reason?.failure, kind)
222
+ }
223
+ if (chunk?.type === 'error' || chunk?.type === 'aborted') throw failureError(chunk.failure, chunk.type)
224
+ }
225
+ } catch (error) {
226
+ if (observedOutput) {
227
+ const failure = failureError(error)
228
+ failure.visionAdapterProducedOutput = true
229
+ throw failure
213
230
  }
214
- if (chunk?.type === 'error' || chunk?.type === 'aborted') throw failureError(chunk.failure, chunk.type)
231
+ throw error
215
232
  }
216
233
  return text
217
234
  }
@@ -223,20 +240,22 @@ function tagInvocationError(value) {
223
240
  return error
224
241
  }
225
242
 
226
- function mayUseExactAdapterBridge(core, error, directProvider) {
227
- if (directProvider === undefined) return false
228
- try {
229
- const classification = core?.classifyVisionFailure?.(error)
230
- const kinds = core?.VISION_FAILURE_KINDS
231
- const allowed = new Set([
232
- kinds?.INVALID_REQUEST,
233
- kinds?.NETWORK,
234
- kinds?.OTHER,
235
- ].filter(Boolean))
236
- return classification && allowed.has(classification.kind)
237
- } catch {
238
- return false
239
- }
243
+ function mayUseExactAdapterBridge(_core, error, directProvider) {
244
+ if (directProvider === undefined || error?.visionAdapterProducedOutput === true) return false
245
+ // Share the runtime's exact pre-wire pi-ai admission predicate. Provider
246
+ // HTTP 400s, ambiguous image errors and network failures must never replay.
247
+ return isLocalImageCapabilityAdmissionFailure(error)
248
+ }
249
+
250
+ export function exactHostImageDeliveryPlan(info, candidate, core) {
251
+ const delivery = hostImageDeliveryFromInfo(info)
252
+ if (delivery !== 'text-projected') return { delivery, path: 'adapter' }
253
+ const verified = candidate?.evidenceScope === 'endpoint'
254
+ && candidate?.endpointConfig?.api === 'openai-completions'
255
+ && typeof candidate?.endpoint === 'string'
256
+ && typeof core?.isOpenAIHttpBridgeTransport === 'function'
257
+ && core.isOpenAIHttpBridgeTransport({ api: 'openai-completions', baseURL: candidate.endpoint })
258
+ return { delivery, path: verified ? 'http-direct' : 'refuse' }
240
259
  }
241
260
 
242
261
  function benchmarkMaxTokensForProvider(provider) {
@@ -295,6 +314,22 @@ export function createExactCapabilityInvoker(ctx, core, candidate, config, optio
295
314
  )
296
315
  const callSignal = mergedSignal(signal, AbortSignal.timeout(fixtureTimeoutMs))
297
316
  const exactHttpProvider = directProviderFor(candidate, config, core)
317
+ const hostInfo = candidate.provider === 'vision-http' ? undefined :
318
+ await resolvedHostInfo(ctx?.llm, { provider: candidate.provider, model: candidate.model, signal: callSignal })
319
+ const imagePlan = candidate.provider === 'vision-http'
320
+ ? { path: 'http-direct' }
321
+ : exactHostImageDeliveryPlan(hostInfo, candidate, core)
322
+ if (imagePlan.path === 'refuse') {
323
+ const error = new Error('Host declares this model text-only and removes image pixels before adapter dispatch; enable Image in DSH model input types, then retest')
324
+ error.code = 'VISION_IMAGE_DELIVERY_UNAVAILABLE'
325
+ error.benchmarkClass = 'host-configuration'
326
+ throw error
327
+ }
328
+ if (imagePlan.path === 'http-direct' && exactHttpProvider === undefined) {
329
+ const error = new Error('verified HTTP image bridge unavailable for this model')
330
+ error.code = 'CAPABILITY_BENCHMARK_PROTOCOL'
331
+ throw error
332
+ }
298
333
  const callExactHttp = async () => {
299
334
  if (exactHttpProvider === undefined) {
300
335
  const error = new Error('exact HTTP bridge is unavailable for this benchmark backend')
@@ -323,7 +358,7 @@ export function createExactCapabilityInvoker(ctx, core, candidate, config, optio
323
358
  let output
324
359
  let transport
325
360
  let latencyMs
326
- if (candidate.provider === 'vision-http') {
361
+ if (candidate.provider === 'vision-http' || imagePlan.path === 'http-direct') {
327
362
  const direct = await callExactHttp()
328
363
  output = direct.output
329
364
  latencyMs = direct.latencyMs
@@ -404,8 +439,11 @@ async function declaredImageState(ctx, core, config, candidate) {
404
439
  candidate.model,
405
440
  config?.extraVisionModels,
406
441
  )
407
- if (decision?.image === true) return 'declared'
408
- if (decision?.image === false) return 'text-only'
442
+ const hostMode = hostImageDeliveryFromInfo(info)
443
+ if (hostMode === 'native-image') return 'declared'
444
+ if (hostMode === 'text-projected') return 'text-only'
445
+ // Name-based guesses and user labels do not rewrite Host input metadata.
446
+ if (decision?.image === true) return 'unknown'
409
447
  } catch {
410
448
  // Metadata is advisory. Exact configured routes remain force-testable.
411
449
  }
@@ -80,6 +80,9 @@ export const VISION_EXACT_CHECK_CLIENT = String.raw`(function(){
80
80
  VISION_CHECK_BACKEND_STALE:[text('当前模型暂时无法直接调用,请确认供应商/模型仍可用后重试','This model is not currently callable; verify the provider/model and retry')],
81
81
  VISION_CHECK_TIMEOUT:[text('测试超时,已自动结束','Test timed out and was stopped')],
82
82
  VISION_CHECK_UNSUPPORTED_IMAGE:[text('实测仅文本 · 图片请求被模型拒绝','Measured text-only · the model rejected image input')],
83
+ VISION_IMAGE_DELIVERY_UNAVAILABLE:[text('Host未声明图片输入,请在DSH模型设置中启用Image再测','Host declares text-only input; enable Image in DSH Models settings and retest')],
84
+ VISION_CHECK_STALE_CONFIG:[text('测试期间模型配置已变化,请重新测试','Model input settings changed during the test; retest required')],
85
+ VISION_LOCAL_ONLY_POLICY:[text('本地识图模式下不允许测试非本地后端','Local-only vision policy blocks testing nonlocal backends')],
83
86
  VISION_CHECK_INFRASTRUCTURE:[text('识图测试组件暂不可用','Vision check infrastructure is unavailable')],
84
87
  CAPABILITY_BENCHMARK_INFRASTRUCTURE:[text('识图测试组件暂不可用','Vision check infrastructure is unavailable')],
85
88
  CAPABILITY_BENCHMARK_VISUAL_PROOF_FAILED:[text('模型没有正确读出测试图','The model did not correctly inspect the test image')]
@@ -91,9 +94,16 @@ export const VISION_EXACT_CHECK_CLIENT = String.raw`(function(){
91
94
  }
92
95
  function reset(control){
93
96
  if(!control)return;
97
+ delete control.dataset.lastResult;
94
98
  setStatus(control,text('快速自检 · 1次请求 · 只验证当前模型能否识图 · 不写入Auto能力分数','Quick check · 1 request · verifies image input only · does not write Auto capability scores'));
95
99
  setButton(control,text('测试识图','Test vision'),false);
96
100
  }
101
+ function makeRemoteNotice(row){
102
+ var note=document.createElement('div');note.setAttribute(CONTROL_ATTR,'remote');
103
+ note.style.cssText='flex:1 0 100%;font-size:12px;opacity:.7;margin-top:4px';
104
+ note.textContent=text('识图自检仅可在DSH本机界面执行;远程页面不发送测试请求。','Vision testing is local-only. Open the DSH UI on the Host to run the image check.');
105
+ row.appendChild(note);return note;
106
+ }
97
107
  function makeControl(row){
98
108
  if(!row||!row.style)return undefined;
99
109
  row.style.flexWrap='wrap';
@@ -131,15 +141,15 @@ export const VISION_EXACT_CHECK_CLIENT = String.raw`(function(){
131
141
  if(!currentRun(control,token,key))return;
132
142
  if(!response.ok||!body||body.ok!==true){
133
143
  var raw=String(body&&(body.error||body.code)||('HTTP '+response.status));
134
- setStatus(control,'✗ '+selection.provider+'/'+selection.model+' · '+failureMessage(body,response.status),raw);notifyChanged();return;
144
+ setStatus(control,'✗ '+selection.provider+'/'+selection.model+' · '+failureMessage(body,response.status),raw);control.dataset.lastResult=String(Date.now());notifyChanged();return;
135
145
  }
136
146
  var via=transport(body.transport),latency=seconds(body.latencyMs),output=short(body.output,72);
137
147
  var suffix=(via?' · '+via:'')+(latency?' · '+latency:'')+(output?' · '+output:'');
138
- setStatus(control,'✓ '+selection.provider+'/'+selection.model+text(' · 图片识别正常',' · image verified')+suffix);notifyChanged();
148
+ setStatus(control,'✓ '+selection.provider+'/'+selection.model+text(' · 图片识别正常',' · image verified')+suffix+' · '+new Date().toLocaleTimeString());control.dataset.lastResult=String(Date.now());notifyChanged();
139
149
  }catch(error){
140
150
  if(!currentRun(control,token,key))return;
141
151
  var message=error&&error.name==='AbortError'?text('测试超时,已自动结束','Test timed out and was stopped'):text('测试失败','Vision check failed');
142
- setStatus(control,'✗ '+selection.provider+'/'+selection.model+' · '+message,String(error&&error.message||error));
152
+ setStatus(control,'✗ '+selection.provider+'/'+selection.model+' · '+message,String(error&&error.message||error));control.dataset.lastResult=String(Date.now());
143
153
  }finally{
144
154
  if(timer!==undefined)clearTimeout(timer);
145
155
  if(ticker!==undefined)clearInterval(ticker);
@@ -148,12 +158,13 @@ export const VISION_EXACT_CHECK_CLIENT = String.raw`(function(){
148
158
  }
149
159
  }
150
160
  function scan(){
151
- if(!localPage())return;
161
+ var local=localPage();
152
162
  var rows=[];try{rows=Array.prototype.slice.call(document.querySelectorAll(ROW_SELECTOR));}catch(_){rows=[];}
153
163
  rows.forEach(function(row){
154
164
  var selection=rowSelection(row),control=row.querySelector('['+CONTROL_ATTR+']');
155
165
  if(!complete(selection)){if(control){abortActive(control);if(control.remove)control.remove();}return;}
156
- if(!control)control=makeControl(row);
166
+ if(!control)control=local?makeControl(row):makeRemoteNotice(row);
167
+ if(!local)return;
157
168
  var key=selectionKey(selection);
158
169
  if(control&&control.dataset.selection!==key){abortActive(control);control.dataset.selection=key;reset(control);}
159
170
  });
@@ -167,8 +178,15 @@ export const VISION_EXACT_CHECK_CLIENT = String.raw`(function(){
167
178
  }catch(_){}
168
179
  return false;
169
180
  }
170
- function start(){
181
+ function invalidatePreviousResults(){
171
182
  if(!localPage())return;
183
+ var controls=document.querySelectorAll('['+CONTROL_ATTR+'][data-last-result]');
184
+ controls.forEach(function(control){abortActive(control);reset(control);
185
+ setStatus(control,text('配置可能已变化,请重新测试','Configuration may have changed; retest required'));
186
+ });
187
+ }
188
+ function start(){
189
+ window.addEventListener('focus',invalidatePreviousResults);
172
190
  document.addEventListener('change',function(event){var row=event&&event.target&&event.target.closest?event.target.closest(ROW_SELECTOR):undefined;if(row)schedule();},true);
173
191
  if(typeof MutationObserver==='function'&&document.documentElement){
174
192
  var observer=new MutationObserver(function(records){
@@ -5,7 +5,7 @@ import process from 'node:process'
5
5
  import { resolveDshHome } from './doctor.js'
6
6
  import { CAPABILITY_BENCHMARK_SUITE_REVISION } from './vision-capability-benchmark.js'
7
7
 
8
- const CACHE_VERSION = 1
8
+ const CACHE_VERSION = 2
9
9
  const MAX_ENTRIES = 128
10
10
 
11
11
  function cleanText(value, max = 256) {
@@ -14,7 +14,7 @@ function cleanText(value, max = 256) {
14
14
 
15
15
  function cleanVerdict(value) {
16
16
  if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
17
- if (value.state !== 'unsupported') return undefined
17
+ if (value.state !== 'unsupported' || value.hostMode !== 'native-image') return undefined
18
18
  if (Number(value.suiteRevision) !== CAPABILITY_BENCHMARK_SUITE_REVISION) return undefined
19
19
  const fingerprint = cleanText(value.fingerprint, 64)
20
20
  if (!/^ep2_[0-9a-f]{32}$/.test(fingerprint)) return undefined
@@ -29,6 +29,7 @@ function cleanVerdict(value) {
29
29
  provider,
30
30
  model,
31
31
  state: 'unsupported',
32
+ hostMode: 'native-image',
32
33
  reason: 'provider-rejected-image',
33
34
  measuredAt,
34
35
  suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
@@ -103,7 +104,7 @@ export function createImageInputVerdictStore(options = {}) {
103
104
  await ready
104
105
  return records.get(String(fingerprint ?? ''))
105
106
  },
106
- async markUnsupported({ fingerprint, key, provider, model, measuredAt = Date.now() } = {}) {
107
+ async markUnsupported({ fingerprint, key, provider, model, hostMode, measuredAt = Date.now() } = {}) {
107
108
  await ready
108
109
  const clean = cleanVerdict({
109
110
  fingerprint,
@@ -111,6 +112,7 @@ export function createImageInputVerdictStore(options = {}) {
111
112
  provider,
112
113
  model,
113
114
  state: 'unsupported',
115
+ hostMode,
114
116
  measuredAt,
115
117
  suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
116
118
  })
@@ -345,7 +345,15 @@ const CLIENT_PRELUDE = String.raw`(function(){
345
345
  })();`
346
346
 
347
347
  function replaceAll(source, before, after) {
348
- return source.includes(before) ? source.split(before).join(after) : source
348
+ let result = source.includes(before) ? source.split(before).join(after) : source
349
+ // Windows git checkouts can put CRLF inside injected client-source template
350
+ // literals. Match only the same owned seam with CRLF; do not normalize or
351
+ // rewrite the surrounding HTML or any unrelated plugin's source.
352
+ if (before.includes('\n')) {
353
+ const windowsBefore = before.replaceAll('\n', '\r\n')
354
+ if (result.includes(windowsBefore)) result = result.split(windowsBefore).join(after)
355
+ }
356
+ return result
349
357
  }
350
358
 
351
359
  export function hardenVisionClientSource(source) {
@@ -403,7 +411,7 @@ export function hardenVisionClientSource(source) {
403
411
  next = replaceAll(
404
412
  next,
405
413
  ` var message = latest && typeof latest.error === 'string' && latest.error !== ''\n ? latest.error\n : t('failedUnknown');`,
406
- ` var recovered = recovery && typeof recovery.getSnapshot === 'function' ? recovery.getSnapshot() : recoveryError;\n var message = latest && typeof latest.error === 'string' && latest.error !== ''\n ? latest.error\n : recovered && recovered.message ? recovered.message : t('failedUnknown');`,
414
+ ` var recoveryStore = typeof recovery === 'undefined' ? null : recovery;\n if (!recoveryStore && typeof window !== 'undefined') {\n var hardening = window.__dshVisionRouterRootHardening;\n if (hardening && typeof hardening.recoveryFor === 'function') recoveryStore = hardening.recoveryFor(directory);\n }\n var recovered = recoveryStore && typeof recoveryStore.getSnapshot === 'function' ? recoveryStore.getSnapshot() : null;\n var message = latest && typeof latest.error === 'string' && latest.error !== ''\n ? latest.error\n : recovered && recovered.message ? recovered.message : t('failedUnknown');`,
407
415
  )
408
416
 
409
417
  next = replaceAll(
@@ -431,10 +439,19 @@ export function hardenVisionClientSource(source) {
431
439
  )
432
440
 
433
441
  next = replaceAll(next, `function wrapStore(store, settings) {`, `function wrapStore(store, settings, recovery) {`)
442
+ // A synthetic recovery snapshot must retain referential identity until
443
+ // its Host source snapshot or recovery error actually changes. Otherwise
444
+ // React's useSyncExternalStore sees a new snapshot on every read and can
445
+ // enter a maximum-update-depth loop after a failed select + catalog refresh.
446
+ next = replaceAll(
447
+ next,
448
+ ` var lastProjected;\n function getSnapshot() {`,
449
+ ` var lastProjected;\n var lastRecoverySource;\n var lastRecoveryError;\n var lastRecoveryRaw;\n function getSnapshot() {`,
450
+ )
434
451
  next = replaceAll(
435
452
  next,
436
453
  ` var raw = typeof store.getSnapshot === 'function' ? store.getSnapshot() : undefined;\n var config = currentVisionConfig(settings);`,
437
- ` var raw = typeof store.getSnapshot === 'function' ? store.getSnapshot() : undefined;\n var recovered = recovery && typeof recovery.getSnapshot === 'function' ? recovery.getSnapshot() : null;\n if (recovered && raw && (raw.status === 'selecting' || raw.status === 'loading')) {\n raw = Object.assign({}, raw, { status: 'error', error: recovered.message || String(recovered) });\n }\n var config = currentVisionConfig(settings);`,
454
+ ` var raw = typeof store.getSnapshot === 'function' ? store.getSnapshot() : undefined;\n var recovered = recovery && typeof recovery.getSnapshot === 'function' ? recovery.getSnapshot() : null;\n if (recovered && raw && (raw.status === 'selecting' || raw.status === 'loading')) {\n if (raw !== lastRecoverySource || recovered !== lastRecoveryError) {\n lastRecoverySource = raw;\n lastRecoveryError = recovered;\n lastRecoveryRaw = Object.assign({}, raw, { status: 'error', error: recovered.message || String(recovered) });\n }\n raw = lastRecoveryRaw;\n }\n var config = currentVisionConfig(settings);`,
438
455
  )
439
456
  next = replaceAll(
440
457
  next,
@@ -126,17 +126,42 @@ function isLocalHostName(value) {
126
126
  return host === 'localhost' || host.endsWith('.localhost') || isLoopbackAddress(host)
127
127
  }
128
128
 
129
+ // Forwarding metadata can only *deny* a local-machine capability. Never
130
+ // interpret X-Forwarded-For or similar caller-controlled headers as proof
131
+ // that a request really originated at the local browser.
132
+ const PROXY_PROVENANCE_HEADERS = new Set([
133
+ 'forwarded',
134
+ 'x-forwarded-for',
135
+ 'x-forwarded-host',
136
+ 'x-forwarded-proto',
137
+ 'x-real-ip',
138
+ 'x-client-ip',
139
+ 'true-client-ip',
140
+ 'cf-connecting-ip',
141
+ 'via',
142
+ ])
143
+
144
+ function hasProxyProvenance(req) {
145
+ const headers = req?.headers
146
+ return !!headers && typeof headers === 'object' &&
147
+ Object.keys(headers).some((name) => PROXY_PROVENANCE_HEADERS.has(name.toLowerCase()))
148
+ }
149
+
129
150
  /**
130
151
  * Browser-facing local capability check. A real network request needs BOTH a
131
- * loopback TCP peer and a loopback/localhost Host header. This prevents the
132
- * common reverse-proxy shape (proxy -> 127.0.0.1, external Host preserved)
133
- * from silently acquiring local-machine capabilities. Internal direct handler
134
- * calls have no transport and remain compatible for tests/host composition.
152
+ * loopback TCP peer and a loopback/localhost Host header, with no forwarding
153
+ * metadata. This protects supported proxy deployments preserving external Host
154
+ * and fails closed when a proxy rewrites Host but keeps standard hop markers.
155
+ * A proxy that erases every originating fact is indistinguishable from a
156
+ * genuine loopback browser request; these headers are not an attestation.
157
+ * Internal direct handler calls have no socket and remain supported.
135
158
  */
136
159
  export function isLocalUiRequest(req) {
137
160
  const transport = req?.socket ?? req?.connection
138
161
  if (!transport) return true
139
- return isLoopbackAddress(transport.remoteAddress) && isLocalHostName(requestHostName(req))
162
+ return isLoopbackAddress(transport.remoteAddress) &&
163
+ isLocalHostName(requestHostName(req)) &&
164
+ !hasProxyProvenance(req)
140
165
  }
141
166
 
142
167
  export function isLocalMutationRoute(path, method) {
@@ -204,40 +229,148 @@ function redactRemoteBody(path, body) {
204
229
  return body
205
230
  }
206
231
 
207
- /** Intercept one JSON end() so local capability material is never issued remotely. */
232
+ // Remote metadata is small. Never buffer an unbounded response from a future
233
+ // handler revision: local-only diagnostics should not turn into remote memory DoS.
234
+ const MAX_REMOTE_REDACTION_BYTES = 1024 * 1024
235
+
236
+ /**
237
+ * A remote response requiring redaction must be completely validated *before*
238
+ * emitting headers or bytes. Unlike intercepting end() alone, this also covers
239
+ * write()+end(), malformed JSON, oversize bodies and async stream completion.
240
+ * No handler may transmit its raw token, path or other local fields while the
241
+ * remote body remains unvalidated.
242
+ */
208
243
  function runWithRemoteReadRedaction(path, handler, req, res) {
209
244
  const originalEnd = res?.end
210
- if (typeof originalEnd !== 'function') return handler(req, res)
211
- let active = true
212
- res.end = function redactedEnd(chunk, ...args) {
213
- if (!active) return originalEnd.call(this, chunk, ...args)
214
- let next = chunk
245
+ const originalWrite = res?.write
246
+ const originalWriteHead = res?.writeHead
247
+ const originalFlushHeaders = res?.flushHeaders
248
+ if (typeof originalEnd !== 'function' || typeof originalWriteHead !== 'function') {
249
+ throw new TypeError('vision-router: remote redaction requires a buffered HTTP response')
250
+ }
251
+
252
+ const chunks = []
253
+ let bytes = 0
254
+ let done = false
255
+ let head
256
+ const restore = () => {
257
+ if (res.end === redactedEnd) res.end = originalEnd
258
+ if (res.write === redactedWrite) {
259
+ if (originalWrite === undefined) delete res.write
260
+ else res.write = originalWrite
261
+ }
262
+ if (res.writeHead === redactedWriteHead) res.writeHead = originalWriteHead
263
+ if (res.flushHeaders === redactedFlushHeaders) {
264
+ if (originalFlushHeaders === undefined) delete res.flushHeaders
265
+ else res.flushHeaders = originalFlushHeaders
266
+ }
267
+ }
268
+ const stripStagedHeaders = () => {
269
+ // Route handlers own the pre-redaction response. Even header values may
270
+ // contain future local-only tokens, paths or Set-Cookie material.
271
+ // Never forward any unreviewed staged header to a remote client.
215
272
  try {
216
- if (typeof chunk === 'string' || Buffer.isBuffer(chunk) || chunk instanceof Uint8Array) {
217
- const parsed = JSON.parse(Buffer.isBuffer(chunk) || chunk instanceof Uint8Array ? Buffer.from(chunk).toString('utf8') : chunk)
218
- next = JSON.stringify(redactRemoteBody(path, parsed))
219
- try { this.removeHeader?.('content-length') } catch { /* best effort */ }
220
- }
221
- } catch {
222
- // If a future handler stops returning JSON, do not corrupt its response.
273
+ for (const key of res.getHeaderNames?.() ?? []) res.removeHeader?.(key)
274
+ } catch { /* a test double may not expose the header table */ }
275
+ for (const key of ['content-length', 'content-encoding', 'transfer-encoding']) {
276
+ try { res.removeHeader?.(key) } catch {}
223
277
  }
224
- return originalEnd.call(this, next, ...args)
225
278
  }
226
- const redactedEndReference = res.end
227
- const restore = () => {
228
- active = false
229
- if (res.end === redactedEndReference) res.end = originalEnd
279
+ const safeHeaders = () => ({
280
+ 'content-type': 'application/json; charset=utf-8',
281
+ 'cache-control': 'no-store',
282
+ })
283
+ const complete = (body, rejected = false, callback) => {
284
+ if (done) return res
285
+ done = true
286
+ // On rejection, deliberately retain the buffered wrappers after sending
287
+ // the safe error. A drifting async handler may continue calling write()
288
+ // or end(); restoring the raw methods here would expose those later bytes.
289
+ // The response is already ended and these wrappers swallow late writes.
290
+ if (!rejected) restore()
291
+ stripStagedHeaders()
292
+ if (rejected) {
293
+ // An invalid or oversized response is an internal gateway failure. Do
294
+ // not repeat the untrusted data or send previously staged headers.
295
+ originalWriteHead.call(res, 502, safeHeaders())
296
+ return originalEnd.call(res, JSON.stringify({
297
+ ok: false,
298
+ error: 'remote response could not be safely redacted',
299
+ }), callback)
300
+ }
301
+ if (head) {
302
+ originalWriteHead.call(res, head.status, safeHeaders())
303
+ } else {
304
+ originalWriteHead.call(res, Number.isInteger(res.statusCode) ? res.statusCode : 200, safeHeaders())
305
+ }
306
+ return originalEnd.call(res, body, callback)
230
307
  }
231
- let result
308
+ const append = (chunk, encoding) => {
309
+ if (chunk === undefined || chunk === null) return true
310
+ let value
311
+ try {
312
+ if (Buffer.isBuffer(chunk)) value = chunk
313
+ else if (typeof chunk === 'string') value = Buffer.from(chunk, typeof encoding === 'string' ? encoding : 'utf8')
314
+ else if (chunk instanceof Uint8Array) value = Buffer.from(chunk)
315
+ else return false
316
+ } catch { return false }
317
+ if (value.length > MAX_REMOTE_REDACTION_BYTES - bytes) return false
318
+ bytes += value.length
319
+ chunks.push(value)
320
+ return true
321
+ }
322
+ const failed = () => complete(undefined, true)
323
+ function redactedWriteHead(status, maybeMessage, maybeHeaders) {
324
+ if (done) return this
325
+ const message = typeof maybeMessage === 'string' ? maybeMessage : undefined
326
+ const headers = message === undefined ? maybeMessage : maybeHeaders
327
+ if (head || !Number.isInteger(status) || status < 100 || status > 599 ||
328
+ (headers !== undefined && (!headers || typeof headers !== 'object' || Array.isArray(headers)))) {
329
+ failed()
330
+ return this
331
+ }
332
+ head = { status, message, headers }
333
+ return this
334
+ }
335
+ function redactedFlushHeaders() {
336
+ // Deferring the header is essential: once sent, an invalid JSON body
337
+ // cannot be replaced by a safe 502.
338
+ }
339
+ function redactedWrite(chunk, encoding, callback) {
340
+ const completeWrite = typeof encoding === 'function' ? encoding : callback
341
+ if (!done && !append(chunk, encoding)) failed()
342
+ if (typeof completeWrite === 'function') queueMicrotask(completeWrite)
343
+ return !done
344
+ }
345
+ function redactedEnd(chunk, encoding, callback) {
346
+ if (done) return res
347
+ const completeEnd = typeof encoding === 'function' ? encoding : callback
348
+ if (!append(chunk, encoding)) return failed()
349
+ try {
350
+ const body = JSON.parse(Buffer.concat(chunks, bytes).toString('utf8'))
351
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return failed()
352
+ const redacted = JSON.stringify(redactRemoteBody(path, body))
353
+ if (Buffer.byteLength(redacted, 'utf8') > MAX_REMOTE_REDACTION_BYTES) return failed()
354
+ return complete(redacted, false, completeEnd)
355
+ } catch {
356
+ return failed()
357
+ }
358
+ }
359
+
360
+ res.writeHead = redactedWriteHead
361
+ res.write = redactedWrite
362
+ res.end = redactedEnd
363
+ res.flushHeaders = redactedFlushHeaders
232
364
  try {
233
- result = handler(req, res)
234
- } catch (error) {
235
- restore()
236
- throw error
365
+ const result = handler(req, res)
366
+ if (result && typeof result.then === 'function') {
367
+ return Promise.resolve(result).catch(() => { if (!done) failed() })
368
+ }
369
+ return result
370
+ } catch {
371
+ if (!done) failed()
372
+ return undefined
237
373
  }
238
- if (result && typeof result.then === 'function') return result.finally(restore)
239
- restore()
240
- return result
241
374
  }
242
375
 
243
376
  function guardedRoute(route, getConnection) {