@browserbridge/bbx 1.8.0 → 1.10.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 (48) hide show
  1. package/PRIVACY.md +29 -3
  2. package/README.md +10 -3
  3. package/SECURITY.md +12 -0
  4. package/package.json +7 -1
  5. package/packages/agent-client/src/cli-args.js +70 -6
  6. package/packages/agent-client/src/cli-batch.js +127 -59
  7. package/packages/agent-client/src/cli-helpers.js +8 -0
  8. package/packages/agent-client/src/cli-output.js +8 -1
  9. package/packages/agent-client/src/cli-setup-commands.js +3 -1
  10. package/packages/agent-client/src/cli.js +254 -26
  11. package/packages/agent-client/src/client.js +156 -3
  12. package/packages/agent-client/src/command-registry.js +14 -4
  13. package/packages/agent-client/src/config.js +195 -0
  14. package/packages/agent-client/src/mcp-config.js +8 -8
  15. package/packages/agent-client/src/npm-self-update.js +305 -0
  16. package/packages/agent-client/src/remotes.js +4 -2
  17. package/packages/agent-client/src/runtime.js +99 -7
  18. package/packages/agent-client/src/types.ts +38 -0
  19. package/packages/mcp-server/src/guidance.js +37 -6
  20. package/packages/mcp-server/src/handlers-capture.js +29 -2
  21. package/packages/mcp-server/src/handlers-dom.js +54 -3
  22. package/packages/mcp-server/src/handlers-page.js +333 -60
  23. package/packages/mcp-server/src/handlers-utils.js +141 -151
  24. package/packages/mcp-server/src/handlers.js +17 -4
  25. package/packages/mcp-server/src/server.js +269 -51
  26. package/packages/mcp-server/src/toolset.js +48 -0
  27. package/packages/native-host/src/artifact-store.js +353 -0
  28. package/packages/native-host/src/config.js +9 -1
  29. package/packages/native-host/src/daemon-logger.js +3 -1
  30. package/packages/native-host/src/daemon.js +457 -27
  31. package/packages/native-host/src/dom-baseline-owners.js +210 -0
  32. package/packages/native-host/src/native-host.js +38 -29
  33. package/packages/protocol/src/budget-presets.js +182 -0
  34. package/packages/protocol/src/capability-values.js +2 -0
  35. package/packages/protocol/src/defaults.js +28 -0
  36. package/packages/protocol/src/errors.js +48 -5
  37. package/packages/protocol/src/har.js +34 -0
  38. package/packages/protocol/src/incidental-sanitizer.js +197 -0
  39. package/packages/protocol/src/index.js +4 -0
  40. package/packages/protocol/src/json-lines.js +3 -2
  41. package/packages/protocol/src/protocol.js +316 -6
  42. package/packages/protocol/src/recovery-telemetry.js +302 -0
  43. package/packages/protocol/src/registry.js +226 -6
  44. package/packages/protocol/src/summary.js +116 -25
  45. package/packages/protocol/src/types.ts +490 -4
  46. package/skills/browser-bridge/SKILL.md +26 -8
  47. package/skills/browser-bridge/references/protocol.md +125 -15
  48. package/skills/browser-bridge/references/token-efficiency.md +17 -7
