@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
@@ -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,75 @@ 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,
31
+ normalizeRecoveryTelemetrySummary,
32
+ RECOVERY_EVENT_KINDS,
33
+ sanitizeIncidentalPath,
34
+ sanitizeIncidentalText,
27
35
  } from '../../protocol/src/index.js';
28
36
  import { methodNeedsTab } from './cli-helpers.js';
29
37
  import { BridgeClient } from './client.js';
38
+ import { applyConfiguredAutoUpdate } from './config.js';
39
+ import { readRemoteConfig } from './remotes.js';
40
+ import { collectSetupStatus } from './setup-status.js';
30
41
 
31
42
  /** @typedef {import('./types.js').BridgeMethod} BridgeMethod */
32
43
  /** @typedef {import('./types.js').BridgeMeta} BridgeMeta */
33
44
  /** @typedef {import('./types.js').BridgeRequestSource} BridgeRequestSource */
34
45
  /** @typedef {import('./types.js').BridgeResponse} BridgeResponse */
46
+ /** @typedef {import('./types.js').BridgeClientOptions} BridgeClientOptions */
35
47
  /** @typedef {import('../../native-host/src/config.js').SupportedBrowser} SupportedBrowser */
36
48
  /** @typedef {import('./types.js').BrowserManifestStatus} BrowserManifestStatus */
37
49
  /** @typedef {import('./types.js').NativeHostManifestIssue} NativeHostManifestIssue */
38
50
  /** @typedef {import('./types.js').DoctorReport} DoctorReport */
39
51
  /** @typedef {import('./types.js').DoctorReportOptions} DoctorReportOptions */
52
+ /** @typedef {import('./types.js').DoctorRecentEvent} DoctorRecentEvent */
40
53
 
41
54
  const CHROMIUM_SANDBOXED_MANIFEST_RE =
42
55
  /(?:^|[/\\])(?:snap[/\\]chromium|\.var[/\\]app[/\\]org\.chromium\.Chromium)[/\\]/;
56
+ const DOCTOR_LOG_LIMIT = 12;
57
+ const DOCTOR_RECENT_EVENT_LIMIT = 10;
58
+ const DOCTOR_SETUP_ENTRY_LIMIT = 100;
59
+ const MAX_DOCTOR_COUNT = 1_000_000_000;
60
+ const SAFE_VERSION_RE = /^\d+\.\d+(?:\.\d+)?(?:[-+][A-Za-z0-9.-]+)?$/u;
61
+ const CHROME_EXTENSION_ID_RE = /^[a-p]{32}$/u;
62
+ const SAFE_DEBUGGER_STATES = new Set(['idle', 'active', 'conflict', 'detached']);
63
+ const SAFE_ROUTE_REASONS = new Set([
64
+ 'enabled',
65
+ 'access_disabled',
66
+ 'enabled_window_missing',
67
+ 'no_routable_active_tab',
68
+ 'reconnected',
69
+ 'restricted_page',
70
+ ]);
71
+ const SAFE_CAPTURE_STATES = new Set([
72
+ 'idle',
73
+ 'stopped',
74
+ 'armed',
75
+ 'active',
76
+ 'capturing',
77
+ 'stop_failed',
78
+ 'unavailable',
79
+ ]);
80
+ const BRIDGE_METHOD_SET = new Set(Object.keys(BRIDGE_METHOD_REGISTRY));
81
+ const DOCTOR_DIAGNOSTIC_METHODS = new Set([
82
+ 'health.ping',
83
+ 'setup.get_status',
84
+ 'log.tail',
85
+ 'daemon.metrics',
86
+ ]);
87
+ const SAFE_DEBUGGER_REASONS = new Set([
88
+ 'debugger_conflict',
89
+ 'debugger_detached',
90
+ 'debugger_replaced',
91
+ 'debugger_canceled',
92
+ 'target_closed',
93
+ ]);
43
94
 
44
95
  /**
45
96
  * @param {BridgeClient} client
@@ -55,7 +106,7 @@ export async function ensureClientConnected(client) {
55
106
  * @param {BridgeClient} client
56
107
  * @param {BridgeMethod} method
57
108
  * @param {Record<string, unknown>} [params={}]
58
- * @param {{ tabId?: number | null, source?: BridgeRequestSource, tokenBudget?: number | null }} [options]
109
+ * @param {{ tabId?: number | null, source?: BridgeRequestSource, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} [options]
59
110
  * @returns {Promise<BridgeResponse>}
60
111
  */
61
112
  export async function requestBridge(client, method, params = {}, options = {}) {
@@ -76,7 +127,7 @@ export async function requestBridge(client, method, params = {}, options = {}) {
76
127
  method,
77
128
  params,
78
129
  tabId: methodNeedsTab(method) ? (options.tabId ?? null) : null,
79
- meta: withRequestMeta(options.source, options.tokenBudget),
130
+ meta: withRequestMeta(options.source, options.tokenBudget, options.automaticRetry),
80
131
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
81
132
  });
82
133
  }
