@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
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
2
 
3
+ import { randomUUID } from 'node:crypto';
4
+
3
5
  import {
4
6
  BridgeError,
5
7
  bridgeMethodNeedsTab,
@@ -12,11 +14,21 @@ import {
12
14
  DEFAULT_TEXT_BUDGET,
13
15
  estimateJsonPayloadCost,
14
16
  getBudgetPreset,
17
+ getBudgetPresetName,
15
18
  getErrorRecovery,
16
- isBudgetPresetName,
17
19
  summarizeBatchErrorItem,
18
20
  summarizeBatchResponseItem,
19
21
  } from '../../protocol/src/index.js';
22
+
23
+ export {
24
+ applyHtmlBudgetPreset,
25
+ applyLimitBudgetPreset,
26
+ applyMethodBudgetPreset,
27
+ applyPageTextBudgetPreset,
28
+ applyTextBudgetPreset,
29
+ applyTreeBudgetPreset,
30
+ getBudgetPresetName,
31
+ } from '../../protocol/src/index.js';
20
32
  import {
21
33
  getDoctorReport,
22
34
  requestBridge,
@@ -33,6 +45,7 @@ import { annotateBridgeSummary, summarizeBridgeResponse } from '../../agent-clie
33
45
  /** @typedef {import('../../protocol/src/types.js').BridgeResponse} BridgeResponse */
34
46
 
35
47
  export const REQUEST_SOURCE = 'mcp';
48
+ const MCP_CLIENT_ID = `mcp_${randomUUID()}`;
36
49
 
37
50
  /** @type {ReadonlySet<BridgeMethod>} */
38
51
  const RETRY_SAFE_METHODS = new Set([
@@ -47,6 +60,8 @@ const RETRY_SAFE_METHODS = new Set([
47
60
  'page.get_text',
48
61
  'page.extract_content',
49
62
  'dom.query',
63
+ 'dom.baseline.compare',
64
+ 'dom.baseline.describe',
50
65
  'dom.describe',
51
66
  'dom.get_text',
52
67
  'dom.get_attributes',
@@ -62,6 +77,8 @@ const RETRY_SAFE_METHODS = new Set([
62
77
  'screenshot.capture_region',
63
78
  'screenshot.capture_element',
64
79
  'screenshot.capture_full_page',
80
+ 'artifact.read',
81
+ 'network.intercept.list',
65
82
  'performance.get_metrics',
66
83
  'cdp.get_document',
67
84
  'cdp.get_dom_snapshot',
@@ -198,10 +215,14 @@ export function summarizeToolError(error) {
198
215
  export async function withToolClient(callback, options = {}) {
199
216
  try {
200
217
  if (!options.destinationId) {
201
- return await withBridgeClient(callback, { checkProtocolOnConnect: false });
218
+ return await withBridgeClient(callback, {
219
+ checkProtocolOnConnect: false,
220
+ clientId: MCP_CLIENT_ID,
221
+ });
202
222
  }
203
223
  const client = await createBridgeClientForDestination(options.destinationId, {
204
224
  checkProtocolOnConnect: false,
225
+ clientId: MCP_CLIENT_ID,
205
226
  });
206
227
  await client.connect();
207
228
  try {
@@ -230,14 +251,6 @@ export async function resolveToolRef(client, input, tabId = null) {
230
251
  throw new BridgeError('INVALID_REQUEST', 'Provide either elementRef or selector.');
231
252
  }
232
253
 
233
- /**
234
- * @param {unknown} value
235
- * @returns {'quick' | 'normal' | 'deep' | null}
236
- */
237
- export function getBudgetPresetName(value) {
238
- return isBudgetPresetName(value) ? value : null;
239
- }
240
-
241
254
  /**
242
255
  * @param {{ budgetPreset?: unknown, selector?: unknown, elementRef?: unknown }} args
243
256
  * @returns {'quick' | 'normal' | 'deep' | null}
@@ -261,144 +274,6 @@ export function getToolTokenBudget(args) {
261
274
  return presetName ? getBudgetPreset(presetName).tokenBudget : null;
262
275
  }
263
276
 
264
- /**
265
- * @template {{ budgetPreset?: unknown, maxNodes?: unknown, maxDepth?: unknown, textBudget?: unknown }} T
266
- * @param {T} args
267
- * @returns {T}
268
- */
269
- export function applyTreeBudgetPreset(args) {
270
- const presetName = getBudgetPresetName(args.budgetPreset);
271
- if (!presetName) {
272
- return args;
273
- }
274
- const preset = getBudgetPreset(presetName);
275
- return /** @type {T} */ ({
276
- ...args,
277
- maxNodes: args.maxNodes ?? preset.maxNodes,
278
- maxDepth: args.maxDepth ?? preset.maxDepth,
279
- textBudget: args.textBudget ?? preset.textBudget,
280
- });
281
- }
282
-
283
- /**
284
- * @template {{ budgetPreset?: unknown, textBudget?: unknown }} T
285
- * @param {T} args
286
- * @returns {T}
287
- */
288
- export function applyTextBudgetPreset(args) {
289
- const presetName = getBudgetPresetName(args.budgetPreset);
290
- if (!presetName) {
291
- return args;
292
- }
293
- const preset = getBudgetPreset(presetName);
294
- return /** @type {T} */ ({
295
- ...args,
296
- textBudget: args.textBudget ?? preset.textBudget,
297
- });
298
- }
299
-
300
- /**
301
- * @template {{ budgetPreset?: unknown, textBudget?: unknown }} T
302
- * @param {T} args
303
- * @returns {T}
304
- */
305
- export function applyPageTextBudgetPreset(args) {
306
- const presetName = getBudgetPresetName(args.budgetPreset);
307
- if (!presetName) {
308
- return args;
309
- }
310
- const textBudgetByPreset = {
311
- quick: 2000,
312
- normal: DEFAULT_PAGE_TEXT_BUDGET,
313
- deep: DEFAULT_PAGE_TEXT_BUDGET * 2,
314
- };
315
- return /** @type {T} */ ({
316
- ...args,
317
- textBudget: args.textBudget ?? textBudgetByPreset[presetName],
318
- });
319
- }
320
-
321
- /**
322
- * @template {{ budgetPreset?: unknown, limit?: unknown }} T
323
- * @param {T} args
324
- * @param {{ quick: number, normal: number, deep: number }} defaults
325
- * @returns {T}
326
- */
327
- export function applyLimitBudgetPreset(args, defaults) {
328
- const presetName = getBudgetPresetName(args.budgetPreset);
329
- if (!presetName) {
330
- return args;
331
- }
332
- return /** @type {T} */ ({
333
- ...args,
334
- limit: args.limit ?? defaults[presetName],
335
- });
336
- }
337
-
338
- /**
339
- * @template {{ budgetPreset?: unknown, maxLength?: unknown }} T
340
- * @param {T} args
341
- * @returns {T}
342
- */
343
- export function applyHtmlBudgetPreset(args) {
344
- const presetName = getBudgetPresetName(args.budgetPreset);
345
- if (!presetName) {
346
- return args;
347
- }
348
- const maxLengthByPreset = {
349
- quick: 600,
350
- normal: DEFAULT_MAX_HTML_LENGTH,
351
- deep: 6000,
352
- };
353
- return /** @type {T} */ ({
354
- ...args,
355
- maxLength: args.maxLength ?? maxLengthByPreset[presetName],
356
- });
357
- }
358
-
359
- /**
360
- * Apply a preset to method parameters before dispatch. Explicit method params
361
- * always win over preset defaults.
362
- *
363
- * @param {BridgeMethod} method
364
- * @param {Record<string, unknown>} params
365
- * @param {unknown} budgetPreset
366
- * @returns {Record<string, unknown>}
367
- */
368
- export function applyMethodBudgetPreset(method, params, budgetPreset) {
369
- const args = { ...params, budgetPreset };
370
- let normalized = args;
371
- if (method === 'dom.query' || method === 'dom.get_accessibility_tree') {
372
- normalized = applyTreeBudgetPreset(args);
373
- } else if (method === 'dom.get_text') {
374
- normalized = applyTextBudgetPreset(args);
375
- } else if (method === 'dom.get_html') {
376
- normalized = applyHtmlBudgetPreset(args);
377
- } else if (method === 'page.get_text' || method === 'page.extract_content') {
378
- normalized = applyPageTextBudgetPreset(args);
379
- } else if (method === 'page.get_console') {
380
- normalized = applyLimitBudgetPreset(args, {
381
- quick: 10,
382
- normal: DEFAULT_CONSOLE_LIMIT,
383
- deep: 100,
384
- });
385
- } else if (method === 'page.get_network') {
386
- normalized = applyLimitBudgetPreset(args, {
387
- quick: 10,
388
- normal: DEFAULT_NETWORK_LIMIT,
389
- deep: 100,
390
- });
391
- } else if (method === 'log.tail') {
392
- normalized = applyLimitBudgetPreset(args, {
393
- quick: 10,
394
- normal: DEFAULT_LOG_TAIL_LIMIT,
395
- deep: 100,
396
- });
397
- }
398
- const { budgetPreset: _budgetPreset, ...methodParams } = normalized;
399
- return methodParams;
400
- }
401
-
402
277
  /**
403
278
  * @param {unknown} input
404
279
  * @param {{ maxEntries?: number, maxCharacters?: number, maxStringLength?: number }} [options]
@@ -558,6 +433,14 @@ function getRequestAwareEvidence(rawResult, method, params, fallback) {
558
433
  limit: { maxEntries },
559
434
  };
560
435
  }
436
+ if (method?.startsWith('network.intercept.')) {
437
+ const bounded = boundToolValue(result, { maxEntries: 100, maxCharacters: 10_000 });
438
+ return {
439
+ value: bounded.value,
440
+ truncated: bounded.truncated,
441
+ limit: { maxEntries: 100, maxCharacters: 10_000 },
442
+ };
443
+ }
561
444
  if (
562
445
  (method === 'page.get_console' || method === 'page.get_network' || method === 'log.tail') &&
563
446
  Array.isArray(result.entries)
@@ -633,15 +516,119 @@ function compactDomNode(value) {
633
516
  return compact;
634
517
  }
635
518
 
519
+ /**
520
+ * Socket-level Node error codes that mean the daemon connection itself was
521
+ * lost, as opposed to an application-level bridge failure.
522
+ *
523
+ * @type {ReadonlySet<string>}
524
+ */
525
+ const CONNECTION_LOSS_CODES = new Set([
526
+ 'ENOTCONN',
527
+ 'ECONNREFUSED',
528
+ 'ECONNRESET',
529
+ 'EPIPE',
530
+ 'ERR_SOCKET_CLOSED',
531
+ ]);
532
+
533
+ const RECONNECT_WAIT_TIMEOUT_MS = 5_000;
534
+ const RECONNECT_POLL_INTERVAL_MS = 250;
535
+
536
+ /**
537
+ * Detect thrown transport errors caused by a lost daemon connection. These
538
+ * never arrive as bridge failure responses because the socket died before (or
539
+ * while) the daemon could reply.
540
+ *
541
+ * @param {unknown} error
542
+ * @returns {boolean}
543
+ */
544
+ export function isConnectionLossError(error) {
545
+ if (!error || typeof error !== 'object') {
546
+ return false;
547
+ }
548
+ const code = /** @type {{ code?: unknown }} */ (error).code;
549
+ if (typeof code === 'string' && CONNECTION_LOSS_CODES.has(code)) {
550
+ return true;
551
+ }
552
+ const message = error instanceof Error ? error.message : '';
553
+ return /Bridge socket closed|BridgeClient is not connected/u.test(message);
554
+ }
555
+
556
+ /**
557
+ * Wait, bounded, for the client to regain its daemon connection.
558
+ *
559
+ * When the client's own auto-reconnect loop is active it owns reconnecting and
560
+ * emits `reconnected`; otherwise one manual connect is attempted per poll
561
+ * interval. Application state is never touched here - this only restores the
562
+ * transport.
563
+ *
564
+ * @param {import('../../agent-client/src/client.js').BridgeClient} client
565
+ * @param {number} [timeoutMs=RECONNECT_WAIT_TIMEOUT_MS]
566
+ * @returns {Promise<boolean>}
567
+ */
568
+ export async function waitForClientReconnect(client, timeoutMs = RECONNECT_WAIT_TIMEOUT_MS) {
569
+ const deadline = Date.now() + timeoutMs;
570
+ while (!client.connected && Date.now() < deadline) {
571
+ if (client.autoReconnect) {
572
+ await new Promise((resolve) => {
573
+ const remaining = Math.max(0, deadline - Date.now());
574
+ /** @type {ReturnType<typeof setTimeout>} */
575
+ let timer;
576
+ const cleanup = () => {
577
+ clearTimeout(timer);
578
+ client.off('reconnected', onReconnected);
579
+ resolve(undefined);
580
+ };
581
+ const onReconnected = () => cleanup();
582
+ timer = setTimeout(cleanup, Math.min(RECONNECT_POLL_INTERVAL_MS, remaining));
583
+ client.on('reconnected', onReconnected);
584
+ });
585
+ continue;
586
+ }
587
+ try {
588
+ await client.connect();
589
+ } catch (error) {
590
+ if (error instanceof Error && /already connected/u.test(error.message)) {
591
+ break;
592
+ }
593
+ // Daemon still unreachable; keep waiting inside the bounded window.
594
+ }
595
+ if (!client.connected) {
596
+ const remaining = Math.max(0, deadline - Date.now());
597
+ await new Promise((resolve) =>
598
+ setTimeout(resolve, Math.min(RECONNECT_POLL_INTERVAL_MS, remaining))
599
+ );
600
+ }
601
+ }
602
+ return client.connected;
603
+ }
604
+
636
605
  /**
637
606
  * @param {import('../../agent-client/src/client.js').BridgeClient} client
638
607
  * @param {BridgeMethod} method
639
608
  * @param {Record<string, unknown>} params
640
- * @param {{ tabId?: number | null, source?: import('../../protocol/src/types.js').BridgeRequestSource, tokenBudget?: number | null }} options
609
+ * @param {{ tabId?: number | null, source?: import('../../protocol/src/types.js').BridgeRequestSource, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} options
641
610
  * @returns {Promise<BridgeResponse>}
642
611
  */
643
612
  export async function requestBridgeWithRetry(client, method, params, options) {
644
- const response = await requestBridge(client, method, params, options);
613
+ /** @type {BridgeResponse} */
614
+ let response;
615
+ try {
616
+ response = await requestBridge(client, method, params, options);
617
+ } catch (error) {
618
+ if (!isConnectionLossError(error) || !isRetrySafeBridgeMethod(method, params)) {
619
+ throw error;
620
+ }
621
+ process.stderr.write(
622
+ `[bbx-mcp] ${method} lost its daemon connection; waiting up to ${RECONNECT_WAIT_TIMEOUT_MS}ms for reconnect before one retry.\n`
623
+ );
624
+ if (!(await waitForClientReconnect(client))) {
625
+ throw error;
626
+ }
627
+ return requestBridge(client, method, params, {
628
+ ...options,
629
+ automaticRetry: 'mcp_second_attempt',
630
+ });
631
+ }
645
632
  const recovery =
646
633
  !response.ok && response.error
647
634
  ? (response.error.recovery ?? getErrorRecovery(response.error.code))
@@ -652,7 +639,10 @@ export async function requestBridgeWithRetry(client, method, params, options) {
652
639
  `[bbx-mcp] Retrying ${method} after ${delay}ms (${response.error.code})\n`
653
640
  );
654
641
  await new Promise((r) => setTimeout(r, delay));
655
- return requestBridge(client, method, params, options);
642
+ return requestBridge(client, method, params, {
643
+ ...options,
644
+ automaticRetry: 'mcp_second_attempt',
645
+ });
656
646
  }
657
647
  return response;
658
648
  }
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import os from 'node:os';
4
+
4
5
  import { DEFAULT_LOG_TAIL_LIMIT, createRuntimeContext } from '../../protocol/src/index.js';
5
6
  import { collectSetupStatus } from '../../agent-client/src/setup-status.js';
6
7
  import {
@@ -54,15 +55,19 @@ export {
54
55
  } from './handlers-capture.js';
55
56
 
56
57
  export {
57
- MAX_BATCH_CALLS,
58
- MAX_BATCH_CONCURRENCY,
59
58
  PAGE_ACTIONS,
60
59
  handlePageTool,
60
+ handleArtifactTool,
61
+ handleInterceptTool,
62
+ handleSensitiveReadTool,
61
63
  handleBatchTool,
62
64
  handleRawCallTool,
65
+ createHarExportResult,
63
66
  handleInvestigateTool,
64
67
  } from './handlers-page.js';
65
68
 
69
+ export { MAX_BATCH_CALLS, MAX_BATCH_CONCURRENCY } from '../../protocol/src/index.js';
70
+
66
71
  /** @typedef {import('./handlers-utils.js').ToolAction} ToolAction */
67
72
  /** @typedef {import('./handlers-utils.js').ToolResult} ToolResult */
68
73
 
@@ -97,8 +102,13 @@ export async function handleStatusTool(args = {}) {
97
102
  }
98
103
 
99
104
  const report = await getDoctorReport();
105
+ const configuredDestinations = await getBridgeDestinations();
106
+ const destinationsToInspect =
107
+ requestedDestinationId === 'local'
108
+ ? configuredDestinations.filter((destination) => destination.local)
109
+ : configuredDestinations;
100
110
  const destinations = await Promise.all(
101
- (await getBridgeDestinations()).map(async (destination) => {
111
+ destinationsToInspect.map(async (destination) => {
102
112
  if (destination.local) {
103
113
  return {
104
114
  ...destination,
@@ -187,7 +197,10 @@ export async function handleSetupTool(args) {
187
197
  const configuredMcp = status.mcpClients.filter((e) => e.configured).length;
188
198
  const installedSkills = status.skillTargets.filter((e) => e.installed).length;
189
199
  const summary = `Optional agent integration status: ${configuredMcp}/${status.mcpClients.length} MCP clients configured, ${installedSkills}/${status.skillTargets.length} skills installed.`;
190
- return createToolResult(summary, { ok: true, status });
200
+ return createToolResult(summary, {
201
+ ok: true,
202
+ status,
203
+ });
191
204
  }
192
205
 
193
206
  /**