@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
@@ -2,9 +2,15 @@
2
2
 
3
3
  import {
4
4
  DEFAULT_CONSOLE_LIMIT,
5
+ DEFAULT_HAR_LIMIT,
5
6
  DEFAULT_NETWORK_LIMIT,
7
+ describeBridgeMethods,
6
8
  ERROR_CODES,
7
9
  getErrorRecovery,
10
+ isBatchSafeBridgeCall,
11
+ isHarDocument,
12
+ MAX_BATCH_CALLS,
13
+ MAX_BATCH_CONCURRENCY,
8
14
  METHOD_SET,
9
15
  } from '../../protocol/src/index.js';
10
16
  import { createBridgeClientForDestination } from '../../agent-client/src/remotes.js';
@@ -33,63 +39,6 @@ import { createScreenshotResult } from './handlers-capture.js';
33
39
  /** @typedef {import('../../agent-client/src/client.js').BridgeClient} BridgeClient */
34
40
  /** @typedef {import('./handlers-utils.js').ToolResult} ToolResult */
35
41
 
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
42
  /** @type {Record<string, { method: BridgeMethod, params: (a: Record<string, unknown>) => Record<string, unknown> }>} */
94
43
  export const PAGE_ACTIONS = {
95
44
  state: { method: 'page.get_state', params: () => ({}) },
@@ -152,11 +101,19 @@ export const PAGE_ACTIONS = {
152
101
  capture: a.capture,
153
102
  }),
154
103
  },
104
+ har: {
105
+ method: 'network.export_har',
106
+ params: (a) => ({
107
+ limit: a.limit,
108
+ urlPattern: a.urlPattern,
109
+ delivery: a.delivery,
110
+ }),
111
+ },
155
112
  performance: { method: 'performance.get_metrics', params: () => ({}) },
156
113
  };
157
114
 
158
115
  /**
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
116
+ * @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
117
  * @returns {Promise<ToolResult>}
161
118
  */
162
119
  export async function handlePageTool(args) {
@@ -175,6 +132,12 @@ export async function handlePageTool(args) {
175
132
  normal: DEFAULT_NETWORK_LIMIT,
176
133
  deep: 100,
177
134
  });
135
+ } else if (args.action === 'har') {
136
+ normalizedArgs = applyLimitBudgetPreset(args, {
137
+ quick: 20,
138
+ normal: DEFAULT_HAR_LIMIT,
139
+ deep: 100,
140
+ });
178
141
  }
179
142
  const entry = PAGE_ACTIONS[normalizedArgs.action];
180
143
  if (!entry) return summarizeToolError(`Unsupported page action "${args.action}".`);
@@ -184,13 +147,204 @@ export async function handlePageTool(args) {
184
147
  ) {
185
148
  return summarizeToolError('expression is required for page evaluate.');
186
149
  }
