dsh-vision-router 3.0.1 → 3.0.2

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,43 +10,32 @@ import { installVisionModelVisibilityBoundary } from './vision-model-visibility-
10
10
 
11
11
  const CLIENT_PRESENTATION_MARK = 'data-vision-router-presentation-boundary'
12
12
 
13
- const VISION_TOGGLE_TEXT_GLYPH = String.raw` // One fixed 14px leading slot carries the state icon: the eye when the
14
- // route is ordinary, the check when Vision is on. Appending the check
15
- // only while active changed the chip width by ~18px and reflowed the
16
- // composer row; reserving trailing space kept the width but left the
17
- // inactive chip visually off-centre.
13
+ const VISION_TOGGLE_TEXT_GLYPH = String.raw` // One fixed 14px leading slot carries both glyphs: the Vision eye and the check that
14
+ // marks the enabled state. The stylesheet shows the check only while the label is
15
+ // present (the same 460px container cut that collapses the label), because a bare check
16
+ // tells a reader nothing — once the label is gone the eye, which still says what the
17
+ // chip does, stays. The slot is a fixed 14px box either way, so the chip width never
18
+ // changes with the state.
18
19
  React.createElement('span', {
19
20
  'aria-hidden': 'true',
20
- style: {
21
- display: 'inline-flex',
22
- alignItems: 'center',
23
- justifyContent: 'center',
24
- flex: '0 0 auto',
25
- width: 14,
26
- height: 14,
27
- fontSize: 13,
28
- lineHeight: 1
29
- }
30
- }, active ? '✓' : '👁'),
31
- React.createElement('span', null, t('label'))`
32
-
33
- const VISION_TOGGLE_FIXED_SVG = String.raw` // One fixed 14px leading slot carries the state icon: the eye when the
34
- // route is ordinary, the check when Vision is on. Appending the check
35
- // only while active changed the chip width by ~18px and reflowed the
36
- // composer row; reserving trailing space kept the width but left the
37
- // inactive chip visually off-centre.
21
+ className: 'vr-vision-toggle-glyph'
22
+ },
23
+ React.createElement('span', { className: 'vr-vision-toggle-glyph-part vr-vision-toggle-glyph-eye' }, '👁'),
24
+ React.createElement('span', { className: 'vr-vision-toggle-glyph-part vr-vision-toggle-glyph-check' }, '✓')),
25
+ React.createElement('span', { className: 'vr-vision-toggle-label' }, t('label'))`
26
+
27
+ const VISION_TOGGLE_FIXED_SVG = String.raw` // One fixed 14px leading slot carries both glyphs: the Vision eye and the check that
28
+ // marks the enabled state. The stylesheet shows the check only while the label is
29
+ // present (the same 460px container cut that collapses the label), because a bare check
30
+ // tells a reader nothing — once the label is gone the eye, which still says what the
31
+ // chip does, stays. The slot is a fixed 14px box either way, so the chip width never
32
+ // changes with the state.
38
33
  React.createElement('span', {
39
34
  'aria-hidden': 'true',
40
- style: {
41
- display: 'inline-flex',
42
- alignItems: 'center',
43
- justifyContent: 'center',
44
- flex: '0 0 auto',
45
- width: 14,
46
- height: 14
47
- }
48
- }, active
49
- ? React.createElement('svg', {
35
+ className: 'vr-vision-toggle-glyph'
36
+ },
37
+ React.createElement('span', { className: 'vr-vision-toggle-glyph-part vr-vision-toggle-glyph-eye' },
38
+ React.createElement('svg', {
50
39
  width: 14,
51
40
  height: 14,
52
41
  viewBox: '0 0 14 14',
@@ -55,13 +44,13 @@ const VISION_TOGGLE_FIXED_SVG = String.raw` // One fixed 14px leading s
55
44
  focusable: 'false',
56
45
  style: { display: 'block', flex: '0 0 auto' }
57
46
  }, React.createElement('path', {
58
- d: 'M2.75 7.15 5.6 10 11.25 4.35',
59
- stroke: 'currentColor',
60
- strokeWidth: 1.5,
61
- strokeLinecap: 'round',
62
- strokeLinejoin: 'round'
63
- }))
64
- : React.createElement('svg', {
47
+ fillRule: 'evenodd',
48
+ clipRule: 'evenodd',
49
+ d: 'M7 2.25c-2.84 0-5.04 1.69-6.25 4.25a1.15 1.15 0 0 0 0 1C1.96 10.06 4.16 11.75 7 11.75s5.04-1.69 6.25-4.25a1.15 1.15 0 0 0 0-1C12.04 3.94 9.84 2.25 7 2.25Zm0 1.25c2.16 0 3.96 1.21 5.05 3.5C10.96 9.29 9.16 10.5 7 10.5S3.04 9.29 1.95 7C3.04 4.71 4.84 3.5 7 3.5Zm0 1.25A2.25 2.25 0 1 0 7 9.25a2.25 2.25 0 0 0 0-4.5Zm0 1.25a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z',
50
+ fill: 'currentColor'
51
+ }))),
52
+ React.createElement('span', { className: 'vr-vision-toggle-glyph-part vr-vision-toggle-glyph-check' },
53
+ React.createElement('svg', {
65
54
  width: 14,
66
55
  height: 14,
67
56
  viewBox: '0 0 14 14',
@@ -70,12 +59,13 @@ const VISION_TOGGLE_FIXED_SVG = String.raw` // One fixed 14px leading s
70
59
  focusable: 'false',
71
60
  style: { display: 'block', flex: '0 0 auto' }
72
61
  }, React.createElement('path', {
73
- fillRule: 'evenodd',
74
- clipRule: 'evenodd',
75
- d: 'M7 2.25c-2.84 0-5.04 1.69-6.25 4.25a1.15 1.15 0 0 0 0 1C1.96 10.06 4.16 11.75 7 11.75s5.04-1.69 6.25-4.25a1.15 1.15 0 0 0 0-1C12.04 3.94 9.84 2.25 7 2.25Zm0 1.25c2.16 0 3.96 1.21 5.05 3.5C10.96 9.29 9.16 10.5 7 10.5S3.04 9.29 1.95 7C3.04 4.71 4.84 3.5 7 3.5Zm0 1.25A2.25 2.25 0 1 0 7 9.25a2.25 2.25 0 0 0 0-4.5Zm0 1.25a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z',
76
- fill: 'currentColor'
77
- }))),
78
- React.createElement('span', null, t('label'))`
62
+ d: 'M2.75 7.15 5.6 10 11.25 4.35',
63
+ stroke: 'currentColor',
64
+ strokeWidth: 1.5,
65
+ strokeLinecap: 'round',
66
+ strokeLinejoin: 'round'
67
+ })))),
68
+ React.createElement('span', { className: 'vr-vision-toggle-label' }, t('label'))`
79
69
 
80
70
  const BITMAP_LIMITS_BASE = String.raw` var maxBitmapSourceBytes = 64 * 1024 * 1024;
81
71
  var maxBitmapDimension = 10000;
package/lib/client.js CHANGED
@@ -4770,6 +4770,7 @@ openLogFolder,
4770
4770
  const imageKeySignature = JSON.stringify(imageKeys)
4771
4771
  const imageOwnerToken = React.useRef({}).current
4772
4772
  const ownedImageKeys = React.useRef(new Set())
4773
+ React.useEffect(() => { if (globalThis.__dvrPresentFocus) globalThis.__dvrPresentFocus() }, [imageKeySignature])
4773
4774
  React.useEffect(() => {
4774
4775
  const current = new Set(imageKeys)
4775
4776
  for (const key of ownedImageKeys.current) {
@@ -4809,7 +4810,7 @@ openLogFolder,
4809
4810
  }
4810
4811
  return React.createElement(
4811
4812
  'div',
4812
- { style: { display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 6, padding: '4px 0' } },
4813
+ { 'data-dvr-present': 'true', style: { display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 6, padding: '4px 0' } },
4813
4814
  parsed && typeof parsed.label === 'string' && parsed.label !== 'image'
4814
4815
  ? React.createElement(
4815
4816
  'div',
@@ -161,6 +161,18 @@ export function eventHasImage(event) {
161
161
  return false
162
162
  }
163
163
 
164
+ /**
165
+ * Read one options/argument bag as a record, or `undefined` when the value is not
166
+ * an object at all. Callers write `objectRecord(options) ?? {}`: an explicit
167
+ * `null` (or a primitive) then means "no options" instead of throwing from a
168
+ * parameter destructure or a property read.
169
+ */
170
+ export function objectRecord(value) {
171
+ return value !== null && typeof value === 'object'
172
+ ? value
173
+ : undefined
174
+ }
175
+
164
176
  export function localOnlyVisionEnabled(config = {}) {
165
177
  return !!config && config.localOnlyVision === true
166
178
  }
@@ -324,6 +336,11 @@ export function rewriteToolResultImages(content, replace) {
324
336
 
325
337
  export function renderVisionPresent(value) {
326
338
  const attachment = value.attachment
339
+ // The card renders the image next to the tool call, which can sit far above a reader who is at
340
+ // the end of a long conversation. The reply body is where that reader actually is, and the
341
+ // client rewrites an absolute local image path written in Markdown into a displayable URL, so
342
+ // the result hands back a paste-ready snippet plus the rule that says why it matters.
343
+ const label = String(value.label === undefined ? 'image' : value.label).replace(/[[\]]/g, '')
327
344
  return [
328
345
  {
329
346
  type: 'text',
@@ -335,6 +352,10 @@ export function renderVisionPresent(value) {
335
352
  bytes: value.bytes,
336
353
  safePresentation: true,
337
354
  attachmentId: String(attachment.attachmentId),
355
+ replyImage: `![${label}](<${value.path}>)`,
356
+ replyImageHint:
357
+ 'Also put replyImage in your visible reply: the host renders an absolute image path in a message ' +
358
+ 'at full size where the reader is, while this card only shows a thumbnail that later messages push away.',
338
359
  }),
339
360
  },
340
361
  { type: 'image', attachment },
@@ -1,4 +1,5 @@
1
1
  import { currentSessionSurfacePolicy } from './session-surface-policy.js'
2
+ import { objectRecord } from './core-primitives.js'
2
3
  import {
3
4
  CORE_VISION_SURFACE_KEYS,
4
5
  projectCoreVisionSurface,
@@ -32,7 +33,8 @@ export function currentCoreVisionSurface(config = {}, options = {}) {
32
33
  * The config source stays live, but schema bootstrap is an internal lifecycle
33
34
  * bit rather than a fake Settings/config value.
34
35
  */
35
- export function createCoreVisionSurfaceRuntime({ config = {} } = {}) {
36
+ export function createCoreVisionSurfaceRuntime(rawOptions) {
37
+ const { config = {} } = objectRecord(rawOptions) ?? {}
36
38
  let schemaBootstrapping = true
37
39
 
38
40
  const current = () => {
@@ -9,6 +9,7 @@ import {
9
9
  callLocalBackend,
10
10
  localDescribePrompt,
11
11
  localProvidersOf,
12
+ objectRecord,
12
13
  toOpenAIContent,
13
14
  } from './core-primitives.js'
14
15
  import { loadSharp } from './sharp-runtime.js'
@@ -170,15 +171,16 @@ async function identifyDesktopCapture(data, {
170
171
  * this module owns only the platform capture and optional local-recognition
171
172
  * implementation behind that existing permission boundary.
172
173
  */
173
- export function createDesktopScreenshotTool({
174
- current,
175
- timeoutMs,
176
- saveArtifact,
177
- stringOutput,
178
- instantLocalStyle,
179
- providerTransport,
180
- captureDesktop = captureDesktopPng,
181
- } = {}) {
174
+ export function createDesktopScreenshotTool(rawOptions) {
175
+ const {
176
+ current,
177
+ timeoutMs,
178
+ saveArtifact,
179
+ stringOutput,
180
+ instantLocalStyle,
181
+ providerTransport,
182
+ captureDesktop = captureDesktopPng,
183
+ } = objectRecord(rawOptions) ?? {}
182
184
  if (
183
185
  typeof current !== 'function' ||
184
186
  typeof timeoutMs !== 'function' ||
@@ -1,26 +1,75 @@
1
1
  const SENSITIVE_QUERY = /^(?:api[_-]?key|key|token|secret|password|authorization|auth|signature|sig)$/i
2
2
 
3
+ /**
4
+ * Credential shapes worth removing from anything that reaches a log, a support
5
+ * report or a model-visible diagnostic.
6
+ *
7
+ * Two callers used to keep their own partial copy of this list, so each one
8
+ * leaked what the other covered (`Authorization: token ghp_…`, `Cookie:`, bare
9
+ * platform tokens, `#token=` fragments). One shared list is the fix: callers may
10
+ * still add their own extra passes, but never their own subset of this one.
11
+ */
12
+ // One spelling for every caller: the log path's own test pins the uppercase
13
+ // marker (`/\[REDACTED/`), the diagnostic path's test is case-insensitive, so
14
+ // uppercase is the only spelling that keeps both existing contracts intact.
15
+ const REDACTED = '[REDACTED]'
16
+ const CREDENTIAL_SHAPE_PATTERNS = [
17
+ // Any Authorization scheme, not only Bearer/Basic (`token`, `ApiKey`, `AWS4-…`),
18
+ // but only the credential itself: a single line may carry safe diagnostics
19
+ // after the header and a support report must keep them. `;` stays inside the value
20
+ // (the log sink has always consumed it) while `,` separates what follows.
21
+ [/\b(Authorization\s*[:=]\s*)(?:[A-Za-z][A-Za-z0-9._-]*\s+)?[^\s,]+/gi, `$1${REDACTED}`],
22
+ [/\b(Proxy-Authorization\s*[:=]\s*)(?:[A-Za-z][A-Za-z0-9._-]*\s+)?[^\s,]+/gi, `$1${REDACTED}`],
23
+ // Cookies are `name=value; name=value` on one line and there is no safe way to
24
+ // guess which pair carries the session, so the whole header value goes.
25
+ [/\b((?:Set-)?Cookie\s*[:=]\s*)[^\r\n]+/gi, `$1${REDACTED}`],
26
+ // URL userinfo (`scheme://user:secret@host`) without needing a URL parser, so
27
+ // the log path — which never parsed URLs — covers it too.
28
+ [/\b([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, `$1${REDACTED}@`],
29
+ [/\b(Bearer\s+)[A-Za-z0-9._~+/-]{8,}/gi, `$1${REDACTED}`],
30
+ // Vendor-prefixed keys: OpenAI/Anthropic, GitHub, AWS, Slack, Google, PEM.
31
+ [/\b(sk-(?:proj-|ant-|or-)?)[A-Za-z0-9_-]{8,}/gi, `$1${REDACTED}`],
32
+ [/\b(gh[pousr]_|github_pat_)[A-Za-z0-9_]{8,}/gi, `$1${REDACTED}`],
33
+ [/\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, `${REDACTED}`],
34
+ [/\b(xox[baprs]-)[A-Za-z0-9-]{8,}/gi, `$1${REDACTED}`],
35
+ [/\b(AIza)[A-Za-z0-9_-]{16,}/g, `$1${REDACTED}`],
36
+ // Signed JSON Web Tokens, with or without a leading scheme word.
37
+ [/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, REDACTED],
38
+ // An unterminated block (a bounded upstream read truncates before the END marker) must
39
+ // redact to the end of the text: without the `$` alternative its whole body passes through.
40
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, REDACTED],
41
+ // `api_key=…`-style assignments, including `apikey` and `access_token`. `;` stays inside the
42
+ // value for the same reason as above; `&` separates an unrelated parameter a report must keep.
43
+ [/\b(access[_-]?token|api[_-]?key|apikey|token|secret|password|passwd|signature|sig)\s*[:=]\s*([^\s,&"']+)/gi, `$1=${REDACTED}`],
44
+ ]
45
+
46
+ /** Apply only the shared credential-shape passes (no URL parsing, no truncation). */
47
+ export function redactCredentialShapes(text) {
48
+ let out = String(text ?? '')
49
+ for (const [pattern, replacement] of CREDENTIAL_SHAPE_PATTERNS) {
50
+ out = out.replace(pattern, replacement)
51
+ }
52
+ return out
53
+ }
54
+
3
55
  function redactUrl(match) {
4
56
  try {
5
57
  const url = new URL(match)
6
- url.username = url.username ? '[redacted]' : ''
7
- url.password = url.password ? '[redacted]' : ''
58
+ url.username = url.username ? REDACTED : ''
59
+ url.password = url.password ? REDACTED : ''
8
60
  for (const key of [...url.searchParams.keys()]) {
9
- if (SENSITIVE_QUERY.test(key)) url.searchParams.set(key, '[redacted]')
61
+ if (SENSITIVE_QUERY.test(key)) url.searchParams.set(key, REDACTED)
10
62
  }
11
- return url.toString()
63
+ return redactCredentialShapes(url.toString())
12
64
  } catch {
13
- return match
65
+ return redactCredentialShapes(match)
14
66
  }
15
67
  }
16
68
 
17
69
  export function redactDiagnosticText(value, max = 400) {
18
70
  const limit = Math.max(32, Math.min(4000, Math.floor(Number(max) || 400)))
19
- let text = String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ')
20
- text = text.replace(/\bhttps?:\/\/[^\s"'<>]+/gi, redactUrl)
21
- text = text.replace(/\b(Authorization\s*:\s*)(?:Bearer|Basic)\s+[^\s,;]+/gi, '$1[redacted]')
22
- text = text.replace(/\b(Bearer\s+)[A-Za-z0-9._~+\/-]{12,}/gi, '$1[redacted]')
23
- text = text.replace(/\b(sk-(?:proj-)?)[A-Za-z0-9_-]{8,}/gi, '$1[redacted]')
24
- text = text.replace(/\b(api[_-]?key|token|secret|password|authorization|signature)\s*[:=]\s*([^\s,;&]+)/gi, '$1=[redacted]')
25
- return text.slice(0, limit)
71
+ const text = String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ')
72
+ return redactCredentialShapes(
73
+ text.replace(/\bhttps?:\/\/[^\s"'<>]+/gi, redactUrl),
74
+ ).slice(0, limit)
26
75
  }
@@ -8,6 +8,7 @@ import {
8
8
  normalizeDshHostCapabilities,
9
9
  } from './dsh-host-capabilities.js'
10
10
  import { DOCTOR_RESPONSE_MAX_BYTES, readResponseJsonBounded } from './http-body-limit.js'
11
+ import { redactDiagnosticText } from './diagnostic-redaction.js'
11
12
  import {
12
13
  DSH_SUPPORT_WINDOW,
13
14
  DSH_VERIFICATION_EVIDENCE,
@@ -16,6 +17,10 @@ import {
16
17
  } from './dsh-support-window.js'
17
18
 
18
19
  const HOST_CAPABILITIES_PATH = '/_dsh/vision-router/host-capabilities'
20
+ const DOCTOR_PROBE_TIMEOUT_MS = 1500
21
+ // The fence is a backstop, not a competing deadline: it must sit after the signal
22
+ // so a cooperative fetch keeps reporting the abort reason it reports today.
23
+ const DOCTOR_PROBE_FENCE_MS = DOCTOR_PROBE_TIMEOUT_MS + 250
19
24
 
20
25
  function commandOf(argv) {
21
26
  const first = argv.find((value) => typeof value === 'string' && !value.startsWith('-'))
@@ -51,11 +56,34 @@ export async function probeDoctorHostCapabilities({ baseUrl, fetchImpl = globalT
51
56
  if (typeof fetchImpl !== 'function') {
52
57
  return { ok: false, source: 'runtime-unavailable', capabilities: unknownSnapshot() }
53
58
  }
54
- const response = await fetchImpl(normalized, {
55
- method: 'GET',
56
- headers: { accept: 'application/json' },
57
- signal: AbortSignal.timeout(1500),
59
+ // The signal below only bounds a fetch that honours it. A non-cooperative
60
+ // implementation would leave this probe pending forever, so the same inline
61
+ // fence the live-model discovery uses (its own timer + explicit cleanup)
62
+ // bounds the await itself. The signal stays so a cooperative fetch still
63
+ // reports the aborted reason it reports today.
64
+ // The fence deliberately keeps the event loop alive: this is a foreground
65
+ // probe the CLI is waiting on, unlike the background benchmark timers that
66
+ // unref themselves. (Node's own AbortSignal.timeout timer is unref'd, so
67
+ // unref'ing this one too would let the process exit before it fires.)
68
+ let deadlineTimer
69
+ const deadline = new Promise((_, reject) => {
70
+ deadlineTimer = setTimeout(() => {
71
+ reject(new Error(`Doctor Host capability probe exceeded ${DOCTOR_PROBE_TIMEOUT_MS}ms`))
72
+ }, DOCTOR_PROBE_FENCE_MS)
58
73
  })
74
+ let response
75
+ try {
76
+ response = await Promise.race([
77
+ fetchImpl(normalized, {
78
+ method: 'GET',
79
+ headers: { accept: 'application/json' },
80
+ signal: AbortSignal.timeout(DOCTOR_PROBE_TIMEOUT_MS),
81
+ }),
82
+ deadline,
83
+ ])
84
+ } finally {
85
+ clearTimeout(deadlineTimer)
86
+ }
59
87
  if (!response.ok) {
60
88
  return {
61
89
  ok: false,
@@ -73,10 +101,13 @@ export async function probeDoctorHostCapabilities({ baseUrl, fetchImpl = globalT
73
101
  capabilities: normalizeDshHostCapabilities(body?.capabilities),
74
102
  }
75
103
  } catch (error) {
104
+ // A fetch failure can echo the request URL, and a proxy failure can echo the
105
+ // proxy URL with its userinfo; the doctor report is meant to be shareable, so
106
+ // the message is redacted at the source rather than at each print site.
76
107
  return {
77
108
  ok: false,
78
109
  source: 'runtime-unavailable',
79
- error: error instanceof Error ? error.message : String(error),
110
+ error: redactDiagnosticText(error instanceof Error ? error.message : String(error), 200),
80
111
  capabilities: unknownSnapshot(),
81
112
  }
82
113
  }
@@ -24,7 +24,10 @@ export function supportWindowUpgradeAdvice(capabilities = {}) {
24
24
  })
25
25
  }
26
26
 
27
- if (batchAttachments === 'unknown' || maxImageDimension === 'unknown') {
27
+ // Capability evidence is tri-state (`true` / `false` / `'unknown'`). Only an
28
+ // explicit `true` proves the support floor, so a missing field, a wrong-typed
29
+ // value and `'unknown'` all mean "not proven" rather than "capable".
30
+ if (batchAttachments !== true || maxImageDimension !== true) {
28
31
  return Object.freeze({
29
32
  level: 'unknown',
30
33
  code: 'HOST_CURRENT_FLOOR_UNKNOWN',
@@ -4,6 +4,7 @@ import process from 'node:process'
4
4
  import { execFile } from 'node:child_process'
5
5
  import { format as formatArgs, promisify } from 'node:util'
6
6
  import { createRequire } from 'node:module'
7
+ import { redactCredentialShapes } from './diagnostic-redaction.js'
7
8
  import { resolveDshHome } from './doctor.js'
8
9
  import { isLoopbackRequest } from './web-capability-boundary.js'
9
10
 
@@ -32,20 +33,19 @@ export function resolveVisionRouterLogPaths(dshHome = resolveDshHome()) {
32
33
  * Diagnostics are intended to be shareable in bug reports. Existing runtime
33
34
  * messages should not contain secrets, but redact common credential shapes as
34
35
  * defense in depth before anything reaches disk.
36
+ *
37
+ * The shape list lives in one place now: this module used to carry its own
38
+ * subset, which leaked `Authorization: token ghp_…`, `Cookie:` headers, bare
39
+ * platform tokens and `#token=` fragments that the other copy already covered.
35
40
  */
36
41
  export function sanitizeLogText(value) {
37
- let text = String(value ?? '')
38
- text = text.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer [REDACTED]')
39
- text = text.replace(/\bsk-(?:proj-)?[A-Za-z0-9_-]{8,}\b/g, '[REDACTED_KEY]')
40
- text = text.replace(
42
+ // Query strings here keep the historical `key=`/`auth=` spelling; everything
43
+ // else (schemes, cookies, vendor keys, JWTs) comes from the shared pass.
44
+ const text = String(value ?? '').replace(
41
45
  /([?&](?:api[_-]?key|access[_-]?token|token|key|auth)=)[^&\s]+/gi,
42
46
  '$1[REDACTED]',
43
47
  )
44
- text = text.replace(
45
- /\b(authorization|api[_-]?key|access[_-]?token)\s*[:=]\s*["']?[^\s"',}]+/gi,
46
- '$1=[REDACTED]',
47
- )
48
- return text
48
+ return redactCredentialShapes(text)
49
49
  }
50
50
 
51
51
  function positiveInteger(value, fallback) {
@@ -15,8 +15,15 @@ export function isOffloadedImageBlock(block) {
15
15
  const value = objectRecord(block);
16
16
  return value?.type === 'image' && value.offloaded === true;
17
17
  }
18
- export function blocksHaveRetainedImage(content) {
19
- if (!Array.isArray(content))
18
+ /**
19
+ * Deepest content nesting this walker inspects, mirroring the bound
20
+ * `catalog-corrections.convertBlocks` already applies to the same block tree.
21
+ * The walker only needs message → content → nested content; an unbounded
22
+ * recursion would turn a pathological tree into a stack overflow.
23
+ */
24
+ const MAX_CONTENT_NESTING_DEPTH = 4;
25
+ export function blocksHaveRetainedImage(content, depth = 0) {
26
+ if (!Array.isArray(content) || depth > MAX_CONTENT_NESTING_DEPTH)
20
27
  return false;
21
28
  for (const block of content) {
22
29
  const value = objectRecord(block);
@@ -24,7 +31,7 @@ export function blocksHaveRetainedImage(content) {
24
31
  continue;
25
32
  if (value.type === 'image' && value.offloaded !== true)
26
33
  return true;
27
- if (Array.isArray(value.content) && blocksHaveRetainedImage(value.content))
34
+ if (Array.isArray(value.content) && blocksHaveRetainedImage(value.content, depth + 1))
28
35
  return true;
29
36
  }
30
37
  return false;
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
4
4
  import path from 'node:path'
5
5
  import { promisify } from 'node:util'
6
6
  import { sameOriginRequest } from './adversarial-hardening.js'
7
+ import { objectRecord } from './core-primitives.js'
7
8
  import { normalizeRuntimeVisionConfig } from './runtime-config-normalizer.js'
8
9
  import { isOffloadedImageBlock, offloadedImagePlaceholder } from './image-offload-compat.js'
9
10
 
@@ -14,12 +15,13 @@ import { isOffloadedImageBlock, offloadedImagePlaceholder } from './image-offloa
14
15
  * specific to opt-in local vision, while non-local providers continue through
15
16
  * the original adapters byte-for-byte.
16
17
  */
17
- export async function triggerDesktopScreenshotPermission({
18
- platform = typeof process !== 'undefined' ? process.platform : '',
19
- run = promisify(execFile),
20
- remove = unlink,
21
- tempDir = tmpdir(),
22
- } = {}) {
18
+ export async function triggerDesktopScreenshotPermission(rawOptions) {
19
+ const {
20
+ platform = typeof process !== 'undefined' ? process.platform : '',
21
+ run = promisify(execFile),
22
+ remove = unlink,
23
+ tempDir = tmpdir(),
24
+ } = objectRecord(rawOptions) ?? {}
23
25
  // Windows has no Screen Recording privacy prompt equivalent and Linux
24
26
  // capture permission is compositor-specific. macOS is the platform where
25
27
  // enabling this feature must proactively cross an OS privacy boundary.
@@ -1,4 +1,10 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import {
3
+ VISION_ROUTER_ADAPTER_OWNER,
4
+ isVisionRouterOwnedAdapter,
5
+ } from './adapter-ownership.js'
6
+
7
+ export { VISION_ROUTER_ADAPTER_OWNER, isVisionRouterOwnedAdapter }
2
8
 
3
9
  /**
4
10
  * Turn-local image ownership used by the public entry composition.
@@ -21,7 +27,8 @@ export const IMAGE_OWNERSHIP = Object.freeze({
21
27
  * an enumerable symbol also survives the rare Object.assign/object-spread
22
28
  * adapter wrapper. Normal JSON/Object.keys surfaces still ignore symbols.
23
29
  */
24
- export const VISION_ROUTER_ADAPTER_OWNER = Symbol.for('dsh-vision-router.adapter-owner')
30
+ // The symbol and the ownership rule are owned by ./adapter-ownership.ts and
31
+ // re-exported here so existing importers keep working unchanged.
25
32
 
26
33
  const imageTurn = new AsyncLocalStorage()
27
34
  const markedAdapters = new WeakMap()
@@ -166,6 +173,9 @@ export function visionRouterAdapterOwner(adapter) {
166
173
  }
167
174
  }
168
175
 
176
+ // isVisionRouterOwnedAdapter is owned by ./adapter-ownership.ts (imported and
177
+ // re-exported at the top of this file) so every caller shares one rule.
178
+
169
179
  function routeList(routes) {
170
180
  const list = Array.isArray(routes) ? routes : [routes]
171
181
  return list
@@ -22,10 +22,25 @@ export function canonicalProxyHost(value) {
22
22
  host = host.slice(0, -1);
23
23
  if (host === '')
24
24
  return '';
25
+ // A `*.host` entry means "this host and its subdomains", which is exactly what the matcher
26
+ // already does for a bare hostname. `domainToASCII` rejects the star, so leaving it in place
27
+ // makes the entry equal no real hostname: the configured host silently proxies nothing.
28
+ if (host.startsWith('*.'))
29
+ host = host.slice(2);
30
+ if (host === '')
31
+ return '';
25
32
  // WHATWG URL.hostname keeps IPv6 literals bracketed. domainToASCII does not
26
- // accept a bare IPv6 literal, so preserve the bracketed spelling and only
27
- // case-fold it. Proxy hosts are otherwise DNS names and can be IDNA-folded.
28
- if (host.startsWith('[') && host.endsWith(']'))
33
+ // accept an IPv6 literal at all, so case-fold it directly. The brackets are
34
+ // pure spelling for the same address: stripping them is what lets a configured
35
+ // `::1` match a URL-derived `[::1]`. A bracketed non-literal (say
36
+ // `[example.com]`) keeps its spelling, so nothing about DNS is widened.
37
+ if (host.startsWith('[') && host.endsWith(']')) {
38
+ const literal = host.slice(1, -1).trim();
39
+ if (literal === '')
40
+ return '';
41
+ return literal.includes(':') ? literal.toLowerCase() : host.toLowerCase();
42
+ }
43
+ if (host.includes(':'))
29
44
  return host.toLowerCase();
30
45
  const ascii = domainToASCII(host);
31
46
  return (ascii || host).toLowerCase();
@@ -4,8 +4,13 @@
4
4
  *
5
5
  * PRODUCT CONTRACT (2.3+): acceptedRisk is reminder/friction only. It is not
6
6
  * authentication, authorization, local-presence proof, or a credential.
7
+ *
8
+ * This function is serialized with `toString()` into the browser boundary source
9
+ * below, so its body must stay self-contained: no imported helper and no free
10
+ * identifier may appear inside it.
7
11
  */
8
- export function createRemoteSettingsRiskClientBoundary(options = {}) {
12
+ export function createRemoteSettingsRiskClientBoundary(rawOptions) {
13
+ var options = rawOptions !== null && typeof rawOptions === 'object' ? rawOptions : {}
9
14
  var confirmImpl = options.confirmImpl
10
15
  var alertImpl = options.alertImpl
11
16
  var locale = options.locale
@@ -1,6 +1,6 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { isVisionRouterOwnedAdapter } from './adapter-ownership.js';
2
3
  import { parseSessionVisionPolicy } from './session-vision-policy.js';
3
- const VISION_ROUTER_ADAPTER_OWNER = Symbol.for('dsh-vision-router.adapter-owner');
4
4
  const VISION_ROUTER_OWNERSHIP = 'vision-router-owned';
5
5
  const DEFAULT_WRAPPER_ROUTE = 'deepseek-vision';
6
6
  const DEFAULT_CHAIN_ROUTE = 'vision-chain';
@@ -139,12 +139,7 @@ function currentAdapter(ctx, provider) {
139
139
  function adapterOwnedByVisionRouter(adapter) {
140
140
  if (!isWeakKey(adapter))
141
141
  return false;
142
- try {
143
- return objectRecord(adapter)?.[VISION_ROUTER_ADAPTER_OWNER] !== undefined;
144
- }
145
- catch {
146
- return false;
147
- }
142
+ return isVisionRouterOwnedAdapter(adapter);
148
143
  }
149
144
  function sameRoute(left, right) {
150
145
  const rightRoute = routeFrom(right);
@@ -12,12 +12,15 @@ export function parseSessionVisionPolicy(value) {
12
12
  if (value === null || typeof value !== 'object' || Array.isArray(value))
13
13
  return undefined;
14
14
  const record = value;
15
- if (record.revision !== SESSION_VISION_POLICY_REVISION)
15
+ // Own keys only: a policy row is persisted JSON, so a value inherited from a
16
+ // prototype is evidence of a malformed row rather than of a policy.
17
+ if (!Object.hasOwn(record, 'revision') || record.revision !== SESSION_VISION_POLICY_REVISION)
16
18
  return undefined;
17
- if (typeof record.enabled !== 'boolean')
19
+ if (!Object.hasOwn(record, 'enabled') || typeof record.enabled !== 'boolean')
18
20
  return undefined;
19
- if (record.source !== 'user' && record.source !== 'delegation')
21
+ if (!Object.hasOwn(record, 'source') || (record.source !== 'user' && record.source !== 'delegation')) {
20
22
  return undefined;
23
+ }
21
24
  if (record.source === 'user') {
22
25
  if (record.inheritedFrom !== undefined)
23
26
  return undefined;
@@ -27,7 +30,9 @@ export function parseSessionVisionPolicy(value) {
27
30
  source: 'user',
28
31
  });
29
32
  }
30
- const inheritedFrom = nonEmptyString(record.inheritedFrom);
33
+ const inheritedFrom = Object.hasOwn(record, 'inheritedFrom')
34
+ ? nonEmptyString(record.inheritedFrom)
35
+ : undefined;
31
36
  if (inheritedFrom === undefined)
32
37
  return undefined;
33
38
  return Object.freeze({
@@ -10,7 +10,9 @@ export const SETTINGS_NUMBER_META = Object.freeze({
10
10
  })
11
11
 
12
12
  export function parseSettingsNumber(key, raw, { allowClear = false } = {}) {
13
- const meta = SETTINGS_NUMBER_META[key]
13
+ // Own keys only: a bare lookup would resolve inherited names such as
14
+ // `__proto__` or `constructor` to prototype machinery instead of "unknown key".
15
+ const meta = Object.hasOwn(SETTINGS_NUMBER_META, key) ? SETTINGS_NUMBER_META[key] : undefined
14
16
  if (!meta) return undefined
15
17
  if (raw === '' || raw === null || raw === undefined) return allowClear ? { clear: true } : undefined
16
18
  const value = Number(raw)