@@ -0,0 +1,210 @@
1
+ // @ts-check
2
+
3
+ import { DOM_BASELINE_TTL_MS, MAX_DOM_BASELINES_GLOBAL } from '../../protocol/src/index.js';
4
+
5
+ /**
6
+ * Ownership bookkeeping for retained semantic DOM baselines.
7
+ *
8
+ * A baseline lives in the extension's memory, but the daemon has to know which
9
+ * extension socket owns it so a later `compare`/`describe`/`release` is routed
10
+ * back to the same document, and so the state is dropped when that socket goes
11
+ * away. This module owns only that mapping and its expiry rules; sending the
12
+ * release request over the wire stays with the daemon, which owns transport.
13
+ *
14
+ * @typedef {import('./daemon.js').ClientSocket} ClientSocket
15
+ * @typedef {{ socket: ClientSocket, expiresAt: number }} BaselineOwner
16
+ */
17
+
18
+ /**
19
+ * Baseline IDs are minted by the extension. The daemon re-validates the shape
20
+ * before trusting one as a map key so a malformed response cannot grow state.
21
+ */
22
+ const BASELINE_ID_PATTERN = /^baseline_[A-Za-z0-9_-]{32,64}$/u;
23
+
24
+ /**
25
+ * Expiry timestamps are allowed a small skew over the nominal TTL to absorb
26
+ * clock jitter between the extension and the daemon.
27
+ */
28
+ const EXPIRY_SKEW_MS = 10_000;
29
+
30
+ /** Cap on tracked mappings per socket, and on abandoned-create entries. */
31
+ const MAX_OWNER_MAPPINGS = MAX_DOM_BASELINES_GLOBAL * 4;
32
+
33
+ /**
34
+ * @param {unknown} baselineId
35
+ * @returns {boolean}
36
+ */
37
+ export function isValidBaselineId(baselineId) {
38
+ return typeof baselineId === 'string' && BASELINE_ID_PATTERN.test(baselineId);
39
+ }
40
+
41
+ export class DomBaselineOwnerRegistry {
42
+ /**
43
+ * @param {{ isCurrentExtensionSocket: (socket: ClientSocket) => boolean }} options
44
+ */
45
+ constructor({ isCurrentExtensionSocket }) {
46
+ /** @type {Map<string, BaselineOwner>} */
47
+ this.owners = new Map();
48
+ /**
49
+ * Creates whose agent went away before the extension replied. The response
50
+ * still arrives later, and the baseline it reports has to be released.
51
+ * @type {Map<string, BaselineOwner>}
52
+ */
53
+ this.abandonedCreates = new Map();
54
+ this.isCurrentExtensionSocket = isCurrentExtensionSocket;
55
+ }
56
+
57
+ /** @returns {number} */
58
+ get size() {
59
+ return this.owners.size;
60
+ }
61
+
62
+ /** Drop every mapping whose TTL has passed. */
63
+ prune() {
64
+ const now = Date.now();
65
+ for (const [baselineId, owner] of this.owners) {
66
+ if (owner.expiresAt <= now) this.owners.delete(baselineId);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Resolve the socket that owns a baseline, or null when the owner is gone,
72
+ * has lost access, or has been replaced by a newer socket for the same
73
+ * extension.
74
+ *
75
+ * @param {string} baselineId
76
+ * @returns {ClientSocket | null}
77
+ */
78
+ get(baselineId) {
79
+ this.prune();
80
+ const owner = this.owners.get(baselineId);
81
+ if (!owner || !owner.socket.__extensionId || !owner.socket.__accessEnabled) return null;
82
+ if (!this.isCurrentExtensionSocket(owner.socket)) {
83
+ this.owners.delete(baselineId);
84
+ return null;
85
+ }
86
+ return owner.socket;
87
+ }
88
+
89
+ /**
90
+ * Record ownership for a freshly created baseline.
91
+ *
92
+ * Returns false when the reported ID or expiry is unusable, or when another
93
+ * socket already claims the ID; the caller releases the orphan in that case.
94
+ *
95
+ * @param {string} baselineId
96
+ * @param {ClientSocket} socket
97
+ * @param {unknown} expiresAt
98
+ * @returns {boolean}
99
+ */
100
+ register(baselineId, socket, expiresAt) {
101
+ this.prune();
102
+ const parsedExpiry = typeof expiresAt === 'string' ? Date.parse(expiresAt) : Number.NaN;
103
+ const now = Date.now();
104
+ if (
105
+ !isValidBaselineId(baselineId) ||
106
+ !socket.__extensionId ||
107
+ !Number.isFinite(parsedExpiry) ||
108
+ parsedExpiry <= now ||
109
+ parsedExpiry > now + DOM_BASELINE_TTL_MS + EXPIRY_SKEW_MS
110
+ ) {
111
+ return false;
112
+ }
113
+ const existing = this.owners.get(baselineId);
114
+ if (existing && existing.socket !== socket) return false;
115
+ while (
116
+ !existing &&
117
+ [...this.owners.values()].filter((owner) => owner.socket === socket).length >=
118
+ MAX_OWNER_MAPPINGS
119
+ ) {
120
+ const oldest = [...this.owners].find(([, owner]) => owner.socket === socket);
121
+ if (!oldest) break;
122
+ this.owners.delete(oldest[0]);
123
+ }
124
+ this.owners.set(baselineId, { socket, expiresAt: parsedExpiry });
125
+ return true;
126
+ }
127
+
128
+ /**
129
+ * @param {string} baselineId
130
+ * @returns {boolean}
131
+ */
132
+ delete(baselineId) {
133
+ return this.owners.delete(baselineId);
134
+ }
135
+
136
+ /**
137
+ * Drop a mapping only when the given socket still owns it, so one extension
138
+ * cannot evict another's baseline by reporting its ID.
139
+ *
140
+ * @param {string} baselineId
141
+ * @param {ClientSocket} socket
142
+ * @returns {boolean}
143
+ */
144
+ deleteIfOwnedBy(baselineId, socket) {
145
+ if (this.owners.get(baselineId)?.socket !== socket) return false;
146
+ return this.owners.delete(baselineId);
147
+ }
148
+
149
+ /**
150
+ * Forget every baseline and abandoned create belonging to a socket.
151
+ *
152
+ * @param {ClientSocket} socket
153
+ */
154
+ clearForSocket(socket) {
155
+ for (const [baselineId, owner] of this.owners) {
156
+ if (owner.socket === socket) this.owners.delete(baselineId);
157
+ }
158
+ for (const [requestId, abandoned] of this.abandonedCreates) {
159
+ if (abandoned.socket === socket) this.abandonedCreates.delete(requestId);
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Remember a `dom.baseline.create` whose agent disconnected before the
165
+ * extension replied, so the eventual response can be released rather than
166
+ * leaking a retained snapshot in the page.
167
+ *
168
+ * @param {string} requestId
169
+ * @param {{ method?: string, targets: Set<ClientSocket> }} pending
170
+ */
171
+ markAbandonedCreate(requestId, pending) {
172
+ if (pending.method !== 'dom.baseline.create') return;
173
+ const target = pending.targets.values().next().value;
174
+ if (!target) return;
175
+ const now = Date.now();
176
+ for (const [id, entry] of this.abandonedCreates) {
177
+ if (entry.expiresAt <= now) this.abandonedCreates.delete(id);
178
+ }
179
+ this.abandonedCreates.set(requestId, {
180
+ socket: target,
181
+ expiresAt: now + DOM_BASELINE_TTL_MS,
182
+ });
183
+ while (this.abandonedCreates.size > MAX_OWNER_MAPPINGS) {
184
+ const oldestId = this.abandonedCreates.keys().next().value;
185
+ if (typeof oldestId !== 'string') break;
186
+ this.abandonedCreates.delete(oldestId);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Claim an abandoned create for the socket that is answering it. Returns
192
+ * false when the request was not abandoned or belongs to another socket.
193
+ *
194
+ * @param {string} requestId
195
+ * @param {ClientSocket} socket
196
+ * @returns {boolean}
197
+ */
198
+ takeAbandonedCreate(requestId, socket) {
199
+ const abandoned = this.abandonedCreates.get(requestId);
200
+ if (abandoned?.socket !== socket) return false;
201
+ this.abandonedCreates.delete(requestId);
202
+ return true;
203
+ }
204
+
205
+ /** Drop all tracked state, used on daemon shutdown. */
206
+ clear() {
207
+ this.owners.clear();
208
+ this.abandonedCreates.clear();
209
+ }
210
+ }
@@ -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
+ parseJsonLines,
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';
@@ -110,6 +115,16 @@ function isHostActivity(message) {
110
115
  );
111
116
  }
112
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
+
113
128
  /**
114
129
  * @param {{ transport?: BridgeTransport, socketPath?: string }} [options={}]
115
130
  * @returns {Promise<void>}
@@ -130,7 +145,7 @@ export async function runNativeHost({
130
145
  response: createFailure(
131
146
  'native_bootstrap',
132
147
  ERROR_CODES.NATIVE_HOST_UNAVAILABLE,
133
- error instanceof Error ? error.message : String(error)
148
+ sanitizeIncidentalText(error instanceof Error ? error.message : String(error))
134
149
  ),
135
150
  });
136
151
  return;
@@ -155,32 +170,10 @@ export async function runNativeHost({
155
170
  ...(authToken ? { authToken } : {}),
156
171
  });
157
172
 
158
- let lineBuffer = '';
159
- socket.on('data', (chunk) => {
160
- lineBuffer += chunk;
161
- if (!lineBuffer.includes('\n') && Buffer.byteLength(lineBuffer, 'utf8') > MAX_JSON_LINE_BYTES) {
162
- console.error(`native-host: daemon JSON line exceeded ${MAX_JSON_LINE_BYTES} bytes`);
163
- socket.destroy();
164
- return;
165
- }
166
- while (lineBuffer.includes('\n')) {
167
- const index = lineBuffer.indexOf('\n');
168
- const line = lineBuffer.slice(0, index).trim();
169
- lineBuffer = lineBuffer.slice(index + 1);
170
- if (!line) {
171
- continue;
172
- }
173
- if (Buffer.byteLength(line, 'utf8') > MAX_JSON_LINE_BYTES) {
174
- console.error(`native-host: daemon JSON line exceeded ${MAX_JSON_LINE_BYTES} bytes`);
175
- socket.destroy();
176
- return;
177
- }
178
- let message;
179
- try {
180
- message = JSON.parse(line);
181
- } catch {
182
- continue;
183
- }
173
+ parseJsonLines(
174
+ socket,
175
+ (raw) => {
176
+ const message = /** @type {Record<string, unknown>} */ (raw);
184
177
  void (async () => {
185
178
  if (message.type === 'extension.request') {
186
179
  await writeNativeMessageQueued(message.request);
@@ -213,8 +206,16 @@ export async function runNativeHost({
213
206
  err instanceof Error ? err.message : err
214
207
  );
215
208
  });
209
+ },
210
+ {
211
+ onProtocolError: (error) => {
212
+ console.error(`native-host: ${error.message}`);
213
+ },
214
+ onInvalidLine: (error) => {
215
+ console.error(`native-host: ignoring malformed daemon JSON line: ${error.message}`);
216
+ },
216
217
  }
217
- });
218
+ );
218
219
 
219
220
  createNativeMessageReader(
220
221
  process.stdin,
@@ -257,6 +258,14 @@ export async function runNativeHost({
257
258
  });
258
259
  return;
259
260
  }
261
+ if (isHostArtifactMessage(message)) {
262
+ const record = /** @type {Record<string, unknown>} */ (message);
263
+ await writeJsonLine(socket, {
264
+ ...record,
265
+ type: String(record.type).replace(/^host\./u, 'extension.'),
266
+ });
267
+ return;
268
+ }
260
269
  await writeJsonLine(socket, {
261
270
  type: 'extension.response',
262
271
  response: message,
@@ -0,0 +1,182 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Shared budget-preset merge helpers. Presets fill gaps in method params;
5
+ * explicit per-call params always win. Used by both the MCP server and the
6
+ * agent CLI so `budgetPreset`/`--preset` behave identically.
7
+ */
8
+
9
+ import {
10
+ DEFAULT_CONSOLE_LIMIT,
11
+ DEFAULT_HAR_LIMIT,
12
+ DEFAULT_LOG_TAIL_LIMIT,
13
+ DEFAULT_MAX_HTML_LENGTH,
14
+ DEFAULT_NETWORK_LIMIT,
15
+ DEFAULT_PAGE_TEXT_BUDGET,
16
+ getBudgetPreset,
17
+ isBudgetPresetName,
18
+ } from './defaults.js';
19
+
20
+ /**
21
+ * @param {unknown} value
22
+ * @returns {import('./defaults.js').BudgetPresetName | null}
23
+ */
24
+ export function getBudgetPresetName(value) {
25
+ return isBudgetPresetName(value) ? value : null;
26
+ }
27
+
28
+ /**
29
+ * @template {{ budgetPreset?: unknown, maxNodes?: unknown, maxDepth?: unknown, textBudget?: unknown }} T
30
+ * @param {T} args
31
+ * @returns {T}
32
+ */
33
+ export function applyTreeBudgetPreset(args) {
34
+ const presetName = getBudgetPresetName(args.budgetPreset);
35
+ if (!presetName) {
36
+ return args;
37
+ }
38
+ const preset = getBudgetPreset(presetName);
39
+ return /** @type {T} */ ({
40
+ ...args,
41
+ maxNodes: args.maxNodes ?? preset.maxNodes,
42
+ maxDepth: args.maxDepth ?? preset.maxDepth,
43
+ textBudget: args.textBudget ?? preset.textBudget,
44
+ });
45
+ }
46
+
47
+ /**
48
+ * @template {{ budgetPreset?: unknown, textBudget?: unknown }} T
49
+ * @param {T} args
50
+ * @returns {T}
51
+ */
52
+ export function applyTextBudgetPreset(args) {
53
+ const presetName = getBudgetPresetName(args.budgetPreset);
54
+ if (!presetName) {
55
+ return args;
56
+ }
57
+ const preset = getBudgetPreset(presetName);
58
+ return /** @type {T} */ ({
59
+ ...args,
60
+ textBudget: args.textBudget ?? preset.textBudget,
61
+ });
62
+ }
63
+
64
+ /**
65
+ * @template {{ budgetPreset?: unknown, textBudget?: unknown }} T
66
+ * @param {T} args
67
+ * @returns {T}
68
+ */
69
+ export function applyPageTextBudgetPreset(args) {
70
+ const presetName = getBudgetPresetName(args.budgetPreset);
71
+ if (!presetName) {
72
+ return args;
73
+ }
74
+ const textBudgetByPreset = {
75
+ quick: 2000,
76
+ normal: DEFAULT_PAGE_TEXT_BUDGET,
77
+ deep: DEFAULT_PAGE_TEXT_BUDGET * 2,
78
+ };
79
+ return /** @type {T} */ ({
80
+ ...args,
81
+ textBudget: args.textBudget ?? textBudgetByPreset[presetName],
82
+ });
83
+ }
84
+
85
+ /**
86
+ * @template {{ budgetPreset?: unknown, limit?: unknown }} T
87
+ * @param {T} args
88
+ * @param {{ quick: number, normal: number, deep: number }} defaults
89
+ * @returns {T}
90
+ */
91
+ export function applyLimitBudgetPreset(args, defaults) {
92
+ const presetName = getBudgetPresetName(args.budgetPreset);
93
+ if (!presetName) {
94
+ return args;
95
+ }
96
+ return /** @type {T} */ ({
97
+ ...args,
98
+ limit: args.limit ?? defaults[presetName],
99
+ });
100
+ }
101
+
102
+ /**
103
+ * @template {{ budgetPreset?: unknown, maxLength?: unknown }} T
104
+ * @param {T} args
105
+ * @returns {T}
106
+ */
107
+ export function applyHtmlBudgetPreset(args) {
108
+ const presetName = getBudgetPresetName(args.budgetPreset);
109
+ if (!presetName) {
110
+ return args;
111
+ }
112
+ const maxLengthByPreset = {
113
+ quick: 600,
114
+ normal: DEFAULT_MAX_HTML_LENGTH,
115
+ deep: 6000,
116
+ };
117
+ return /** @type {T} */ ({
118
+ ...args,
119
+ maxLength: args.maxLength ?? maxLengthByPreset[presetName],
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Apply a preset to method parameters before dispatch. Explicit method params
125
+ * always win over preset defaults.
126
+ *
127
+ * @param {import('./types.js').BridgeMethod} method
128
+ * @param {Record<string, unknown>} params
129
+ * @param {unknown} budgetPreset
130
+ * @returns {Record<string, unknown>}
131
+ */
132
+ export function applyMethodBudgetPreset(method, params, budgetPreset) {
133
+ const args = { ...params, budgetPreset };
134
+ /** @type {Record<string, unknown>} */
135
+ let normalized = args;
136
+ if (
137
+ method === 'dom.query' ||
138
+ method === 'dom.get_accessibility_tree' ||
139
+ method === 'dom.baseline.create'
140
+ ) {
141
+ normalized = applyTreeBudgetPreset(args);
142
+ } else if (method === 'dom.baseline.compare') {
143
+ const preset = getBudgetPresetName(budgetPreset);
144
+ const defaults = { quick: 10, normal: 50, deep: 100 };
145
+ normalized = {
146
+ ...args,
147
+ maxChanges: params.maxChanges ?? (preset ? defaults[preset] : undefined),
148
+ };
149
+ } else if (method === 'dom.get_text') {
150
+ normalized = applyTextBudgetPreset(args);
151
+ } else if (method === 'dom.get_html') {
152
+ normalized = applyHtmlBudgetPreset(args);
153
+ } else if (method === 'page.get_text' || method === 'page.extract_content') {
154
+ normalized = applyPageTextBudgetPreset(args);
155
+ } else if (method === 'page.get_console') {
156
+ normalized = applyLimitBudgetPreset(args, {
157
+ quick: 10,
158
+ normal: DEFAULT_CONSOLE_LIMIT,
159
+ deep: 100,
160
+ });
161
+ } else if (method === 'page.get_network') {
162
+ normalized = applyLimitBudgetPreset(args, {
163
+ quick: 10,
164
+ normal: DEFAULT_NETWORK_LIMIT,
165
+ deep: 100,
166
+ });
167
+ } else if (method === 'network.export_har') {
168
+ normalized = applyLimitBudgetPreset(args, {
169
+ quick: 20,
170
+ normal: DEFAULT_HAR_LIMIT,
171
+ deep: 100,
172
+ });
173
+ } else if (method === 'log.tail') {
174
+ normalized = applyLimitBudgetPreset(args, {
175
+ quick: 10,
176
+ normal: DEFAULT_LOG_TAIL_LIMIT,
177
+ deep: 100,
178
+ });
179
+ }
180
+ const { budgetPreset: _budgetPreset, ...methodParams } = normalized;
181
+ return methodParams;
182
+ }
@@ -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
  ]);
@@ -21,6 +21,9 @@ export const DEFAULT_MAX_HTML_LENGTH = 2000;
21
21
  export const DEFAULT_A11Y_MAX_NODES = 500;
22
22
  export const DEFAULT_A11Y_MAX_DEPTH = 6;
23
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;
24
27
  export const DEFAULT_CONSOLE_LIMIT = 50;
25
28
  export const DEFAULT_VIEWPORT_WIDTH = 1280;
26
29
  export const DEFAULT_VIEWPORT_HEIGHT = 720;
@@ -34,6 +37,31 @@ export const MAX_NATIVE_MESSAGE_BYTES = 1_048_576;
34
37
  /** Maximum size of one newline-delimited daemon socket message in bytes. */
35
38
  export const MAX_JSON_LINE_BYTES = MAX_NATIVE_MESSAGE_BYTES;
36
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
+
37
65
  /** Default timeout for a bridge request awaiting an extension response (ms). */
38
66
  export const DEFAULT_DAEMON_PENDING_TIMEOUT_MS = 30_000;
39
67
 
@@ -16,7 +16,15 @@ export const ERROR_CODES = Object.freeze({
16
16
  DIALOG_NOT_OPEN: 'DIALOG_NOT_OPEN',
17
17
  DIALOG_ACTION_CONFLICT: 'DIALOG_ACTION_CONFLICT',
18
18
  RESULT_TRUNCATED: 'RESULT_TRUNCATED',
19
- 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',
20
28
  INTERNAL_ERROR: 'INTERNAL_ERROR',
21
29
  INVALID_REQUEST: 'INVALID_REQUEST',
22
30
  NATIVE_HOST_UNAVAILABLE: 'NATIVE_HOST_UNAVAILABLE',
@@ -38,6 +46,15 @@ export const ERROR_RECOVERY = Object.freeze({
38
46
  retry: false,
39
47
  hint: 'Result was truncated to fit the response budget. Narrow the query or raise the relevant budget if more detail is required.',
40
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
+ },
41
58
  [ERROR_CODES.ELEMENT_STALE]: {
42
59
  retry: false,
43
60
  alternativeMethod: 'dom.query',
@@ -97,10 +114,36 @@ export const ERROR_RECOVERY = Object.freeze({
97
114
  retryAfterMs: 1000,
98
115
  hint: 'Operation exceeded the time limit. Retry once, or simplify the request (smaller maxNodes, narrower selector).',
99
116
  },
100
- [ERROR_CODES.RATE_LIMITED]: {
101
- retry: true,
102
- retryAfterMs: 2000,
103
- 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.',
104
147
  },
105
148
  [ERROR_CODES.EXTENSION_DISCONNECTED]: {
106
149
  retry: true,
@@ -0,0 +1,34 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Minimal structural validation for HAR 1.2 documents shared by the CLI and
5
+ * the MCP server. Only the envelope is validated (`log.version` and the
6
+ * `entries` array); deeper field checks are left to HAR consumers.
7
+ */
8
+
9
+ /**
10
+ * @param {unknown} value
11
+ * @returns {value is import('./types.js').HarLog}
12
+ */
13
+ export function isHarLog(value) {
14
+ return Boolean(
15
+ value &&
16
+ typeof value === 'object' &&
17
+ !Array.isArray(value) &&
18
+ Reflect.get(value, 'version') === '1.2' &&
19
+ Array.isArray(Reflect.get(value, 'entries'))
20
+ );
21
+ }
22
+
23
+ /**
24
+ * @param {unknown} value
25
+ * @returns {value is import('./types.js').HarDocument}
26
+ */
27
+ export function isHarDocument(value) {
28
+ return Boolean(
29
+ value &&
30
+ typeof value === 'object' &&
31
+ !Array.isArray(value) &&
32
+ isHarLog(Reflect.get(value, 'log'))
33
+ );
34
+ }