187
- return callBridgeTool(entry.method, entry.params(normalizedArgs), {
150
+ const params = entry.params(normalizedArgs);
151
+ if (entry.method === 'network.export_har') {
152
+ return withToolClient(
153
+ async (client) => {
154
+ const response = await requestBridgeWithRetry(client, entry.method, params, {
155
+ tabId: typeof normalizedArgs.tabId === 'number' ? normalizedArgs.tabId : null,
156
+ source: REQUEST_SOURCE,
157
+ tokenBudget: getToolTokenBudget(normalizedArgs),
158
+ });
159
+ return createHarExportResult(response);
160
+ },
161
+ { destinationId: normalizedArgs.destinationId ?? null }
162
+ );
163
+ }
164
+ return callBridgeTool(entry.method, params, {
188
165
  tabId: typeof normalizedArgs.tabId === 'number' ? normalizedArgs.tabId : null,
189
166
  tokenBudget: getToolTokenBudget(normalizedArgs),
190
167
  destinationId: normalizedArgs.destinationId ?? null,
191
168
  });
192
169
  }
193
170
 
171
+ /**
172
+ * Read one chunk of a daemon-owned artifact or delete it. Reads are paged by
173
+ * the caller: pass the returned nextOffset until it comes back null, then
174
+ * verify the reassembled bytes against sha256.
175
+ *
176
+ * @param {{ action: string, artifactId?: string, offset?: number, limit?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
177
+ * @returns {Promise<ToolResult>}
178
+ */
179
+ export async function handleArtifactTool(args) {
180
+ if (args.action !== 'read' && args.action !== 'delete') {
181
+ return summarizeToolError(`Unsupported artifact action "${args.action}".`);
182
+ }
183
+ if (typeof args.artifactId !== 'string' || !args.artifactId.trim()) {
184
+ return summarizeToolError('artifactId is required.');
185
+ }
186
+ if (args.offset !== undefined && (!Number.isInteger(args.offset) || args.offset < 0)) {
187
+ return summarizeToolError('offset must be a non-negative integer.');
188
+ }
189
+ if (args.limit !== undefined && (!Number.isInteger(args.limit) || args.limit < 1)) {
190
+ return summarizeToolError('limit must be a positive integer.');
191
+ }
192
+ const method = /** @type {BridgeMethod} */ (
193
+ args.action === 'read' ? 'artifact.read' : 'artifact.delete'
194
+ );
195
+ const params =
196
+ args.action === 'read'
197
+ ? {
198
+ artifactId: args.artifactId,
199
+ offset: args.offset ?? 0,
200
+ ...(typeof args.limit === 'number' ? { maxBytes: args.limit } : {}),
201
+ }
202
+ : { artifactId: args.artifactId };
203
+ return withToolClient(
204
+ async (client) => {
205
+ const response = await requestBridgeWithRetry(client, method, params, {
206
+ tabId: null,
207
+ source: REQUEST_SOURCE,
208
+ tokenBudget: getToolTokenBudget(args),
209
+ });
210
+ return method === 'artifact.read'
211
+ ? createArtifactReadResult(response)
212
+ : summarizeToolResponse(response, method, params);
213
+ },
214
+ { destinationId: args.destinationId ?? null }
215
+ );
216
+ }
217
+
218
+ /**
219
+ * Manage CDP Fetch request interception rules for a tab.
220
+ *
221
+ * @param {{ action: string, urlPattern?: string, ruleAction?: 'block' | 'fulfill' | 'continue', statusCode?: number, body?: string, headers?: Record<string, string>, ruleId?: string, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
222
+ * @returns {Promise<ToolResult>}
223
+ */
224
+ export async function handleInterceptTool(args) {
225
+ const requestedTabId = typeof args.tabId === 'number' ? args.tabId : null;
226
+ return withToolClient(
227
+ async (client) => {
228
+ /** @type {BridgeMethod} */
229
+ let method;
230
+ /** @type {Record<string, unknown>} */
231
+ let params;
232
+ switch (args.action) {
233
+ case 'add': {
234
+ if (typeof args.urlPattern !== 'string' || !args.urlPattern.trim()) {
235
+ return summarizeToolError('urlPattern is required for intercept add.');
236
+ }
237
+ method = 'network.intercept.add';
238
+ params = {
239
+ urlPattern: args.urlPattern,
240
+ ...(args.ruleAction ? { action: args.ruleAction } : {}),
241
+ ...(typeof args.statusCode === 'number' ? { statusCode: args.statusCode } : {}),
242
+ ...(typeof args.body === 'string' ? { body: args.body } : {}),
243
+ ...(args.headers ? { headers: args.headers } : {}),
244
+ };
245
+ break;
246
+ }
247
+ case 'remove': {
248
+ if (typeof args.ruleId !== 'string' || !args.ruleId.trim()) {
249
+ return summarizeToolError('ruleId is required for intercept remove.');
250
+ }
251
+ method = 'network.intercept.remove';
252
+ params = { ruleId: args.ruleId };
253
+ break;
254
+ }
255
+ case 'list':
256
+ method = 'network.intercept.list';
257
+ params = {};
258
+ break;
259
+ case 'clear':
260
+ method = 'network.intercept.clear';
261
+ params = {};
262
+ break;
263
+ default:
264
+ return summarizeToolError(`Unsupported intercept action "${args.action}".`);
265
+ }
266
+ const response = await requestBridgeWithRetry(client, method, params, {
267
+ tabId: requestedTabId,
268
+ source: REQUEST_SOURCE,
269
+ tokenBudget: getToolTokenBudget(args),
270
+ });
271
+ return summarizeToolResponse(response, method, params);
272
+ },
273
+ { destinationId: args.destinationId ?? null }
274
+ );
275
+ }
276
+
277
+ /**
278
+ * @param {import('../../protocol/src/types.js').BridgeResponse} response
279
+ * @returns {ToolResult}
280
+ */
281
+ function createArtifactReadResult(response) {
282
+ if (!response.ok) return summarizeToolResponse(response, 'artifact.read', {});
283
+ const result =
284
+ response.result && typeof response.result === 'object' && !Array.isArray(response.result)
285
+ ? /** @type {Record<string, unknown>} */ (response.result)
286
+ : {};
287
+ if (
288
+ typeof result.artifactId !== 'string' ||
289
+ typeof result.data !== 'string' ||
290
+ typeof result.offset !== 'number' ||
291
+ typeof result.byteLength !== 'number' ||
292
+ typeof result.totalBytes !== 'number' ||
293
+ typeof result.sha256 !== 'string' ||
294
+ (result.nextOffset !== null && typeof result.nextOffset !== 'number')
295
+ ) {
296
+ return summarizeToolError('artifact.read returned invalid chunk metadata.');
297
+ }
298
+ const nextOffset = /** @type {number | null} */ (result.nextOffset);
299
+ return createToolResult(
300
+ `Read ${result.byteLength} bytes from artifact ${result.artifactId} at offset ${result.offset} (${result.totalBytes} bytes total). ${
301
+ nextOffset === null
302
+ ? 'Artifact fully read; verify the reassembled bytes against sha256.'
303
+ : `Continue reading with offset ${nextOffset}.`
304
+ }`,
305
+ {
306
+ ok: true,
307
+ method: 'artifact.read',
308
+ artifactId: result.artifactId,
309
+ data: result.data,
310
+ offset: result.offset,
311
+ byteLength: result.byteLength,
312
+ nextOffset,
313
+ totalBytes: result.totalBytes,
314
+ sha256: result.sha256,
315
+ chunkIndex: result.chunkIndex,
316
+ chunkCount: result.chunkCount,
317
+ expiresAt: result.expiresAt,
318
+ }
319
+ );
320
+ }
321
+
322
+ /**
323
+ * Deliberately read one exact storage value without retries, budgeting, or
324
+ * value-derived delivery metrics.
325
+ *
326
+ * @param {{ source: 'local_storage' | 'session_storage', key: string, tabId?: number, destinationId?: string }} args
327
+ * @returns {Promise<ToolResult>}
328
+ */
329
+ export async function handleSensitiveReadTool(args) {
330
+ return withToolClient(
331
+ async (client) => {
332
+ const response = await requestBridgeWithRetry(
333
+ client,
334
+ 'sensitive.read',
335
+ { source: args.source, key: args.key },
336
+ {
337
+ tabId: typeof args.tabId === 'number' ? args.tabId : null,
338
+ source: REQUEST_SOURCE,
339
+ tokenBudget: null,
340
+ }
341
+ );
342
+ return createSensitiveReadResult(response);
343
+ },
344
+ { destinationId: args.destinationId ?? null }
345
+ );
346
+ }
347
+
194
348
  /**
195
349
  * @param {{ calls?: Array<{ method?: string, params?: Record<string, unknown>, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }> }} args
196
350
  * @returns {Promise<ToolResult>}
@@ -354,6 +508,14 @@ export async function handleRawCallTool(args) {
354
508
  if (!METHOD_SET.has(/** @type {BridgeMethod} */ (args.method))) {
355
509
  return summarizeToolError(`Unknown bridge method "${args.method}".`);
356
510
  }
