@browserbridge/bbx 1.7.8 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/PRIVACY.md +57 -12
  2. package/README.md +19 -8
  3. package/SECURITY.md +12 -0
  4. package/package.json +15 -6
  5. package/packages/agent-client/src/cli-args.js +59 -0
  6. package/packages/agent-client/src/cli-batch.js +121 -58
  7. package/packages/agent-client/src/cli-output.js +8 -1
  8. package/packages/agent-client/src/cli.js +272 -70
  9. package/packages/agent-client/src/client.js +296 -8
  10. package/packages/agent-client/src/command-registry.js +64 -55
  11. package/packages/agent-client/src/config.js +195 -0
  12. package/packages/agent-client/src/content-extract.js +198 -0
  13. package/packages/agent-client/src/npm-self-update.js +305 -0
  14. package/packages/agent-client/src/remotes.js +4 -2
  15. package/packages/agent-client/src/runtime.js +864 -39
  16. package/packages/agent-client/src/types.ts +169 -6
  17. package/packages/mcp-server/src/guidance.js +1 -1
  18. package/packages/mcp-server/src/handlers-capture.js +69 -6
  19. package/packages/mcp-server/src/handlers-dom.js +61 -4
  20. package/packages/mcp-server/src/handlers-navigation.js +2 -1
  21. package/packages/mcp-server/src/handlers-page.js +211 -54
  22. package/packages/mcp-server/src/handlers-utils.js +101 -10
  23. package/packages/mcp-server/src/handlers.js +14 -7
  24. package/packages/mcp-server/src/server.js +176 -11
  25. package/packages/native-host/src/artifact-store.js +353 -0
  26. package/packages/native-host/src/config.js +9 -1
  27. package/packages/native-host/src/daemon-logger.js +3 -1
  28. package/packages/native-host/src/daemon.js +701 -33
  29. package/packages/native-host/src/native-host.js +28 -3
  30. package/packages/protocol/src/capability-values.js +2 -0
  31. package/packages/protocol/src/defaults.js +31 -0
  32. package/packages/protocol/src/errors.js +102 -6
  33. package/packages/protocol/src/incidental-sanitizer.js +197 -0
  34. package/packages/protocol/src/index.js +2 -0
  35. package/packages/protocol/src/protocol.js +612 -6
  36. package/packages/protocol/src/recovery-telemetry.js +302 -0
  37. package/packages/protocol/src/registry.js +206 -38
  38. package/packages/protocol/src/summary.js +203 -30
  39. package/packages/protocol/src/types.ts +721 -2
  40. package/skills/browser-bridge/SKILL.md +54 -19
  41. package/skills/browser-bridge/references/interaction.md +55 -11
  42. package/skills/browser-bridge/references/patch-workflow.md +2 -0
  43. package/skills/browser-bridge/references/protocol.md +265 -86
  44. package/skills/browser-bridge/references/token-efficiency.md +29 -13
@@ -2,7 +2,12 @@
2
2
 
3
3
  import net from 'node:net';
4
4
 
5
- import { createFailure, ERROR_CODES, MAX_JSON_LINE_BYTES } from '../../protocol/src/index.js';
5
+ import {
6
+ createFailure,
7
+ ERROR_CODES,
8
+ MAX_JSON_LINE_BYTES,
9
+ sanitizeIncidentalText,
10
+ } from '../../protocol/src/index.js';
6
11
  import { readBridgeAuthToken } from './auth-token.js';
7
12
  import { createSocketBridgeTransport, getBridgeTransport } from './config.js';
8
13
  import { spawnBridgeDaemonProcess } from './daemon-process.js';
@@ -28,7 +33,8 @@ import { createNativeMessageReader, createNativeMessageWriter, writeJsonLine } f
28
33
  * @typedef {{
29
34
  * type?: string,
30
35
  * browserName?: string,
31
- * profileLabel?: string
36
+ * profileLabel?: string,
37
+ * browserExtensionId?: string
32
38
  * }} HostIdentityMessage
33
39
  */
34
40
 
@@ -109,6 +115,16 @@ function isHostActivity(message) {
109
115
  );
110
116
  }
111
117
 
