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.
- package/README.md +9 -8
- package/README.zh.md +7 -6
- package/docs/architecture/ci-risk-posture.md +45 -0
- package/docs/architecture/dsh-021-alpha1-adaptation.md +46 -0
- package/docs/releases/v3.0.2.md +105 -0
- package/index.js +13 -3
- package/lib/adapter-ownership.js +51 -0
- package/lib/client-presentation-boundary-main.js +113 -50
- package/lib/client-presentation-boundary.js +36 -46
- package/lib/client.js +2 -1
- package/lib/core-primitives.js +21 -0
- package/lib/core-vision-surface.js +3 -1
- package/lib/desktop-screenshot-tool.js +11 -9
- package/lib/diagnostic-redaction.js +61 -12
- package/lib/doctor-cli-p0.js +36 -5
- package/lib/dsh-support-window.js +4 -1
- package/lib/file-logger.js +9 -9
- package/lib/image-offload-compat.js +10 -3
- package/lib/local-vision-stabilizer.js +8 -6
- package/lib/native-image-coexistence.js +11 -1
- package/lib/proxy-routing.js +18 -3
- package/lib/remote-settings-risk-client-boundary.js +6 -1
- package/lib/session-vision-mode-authority.js +2 -7
- package/lib/session-vision-policy.js +9 -4
- package/lib/settings-number-contract.js +3 -1
- package/lib/vision-background-stop-store-core.js +14 -2
- package/lib/vision-resilience.js +31 -2
- package/lib/vision-tool-runtime-boundary.js +2 -7
- package/package.json +2 -2
|
@@ -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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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',
|
package/lib/core-primitives.js
CHANGED
|
@@ -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: ``,
|
|
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(
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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 ?
|
|
7
|
-
url.password = url.password ?
|
|
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,
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
}
|
package/lib/doctor-cli-p0.js
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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',
|
package/lib/file-logger.js
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
text =
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
package/lib/proxy-routing.js
CHANGED
|
@@ -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
|
|
27
|
-
//
|
|
28
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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)
|