511
+ if (args.method === 'protocol.describe') {
512
+ try {
513
+ const result = describeBridgeMethods(args.params);
514
+ return createToolResult('Bridge protocol description loaded.', result);
515
+ } catch (error) {
516
+ return summarizeToolError(error);
517
+ }
518
+ }
357
519
  return withToolClient(
358
520
  async (client) => {
359
521
  const method = /** @type {BridgeMethod} */ (args.method);
@@ -361,17 +523,128 @@ export async function handleRawCallTool(args) {
361
523
  const response = await requestBridgeWithRetry(client, method, params, {
362
524
  tabId: typeof args.tabId === 'number' ? args.tabId : null,
363
525
  source: REQUEST_SOURCE,
364
- tokenBudget: method.startsWith('screenshot.') ? null : getToolTokenBudget(args),
526
+ tokenBudget:
527
+ method.startsWith('screenshot.') || method === 'sensitive.read'
528
+ ? null
529
+ : getToolTokenBudget(args),
365
530
  });
366
531
  if (response.ok && method.startsWith('screenshot.')) {
367
532
  return createScreenshotResult(response, method);
368
533
  }
534
+ if (method === 'network.export_har') {
535
+ return createHarExportResult(response);
536
+ }
537
+ if (method === 'sensitive.read') {
538
+ return createSensitiveReadResult(response);
539
+ }
369
540
  return summarizeToolResponse(response, method, params);
370
541
  },
371
542
  { destinationId: args.destinationId ?? null }
372
543
  );
373
544
  }