118
+ /** @param {unknown} message */
119
+ function isHostArtifactMessage(message) {
120
+ return Boolean(
121
+ message &&
122
+ typeof message === 'object' &&
123
+ typeof (/** @type {Record<string, unknown>} */ (message).type) === 'string' &&
124
+ String(/** @type {Record<string, unknown>} */ (message).type).startsWith('host.artifact.')
125
+ );
126
+ }
127
+
112
128
  /**
113
129
  * @param {{ transport?: BridgeTransport, socketPath?: string }} [options={}]
114
130
  * @returns {Promise<void>}
@@ -129,7 +145,7 @@ export async function runNativeHost({
129
145
  response: createFailure(
130
146
  'native_bootstrap',
131
147
  ERROR_CODES.NATIVE_HOST_UNAVAILABLE,
132
- error instanceof Error ? error.message : String(error)
148
+ sanitizeIncidentalText(error instanceof Error ? error.message : String(error))
133
149
  ),
134
150
  });
135
151
  return;
@@ -238,6 +254,7 @@ export async function runNativeHost({
238
254
  type: 'extension.identity',
239
255
  browserName: message.browserName,
240
256
  profileLabel: message.profileLabel,
257
+ browserExtensionId: message.browserExtensionId,
241
258
  });
242
259
  return;
243
260
  }
@@ -255,6 +272,14 @@ export async function runNativeHost({
255
272
  });
256
273
  return;
257
274
  }
275
+ if (isHostArtifactMessage(message)) {
276
+ const record = /** @type {Record<string, unknown>} */ (message);
277
+ await writeJsonLine(socket, {
278
+ ...record,
279
+ type: String(record.type).replace(/^host\./u, 'extension.'),
280
+ });
281
+ return;
282
+ }
258
283
  await writeJsonLine(socket, {
259
284
  type: 'extension.response',
260
285
  response: message,
@@ -20,6 +20,7 @@ export const CAPABILITIES = Object.freeze({
20
20
  PERFORMANCE_READ: 'performance.read',
21
21
  NETWORK_READ: 'network.read',
22
22
  NETWORK_INTERCEPT: 'network.intercept',
23
+ SENSITIVE_READ: 'sensitive.read',
23
24
  });
24
25
 
25
26
  export const DEFAULT_CAPABILITIES = Object.freeze([
@@ -42,4 +43,5 @@ export const DEFAULT_CAPABILITIES = Object.freeze([
42
43
  CAPABILITIES.PERFORMANCE_READ,
43
44
  CAPABILITIES.NETWORK_READ,
44
45
  CAPABILITIES.NETWORK_INTERCEPT,
46
+ CAPABILITIES.SENSITIVE_READ,
45
47
  ]);
@@ -11,6 +11,9 @@ export const DEFAULT_MAX_NODES = 25;
11
11
  export const DEFAULT_MAX_DEPTH = 4;
12
12
  export const DEFAULT_TEXT_BUDGET = 600;
13
13
  export const DEFAULT_PAGE_TEXT_BUDGET = 8000;
14
+ export const DEFAULT_EXTRACT_SETTLE_TIMEOUT_MS = 2000;
15
+ export const MAX_EXTRACT_SETTLE_TIMEOUT_MS = 10_000;
16
+ export const EXTRACT_SETTLE_QUIET_MS = 100;
14
17
  export const DEFAULT_WAIT_TIMEOUT_MS = 5000;
15
18
  export const DEFAULT_EVAL_TIMEOUT_MS = 5000;
16
19
  export const DEFAULT_NAV_TIMEOUT_MS = 15000;
@@ -18,6 +21,9 @@ export const DEFAULT_MAX_HTML_LENGTH = 2000;
18
21
  export const DEFAULT_A11Y_MAX_NODES = 500;
19
22
  export const DEFAULT_A11Y_MAX_DEPTH = 6;
20
23
  export const DEFAULT_NETWORK_LIMIT = 50;
24
+ export const DEFAULT_HAR_LIMIT = 50;
25
+ export const MAX_HAR_ENTRIES = 200;
26
+ export const HAR_AUTO_INLINE_BYTES = 262_144;
21
27
  export const DEFAULT_CONSOLE_LIMIT = 50;
22
28
  export const DEFAULT_VIEWPORT_WIDTH = 1280;
23
29
  export const DEFAULT_VIEWPORT_HEIGHT = 720;
@@ -31,6 +37,31 @@ export const MAX_NATIVE_MESSAGE_BYTES = 1_048_576;
31
37
  /** Maximum size of one newline-delimited daemon socket message in bytes. */
32
38
  export const MAX_JSON_LINE_BYTES = MAX_NATIVE_MESSAGE_BYTES;
33
39
 
40
+ /** Maximum UTF-8 size of an exact sensitive value returned atomically. */
41
+ export const MAX_SENSITIVE_VALUE_BYTES = 262_144;
42
+
43
+ /** Maximum calls accepted by any read-only batch surface. */
44
+ export const MAX_BATCH_CALLS = 20;
45
+
46
+ /** Maximum read-only batch calls executing concurrently. */
47
+ export const MAX_BATCH_CONCURRENCY = 5;
48
+ export const SCREENSHOT_AUTO_INLINE_BYTES = 262_144;
49
+ export const SCREENSHOT_MAX_INLINE_BYTES = 524_288;
50
+ export const ARTIFACT_CHUNK_BYTES = 196_608;
51
+ export const MAX_ARTIFACT_BYTES = 33_554_432;
52
+ export const MAX_ARTIFACT_CLIENT_BYTES = 67_108_864;
53
+ export const MAX_ARTIFACT_TOTAL_BYTES = 268_435_456;
54
+ export const MAX_ARTIFACTS_PER_CLIENT = 16;
55
+ export const ARTIFACT_TTL_MS = 300_000;
56
+
57
+ /** DOM baseline retention and storage limits. */
58
+ export const DOM_BASELINE_TTL_MS = 300_000;
59
+ export const MAX_DOM_BASELINES_PER_TAB = 8;
60
+ export const MAX_DOM_BASELINES_GLOBAL = 32;
61
+ export const MAX_DOM_BASELINE_BYTES_PER_TAB = 1_048_576;
62
+ export const MAX_DOM_BASELINE_BYTES_GLOBAL = 4_194_304;
63
+ export const MAX_DOM_BASELINE_BYTES = 262_144;
64
+
34
65
  /** Default timeout for a bridge request awaiting an extension response (ms). */
35
66
  export const DEFAULT_DAEMON_PENDING_TIMEOUT_MS = 30_000;
36
67
 
@@ -6,8 +6,25 @@ export const ERROR_CODES = Object.freeze({
6
6
  ACCESS_DENIED: 'ACCESS_DENIED',
7
7
  TAB_MISMATCH: 'TAB_MISMATCH',
8
8
  ELEMENT_STALE: 'ELEMENT_STALE',
9
+ ELEMENT_AMBIGUOUS: 'ELEMENT_AMBIGUOUS',
10
+ ELEMENT_NOT_ACTIONABLE: 'ELEMENT_NOT_ACTIONABLE',
11
+ ELEMENT_OBSCURED: 'ELEMENT_OBSCURED',
12
+ ELEMENT_NOT_FOUND: 'ELEMENT_NOT_FOUND',
13
+ INPUT_UNSUPPORTED: 'INPUT_UNSUPPORTED',
14
+ INPUT_INVALID_TARGET: 'INPUT_INVALID_TARGET',
15
+ INPUT_FOCUS_CHANGED: 'INPUT_FOCUS_CHANGED',
16
+ DIALOG_NOT_OPEN: 'DIALOG_NOT_OPEN',
17
+ DIALOG_ACTION_CONFLICT: 'DIALOG_ACTION_CONFLICT',
9
18
  RESULT_TRUNCATED: 'RESULT_TRUNCATED',
10
- RATE_LIMITED: 'RATE_LIMITED',
19
+ RESULT_TOO_LARGE: 'RESULT_TOO_LARGE',
20
+ SENSITIVE_TARGET_NOT_FOUND: 'SENSITIVE_TARGET_NOT_FOUND',
21
+ CONTENT_SCRIPT_UNAVAILABLE: 'CONTENT_SCRIPT_UNAVAILABLE',
22
+ ARTIFACT_NOT_FOUND: 'ARTIFACT_NOT_FOUND',
23
+ ARTIFACT_QUOTA_EXCEEDED: 'ARTIFACT_QUOTA_EXCEEDED',
24
+ ARTIFACT_TRANSFER_INVALID: 'ARTIFACT_TRANSFER_INVALID',
25
+ DOM_BASELINE_NOT_FOUND: 'DOM_BASELINE_NOT_FOUND',
26
+ DOM_BASELINE_INVALIDATED: 'DOM_BASELINE_INVALIDATED',
27
+ DOM_BASELINE_QUOTA_EXCEEDED: 'DOM_BASELINE_QUOTA_EXCEEDED',
11
28
  INTERNAL_ERROR: 'INTERNAL_ERROR',
12
29
  INVALID_REQUEST: 'INVALID_REQUEST',
13
30
  NATIVE_HOST_UNAVAILABLE: 'NATIVE_HOST_UNAVAILABLE',
@@ -29,10 +46,63 @@ export const ERROR_RECOVERY = Object.freeze({
29
46
  retry: false,
30
47
  hint: 'Result was truncated to fit the response budget. Narrow the query or raise the relevant budget if more detail is required.',
31
48
  },
49
+ [ERROR_CODES.RESULT_TOO_LARGE]: {
50
+ retry: false,
51
+ hint: 'The result exceeds its atomic delivery limit. For screenshots, use artifact delivery, a smaller region, lower scale, or lossy compression. For sensitive reads, use a narrower exact target; partial values are never returned.',
52
+ },
53
+ [ERROR_CODES.SENSITIVE_TARGET_NOT_FOUND]: {
54
+ retry: false,
55
+ alternativeMethod: 'page.get_storage',
56
+ hint: 'The exact storage key does not exist. Use page.get_storage to inspect metadata before another deliberate sensitive read.',
57
+ },
32
58
  [ERROR_CODES.ELEMENT_STALE]: {
33
59
  retry: false,
34
60
  alternativeMethod: 'dom.query',
35
- hint: 'Element was removed from the DOM. Re-query with the same selector to get a fresh elementRef.',
61
+ hint: 'Element was removed from the DOM. Re-query for a fresh elementRef, or retry an input once with recoverStale=true when the same page and a strong unique descriptor still apply.',
62
+ },
63
+ [ERROR_CODES.ELEMENT_AMBIGUOUS]: {
64
+ retry: false,
65
+ alternativeMethod: 'dom.query',
66
+ hint: 'Multiple candidates matched, candidates tied, or a bounded stale-recovery scan could not prove uniqueness. Re-query and use a more specific selector or fresh elementRef.',
67
+ },
68
+ [ERROR_CODES.ELEMENT_NOT_ACTIONABLE]: {
69
+ retry: false,
70
+ alternativeMethod: 'dom.describe',
71
+ hint: 'The target is hidden, disabled, inert, or has no rendered area. Inspect it and choose an actionable target.',
72
+ },
73
+ [ERROR_CODES.ELEMENT_OBSCURED]: {
74
+ retry: false,
75
+ alternativeMethod: 'layout.hit_test',
76
+ hint: 'Another element blocks the target point. Inspect the blocker or wait for the overlay to close.',
77
+ },
78
+ [ERROR_CODES.ELEMENT_NOT_FOUND]: {
79
+ retry: false,
80
+ alternativeMethod: 'dom.query',
81
+ hint: 'No element matched the target selector. Check or narrow the selector with dom.query.',
82
+ },
83
+ [ERROR_CODES.INPUT_UNSUPPORTED]: {
84
+ retry: false,
85
+ hint: 'The requested execution mode does not support this input operation. Use executionMode=dom or choose a CDP-supported input method.',
86
+ },
87
+ [ERROR_CODES.INPUT_INVALID_TARGET]: {
88
+ retry: false,
89
+ alternativeMethod: 'dom.describe',
90
+ hint: 'The target is not compatible with this input operation or the requested option does not exist. Inspect the target and choose the appropriate control.',
91
+ },
92
+ [ERROR_CODES.INPUT_FOCUS_CHANGED]: {
93
+ retry: false,
94
+ alternativeMethod: 'dom.describe',
95
+ hint: 'Focus moved away from the resolved editable target before native text dispatch. Inspect focus handlers or target the control that retained focus.',
96
+ },
97
+ [ERROR_CODES.DIALOG_NOT_OPEN]: {
98
+ retry: false,
99
+ alternativeMethod: 'page.handle_dialog',
100
+ hint: 'No JavaScript dialog is currently observable. Inspect or trigger the dialog before trying another explicit action.',
101
+ },
102
+ [ERROR_CODES.DIALOG_ACTION_CONFLICT]: {
103
+ retry: false,
104
+ alternativeMethod: 'page.handle_dialog',
105
+ hint: 'The observed dialog changed before or during CDP dispatch. Do not repeat the action automatically; inspect the current dialog state first.',
36
106
  },
37
107
  [ERROR_CODES.TAB_MISMATCH]: {
38
108
  retry: false,
@@ -44,10 +114,36 @@ export const ERROR_RECOVERY = Object.freeze({
44
114
  retryAfterMs: 1000,
45
115
  hint: 'Operation exceeded the time limit. Retry once, or simplify the request (smaller maxNodes, narrower selector).',
46
116
  },
47
- [ERROR_CODES.RATE_LIMITED]: {
48
- retry: true,
49
- retryAfterMs: 2000,
50
- hint: 'Too many requests. Back off and retry after a short delay.',
117
+ [ERROR_CODES.CONTENT_SCRIPT_UNAVAILABLE]: {
118
+ retry: false,
119
+ hint: 'The page cannot host the Browser Bridge content script. Switch to a normal http(s) page in the enabled window.',
120
+ },
121
+ [ERROR_CODES.ARTIFACT_NOT_FOUND]: {
122
+ retry: false,
123
+ hint: 'The artifact is missing, expired, deleted, or owned by another client. Capture it again.',
124
+ },
125
+ [ERROR_CODES.ARTIFACT_QUOTA_EXCEEDED]: {
126
+ retry: false,
127
+ hint: 'Artifact storage quota was exceeded. Delete unneeded artifacts or retry after expiry.',
128
+ },
129
+ [ERROR_CODES.ARTIFACT_TRANSFER_INVALID]: {
130
+ retry: false,
131
+ hint: 'Artifact transfer validation failed. Capture the screenshot again.',
132
+ },
133
+ [ERROR_CODES.DOM_BASELINE_NOT_FOUND]: {
134
+ retry: false,
135
+ alternativeMethod: 'dom.baseline.create',
136
+ hint: 'The DOM baseline is missing, expired, or released. Create a new baseline before comparing or describing it.',
137
+ },
138
+ [ERROR_CODES.DOM_BASELINE_INVALIDATED]: {
139
+ retry: false,
140
+ alternativeMethod: 'dom.baseline.create',
141
+ hint: 'The page document or baseline scope changed. Create a new baseline in the current document.',
142
+ },
143
+ [ERROR_CODES.DOM_BASELINE_QUOTA_EXCEEDED]: {
144
+ retry: false,
145
+ alternativeMethod: 'dom.baseline.release',
146
+ hint: 'DOM baseline storage quota was exceeded. Release unneeded baselines, narrow the selector, or reduce maxNodes and textBudget before creating another baseline.',
51
147
  },
52
148
  [ERROR_CODES.EXTENSION_DISCONNECTED]: {
53
149
  retry: true,
@@ -0,0 +1,197 @@
1
+ // @ts-check
2
+
3
+ const DEFAULT_MAX_STRING_LENGTH = 4_096;
4
+ const DEFAULT_MAX_DEPTH = 8;
5
+ const DEFAULT_MAX_NODES = 1_000;
6
+ const REDACTED = '[redacted]';
7
+ const REDACTED_PATH = '[redacted-path]';
8
+
9
+ const SENSITIVE_KEYS = new Set([
10
+ 'authorization',
11
+ 'proxyauthorization',
12
+ 'cookie',
13
+ 'setcookie',
14
+ 'password',
15
+ 'passphrase',
16
+ 'secret',
17
+ 'clientsecret',
18
+ 'token',
19
+ 'accesstoken',
20
+ 'refreshtoken',
21
+ 'idtoken',
22
+ 'authtoken',
23
+ 'apikey',
24
+ 'credential',
25
+ 'credentials',
26
+ ]);
27
+
28
+ /** @param {string} key */
29
+ export function isSensitiveIncidentalKey(key) {
30
+ return SENSITIVE_KEYS.has(key.toLowerCase().replaceAll(/[^a-z0-9]/gu, ''));
31
+ }
32
+
33
+ /**
34
+ * Sanitize a URL that appears incidentally in logs, activity, or diagnostics.
35
+ * Query-key context is retained while credentials, values, and fragments are
36
+ * discarded.
37
+ *
38
+ * @param {unknown} value
39
+ * @param {number} [maxLength]
40
+ * @returns {string}
41
+ */
42
+ export function sanitizeIncidentalUrl(value, maxLength = DEFAULT_MAX_STRING_LENGTH) {
43
+ const raw = typeof value === 'string' ? value.slice(0, maxLength) : String(value ?? '');
44
+ if (!raw) return '';
45
+ if (/^data:/iu.test(raw)) {
46
+ const mime = raw
47
+ .slice(5)
48
+ .split(/[;,]/u, 1)[0]
49
+ .replace(/[^a-z0-9./+-]/giu, '')
50
+ .slice(0, 100);
51
+ return mime ? `data:${mime};${REDACTED}` : `data:${REDACTED}`;
52
+ }
53
+ if (/^blob:/iu.test(raw)) {
54
+ try {
55
+ return `blob:${new URL(raw.slice(5)).origin}/${REDACTED}`.slice(0, maxLength);
56
+ } catch {
57
+ return `blob:${REDACTED}`;
58
+ }
59
+ }
60
+ if (/^file:/iu.test(raw)) {
61
+ try {
62
+ return `file:${sanitizeIncidentalPath(decodeURIComponent(new URL(raw).pathname))}`;
63
+ } catch {
64
+ return `file:${REDACTED_PATH}`;
65
+ }
66
+ }
67
+ try {
68
+ const parsed = new URL(raw);
69
+ parsed.username = '';
70
+ parsed.password = '';
71
+ parsed.hash = '';
72
+ for (const key of new Set(parsed.searchParams.keys())) {
73
+ parsed.searchParams.set(key, REDACTED);
74
+ }
75
+ return parsed.href.slice(0, maxLength);
76
+ } catch {
77
+ const withoutCredentials = raw.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/iu, '$1');
78
+ const summary = withoutCredentials.split(/[?#]/u, 1)[0];
79
+ return summary ? summary.slice(0, maxLength) : '[redacted-url]';
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Reduce a local path to a basename-only marker.
85
+ *
86
+ * @param {unknown} value
87
+ * @returns {string}
88
+ */
89
+ export function sanitizeIncidentalPath(value) {
90
+ const raw = typeof value === 'string' ? value : String(value ?? '');
91
+ const basename = raw.split(/[\\/]/u).filter(Boolean).at(-1)?.slice(0, 160);
92
+ return basename ? `${REDACTED_PATH}/${basename}` : REDACTED_PATH;
93
+ }
94
+
95
+ /**
96
+ * Sanitize secrets, URLs, and paths embedded in an incidental text message.
97
+ *
98
+ * @param {unknown} value
99
+ * @param {number} [maxLength]
100
+ * @returns {string}
101
+ */
102
+ export function sanitizeIncidentalText(value, maxLength = DEFAULT_MAX_STRING_LENGTH) {
103
+ let text = typeof value === 'string' ? value : String(value ?? '');
104
+ text = text.replace(
105
+ /\b(authorization|proxy-authorization)\s*:\s*[^\r\n,;]+/giu,
106
+ (_match, name) => `${name}: ${REDACTED}`
107
+ );
108
+ text = text.replace(/\b(set-cookie|cookie)\s*:\s*[^\r\n]+/giu, (_match, name) => {
109
+ return `${name}: ${REDACTED}`;
110
+ });
111
+ text = text.replace(
112
+ /\b(?:https?|file|blob|chrome|chrome-extension|edge|devtools):[^\s"'<>]+/giu,
113
+ (url) => sanitizeIncidentalUrl(url, maxLength)
114
+ );
115
+ text = text.replace(/\bdata:[^\s"'<>]+/giu, (url) => sanitizeIncidentalUrl(url, maxLength));
116
+ text = text.replace(/(?:^|[\s"'(])([a-z]:\\[^\s"')]+|\\\\[^\s"')]+)/giu, (match, path) =>
117
+ match.replace(path, sanitizeIncidentalPath(path))
118
+ );
119
+ text = text.replace(
120
+ /(?:^|[\s"'(])(\/(?:Users|home|private|var|tmp|opt|etc|root|usr|srv|mnt|Volumes|Library|Applications)\/[^\s"')]+)/gu,
121
+ (match, path) => match.replace(path, sanitizeIncidentalPath(path))
122
+ );
123
+ return text.slice(0, maxLength);
124
+ }
125
+
126
+ /**
127
+ * Recursively sanitize incidental structured data while bounding traversal.
128
+ * Explicit sensitive-read values must never pass through this helper.
129
+ *
130
+ * @param {unknown} value
131
+ * @param {{ maxDepth?: number, maxNodes?: number, maxStringLength?: number }} [options]
132
+ * @returns {unknown}
133
+ */
134
+ export function sanitizeIncidentalValue(value, options = {}) {
135
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
136
+ const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
137
+ const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING_LENGTH;
138
+ const seen = new WeakSet();
139
+ let nodes = 0;
140
+
141
+ /** @param {unknown} current @param {number} depth @param {string | null} key @returns {unknown} */
142
+ const visit = (current, depth, key) => {
143
+ nodes += 1;
144
+ if (nodes > maxNodes) return '[truncated]';
145
+ if (key !== null && isSensitiveIncidentalKey(key)) return REDACTED;
146
+ if (typeof current === 'string') {
147
+ if (key && /url|uri|href|endpoint/iu.test(key)) {
148
+ return sanitizeIncidentalUrl(current, maxStringLength);
149
+ }
150
+ if (key && /path|directory|filename/iu.test(key) && isAbsolutePath(current)) {
151
+ return sanitizeIncidentalPath(current);
152
+ }
153
+ return sanitizeIncidentalText(current, maxStringLength);
154
+ }
155
+ if (
156
+ current === null ||
157
+ typeof current === 'number' ||
158
+ typeof current === 'boolean' ||
159
+ typeof current === 'undefined'
160
+ ) {
161
+ return current;
162
+ }
163
+ if (
164
+ typeof current === 'bigint' ||
165
+ typeof current === 'symbol' ||
166
+ typeof current === 'function'
167
+ ) {
168
+ return sanitizeIncidentalText(current, maxStringLength);
169
+ }
170
+ if (current instanceof Error) {
171
+ return {
172
+ name: sanitizeIncidentalText(current.name, maxStringLength),
173
+ message: sanitizeIncidentalText(current.message, maxStringLength),
174
+ };
175
+ }
176
+ if (depth >= maxDepth) return '[truncated]';
177
+ if (seen.has(current)) return '[circular]';
178
+ seen.add(current);
179
+ if (Array.isArray(current)) {
180
+ return current.map((item) => visit(item, depth + 1, null));
181
+ }
182
+ const result = /** @type {Record<string, unknown>} */ ({});
183
+ for (const [childKey, childValue] of Object.entries(current)) {
184
+ result[sanitizeIncidentalText(childKey, 160)] = visit(childValue, depth + 1, childKey);
185
+ }
186
+ return result;
187
+ };
188
+
189
+ return visit(value, 0, null);
190
+ }
191
+
192
+ /** @param {string} value */
193
+ function isAbsolutePath(value) {
194
+ return /^(?:[a-z]:\\|\\\\|\/(?:Users|home|private|var|tmp|opt|etc|root|usr|srv|mnt|Volumes|Library|Applications)\/)/iu.test(
195
+ value
196
+ );
197
+ }
@@ -8,8 +8,10 @@ export * from './budget.js';
8
8
  export * from './capabilities.js';
9
9
  export * from './defaults.js';
10
10
  export * from './errors.js';
11
+ export * from './incidental-sanitizer.js';
11
12
  export * from './json-lines.js';
12
13
  export * from './payload-cost.js';
13
14
  export * from './protocol.js';
14
15
  export * from './registry.js';
16
+ export * from './recovery-telemetry.js';
15
17
  export * from './summary.js';