@browserbridge/bbx 1.7.7 → 1.8.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 (34) hide show
  1. package/PRIVACY.md +111 -0
  2. package/README.md +13 -5
  3. package/RESPONSIBLE_USE.md +16 -0
  4. package/SECURITY.md +52 -0
  5. package/package.json +12 -6
  6. package/packages/agent-client/src/cli-args.js +24 -0
  7. package/packages/agent-client/src/cli.js +46 -52
  8. package/packages/agent-client/src/client.js +140 -5
  9. package/packages/agent-client/src/command-registry.js +56 -53
  10. package/packages/agent-client/src/content-extract.js +198 -0
  11. package/packages/agent-client/src/remotes.js +1 -1
  12. package/packages/agent-client/src/runtime.js +768 -35
  13. package/packages/agent-client/src/types.ts +131 -6
  14. package/packages/mcp-server/src/guidance.js +1 -1
  15. package/packages/mcp-server/src/handlers-capture.js +41 -5
  16. package/packages/mcp-server/src/handlers-dom.js +8 -2
  17. package/packages/mcp-server/src/handlers-navigation.js +2 -1
  18. package/packages/mcp-server/src/handlers-page.js +41 -4
  19. package/packages/mcp-server/src/handlers-utils.js +67 -7
  20. package/packages/mcp-server/src/handlers.js +4 -4
  21. package/packages/mcp-server/src/server.js +102 -6
  22. package/packages/native-host/src/daemon.js +158 -6
  23. package/packages/native-host/src/native-host.js +3 -1
  24. package/packages/protocol/src/defaults.js +3 -0
  25. package/packages/protocol/src/errors.js +54 -1
  26. package/packages/protocol/src/protocol.js +297 -1
  27. package/packages/protocol/src/registry.js +66 -35
  28. package/packages/protocol/src/summary.js +87 -5
  29. package/packages/protocol/src/types.ts +234 -0
  30. package/skills/browser-bridge/SKILL.md +34 -13
  31. package/skills/browser-bridge/references/interaction.md +55 -11
  32. package/skills/browser-bridge/references/patch-workflow.md +2 -0
  33. package/skills/browser-bridge/references/protocol.md +157 -82
  34. package/skills/browser-bridge/references/token-efficiency.md +19 -13
@@ -9,8 +9,10 @@ import {
9
9
  getBridgeDir,
10
10
  getDaemonLogPath,
11
11
  getDaemonPidPath,
12
+ getBridgeTransport,
12
13
  getManifestInstallDir,
13
14
  getSocketPath,
15
+ readProxyConfig,
14
16
  SUPPORTED_BROWSERS,
15
17
  } from '../../native-host/src/config.js';