374
545
 
546
+ /**
547
+ * Preserve the atomic HAR payload exactly. Generic evidence bounding must not
548
+ * turn a valid HAR document into a structurally invalid partial document.
549
+ *
550
+ * @param {import('../../protocol/src/types.js').BridgeResponse} response
551
+ * @returns {ToolResult}
552
+ */
553
+ export function createHarExportResult(response) {
554
+ if (!response.ok) return summarizeToolResponse(response, 'network.export_har', {});
555
+ const result =
556
+ response.result && typeof response.result === 'object' && !Array.isArray(response.result)
557
+ ? /** @type {Record<string, unknown>} */ (response.result)
558
+ : {};
559
+ const numericFields = [
560
+ 'byteLength',
561
+ 'entryCount',
562
+ 'totalEntries',
563
+ 'dropped',
564
+ 'abandoned',
565
+ 'inflight',
566
+ ];
567
+ const captureState = result.captureState;
568
+ const validMetadata =
569
+ result.format === 'har' &&
570
+ result.harVersion === '1.2' &&
571
+ result.mimeType === 'application/json' &&
572
+ numericFields.every((field) => typeof result[field] === 'number') &&
573
+ (typeof result.startedAt === 'number' || result.startedAt === null) &&
574
+ (captureState === 'armed' ||
575
+ captureState === 'stop_failed' ||
576
+ captureState === 'stopped' ||
577
+ captureState === 'instrumented') &&
578
+ typeof result.truncated === 'boolean' &&
579
+ result.truncation !== null &&
580
+ typeof result.truncation === 'object';
581
+ if (!validMetadata) {
582
+ return summarizeToolError('network.export_har returned invalid HAR metadata.');
583
+ }
584
+
585
+ if (result.delivery === 'inline') {
586
+ if (!isHarDocument(result.har)) {
587
+ return summarizeToolError('network.export_har returned an invalid inline HAR document.');
588
+ }
589
+ } else if (result.delivery === 'artifact') {
590
+ const artifact =
591
+ result.artifact && typeof result.artifact === 'object' && !Array.isArray(result.artifact)
592
+ ? /** @type {Record<string, unknown>} */ (result.artifact)
593
+ : {};
594
+ if (
595
+ artifact.kind !== 'har' ||
596
+ artifact.mimeType !== 'application/json' ||
597
+ typeof artifact.artifactId !== 'string' ||
598
+ typeof artifact.byteLength !== 'number' ||
599
+ typeof artifact.sha256 !== 'string' ||
600
+ !/^[a-f0-9]{64}$/u.test(artifact.sha256) ||
601
+ typeof artifact.chunkSize !== 'number' ||
602
+ typeof artifact.chunkCount !== 'number' ||
603
+ typeof artifact.createdAt !== 'string' ||
604
+ typeof artifact.expiresAt !== 'string'
605
+ ) {
606
+ return summarizeToolError('network.export_har returned invalid artifact metadata.');
607
+ }
608
+ } else {
609
+ return summarizeToolError('network.export_har returned an invalid delivery mode.');
610
+ }
611
+
612
+ const summary = summarizeBridgeResponse(response, 'network.export_har');
613
+ return createToolResult(summary.summary, result);
614
+ }
615
+
616
+ /**
617
+ * @param {import('../../protocol/src/types.js').BridgeResponse} response
618
+ * @returns {ToolResult}
619
+ */
620
+ function createSensitiveReadResult(response) {
621
+ if (!response.ok) return summarizeToolResponse(response, 'sensitive.read', {});
622
+ const result =
623
+ response.result && typeof response.result === 'object'
624
+ ? /** @type {Record<string, unknown>} */ (response.result)
625
+ : {};
626
+ if (
627
+ (result.source !== 'local_storage' && result.source !== 'session_storage') ||
628
+ typeof result.value !== 'string' ||
629
+ result.exact !== true
630
+ ) {
631
+ return summarizeToolError('sensitive.read returned an invalid exact-value response.');
632
+ }
633
+ return {
634
+ content: [
635
+ {
636
+ type: 'text',
637
+ text: 'Sensitive storage read succeeded. The exact value is available in structuredContent.value.',
638
+ },
639
+ ],
640
+ structuredContent: {
641
+ source: result.source,
642
+ value: result.value,
643
+ exact: true,
644
+ },
645
+ };
646
+ }
647
+
375
648
  /**
376
649
  * @typedef {{ method: BridgeMethod, params: (args: Record<string, unknown>) => Record<string, unknown> }} InvestigateStep
377
650
  */