@@ -118,9 +169,10 @@ export async function resolveRef(client, refOrSelector, tabId = null, source) {
118
169
  /**
119
170
  * @param {BridgeRequestSource | undefined} source
120
171
  * @param {number | null | undefined} tokenBudget
172
+ * @param {'mcp_second_attempt' | undefined} automaticRetry
121
173
  * @returns {BridgeMeta}
122
174
  */
123
- function withRequestMeta(source, tokenBudget) {
175
+ function withRequestMeta(source, tokenBudget, automaticRetry) {
124
176
  /** @type {BridgeMeta} */
125
177
  const meta = {};
126
178
  if (source) {
@@ -129,16 +181,20 @@ function withRequestMeta(source, tokenBudget) {
129
181
  if (typeof tokenBudget === 'number' && Number.isFinite(tokenBudget)) {
130
182
  meta.token_budget = tokenBudget;
131
183
  }
184
+ if (automaticRetry === 'mcp_second_attempt') {
185
+ meta.automatic_retry = { attempt: 2, reason: 'retryable_error' };
186
+ }
132
187
  return meta;
133
188
  }
134
189
 
135
190
  /**
136
191
  * @template T
137
192
  * @param {(client: BridgeClient) => Promise<T>} callback
193
+ * @param {BridgeClientOptions} [options]
138
194
  * @returns {Promise<T>}
139
195
  */
140
- export async function withBridgeClient(callback) {
141
- const client = new BridgeClient();
196
+ export async function withBridgeClient(callback, options) {
197
+ const client = new BridgeClient(await applyConfiguredAutoUpdate(options));
142
198
  await ensureClientConnected(client);
143
199
  try {
144
200
  return await callback(client);
@@ -188,6 +244,44 @@ export async function checkBrowserManifests() {
188
244
  );
189
245
  }
190
246
 
247
+ /**
248
+ * Read the allowlisted extension IDs from every installed browser manifest.
249
+ * Invalid manifests are already reported by the native-host health check and
250
+ * do not prevent the remaining browsers from contributing identity data.
251
+ *
252
+ * @param {BrowserManifestStatus[]} browserManifests
253
+ * @param {typeof fs.promises.readFile} [readFile]
254
+ * @returns {Promise<string[]>}
255
+ */
256
+ export async function readInstalledExtensionIds(
257
+ browserManifests,
258
+ readFile = fs.promises.readFile.bind(fs.promises)
259
+ ) {
260
+ /** @type {Set<string>} */
261
+ const extensionIds = new Set();
262
+ for (const entry of browserManifests.slice(0, DOCTOR_SETUP_ENTRY_LIMIT)) {
263
+ if (!entry.installed || extensionIds.size >= DOCTOR_SETUP_ENTRY_LIMIT) {
264
+ continue;
265
+ }
266
+ try {
267
+ const parsed = JSON.parse(await readFile(entry.manifestPath, 'utf8'));
268
+ const origins = Array.isArray(parsed?.allowed_origins) ? parsed.allowed_origins : [];
269
+ for (const origin of origins.slice(0, DOCTOR_SETUP_ENTRY_LIMIT)) {
270
+ const match =
271
+ typeof origin === 'string'
272
+ ? /^chrome-extension:\/\/([a-p]{32})\/(?:\*)?$/u.exec(origin)
273
+ : null;
274
+ if (match?.[1]) {
275
+ extensionIds.add(match[1]);
276
+ }
277
+ }
278
+ } catch {
279
+ // The native-host manifest health collector reports malformed files.
280
+ }
281
+ }
282
+ return [...extensionIds];
283
+ }
284
+
191
285
  /**
192
286
  * @param {string} line
193
287
  * @param {number} startIndex
@@ -377,6 +471,405 @@ export async function checkUnwritableBridgePaths() {
377
471
  return unwritable;
378
472
  }
379
473
 
474
+ /**
475
+ * Doctor must never restart a daemon while it is inspecting version drift.
476
+ *
477
+ * @template T
478
+ * @param {(client: BridgeClient) => Promise<T>} callback
479
+ * @returns {Promise<T>}
480
+ */
481
+ async function withDoctorBridgeClient(callback) {
482
+ const client = new BridgeClient({ restartDaemonOnVersionMismatch: false });
483
+ try {
484
+ await ensureClientConnected(client);
485
+ return await callback(client);
486
+ } finally {
487
+ await client.close().catch(() => {});
488
+ }
489
+ }
490
+
491
+ /**
492
+ * @param {unknown} value
493
+ * @returns {Record<string, unknown> | null}
494
+ */
495
+ function asRecord(value) {
496
+ return value && typeof value === 'object' && !Array.isArray(value)
497
+ ? /** @type {Record<string, unknown>} */ (value)
498
+ : null;
499
+ }
500
+
501
+ /**
502
+ * @param {unknown} value
503
+ * @returns {number}
504
+ */
505
+ function boundedCount(value) {
506
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
507
+ ? Math.min(MAX_DOCTOR_COUNT, Math.trunc(value))
508
+ : 0;
509
+ }
510
+
511
+ /**
512
+ * @param {unknown} value
513
+ * @returns {string | null}
514
+ */
515
+ function safeVersion(value) {
516
+ return typeof value === 'string' && value.length <= 32 && SAFE_VERSION_RE.test(value)
517
+ ? value
518
+ : null;
519
+ }
520
+
521
+ /**
522
+ * @param {unknown} value
523
+ * @returns {string[]}
524
+ */
525
+ function safeVersions(value) {
526
+ if (!Array.isArray(value)) {
527
+ return [];
528
+ }
529
+ /** @type {Set<string>} */
530
+ const versions = new Set();
531
+ for (const candidate of value) {
532
+ const version = safeVersion(candidate);
533
+ if (version) {
534
+ versions.add(version);
535
+ }
536
+ if (versions.size === 10) {
537
+ break;
538
+ }
539
+ }
540
+ return [...versions];
541
+ }
542
+
543
+ /**
544
+ * @param {string} left
545
+ * @param {string} right
546
+ * @returns {number}
547
+ */
548
+ function compareVersions(left, right) {
549
+ const leftParts = left.split('.').map((part) => Number.parseInt(part, 10) || 0);
550
+ const rightParts = right.split('.').map((part) => Number.parseInt(part, 10) || 0);
551
+ const length = Math.max(leftParts.length, rightParts.length);
552
+ for (let index = 0; index < length; index += 1) {
553
+ const difference = (leftParts[index] || 0) - (rightParts[index] || 0);
554
+ if (difference !== 0) {
555
+ return difference > 0 ? 1 : -1;
556
+ }
557
+ }
558
+ return 0;
559
+ }
560
+
561
+ /**
562
+ * Convert untrusted daemon log entries into a fixed allowlisted shape. Raw
563
+ * messages are inspected only to derive known cause categories and are never
564
+ * returned.
565
+ *
566
+ * @param {unknown} value
567
+ * @returns {DoctorRecentEvent['cause'] | undefined}
568
+ */
569
+ function classifyRecentCause(value) {
570
+ const entry = asRecord(value);
571
+ if (!entry) {
572
+ return undefined;
573
+ }
574
+ const error = asRecord(entry.error);
575
+ const candidates = [
576
+ entry.cause,
577
+ entry.reason,
578
+ entry.errorCode,
579
+ entry.code,
580
+ entry.message,
581
+ entry.summary,
582
+ error?.code,
583
+ error?.message,
584
+ ]
585
+ .filter((candidate) => typeof candidate === 'string')
586
+ .map((candidate) => candidate.slice(0, 200))
587
+ .join(' ')
588
+ .slice(0, 1000);
589
+ if (/another debugger|debugger.{0,40}(?:already attached|conflict|in use)/iu.test(candidates)) {
590
+ return 'debugger_conflict';
591
+ }
592
+ if (/debugger.{0,40}detach|detach.{0,40}debugger/iu.test(candidates)) {
593
+ return 'debugger_detached';
594
+ }
595
+ if (/DIALOG_ACTION_CONFLICT/iu.test(candidates)) {
596
+ return 'dialog_conflict';
597
+ }
598
+ if (/EXTENSION_DISCONNECTED|extension.{0,40}disconnect/iu.test(candidates)) {
599
+ return 'extension_disconnected';
600
+ }
601
+ if (/TAB_MISMATCH|wrong.{0,20}window|enabled window.{0,30}mismatch/iu.test(candidates)) {
602
+ return 'wrong_window';
603
+ }
604
+ return undefined;
605
+ }
606
+
607
+ /**
608
+ * @param {unknown} value
609
+ * @returns {DoctorRecentEvent[]}
610
+ */
611
+ function summarizeRecentEvents(value) {
612
+ const result = asRecord(value);
613
+ const entries = Array.isArray(result?.entries)
614
+ ? result.entries
615
+ .slice(-DOCTOR_LOG_LIMIT)
616
+ .filter((candidate) => {
617
+ const entry = asRecord(candidate);
618
+ return !DOCTOR_DIAGNOSTIC_METHODS.has(
619
+ typeof entry?.method === 'string' ? entry.method : ''
620
+ );
621
+ })
622
+ .slice(-DOCTOR_RECENT_EVENT_LIMIT)
623
+ : [];
624
+ /** @type {DoctorRecentEvent[]} */
625
+ const events = [];
626
+ for (const candidate of entries) {
627
+ const entry = asRecord(candidate);
628
+ if (!entry) {
629
+ continue;
630
+ }
631
+ /** @type {DoctorRecentEvent} */
632
+ const event = {};
633
+ if (typeof entry.at === 'string' && /^\d{4}-\d{2}-\d{2}T/u.test(entry.at)) {
634
+ const timestamp = Date.parse(entry.at);
635
+ if (Number.isFinite(timestamp)) {
636
+ event.at = new Date(timestamp).toISOString();
637
+ }
638
+ }
639
+ if (typeof entry.method === 'string' && BRIDGE_METHOD_SET.has(entry.method)) {
640
+ event.method = /** @type {BridgeMethod} */ (entry.method);
641
+ }
642
+ if (typeof entry.ok === 'boolean') {
643
+ event.ok = entry.ok;
644
+ }
645
+ if (entry.source === 'cli' || entry.source === 'mcp') {
646
+ event.source = entry.source;
647
+ }
648
+ event.cause = classifyRecentCause(entry);
649
+ if (Object.values(event).some((field) => field !== undefined)) {
650
+ events.push(event);
651
+ }
652
+ }
653
+ return events;
654
+ }
655
+
656
+ /**
657
+ * @param {unknown} value
658
+ * @returns {import('./types.js').DoctorDaemonMetrics | null}
659
+ */
660
+ function summarizeMetrics(value) {
661
+ const metrics = asRecord(value);
662
+ if (!metrics) {
663
+ return null;
664
+ }
665
+ return {
666
+ uptimeMs: boundedCount(metrics.uptimeMs),
667
+ activeAgents: boundedCount(metrics.activeAgents),
668
+ activeExtensions: boundedCount(metrics.activeExtensions),
669
+ pendingRequests: boundedCount(metrics.pendingRequests),
670
+ requestsProcessed: boundedCount(metrics.requestsProcessed),
671
+ requestsFailed: boundedCount(metrics.requestsFailed),
672
+ avgResponseTimeMs: boundedCount(metrics.avgResponseTimeMs),
673
+ recovery: normalizeRecoveryTelemetrySummary(metrics.recovery, 'daemon'),
674
+ };
675
+ }
676
+
677
+ /**
678
+ * @param {Record<string, unknown> | null} health
679
+ * @param {import('./types.js').DoctorDaemonMetrics | null} metrics
680
+ * @returns {import('./types.js').DoctorRecoveryDiagnostics}
681
+ */
682
+ function summarizeRecovery(health, metrics) {
683
+ const recovery = asRecord(health?.recovery);
684
+ const daemon = metrics?.recovery ?? normalizeRecoveryTelemetrySummary(recovery?.daemon, 'daemon');
685
+ const routedExtension = normalizeRecoveryTelemetrySummary(
686
+ recovery?.routedExtension,
687
+ 'routedExtension'
688
+ );
689
+ return {
690
+ daemon,
691
+ routedExtension,
692
+ activeLoops: RECOVERY_EVENT_KINDS.filter(
693
+ (kind) =>
694
+ daemon?.events[kind].activeLoop === true ||
695
+ routedExtension?.events[kind].activeLoop === true
696
+ ),
697
+ };
698
+ }
699
+
700
+ /**
701
+ * @param {unknown} value
702
+ * @param {'daemon' | 'direct'} source
703
+ * @returns {import('./types.js').DoctorSetupSummary | null}
704
+ */
705
+ function summarizeSetupStatus(value, source) {
706
+ const status = asRecord(value);
707
+ if (
708
+ (status?.scope !== 'global' && status?.scope !== 'local') ||
709
+ !Array.isArray(status.mcpClients) ||
710
+ !Array.isArray(status.skillTargets)
711
+ ) {
712
+ return null;
713
+ }
714
+ const mcpClients = status.mcpClients.slice(0, DOCTOR_SETUP_ENTRY_LIMIT).map(asRecord);
715
+ const skillTargets = status.skillTargets.slice(0, DOCTOR_SETUP_ENTRY_LIMIT).map(asRecord);
716
+ return {
717
+ source,
718
+ scope: status.scope,
719
+ mcp: {
720
+ detected: boundedCount(mcpClients.filter((client) => client?.detected === true).length),
721
+ configured: boundedCount(mcpClients.filter((client) => client?.configured === true).length),
722
+ },
723
+ skills: {
724
+ detected: boundedCount(skillTargets.filter((target) => target?.detected === true).length),
725
+ installed: boundedCount(skillTargets.filter((target) => target?.installed === true).length),
726
+ managed: boundedCount(skillTargets.filter((target) => target?.managed === true).length),
727
+ updatesAvailable: boundedCount(
728
+ skillTargets.filter((target) => target?.updateAvailable === true).length
729
+ ),
730
+ },
731
+ };
732
+ }
733
+
734
+ /**
735
+ * @param {Record<string, unknown> | null} health
736
+ * @returns {import('./types.js').DoctorDebuggerDiagnostics}
737
+ */
738
+ function summarizeDebugger(health) {
739
+ const debuggerHealth = asRecord(health?.debugger);
740
+ const captureHealth = asRecord(health?.capture);
741
+ const rawState = debuggerHealth?.state ?? debuggerHealth?.status ?? health?.debuggerState;
742
+ /** @type {import('./types.js').DoctorDebuggerDiagnostics['state']} */
743
+ let state =
744
+ typeof rawState === 'string' && SAFE_DEBUGGER_STATES.has(rawState)
745
+ ? /** @type {'idle' | 'active' | 'conflict' | 'detached'} */ (rawState)
746
+ : 'unknown';
747
+ if (debuggerHealth?.conflict === true) {
748
+ state = 'conflict';
749
+ }
750
+
751
+ const attachedTabs = debuggerHealth?.attachedTabs;
752
+ const attachedTabCount = Array.isArray(attachedTabs)
753
+ ? boundedCount(attachedTabs.length)
754
+ : typeof debuggerHealth?.attachedTabCount === 'number'
755
+ ? boundedCount(debuggerHealth.attachedTabCount)
756
+ : null;
757
+ const heldTabCount =
758
+ typeof debuggerHealth?.heldTabCount === 'number'
759
+ ? boundedCount(debuggerHealth.heldTabCount)
760
+ : null;
761
+ const pendingTabCount =
762
+ typeof debuggerHealth?.pendingTabCount === 'number'
763
+ ? boundedCount(debuggerHealth.pendingTabCount)
764
+ : null;
765
+ const recentReason =
766
+ typeof debuggerHealth?.recentReason === 'string' &&
767
+ SAFE_DEBUGGER_REASONS.has(debuggerHealth.recentReason)
768
+ ? /** @type {import('./types.js').DoctorDebuggerDiagnostics['recentReason']} */ (
769
+ debuggerHealth.recentReason
770
+ )
771
+ : null;
772
+ const rawCaptureState =
773
+ captureHealth?.state ?? debuggerHealth?.captureState ?? health?.captureState;
774
+ const captureState =
775
+ typeof rawCaptureState === 'string' && SAFE_CAPTURE_STATES.has(rawCaptureState)
776
+ ? /** @type {import('./types.js').DoctorDebuggerDiagnostics['captureState']} */ (
777
+ rawCaptureState
778
+ )
779
+ : 'unknown';
780
+ const captureActiveTabCount =
781
+ typeof captureHealth?.activeTabCount === 'number'
782
+ ? boundedCount(captureHealth.activeTabCount)
783
+ : null;
784
+ const captureOwnershipCount =
785
+ typeof captureHealth?.ownershipCount === 'number'
786
+ ? boundedCount(captureHealth.ownershipCount)
787
+ : null;
788
+ const captureInflightCount =
789
+ typeof captureHealth?.inflightCount === 'number'
790
+ ? boundedCount(captureHealth.inflightCount)
791
+ : null;
792
+ const interceptionActiveTabCount =
793
+ typeof captureHealth?.interceptionActiveTabCount === 'number'
794
+ ? boundedCount(captureHealth.interceptionActiveTabCount)
795
+ : null;
796
+ const interceptionRuleCount =
797
+ typeof captureHealth?.interceptionRuleCount === 'number'
798
+ ? boundedCount(captureHealth.interceptionRuleCount)
799
+ : null;
800
+ return {
801
+ state,
802
+ attachedTabCount,
803
+ heldTabCount,
804
+ pendingTabCount,
805
+ recentReason,
806
+ captureState,
807
+ captureActiveTabCount,
808
+ captureOwnershipCount,
809
+ captureInflightCount,
810
+ interceptionActiveTabCount,
811
+ interceptionRuleCount,
812
+ };
813
+ }
814
+
815
+ /**
816
+ * @param {Record<string, unknown> | null} health
817
+ * @param {boolean} extensionConnected
818
+ * @returns {import('./types.js').DoctorProtocolDiagnostics}
819
+ */
820
+ function summarizeProtocol(health, extensionConnected) {
821
+ const clientVersion = getProtocolVersion();
822
+ const advertisedDaemonVersions = safeVersions(health?.daemon_supported_versions);
823
+ const daemonSupportedVersions =
824
+ advertisedDaemonVersions.length > 0 || extensionConnected
825
+ ? advertisedDaemonVersions
826
+ : safeVersions(health?.supported_versions);
827
+ const extensionSupportedVersions = extensionConnected
828
+ ? Array.isArray(health?.extension_supported_versions)
829
+ ? safeVersions(health.extension_supported_versions)
830
+ : safeVersions(health?.supported_versions)
831
+ : [];
832
+ const daemonCompatible = daemonSupportedVersions.length
833
+ ? daemonSupportedVersions.includes(clientVersion)
834
+ : null;
835
+ const extensionCompatible = extensionConnected
836
+ ? extensionSupportedVersions.length
837
+ ? extensionSupportedVersions.includes(clientVersion)
838
+ : null
839
+ : null;
840
+ const knownCompatibility = [daemonCompatible, extensionCompatible].filter(
841
+ (value) => value !== null
842
+ );
843
+ const compatible = knownCompatibility.includes(false)
844
+ ? false
845
+ : knownCompatibility.length
846
+ ? true
847
+ : null;
848
+
849
+ /** @type {import('./types.js').DoctorProtocolDiagnostics['migration']} */
850
+ let migration = compatible === true ? 'none' : 'unknown';
851
+ const latestExtension = extensionSupportedVersions[0];
852
+ const latestDaemon = daemonSupportedVersions[0];
853
+ if (extensionCompatible === false && latestExtension) {
854
+ migration =
855
+ compareVersions(latestExtension, clientVersion) > 0 ? 'update_client' : 'update_extension';
856
+ } else if (daemonCompatible === false && latestDaemon) {
857
+ migration =
858
+ compareVersions(latestDaemon, clientVersion) > 0 ? 'update_client' : 'restart_daemon';
859
+ }
860
+
861
+ return {
862
+ clientVersion,
863
+ daemonVersion: safeVersion(health?.daemonVersion),
864
+ daemonSupportedVersions,
865
+ extensionSupportedVersions,
866
+ daemonCompatible,
867
+ extensionCompatible,
868
+ compatible,
869
+ migration,
870
+ };
871
+ }
872
+
380
873
  /**
381
874
  * @param {DoctorReportOptions} [options={}]
382
875
  * @returns {Promise<DoctorReport>}
@@ -404,6 +897,53 @@ export async function getDoctorReport(options = {}) {
404
897
  CHROMIUM_SANDBOXED_MANIFEST_RE.test(entry.manifestPath)
405
898
  );
406
899
 
900
+ /** @type {'socket' | 'tcp' | 'unknown'} */
901
+ let transportKind = 'unknown';
902
+ let proxyConfigured = false;
903
+ /** @type {string[]} */
904
+ const diagnosticFailures = [];
905
+ /** @type {string[]} */
906
+ let installedExtensionIds = [];
907
+ try {
908
+ installedExtensionIds = await (options.readInstalledExtensionIds || readInstalledExtensionIds)(
909
+ browserManifests
910
+ );
911
+ installedExtensionIds = installedExtensionIds
912
+ .filter((extensionId) => CHROME_EXTENSION_ID_RE.test(extensionId))
913
+ .slice(0, DOCTOR_SETUP_ENTRY_LIMIT);
914
+ } catch {
915
+ diagnosticFailures.push('manifest_identity_unavailable');
916
+ }
917
+ try {
918
+ transportKind = (options.getLocalTransport || getBridgeTransport)().type;
919
+ } catch {
920
+ diagnosticFailures.push('transport_config_unavailable');
921
+ }
922
+ try {
923
+ proxyConfigured = Boolean((options.readProxyConfig || readProxyConfig)());
924
+ } catch {
925
+ diagnosticFailures.push('proxy_config_unavailable');
926
+ }
927
+
928
+ /** @type {import('./types.js').DoctorRemoteDiagnostics} */
929
+ let remoteDestinations = {
930
+ configuredCount: 0,
931
+ status: 'not_configured',
932
+ credentials: 'not_configured',
933
+ };
934
+ try {
935
+ const remoteConfig = await (options.readRemoteConfig || readRemoteConfig)();
936
+ const configuredCount = remoteConfig.remotes.length;
937
+ remoteDestinations = {
938
+ configuredCount,
939
+ status: configuredCount > 0 ? 'not_probed_local_only' : 'not_configured',
940
+ credentials: configuredCount > 0 ? 'unverified' : 'not_configured',
941
+ };
942
+ } catch {
943
+ diagnosticFailures.push('remote_config_unavailable');
944
+ remoteDestinations.status = 'config_unavailable';
945
+ }
946
+
407
947
  /** @type {DoctorReport} */
408
948
  const report = {
409
949
  manifestInstalled,
@@ -412,6 +952,7 @@ export async function getDoctorReport(options = {}) {
412
952
  defaultExtensionId: defaultExtensionId.extensionId,
413
953
  defaultExtensionIdSource: defaultExtensionId.source,
414
954
  daemonReachable: false,
955
+ healthAvailable: false,
415
956
  extensionConnected: false,
416
957
  accessEnabled: false,
417
958
  enabledWindowId: null,
@@ -425,35 +966,207 @@ export async function getDoctorReport(options = {}) {
425
966
  issues: [],
426
967
  nextSteps: [],
427
968
  browserManifests,
969
+ transport: {
970
+ kind: transportKind,
971
+ local: true,
972
+ status: 'offline',
973
+ proxyConfigured,
974
+ proxyExposed: null,
975
+ credentials: transportKind === 'socket' ? 'not_required' : 'unknown',
976
+ },
977
+ connections: {
978
+ extensionCount: 0,
979
+ profileCount: 0,
980
+ },
981
+ protocol: summarizeProtocol(null, false),
982
+ debugger: summarizeDebugger(null),
983
+ recovery: summarizeRecovery(null, null),
984
+ metrics: null,
985
+ recentEvents: [],
986
+ recentCauses: [],
987
+ setup: null,
988
+ remoteDestinations,
989
+ diagnosticFailures,
428
990
  };
429
991
 
992
+ /** @type {Record<string, unknown> | null} */
993
+ let healthResult = null;
994
+ /** @type {unknown} */
995
+ let setupStatus = null;
996
+ /** @type {Record<string, unknown> | null} */
997
+ let metricsResult = null;
998
+ /** @type {Record<string, unknown> | null} */
999
+ let logsResult = null;
1000
+ /** @type {unknown} */
1001
+ let connectionError = null;
1002
+
430
1003
  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);
1004
+ await (options.bridgeClientRunner || withDoctorBridgeClient)(async (client) => {
1005
+ report.daemonReachable = true;
1006
+ /**
1007
+ * @param {BridgeMethod} method
1008
+ * @param {Record<string, unknown>} [params]
1009
+ * @returns {Promise<Record<string, unknown> | null>}
1010
+ */
1011
+ async function requestDiagnostic(method, params = {}) {
1012
+ try {
1013
+ const response = await client.request({ method, params });
1014
+ if (!response.ok) {
1015
+ diagnosticFailures.push(`${method}_failed`);
1016
+ return null;
1017
+ }
1018
+ report.daemonReachable = true;
1019
+ return asRecord(response.result);
1020
+ } catch {
1021
+ diagnosticFailures.push(`${method}_failed`);
1022
+ return null;
1023
+ }
1024
+ }
1025
+
1026
+ healthResult = await requestDiagnostic('health.ping');
1027
+ if (options.includeSetupStatus !== false) {
1028
+ const setupResult = await requestDiagnostic('setup.get_status');
1029
+ if (setupResult) {
1030
+ setupStatus = setupResult;
1031
+ }
435
1032
  }
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';
1033
+ logsResult = await requestDiagnostic('log.tail', { limit: DOCTOR_LOG_LIMIT });
1034
+ metricsResult = await requestDiagnostic('daemon.metrics');
453
1035
  });
454
- } catch {
455
- report.daemonReachable = false;
456
- report.extensionConnected = false;
1036
+ } catch (error) {
1037
+ connectionError = error;
1038
+ diagnosticFailures.push('daemon_connection_failed');
1039
+ }
1040
+
1041
+ if (!report.daemonReachable && options.includeSetupStatus !== false) {
1042
+ try {
1043
+ setupStatus = await (options.collectSetupStatus || collectSetupStatus)();
1044
+ const summary = summarizeSetupStatus(setupStatus, 'direct');
1045
+ if (summary) {
1046
+ report.setup = summary;
1047
+ } else {
1048
+ diagnosticFailures.push('setup_status_invalid');
1049
+ }
1050
+ } catch {
1051
+ diagnosticFailures.push('setup_direct_failed');
1052
+ }
1053
+ } else if (setupStatus) {
1054
+ const summary = summarizeSetupStatus(setupStatus, 'daemon');
1055
+ if (summary) {
1056
+ report.setup = summary;
1057
+ } else {
1058
+ diagnosticFailures.push('setup_status_invalid');
1059
+ }
1060
+ }
1061
+
1062
+ const connectionMessage =
1063
+ connectionError instanceof Error ? connectionError.message.slice(0, 200) : '';
1064
+ const authenticationFailed = /authentication failed|access denied|invalid.{0,20}token/iu.test(
1065
+ connectionMessage
1066
+ );
1067
+ report.transport.status = authenticationFailed
1068
+ ? 'authentication_failed'
1069
+ : report.daemonReachable
1070
+ ? 'reachable'
1071
+ : 'offline';
1072
+ report.transport.credentials =
1073
+ transportKind === 'socket'
1074
+ ? 'not_required'
1075
+ : authenticationFailed
1076
+ ? 'rejected'
1077
+ : report.daemonReachable
1078
+ ? 'accepted'
1079
+ : 'unknown';
1080
+
1081
+ const resolvedHealth = /** @type {Record<string, unknown> | null} */ (
1082
+ /** @type {unknown} */ (healthResult)
1083
+ );
1084
+ report.healthAvailable = resolvedHealth?.daemon === 'ok';
1085
+ if (
1086
+ report.daemonReachable &&
1087
+ !report.healthAvailable &&
1088
+ !diagnosticFailures.includes('health.ping_failed')
1089
+ ) {
1090
+ diagnosticFailures.push('health.ping_invalid');
1091
+ }
1092
+ const healthAccess = report.healthAvailable ? asRecord(resolvedHealth?.access) : null;
1093
+ if (report.healthAvailable) {
1094
+ report.extensionConnected = resolvedHealth?.extensionConnected === true;
1095
+ report.accessEnabled = healthAccess?.enabled === true;
1096
+ report.enabledWindowId =
1097
+ typeof healthAccess?.windowId === 'number' ? boundedCount(healthAccess.windowId) : null;
1098
+ report.routeTabId =
1099
+ typeof healthAccess?.routeTabId === 'number' ? boundedCount(healthAccess.routeTabId) : null;
1100
+ report.routeReady = healthAccess?.routeReady === true;
1101
+ report.routeReason =
1102
+ typeof healthAccess?.reason === 'string' && SAFE_ROUTE_REASONS.has(healthAccess.reason)
1103
+ ? healthAccess.reason
1104
+ : 'access_disabled';
1105
+ }
1106
+
1107
+ const connectedExtensions =
1108
+ report.healthAvailable && Array.isArray(resolvedHealth?.connectedExtensions)
1109
+ ? resolvedHealth.connectedExtensions.slice(0, 100)
1110
+ : [];
1111
+ let profileCount = 0;
1112
+ for (const extension of connectedExtensions) {
1113
+ const extensionRecord = asRecord(extension);
1114
+ if (typeof extensionRecord?.profileLabel === 'string' && extensionRecord.profileLabel) {
1115
+ profileCount += 1;
1116
+ }
1117
+ }
1118
+ report.metrics = summarizeMetrics(metricsResult);
1119
+ report.connections.extensionCount = connectedExtensions.length
1120
+ ? connectedExtensions.length
1121
+ : report.metrics?.activeExtensions || (report.extensionConnected ? 1 : 0);
1122
+ report.connections.profileCount = profileCount;
1123
+ if (report.healthAvailable && report.connections.extensionCount > 0) {
1124
+ report.extensionConnected = true;
1125
+ }
1126
+
1127
+ const proxyHealth = report.healthAvailable ? asRecord(resolvedHealth?.proxy) : null;
1128
+ report.transport.proxyConfigured = proxyConfigured;
1129
+ report.transport.proxyExposed =
1130
+ typeof proxyHealth?.enabled === 'boolean' ? proxyHealth.enabled : null;
1131
+ report.protocol = summarizeProtocol(
1132
+ report.healthAvailable ? resolvedHealth : null,
1133
+ report.extensionConnected
1134
+ );
1135
+ report.recentEvents = summarizeRecentEvents(logsResult);
1136
+ report.recentCauses = [
1137
+ ...new Set(
1138
+ report.recentEvents.map((event) => event.cause).filter((cause) => cause !== undefined)
1139
+ ),
1140
+ ];
1141
+ report.debugger = summarizeDebugger(report.healthAvailable ? resolvedHealth : null);
1142
+ report.recovery = summarizeRecovery(
1143
+ report.healthAvailable ? resolvedHealth : null,
1144
+ report.metrics
1145
+ );
1146
+ if (report.debugger.recentReason && !report.recentCauses.includes(report.debugger.recentReason)) {
1147
+ report.recentCauses.push(report.debugger.recentReason);
1148
+ }
1149
+
1150
+ // Keep optional diagnostics deterministic even if several RPCs fail.
1151
+ report.diagnosticFailures = [...new Set(diagnosticFailures)].slice(0, 12);
1152
+
1153
+ const connectedBrowserExtensionIds = connectedExtensions
1154
+ .map((extension) => asRecord(extension)?.browserExtensionId)
1155
+ .filter(
1156
+ (extensionId) => typeof extensionId === 'string' && CHROME_EXTENSION_ID_RE.test(extensionId)
1157
+ );
1158
+ const extensionIdentityMismatch =
1159
+ connectedBrowserExtensionIds.length > 0 &&
1160
+ installedExtensionIds.length > 0 &&
1161
+ connectedBrowserExtensionIds.some(
1162
+ (extensionId) => !installedExtensionIds.includes(/** @type {string} */ (extensionId))
1163
+ );
1164
+
1165
+ if (authenticationFailed) {
1166
+ report.issues.push('proxy_credentials_stale');
1167
+ report.nextSteps.push(
1168
+ 'Local TCP authentication failed. Run `bbx proxy enable --rotate-token`, restart the daemon, and update configured remote clients with `bbx remote add`.'
1169
+ );
457
1170
  }
458
1171
 
459
1172
  if (unwritableBridgePaths.length > 0) {
@@ -482,11 +1195,23 @@ export async function getDoctorReport(options = {}) {
482
1195
  : 'Run `bbx install <extension-id>` (or `bbx install --all`) to install the native host manifest.'
483
1196
  );
484
1197
  }
485
- if (!report.daemonReachable) {
1198
+ if (!report.daemonReachable && !authenticationFailed) {
486
1199
  report.issues.push('daemon_offline');
487
1200
  report.nextSteps.push('Run `bbx-daemon` and retry `bbx status` or `bbx doctor`.');
488
1201
  }
489
- if (report.daemonReachable && !report.extensionConnected) {
1202
+ if (report.daemonReachable && !report.healthAvailable) {
1203
+ report.issues.push('health_unavailable');
1204
+ report.nextSteps.push(
1205
+ '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.'
1206
+ );
1207
+ }
1208
+ if (report.healthAvailable && extensionIdentityMismatch) {
1209
+ report.issues.push('extension_id_mismatch');
1210
+ report.nextSteps.push(
1211
+ '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.'
1212
+ );
1213
+ }
1214
+ if (report.healthAvailable && !report.extensionConnected) {
490
1215
  report.issues.push('extension_disconnected');
491
1216
  if (chromiumSandboxedManifestInstalled) {
492
1217
  report.issues.push('chromium_sandboxed_native_host_limited');
@@ -495,20 +1220,120 @@ export async function getDoctorReport(options = {}) {
495
1220
  );
496
1221
  }
497
1222
  report.nextSteps.push(
498
- 'Open Chrome and make sure the Browser Bridge extension is installed and active.'
1223
+ '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
1224
  );
500
1225
  }
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) {
1226
+ if (report.healthAvailable && report.extensionConnected && !report.accessEnabled) {
1227
+ if (report.routeReason === 'enabled_window_missing') {
1228
+ report.issues.push('enabled_window_missing');
1229
+ report.nextSteps.push(
1230
+ 'The previously enabled window no longer exists. Focus the intended Chrome window and click Enable in the Browser Bridge popup or side panel.'
1231
+ );
1232
+ } else {
1233
+ report.issues.push('access_disabled');
1234
+ report.nextSteps.push(
1235
+ '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.'
1236
+ );
1237
+ }
1238
+ } else if (report.healthAvailable && report.extensionConnected && !report.routeReady) {
507
1239
  report.issues.push(report.routeReason || 'no_routable_active_tab');
508
1240
  report.nextSteps.push(
509
1241
  'Switch to a supported page in the enabled window, or use an explicit tabId override.'
510
1242
  );
511
1243
  }
512
1244
 
513
- return report;
1245
+ if (report.protocol.compatible === false) {
1246
+ report.issues.push('protocol_mismatch');
1247
+ if (report.protocol.migration === 'update_extension') {
1248
+ report.nextSteps.push(
1249
+ 'Update or reload the Browser Bridge extension so it supports the client protocol, then retry `bbx doctor`.'
1250
+ );
1251
+ } else if (report.protocol.migration === 'restart_daemon') {
1252
+ report.nextSteps.push(
1253
+ 'Run `bbx restart` to replace the stale daemon with this installed CLI version, then retry `bbx doctor`.'
1254
+ );
1255
+ } else {
1256
+ report.nextSteps.push(
1257
+ 'Update the Browser Bridge CLI/npm package, run `bbx restart`, and retry `bbx doctor`.'
1258
+ );
1259
+ }
1260
+ }
1261
+
1262
+ if (report.debugger.state === 'conflict') {
1263
+ report.issues.push('debugger_conflict');
1264
+ }
1265
+ if (report.debugger.state === 'conflict' || report.recentCauses.includes('debugger_conflict')) {
1266
+ report.nextSteps.push(
1267
+ 'Close DevTools or any other debugger attached to the target tab, then retry the debugger-backed Browser Bridge operation.'
1268
+ );
1269
+ } else if (
1270
+ report.debugger.state === 'detached' ||
1271
+ report.recentCauses.includes('debugger_detached')
1272
+ ) {
1273
+ report.nextSteps.push(
1274
+ 'A recent debugger session detached. Keep the target tab open and retry the debugger-backed operation; close competing debuggers if detaches continue.'
1275
+ );
1276
+ }
1277
+
1278
+ if (report.recentCauses.includes('wrong_window')) {
1279
+ report.nextSteps.push(
1280
+ 'A recent request targeted the wrong window. Focus the intended Chrome window, click Enable there, and retry without reusing a tabId from another window.'
1281
+ );
1282
+ }
1283
+
1284
+ for (const loop of report.recovery.activeLoops) {
1285
+ const issue = `${loop}_loop`;
1286
+ if (!report.issues.includes(issue)) report.issues.push(issue);
1287
+ if (loop === 'stale_ref_recovery') {
1288
+ report.nextSteps.push(
1289
+ 'Stale-reference recovery is repeatedly failing. Re-query the target for a fresh elementRef and retry once without reusing old refs.'
1290
+ );
1291
+ } else if (
1292
+ loop === 'debugger_reattach' &&
1293
+ report.debugger.state !== 'conflict' &&
1294
+ !report.recentCauses.includes('debugger_conflict')
1295
+ ) {
1296
+ report.nextSteps.push(
1297
+ 'Debugger reattachment is repeatedly failing. Keep the tab open, close competing debuggers, and retry one debugger-backed operation.'
1298
+ );
1299
+ } else if (loop === 'content_script_reinjection') {
1300
+ report.nextSteps.push(
1301
+ 'Content-script reinjection is repeatedly failing. Reload the Browser Bridge extension and target page, then enable the window again.'
1302
+ );
1303
+ } else if (loop === 'native_host_reconnect' && !authenticationFailed) {
1304
+ report.nextSteps.push(
1305
+ 'Native-host reconnects are repeatedly failing. Run `bbx restart`; for remote TCP proxy use, verify the endpoint and rotate stale credentials with `bbx proxy enable --rotate-token`.'
1306
+ );
1307
+ } else if (loop === 'automatic_mcp_retry') {
1308
+ report.nextSteps.push(
1309
+ 'Automatic MCP retries are repeatedly failing. Stop the retry loop, resolve the reported bridge readiness issue, then retry the original read once.'
1310
+ );
1311
+ } else if (loop === 'request_outcome') {
1312
+ report.nextSteps.push(
1313
+ 'Browser requests are repeatedly failing. Inspect the first typed bridge error, narrow the request or target as directed, and retry once after correcting that specific failure.'
1314
+ );
1315
+ }
1316
+ }
1317
+
1318
+ return {
1319
+ ...report,
1320
+ manifestPath: sanitizeDoctorPath(report.manifestPath),
1321
+ daemonLogPath: sanitizeDoctorPath(report.daemonLogPath),
1322
+ unwritableBridgePaths: report.unwritableBridgePaths.map(sanitizeDoctorPath),
1323
+ nativeHostManifestIssues: report.nativeHostManifestIssues.map((issue) => ({
1324
+ ...issue,
1325
+ manifestPath: sanitizeDoctorPath(issue.manifestPath),
1326
+ message: sanitizeIncidentalText(issue.message),
1327
+ })),
1328
+ browserManifests: report.browserManifests.map((browser) => ({
1329
+ ...browser,
1330
+ manifestPath: sanitizeDoctorPath(browser.manifestPath),
1331
+ })),
1332
+ nextSteps: report.nextSteps.map((step) => sanitizeIncidentalText(step)),
1333
+ };
1334
+ }
1335
+
1336
+ /** @param {string} value */
1337
+ function sanitizeDoctorPath(value) {
1338
+ return value ? sanitizeIncidentalPath(value) : '';
514
1339
  }