16
18
  import {
@@ -20,26 +22,70 @@ import {
20
22
  import { resolveDefaultExtensionId } from '../../native-host/src/install-manifest.js';
21
23
  import {
22
24
  BridgeError,
25
+ BRIDGE_METHOD_REGISTRY,
23
26
  CLIENT_REQUEST_TIMEOUT_MARGIN_MS,
24
27
  DEFAULT_CLIENT_REQUEST_TIMEOUT_MS,
25
28
  getBridgeOperationTimeoutMs,
29
+ getProtocolVersion,
26
30
  MAX_CLIENT_REQUEST_TIMEOUT_MS,
27
31
  } from '../../protocol/src/index.js';
28
32
  import { methodNeedsTab } from './cli-helpers.js';
29
33
  import { BridgeClient } from './client.js';
34
+ import { readRemoteConfig } from './remotes.js';
35
+ import { collectSetupStatus } from './setup-status.js';
30
36
 
31
37
  /** @typedef {import('./types.js').BridgeMethod} BridgeMethod */
32
38
  /** @typedef {import('./types.js').BridgeMeta} BridgeMeta */
33
39
  /** @typedef {import('./types.js').BridgeRequestSource} BridgeRequestSource */
34
40
  /** @typedef {import('./types.js').BridgeResponse} BridgeResponse */
41
+ /** @typedef {import('./types.js').BridgeClientOptions} BridgeClientOptions */
35
42
  /** @typedef {import('../../native-host/src/config.js').SupportedBrowser} SupportedBrowser */
36
43
  /** @typedef {import('./types.js').BrowserManifestStatus} BrowserManifestStatus */
37
44
  /** @typedef {import('./types.js').NativeHostManifestIssue} NativeHostManifestIssue */
38
45
  /** @typedef {import('./types.js').DoctorReport} DoctorReport */
39
46
  /** @typedef {import('./types.js').DoctorReportOptions} DoctorReportOptions */
47
+ /** @typedef {import('./types.js').DoctorRecentEvent} DoctorRecentEvent */
40
48
 
41
49
  const CHROMIUM_SANDBOXED_MANIFEST_RE =
42
50
  /(?:^|[/\\])(?:snap[/\\]chromium|\.var[/\\]app[/\\]org\.chromium\.Chromium)[/\\]/;
51
+ const DOCTOR_LOG_LIMIT = 12;
52
+ const DOCTOR_RECENT_EVENT_LIMIT = 10;
53
+ const DOCTOR_SETUP_ENTRY_LIMIT = 100;
54
+ const MAX_DOCTOR_COUNT = 1_000_000_000;
55
+ const SAFE_VERSION_RE = /^\d+\.\d+(?:\.\d+)?(?:[-+][A-Za-z0-9.-]+)?$/u;
56
+ const CHROME_EXTENSION_ID_RE = /^[a-p]{32}$/u;
57
+ const SAFE_DEBUGGER_STATES = new Set(['idle', 'active', 'conflict', 'detached']);
58
+ const SAFE_ROUTE_REASONS = new Set([
59
+ 'enabled',
60
+ 'access_disabled',
61
+ 'enabled_window_missing',
62
+ 'no_routable_active_tab',
63
+ 'reconnected',
64
+ 'restricted_page',
65
+ ]);
66
+ const SAFE_CAPTURE_STATES = new Set([
67
+ 'idle',
68
+ 'stopped',
69
+ 'armed',
70
+ 'active',
71
+ 'capturing',
72
+ 'stop_failed',
73
+ 'unavailable',
74
+ ]);
75
+ const BRIDGE_METHOD_SET = new Set(Object.keys(BRIDGE_METHOD_REGISTRY));
76
+ const DOCTOR_DIAGNOSTIC_METHODS = new Set([
77
+ 'health.ping',
78
+ 'setup.get_status',
79
+ 'log.tail',
80
+ 'daemon.metrics',
81
+ ]);
82
+ const SAFE_DEBUGGER_REASONS = new Set([
83
+ 'debugger_conflict',
84
+ 'debugger_detached',
85
+ 'debugger_replaced',
86
+ 'debugger_canceled',
87
+ 'target_closed',
88
+ ]);
43
89
 
44
90
  /**
45
91
  * @param {BridgeClient} client
@@ -135,10 +181,11 @@ function withRequestMeta(source, tokenBudget) {
135
181
  /**
136
182
  * @template T
137
183
  * @param {(client: BridgeClient) => Promise<T>} callback
184
+ * @param {BridgeClientOptions} [options]
138
185
  * @returns {Promise<T>}
139
186
  */
140
- export async function withBridgeClient(callback) {
141
- const client = new BridgeClient();
187
+ export async function withBridgeClient(callback, options) {
188
+ const client = new BridgeClient(options);
142
189
  await ensureClientConnected(client);
143
190
  try {
144
191
  return await callback(client);
@@ -188,6 +235,44 @@ export async function checkBrowserManifests() {
188
235
  );
189
236
  }
190
237
 
238
+ /**
239
+ * Read the allowlisted extension IDs from every installed browser manifest.
240
+ * Invalid manifests are already reported by the native-host health check and
241
+ * do not prevent the remaining browsers from contributing identity data.
242
+ *
243
+ * @param {BrowserManifestStatus[]} browserManifests
244
+ * @param {typeof fs.promises.readFile} [readFile]
245
+ * @returns {Promise<string[]>}
246
+ */
247
+ export async function readInstalledExtensionIds(
248
+ browserManifests,
249
+ readFile = fs.promises.readFile.bind(fs.promises)
250
+ ) {
251
+ /** @type {Set<string>} */
252
+ const extensionIds = new Set();
253
+ for (const entry of browserManifests.slice(0, DOCTOR_SETUP_ENTRY_LIMIT)) {
254
+ if (!entry.installed || extensionIds.size >= DOCTOR_SETUP_ENTRY_LIMIT) {
255
+ continue;
256
+ }
257
+ try {
258
+ const parsed = JSON.parse(await readFile(entry.manifestPath, 'utf8'));
259
+ const origins = Array.isArray(parsed?.allowed_origins) ? parsed.allowed_origins : [];
260
+ for (const origin of origins.slice(0, DOCTOR_SETUP_ENTRY_LIMIT)) {
261
+ const match =
262
+ typeof origin === 'string'
263
+ ? /^chrome-extension:\/\/([a-p]{32})\/(?:\*)?$/u.exec(origin)
264
+ : null;
265
+ if (match?.[1]) {
266
+ extensionIds.add(match[1]);
267
+ }
268
+ }
269
+ } catch {
270
+ // The native-host manifest health collector reports malformed files.
271
+ }
272
+ }
273
+ return [...extensionIds];
274
+ }
275
+
191
276
  /**
192
277
  * @param {string} line
193
278
  * @param {number} startIndex
@@ -377,6 +462,381 @@ export async function checkUnwritableBridgePaths() {
377
462
  return unwritable;
378
463
  }
379
464
 
465
+ /**
466
+ * Doctor must never restart a daemon while it is inspecting version drift.
467
+ *
468
+ * @template T
469
+ * @param {(client: BridgeClient) => Promise<T>} callback
470
+ * @returns {Promise<T>}
471
+ */
472
+ async function withDoctorBridgeClient(callback) {
473
+ const client = new BridgeClient({ restartDaemonOnVersionMismatch: false });
474
+ try {
475
+ await ensureClientConnected(client);
476
+ return await callback(client);
477
+ } finally {
478
+ await client.close().catch(() => {});
479
+ }
480
+ }
481
+
482
+ /**
483
+ * @param {unknown} value
484
+ * @returns {Record<string, unknown> | null}
485
+ */
486
+ function asRecord(value) {
487
+ return value && typeof value === 'object' && !Array.isArray(value)
488
+ ? /** @type {Record<string, unknown>} */ (value)
489
+ : null;
490
+ }
491
+
492
+ /**
493
+ * @param {unknown} value
494
+ * @returns {number}
495
+ */
496
+ function boundedCount(value) {
497
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
498
+ ? Math.min(MAX_DOCTOR_COUNT, Math.trunc(value))
499
+ : 0;
500
+ }
501
+
502
+ /**
503
+ * @param {unknown} value
504
+ * @returns {string | null}
505
+ */
506
+ function safeVersion(value) {
507
+ return typeof value === 'string' && value.length <= 32 && SAFE_VERSION_RE.test(value)
508
+ ? value
509
+ : null;
510
+ }
511
+
512
+ /**
513
+ * @param {unknown} value
514
+ * @returns {string[]}
515
+ */
516
+ function safeVersions(value) {
517
+ if (!Array.isArray(value)) {
518
+ return [];
519
+ }
520
+ /** @type {Set<string>} */
521
+ const versions = new Set();
522
+ for (const candidate of value) {
523
+ const version = safeVersion(candidate);
524
+ if (version) {
525
+ versions.add(version);
526
+ }
527
+ if (versions.size === 10) {
528
+ break;
529
+ }
530
+ }
531
+ return [...versions];
532
+ }
533
+
534
+ /**
535
+ * @param {string} left
536
+ * @param {string} right
537
+ * @returns {number}
538
+ */
539
+ function compareVersions(left, right) {
540
+ const leftParts = left.split('.').map((part) => Number.parseInt(part, 10) || 0);
541
+ const rightParts = right.split('.').map((part) => Number.parseInt(part, 10) || 0);
542
+ const length = Math.max(leftParts.length, rightParts.length);
543
+ for (let index = 0; index < length; index += 1) {
544
+ const difference = (leftParts[index] || 0) - (rightParts[index] || 0);
545
+ if (difference !== 0) {
546
+ return difference > 0 ? 1 : -1;
547
+ }
548
+ }
549
+ return 0;
550
+ }
551
+
552
+ /**
553
+ * Convert untrusted daemon log entries into a fixed allowlisted shape. Raw
554
+ * messages are inspected only to derive known cause categories and are never
555
+ * returned.
556
+ *
557
+ * @param {unknown} value
558
+ * @returns {DoctorRecentEvent['cause'] | undefined}
559
+ */
560
+ function classifyRecentCause(value) {
561
+ const entry = asRecord(value);
562
+ if (!entry) {
563
+ return undefined;
564
+ }
565
+ const error = asRecord(entry.error);
566
+ const candidates = [
567
+ entry.cause,
568
+ entry.reason,
569
+ entry.errorCode,
570
+ entry.code,
571
+ entry.message,
572
+ entry.summary,
573
+ error?.code,
574
+ error?.message,
575
+ ]
576
+ .filter((candidate) => typeof candidate === 'string')
577
+ .map((candidate) => candidate.slice(0, 200))
578
+ .join(' ')
579
+ .slice(0, 1000);
580
+ if (/another debugger|debugger.{0,40}(?:already attached|conflict|in use)/iu.test(candidates)) {
581
+ return 'debugger_conflict';
582
+ }
583
+ if (/debugger.{0,40}detach|detach.{0,40}debugger/iu.test(candidates)) {
584
+ return 'debugger_detached';
585
+ }
586
+ if (/DIALOG_ACTION_CONFLICT/iu.test(candidates)) {
587
+ return 'dialog_conflict';
588
+ }
589
+ if (/EXTENSION_DISCONNECTED|extension.{0,40}disconnect/iu.test(candidates)) {
590
+ return 'extension_disconnected';
591
+ }
592
+ if (/TAB_MISMATCH|wrong.{0,20}window|enabled window.{0,30}mismatch/iu.test(candidates)) {
593
+ return 'wrong_window';
594
+ }
595
+ return undefined;
596
+ }
597
+
598
+ /**
599
+ * @param {unknown} value
600
+ * @returns {DoctorRecentEvent[]}
601
+ */
602
+ function summarizeRecentEvents(value) {
603
+ const result = asRecord(value);
604
+ const entries = Array.isArray(result?.entries)
605
+ ? result.entries
606
+ .slice(-DOCTOR_LOG_LIMIT)
607
+ .filter((candidate) => {
608
+ const entry = asRecord(candidate);
609
+ return !DOCTOR_DIAGNOSTIC_METHODS.has(
610
+ typeof entry?.method === 'string' ? entry.method : ''
611
+ );
612
+ })
613
+ .slice(-DOCTOR_RECENT_EVENT_LIMIT)
614
+ : [];
615
+ /** @type {DoctorRecentEvent[]} */
616
+ const events = [];
617
+ for (const candidate of entries) {
618
+ const entry = asRecord(candidate);
619
+ if (!entry) {
620
+ continue;
621
+ }
622
+ /** @type {DoctorRecentEvent} */
623
+ const event = {};
624
+ if (typeof entry.at === 'string' && /^\d{4}-\d{2}-\d{2}T/u.test(entry.at)) {
625
+ const timestamp = Date.parse(entry.at);
626
+ if (Number.isFinite(timestamp)) {
627
+ event.at = new Date(timestamp).toISOString();
628
+ }
629
+ }
630
+ if (typeof entry.method === 'string' && BRIDGE_METHOD_SET.has(entry.method)) {
631
+ event.method = /** @type {BridgeMethod} */ (entry.method);
632
+ }
633
+ if (typeof entry.ok === 'boolean') {
634
+ event.ok = entry.ok;
635
+ }
636
+ if (entry.source === 'cli' || entry.source === 'mcp') {
637
+ event.source = entry.source;
638
+ }
639
+ event.cause = classifyRecentCause(entry);
640
+ if (Object.values(event).some((field) => field !== undefined)) {
641
+ events.push(event);
642
+ }
643
+ }
644
+ return events;
645
+ }
646
+
647
+ /**
648
+ * @param {unknown} value
649
+ * @returns {import('./types.js').DoctorDaemonMetrics | null}
650
+ */
651
+ function summarizeMetrics(value) {
652
+ const metrics = asRecord(value);
653
+ if (!metrics) {
654
+ return null;
655
+ }
656
+ return {
657
+ uptimeMs: boundedCount(metrics.uptimeMs),
658
+ activeAgents: boundedCount(metrics.activeAgents),
659
+ activeExtensions: boundedCount(metrics.activeExtensions),
660
+ pendingRequests: boundedCount(metrics.pendingRequests),
661
+ requestsProcessed: boundedCount(metrics.requestsProcessed),
662
+ requestsFailed: boundedCount(metrics.requestsFailed),
663
+ avgResponseTimeMs: boundedCount(metrics.avgResponseTimeMs),
664
+ };
665
+ }
666
+
667
+ /**
668
+ * @param {unknown} value
669
+ * @param {'daemon' | 'direct'} source
670
+ * @returns {import('./types.js').DoctorSetupSummary | null}
671
+ */
672
+ function summarizeSetupStatus(value, source) {
673
+ const status = asRecord(value);
674
+ if (
675
+ (status?.scope !== 'global' && status?.scope !== 'local') ||
676
+ !Array.isArray(status.mcpClients) ||
677
+ !Array.isArray(status.skillTargets)
678
+ ) {
679
+ return null;
680
+ }
681
+ const mcpClients = status.mcpClients.slice(0, DOCTOR_SETUP_ENTRY_LIMIT).map(asRecord);
682
+ const skillTargets = status.skillTargets.slice(0, DOCTOR_SETUP_ENTRY_LIMIT).map(asRecord);
683
+ return {
684
+ source,
685
+ scope: status.scope,
686
+ mcp: {
687
+ detected: boundedCount(mcpClients.filter((client) => client?.detected === true).length),
688
+ configured: boundedCount(mcpClients.filter((client) => client?.configured === true).length),
689
+ },
690
+ skills: {
691
+ detected: boundedCount(skillTargets.filter((target) => target?.detected === true).length),
692
+ installed: boundedCount(skillTargets.filter((target) => target?.installed === true).length),
693
+ managed: boundedCount(skillTargets.filter((target) => target?.managed === true).length),
694
+ updatesAvailable: boundedCount(
695
+ skillTargets.filter((target) => target?.updateAvailable === true).length
696
+ ),
697
+ },
698
+ };
699
+ }
700
+
701
+ /**
702
+ * @param {Record<string, unknown> | null} health
703
+ * @returns {import('./types.js').DoctorDebuggerDiagnostics}
704
+ */
705
+ function summarizeDebugger(health) {
706
+ const debuggerHealth = asRecord(health?.debugger);
707
+ const captureHealth = asRecord(health?.capture);
708
+ const rawState = debuggerHealth?.state ?? debuggerHealth?.status ?? health?.debuggerState;
709
+ /** @type {import('./types.js').DoctorDebuggerDiagnostics['state']} */
710
+ let state =
711
+ typeof rawState === 'string' && SAFE_DEBUGGER_STATES.has(rawState)
712
+ ? /** @type {'idle' | 'active' | 'conflict' | 'detached'} */ (rawState)
713
+ : 'unknown';
714
+ if (debuggerHealth?.conflict === true) {
715
+ state = 'conflict';
716
+ }
717
+
718
+ const attachedTabs = debuggerHealth?.attachedTabs;
719
+ const attachedTabCount = Array.isArray(attachedTabs)
720
+ ? boundedCount(attachedTabs.length)
721
+ : typeof debuggerHealth?.attachedTabCount === 'number'
722
+ ? boundedCount(debuggerHealth.attachedTabCount)
723
+ : null;
724
+ const heldTabCount =
725
+ typeof debuggerHealth?.heldTabCount === 'number'
726
+ ? boundedCount(debuggerHealth.heldTabCount)
727
+ : null;
728
+ const pendingTabCount =
729
+ typeof debuggerHealth?.pendingTabCount === 'number'
730
+ ? boundedCount(debuggerHealth.pendingTabCount)
731
+ : null;
732
+ const recentReason =
733
+ typeof debuggerHealth?.recentReason === 'string' &&
734
+ SAFE_DEBUGGER_REASONS.has(debuggerHealth.recentReason)
735
+ ? /** @type {import('./types.js').DoctorDebuggerDiagnostics['recentReason']} */ (
736
+ debuggerHealth.recentReason
737
+ )
738
+ : null;
739
+ const rawCaptureState =
740
+ captureHealth?.state ?? debuggerHealth?.captureState ?? health?.captureState;
741
+ const captureState =
742
+ typeof rawCaptureState === 'string' && SAFE_CAPTURE_STATES.has(rawCaptureState)
743
+ ? /** @type {import('./types.js').DoctorDebuggerDiagnostics['captureState']} */ (
744
+ rawCaptureState
745
+ )
746
+ : 'unknown';
747
+ const captureActiveTabCount =
748
+ typeof captureHealth?.activeTabCount === 'number'
749
+ ? boundedCount(captureHealth.activeTabCount)
750
+ : null;
751
+ const captureOwnershipCount =
752
+ typeof captureHealth?.ownershipCount === 'number'
753
+ ? boundedCount(captureHealth.ownershipCount)
754
+ : null;
755
+ const captureInflightCount =
756
+ typeof captureHealth?.inflightCount === 'number'
757
+ ? boundedCount(captureHealth.inflightCount)
758
+ : null;
759
+ const interceptionActiveTabCount =
760
+ typeof captureHealth?.interceptionActiveTabCount === 'number'
761
+ ? boundedCount(captureHealth.interceptionActiveTabCount)
762
+ : null;
763
+ const interceptionRuleCount =
764
+ typeof captureHealth?.interceptionRuleCount === 'number'
765
+ ? boundedCount(captureHealth.interceptionRuleCount)
766
+ : null;
767
+ return {
768
+ state,
769
+ attachedTabCount,
770
+ heldTabCount,
771
+ pendingTabCount,
772
+ recentReason,
773
+ captureState,
774
+ captureActiveTabCount,
775
+ captureOwnershipCount,
776
+ captureInflightCount,
777
+ interceptionActiveTabCount,
778
+ interceptionRuleCount,
779
+ };
780
+ }
781
+
782
+ /**
783
+ * @param {Record<string, unknown> | null} health
784
+ * @param {boolean} extensionConnected
785
+ * @returns {import('./types.js').DoctorProtocolDiagnostics}
786
+ */
787
+ function summarizeProtocol(health, extensionConnected) {
788
+ const clientVersion = getProtocolVersion();
789
+ const advertisedDaemonVersions = safeVersions(health?.daemon_supported_versions);
790
+ const daemonSupportedVersions =
791
+ advertisedDaemonVersions.length > 0 || extensionConnected
792
+ ? advertisedDaemonVersions
793
+ : safeVersions(health?.supported_versions);
794
+ const extensionSupportedVersions = extensionConnected
795
+ ? Array.isArray(health?.extension_supported_versions)
796
+ ? safeVersions(health.extension_supported_versions)
797
+ : safeVersions(health?.supported_versions)
798
+ : [];
799
+ const daemonCompatible = daemonSupportedVersions.length
800
+ ? daemonSupportedVersions.includes(clientVersion)
801
+ : null;
802
+ const extensionCompatible = extensionConnected
803
+ ? extensionSupportedVersions.length
804
+ ? extensionSupportedVersions.includes(clientVersion)
805
+ : null
806
+ : null;
807
+ const knownCompatibility = [daemonCompatible, extensionCompatible].filter(
808
+ (value) => value !== null
809
+ );
810
+ const compatible = knownCompatibility.includes(false)
811
+ ? false
812
+ : knownCompatibility.length
813
+ ? true
814
+ : null;
815
+
816
+ /** @type {import('./types.js').DoctorProtocolDiagnostics['migration']} */
817
+ let migration = compatible === true ? 'none' : 'unknown';
818
+ const latestExtension = extensionSupportedVersions[0];
819
+ const latestDaemon = daemonSupportedVersions[0];
820
+ if (extensionCompatible === false && latestExtension) {
821
+ migration =
822
+ compareVersions(latestExtension, clientVersion) > 0 ? 'update_client' : 'update_extension';
823
+ } else if (daemonCompatible === false && latestDaemon) {
824
+ migration =
825
+ compareVersions(latestDaemon, clientVersion) > 0 ? 'update_client' : 'restart_daemon';
826
+ }
827
+
828
+ return {
829
+ clientVersion,
830
+ daemonVersion: safeVersion(health?.daemonVersion),
831
+ daemonSupportedVersions,
832
+ extensionSupportedVersions,
833
+ daemonCompatible,
834
+ extensionCompatible,
835
+ compatible,
836
+ migration,
837
+ };
838
+ }
839
+
380
840
  /**
381
841
  * @param {DoctorReportOptions} [options={}]
382
842
  * @returns {Promise<DoctorReport>}
@@ -404,6 +864,53 @@ export async function getDoctorReport(options = {}) {
404
864
  CHROMIUM_SANDBOXED_MANIFEST_RE.test(entry.manifestPath)
405
865
  );
406
866
 
867
+ /** @type {'socket' | 'tcp' | 'unknown'} */
868
+ let transportKind = 'unknown';
869
+ let proxyConfigured = false;
870
+ /** @type {string[]} */
871
+ const diagnosticFailures = [];
872
+ /** @type {string[]} */
873
+ let installedExtensionIds = [];
874
+ try {
875
+ installedExtensionIds = await (options.readInstalledExtensionIds || readInstalledExtensionIds)(
876
+ browserManifests
877
+ );
878
+ installedExtensionIds = installedExtensionIds
879
+ .filter((extensionId) => CHROME_EXTENSION_ID_RE.test(extensionId))
880
+ .slice(0, DOCTOR_SETUP_ENTRY_LIMIT);
881
+ } catch {
882
+ diagnosticFailures.push('manifest_identity_unavailable');
883
+ }
884
+ try {
885
+ transportKind = (options.getLocalTransport || getBridgeTransport)().type;
886
+ } catch {
887
+ diagnosticFailures.push('transport_config_unavailable');
888
+ }
889
+ try {
890
+ proxyConfigured = Boolean((options.readProxyConfig || readProxyConfig)());
891
+ } catch {
892
+ diagnosticFailures.push('proxy_config_unavailable');
893
+ }
894
+
895
+ /** @type {import('./types.js').DoctorRemoteDiagnostics} */
896
+ let remoteDestinations = {
897
+ configuredCount: 0,
898
+ status: 'not_configured',
899
+ credentials: 'not_configured',
900
+ };
901
+ try {
902
+ const remoteConfig = await (options.readRemoteConfig || readRemoteConfig)();
903
+ const configuredCount = remoteConfig.remotes.length;
904
+ remoteDestinations = {
905
+ configuredCount,
906
+ status: configuredCount > 0 ? 'not_probed_local_only' : 'not_configured',
907
+ credentials: configuredCount > 0 ? 'unverified' : 'not_configured',
908
+ };
909
+ } catch {
910
+ diagnosticFailures.push('remote_config_unavailable');
911
+ remoteDestinations.status = 'config_unavailable';
912
+ }
913
+
407
914
  /** @type {DoctorReport} */
408
915
  const report = {
409
916
  manifestInstalled,
@@ -412,6 +919,7 @@ export async function getDoctorReport(options = {}) {
412
919
  defaultExtensionId: defaultExtensionId.extensionId,
413
920
  defaultExtensionIdSource: defaultExtensionId.source,
414
921
  daemonReachable: false,
922
+ healthAvailable: false,
415
923
  extensionConnected: false,
416
924
  accessEnabled: false,
417
925
  enabledWindowId: null,
@@ -425,35 +933,202 @@ export async function getDoctorReport(options = {}) {
425
933
  issues: [],
426
934
  nextSteps: [],
427
935
  browserManifests,
936
+ transport: {
937
+ kind: transportKind,
938
+ local: true,
939
+ status: 'offline',
940
+ proxyConfigured,
941
+ proxyExposed: null,
942
+ credentials: transportKind === 'socket' ? 'not_required' : 'unknown',
943
+ },
944
+ connections: {
945
+ extensionCount: 0,
946
+ profileCount: 0,
947
+ },
948
+ protocol: summarizeProtocol(null, false),
949
+ debugger: summarizeDebugger(null),
950
+ metrics: null,
951
+ recentEvents: [],
952
+ recentCauses: [],
953
+ setup: null,
954
+ remoteDestinations,
955
+ diagnosticFailures,
428
956
  };
429
957
 
958
+ /** @type {Record<string, unknown> | null} */
959
+ let healthResult = null;
960
+ /** @type {unknown} */
961
+ let setupStatus = null;
962
+ /** @type {Record<string, unknown> | null} */
963
+ let metricsResult = null;
964
+ /** @type {Record<string, unknown> | null} */
965
+ let logsResult = null;
966
+ /** @type {unknown} */
967
+ let connectionError = null;
968
+
430
969
  try {
431
- await (options.bridgeClientRunner || withBridgeClient)(async (client) => {
432
- const response = await client.request({ method: 'health.ping' });
433
- if (!response.ok) {
434
- throw new Error(response.error.message);
970
+ await (options.bridgeClientRunner || withDoctorBridgeClient)(async (client) => {
971
+ report.daemonReachable = true;
972
+ /**
973
+ * @param {BridgeMethod} method
974
+ * @param {Record<string, unknown>} [params]
975
+ * @returns {Promise<Record<string, unknown> | null>}
976
+ */
977
+ async function requestDiagnostic(method, params = {}) {
978
+ try {
979
+ const response = await client.request({ method, params });
980
+ if (!response.ok) {
981
+ diagnosticFailures.push(`${method}_failed`);
982
+ return null;
983
+ }
984
+ report.daemonReachable = true;
985
+ return asRecord(response.result);
986
+ } catch {
987
+ diagnosticFailures.push(`${method}_failed`);
988
+ return null;
989
+ }
990
+ }
991
+
992
+ healthResult = await requestDiagnostic('health.ping');
993
+ if (options.includeSetupStatus !== false) {
994
+ const setupResult = await requestDiagnostic('setup.get_status');
995
+ if (setupResult) {
996
+ setupStatus = setupResult;
997
+ }
435
998
  }
436
- const result = /** @type {{ daemon?: string, extensionConnected?: boolean, access?: {
437
- enabled?: boolean,
438
- windowId?: number | null,
439
- routeTabId?: number | null,
440
- routeReady?: boolean,
441
- reason?: string
442
- } }} */ (response.result);
443
- report.daemonReachable = result.daemon === 'ok';
444
- report.extensionConnected = result.extensionConnected === true;
445
- report.accessEnabled = result.access?.enabled === true;
446
- report.enabledWindowId =
447
- typeof result.access?.windowId === 'number' ? result.access.windowId : null;
448
- report.routeTabId =
449
- typeof result.access?.routeTabId === 'number' ? result.access.routeTabId : null;
450
- report.routeReady = result.access?.routeReady === true;
451
- report.routeReason =
452
- typeof result.access?.reason === 'string' ? result.access.reason : 'access_disabled';
999
+ logsResult = await requestDiagnostic('log.tail', { limit: DOCTOR_LOG_LIMIT });
1000
+ metricsResult = await requestDiagnostic('daemon.metrics');
453
1001
  });
454
- } catch {
455
- report.daemonReachable = false;
456
- report.extensionConnected = false;
1002
+ } catch (error) {
1003
+ connectionError = error;
1004
+ diagnosticFailures.push('daemon_connection_failed');
1005
+ }
1006
+
1007
+ if (!report.daemonReachable && options.includeSetupStatus !== false) {
1008
+ try {
1009
+ setupStatus = await (options.collectSetupStatus || collectSetupStatus)();
1010
+ const summary = summarizeSetupStatus(setupStatus, 'direct');
1011
+ if (summary) {
1012
+ report.setup = summary;
1013
+ } else {
1014
+ diagnosticFailures.push('setup_status_invalid');
1015
+ }
1016
+ } catch {
1017
+ diagnosticFailures.push('setup_direct_failed');
1018
+ }
1019
+ } else if (setupStatus) {
1020
+ const summary = summarizeSetupStatus(setupStatus, 'daemon');
1021
+ if (summary) {
1022
+ report.setup = summary;
1023
+ } else {
1024
+ diagnosticFailures.push('setup_status_invalid');
1025
+ }
1026
+ }
1027
+
1028
+ const connectionMessage =
1029
+ connectionError instanceof Error ? connectionError.message.slice(0, 200) : '';
1030
+ const authenticationFailed = /authentication failed|access denied|invalid.{0,20}token/iu.test(
1031
+ connectionMessage
1032
+ );
1033
+ report.transport.status = authenticationFailed
1034
+ ? 'authentication_failed'
1035
+ : report.daemonReachable
1036
+ ? 'reachable'
1037
+ : 'offline';
1038
+ report.transport.credentials =
1039
+ transportKind === 'socket'
1040
+ ? 'not_required'
1041
+ : authenticationFailed
1042
+ ? 'rejected'
1043
+ : report.daemonReachable
1044
+ ? 'accepted'
1045
+ : 'unknown';
1046
+
1047
+ const resolvedHealth = /** @type {Record<string, unknown> | null} */ (
1048
+ /** @type {unknown} */ (healthResult)
1049
+ );
1050
+ report.healthAvailable = resolvedHealth?.daemon === 'ok';
1051
+ if (
1052
+ report.daemonReachable &&
1053
+ !report.healthAvailable &&
1054
+ !diagnosticFailures.includes('health.ping_failed')
1055
+ ) {
1056
+ diagnosticFailures.push('health.ping_invalid');
1057
+ }
1058
+ const healthAccess = report.healthAvailable ? asRecord(resolvedHealth?.access) : null;
1059
+ if (report.healthAvailable) {
1060
+ report.extensionConnected = resolvedHealth?.extensionConnected === true;
1061
+ report.accessEnabled = healthAccess?.enabled === true;
1062
+ report.enabledWindowId =
1063
+ typeof healthAccess?.windowId === 'number' ? boundedCount(healthAccess.windowId) : null;
1064
+ report.routeTabId =
1065
+ typeof healthAccess?.routeTabId === 'number' ? boundedCount(healthAccess.routeTabId) : null;
1066
+ report.routeReady = healthAccess?.routeReady === true;
1067
+ report.routeReason =
1068
+ typeof healthAccess?.reason === 'string' && SAFE_ROUTE_REASONS.has(healthAccess.reason)
1069
+ ? healthAccess.reason
1070
+ : 'access_disabled';
1071
+ }
1072
+
1073
+ const connectedExtensions =
1074
+ report.healthAvailable && Array.isArray(resolvedHealth?.connectedExtensions)
1075
+ ? resolvedHealth.connectedExtensions.slice(0, 100)
1076
+ : [];
1077
+ let profileCount = 0;
1078
+ for (const extension of connectedExtensions) {
1079
+ const extensionRecord = asRecord(extension);
1080
+ if (typeof extensionRecord?.profileLabel === 'string' && extensionRecord.profileLabel) {
1081
+ profileCount += 1;
1082
+ }
1083
+ }
1084
+ report.metrics = summarizeMetrics(metricsResult);
1085
+ report.connections.extensionCount = connectedExtensions.length
1086
+ ? connectedExtensions.length
1087
+ : report.metrics?.activeExtensions || (report.extensionConnected ? 1 : 0);
1088
+ report.connections.profileCount = profileCount;
1089
+ if (report.healthAvailable && report.connections.extensionCount > 0) {
1090
+ report.extensionConnected = true;
1091
+ }
1092
+
1093
+ const proxyHealth = report.healthAvailable ? asRecord(resolvedHealth?.proxy) : null;
1094
+ report.transport.proxyConfigured = proxyConfigured;
1095
+ report.transport.proxyExposed =
1096
+ typeof proxyHealth?.enabled === 'boolean' ? proxyHealth.enabled : null;
1097
+ report.protocol = summarizeProtocol(
1098
+ report.healthAvailable ? resolvedHealth : null,
1099
+ report.extensionConnected
1100
+ );
1101
+ report.recentEvents = summarizeRecentEvents(logsResult);
1102
+ report.recentCauses = [
1103
+ ...new Set(
1104
+ report.recentEvents.map((event) => event.cause).filter((cause) => cause !== undefined)
1105
+ ),
1106
+ ];
1107
+ report.debugger = summarizeDebugger(report.healthAvailable ? resolvedHealth : null);
1108
+ if (report.debugger.recentReason && !report.recentCauses.includes(report.debugger.recentReason)) {
1109
+ report.recentCauses.push(report.debugger.recentReason);
1110
+ }
1111
+
1112
+ // Keep optional diagnostics deterministic even if several RPCs fail.
1113
+ report.diagnosticFailures = [...new Set(diagnosticFailures)].slice(0, 12);
1114
+
1115
+ const connectedBrowserExtensionIds = connectedExtensions
1116
+ .map((extension) => asRecord(extension)?.browserExtensionId)
1117
+ .filter(
1118
+ (extensionId) => typeof extensionId === 'string' && CHROME_EXTENSION_ID_RE.test(extensionId)
1119
+ );
1120
+ const extensionIdentityMismatch =
1121
+ connectedBrowserExtensionIds.length > 0 &&
1122
+ installedExtensionIds.length > 0 &&
1123
+ connectedBrowserExtensionIds.some(
1124
+ (extensionId) => !installedExtensionIds.includes(/** @type {string} */ (extensionId))
1125
+ );
1126
+
1127
+ if (authenticationFailed) {
1128
+ report.issues.push('proxy_credentials_stale');
1129
+ report.nextSteps.push(
1130
+ 'Local TCP authentication failed. Run `bbx proxy enable --rotate-token`, restart the daemon, and update configured remote clients with `bbx remote add`.'
1131
+ );
457
1132
  }
458
1133
 
459
1134
  if (unwritableBridgePaths.length > 0) {
@@ -482,11 +1157,23 @@ export async function getDoctorReport(options = {}) {
482
1157
  : 'Run `bbx install <extension-id>` (or `bbx install --all`) to install the native host manifest.'
483
1158
  );
484
1159
  }
485
- if (!report.daemonReachable) {
1160
+ if (!report.daemonReachable && !authenticationFailed) {
486
1161
  report.issues.push('daemon_offline');
487
1162
  report.nextSteps.push('Run `bbx-daemon` and retry `bbx status` or `bbx doctor`.');
488
1163
  }
489
- if (report.daemonReachable && !report.extensionConnected) {
1164
+ if (report.daemonReachable && !report.healthAvailable) {
1165
+ report.issues.push('health_unavailable');
1166
+ report.nextSteps.push(
1167
+ 'The daemon is reachable but `health.ping` did not return valid core health. Run `bbx restart`, then update the Browser Bridge CLI and extension if the health check still fails.'
1168
+ );
1169
+ }
1170
+ if (report.healthAvailable && extensionIdentityMismatch) {
1171
+ report.issues.push('extension_id_mismatch');
1172
+ report.nextSteps.push(
1173
+ 'A connected Chrome extension ID is not allowed by any installed native host manifest. Reinstall the matching browser manifest with `bbx install --browser <browser>` and reload that extension.'
1174
+ );
1175
+ }
1176
+ if (report.healthAvailable && !report.extensionConnected) {
490
1177
  report.issues.push('extension_disconnected');
491
1178
  if (chromiumSandboxedManifestInstalled) {
492
1179
  report.issues.push('chromium_sandboxed_native_host_limited');
@@ -495,20 +1182,66 @@ export async function getDoctorReport(options = {}) {
495
1182
  );
496
1183
  }
497
1184
  report.nextSteps.push(
498
- 'Open Chrome and make sure the Browser Bridge extension is installed and active.'
1185
+ 'Open Chrome and make sure the Browser Bridge extension is installed and active. If Chrome reports a missing native host, run `bbx install --all` and reload the extension.'
499
1186
  );
500
1187
  }
501
- if (report.daemonReachable && report.extensionConnected && !report.accessEnabled) {
502
- report.issues.push('access_disabled');
503
- report.nextSteps.push(
504
- 'If a Browser Bridge call returns ACCESS_DENIED, stop requesting access. Ask the user to click Enable for the needed window, then tell you when that window is ready.'
505
- );
506
- } else if (report.daemonReachable && report.extensionConnected && !report.routeReady) {
1188
+ if (report.healthAvailable && report.extensionConnected && !report.accessEnabled) {
1189
+ if (report.routeReason === 'enabled_window_missing') {
1190
+ report.issues.push('enabled_window_missing');
1191
+ report.nextSteps.push(
1192
+ 'The previously enabled window no longer exists. Focus the intended Chrome window and click Enable in the Browser Bridge popup or side panel.'
1193
+ );
1194
+ } else {
1195
+ report.issues.push('access_disabled');
1196
+ report.nextSteps.push(
1197
+ 'If a Browser Bridge call returns ACCESS_DENIED, stop requesting access. Ask the user to focus the needed Chrome window and click Enable for the needed window, then tell you when that window is ready.'
1198
+ );
1199
+ }
1200
+ } else if (report.healthAvailable && report.extensionConnected && !report.routeReady) {
507
1201
  report.issues.push(report.routeReason || 'no_routable_active_tab');
508
1202
  report.nextSteps.push(
509
1203
  'Switch to a supported page in the enabled window, or use an explicit tabId override.'
510
1204
  );
511
1205
  }
512
1206
 
1207
+ if (report.protocol.compatible === false) {
1208
+ report.issues.push('protocol_mismatch');
1209
+ if (report.protocol.migration === 'update_extension') {
1210
+ report.nextSteps.push(
1211
+ 'Update or reload the Browser Bridge extension so it supports the client protocol, then retry `bbx doctor`.'
1212
+ );
1213
+ } else if (report.protocol.migration === 'restart_daemon') {
1214
+ report.nextSteps.push(
1215
+ 'Run `bbx restart` to replace the stale daemon with this installed CLI version, then retry `bbx doctor`.'
1216
+ );
1217
+ } else {
1218
+ report.nextSteps.push(
1219
+ 'Update the Browser Bridge CLI/npm package, run `bbx restart`, and retry `bbx doctor`.'
1220
+ );
1221
+ }
1222
+ }
1223
+
1224
+ if (report.debugger.state === 'conflict') {
1225
+ report.issues.push('debugger_conflict');
1226
+ }
1227
+ if (report.debugger.state === 'conflict' || report.recentCauses.includes('debugger_conflict')) {
1228
+ report.nextSteps.push(
1229
+ 'Close DevTools or any other debugger attached to the target tab, then retry the debugger-backed Browser Bridge operation.'
1230
+ );
1231
+ } else if (
1232
+ report.debugger.state === 'detached' ||
1233
+ report.recentCauses.includes('debugger_detached')
1234
+ ) {
1235
+ report.nextSteps.push(
1236
+ 'A recent debugger session detached. Keep the target tab open and retry the debugger-backed operation; close competing debuggers if detaches continue.'
1237
+ );
1238
+ }
1239
+
1240
+ if (report.recentCauses.includes('wrong_window')) {
1241
+ report.nextSteps.push(
1242
+ 'A recent request targeted the wrong window. Focus the intended Chrome window, click Enable there, and retry without reusing a tabId from another window.'
1243
+ );
1244
+ }
1245
+
513
1246
  return report;
514
1247
  }