@browserbridge/bbx 1.7.8 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/PRIVACY.md +57 -12
  2. package/README.md +19 -8
  3. package/SECURITY.md +12 -0
  4. package/package.json +15 -6
  5. package/packages/agent-client/src/cli-args.js +59 -0
  6. package/packages/agent-client/src/cli-batch.js +121 -58
  7. package/packages/agent-client/src/cli-output.js +8 -1
  8. package/packages/agent-client/src/cli.js +272 -70
  9. package/packages/agent-client/src/client.js +296 -8
  10. package/packages/agent-client/src/command-registry.js +64 -55
  11. package/packages/agent-client/src/config.js +195 -0
  12. package/packages/agent-client/src/content-extract.js +198 -0
  13. package/packages/agent-client/src/npm-self-update.js +305 -0
  14. package/packages/agent-client/src/remotes.js +4 -2
  15. package/packages/agent-client/src/runtime.js +864 -39
  16. package/packages/agent-client/src/types.ts +169 -6
  17. package/packages/mcp-server/src/guidance.js +1 -1
  18. package/packages/mcp-server/src/handlers-capture.js +69 -6
  19. package/packages/mcp-server/src/handlers-dom.js +61 -4
  20. package/packages/mcp-server/src/handlers-navigation.js +2 -1
  21. package/packages/mcp-server/src/handlers-page.js +211 -54
  22. package/packages/mcp-server/src/handlers-utils.js +101 -10
  23. package/packages/mcp-server/src/handlers.js +14 -7
  24. package/packages/mcp-server/src/server.js +176 -11
  25. package/packages/native-host/src/artifact-store.js +353 -0
  26. package/packages/native-host/src/config.js +9 -1
  27. package/packages/native-host/src/daemon-logger.js +3 -1
  28. package/packages/native-host/src/daemon.js +701 -33
  29. package/packages/native-host/src/native-host.js +28 -3
  30. package/packages/protocol/src/capability-values.js +2 -0
  31. package/packages/protocol/src/defaults.js +31 -0
  32. package/packages/protocol/src/errors.js +102 -6
  33. package/packages/protocol/src/incidental-sanitizer.js +197 -0
  34. package/packages/protocol/src/index.js +2 -0
  35. package/packages/protocol/src/protocol.js +612 -6
  36. package/packages/protocol/src/recovery-telemetry.js +302 -0
  37. package/packages/protocol/src/registry.js +206 -38
  38. package/packages/protocol/src/summary.js +203 -30
  39. package/packages/protocol/src/types.ts +721 -2
  40. package/skills/browser-bridge/SKILL.md +54 -19
  41. package/skills/browser-bridge/references/interaction.md +55 -11
  42. package/skills/browser-bridge/references/patch-workflow.md +2 -0
  43. package/skills/browser-bridge/references/protocol.md +265 -86
  44. package/skills/browser-bridge/references/token-efficiency.md +29 -13
@@ -4,6 +4,8 @@
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { createHash } from 'node:crypto';
7
9
  import { fileURLToPath } from 'node:url';
8
10
 
9
11
  import { applyWindowsTcpTransportDefaults } from '../../native-host/src/config.js';
