@browserbridge/bbx 1.8.0 → 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 (39) 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 +35 -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 +227 -19
  9. package/packages/agent-client/src/client.js +156 -3
  10. package/packages/agent-client/src/command-registry.js +8 -2
  11. package/packages/agent-client/src/config.js +195 -0
  12. package/packages/agent-client/src/npm-self-update.js +305 -0
  13. package/packages/agent-client/src/remotes.js +4 -2
  14. package/packages/agent-client/src/runtime.js +97 -5
  15. package/packages/agent-client/src/types.ts +38 -0
  16. package/packages/mcp-server/src/handlers-capture.js +29 -2
  17. package/packages/mcp-server/src/handlers-dom.js +54 -3
  18. package/packages/mcp-server/src/handlers-page.js +180 -60
  19. package/packages/mcp-server/src/handlers-utils.js +35 -4
  20. package/packages/mcp-server/src/handlers.js +10 -3
  21. package/packages/mcp-server/src/server.js +75 -6
  22. package/packages/native-host/src/artifact-store.js +353 -0
  23. package/packages/native-host/src/config.js +9 -1
  24. package/packages/native-host/src/daemon-logger.js +3 -1
  25. package/packages/native-host/src/daemon.js +543 -27
  26. package/packages/native-host/src/native-host.js +25 -2
  27. package/packages/protocol/src/capability-values.js +2 -0
  28. package/packages/protocol/src/defaults.js +28 -0
  29. package/packages/protocol/src/errors.js +48 -5
  30. package/packages/protocol/src/incidental-sanitizer.js +197 -0
  31. package/packages/protocol/src/index.js +2 -0
  32. package/packages/protocol/src/protocol.js +316 -6
  33. package/packages/protocol/src/recovery-telemetry.js +302 -0
  34. package/packages/protocol/src/registry.js +143 -6
  35. package/packages/protocol/src/summary.js +116 -25
  36. package/packages/protocol/src/types.ts +489 -4
  37. package/skills/browser-bridge/SKILL.md +22 -8
  38. package/skills/browser-bridge/references/protocol.md +117 -13
  39. package/skills/browser-bridge/references/token-efficiency.md +17 -7
@@ -4,6 +4,8 @@ import type {
4
4
  BridgeMethod,
5
5
  BridgeRequestSource,
6
6
  BridgeResponse,
7
+ RecoveryEventKind,
8
+ RecoveryTelemetrySummary,
7
9
  ScreenshotResult,
8
10
  SetupStatus,
9
11
  } from '../../protocol/src/types.js';
@@ -30,6 +32,26 @@ export type McpClientName =
30
32
 
31
33
  export type SupportedTarget = McpClientName;
32
34
 