@@ -16,6 +18,8 @@ import {
16
18
  import { startBridgeMcpServer } from '../../mcp-server/src/server.js';
17
19
  import {
18
20
  extractRemoteFlag,
21
+ extractHarFlags,
22
+ extractScreenshotFlags,
19
23
  extractTabFlag,
20
24
  parseCallCommand,
21
25
  parseInterceptAddArgs,
@@ -31,12 +35,16 @@ import {
31
35
  uninstallBrowserBridge,
32
36
  } from './cli-setup-commands.js';
33
37
  import { SHORTCUT_COMMANDS } from './command-registry.js';
38
+ import { getAutoUpdatePolicy, parseAutoUpdatePolicy, setAutoUpdatePolicy } from './config.js';
34
39
  import { formatMcpConfig, isMcpClientName, MCP_CLIENT_NAMES } from './mcp-config.js';
35
40
  import { getDoctorReport, requestBridge, resolveRef } from './runtime.js';
36
41
  import { createBridgeClientForDestination } from './remotes.js';
42
+ import { atomicWriteFile } from './atomic-write.js';
37
43
 
38
44
  /** @typedef {import('./types.js').BridgeMethod} BridgeMethod */
39
45
  /** @typedef {import('./types.js').ScreenshotResult} ScreenshotResult */
46
+ /** @typedef {import('../../protocol/src/types.js').ArtifactDescriptor} ArtifactDescriptor */
47
+ /** @typedef {import('../../protocol/src/types.js').HarExportResult} HarExportResult */
40
48
 
41
49
  const REQUEST_SOURCE = 'cli';
42
50
  const TEST_TIMEOUT_ENV = 'BBX_CLIENT_REQUEST_TIMEOUT_MS';
@@ -63,6 +71,7 @@ const LOCAL_ONLY_COMMANDS = new Set([
63
71
  'mcp',
64
72
  'proxy',
65
73
  'remote',
74
+ 'config',
66
75
  'doctor',
67
76
  'restart',
68
77
  ]);
@@ -166,6 +175,11 @@ if (command === 'remote') {
166
175
  process.exit();
167
176
  }
168
177
 
178
+ if (command === 'config') {
179
+ await runLocalCommand(() => handleConfigCommand(rest));
180
+ process.exit();
181
+ }
182
+
169
183
  const clientTimeoutMs = getClientTimeoutOverride();
170
184
  applyWindowsTcpTransportDefaults();
171
185
  const client = await createCliClient();
@@ -188,6 +202,26 @@ async function runLocalCommand(run) {
188
202
  }
189
203
  }
190
204
 
205
+ /**
206
+ * @param {string[]} args
207
+ * @returns {Promise<void>}
208
+ */
209
+ async function handleConfigCommand(args) {
210
+ const [action, key, value] = args;
211
+ if (action === 'get' && key === 'auto-update' && value === undefined) {
212
+ process.stdout.write(`${await getAutoUpdatePolicy()}\n`);
213
+ return;
214
+ }
215
+ if (action === 'set' && key === 'auto-update' && value !== undefined && args.length === 3) {
216
+ const policy = parseAutoUpdatePolicy(value);
217
+ const configPath = await setAutoUpdatePolicy(policy);
218
+ process.stdout.write(`Browser Bridge auto-update policy set to "${policy}".\n`);
219
+ process.stdout.write(`Config: ${configPath}\n`);
220
+ return;
221
+ }
222
+ throw new Error('Usage: bbx config <get auto-update|set auto-update off|compatible>');
223
+ }
224
+
191
225
  /**
192
226
  * Create the bridge client for this invocation, honoring the --remote flag
193
227
  * and BBX_REMOTE env var. Exits with a friendly error when the destination
@@ -197,10 +231,10 @@ async function runLocalCommand(run) {
197
231
  */
198
232
  async function createCliClient() {
199
233
  try {
200
- return await createBridgeClientForDestination(
201
- remoteDestinationId,
202
- clientTimeoutMs ? { defaultTimeoutMs: clientTimeoutMs } : {}
203
- );
234
+ return await createBridgeClientForDestination(remoteDestinationId, {
235
+ checkProtocolOnConnect: false,
236
+ ...(clientTimeoutMs ? { defaultTimeoutMs: clientTimeoutMs } : {}),
237
+ });
204
238
  } catch (error) {
205
239
  printJson({
206
240
  ok: false,
@@ -212,6 +246,7 @@ async function createCliClient() {
212
246
  }
213
247
 
214
248
  async function main() {
249
+ let relaunchAfterUpdate = false;
215
250
  try {
216
251
  if (command === 'status') {
217
252
  const healthResponse = await requestBridge(
@@ -337,7 +372,7 @@ async function main() {
337
372
  tabId,
338
373
  source: REQUEST_SOURCE,
339
374
  });
340
- printCallResponse(response);
375
+ printCallResponse(response, method);
341
376
  return;
342
377
  }
343
378
 
@@ -356,68 +391,37 @@ async function main() {
356
391
  tabId,
357
392
  source: REQUEST_SOURCE,
358
393
  });
359
- printCallResponse(response);
394
+ printCallResponse(response, method);
360
395
  return;
361
396
  }
362
397
 
363
398
  const shortcutCmd = SHORTCUT_COMMANDS[command];
364
399
  if (shortcutCmd) {
365
- // Allow `bbx <shortcut> --tab <id> ...` to target a specific tab.
366
- // Without this, --tab gets eaten as a positional argument and the
367
- // request hits whatever tab the bridge route happens to be pointing
368
- // at (typically the active tab). For element-resolving shortcuts
369
- // the ref must be resolved against the SAME tab the action targets,
370
- // so we pass tabId to both resolveRef and the subsequent request.
371
400
  const { tabId, rest: shortcutArgs } = extractTabFlag(rest);
372
- const selectorInput = shortcutCmd.resolve ? shortcutArgs[0] : null;
373
- if (shortcutCmd.resolve && !selectorInput) {
374
- throw new Error(`Usage: ${command} <ref|selector>`);
375
- }
376
-
377
- // Retry-on-stale: if the action fails with ELEMENT_STALE and the
378
- // original input was a selector (not an el_xxx ref), re-resolve the
379
- // selector and retry once. This handles the common case where the
380
- // agent resolved an element, then the page re-rendered (React
381
- // reconciliation, SPA navigation) before the action was dispatched.
382
- const canRetry = typeof selectorInput === 'string' && !selectorInput.startsWith('el_');
383
- const maxAttempts = canRetry ? 2 : 1;
384
- /** @type {import('./runtime.js').BridgeResponse | undefined} */
385
- let response;
386
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
387
- let elementRef;
388
- if (shortcutCmd.resolve && selectorInput) {
389
- elementRef = await resolveRef(client, selectorInput, tabId, REQUEST_SOURCE);
390
- }
391
- response = await requestBridge(
392
- client,
393
- shortcutCmd.method,
394
- shortcutCmd.build(shortcutArgs, elementRef),
395
- { source: REQUEST_SOURCE, tabId }
396
- );
397
- const isStale = !response.ok && response.error?.code === 'ELEMENT_STALE';
398
- if (isStale && attempt < maxAttempts) {
399
- process.stderr.write(
400
- `bbx: ELEMENT_STALE on "${selectorInput}", re-resolving and retrying...\n`
401
- );
402
- }
403
- if (!isStale || attempt >= maxAttempts) break;
404
- }
405
- if (response) await printSummary(response, shortcutCmd.printMethod);
401
+ const response = await requestBridge(
402
+ client,
403
+ shortcutCmd.method,
404
+ shortcutCmd.build(shortcutArgs),
405
+ { source: REQUEST_SOURCE, tabId }
406
+ );
407
+ await printSummary(response, shortcutCmd.printMethod);
406
408
  return;
407
409
  }
408
410
 
409
411
  if (command === 'press-key') {
410
412
  const [key, refOrSelector] = rest;
411
413
  if (!key) throw new Error('Usage: press-key <key> [ref|selector]');
412
- const elementRef = refOrSelector
413
- ? await resolveRef(client, refOrSelector, null, REQUEST_SOURCE)
414
+ const target = refOrSelector
415
+ ? refOrSelector.startsWith('el_')
416
+ ? { elementRef: refOrSelector }
417
+ : { selector: refOrSelector }
414
418
  : undefined;
415
419
  const response = await requestBridge(
416
420
  client,
417
421
  'input.press_key',
418
422
  {
419
423
  key,
420
- target: elementRef ? { elementRef } : undefined,
424
+ target,
421
425
  },
422
426
  { source: REQUEST_SOURCE }
423
427
  );
@@ -447,15 +451,21 @@ async function main() {
447
451
 
448
452
  if (command === 'screenshot') {
449
453
  const parsed = extractTabFlag(rest);
450
- const [refOrSelector, outputPath] = parsed.rest;
454
+ const screenshotOptions = extractScreenshotFlags(parsed.rest);
455
+ const [refOrSelector, outputPath] = screenshotOptions.rest;
451
456
  if (!refOrSelector)
452
- throw new Error('Usage: screenshot [--tab <tabId>] <ref|selector> [path]');
457
+ throw new Error(
458
+ 'Usage: screenshot [--tab <tabId>] [--format png|jpeg|webp] [--quality 0-100] <ref|selector> [path]'
459
+ );
453
460
  const elementRef = await resolveRef(client, refOrSelector, parsed.tabId, REQUEST_SOURCE);
454
461
  const response = await requestBridge(
455
462
  client,
456
463
  'screenshot.capture_element',
457
464
  {
458
465
  elementRef,
466
+ format: screenshotOptions.format,
467
+ quality: screenshotOptions.quality,
468
+ delivery: 'artifact',
459
469
  },
460
470
  { tabId: parsed.tabId, source: REQUEST_SOURCE }
461
471
  );
@@ -464,13 +474,79 @@ async function main() {
464
474
  return;
465
475
  }
466
476
  const screenshotResult = /** @type {ScreenshotResult} */ (response.result);
467
- const filePath = outputPath || path.join(os.tmpdir(), `bbx-${Date.now()}.png`);
468
- const data = screenshotResult.image.replace(/^data:image\/png;base64,/, '');
469
- await fs.promises.writeFile(filePath, Buffer.from(data, 'base64'));
477
+ const extension = screenshotOptions.format === 'jpeg' ? 'jpg' : screenshotOptions.format;
478
+ const acceptedExtensions =
479
+ screenshotOptions.format === 'jpeg'
480
+ ? new Set(['.jpg', '.jpeg'])
481
+ : new Set([`.${extension}`]);
482
+ let filePath = outputPath || path.join(os.tmpdir(), `bbx-${Date.now()}.${extension}`);
483
+ if (outputPath && !path.extname(outputPath)) filePath = `${outputPath}.${extension}`;
484
+ if (
485
+ outputPath &&
486
+ path.extname(outputPath) &&
487
+ !acceptedExtensions.has(path.extname(outputPath))
488
+ ) {
489
+ throw new Error(`Screenshot path extension must match ${screenshotOptions.format} format.`);
490
+ }
491
+ const imageBytes =
492
+ screenshotResult.delivery === 'artifact'
493
+ ? await downloadArtifact(client, screenshotResult.artifact, 'screenshot')
494
+ : decodeScreenshotDataUrl(screenshotResult.image);
495
+ await atomicWriteFile(filePath, imageBytes);
470
496
  printJson({
471
497
  ok: true,
472
498
  summary: `Screenshot saved to ${filePath}.`,
473
- evidence: { savedTo: filePath, rect: screenshotResult.rect },
499
+ evidence: {
500
+ savedTo: filePath,
501
+ rect: screenshotResult.rect,
502
+ format: screenshotResult.format,
503
+ complete: screenshotResult.complete,
504
+ clipped: screenshotResult.clipped,
505
+ },
506
+ });
507
+ return;
508
+ }
509
+
510
+ if (command === 'har') {
511
+ const parsed = extractTabFlag(rest);
512
+ const harOptions = extractHarFlags(parsed.rest);
513
+ const [outputPath = 'browser-bridge.har', ...extra] = harOptions.rest;
514
+ if (extra.length > 0) {
515
+ throw new Error(
516
+ 'Usage: har [--tab <tabId>] [--limit 1-200] [--url-pattern <pattern>] [--delivery inline|artifact|auto] [outPath]'
517
+ );
518
+ }
519
+ if (path.extname(outputPath) !== '.har') {
520
+ throw new Error('HAR output path must use the .har extension.');
521
+ }
522
+ const response = await requestBridge(
523
+ client,
524
+ 'network.export_har',
525
+ {
526
+ limit: harOptions.limit,
527
+ urlPattern: harOptions.urlPattern,
528
+ delivery: harOptions.delivery,
529
+ },
530
+ { tabId: parsed.tabId, source: REQUEST_SOURCE }
531
+ );
532
+ if (!response.ok) {
533
+ await printSummary(response);
534
+ return;
535
+ }
536
+ const result = /** @type {HarExportResult} */ (response.result);
537
+ let harBytes;
538
+ if (result.delivery === 'artifact') {
539
+ harBytes = await downloadArtifact(client, result.artifact, 'har');
540
+ assertValidHarBytes(harBytes);
541
+ } else {
542
+ assertValidHarDocument(result.har);
543
+ harBytes = Buffer.from(`${JSON.stringify(result.har, null, 2)}\n`, 'utf8');
544
+ }
545
+ await atomicWriteFile(outputPath, harBytes);
546
+ printJson({
547
+ ok: true,
548
+ summary: `HAR saved to ${outputPath}.`,
549
+ evidence: { savedTo: outputPath, delivery: result.delivery },
474
550
  });
475
551
  return;
476
552
  }
@@ -565,25 +641,151 @@ async function main() {
565
641
  } catch (error) {
566
642
  const message = error instanceof Error ? error.message : String(error);
567
643
  const raw = getErrorCode(error);
568
- let code = 'ERROR';
569
- if (raw === 'ENOENT' || raw === 'ECONNREFUSED' || raw === 'EINVAL') {
570
- code = 'DAEMON_OFFLINE';
571
- } else if (raw === 'BRIDGE_TIMEOUT') {
572
- code = 'BRIDGE_TIMEOUT';
573
- } else if (/socket closed/i.test(message)) {
574
- code = 'CONNECTION_LOST';
575
- } else if (raw) {
576
- code = String(raw);
644
+ if (raw === 'BBX_NPM_UPDATED') {
645
+ relaunchAfterUpdate = true;
646
+ } else {
647
+ let code = 'ERROR';
648
+ if (raw === 'ENOENT' || raw === 'ECONNREFUSED' || raw === 'EINVAL') {
649
+ code = 'DAEMON_OFFLINE';
650
+ } else if (raw === 'BRIDGE_TIMEOUT') {
651
+ code = 'BRIDGE_TIMEOUT';
652
+ } else if (/socket closed/i.test(message)) {
653
+ code = 'CONNECTION_LOST';
654
+ } else if (raw) {
655
+ code = String(raw);
656
+ }
657
+ printJson({
658
+ ok: false,
659
+ summary: `${code}: ${message}`,
660
+ evidence: null,
661
+ });
662
+ process.exitCode = 1;
577
663
  }
578
- printJson({
579
- ok: false,
580
- summary: `${code}: ${message}`,
581
- evidence: null,
582
- });
583
- process.exitCode = 1;
584
664
  } finally {
585
665
  await client.close();
586
666
  }
667
+ if (relaunchAfterUpdate) {
668
+ relaunchCli();
669
+ }
670
+ }
671
+
672
+ /** @param {string} value */
673
+ function decodeScreenshotDataUrl(value) {
674
+ const match = /^data:image\/(?:png|jpeg|webp);base64,([A-Za-z0-9+/]*={0,2})$/u.exec(value);
675
+ if (!match) throw new Error('Screenshot response contains invalid base64 image data.');
676
+ const bytes = Buffer.from(match[1], 'base64');
677
+ if (bytes.toString('base64') !== match[1]) {
678
+ throw new Error('Screenshot response contains non-canonical base64 image data.');
679
+ }
680
+ return bytes;
681
+ }
682
+
683
+ /**
684
+ * @param {import('./client.js').BridgeClient} client
685
+ * @param {ArtifactDescriptor} artifact
686
+ * @param {'screenshot' | 'har'} [expectedKind]
687
+ */
688
+ async function downloadArtifact(client, artifact, expectedKind) {
689
+ const chunks = [];
690
+ let offset = 0;
691
+ try {
692
+ if (expectedKind && artifact.kind !== expectedKind) {
693
+ throw new Error(`Artifact kind does not match the expected ${expectedKind} result.`);
694
+ }
695
+ if (expectedKind === 'har' && artifact.mimeType !== 'application/json') {
696
+ throw new Error('HAR artifact MIME type is invalid.');
697
+ }
698
+ while (offset < artifact.byteLength) {
699
+ const response = await requestBridge(
700
+ client,
701
+ 'artifact.read',
702
+ { artifactId: artifact.artifactId, offset },
703
+ { source: REQUEST_SOURCE }
704
+ );
705
+ if (!response.ok) throw new Error(response.error.message);
706
+ const result = /** @type {Record<string, unknown>} */ (response.result);
707
+ if (result.artifactId !== artifact.artifactId || result.offset !== offset) {
708
+ throw new Error('Artifact chunk metadata is inconsistent.');
709
+ }
710
+ const data = String(result.data ?? '');
711
+ const bytes = Buffer.from(data, 'base64');
712
+ if (bytes.toString('base64') !== data || bytes.length !== result.byteLength) {
713
+ throw new Error('Artifact chunk contains invalid base64 data.');
714
+ }
715
+ chunks.push(bytes);
716
+ if (result.nextOffset === null) break;
717
+ if (typeof result.nextOffset !== 'number' || result.nextOffset <= offset) {
718
+ throw new Error('Artifact chunk did not advance the read offset.');
719
+ }
720
+ offset = result.nextOffset;
721
+ }
722
+ const output = Buffer.concat(chunks);
723
+ const sha256 = createHash('sha256').update(output).digest('hex');
724
+ if (output.length !== artifact.byteLength || sha256 !== artifact.sha256) {
725
+ throw new Error('Artifact length or checksum does not match the capture metadata.');
726
+ }
727
+ return output;
728
+ } finally {
729
+ await requestBridge(
730
+ client,
731
+ 'artifact.delete',
732
+ { artifactId: artifact.artifactId },
733
+ { source: REQUEST_SOURCE }
734
+ ).catch(() => {});
735
+ }
736
+ }
737
+
738
+ /** @param {Buffer} bytes */
739
+ function assertValidHarBytes(bytes) {
740
+ const text = bytes.toString('utf8');
741
+ if (!Buffer.from(text, 'utf8').equals(bytes)) {
742
+ throw new Error('HAR artifact is not valid UTF-8 JSON.');
743
+ }
744
+ let document;
745
+ try {
746
+ document = JSON.parse(text);
747
+ } catch {
748
+ throw new Error('HAR artifact is not valid JSON.');
749
+ }
750
+ assertValidHarDocument(document);
751
+ }
752
+
753
+ /** @param {unknown} document */
754
+ function assertValidHarDocument(document) {
755
+ if (!document || typeof document !== 'object' || Array.isArray(document)) {
756
+ throw new Error('HAR response does not contain an inline HAR object.');
757
+ }
758
+ const log = Reflect.get(document, 'log');
759
+ if (
760
+ !log ||
761
+ typeof log !== 'object' ||
762
+ Array.isArray(log) ||
763
+ Reflect.get(log, 'version') !== '1.2' ||
764
+ !Array.isArray(Reflect.get(log, 'entries'))
765
+ ) {
766
+ throw new Error('HAR response does not contain a valid HAR 1.2 document.');
767
+ }
768
+ }
769
+
770
+ /**
771
+ * Replace this invocation with a fresh Node process after npm has replaced the
772
+ * package on disk. Child stdio is inherited so command output remains stable.
773
+ *
774
+ * @returns {void}
775
+ */
776
+ function relaunchCli() {
777
+ const result = spawnSync(
778
+ process.execPath,
779
+ [fileURLToPath(import.meta.url), ...process.argv.slice(2)],
780
+ {
781
+ stdio: 'inherit',
782
+ env: process.env,
783
+ }
784
+ );
785
+ if (result.error) {
786
+ throw result.error;
787
+ }
788
+ process.exitCode = result.status ?? 1;
587
789
  }
588
790
 
589
791
  /**