35
+ export type AutoUpdatePolicy = 'off' | 'compatible';
36
+
37
+ export interface BrowserBridgeConfig {
38
+ autoUpdate: AutoUpdatePolicy;
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ export interface NpmUpdateResult {
43
+ updated: boolean;
44
+ reason:
45
+ | 'updated'
46
+ | 'invalid_extension_version'
47
+ | 'invalid_installed_version'
48
+ | 'extension_not_newer'
49
+ | 'not_global_install'
50
+ | 'no_compatible_update';
51
+ previousVersion?: string;
52
+ version?: string;
53
+ }
54
+
33
55
  export type Detector = () => boolean | Promise<boolean>;
34
56
 
35
57
  export interface InstallAgentOptions {
@@ -51,6 +73,8 @@ export interface SetupStatusOptions {
51
73
 
52
74
  export interface ProtocolHealthResult {
53
75
  extensionConnected?: boolean;
76
+ extensionVersion?: string;
77
+ daemonVersion?: string;
54
78
  supported_versions?: string[];
55
79
  extension_supported_versions?: string[];
56
80
  daemon_supported_versions?: string[];
@@ -91,6 +115,12 @@ export interface BridgeClientOptions {
91
115
  checkProtocolOnConnect?: boolean;
92
116
  restartDaemonOnVersionMismatch?: boolean;
93
117
  restartDaemonFn?: typeof restartBridgeDaemon;
118
+ updateNpmOnCompatibleVersion?: boolean;
119
+ exitProcessOnNpmUpdate?: boolean;
120
+ updateCompatibleNpmPackageFn?: (options: {
121
+ extensionVersion: string;
122
+ supportedVersions: readonly string[];
123
+ }) => Promise<NpmUpdateResult>;
94
124
  authToken?: string | null;
95
125
  }
96
126
 
@@ -182,6 +212,13 @@ export interface DoctorDaemonMetrics {
182
212
  requestsProcessed: number;
183
213
  requestsFailed: number;
184
214
  avgResponseTimeMs: number;
215
+ recovery: RecoveryTelemetrySummary | null;
216
+ }
217
+
218
+ export interface DoctorRecoveryDiagnostics {
219
+ daemon: RecoveryTelemetrySummary | null;
220
+ routedExtension: RecoveryTelemetrySummary | null;
221
+ activeLoops: RecoveryEventKind[];
185
222
  }
186
223
 
187
224
  export interface DoctorRecentEvent {
@@ -246,6 +283,7 @@ export interface DoctorReport {
246
283
  connections: DoctorConnectionDiagnostics;
247
284
  protocol: DoctorProtocolDiagnostics;
248
285
  debugger: DoctorDebuggerDiagnostics;
286
+ recovery: DoctorRecoveryDiagnostics;
249
287
  metrics: DoctorDaemonMetrics | null;
250
288
  recentEvents: DoctorRecentEvent[];
251
289
  recentCauses: DoctorRecentEvent['cause'][];
@@ -26,6 +26,8 @@ export const CAPTURE_ACTIONS = {
26
26
  elementRef: r,
27
27
  ...(a.format ? { format: a.format } : {}),
28
28
  ...(typeof a.quality === 'number' ? { quality: a.quality } : {}),
29
+ delivery: a.delivery ?? 'auto',
30
+ ...(typeof a.scale === 'number' ? { scale: a.scale } : {}),
29
31
  }),
30
32
  },
31
33
  region: {
@@ -35,6 +37,8 @@ export const CAPTURE_ACTIONS = {
35
37
  .../** @type {Record<string, unknown>} */ (a.rect || {}),
36
38
  ...(a.format ? { format: a.format } : {}),
37
39
  ...(typeof a.quality === 'number' ? { quality: a.quality } : {}),
40
+ delivery: a.delivery ?? 'auto',
41
+ ...(typeof a.scale === 'number' ? { scale: a.scale } : {}),
38
42
  }),
39
43
  },
40
44
  full_page: {
@@ -43,13 +47,15 @@ export const CAPTURE_ACTIONS = {
43
47
  params: (a) => ({
44
48
  ...(a.format ? { format: a.format } : {}),
45
49
  ...(typeof a.quality === 'number' ? { quality: a.quality } : {}),
50
+ delivery: a.delivery ?? 'auto',
51
+ ...(typeof a.scale === 'number' ? { scale: a.scale } : {}),
46
52
  }),
47
53
  },
48
54
  cdp_document: { ref: false, method: 'cdp.get_document', params: () => ({}) },
49
55
  cdp_dom_snapshot: {
50
56
  ref: false,
51
57
  method: 'cdp.get_dom_snapshot',
52
- params: () => ({}),
58
+ params: (a) => ({ computedStyles: a.computedStyles }),
53
59
  },
54
60
  cdp_box_model: {
55
61
  ref: false,
@@ -93,7 +99,7 @@ function isValidCaptureRegion(rect) {
93
99
  }
94
100
 
95
101
  /**
96
- * @param {{ action: string, elementRef?: string, selector?: string, rect?: Record<string, unknown>, format?: 'png' | 'jpeg' | 'webp', quality?: number, nodeId?: number, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
102
+ * @param {{ action: string, elementRef?: string, selector?: string, rect?: Record<string, unknown>, format?: 'png' | 'jpeg' | 'webp', quality?: number, delivery?: 'auto' | 'inline' | 'artifact', scale?: number, nodeId?: number, computedStyles?: string[], tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
97
103
  * @returns {Promise<ToolResult>}
98
104
  */
99
105
  export async function handleCaptureTool(args) {
@@ -141,6 +147,27 @@ export async function handleCaptureTool(args) {
141
147
  */
142
148
  export function createScreenshotResult(response, method) {
143
149
  const result = toRecord(response.result);
150
+ if (result.delivery === 'artifact') {
151
+ const artifact = toRecord(result.artifact);
152
+ if (typeof artifact.artifactId !== 'string' || typeof artifact.byteLength !== 'number') {
153
+ return summarizeToolError(new Error(`${method} returned invalid artifact metadata.`));
154
+ }
155
+ return createToolResult(
156
+ `Captured screenshot artifact (${artifact.byteLength} bytes, expires ${artifact.expiresAt ?? 'soon'}).`,
157
+ {
158
+ ok: true,
159
+ method,
160
+ delivery: 'artifact',
161
+ artifact,
162
+ mimeType: result.mimeType,
163
+ byteLength: result.byteLength,
164
+ dimensions: result.dimensions,
165
+ rect: boundedRect(result.rect),
166
+ complete: result.complete === true,
167
+ clipped: result.clipped === true,
168
+ }
169
+ );
170
+ }
144
171
  if (typeof result.image !== 'string') {
145
172
  return summarizeToolError(new Error(`${method} returned no image data.`));
146
173
  }
@@ -15,6 +15,32 @@ import {
15
15
 
16
16
  /** @type {Record<string, ToolAction>} */
17
17
  export const DOM_ACTIONS = {
18
+ baseline_create: {
19
+ ref: false,
20
+ method: 'dom.baseline.create',
21
+ params: (a) => ({
22
+ selector: a.selector,
23
+ maxNodes: a.maxNodes,
24
+ maxDepth: a.maxDepth,
25
+ textBudget: a.textBudget,
26
+ attributeAllowlist: a.attributeAllowlist,
27
+ }),
28
+ },
29
+ baseline_compare: {
30
+ ref: false,
31
+ method: 'dom.baseline.compare',
32
+ params: (a) => ({ baselineId: a.baselineId, maxChanges: a.maxChanges }),
33
+ },
34
+ baseline_describe: {
35
+ ref: false,
36
+ method: 'dom.baseline.describe',
37
+ params: (a) => ({ baselineId: a.baselineId }),
38
+ },
39
+ baseline_release: {
40
+ ref: false,
41
+ method: 'dom.baseline.release',
42
+ params: (a) => ({ baselineId: a.baselineId }),
43
+ },
18
44
  query: {
19
45
  ref: false,
20
46
  method: 'dom.query',
@@ -96,7 +122,7 @@ export const DOM_ACTIONS = {
96
122
  };
97
123
 
98
124
  /**
99
- * @param {{ action: string, selector?: string, elementRef?: string, withinRef?: string, maxNodes?: number, maxDepth?: number, compact?: boolean, interactiveOnly?: boolean, textBudget?: number, includeBbox?: boolean, attributeAllowlist?: string[], attributes?: string[], text?: string, exact?: boolean, maxResults?: number, role?: string, name?: string, state?: string, timeoutMs?: number, outer?: boolean, maxLength?: number, tabId?: number, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
125
+ * @param {{ action: string, baselineId?: string, maxChanges?: number, selector?: string, elementRef?: string, withinRef?: string, maxNodes?: number, maxDepth?: number, compact?: boolean, interactiveOnly?: boolean, textBudget?: number, includeBbox?: boolean, attributeAllowlist?: string[], attributes?: string[], text?: string, exact?: boolean, maxResults?: number, role?: string, name?: string, state?: string, timeoutMs?: number, outer?: boolean, maxLength?: number, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
100
126
  * @returns {Promise<ToolResult>}
101
127
  */
102
128
  export async function handleDomTool(args) {
@@ -109,7 +135,17 @@ export async function handleDomTool(args) {
109
135
  if (args.action === 'find_role' && !hasText(args.role)) {
110
136
  return summarizeToolError('role is required for dom.find_by_role.');
111
137
  }
112
- if (args.action === 'query' || args.action === 'accessibility_tree') {
138
+ if (
139
+ ['baseline_compare', 'baseline_describe', 'baseline_release'].includes(args.action) &&
140
+ !hasText(args.baselineId)
141
+ ) {
142
+ return summarizeToolError('baselineId is required for this DOM baseline action.');
143
+ }
144
+ if (
145
+ args.action === 'query' ||
146
+ args.action === 'accessibility_tree' ||
147
+ args.action === 'baseline_create'
148
+ ) {
113
149
  const inferred =
114
150
  args.action === 'accessibility_tree' ? 'normal' : inferBudgetFromSelector(args);
115
151
  const withBudget = inferred ? { ...args, budgetPreset: args.budgetPreset ?? inferred } : args;
@@ -118,6 +154,18 @@ export async function handleDomTool(args) {
118
154
  if (args.action === 'text') {
119
155
  return dispatchToolAction(DOM_ACTIONS, applyTextBudgetPreset(args), 'DOM');
120
156
  }
157
+ if (args.action === 'baseline_compare') {
158
+ const byPreset = { quick: 10, normal: 50, deep: 100 };
159
+ return dispatchToolAction(
160
+ DOM_ACTIONS,
161
+ {
162
+ ...args,
163
+ maxChanges:
164
+ args.maxChanges ?? (args.budgetPreset ? byPreset[args.budgetPreset] : undefined),
165
+ },
166
+ 'DOM'
167
+ );
168
+ }
121
169
  if (args.action === 'html') {
122
170
  return dispatchToolAction(DOM_ACTIONS, applyHtmlBudgetPreset(args), 'DOM');
123
171
  }
@@ -189,11 +237,14 @@ export const PATCH_ACTIONS = {
189
237
  removeAttribute: 'remove_attribute',
190
238
  addClass: 'add_class',
191
239
  removeClass: 'remove_class',
240
+ toggleClass: 'toggle_class',
192
241
  setTextContent: 'set_text',
193
242
  };
194
243
  const normalizedOperation = opMap[operation] || operation;
195
244
  const value =
196
- normalizedOperation === 'add_class' || normalizedOperation === 'remove_class'
245
+ normalizedOperation === 'add_class' ||
246
+ normalizedOperation === 'remove_class' ||
247
+ normalizedOperation === 'toggle_class'
197
248
  ? (a.value ?? a.name)
198
249
  : a.value;
199
250
  return {
@@ -2,9 +2,13 @@
2
2
 
3
3
  import {
4
4
  DEFAULT_CONSOLE_LIMIT,
5
+ DEFAULT_HAR_LIMIT,
5
6
  DEFAULT_NETWORK_LIMIT,
6
7
  ERROR_CODES,
7
8
  getErrorRecovery,
9
+ isBatchSafeBridgeCall,
10
+ MAX_BATCH_CALLS,
11
+ MAX_BATCH_CONCURRENCY,
8
12
  METHOD_SET,
9
13
  } from '../../protocol/src/index.js';
10
14
  import { createBridgeClientForDestination } from '../../agent-client/src/remotes.js';
@@ -33,63 +37,6 @@ import { createScreenshotResult } from './handlers-capture.js';
33
37
  /** @typedef {import('../../agent-client/src/client.js').BridgeClient} BridgeClient */
34
38
  /** @typedef {import('./handlers-utils.js').ToolResult} ToolResult */
35
39
 
36
- export const MAX_BATCH_CALLS = 20;
37
- export const MAX_BATCH_CONCURRENCY = 5;
38
-
39
- /** @type {ReadonlySet<BridgeMethod>} */
40
- const BATCH_SAFE_METHODS = new Set([
41
- 'health.ping',
42
- 'daemon.metrics',
43
- 'tabs.list',
44
- 'skill.get_runtime_context',
45
- 'setup.get_status',
46
- 'log.tail',
47
- 'page.get_state',
48
- 'page.get_console',
49
- 'page.wait_for_load_state',
50
- 'page.get_storage',
51
- 'page.get_text',
52
- 'page.extract_content',
53
- 'page.get_network',
54
- 'dom.query',
55
- 'dom.describe',
56
- 'dom.get_text',
57
- 'dom.get_attributes',
58
- 'dom.wait_for',
59
- 'dom.find_by_text',
60
- 'dom.find_by_role',
61
- 'dom.get_html',
62
- 'dom.get_accessibility_tree',
63
- 'layout.get_box_model',
64
- 'layout.hit_test',
65
- 'styles.get_computed',
66
- 'styles.get_matched_rules',
67
- 'network.intercept.list',
68
- 'patch.list',
69
- 'performance.get_metrics',
70
- ]);
71
-
72
- /**
73
- * @param {BridgeMethod} method
74
- * @param {Record<string, unknown>} params
75
- * @returns {boolean}
76
- */
77
- export function isBatchSafeBridgeCall(method, params) {
78
- if (!BATCH_SAFE_METHODS.has(method)) return false;
79
- if ((method === 'page.get_console' || method === 'page.get_network') && params.clear === true) {
80
- return false;
81
- }
82
- if (
83
- method === 'page.get_network' &&
84
- params.source === 'cdp' &&
85
- params.capture !== undefined &&
86
- params.capture !== 'read'
87
- ) {
88
- return false;
89
- }
90
- return true;
91
- }
92
-
93
40
  /** @type {Record<string, { method: BridgeMethod, params: (a: Record<string, unknown>) => Record<string, unknown> }>} */
94
41
  export const PAGE_ACTIONS = {
95
42
  state: { method: 'page.get_state', params: () => ({}) },
@@ -152,11 +99,19 @@ export const PAGE_ACTIONS = {
152
99
  capture: a.capture,
153
100
  }),
154
101
  },
102
+ har: {
103
+ method: 'network.export_har',
104
+ params: (a) => ({
105
+ limit: a.limit,
106
+ urlPattern: a.urlPattern,
107
+ delivery: a.delivery,
108
+ }),
109
+ },
155
110
  performance: { method: 'performance.get_metrics', params: () => ({}) },
156
111
  };
157
112
 
158
113
  /**
159
- * @param {{ action: string, expression?: string, awaitPromise?: boolean, timeoutMs?: number, returnByValue?: boolean, level?: string, clear?: boolean, limit?: number, type?: string, keys?: string[], textBudget?: number, format?: 'text' | 'markdown', selector?: string, includeMetadata?: boolean, consistency?: 'best_effort' | 'settled', settleTimeoutMs?: number, urlPattern?: string, source?: 'fetch-xhr' | 'cdp', capture?: 'read' | 'start' | 'clear' | 'stop', dialogAction?: string, promptText?: string, expectedDialogId?: string, waitForLoad?: boolean, url?: string, urlMatch?: string, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
114
+ * @param {{ action: string, expression?: string, awaitPromise?: boolean, timeoutMs?: number, returnByValue?: boolean, level?: string, clear?: boolean, limit?: number, type?: string, keys?: string[], textBudget?: number, format?: 'text' | 'markdown', selector?: string, includeMetadata?: boolean, consistency?: 'best_effort' | 'settled', settleTimeoutMs?: number, urlPattern?: string, source?: 'fetch-xhr' | 'cdp', capture?: 'read' | 'start' | 'clear' | 'stop', delivery?: 'auto' | 'inline' | 'artifact', dialogAction?: string, promptText?: string, expectedDialogId?: string, waitForLoad?: boolean, url?: string, urlMatch?: string, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
160
115
  * @returns {Promise<ToolResult>}
161
116
  */
162
117
  export async function handlePageTool(args) {
@@ -175,6 +130,12 @@ export async function handlePageTool(args) {
175
130
  normal: DEFAULT_NETWORK_LIMIT,
176
131
  deep: 100,
177
132
  });
133
+ } else if (args.action === 'har') {
134
+ normalizedArgs = applyLimitBudgetPreset(args, {
135
+ quick: 20,
136
+ normal: DEFAULT_HAR_LIMIT,
137
+ deep: 100,
138
+ });
178
139
  }
179
140
  const entry = PAGE_ACTIONS[normalizedArgs.action];
180
141
  if (!entry) return summarizeToolError(`Unsupported page action "${args.action}".`);
@@ -184,13 +145,53 @@ export async function handlePageTool(args) {
184
145
  ) {
185
146
  return summarizeToolError('expression is required for page evaluate.');
186
147
  }
187
- return callBridgeTool(entry.method, entry.params(normalizedArgs), {
148
+ const params = entry.params(normalizedArgs);
149
+ if (entry.method === 'network.export_har') {
150
+ return withToolClient(
151
+ async (client) => {
152
+ const response = await requestBridgeWithRetry(client, entry.method, params, {
153
+ tabId: typeof normalizedArgs.tabId === 'number' ? normalizedArgs.tabId : null,
154
+ source: REQUEST_SOURCE,
155
+ tokenBudget: getToolTokenBudget(normalizedArgs),
156
+ });
157
+ return createHarExportResult(response);
158
+ },
159
+ { destinationId: normalizedArgs.destinationId ?? null }
160
+ );
161
+ }
162
+ return callBridgeTool(entry.method, params, {
188
163
  tabId: typeof normalizedArgs.tabId === 'number' ? normalizedArgs.tabId : null,
189
164
  tokenBudget: getToolTokenBudget(normalizedArgs),
190
165
  destinationId: normalizedArgs.destinationId ?? null,
191
166
  });
192
167
  }
193
168
 
169
+ /**
170
+ * Deliberately read one exact storage value without retries, budgeting, or
171
+ * value-derived delivery metrics.
172
+ *
173
+ * @param {{ source: 'local_storage' | 'session_storage', key: string, tabId?: number, destinationId?: string }} args
174
+ * @returns {Promise<ToolResult>}
175
+ */
176
+ export async function handleSensitiveReadTool(args) {
177
+ return withToolClient(
178
+ async (client) => {
179
+ const response = await requestBridgeWithRetry(
180
+ client,
181
+ 'sensitive.read',
182
+ { source: args.source, key: args.key },
183
+ {
184
+ tabId: typeof args.tabId === 'number' ? args.tabId : null,
185
+ source: REQUEST_SOURCE,
186
+ tokenBudget: null,
187
+ }
188
+ );
189
+ return createSensitiveReadResult(response);
190
+ },
191
+ { destinationId: args.destinationId ?? null }
192
+ );
193
+ }
194
+
194
195
  /**
195
196
  * @param {{ calls?: Array<{ method?: string, params?: Record<string, unknown>, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }> }} args
196
197
  * @returns {Promise<ToolResult>}
@@ -361,17 +362,136 @@ export async function handleRawCallTool(args) {
361
362
  const response = await requestBridgeWithRetry(client, method, params, {
362
363
  tabId: typeof args.tabId === 'number' ? args.tabId : null,
363
364
  source: REQUEST_SOURCE,
364
- tokenBudget: method.startsWith('screenshot.') ? null : getToolTokenBudget(args),
365
+ tokenBudget:
366
+ method.startsWith('screenshot.') || method === 'sensitive.read'
367
+ ? null
368
+ : getToolTokenBudget(args),
365
369
  });
366
370
  if (response.ok && method.startsWith('screenshot.')) {
367
371
  return createScreenshotResult(response, method);
368
372
  }
373
+ if (method === 'network.export_har') {
374
+ return createHarExportResult(response);
375
+ }
376
+ if (method === 'sensitive.read') {
377
+ return createSensitiveReadResult(response);
378
+ }
369
379
  return summarizeToolResponse(response, method, params);
370
380
  },
371
381
  { destinationId: args.destinationId ?? null }
372
382
  );
373
383
  }
374
384
 
385
+ /**
386
+ * Preserve the atomic HAR payload exactly. Generic evidence bounding must not
387
+ * turn a valid HAR document into a structurally invalid partial document.
388
+ *
389
+ * @param {import('../../protocol/src/types.js').BridgeResponse} response
390
+ * @returns {ToolResult}
391
+ */
392
+ export function createHarExportResult(response) {
393
+ if (!response.ok) return summarizeToolResponse(response, 'network.export_har', {});
394
+ const result =
395
+ response.result && typeof response.result === 'object' && !Array.isArray(response.result)
396
+ ? /** @type {Record<string, unknown>} */ (response.result)
397
+ : {};
398
+ const numericFields = [
399
+ 'byteLength',
400
+ 'entryCount',
401
+ 'totalEntries',
402
+ 'dropped',
403
+ 'abandoned',
404
+ 'inflight',
405
+ ];
406
+ const captureState = result.captureState;
407
+ const validMetadata =
408
+ result.format === 'har' &&
409
+ result.harVersion === '1.2' &&
410
+ result.mimeType === 'application/json' &&
411
+ numericFields.every((field) => typeof result[field] === 'number') &&
412
+ (typeof result.startedAt === 'number' || result.startedAt === null) &&
413
+ (captureState === 'armed' ||
414
+ captureState === 'stop_failed' ||
415
+ captureState === 'stopped' ||
416
+ captureState === 'instrumented') &&
417
+ typeof result.truncated === 'boolean' &&
418
+ result.truncation !== null &&
419
+ typeof result.truncation === 'object';
420
+ if (!validMetadata) {
421
+ return summarizeToolError('network.export_har returned invalid HAR metadata.');
422
+ }
423
+
424
+ if (result.delivery === 'inline') {
425
+ const har =
426
+ result.har && typeof result.har === 'object' && !Array.isArray(result.har)
427
+ ? /** @type {Record<string, unknown>} */ (result.har)
428
+ : {};
429
+ const log =
430
+ har.log && typeof har.log === 'object' && !Array.isArray(har.log)
431
+ ? /** @type {Record<string, unknown>} */ (har.log)
432
+ : {};
433
+ if (log.version !== '1.2' || !Array.isArray(log.entries)) {
434
+ return summarizeToolError('network.export_har returned an invalid inline HAR document.');
435
+ }
436
+ } else if (result.delivery === 'artifact') {
437
+ const artifact =
438
+ result.artifact && typeof result.artifact === 'object' && !Array.isArray(result.artifact)
439
+ ? /** @type {Record<string, unknown>} */ (result.artifact)
440
+ : {};
441
+ if (
442
+ artifact.kind !== 'har' ||
443
+ artifact.mimeType !== 'application/json' ||
444
+ typeof artifact.artifactId !== 'string' ||
445
+ typeof artifact.byteLength !== 'number' ||
446
+ typeof artifact.sha256 !== 'string' ||
447
+ !/^[a-f0-9]{64}$/u.test(artifact.sha256) ||
448
+ typeof artifact.chunkSize !== 'number' ||
449
+ typeof artifact.chunkCount !== 'number' ||
450
+ typeof artifact.createdAt !== 'string' ||
451
+ typeof artifact.expiresAt !== 'string'
452
+ ) {
453
+ return summarizeToolError('network.export_har returned invalid artifact metadata.');
454
+ }
455
+ } else {
456
+ return summarizeToolError('network.export_har returned an invalid delivery mode.');
457
+ }
458
+
459
+ const summary = summarizeBridgeResponse(response, 'network.export_har');
460
+ return createToolResult(summary.summary, result);
461
+ }
462
+
463
+ /**
464
+ * @param {import('../../protocol/src/types.js').BridgeResponse} response
465
+ * @returns {ToolResult}
466
+ */
467
+ function createSensitiveReadResult(response) {
468
+ if (!response.ok) return summarizeToolResponse(response, 'sensitive.read', {});
469
+ const result =
470
+ response.result && typeof response.result === 'object'
471
+ ? /** @type {Record<string, unknown>} */ (response.result)
472
+ : {};
473
+ if (
474
+ (result.source !== 'local_storage' && result.source !== 'session_storage') ||
475
+ typeof result.value !== 'string' ||
476
+ result.exact !== true
477
+ ) {
478
+ return summarizeToolError('sensitive.read returned an invalid exact-value response.');
479
+ }
480
+ return {
481
+ content: [
482
+ {
483
+ type: 'text',
484
+ text: 'Sensitive storage read succeeded. The exact value is available in structuredContent.value.',
485
+ },
486
+ ],
487
+ structuredContent: {
488
+ source: result.source,
489
+ value: result.value,
490
+ exact: true,
491
+ },
492
+ };
493
+ }
494
+
375
495
  /**
376
496
  * @typedef {{ method: BridgeMethod, params: (args: Record<string, unknown>) => Record<string, unknown> }} InvestigateStep
377
497
  */
@@ -1,9 +1,12 @@
1
1
  // @ts-check
2
2
 
3
+ import { randomUUID } from 'node:crypto';
4
+
3
5
  import {
4
6
  BridgeError,
5
7
  bridgeMethodNeedsTab,
6
8
  DEFAULT_CONSOLE_LIMIT,
9
+ DEFAULT_HAR_LIMIT,
7
10
  DEFAULT_LOG_TAIL_LIMIT,
8
11
  DEFAULT_MAX_HTML_LENGTH,
9
12
  DEFAULT_MAX_NODES,
@@ -33,6 +36,7 @@ import { annotateBridgeSummary, summarizeBridgeResponse } from '../../agent-clie
33
36
  /** @typedef {import('../../protocol/src/types.js').BridgeResponse} BridgeResponse */
34
37
 
35
38
  export const REQUEST_SOURCE = 'mcp';
39
+ const MCP_CLIENT_ID = `mcp_${randomUUID()}`;
36
40
 
37
41
  /** @type {ReadonlySet<BridgeMethod>} */
38
42
  const RETRY_SAFE_METHODS = new Set([
@@ -47,6 +51,8 @@ const RETRY_SAFE_METHODS = new Set([
47
51
  'page.get_text',
48
52
  'page.extract_content',
49
53
  'dom.query',
54
+ 'dom.baseline.compare',
55
+ 'dom.baseline.describe',
50
56
  'dom.describe',
51
57
  'dom.get_text',
52
58
  'dom.get_attributes',
@@ -198,10 +204,14 @@ export function summarizeToolError(error) {
198
204
  export async function withToolClient(callback, options = {}) {
199
205
  try {
200
206
  if (!options.destinationId) {
201
- return await withBridgeClient(callback, { checkProtocolOnConnect: false });
207
+ return await withBridgeClient(callback, {
208
+ checkProtocolOnConnect: false,
209
+ clientId: MCP_CLIENT_ID,
210
+ });
202
211
  }
203
212
  const client = await createBridgeClientForDestination(options.destinationId, {
204
213
  checkProtocolOnConnect: false,
214
+ clientId: MCP_CLIENT_ID,
205
215
  });
206
216
  await client.connect();
207
217
  try {
@@ -367,9 +377,21 @@ export function applyHtmlBudgetPreset(args) {
367
377
  */
368
378
  export function applyMethodBudgetPreset(method, params, budgetPreset) {
369
379
  const args = { ...params, budgetPreset };
380
+ /** @type {Record<string, unknown>} */
370
381
  let normalized = args;
371
- if (method === 'dom.query' || method === 'dom.get_accessibility_tree') {
382
+ if (
383
+ method === 'dom.query' ||
384
+ method === 'dom.get_accessibility_tree' ||
385
+ method === 'dom.baseline.create'
386
+ ) {
372
387
  normalized = applyTreeBudgetPreset(args);
388
+ } else if (method === 'dom.baseline.compare') {
389
+ const preset = getBudgetPresetName(budgetPreset);
390
+ const defaults = { quick: 10, normal: 50, deep: 100 };
391
+ normalized = {
392
+ ...args,
393
+ maxChanges: params.maxChanges ?? (preset ? defaults[preset] : undefined),
394
+ };
373
395
  } else if (method === 'dom.get_text') {
374
396
  normalized = applyTextBudgetPreset(args);
375
397
  } else if (method === 'dom.get_html') {
@@ -388,6 +410,12 @@ export function applyMethodBudgetPreset(method, params, budgetPreset) {
388
410
  normal: DEFAULT_NETWORK_LIMIT,
389
411
  deep: 100,
390
412
  });
413
+ } else if (method === 'network.export_har') {
414
+ normalized = applyLimitBudgetPreset(args, {
415
+ quick: 20,
416
+ normal: DEFAULT_HAR_LIMIT,
417
+ deep: 100,
418
+ });
391
419
  } else if (method === 'log.tail') {
392
420
  normalized = applyLimitBudgetPreset(args, {
393
421
  quick: 10,
@@ -637,7 +665,7 @@ function compactDomNode(value) {
637
665
  * @param {import('../../agent-client/src/client.js').BridgeClient} client
638
666
  * @param {BridgeMethod} method
639
667
  * @param {Record<string, unknown>} params
640
- * @param {{ tabId?: number | null, source?: import('../../protocol/src/types.js').BridgeRequestSource, tokenBudget?: number | null }} options
668
+ * @param {{ tabId?: number | null, source?: import('../../protocol/src/types.js').BridgeRequestSource, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} options
641
669
  * @returns {Promise<BridgeResponse>}
642
670
  */
643
671
  export async function requestBridgeWithRetry(client, method, params, options) {
@@ -652,7 +680,10 @@ export async function requestBridgeWithRetry(client, method, params, options) {
652
680
  `[bbx-mcp] Retrying ${method} after ${delay}ms (${response.error.code})\n`
653
681
  );
654
682
  await new Promise((r) => setTimeout(r, delay));
655
- return requestBridge(client, method, params, options);
683
+ return requestBridge(client, method, params, {
684
+ ...options,
685
+ automaticRetry: 'mcp_second_attempt',
686
+ });
656
687
  }
657
688
  return response;
658
689
  }
@@ -54,15 +54,17 @@ export {
54
54
  } from './handlers-capture.js';
55
55
 
56
56
  export {
57
- MAX_BATCH_CALLS,
58
- MAX_BATCH_CONCURRENCY,
59
57
  PAGE_ACTIONS,
60
58
  handlePageTool,
59
+ handleSensitiveReadTool,
61
60
  handleBatchTool,
62
61
  handleRawCallTool,
62
+ createHarExportResult,
63
63
  handleInvestigateTool,
64
64
  } from './handlers-page.js';
65
65
 
66
+ export { MAX_BATCH_CALLS, MAX_BATCH_CONCURRENCY } from '../../protocol/src/index.js';
67
+
66
68
  /** @typedef {import('./handlers-utils.js').ToolAction} ToolAction */
67
69
  /** @typedef {import('./handlers-utils.js').ToolResult} ToolResult */
68
70
 
@@ -97,8 +99,13 @@ export async function handleStatusTool(args = {}) {
97
99
  }
98
100
 
99
101
  const report = await getDoctorReport();
102
+ const configuredDestinations = await getBridgeDestinations();
103
+ const destinationsToInspect =
104
+ requestedDestinationId === 'local'
105
+ ? configuredDestinations.filter((destination) => destination.local)
106
+ : configuredDestinations;
100
107
  const destinations = await Promise.all(
101
- (await getBridgeDestinations()).map(async (destination) => {
108
+ destinationsToInspect.map(async (destination) => {
102
109
  if (destination.local) {
103
110
  return {
104
111
  ...destination,