@browserbridge/bbx 1.0.1 → 1.2.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 (70) hide show
  1. package/README.md +4 -4
  2. package/package.json +11 -13
  3. package/packages/agent-client/src/cli-helpers.js +33 -0
  4. package/packages/agent-client/src/cli.js +122 -45
  5. package/packages/agent-client/src/client.js +134 -8
  6. package/packages/agent-client/src/command-registry.js +4 -1
  7. package/packages/agent-client/src/detect.js +159 -48
  8. package/packages/agent-client/src/install.js +24 -1
  9. package/packages/agent-client/src/mcp-config.js +29 -10
  10. package/packages/agent-client/src/setup-status.js +12 -4
  11. package/packages/mcp-server/src/bin.js +57 -5
  12. package/packages/mcp-server/src/handlers-capture.js +279 -0
  13. package/packages/mcp-server/src/handlers-dom.js +196 -0
  14. package/packages/mcp-server/src/handlers-navigation.js +79 -0
  15. package/packages/mcp-server/src/handlers-page.js +365 -0
  16. package/packages/mcp-server/src/handlers-utils.js +296 -0
  17. package/packages/mcp-server/src/handlers.js +63 -1159
  18. package/packages/mcp-server/src/server.js +13 -3
  19. package/packages/native-host/bin/bridge-daemon.js +34 -4
  20. package/packages/native-host/bin/install-manifest.js +32 -2
  21. package/packages/native-host/bin/postinstall.js +16 -0
  22. package/packages/native-host/src/config.js +131 -6
  23. package/packages/native-host/src/daemon-logger.js +157 -0
  24. package/packages/native-host/src/daemon-process.js +422 -0
  25. package/packages/native-host/src/daemon.js +322 -77
  26. package/packages/native-host/src/framing.js +131 -11
  27. package/packages/native-host/src/install-manifest.js +121 -7
  28. package/packages/native-host/src/native-host.js +110 -73
  29. package/packages/protocol/src/capabilities.js +4 -0
  30. package/packages/protocol/src/defaults.js +1 -0
  31. package/packages/protocol/src/errors.js +4 -0
  32. package/packages/protocol/src/payload-cost.js +19 -6
  33. package/packages/protocol/src/protocol.js +143 -7
  34. package/packages/protocol/src/registry.js +13 -0
  35. package/packages/protocol/src/summary.js +18 -10
  36. package/packages/protocol/src/types.js +28 -3
  37. package/skills/browser-bridge/SKILL.md +2 -1
  38. package/skills/browser-bridge/references/interaction.md +1 -0
  39. package/skills/browser-bridge/references/protocol.md +2 -1
  40. package/CHANGELOG.md +0 -55
  41. package/assets/banner.jpg +0 -0
  42. package/assets/logo.png +0 -0
  43. package/assets/logo.svg +0 -65
  44. package/docs/api-reference.md +0 -157
  45. package/docs/cli-guide.md +0 -128
  46. package/docs/index.md +0 -25
  47. package/docs/manual-setup.md +0 -140
  48. package/docs/mcp-vs-cli.md +0 -258
  49. package/docs/publishing.md +0 -112
  50. package/docs/quickstart.md +0 -104
  51. package/docs/troubleshooting.md +0 -59
  52. package/docs/unpacked-extension.md +0 -72
  53. package/docs/usage-scenarios.md +0 -136
  54. package/manifest.json +0 -38
  55. package/packages/extension/assets/icon-128.png +0 -0
  56. package/packages/extension/assets/icon-16.png +0 -0
  57. package/packages/extension/assets/icon-32.png +0 -0
  58. package/packages/extension/assets/icon-48.png +0 -0
  59. package/packages/extension/src/background-helpers.js +0 -474
  60. package/packages/extension/src/background-routing.js +0 -89
  61. package/packages/extension/src/background.js +0 -3490
  62. package/packages/extension/src/content-script-helpers.js +0 -282
  63. package/packages/extension/src/content-script.js +0 -2043
  64. package/packages/extension/src/debugger-coordinator.js +0 -188
  65. package/packages/extension/src/sidepanel-helpers.js +0 -104
  66. package/packages/extension/ui/popup.html +0 -35
  67. package/packages/extension/ui/popup.js +0 -298
  68. package/packages/extension/ui/sidepanel.html +0 -102
  69. package/packages/extension/ui/sidepanel.js +0 -1771
  70. package/packages/extension/ui/ui.css +0 -1160
@@ -1,3490 +0,0 @@
1
- // @ts-check
2
-
3
- import {
4
- bridgeMethodNeedsTab,
5
- ERROR_CODES,
6
- createFailure,
7
- createRequest,
8
- createRuntimeContext,
9
- createSuccess,
10
- estimateJsonPayloadCost,
11
- normalizeAccessibilityTreeParams,
12
- normalizeCheckedAction,
13
- normalizeConsoleParams,
14
- normalizeDomQuery,
15
- normalizeDragParams,
16
- normalizeEvaluateParams,
17
- normalizeFindByRoleParams,
18
- normalizeFindByTextParams,
19
- normalizeGetHtmlParams,
20
- normalizeHoverParams,
21
- normalizeInputAction,
22
- normalizeNavigationAction,
23
- normalizeNetworkParams,
24
- normalizePageTextParams,
25
- normalizePatchOperation,
26
- normalizeSelectAction,
27
- normalizeStorageParams,
28
- normalizeStyleQuery,
29
- normalizeTabCloseParams,
30
- normalizeTabCreateParams,
31
- normalizeViewportAction,
32
- normalizeViewportResizeParams,
33
- normalizeWaitForLoadStateParams,
34
- normalizeWaitForParams,
35
- SUPPORTED_VERSIONS,
36
- } from '../../protocol/src/index.js';
37
- import { summarizeBridgeResponse } from '../../protocol/src/index.js';
38
- import {
39
- enforceTokenBudget,
40
- getResponseDiagnostics,
41
- getErrorMessage,
42
- matchesConsoleLevel,
43
- normalizeRuntimeErrorMessage,
44
- safeOrigin,
45
- shouldLogAction,
46
- simplifyAXNode,
47
- summarizeActionResult,
48
- summarizeTabResult,
49
- } from './background-helpers.js';
50
- import {
51
- isRestrictedAutomationUrl,
52
- normalizeRequestedAccessTab,
53
- resolveWindowScopedTab,
54
- selectRequestTabCandidate,
55
- } from './background-routing.js';
56
- import { TabDebuggerCoordinator } from './debugger-coordinator.js';
57
-
58
- /** @typedef {import('../../protocol/src/types.js').BridgeRequest} BridgeRequest */
59
- /** @typedef {import('../../protocol/src/types.js').BridgeResponse} BridgeResponse */
60
- /** @typedef {import('../../protocol/src/types.js').ErrorCode} ErrorCode */
61
- /** @typedef {import('../../protocol/src/types.js').SetupStatus} SetupStatus */
62
-
63
- /**
64
- * @typedef {{
65
- * windowId: number,
66
- * title: string,
67
- * enabledAt: number
68
- * }} EnabledWindowState
69
- */
70
-
71
- /**
72
- * @typedef {{
73
- * tabId: number,
74
- * windowId: number,
75
- * title: string,
76
- * url: string
77
- * }} ResolvedTabTarget
78
- */
79
-
80
- /**
81
- * @typedef {{
82
- * id: string,
83
- * at: number,
84
- * method: string,
85
- * source: string,
86
- * tabId: number | null,
87
- * url: string,
88
- * ok: boolean,
89
- * summary: string,
90
- * responseBytes: number,
91
- * approxTokens: number,
92
- * imageApproxTokens: number,
93
- * costClass: 'cheap' | 'moderate' | 'heavy' | 'extreme',
94
- * imageBytes: number,
95
- * summaryBytes: number,
96
- * summaryTokens: number,
97
- * summaryCostClass: 'cheap' | 'moderate' | 'heavy' | 'extreme',
98
- * debuggerBacked: boolean,
99
- * overBudget: boolean,
100
- * hasScreenshot: boolean,
101
- * nodeCount: number | null,
102
- * continuationHint: string | null
103
- * }} ActionLogEntry
104
- */
105
-
106
- /**
107
- * @typedef {{
108
- * tabId: number,
109
- * windowId: number,
110
- * title: string,
111
- * url: string,
112
- * enabled: boolean,
113
- * accessRequested: boolean,
114
- * restricted: boolean
115
- * }} CurrentTabState
116
- */
117
-
118
- /**
119
- * @typedef {{
120
- * status?: string,
121
- * title?: string,
122
- * url?: string
123
- * }} TabChangeInfo
124
- */
125
-
126
- /**
127
- * @param {unknown} value
128
- * @returns {value is number}
129
- */
130
- function isNumber(value) {
131
- return typeof value === 'number' && Number.isFinite(value);
132
- }
133
-
134
- /**
135
- * @typedef {{
136
- * scopeTabId: number | null,
137
- * surface: 'popup' | 'sidepanel'
138
- * }} UiPortState
139
- */
140
-
141
- /**
142
- * @typedef {{
143
- * action?: 'install' | 'uninstall',
144
- * kind: 'mcp' | 'skill',
145
- * target: string
146
- * }} SetupInstallAction
147
- */
148
-
149
- /**
150
- * @typedef {{
151
- * nativePort: chrome.runtime.Port | null,
152
- * enabledWindow: EnabledWindowState | null,
153
- * requestedAccessWindowId: number | null,
154
- * requestedAccessPopupWindowId: number | null,
155
- * nativeReconnectAttempts: number,
156
- * actionLog: ActionLogEntry[],
157
- * uiPorts: Map<chrome.runtime.Port, UiPortState>,
158
- * setupStatus: SetupStatus | null,
159
- * setupStatusPending: boolean,
160
- * setupStatusPendingRequestId: string | null,
161
- * setupStatusUpdatedAt: number,
162
- * setupStatusError: string | null,
163
- * setupStatusTimeoutId: ReturnType<typeof setTimeout> | null,
164
- * setupInstallPendingRequestId: string | null,
165
- * setupInstallPendingAction: SetupInstallAction | null,
166
- * setupInstallPendingKey: string | null,
167
- * setupInstallError: string | null
168
- * }} ExtensionState
169
- */
170
-
171
- /**
172
- * @returns {string}
173
- */
174
- function detectBrowserName() {
175
- const ua = navigator.userAgent;
176
- if (ua.includes('Edg/')) return 'edge';
177
- if (ua.includes('OPR/') || ua.includes('Opera')) return 'opera';
178
- if (ua.includes('Brave')) return 'brave';
179
- if (ua.includes('Arc/')) return 'arc';
180
- if (ua.includes('Vivaldi/')) return 'vivaldi';
181
- return 'chrome';
182
- }
183
-
184
- /**
185
- * @returns {Promise<string>}
186
- */
187
- async function getProfileLabel() {
188
- const STORAGE_KEY = 'bb_profile_label';
189
- try {
190
- const result = await chrome.storage.session.get(STORAGE_KEY);
191
- if (result[STORAGE_KEY]) {
192
- return /** @type {string} */ (result[STORAGE_KEY]);
193
- }
194
- const label = `profile_${Math.random().toString(36).slice(2, 8)}`;
195
- await chrome.storage.session.set({ [STORAGE_KEY]: label });
196
- return label;
197
- } catch {
198
- return `profile_${Date.now().toString(36)}`;
199
- }
200
- }
201
-
202
- /**
203
- * Send browser/profile identity to the daemon via the native host.
204
- *
205
- * @param {chrome.runtime.Port} port
206
- * @returns {void}
207
- */
208
- function sendIdentity(port) {
209
- const browserName = detectBrowserName();
210
- void getProfileLabel().then((profileLabel) => {
211
- try {
212
- port.postMessage({ type: 'host.identity', browserName, profileLabel });
213
- } catch {
214
- /* port may have disconnected */
215
- }
216
- });
217
- }
218
-
219
- /**
220
- * Notify the daemon that this browser/profile was recently active so untargeted
221
- * access prompts can be routed to one browser instead of broadcasting.
222
- *
223
- * @param {chrome.runtime.Port | null} [port=state.nativePort]
224
- * @returns {void}
225
- */
226
- function sendActivityUpdate(port = state.nativePort) {
227
- if (!port) return;
228
- try {
229
- port.postMessage({ type: 'host.activity', at: Date.now() });
230
- } catch {
231
- /* port may have disconnected */
232
- }
233
- }
234
-
235
- /**
236
- * Notify the daemon whether this extension currently has access enabled.
237
- *
238
- * @param {boolean} enabled
239
- * @returns {void}
240
- */
241
- function sendAccessUpdate(enabled) {
242
- if (!state.nativePort) return;
243
- try {
244
- state.nativePort.postMessage({
245
- type: 'host.access_update',
246
- accessEnabled: enabled,
247
- });
248
- } catch {
249
- /* port may have disconnected */
250
- }
251
- }
252
-
253
- const NATIVE_APP_NAME = 'com.browserbridge.browser_bridge';
254
- const CONTENT_SCRIPT_TIMEOUT_MS = 5_000;
255
- const MAX_ACTION_LOG_ENTRIES = 50;
256
- const ENABLED_WINDOW_STORAGE_KEY = 'enabledWindow';
257
- const ACTION_LOG_STORAGE_KEY = 'actionLog';
258
- const SIDEPANEL_PATH = 'packages/extension/ui/sidepanel.html';
259
- const POPUP_PATH = 'packages/extension/ui/popup.html';
260
- const ENABLED_BADGE_TEXT = 'AI';
261
- const ACCESS_REQUEST_BADGE_TEXT = '!';
262
- const RESTRICTED_BADGE_TEXT = '!';
263
- const DEBUGGER_PROTOCOL_VERSION = '1.3';
264
- const SETUP_STATUS_STALE_MS = 30_000;
265
- const SETUP_STATUS_TIMEOUT_MS = 5_000;
266
- const ACCESS_DENIED_WINDOW_OFF = 'Browser Bridge is off for this window.';
267
- const ACCESS_DENIED_TAB_CLOSE = 'tabs.close only works inside the enabled window.';
268
- const KEEPALIVE_ALARM_NAME = 'bb-keepalive';
269
- const NATIVE_RECONNECT_BASE_MS = 2_000;
270
- const NATIVE_RECONNECT_MAX_MS = 30_000;
271
-
272
- /**
273
- * @param {string} left
274
- * @param {string} right
275
- * @returns {number}
276
- */
277
- function compareProtocolVersions(left, right) {
278
- const leftParts = left.split('.').map((part) => Number(part) || 0);
279
- const rightParts = right.split('.').map((part) => Number(part) || 0);
280
- const length = Math.max(leftParts.length, rightParts.length);
281
- for (let index = 0; index < length; index += 1) {
282
- const delta = (leftParts[index] || 0) - (rightParts[index] || 0);
283
- if (delta !== 0) {
284
- return delta > 0 ? 1 : -1;
285
- }
286
- }
287
- return 0;
288
- }
289
-
290
- /**
291
- * @param {string | undefined} requestedVersion
292
- * @returns {{ supported_versions: readonly string[], deprecated_since?: string, migration_hint?: string }}
293
- */
294
- function getVersionNegotiationPayload(requestedVersion) {
295
- const latestSupported = SUPPORTED_VERSIONS[0];
296
- if (!requestedVersion || !latestSupported || SUPPORTED_VERSIONS.includes(requestedVersion)) {
297
- return { supported_versions: SUPPORTED_VERSIONS };
298
- }
299
-
300
- const localIsNewer = compareProtocolVersions(latestSupported, requestedVersion) > 0;
301
- return {
302
- supported_versions: SUPPORTED_VERSIONS,
303
- ...(localIsNewer ? { deprecated_since: latestSupported } : {}),
304
- migration_hint: localIsNewer
305
- ? `Browser Bridge extension is newer than the client protocol ${requestedVersion}. Update the Browser Bridge CLI/npm package to ${latestSupported} or later.`
306
- : `Browser Bridge extension is older than the client protocol ${requestedVersion}. Update the extension to a build that supports ${requestedVersion}.`,
307
- };
308
- }
309
-
310
- /** @type {ReturnType<typeof setTimeout> | null} */
311
- let _nativeReconnectTimer = null;
312
- let nativeReconnectDelay = NATIVE_RECONNECT_BASE_MS;
313
-
314
- /** @type {ExtensionState} */
315
- const state = {
316
- nativePort: null,
317
- enabledWindow: null,
318
- requestedAccessWindowId: null,
319
- requestedAccessPopupWindowId: null,
320
- nativeReconnectAttempts: 0,
321
- actionLog: [],
322
- uiPorts: new Map(),
323
- setupStatus: null,
324
- setupStatusPending: false,
325
- setupStatusPendingRequestId: null,
326
- setupStatusUpdatedAt: 0,
327
- setupStatusError: null,
328
- setupStatusTimeoutId: null,
329
- setupInstallPendingRequestId: null,
330
- setupInstallPendingAction: null,
331
- setupInstallPendingKey: null,
332
- setupInstallError: null,
333
- };
334
-
335
- const tabDebugger = new TabDebuggerCoordinator({
336
- attach: (target, protocolVersion) => chrome.debugger.attach(target, protocolVersion),
337
- detach: (target) => chrome.debugger.detach(target),
338
- protocolVersion: DEBUGGER_PROTOCOL_VERSION,
339
- });
340
-
341
- void initializeState().catch(reportAsyncError);
342
- connectNative();
343
-
344
- chrome.runtime.onInstalled.addListener(async () => {
345
- await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
346
- });
347
-
348
- chrome.tabs.onActivated.addListener(({ tabId }) => {
349
- sendActivityUpdate();
350
- void updateActionIndicatorForTab(tabId).catch(reportAsyncError);
351
- void syncGlobalBadgeToActiveTab().catch(reportAsyncError);
352
- void emitUiState().catch(reportAsyncError);
353
- });
354
-
355
- chrome.windows.onFocusChanged.addListener((windowId) => {
356
- if (typeof windowId === 'number' && windowId >= 0) {
357
- sendActivityUpdate();
358
- }
359
- });
360
-
361
- chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
362
- void handleTabUpdated(tabId, changeInfo, tab).catch(reportAsyncError);
363
- });
364
-
365
- chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
366
- void handleTabRemoved(tabId, removeInfo).catch(reportAsyncError);
367
- });
368
-
369
- chrome.windows.onRemoved.addListener((windowId) => {
370
- clearRequestedAccessPopupWindow(windowId);
371
- });
372
-
373
- chrome.alarms.onAlarm.addListener((alarm) => {
374
- if (alarm.name === KEEPALIVE_ALARM_NAME) {
375
- // No-op: the alarm firing is enough to wake the service worker.
376
- // Verify we still need to stay alive.
377
- if (!state.enabledWindow) {
378
- void chrome.alarms.clear(KEEPALIVE_ALARM_NAME);
379
- }
380
- }
381
- });
382
-
383
- chrome.runtime.onConnect.addListener((port) => {
384
- const surface = getUiSurfaceFromPortName(port.name);
385
- if (surface) {
386
- state.uiPorts.set(port, { scopeTabId: null, surface });
387
- port.onMessage.addListener((message) => {
388
- void handleUiMessage(port, message).catch(reportAsyncError);
389
- });
390
- port.onDisconnect.addListener(() => {
391
- state.uiPorts.delete(port);
392
- });
393
- void emitUiStateForPort(port);
394
- }
395
- });
396
-
397
- chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
398
- if (
399
- message?.type === 'bridge.open-sidepanel' &&
400
- typeof message.tabId === 'number' &&
401
- typeof message.windowId === 'number'
402
- ) {
403
- void openSidePanelForTab(message.tabId, message.windowId)
404
- .then(() => {
405
- sendResponse({ ok: true });
406
- })
407
- .catch((error) => {
408
- sendResponse({
409
- ok: false,
410
- error: error instanceof Error ? error.message : String(error),
411
- });
412
- });
413
- return true;
414
- }
415
-
416
- if (message?.type === 'bridge.open-sidepanel' && sender.tab?.id && sender.tab.windowId) {
417
- void openSidePanelForTab(sender.tab.id, sender.tab.windowId)
418
- .then(() => {
419
- sendResponse({ ok: true });
420
- })
421
- .catch((error) => {
422
- sendResponse({
423
- ok: false,
424
- error: error instanceof Error ? error.message : String(error),
425
- });
426
- });
427
- return true;
428
- }
429
-
430
- return false;
431
- });
432
-
433
- /**
434
- * Restore persisted window access state when the service worker starts so the
435
- * current browser-run grant survives worker restarts.
436
- *
437
- * @returns {Promise<void>}
438
- */
439
- async function initializeState() {
440
- await restoreEnabledWindow();
441
- await restoreActionLog();
442
- await primeEnabledWindowInstrumentation();
443
- await refreshActionIndicators();
444
- }
445
-
446
- /**
447
- * @returns {void}
448
- */
449
- function clearNativeReconnectTimer() {
450
- if (!_nativeReconnectTimer) {
451
- return;
452
- }
453
- clearTimeout(_nativeReconnectTimer);
454
- _nativeReconnectTimer = null;
455
- }
456
-
457
- /**
458
- * Schedule the next native-host reconnect attempt using the shared backoff
459
- * path used after runtime disconnects.
460
- *
461
- * @param {string} errorMessage
462
- * @param {{
463
- * method?: string,
464
- * summaryPrefix?: string,
465
- * updateDisconnectedUi?: boolean
466
- * }} [options]
467
- * @returns {void}
468
- */
469
- function scheduleNativeReconnect(errorMessage, options = {}) {
470
- const method = typeof options.method === 'string' ? options.method : 'native.disconnect';
471
- const summaryPrefix =
472
- typeof options.summaryPrefix === 'string' ? options.summaryPrefix : 'Native host disconnected';
473
- const updateDisconnectedUi = options.updateDisconnectedUi === true;
474
-
475
- state.nativeReconnectAttempts += 1;
476
- const reconnectAttempt = state.nativeReconnectAttempts;
477
- clearSetupStatus(errorMessage);
478
-
479
- if (updateDisconnectedUi) {
480
- state.nativePort = null;
481
- broadcastUi({
482
- type: 'native.status',
483
- connected: false,
484
- error: errorMessage,
485
- });
486
- }
487
- void emitUiState().catch(reportAsyncError);
488
-
489
- void appendActionLogEntry({
490
- method,
491
- source: 'extension',
492
- ok: false,
493
- summary: `${summaryPrefix} (attempt ${reconnectAttempt}): ${errorMessage}. Reconnecting in ${nativeReconnectDelay}ms.`,
494
- });
495
-
496
- clearNativeReconnectTimer();
497
- _nativeReconnectTimer = setTimeout(() => {
498
- _nativeReconnectTimer = null;
499
- connectNative();
500
- }, nativeReconnectDelay);
501
- nativeReconnectDelay = Math.min(nativeReconnectDelay * 2, NATIVE_RECONNECT_MAX_MS);
502
- }
503
-
504
- /**
505
- * Connect the extension service worker to the local Native Messaging host and
506
- * fan connection state out to the popup and side panel UIs.
507
- *
508
- * @returns {void}
509
- */
510
- function connectNative() {
511
- clearNativeReconnectTimer();
512
- try {
513
- const candidatePort = chrome.runtime.connectNative(NATIVE_APP_NAME);
514
- const wasReconnect = nativeReconnectDelay > NATIVE_RECONNECT_BASE_MS;
515
- const reconnectAttempts = state.nativeReconnectAttempts;
516
- const stabilityTimer = setTimeout(() => {
517
- state.nativePort = candidatePort;
518
- nativeReconnectDelay = NATIVE_RECONNECT_BASE_MS;
519
- state.nativeReconnectAttempts = 0;
520
- broadcastUi({ type: 'native.status', connected: true });
521
- refreshSetupStatus(true);
522
- void refreshActionIndicators();
523
- void emitUiState();
524
- sendIdentity(candidatePort);
525
- sendActivityUpdate(candidatePort);
526
- if (wasReconnect && reconnectAttempts > 0) {
527
- void appendActionLogEntry({
528
- method: 'native.reconnect',
529
- source: 'extension',
530
- ok: true,
531
- summary: `Native host reconnected after ${reconnectAttempts} attempt${reconnectAttempts === 1 ? '' : 's'}.`,
532
- });
533
- }
534
- }, 500);
535
- candidatePort.onMessage.addListener((request) => {
536
- if (handleHostStatusMessage(request)) {
537
- return;
538
- }
539
- void handleBridgeRequest(request).catch(reportAsyncError);
540
- });
541
- candidatePort.onDisconnect.addListener(() => {
542
- clearTimeout(stabilityTimer);
543
- const disconnectError = chrome.runtime.lastError?.message ?? 'Native host disconnected.';
544
- scheduleNativeReconnect(disconnectError, {
545
- method: 'native.disconnect',
546
- summaryPrefix: 'Native host disconnected',
547
- updateDisconnectedUi: state.nativePort === candidatePort,
548
- });
549
- });
550
- } catch (error) {
551
- scheduleNativeReconnect(getErrorMessage(error), {
552
- method: 'native.connect',
553
- summaryPrefix: 'Native host connection failed',
554
- updateDisconnectedUi: !state.nativePort,
555
- });
556
- }
557
- }
558
-
559
- /**
560
- * Route a validated bridge request to the extension capability that should
561
- * satisfy it.
562
- *
563
- * @param {BridgeRequest} request
564
- * @returns {Promise<void>}
565
- */
566
- async function handleBridgeRequest(request) {
567
- const actionContext = shouldLogAction(request.method) ? await getActionContext(request) : null;
568
- /** @type {BridgeResponse} */
569
- let response;
570
-
571
- try {
572
- response = await dispatchBridgeRequest(request);
573
- } catch (error) {
574
- response = toFailureResponse(request, error);
575
- }
576
-
577
- if (
578
- !response.ok &&
579
- response.error.code === ERROR_CODES.ACCESS_DENIED &&
580
- response.error.message === ACCESS_DENIED_WINDOW_OFF
581
- ) {
582
- await requestEnableFromAgentSide(request);
583
- }
584
-
585
- response = enrichBridgeResponse(request, response);
586
-
587
- await logBridgeAction(request, response, actionContext);
588
- reply(response);
589
- }
590
-
591
- /**
592
- * Resolve one bridge request into a structured response.
593
- *
594
- * @param {BridgeRequest} request
595
- * @returns {Promise<BridgeResponse>}
596
- */
597
- async function dispatchBridgeRequest(request) {
598
- switch (request.method) {
599
- case 'health.ping':
600
- return createSuccess(
601
- request.id,
602
- {
603
- extension: 'ok',
604
- access: await getAccessStatus(),
605
- ...getVersionNegotiationPayload(request.meta?.protocol_version),
606
- },
607
- { method: request.method }
608
- );
609
- case 'access.request':
610
- return handleAccessRequest(request);
611
- case 'skill.get_runtime_context':
612
- return createSuccess(request.id, createRuntimeContext(), {
613
- method: request.method,
614
- });
615
- case 'tabs.list':
616
- return handleListTabs(request);
617
- case 'tabs.create':
618
- return handleCreateTab(request);
619
- case 'tabs.close':
620
- return handleCloseTab(request);
621
- case 'page.evaluate':
622
- return handlePageEvaluate(request);
623
- case 'page.get_console':
624
- return handlePageGetConsole(request);
625
- case 'page.wait_for_load_state':
626
- return handleWaitForLoadState(request);
627
- case 'dom.get_accessibility_tree':
628
- return handleAccessibilityTree(request);
629
- case 'page.get_network':
630
- return handleGetNetwork(request);
631
- case 'viewport.resize':
632
- return handleViewportResize(request);
633
- case 'performance.get_metrics':
634
- return handlePerformanceMetrics(request);
635
- case 'navigation.navigate':
636
- case 'navigation.reload':
637
- case 'navigation.go_back':
638
- case 'navigation.go_forward':
639
- return handleNavigationRequest(request);
640
- case 'page.get_state':
641
- case 'page.get_storage':
642
- case 'page.get_text':
643
- case 'dom.query':
644
- case 'dom.describe':
645
- case 'dom.get_text':
646
- case 'dom.get_attributes':
647
- case 'dom.wait_for':
648
- case 'dom.find_by_text':
649
- case 'dom.find_by_role':
650
- case 'dom.get_html':
651
- case 'layout.get_box_model':
652
- case 'layout.hit_test':
653
- case 'styles.get_computed':
654
- case 'styles.get_matched_rules':
655
- case 'viewport.scroll':
656
- case 'input.click':
657
- case 'input.focus':
658
- case 'input.type':
659
- case 'input.press_key':
660
- case 'input.set_checked':
661
- case 'input.select_option':
662
- case 'input.hover':
663
- case 'input.drag':
664
- case 'input.scroll_into_view':
665
- case 'patch.apply_styles':
666
- case 'patch.apply_dom':
667
- case 'patch.list':
668
- case 'patch.rollback':
669
- case 'patch.commit_session_baseline':
670
- case 'screenshot.capture_region':
671
- case 'screenshot.capture_element':
672
- case 'screenshot.capture_full_page':
673
- return handleTabBoundRequest(request);
674
- case 'cdp.get_document':
675
- case 'cdp.get_dom_snapshot':
676
- case 'cdp.get_box_model':
677
- case 'cdp.get_computed_styles_for_node':
678
- return handleCdpRequest(request);
679
- default:
680
- return createFailure(
681
- request.id,
682
- ERROR_CODES.INVALID_REQUEST,
683
- `Unhandled method ${request.method}`
684
- );
685
- }
686
- }
687
-
688
- /**
689
- * Restore the enabled window for the current browser run.
690
- *
691
- * @returns {Promise<void>}
692
- */
693
- async function restoreEnabledWindow() {
694
- const stored = await chrome.storage.session.get(ENABLED_WINDOW_STORAGE_KEY);
695
- const enabledWindow = stored[ENABLED_WINDOW_STORAGE_KEY];
696
- if (!enabledWindow || typeof enabledWindow !== 'object') {
697
- state.enabledWindow = null;
698
- return;
699
- }
700
-
701
- const candidate = /** @type {Record<string, unknown>} */ (enabledWindow);
702
- const windowId = Number(candidate.windowId);
703
- if (!Number.isFinite(windowId) || windowId <= 0) {
704
- state.enabledWindow = null;
705
- return;
706
- }
707
-
708
- state.enabledWindow = {
709
- windowId,
710
- title: typeof candidate.title === 'string' ? candidate.title : '',
711
- enabledAt: Number(candidate.enabledAt) || Date.now(),
712
- };
713
- sendAccessUpdate(true);
714
- }
715
-
716
- /**
717
- * Best-effort reinjection of passive instrumentation for tabs in the enabled
718
- * window so reads like `page.get_console` can see activity that happened
719
- * before the first explicit read.
720
- *
721
- * @returns {Promise<void>}
722
- */
723
- async function primeEnabledWindowInstrumentation() {
724
- if (!state.enabledWindow) {
725
- return;
726
- }
727
- await injectContentScriptsForWindow(state.enabledWindow.windowId);
728
- await primeWindowConsoleCapture(state.enabledWindow.windowId);
729
- }
730
-
731
- /**
732
- * Restore the recent action log that powers the side panel activity view.
733
- *
734
- * @returns {Promise<void>}
735
- */
736
- async function restoreActionLog() {
737
- const stored = await chrome.storage.session.get(ACTION_LOG_STORAGE_KEY);
738
- const entries = stored[ACTION_LOG_STORAGE_KEY];
739
- if (Array.isArray(entries)) {
740
- state.actionLog = entries
741
- .map((entry) => normalizeActionLogEntry(entry))
742
- .filter((entry) => entry !== null);
743
- }
744
- }
745
-
746
- /**
747
- * Clear the enabled window state if the window no longer exists.
748
- * Retries once after a short delay to ride over transient API errors.
749
- *
750
- * @returns {Promise<boolean>} true if the window was verified gone and cleared
751
- */
752
- async function clearEnabledWindowIfGone() {
753
- if (!state.enabledWindow) {
754
- return false;
755
- }
756
- let gone = false;
757
- try {
758
- await chrome.windows.get(state.enabledWindow.windowId);
759
- } catch (e) {
760
- const msg = getErrorMessage(e).toLowerCase();
761
- if (msg.includes('no window') || msg.includes('not found') || msg.includes('window closed')) {
762
- gone = true;
763
- } else {
764
- await new Promise((r) => {
765
- setTimeout(r, 300);
766
- });
767
- try {
768
- await chrome.windows.get(state.enabledWindow.windowId);
769
- } catch (_e2) {
770
- gone = true;
771
- }
772
- }
773
- }
774
- if (gone) {
775
- state.enabledWindow = null;
776
- await chrome.storage.session.remove(ENABLED_WINDOW_STORAGE_KEY);
777
- sendAccessUpdate(false);
778
- return true;
779
- }
780
- return false;
781
- }
782
-
783
- /**
784
- * Build a compact access-status payload for health and doctor flows.
785
- *
786
- * @returns {Promise<{
787
- * enabled: boolean,
788
- * windowId: number | null,
789
- * routeTabId: number | null,
790
- * routeReady: boolean,
791
- * routeUrl: string,
792
- * reason: 'enabled' | 'access_disabled' | 'enabled_window_missing' | 'no_routable_active_tab' | 'restricted_page'
793
- * }>}
794
- */
795
- async function getAccessStatus() {
796
- if (!state.enabledWindow) {
797
- return {
798
- enabled: false,
799
- windowId: null,
800
- routeTabId: null,
801
- routeReady: false,
802
- routeUrl: '',
803
- reason: 'access_disabled',
804
- };
805
- }
806
-
807
- try {
808
- await chrome.windows.get(state.enabledWindow.windowId);
809
- } catch {
810
- const cleared = await clearEnabledWindowIfGone();
811
- if (cleared) {
812
- return {
813
- enabled: false,
814
- windowId: null,
815
- routeTabId: null,
816
- routeReady: false,
817
- routeUrl: '',
818
- reason: 'enabled_window_missing',
819
- };
820
- }
821
- }
822
-
823
- const tabs = await chrome.tabs.query({
824
- active: true,
825
- windowId: state.enabledWindow.windowId,
826
- });
827
- const tab = tabs[0];
828
- if (!tab?.id || typeof tab.url !== 'string') {
829
- return {
830
- enabled: true,
831
- windowId: state.enabledWindow.windowId,
832
- routeTabId: null,
833
- routeReady: false,
834
- routeUrl: '',
835
- reason: 'no_routable_active_tab',
836
- };
837
- }
838
-
839
- if (isRestrictedAutomationUrl(tab.url)) {
840
- return {
841
- enabled: true,
842
- windowId: state.enabledWindow.windowId,
843
- routeTabId: tab.id,
844
- routeReady: false,
845
- routeUrl: tab.url,
846
- reason: 'restricted_page',
847
- };
848
- }
849
-
850
- return {
851
- enabled: true,
852
- windowId: state.enabledWindow.windowId,
853
- routeTabId: tab.id,
854
- routeReady: true,
855
- routeUrl: tab.url,
856
- reason: 'enabled',
857
- };
858
- }
859
-
860
- /**
861
- * Summarize the currently open tabs in the enabled window so the client can
862
- * inspect or explicitly target them.
863
- *
864
- * @param {BridgeRequest} request
865
- * @returns {Promise<BridgeResponse>}
866
- */
867
- async function handleListTabs(request) {
868
- if (!state.enabledWindow) {
869
- return createFailure(request.id, ERROR_CODES.ACCESS_DENIED, ACCESS_DENIED_WINDOW_OFF, null, {
870
- method: request.method,
871
- });
872
- }
873
-
874
- const tabs = await chrome.tabs.query({
875
- windowId: state.enabledWindow.windowId,
876
- });
877
- const summarized = tabs
878
- .map((tab) => {
879
- if (!isNumber(tab.id) || typeof tab.url !== 'string') {
880
- return null;
881
- }
882
- return {
883
- tabId: tab.id,
884
- windowId: tab.windowId,
885
- active: Boolean(tab.active),
886
- title: tab.title ?? '',
887
- origin: safeOrigin(tab.url),
888
- url: tab.url,
889
- };
890
- })
891
- .filter((tab) => tab !== null);
892
- return createSuccess(request.id, { tabs: summarized }, { method: request.method });
893
- }
894
-
895
- /**
896
- * Normalizers for tab-bound request params. Each entry maps a bridge method
897
- * to a function that coerces and defaults the raw request params.
898
- *
899
- * @type {Record<string, ((params: Record<string, unknown>) => Record<string, unknown>) | undefined>}
900
- */
901
- const TAB_BOUND_NORMALIZERS = {
902
- 'dom.query': normalizeDomQuery,
903
- 'dom.wait_for': normalizeWaitForParams,
904
- 'dom.find_by_text': normalizeFindByTextParams,
905
- 'dom.find_by_role': normalizeFindByRoleParams,
906
- 'dom.get_html': normalizeGetHtmlParams,
907
- 'styles.get_computed': normalizeStyleQuery,
908
- 'styles.get_matched_rules': normalizeStyleQuery,
909
- 'viewport.scroll': normalizeViewportAction,
910
- 'input.click': normalizeInputAction,
911
- 'input.focus': normalizeInputAction,
912
- 'input.type': normalizeInputAction,
913
- 'input.press_key': normalizeInputAction,
914
- 'input.set_checked': normalizeCheckedAction,
915
- 'input.select_option': normalizeSelectAction,
916
- 'input.hover': normalizeHoverParams,
917
- 'input.drag': normalizeDragParams,
918
- 'patch.apply_styles': normalizePatchOperation,
919
- 'patch.apply_dom': normalizePatchOperation,
920
- 'patch.list': normalizePatchOperation,
921
- 'patch.rollback': normalizePatchOperation,
922
- 'patch.commit_session_baseline': normalizePatchOperation,
923
- 'page.get_storage': normalizeStorageParams,
924
- 'page.get_text': normalizePageTextParams,
925
- };
926
-
927
- /**
928
- * Dispatch a tab-bound request to the content script after enforcing the
929
- * session scope and capability requirements.
930
- *
931
- * @param {BridgeRequest} request
932
- * @returns {Promise<BridgeResponse>}
933
- */
934
- async function handleTabBoundRequest(request) {
935
- const target = await resolveRequestTarget(request);
936
- await ensureContentScript(target.tabId);
937
- const normalizer = TAB_BOUND_NORMALIZERS[request.method];
938
- const payload = normalizer ? normalizer(request.params) : request.params;
939
-
940
- if (request.method.startsWith('screenshot.')) {
941
- const result = await handleScreenshot(target, request.method, request.params);
942
- return createSuccess(request.id, result, { method: request.method });
943
- }
944
-
945
- const timeoutMs = getContentScriptTimeout(request.method, payload);
946
- const response = await sendTabMessage(
947
- target.tabId,
948
- {
949
- type: 'bridge.execute',
950
- method: request.method,
951
- params: payload,
952
- },
953
- timeoutMs
954
- );
955
- if (response?.error) {
956
- return toFailureResponse(request, response.error);
957
- }
958
- return createSuccess(request.id, response, { method: request.method });
959
- }
960
-
961
- /**
962
- * Execute a tab-level navigation action and optionally wait for the next load
963
- * cycle to complete.
964
- *
965
- * @param {BridgeRequest} request
966
- * @returns {Promise<BridgeResponse>}
967
- */
968
- async function handleNavigationRequest(request) {
969
- const target = await resolveRequestTarget(request);
970
- const action = normalizeNavigationAction(request.params);
971
-
972
- if (request.method === 'navigation.navigate') {
973
- if (!action.url) {
974
- throw new Error(ERROR_CODES.INVALID_REQUEST);
975
- }
976
- await chrome.tabs.update(target.tabId, { url: action.url });
977
- } else if (request.method === 'navigation.reload') {
978
- await chrome.tabs.reload(target.tabId);
979
- } else if (request.method === 'navigation.go_back') {
980
- await chrome.tabs.goBack(target.tabId);
981
- } else {
982
- await chrome.tabs.goForward(target.tabId);
983
- }
984
-
985
- const tab = action.waitForLoad
986
- ? await waitForTabComplete(target.tabId, action.timeoutMs)
987
- : await chrome.tabs.get(target.tabId);
988
- await emitUiState();
989
-
990
- return createSuccess(request.id, summarizeTabResult(tab, request.method), {
991
- method: request.method,
992
- });
993
- }
994
-
995
- /**
996
- * Compute a per-method content script timeout that accommodates long-running
997
- * operations such as dom.wait_for or hover-with-duration.
998
- *
999
- * @param {string} method
1000
- * @param {Record<string, unknown>} params
1001
- * @returns {number}
1002
- */
1003
- function getContentScriptTimeout(method, params) {
1004
- if (method === 'dom.wait_for') {
1005
- return Math.min(Math.max(Number(params?.timeoutMs) || 5_000, 100), 30_000) + 2_000;
1006
- }
1007
- if (method === 'input.hover' && Number(params?.duration) > 0) {
1008
- return CONTENT_SCRIPT_TIMEOUT_MS + Math.min(Number(params.duration), 5_000) + 1_000;
1009
- }
1010
- return CONTENT_SCRIPT_TIMEOUT_MS;
1011
- }
1012
-
1013
- /**
1014
- * Evaluate a JavaScript expression in the page's main context using the
1015
- * Chrome DevTools Protocol, avoiding content-script CSP restrictions.
1016
- *
1017
- * @param {BridgeRequest} request
1018
- * @returns {Promise<BridgeResponse>}
1019
- */
1020
- async function handlePageEvaluate(request) {
1021
- const target = await resolveRequestTarget(request);
1022
- const params = normalizeEvaluateParams(request.params);
1023
- if (!params.expression) {
1024
- return createFailure(request.id, ERROR_CODES.INVALID_REQUEST, 'expression is required.', null, {
1025
- method: request.method,
1026
- });
1027
- }
1028
- return tabDebugger.run(target.tabId, async (debugTarget) => {
1029
- const result = await chrome.debugger.sendCommand(debugTarget, 'Runtime.evaluate', {
1030
- expression: params.expression,
1031
- returnByValue: params.returnByValue,
1032
- awaitPromise: params.awaitPromise,
1033
- timeout: params.timeoutMs,
1034
- userGesture: true,
1035
- generatePreview: false,
1036
- replMode: true,
1037
- });
1038
- const cdpResult =
1039
- /** @type {{ result?: { type?: string, value?: unknown, description?: string }, exceptionDetails?: { text?: string, exception?: { description?: string } } }} */ (
1040
- result
1041
- );
1042
- if (cdpResult.exceptionDetails) {
1043
- const errText =
1044
- cdpResult.exceptionDetails.exception?.description ||
1045
- cdpResult.exceptionDetails.text ||
1046
- 'Evaluation failed.';
1047
- return createFailure(request.id, ERROR_CODES.INTERNAL_ERROR, errText, null, {
1048
- method: request.method,
1049
- });
1050
- }
1051
- return createSuccess(
1052
- request.id,
1053
- {
1054
- value: cdpResult.result?.value ?? null,
1055
- type: cdpResult.result?.type ?? 'undefined',
1056
- },
1057
- { method: request.method }
1058
- );
1059
- });
1060
- }
1061
-
1062
- /**
1063
- * Install a console interceptor on the page and retrieve buffered messages.
1064
- * Uses chrome.scripting.executeScript in the MAIN world.
1065
- *
1066
- * @param {BridgeRequest} request
1067
- * @returns {Promise<BridgeResponse>}
1068
- */
1069
- async function handlePageGetConsole(request) {
1070
- const target = await resolveRequestTarget(request);
1071
- const params = normalizeConsoleParams(request.params);
1072
-
1073
- await primeTabConsoleCapture(target.tabId);
1074
- const { entries, dropped } = await readConsoleBuffer(target.tabId, params.clear);
1075
- const filtered =
1076
- params.level === 'all'
1077
- ? entries
1078
- : entries.filter((/** @type {{ level: string }} */ e) =>
1079
- matchesConsoleLevel(params.level, e.level)
1080
- );
1081
- const limited = filtered.slice(-params.limit);
1082
-
1083
- return createSuccess(
1084
- request.id,
1085
- { entries: limited, count: limited.length, total: entries.length, dropped },
1086
- { method: request.method }
1087
- );
1088
- }
1089
-
1090
- /**
1091
- * Create a new tab with an optional URL.
1092
- *
1093
- * @param {BridgeRequest} request
1094
- * @returns {Promise<BridgeResponse>}
1095
- */
1096
- async function handleCreateTab(request) {
1097
- if (!state.enabledWindow) {
1098
- return createFailure(request.id, ERROR_CODES.ACCESS_DENIED, ACCESS_DENIED_WINDOW_OFF, null, {
1099
- method: request.method,
1100
- });
1101
- }
1102
- const params = normalizeTabCreateParams(request.params);
1103
- const tab = await chrome.tabs.create({
1104
- url: params.url,
1105
- active: params.active,
1106
- windowId: state.enabledWindow.windowId,
1107
- });
1108
- return createSuccess(request.id, summarizeTabResult(tab, request.method), {
1109
- method: request.method,
1110
- });
1111
- }
1112
-
1113
- /**
1114
- * Close a tab by tabId.
1115
- *
1116
- * @param {BridgeRequest} request
1117
- * @returns {Promise<BridgeResponse>}
1118
- */
1119
- async function handleCloseTab(request) {
1120
- const params = normalizeTabCloseParams(request.params);
1121
- if (!state.enabledWindow) {
1122
- return createFailure(request.id, ERROR_CODES.ACCESS_DENIED, ACCESS_DENIED_WINDOW_OFF, null, {
1123
- method: request.method,
1124
- });
1125
- }
1126
- let tab;
1127
- try {
1128
- tab = await chrome.tabs.get(params.tabId);
1129
- } catch {
1130
- return createFailure(
1131
- request.id,
1132
- ERROR_CODES.TAB_MISMATCH,
1133
- `Tab ${params.tabId} not found.`,
1134
- null,
1135
- { method: request.method }
1136
- );
1137
- }
1138
- if (tab.windowId !== state.enabledWindow.windowId) {
1139
- return createFailure(request.id, ERROR_CODES.ACCESS_DENIED, ACCESS_DENIED_TAB_CLOSE, null, {
1140
- method: request.method,
1141
- });
1142
- }
1143
- await chrome.tabs.remove(params.tabId);
1144
- return createSuccess(
1145
- request.id,
1146
- { closed: true, tabId: params.tabId },
1147
- { method: request.method }
1148
- );
1149
- }
1150
-
1151
- /**
1152
- * Return the full accessibility tree for the target tab via CDP
1153
- * Accessibility.getFullAXTree. Returns a pruned, token-efficient tree with
1154
- * roles, names, descriptions, and interactive states.
1155
- *
1156
- * @param {BridgeRequest} request
1157
- * @returns {Promise<BridgeResponse>}
1158
- */
1159
- async function handleAccessibilityTree(request) {
1160
- const target = await resolveRequestTarget(request);
1161
- const params = normalizeAccessibilityTreeParams(request.params);
1162
- return tabDebugger.run(target.tabId, async (debugTarget) => {
1163
- await chrome.debugger.sendCommand(debugTarget, 'Accessibility.enable', {});
1164
- const result = await chrome.debugger.sendCommand(debugTarget, 'Accessibility.getFullAXTree', {
1165
- depth: params.maxDepth,
1166
- });
1167
- const cdpResult = /** @type {{ nodes?: Array<Record<string, unknown>> }} */ (result);
1168
- const rawNodes = cdpResult.nodes || [];
1169
- const pruned = rawNodes.slice(0, params.maxNodes).map(simplifyAXNode);
1170
- await chrome.debugger.sendCommand(debugTarget, 'Accessibility.disable', {});
1171
- return createSuccess(
1172
- request.id,
1173
- {
1174
- nodes: pruned,
1175
- count: pruned.length,
1176
- total: rawNodes.length,
1177
- truncated: rawNodes.length > params.maxNodes,
1178
- },
1179
- { method: request.method }
1180
- );
1181
- });
1182
- }
1183
-
1184
- /**
1185
- * Install a network interceptor and retrieve buffered request/response entries
1186
- * via chrome.scripting.executeScript in the MAIN world, capturing fetch/XHR.
1187
- *
1188
- * @param {BridgeRequest} request
1189
- * @returns {Promise<BridgeResponse>}
1190
- */
1191
- async function handleGetNetwork(request) {
1192
- const target = await resolveRequestTarget(request);
1193
- const params = normalizeNetworkParams(request.params);
1194
- await ensureNetworkInterceptor(target.tabId);
1195
- const { entries, dropped } = await readNetworkBuffer(target.tabId, params.clear);
1196
- const urlPattern = typeof params.urlPattern === 'string' ? params.urlPattern : null;
1197
- const filtered = urlPattern
1198
- ? entries.filter((/** @type {{ url: string }} */ e) => e.url.includes(urlPattern))
1199
- : entries;
1200
- const limited = filtered.slice(-params.limit);
1201
- return createSuccess(
1202
- request.id,
1203
- { entries: limited, count: limited.length, total: entries.length, dropped },
1204
- { method: request.method }
1205
- );
1206
- }
1207
-
1208
- /**
1209
- * Inject the network interceptor into the page's main world. Patches
1210
- * fetch and XMLHttpRequest to capture request/response metadata.
1211
- *
1212
- * @param {number} tabId
1213
- * @returns {Promise<void>}
1214
- */
1215
- async function ensureNetworkInterceptor(tabId) {
1216
- await chrome.scripting.executeScript({
1217
- target: { tabId },
1218
- world: 'MAIN',
1219
- func: () => {
1220
- // @ts-ignore
1221
- if (globalThis.__bb_network_installed) return;
1222
- // @ts-ignore
1223
- globalThis.__bb_network_installed = true;
1224
- /** @type {Array<{method: string, url: string, status: number, duration: number, type: string, ts: number, size: number}>} */
1225
- const buffer = [];
1226
- // @ts-ignore
1227
- globalThis.__bb_network_buffer = buffer;
1228
- // @ts-ignore
1229
- globalThis.__bb_network_dropped = 0;
1230
- const MAX = 200;
1231
-
1232
- const origFetch = globalThis.fetch;
1233
- // @ts-ignore - intentional main-world global override
1234
- globalThis.fetch = async function (...args) {
1235
- // @ts-ignore
1236
- const req = new Request(...args);
1237
- const entry = {
1238
- method: req.method,
1239
- url: req.url,
1240
- status: 0,
1241
- duration: 0,
1242
- type: 'fetch',
1243
- ts: Date.now(),
1244
- size: 0,
1245
- };
1246
- const startTime = performance.now();
1247
- try {
1248
- const resp = await origFetch.apply(globalThis, args);
1249
- entry.status = resp.status;
1250
- entry.duration = Math.round(performance.now() - startTime);
1251
- const cl = resp.headers.get('content-length');
1252
- if (cl) entry.size = Number(cl);
1253
- return resp;
1254
- } catch (err) {
1255
- entry.status = 0;
1256
- entry.duration = Math.round(performance.now() - startTime);
1257
- throw err;
1258
- } finally {
1259
- buffer.push(entry);
1260
- if (buffer.length > MAX) {
1261
- const dropped =
1262
- /** @type {Record<string, unknown>} */ (globalThis).__bb_network_dropped;
1263
- /** @type {Record<string, unknown>} */ (globalThis).__bb_network_dropped =
1264
- (typeof dropped === 'number' ? dropped : 0) + (buffer.length - MAX);
1265
- buffer.splice(0, buffer.length - MAX);
1266
- }
1267
- }
1268
- };
1269
-
1270
- const origOpen = XMLHttpRequest.prototype.open;
1271
- const origSend = XMLHttpRequest.prototype.send;
1272
- /**
1273
- * @this {XMLHttpRequest & { __bb_method?: string, __bb_url?: string }}
1274
- * @param {string} method
1275
- * @param {string | URL} url
1276
- * @param {...unknown} rest
1277
- * @returns {unknown}
1278
- */
1279
- XMLHttpRequest.prototype.open = function (method, url, ...rest) {
1280
- // @ts-ignore - stashing method/url for XHR interception
1281
- this.__bb_method = method;
1282
- // @ts-ignore
1283
- this.__bb_url = String(url);
1284
- return /** @type {any} */ (origOpen).call(this, method, url, ...rest);
1285
- };
1286
- /**
1287
- * @this {XMLHttpRequest & { __bb_method?: string, __bb_url?: string }}
1288
- * @param {...unknown} args
1289
- * @returns {unknown}
1290
- */
1291
- XMLHttpRequest.prototype.send = function (...args) {
1292
- // @ts-ignore
1293
- const entry = {
1294
- method: this.__bb_method || 'GET',
1295
- url: this.__bb_url || '',
1296
- status: 0,
1297
- duration: 0,
1298
- type: 'xhr',
1299
- ts: Date.now(),
1300
- size: 0,
1301
- };
1302
- const startTime = performance.now();
1303
- this.addEventListener('loadend', () => {
1304
- entry.status = this.status;
1305
- entry.duration = Math.round(performance.now() - startTime);
1306
- const cl = this.getResponseHeader('content-length');
1307
- if (cl) entry.size = Number(cl);
1308
- buffer.push(entry);
1309
- if (buffer.length > MAX) {
1310
- const dropped =
1311
- /** @type {Record<string, unknown>} */ (globalThis).__bb_network_dropped;
1312
- /** @type {Record<string, unknown>} */ (globalThis).__bb_network_dropped =
1313
- (typeof dropped === 'number' ? dropped : 0) + (buffer.length - MAX);
1314
- buffer.splice(0, buffer.length - MAX);
1315
- }
1316
- });
1317
- return /** @type {any} */ (origSend).apply(this, args);
1318
- };
1319
- },
1320
- });
1321
- }
1322
-
1323
- /**
1324
- * Read and optionally clear the network buffer from the page's main world.
1325
- *
1326
- * @param {number} tabId
1327
- * @param {boolean} clear
1328
- * @returns {Promise<{ entries: Array<{method: string, url: string, status: number, duration: number, type: string, ts: number, size: number}>, dropped: number }>}
1329
- */
1330
- async function readNetworkBuffer(tabId, clear) {
1331
- const results = await chrome.scripting.executeScript({
1332
- target: { tabId },
1333
- world: 'MAIN',
1334
- func: (shouldClear) => {
1335
- // @ts-ignore
1336
- const buf = globalThis.__bb_network_buffer || [];
1337
- // @ts-ignore
1338
- const dropped = globalThis.__bb_network_dropped || 0;
1339
- const copy = [...buf];
1340
- if (shouldClear) {
1341
- // @ts-ignore
1342
- globalThis.__bb_network_buffer = [];
1343
- // @ts-ignore
1344
- globalThis.__bb_network_dropped = 0;
1345
- }
1346
- return { entries: copy, dropped };
1347
- },
1348
- args: [clear],
1349
- });
1350
- return /** @type {any} */ (results?.[0]?.result) || { entries: [], dropped: 0 };
1351
- }
1352
-
1353
- /**
1354
- * Resize the browser viewport via CDP Emulation.setDeviceMetricsOverride
1355
- * or reset to natural size when width/height are 0.
1356
- *
1357
- * @param {BridgeRequest} request
1358
- * @returns {Promise<BridgeResponse>}
1359
- */
1360
- async function handleViewportResize(request) {
1361
- const target = await resolveRequestTarget(request);
1362
- const params = normalizeViewportResizeParams(request.params);
1363
- return tabDebugger.run(target.tabId, async (debugTarget) => {
1364
- if (params.reset || (params.width === 0 && params.height === 0)) {
1365
- await chrome.debugger.sendCommand(debugTarget, 'Emulation.clearDeviceMetricsOverride', {});
1366
- } else {
1367
- await chrome.debugger.sendCommand(debugTarget, 'Emulation.setDeviceMetricsOverride', {
1368
- width: params.width,
1369
- height: params.height,
1370
- deviceScaleFactor: params.deviceScaleFactor,
1371
- mobile: params.width < 768,
1372
- });
1373
- }
1374
- return createSuccess(
1375
- request.id,
1376
- {
1377
- resized: true,
1378
- width: params.width,
1379
- height: params.height,
1380
- deviceScaleFactor: params.deviceScaleFactor,
1381
- reset: params.reset,
1382
- },
1383
- { method: request.method }
1384
- );
1385
- });
1386
- }
1387
-
1388
- /**
1389
- * Return browser performance metrics via CDP Performance.getMetrics.
1390
- *
1391
- * @param {BridgeRequest} request
1392
- * @returns {Promise<BridgeResponse>}
1393
- */
1394
- async function handlePerformanceMetrics(request) {
1395
- const target = await resolveRequestTarget(request);
1396
- return tabDebugger.run(target.tabId, async (debugTarget) => {
1397
- await chrome.debugger.sendCommand(debugTarget, 'Performance.enable', {
1398
- timeDomain: 'timeTicks',
1399
- });
1400
- const result = await chrome.debugger.sendCommand(debugTarget, 'Performance.getMetrics', {});
1401
- await chrome.debugger.sendCommand(debugTarget, 'Performance.disable', {});
1402
- const cdpResult = /** @type {{ metrics?: Array<{ name: string, value: number }> }} */ (result);
1403
- const metrics = (cdpResult.metrics || []).reduce((acc, m) => {
1404
- acc[m.name] = m.value;
1405
- return acc;
1406
- }, /** @type {Record<string, number>} */ ({}));
1407
- return createSuccess(request.id, { metrics }, { method: request.method });
1408
- });
1409
- }
1410
-
1411
- /**
1412
- * Wait for the tab to reach the 'complete' load state.
1413
- *
1414
- * @param {BridgeRequest} request
1415
- * @returns {Promise<BridgeResponse>}
1416
- */
1417
- async function handleWaitForLoadState(request) {
1418
- const target = await resolveRequestTarget(request);
1419
- const params = normalizeWaitForLoadStateParams(request.params);
1420
- const tab = params.waitForLoad
1421
- ? await waitForTabComplete(target.tabId, params.timeoutMs)
1422
- : await chrome.tabs.get(target.tabId);
1423
- return createSuccess(request.id, summarizeTabResult(tab, request.method), {
1424
- method: request.method,
1425
- });
1426
- }
1427
-
1428
- /**
1429
- * Inject the console interceptor into the page's main world if not already
1430
- * present. The interceptor patches console methods and captures unhandled
1431
- * errors into a bounded in-page buffer.
1432
- *
1433
- * @param {number} tabId
1434
- * @returns {Promise<void>}
1435
- */
1436
- async function ensureConsoleInterceptor(tabId) {
1437
- await chrome.scripting.executeScript({
1438
- target: { tabId },
1439
- world: 'MAIN',
1440
- func: () => {
1441
- // @ts-ignore - intentional main-world global
1442
- if (globalThis.__bb_console_installed) return;
1443
- // @ts-ignore
1444
- globalThis.__bb_console_installed = true;
1445
- /** @type {Array<{level: string, args: string[], ts: number}>} */
1446
- const buffer = [];
1447
- // @ts-ignore
1448
- globalThis.__bb_console_buffer = buffer;
1449
- // @ts-ignore
1450
- globalThis.__bb_console_dropped = 0;
1451
- const MAX = 200;
1452
- const orig = /** @type {Record<string, Function>} */ ({});
1453
- const consoleMethods =
1454
- /** @type {Record<string, (...args: unknown[]) => void>} */ (
1455
- /** @type {unknown} */ (console)
1456
- );
1457
- for (const level of ['log', 'warn', 'error', 'info', 'debug']) {
1458
- orig[level] = consoleMethods[level];
1459
- consoleMethods[level] = (...args) => {
1460
- buffer.push({
1461
- level,
1462
- args: args.map((a) => {
1463
- try {
1464
- return typeof a === 'object'
1465
- ? JSON.stringify(a).slice(0, 500)
1466
- : String(a).slice(0, 500);
1467
- } catch {
1468
- return String(a).slice(0, 500);
1469
- }
1470
- }),
1471
- ts: Date.now(),
1472
- });
1473
- if (buffer.length > MAX) {
1474
- const dropped =
1475
- /** @type {Record<string, unknown>} */ (globalThis).__bb_console_dropped;
1476
- /** @type {Record<string, unknown>} */ (globalThis).__bb_console_dropped =
1477
- (typeof dropped === 'number' ? dropped : 0) + (buffer.length - MAX);
1478
- buffer.splice(0, buffer.length - MAX);
1479
- }
1480
- orig[level].apply(console, args);
1481
- };
1482
- }
1483
- globalThis.addEventListener('error', (e) => {
1484
- buffer.push({
1485
- level: 'exception',
1486
- args: [
1487
- e.message || 'Unknown error',
1488
- e.filename ? `${e.filename}:${e.lineno}:${e.colno}` : '',
1489
- ],
1490
- ts: Date.now(),
1491
- });
1492
- if (buffer.length > MAX) {
1493
- const dropped = /** @type {Record<string, unknown>} */ (globalThis).__bb_console_dropped;
1494
- /** @type {Record<string, unknown>} */ (globalThis).__bb_console_dropped =
1495
- (typeof dropped === 'number' ? dropped : 0) + (buffer.length - MAX);
1496
- buffer.splice(0, buffer.length - MAX);
1497
- }
1498
- });
1499
- globalThis.addEventListener('unhandledrejection', (e) => {
1500
- buffer.push({
1501
- level: 'rejection',
1502
- args: [String(e.reason).slice(0, 500)],
1503
- ts: Date.now(),
1504
- });
1505
- if (buffer.length > MAX) {
1506
- const dropped = /** @type {Record<string, unknown>} */ (globalThis).__bb_console_dropped;
1507
- /** @type {Record<string, unknown>} */ (globalThis).__bb_console_dropped =
1508
- (typeof dropped === 'number' ? dropped : 0) + (buffer.length - MAX);
1509
- buffer.splice(0, buffer.length - MAX);
1510
- }
1511
- });
1512
- },
1513
- });
1514
- }
1515
-
1516
- /**
1517
- * Best-effort console capture installation for enabled tabs. Some URLs cannot
1518
- * be scripted; those failures should not block enablement or navigation.
1519
- *
1520
- * @param {number} tabId
1521
- * @param {boolean} [resetBuffer=false]
1522
- * @returns {Promise<void>}
1523
- */
1524
- async function primeTabConsoleCapture(tabId, resetBuffer = false) {
1525
- try {
1526
- await ensureConsoleInterceptor(tabId);
1527
- if (resetBuffer) {
1528
- await readConsoleBuffer(tabId, true);
1529
- }
1530
- } catch (error) {
1531
- if (isRecoverableInstrumentationError(error)) {
1532
- return;
1533
- }
1534
- throw error;
1535
- }
1536
- }
1537
-
1538
- /**
1539
- * @param {unknown} error
1540
- * @returns {boolean}
1541
- */
1542
- function isRecoverableInstrumentationError(error) {
1543
- const message = normalizeRuntimeErrorMessage(getErrorMessage(error));
1544
- return (
1545
- message === ERROR_CODES.TAB_MISMATCH ||
1546
- /Cannot access contents of/i.test(message) ||
1547
- /The extensions gallery cannot be scripted/i.test(message) ||
1548
- /Cannot access a chrome:\/\//i.test(message) ||
1549
- /Cannot script/i.test(message) ||
1550
- /CONTENT_SCRIPT_UNAVAILABLE/i.test(message) ||
1551
- /No tab with id/i.test(message) ||
1552
- /Cannot attach to this target/i.test(message) ||
1553
- /Another debugger is already attached/i.test(message)
1554
- );
1555
- }
1556
-
1557
- /**
1558
- * Read and optionally clear the console buffer from the page's main world.
1559
- *
1560
- * @param {number} tabId
1561
- * @param {boolean} clear
1562
- * @returns {Promise<{ entries: Array<{level: string, args: string[], ts: number}>, dropped: number }>}
1563
- */
1564
- async function readConsoleBuffer(tabId, clear) {
1565
- const results = await chrome.scripting.executeScript({
1566
- target: { tabId },
1567
- world: 'MAIN',
1568
- func: (shouldClear) => {
1569
- // @ts-ignore
1570
- const buf = globalThis.__bb_console_buffer || [];
1571
- // @ts-ignore
1572
- const dropped = globalThis.__bb_console_dropped || 0;
1573
- const copy = [...buf];
1574
- if (shouldClear) {
1575
- // @ts-ignore
1576
- globalThis.__bb_console_buffer = [];
1577
- // @ts-ignore
1578
- globalThis.__bb_console_dropped = 0;
1579
- }
1580
- return { entries: copy, dropped };
1581
- },
1582
- args: [clear],
1583
- });
1584
- return /** @type {any} */ (results?.[0]?.result) || { entries: [], dropped: 0 };
1585
- }
1586
-
1587
- /**
1588
- * Prime console capture for every tab in one window.
1589
- *
1590
- * @param {number} windowId
1591
- * @param {boolean} [resetBuffer=false]
1592
- * @returns {Promise<void>}
1593
- */
1594
- async function primeWindowConsoleCapture(windowId, resetBuffer = false) {
1595
- const tabs = await chrome.tabs.query({ windowId });
1596
- const tabIds = tabs
1597
- .map((tab) => (isNumber(tab.id) ? tab.id : null))
1598
- .filter((tabId) => tabId !== null);
1599
- await Promise.allSettled(tabIds.map((tabId) => primeTabConsoleCapture(tabId, resetBuffer)));
1600
- }
1601
-
1602
- /**
1603
- * Clear bridge buffers and roll back active patches for one tab.
1604
- *
1605
- * @param {number} tabId
1606
- * @returns {Promise<void>}
1607
- */
1608
- async function clearTabBridgeState(tabId) {
1609
- try {
1610
- await rollbackAllPatchesForTab(tabId);
1611
- } catch (error) {
1612
- if (!isRecoverableInstrumentationError(error)) {
1613
- throw error;
1614
- }
1615
- }
1616
- try {
1617
- await readConsoleBuffer(tabId, true);
1618
- } catch (error) {
1619
- if (!isRecoverableInstrumentationError(error)) {
1620
- throw error;
1621
- }
1622
- }
1623
- try {
1624
- await readNetworkBuffer(tabId, true);
1625
- } catch (error) {
1626
- if (!isRecoverableInstrumentationError(error)) {
1627
- throw error;
1628
- }
1629
- }
1630
- }
1631
-
1632
- /**
1633
- * Clear tab-local bridge state for all tabs in one window.
1634
- *
1635
- * @param {number} windowId
1636
- * @returns {Promise<void>}
1637
- */
1638
- async function clearWindowBridgeState(windowId) {
1639
- const tabs = await chrome.tabs.query({ windowId });
1640
- const tabIds = tabs
1641
- .filter((tab) => tab.url && !isRestrictedAutomationUrl(tab.url))
1642
- .map((tab) => (isNumber(tab.id) ? tab.id : null))
1643
- .filter((tabId) => tabId !== null);
1644
- await Promise.allSettled(tabIds.map((tabId) => clearTabBridgeState(tabId)));
1645
- }
1646
-
1647
- /**
1648
- * Roll back all reversible patches currently tracked in one tab.
1649
- *
1650
- * @param {number} tabId
1651
- * @returns {Promise<void>}
1652
- */
1653
- async function rollbackAllPatchesForTab(tabId) {
1654
- try {
1655
- await ensureContentScript(tabId);
1656
- const listed = await sendTabMessage(
1657
- tabId,
1658
- {
1659
- type: 'bridge.execute',
1660
- method: 'patch.list',
1661
- params: {},
1662
- },
1663
- CONTENT_SCRIPT_TIMEOUT_MS
1664
- );
1665
- const patches = Array.isArray(listed) ? listed : listed?.patches;
1666
- if (!Array.isArray(patches)) {
1667
- return;
1668
- }
1669
- for (const patch of patches) {
1670
- const patchId =
1671
- patch && typeof patch === 'object'
1672
- ? /** @type {Record<string, unknown>} */ (patch).patchId
1673
- : null;
1674
- if (typeof patchId !== 'string' || !patchId) {
1675
- continue;
1676
- }
1677
- await sendTabMessage(
1678
- tabId,
1679
- {
1680
- type: 'bridge.execute',
1681
- method: 'patch.rollback',
1682
- params: { patchId },
1683
- },
1684
- CONTENT_SCRIPT_TIMEOUT_MS
1685
- );
1686
- }
1687
- } catch (error) {
1688
- if (!isRecoverableInstrumentationError(error)) {
1689
- throw error;
1690
- }
1691
- }
1692
- }
1693
-
1694
- /**
1695
- * @param {number} tabId
1696
- * @returns {Promise<boolean>}
1697
- */
1698
- async function isTabEnabled(tabId) {
1699
- if (!state.enabledWindow) {
1700
- return false;
1701
- }
1702
- try {
1703
- const tab = await chrome.tabs.get(tabId);
1704
- return tab.windowId === state.enabledWindow.windowId;
1705
- } catch {
1706
- return false;
1707
- }
1708
- }
1709
-
1710
- /**
1711
- * Capture a targeted screenshot for the current target tab by asking the content
1712
- * script for an element rect and then cropping the visible tab image.
1713
- *
1714
- * @param {ResolvedTabTarget} target
1715
- * @param {string} method
1716
- * @param {Record<string, unknown>} params
1717
- * @returns {Promise<{ rect: unknown, image: string }>}
1718
- */
1719
- async function handleScreenshot(target, method, params) {
1720
- /** @type {{ x: number, y: number, width: number, height: number, scale: number }} */
1721
- let clip;
1722
-
1723
- if (method === 'screenshot.capture_element') {
1724
- await ensureContentScript(target.tabId);
1725
- try {
1726
- clip = await sendTabMessage(
1727
- target.tabId,
1728
- {
1729
- type: 'bridge.execute',
1730
- method,
1731
- params,
1732
- },
1733
- CONTENT_SCRIPT_TIMEOUT_MS
1734
- );
1735
- } catch (err) {
1736
- // Retry once after a brief pause - the page may have been mid-render
1737
- if (err instanceof Error && /stale/i.test(err.message)) {
1738
- await new Promise((r) => setTimeout(r, 250));
1739
- clip = await sendTabMessage(
1740
- target.tabId,
1741
- {
1742
- type: 'bridge.execute',
1743
- method,
1744
- params,
1745
- },
1746
- CONTENT_SCRIPT_TIMEOUT_MS
1747
- );
1748
- } else {
1749
- throw err;
1750
- }
1751
- }
1752
- // Defensively coerce content-script values - NaN / undefined / negative
1753
- // would slip past the < 1 guard and reach CDP as invalid values.
1754
- clip = {
1755
- x: Math.max(0, Number(clip.x) || 0),
1756
- y: Math.max(0, Number(clip.y) || 0),
1757
- width: Math.max(0, Number(clip.width) || 0),
1758
- height: Math.max(0, Number(clip.height) || 0),
1759
- scale: Number(clip.scale) || 1,
1760
- };
1761
- } else if (method === 'screenshot.capture_full_page') {
1762
- await ensureContentScript(target.tabId);
1763
- const dims =
1764
- /** @type {{ scrollWidth: number, scrollHeight: number, devicePixelRatio: number }} */ (
1765
- await sendTabMessage(
1766
- target.tabId,
1767
- { type: 'bridge.execute', method, params },
1768
- CONTENT_SCRIPT_TIMEOUT_MS
1769
- )
1770
- );
1771
- clip = {
1772
- x: 0,
1773
- y: 0,
1774
- width: Math.min(Math.max(1, Number(dims.scrollWidth) || 1), 16384),
1775
- height: Math.min(Math.max(1, Number(dims.scrollHeight) || 1), 16384),
1776
- scale: Number(dims.devicePixelRatio) || 1,
1777
- };
1778
- } else {
1779
- // capture_region: params already carry viewport coordinates
1780
- const scale = Number(params.scale) || 1;
1781
- clip = {
1782
- x: Number(params.x) || 0,
1783
- y: Number(params.y) || 0,
1784
- width: Math.max(1, Number(params.width) || 1),
1785
- height: Math.max(1, Number(params.height) || 1),
1786
- scale,
1787
- };
1788
- }
1789
-
1790
- if (clip.width < 1 || clip.height < 1) {
1791
- throw new Error(
1792
- `Capture target has no visible area (${clip.width}\u00d7${clip.height}px). ` +
1793
- 'It may be hidden, collapsed, or not yet rendered.'
1794
- );
1795
- }
1796
-
1797
- // Use CDP Page.captureScreenshot - works regardless of tab focus,
1798
- // captures renderer output directly with built-in clip support.
1799
- return tabDebugger.run(target.tabId, async (debugTarget) => {
1800
- const dpr = clip.scale || 1;
1801
- const cdpResult = /** @type {{ data?: string }} */ (
1802
- await chrome.debugger.sendCommand(debugTarget, 'Page.captureScreenshot', {
1803
- format: 'png',
1804
- clip: {
1805
- x: Math.max(0, clip.x),
1806
- y: Math.max(0, clip.y),
1807
- width: clip.width,
1808
- height: clip.height,
1809
- scale: dpr,
1810
- },
1811
- captureBeyondViewport: method === 'screenshot.capture_full_page',
1812
- })
1813
- );
1814
- if (!cdpResult?.data) {
1815
- throw new Error('CDP Page.captureScreenshot returned empty data.');
1816
- }
1817
- return {
1818
- rect: clip,
1819
- image: `data:image/png;base64,${cdpResult.data}`,
1820
- };
1821
- });
1822
- }
1823
-
1824
- /**
1825
- * Send a message to the content script and fail fast if it does not respond.
1826
- *
1827
- * @param {number} tabId
1828
- * @param {Record<string, unknown>} message
1829
- * @param {number} timeoutMs
1830
- * @returns {Promise<any>}
1831
- */
1832
- async function sendTabMessage(tabId, message, timeoutMs) {
1833
- /** @type {ReturnType<typeof setTimeout> | undefined} */
1834
- let timeoutId;
1835
- const timeout = new Promise((_, reject) => {
1836
- timeoutId = setTimeout(
1837
- () =>
1838
- reject(new Error(`Timed out waiting for content script response after ${timeoutMs}ms.`)),
1839
- timeoutMs
1840
- );
1841
- });
1842
- try {
1843
- return await Promise.race([chrome.tabs.sendMessage(tabId, message), timeout]);
1844
- } finally {
1845
- clearTimeout(timeoutId);
1846
- }
1847
- }
1848
-
1849
- /**
1850
- * Proactively inject content scripts into all scriptable tabs in a window
1851
- * when Bridge access is enabled. Errors on restricted pages are silently
1852
- * ignored since ensureContentScript will handle them on demand.
1853
- *
1854
- * @param {number} windowId
1855
- * @returns {Promise<void>}
1856
- */
1857
- async function injectContentScriptsForWindow(windowId) {
1858
- const tabs = await chrome.tabs.query({ windowId });
1859
- await Promise.allSettled(
1860
- tabs
1861
- .map((tab) =>
1862
- isNumber(tab.id) && tab.url && !isRestrictedAutomationUrl(tab.url) ? tab.id : null
1863
- )
1864
- .filter((tabId) => tabId !== null)
1865
- .map((tabId) => ensureContentScript(tabId))
1866
- );
1867
- }
1868
-
1869
- /**
1870
- * Detect Chrome scripting errors that indicate a restricted or unscriptable page.
1871
- *
1872
- * @param {string} message
1873
- * @returns {boolean}
1874
- */
1875
- function isRestrictedScriptingError(message) {
1876
- return (
1877
- /Cannot access contents of/i.test(message) ||
1878
- /The extensions gallery cannot be scripted/i.test(message) ||
1879
- /Cannot access a chrome:\/\//i.test(message) ||
1880
- /Cannot script/i.test(message)
1881
- );
1882
- }
1883
-
1884
- /**
1885
- * Ensure the content script is present on the target tab before issuing
1886
- * content-script-backed requests. This makes page operations resilient after
1887
- * extension reloads or on tabs that predate the current extension version.
1888
- *
1889
- * @param {number} tabId
1890
- * @returns {Promise<void>}
1891
- */
1892
- async function ensureContentScript(tabId) {
1893
- try {
1894
- await sendTabMessage(tabId, { type: 'bridge.ping' }, CONTENT_SCRIPT_TIMEOUT_MS);
1895
- return;
1896
- } catch {
1897
- try {
1898
- await chrome.scripting.executeScript({
1899
- target: { tabId },
1900
- files: [
1901
- 'packages/extension/src/content-script-helpers.js',
1902
- 'packages/extension/src/content-script.js',
1903
- ],
1904
- });
1905
- } catch (injectError) {
1906
- const msg = injectError instanceof Error ? injectError.message : String(injectError);
1907
- if (isRestrictedScriptingError(msg)) {
1908
- throw new Error(
1909
- 'CONTENT_SCRIPT_UNAVAILABLE: Content script not available on this page (restricted or extension page).',
1910
- { cause: injectError }
1911
- );
1912
- }
1913
- throw injectError;
1914
- }
1915
- }
1916
- }
1917
-
1918
- /**
1919
- * Execute a restricted Chrome DevTools Protocol request against the enabled
1920
- * tab.
1921
- *
1922
- * @param {BridgeRequest} request
1923
- * @returns {Promise<BridgeResponse>}
1924
- */
1925
- async function handleCdpRequest(request) {
1926
- const target = await resolveRequestTarget(request);
1927
- return tabDebugger.run(target.tabId, async (debugTarget) => {
1928
- let command;
1929
- let params = {};
1930
- if (request.method === 'cdp.get_document') {
1931
- command = 'DOM.getDocument';
1932
- params = { depth: 2, pierce: false };
1933
- } else if (request.method === 'cdp.get_dom_snapshot') {
1934
- command = 'DOMSnapshot.captureSnapshot';
1935
- params = { computedStyles: request.params?.computedStyles ?? [] };
1936
- } else if (request.method === 'cdp.get_box_model') {
1937
- const nodeId = request.params?.nodeId;
1938
- if (typeof nodeId !== 'number' || !Number.isFinite(nodeId)) {
1939
- return createFailure(
1940
- request.id,
1941
- ERROR_CODES.INVALID_REQUEST,
1942
- 'nodeId must be a finite number.',
1943
- null,
1944
- { method: request.method }
1945
- );
1946
- }
1947
- command = 'DOM.getBoxModel';
1948
- params = { nodeId };
1949
- } else {
1950
- const nodeId = request.params?.nodeId;
1951
- if (typeof nodeId !== 'number' || !Number.isFinite(nodeId)) {
1952
- return createFailure(
1953
- request.id,
1954
- ERROR_CODES.INVALID_REQUEST,
1955
- 'nodeId must be a finite number.',
1956
- null,
1957
- { method: request.method }
1958
- );
1959
- }
1960
- command = 'CSS.getComputedStyleForNode';
1961
- params = { nodeId };
1962
- }
1963
- const result = await chrome.debugger.sendCommand(
1964
- debugTarget,
1965
- command,
1966
- /** @type {Record<string, unknown>} */ (params)
1967
- );
1968
- return createSuccess(request.id, result, { method: request.method });
1969
- });
1970
- }
1971
-
1972
- /**
1973
- * Wait for a tab to reach the `complete` status after a navigation-like action.
1974
- *
1975
- * @param {number} tabId
1976
- * @param {number} timeoutMs
1977
- * @returns {Promise<chrome.tabs.Tab>}
1978
- */
1979
- async function waitForTabComplete(tabId, timeoutMs) {
1980
- const initialTab = await chrome.tabs.get(tabId);
1981
- if (initialTab.status === 'complete') {
1982
- return initialTab;
1983
- }
1984
-
1985
- return new Promise((resolve, reject) => {
1986
- let finished = false;
1987
- const timeoutId = setTimeout(() => {
1988
- cleanup();
1989
- reject(
1990
- new Error(`Timed out waiting for tab ${tabId} to finish loading after ${timeoutMs}ms.`)
1991
- );
1992
- }, timeoutMs);
1993
-
1994
- /**
1995
- * @returns {void}
1996
- */
1997
- function cleanup() {
1998
- if (finished) {
1999
- return;
2000
- }
2001
- finished = true;
2002
- clearTimeout(timeoutId);
2003
- chrome.tabs.onUpdated.removeListener(onUpdated);
2004
- chrome.tabs.onRemoved.removeListener(onRemoved);
2005
- }
2006
-
2007
- /**
2008
- * @param {number} updatedTabId
2009
- * @param {TabChangeInfo} changeInfo
2010
- * @param {chrome.tabs.Tab} tab
2011
- * @returns {void}
2012
- */
2013
- function onUpdated(updatedTabId, changeInfo, tab) {
2014
- if (updatedTabId !== tabId || changeInfo.status !== 'complete') {
2015
- return;
2016
- }
2017
- cleanup();
2018
- resolve(tab);
2019
- }
2020
-
2021
- /**
2022
- * @param {number} removedTabId
2023
- * @returns {void}
2024
- */
2025
- function onRemoved(removedTabId) {
2026
- if (removedTabId !== tabId) {
2027
- return;
2028
- }
2029
- cleanup();
2030
- reject(new Error(ERROR_CODES.TAB_MISMATCH));
2031
- }
2032
-
2033
- chrome.tabs.onUpdated.addListener(onUpdated);
2034
- chrome.tabs.onRemoved.addListener(onRemoved);
2035
- });
2036
- }
2037
-
2038
- /**
2039
- * Resolve the tab a request should operate on. Requests may explicitly target
2040
- * one tab via `tab_id`; otherwise they follow the active tab in the enabled
2041
- * window.
2042
- *
2043
- * @param {BridgeRequest} request
2044
- * @param {{ requireScriptable?: boolean }} [options]
2045
- * @returns {Promise<ResolvedTabTarget>}
2046
- */
2047
- async function resolveRequestTarget(request, options = {}) {
2048
- const requireScriptable = options.requireScriptable !== false;
2049
- if (!state.enabledWindow) {
2050
- throw new Error(ERROR_CODES.ACCESS_DENIED);
2051
- }
2052
-
2053
- try {
2054
- await chrome.windows.get(state.enabledWindow.windowId);
2055
- } catch {
2056
- const cleared = await clearEnabledWindowIfGone();
2057
- if (cleared) {
2058
- throw new Error(ERROR_CODES.ACCESS_DENIED);
2059
- }
2060
- }
2061
-
2062
- /** @type {chrome.tabs.Tab | null} */
2063
- let explicitTab = null;
2064
- if (typeof request.tab_id === 'number' && Number.isFinite(request.tab_id)) {
2065
- explicitTab = await chrome.tabs.get(request.tab_id);
2066
- }
2067
- const [activeTab] = await chrome.tabs.query({
2068
- active: true,
2069
- windowId: state.enabledWindow.windowId,
2070
- });
2071
- const tab = selectRequestTabCandidate(request.tab_id, explicitTab, activeTab ?? null);
2072
-
2073
- return resolveWindowScopedTab(tab, state.enabledWindow.windowId, {
2074
- requireScriptable,
2075
- });
2076
- }
2077
-
2078
- /**
2079
- * Resolve the current active tab in the last-focused window so the popup and
2080
- * side panel can reflect and toggle its bridge enablement state.
2081
- *
2082
- * @returns {Promise<CurrentTabState | null>}
2083
- */
2084
- async function getCurrentTabState() {
2085
- const [activeTab] = await chrome.tabs.query({
2086
- active: true,
2087
- lastFocusedWindow: true,
2088
- });
2089
- if (!activeTab?.id || typeof activeTab.windowId !== 'number' || !activeTab.url) {
2090
- return null;
2091
- }
2092
-
2093
- return {
2094
- tabId: activeTab.id,
2095
- windowId: activeTab.windowId,
2096
- title: activeTab.title ?? '',
2097
- url: activeTab.url,
2098
- enabled: isWindowEnabled(activeTab.windowId),
2099
- accessRequested: isAccessRequestedWindow(activeTab.windowId),
2100
- restricted: isRestrictedAutomationUrl(activeTab.url),
2101
- };
2102
- }
2103
-
2104
- /**
2105
- * Resolve one specific tab into the UI shape used by the popup and side panel.
2106
- *
2107
- * @param {number | null} tabId
2108
- * @returns {Promise<CurrentTabState | null>}
2109
- */
2110
- async function getTabState(tabId) {
2111
- if (!tabId) {
2112
- return null;
2113
- }
2114
-
2115
- try {
2116
- const tab = await chrome.tabs.get(tabId);
2117
- if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number' || !tab.url) {
2118
- return null;
2119
- }
2120
-
2121
- return {
2122
- tabId: tab.id,
2123
- windowId: tab.windowId,
2124
- title: tab.title ?? '',
2125
- url: tab.url,
2126
- enabled: isWindowEnabled(tab.windowId),
2127
- accessRequested: isAccessRequestedWindow(tab.windowId),
2128
- restricted: isRestrictedAutomationUrl(tab.url),
2129
- };
2130
- } catch {
2131
- return null;
2132
- }
2133
- }
2134
-
2135
- /**
2136
- * Enable or disable bridge communication for the current window.
2137
- *
2138
- * @param {boolean} enabled
2139
- * @returns {Promise<void>}
2140
- */
2141
- async function setCurrentWindowEnabled(enabled) {
2142
- const currentTab = await getCurrentTabState();
2143
- if (!currentTab?.url) {
2144
- throw new Error(ERROR_CODES.TAB_MISMATCH);
2145
- }
2146
-
2147
- await setWindowEnabled(currentTab.windowId, currentTab.title, enabled);
2148
- }
2149
-
2150
- /**
2151
- * Enable or disable bridge communication for one specific window.
2152
- *
2153
- * @param {number} windowId
2154
- * @param {string} title
2155
- * @param {boolean} enabled
2156
- * @returns {Promise<void>}
2157
- */
2158
- async function setWindowEnabled(windowId, title, enabled) {
2159
- clearRequestedAccessWindow();
2160
- const access = {
2161
- windowId,
2162
- title,
2163
- enabledAt: Date.now(),
2164
- };
2165
-
2166
- if (enabled) {
2167
- state.enabledWindow = access;
2168
- await chrome.storage.session.set({
2169
- [ENABLED_WINDOW_STORAGE_KEY]: access,
2170
- });
2171
- } else {
2172
- if (state.enabledWindow && state.enabledWindow.windowId === windowId) {
2173
- state.enabledWindow = null;
2174
- await chrome.storage.session.remove(ENABLED_WINDOW_STORAGE_KEY);
2175
- }
2176
- }
2177
-
2178
- try {
2179
- await refreshActionIndicators();
2180
- } catch {
2181
- /* Badge updates can fail for closed or restricted tabs. */
2182
- }
2183
- await emitUiState();
2184
-
2185
- if (enabled) {
2186
- sendAccessUpdate(true);
2187
- await chrome.alarms.create(KEEPALIVE_ALARM_NAME, { periodInMinutes: 0.4 });
2188
- await Promise.allSettled([
2189
- injectContentScriptsForWindow(access.windowId),
2190
- primeWindowConsoleCapture(access.windowId, true),
2191
- ]);
2192
- } else {
2193
- sendAccessUpdate(false);
2194
- try {
2195
- await chrome.alarms.clear(KEEPALIVE_ALARM_NAME);
2196
- await clearWindowBridgeState(windowId);
2197
- } catch (error) {
2198
- reportAsyncError(error);
2199
- }
2200
- }
2201
- }
2202
-
2203
- /**
2204
- * React to tab navigation/title changes so popup and side panel state stays in
2205
- * sync with the tab the user is currently looking at.
2206
- *
2207
- * @param {number} tabId
2208
- * @param {TabChangeInfo} changeInfo
2209
- * @param {chrome.tabs.Tab} tab
2210
- * @returns {Promise<void>}
2211
- */
2212
- async function handleTabUpdated(tabId, changeInfo, tab) {
2213
- if (
2214
- typeof changeInfo.title === 'string' &&
2215
- state.enabledWindow &&
2216
- tab.windowId === state.enabledWindow.windowId
2217
- ) {
2218
- state.enabledWindow = {
2219
- ...state.enabledWindow,
2220
- title: changeInfo.title,
2221
- };
2222
- await chrome.storage.session.set({
2223
- [ENABLED_WINDOW_STORAGE_KEY]: state.enabledWindow,
2224
- });
2225
- }
2226
-
2227
- if (
2228
- typeof changeInfo.url === 'string' ||
2229
- typeof changeInfo.title === 'string' ||
2230
- changeInfo.status === 'complete'
2231
- ) {
2232
- if (
2233
- changeInfo.status === 'complete' &&
2234
- state.enabledWindow &&
2235
- tab.windowId === state.enabledWindow.windowId
2236
- ) {
2237
- await primeTabConsoleCapture(tabId);
2238
- }
2239
- await updateActionIndicatorForTab(tabId);
2240
- await emitUiState();
2241
- }
2242
- }
2243
-
2244
- /**
2245
- * Remove any persisted enablement when the enabled window closes.
2246
- *
2247
- * @param {number} tabId
2248
- * @param {{ windowId: number, isWindowClosing: boolean }} removeInfo
2249
- * @returns {Promise<void>}
2250
- */
2251
- async function handleTabRemoved(tabId, removeInfo) {
2252
- if (
2253
- state.enabledWindow &&
2254
- removeInfo.isWindowClosing &&
2255
- removeInfo.windowId === state.enabledWindow.windowId
2256
- ) {
2257
- state.enabledWindow = null;
2258
- await chrome.storage.session.remove(ENABLED_WINDOW_STORAGE_KEY);
2259
- sendAccessUpdate(false);
2260
- }
2261
- if (removeInfo.isWindowClosing && removeInfo.windowId === state.requestedAccessWindowId) {
2262
- clearRequestedAccessWindow(removeInfo.windowId);
2263
- }
2264
- await updateActionIndicatorForTab(tabId);
2265
- await emitUiState();
2266
- }
2267
-
2268
- /**
2269
- * Refresh the extension action badge and title across the currently open tabs.
2270
- *
2271
- * @returns {Promise<void>}
2272
- */
2273
- async function refreshActionIndicators() {
2274
- const query = state.enabledWindow ? { windowId: state.enabledWindow.windowId } : {};
2275
- const tabs = await chrome.tabs.query(query);
2276
- const tabIds = tabs
2277
- .map((tab) => (isNumber(tab.id) ? tab.id : null))
2278
- .filter((tabId) => tabId !== null);
2279
- await Promise.allSettled(tabIds.map((tabId) => updateActionIndicatorForTab(tabId)));
2280
-
2281
- // Some Chromium-based browsers (e.g. Edge) do not visually refresh the toolbar
2282
- // badge after per-tab updates until the tab navigates. Setting the global badge
2283
- // (without tabId) to match the active tab forces an immediate repaint.
2284
- await syncGlobalBadgeToActiveTab();
2285
- }
2286
-
2287
- /**
2288
- * Set the global badge (no tabId) to match the active tab in the last-focused
2289
- * window. This forces browsers that batch per-tab badge updates (e.g. Edge) to
2290
- * immediately repaint the toolbar icon.
2291
- *
2292
- * @returns {Promise<void>}
2293
- */
2294
- async function syncGlobalBadgeToActiveTab() {
2295
- try {
2296
- const [activeTab] = await chrome.tabs.query({
2297
- active: true,
2298
- lastFocusedWindow: true,
2299
- });
2300
- if (!activeTab?.id) return;
2301
- const enabled = await isTabEnabled(activeTab.id);
2302
- const accessRequested = !enabled && (await isAccessRequestedTab(activeTab.id));
2303
- const restricted = enabled && isRestrictedAutomationUrl(activeTab.url ?? '');
2304
- const text = enabled
2305
- ? restricted
2306
- ? RESTRICTED_BADGE_TEXT
2307
- : ENABLED_BADGE_TEXT
2308
- : accessRequested
2309
- ? ACCESS_REQUEST_BADGE_TEXT
2310
- : '';
2311
- const bgColor = enabled
2312
- ? restricted
2313
- ? '#e07020'
2314
- : '#787878'
2315
- : accessRequested
2316
- ? '#f2cf2f'
2317
- : '#464646';
2318
- const textColor = enabled ? '#ffffff' : accessRequested ? '#000000' : '#ffffff';
2319
- await chrome.action.setBadgeText({ text });
2320
- try {
2321
- await chrome.action.setBadgeBackgroundColor({ color: bgColor });
2322
- } catch {
2323
- /* unsupported */
2324
- }
2325
- try {
2326
- await chrome.action.setBadgeTextColor({ color: textColor });
2327
- } catch {
2328
- /* unsupported */
2329
- }
2330
- } catch {
2331
- /* non-critical */
2332
- }
2333
- }
2334
-
2335
- /**
2336
- * Update the action badge and title for one tab so enabled windows are visibly
2337
- * marked from the Chrome toolbar.
2338
- *
2339
- * @param {number} tabId
2340
- * @returns {Promise<void>}
2341
- */
2342
- async function updateActionIndicatorForTab(tabId) {
2343
- const enabled = await isTabEnabled(tabId);
2344
- const accessRequested = !enabled && (await isAccessRequestedTab(tabId));
2345
- let restricted = false;
2346
- if (enabled) {
2347
- try {
2348
- const tab = await chrome.tabs.get(tabId);
2349
- restricted = isRestrictedAutomationUrl(tab.url ?? '');
2350
- } catch {
2351
- /* ignore */
2352
- }
2353
- }
2354
- const badgeText = enabled
2355
- ? restricted
2356
- ? RESTRICTED_BADGE_TEXT
2357
- : ENABLED_BADGE_TEXT
2358
- : accessRequested
2359
- ? ACCESS_REQUEST_BADGE_TEXT
2360
- : '';
2361
- const bgColor = enabled
2362
- ? restricted
2363
- ? '#e07020'
2364
- : '#787878'
2365
- : accessRequested
2366
- ? '#f2cf2f'
2367
- : '#464646';
2368
- const textColor = enabled ? '#ffffff' : accessRequested ? '#000000' : '#ffffff';
2369
- try {
2370
- await chrome.action.setBadgeBackgroundColor({ tabId, color: bgColor });
2371
- } catch {
2372
- /* color APIs may be unsupported */
2373
- }
2374
- try {
2375
- await chrome.action.setBadgeTextColor({ tabId, color: textColor });
2376
- } catch {
2377
- /* setBadgeTextColor not supported everywhere */
2378
- }
2379
- try {
2380
- if (enabled && restricted) {
2381
- await chrome.action.setTitle({
2382
- tabId,
2383
- title: 'Browser Bridge is enabled, but this page cannot be interacted with.',
2384
- });
2385
- } else if (enabled) {
2386
- await chrome.action.setTitle({
2387
- tabId,
2388
- title: 'Browser Bridge is enabled for this window.',
2389
- });
2390
- } else if (accessRequested) {
2391
- await chrome.action.setTitle({
2392
- tabId,
2393
- title:
2394
- 'Agent requested Browser Bridge access for this window. Click to open Browser Bridge, then click Enable.',
2395
- });
2396
- } else {
2397
- await chrome.action.setTitle({ tabId, title: 'Browser Bridge' });
2398
- }
2399
- } catch {
2400
- /* title can fail for closed tabs */
2401
- }
2402
- try {
2403
- await chrome.action.setBadgeText({ tabId, text: badgeText });
2404
- } catch (error) {
2405
- if (normalizeRuntimeErrorMessage(getErrorMessage(error)) === ERROR_CODES.TAB_MISMATCH) {
2406
- return;
2407
- }
2408
- throw error;
2409
- }
2410
- }
2411
-
2412
- /**
2413
- * Check whether the user explicitly enabled bridge communication for a given
2414
- * window.
2415
- *
2416
- * @param {number} windowId
2417
- * @returns {boolean}
2418
- */
2419
- function isWindowEnabled(windowId) {
2420
- return state.enabledWindow?.windowId === windowId;
2421
- }
2422
-
2423
- /**
2424
- * @param {number} windowId
2425
- * @returns {boolean}
2426
- */
2427
- function isAccessRequestedWindow(windowId) {
2428
- return state.requestedAccessWindowId === windowId;
2429
- }
2430
-
2431
- /**
2432
- * @param {number} tabId
2433
- * @returns {Promise<boolean>}
2434
- */
2435
- async function isAccessRequestedTab(tabId) {
2436
- try {
2437
- const tab = await chrome.tabs.get(tabId);
2438
- return typeof tab.windowId === 'number' && isAccessRequestedWindow(tab.windowId);
2439
- } catch {
2440
- return false;
2441
- }
2442
- }
2443
-
2444
- /**
2445
- * @param {number | null} [windowId=null]
2446
- * @returns {void}
2447
- */
2448
- function clearRequestedAccessWindow(windowId = null) {
2449
- if (windowId == null || state.requestedAccessWindowId === windowId) {
2450
- state.requestedAccessWindowId = null;
2451
- }
2452
- }
2453
-
2454
- /**
2455
- * @param {number | null} [windowId=null]
2456
- * @returns {void}
2457
- */
2458
- function clearRequestedAccessPopupWindow(windowId = null) {
2459
- if (windowId == null || state.requestedAccessPopupWindowId === windowId) {
2460
- state.requestedAccessPopupWindowId = null;
2461
- }
2462
- }
2463
-
2464
- /**
2465
- * Surface an enable cue in the extension UI when an agent-side request fails
2466
- * because Browser Bridge is off for the target window.
2467
- *
2468
- * @param {BridgeRequest} request
2469
- * @returns {Promise<void>}
2470
- */
2471
- async function requestEnableFromAgentSide(request) {
2472
- const target = await resolveRequestedAccessTarget(request);
2473
- if (!target) {
2474
- return;
2475
- }
2476
-
2477
- if (state.requestedAccessWindowId != null) {
2478
- return;
2479
- }
2480
-
2481
- state.requestedAccessWindowId = target.windowId;
2482
- await refreshActionIndicators();
2483
- await emitUiState();
2484
- await openRequestedAccessUi(target);
2485
- }
2486
-
2487
- /**
2488
- * @param {ResolvedTabTarget} target
2489
- * @returns {{ allowed: true } | { allowed: false, message: string }}
2490
- */
2491
- function checkAccessRequestAvailability(target) {
2492
- if (state.requestedAccessWindowId == null) {
2493
- return { allowed: true };
2494
- }
2495
-
2496
- if (state.requestedAccessWindowId === target.windowId) {
2497
- return {
2498
- allowed: false,
2499
- message:
2500
- 'Browser Bridge access is already pending for this window. Ask the user to click Enable before requesting access again.',
2501
- };
2502
- }
2503
-
2504
- return {
2505
- allowed: false,
2506
- message:
2507
- 'Browser Bridge access is already pending for another window. Ask the user to click Enable for that window before requesting access again.',
2508
- };
2509
- }
2510
-
2511
- /**
2512
- * Handle an explicit access.request call. Resolves the active tab in the
2513
- * last-focused window, surfaces the Enable cue in the extension UI, and
2514
- * returns the requested window/tab metadata.
2515
- *
2516
- * @param {BridgeRequest} request
2517
- * @returns {Promise<BridgeResponse>}
2518
- */
2519
- async function handleAccessRequest(request) {
2520
- const target = await resolveRequestedAccessTarget(request);
2521
-
2522
- if (state.enabledWindow) {
2523
- const access = await getAccessStatus();
2524
- return createSuccess(
2525
- request.id,
2526
- {
2527
- enabled: true,
2528
- access,
2529
- },
2530
- { method: request.method }
2531
- );
2532
- }
2533
-
2534
- if (!target) {
2535
- return createFailure(
2536
- request.id,
2537
- ERROR_CODES.ACCESS_DENIED,
2538
- 'No scriptable tab found in the focused window.',
2539
- null,
2540
- { method: request.method }
2541
- );
2542
- }
2543
-
2544
- const availability = checkAccessRequestAvailability(target);
2545
- if (!availability.allowed) {
2546
- return createFailure(
2547
- request.id,
2548
- ERROR_CODES.ACCESS_DENIED,
2549
- availability.message,
2550
- {
2551
- requestedWindowId: state.requestedAccessWindowId,
2552
- requestedTargetWindowId: target.windowId,
2553
- requestedTargetTabId: target.tabId,
2554
- },
2555
- { method: request.method }
2556
- );
2557
- }
2558
-
2559
- state.requestedAccessWindowId = target.windowId;
2560
- await refreshActionIndicators();
2561
- await emitUiState();
2562
- await openRequestedAccessUi(target);
2563
-
2564
- return createSuccess(
2565
- request.id,
2566
- {
2567
- enabled: false,
2568
- requested: true,
2569
- windowId: target.windowId,
2570
- tabId: target.tabId,
2571
- title: target.title,
2572
- url: target.url,
2573
- },
2574
- { method: request.method }
2575
- );
2576
- }
2577
-
2578
- /**
2579
- * @param {BridgeRequest} request
2580
- * @returns {Promise<ResolvedTabTarget | null>}
2581
- */
2582
- async function resolveRequestedAccessTarget(request) {
2583
- if (typeof request.tab_id === 'number' && request.tab_id > 0) {
2584
- try {
2585
- const tab = await chrome.tabs.get(request.tab_id);
2586
- return normalizeRequestedAccessTab(tab);
2587
- } catch {
2588
- return null;
2589
- }
2590
- }
2591
-
2592
- const tabs = await chrome.tabs.query({
2593
- active: true,
2594
- lastFocusedWindow: true,
2595
- });
2596
- return normalizeRequestedAccessTab(tabs[0] ?? null);
2597
- }
2598
-
2599
- /**
2600
- * Open Browser Bridge UI for an agent-side access request. If the side panel
2601
- * is already open for that window, leave it in place so its existing attention
2602
- * state continues to guide the user. Otherwise open one controlled popup
2603
- * window so multiple browser windows cannot splash duplicate prompts.
2604
- *
2605
- * @param {ResolvedTabTarget} target
2606
- * @returns {Promise<void>}
2607
- */
2608
- async function openRequestedAccessUi(target) {
2609
- if (await isSidePanelOpenForWindow(target.windowId)) {
2610
- return;
2611
- }
2612
-
2613
- try {
2614
- await openRequestedAccessPopupWindow(target);
2615
- } catch (error) {
2616
- console.warn('Could not open Browser Bridge popup window for access request.', error);
2617
- }
2618
- }
2619
-
2620
- /**
2621
- * Open the popup UI in its own small extension window, scoped to the requested
2622
- * tab. Reuse the same popup window while access remains pending so only one
2623
- * visible prompt exists across browser windows.
2624
- *
2625
- * @param {ResolvedTabTarget} target
2626
- * @returns {Promise<void>}
2627
- */
2628
- async function openRequestedAccessPopupWindow(target) {
2629
- const popupUrl = chrome.runtime.getURL(
2630
- `${POPUP_PATH}?tabId=${encodeURIComponent(String(target.tabId))}&windowed=1`
2631
- );
2632
- const popupWidth = 420;
2633
- const popupHeight = 320;
2634
- const popupPlacement = await getRequestedAccessPopupPlacement(target.windowId, popupWidth);
2635
-
2636
- if (state.requestedAccessPopupWindowId != null) {
2637
- try {
2638
- const existingWindow = await chrome.windows.get(state.requestedAccessPopupWindowId, {
2639
- populate: true,
2640
- });
2641
- const existingWindowId = typeof existingWindow.id === 'number' ? existingWindow.id : null;
2642
- const popupTabId = existingWindow.tabs?.find((tab) => typeof tab.id === 'number')?.id ?? null;
2643
- if (existingWindowId == null || popupTabId == null) {
2644
- throw new Error('Requested access popup window is missing its tab.');
2645
- }
2646
- await chrome.tabs.update(popupTabId, { url: popupUrl });
2647
- await chrome.windows.update(existingWindowId, {
2648
- focused: true,
2649
- ...(popupPlacement ?? {}),
2650
- });
2651
- return;
2652
- } catch {
2653
- clearRequestedAccessPopupWindow();
2654
- }
2655
- }
2656
-
2657
- let createData = /** @type {chrome.windows.CreateData} */ ({
2658
- url: popupUrl,
2659
- type: 'popup',
2660
- focused: true,
2661
- width: popupWidth,
2662
- height: popupHeight,
2663
- });
2664
-
2665
- if (popupPlacement) {
2666
- createData = {
2667
- ...createData,
2668
- ...popupPlacement,
2669
- };
2670
- }
2671
-
2672
- const popupWindow = await chrome.windows.create(createData);
2673
- state.requestedAccessPopupWindowId = typeof popupWindow?.id === 'number' ? popupWindow.id : null;
2674
- }
2675
-
2676
- /**
2677
- * @param {number} targetWindowId
2678
- * @param {number} popupWidth
2679
- * @returns {Promise<Pick<chrome.windows.UpdateInfo, 'left' | 'top'> | null>}
2680
- */
2681
- async function getRequestedAccessPopupPlacement(targetWindowId, popupWidth) {
2682
- try {
2683
- const browserWindow = await chrome.windows.get(targetWindowId);
2684
- if (
2685
- typeof browserWindow.left === 'number' &&
2686
- typeof browserWindow.top === 'number' &&
2687
- typeof browserWindow.width === 'number'
2688
- ) {
2689
- return {
2690
- left: browserWindow.left + Math.max(24, browserWindow.width - popupWidth - 40),
2691
- top: browserWindow.top + 72,
2692
- };
2693
- }
2694
- } catch {
2695
- // Ignore window positioning failures and fall back to Chrome defaults.
2696
- }
2697
-
2698
- return null;
2699
- }
2700
-
2701
- /**
2702
- * @param {number} windowId
2703
- * @returns {Promise<boolean>}
2704
- */
2705
- async function isSidePanelOpenForWindow(windowId) {
2706
- for (const portState of state.uiPorts.values()) {
2707
- if (portState.surface !== 'sidepanel') {
2708
- continue;
2709
- }
2710
- const currentTab = portState.scopeTabId
2711
- ? await getTabState(portState.scopeTabId)
2712
- : await getCurrentTabState();
2713
- if (currentTab?.windowId === windowId) {
2714
- return true;
2715
- }
2716
- }
2717
- return false;
2718
- }
2719
-
2720
- /**
2721
- * @param {string} portName
2722
- * @returns {'popup' | 'sidepanel' | null}
2723
- */
2724
- function getUiSurfaceFromPortName(portName) {
2725
- if (portName === 'ui-popup') {
2726
- return 'popup';
2727
- }
2728
- if (portName === 'ui-sidepanel') {
2729
- return 'sidepanel';
2730
- }
2731
- if (portName === 'ui') {
2732
- return 'popup';
2733
- }
2734
- return null;
2735
- }
2736
-
2737
- /**
2738
- * Resolve the scope context for a bridge request so the action log can show
2739
- * where an operation happened even if that operation later revokes the session.
2740
- *
2741
- * @param {BridgeRequest} request
2742
- * @returns {Promise<{ tabId: number | null, url: string } | null>}
2743
- */
2744
- async function getActionContext(request) {
2745
- try {
2746
- if (request.method === 'tabs.close') {
2747
- const params = normalizeTabCloseParams(request.params);
2748
- const tab = await chrome.tabs.get(params.tabId);
2749
- return {
2750
- tabId: params.tabId,
2751
- url: tab.url ?? '',
2752
- };
2753
- }
2754
- if (!bridgeMethodNeedsTab(request.method)) {
2755
- return null;
2756
- }
2757
- const tab = await resolveRequestTarget(request, {
2758
- requireScriptable: request.method !== 'tabs.create',
2759
- });
2760
- return {
2761
- tabId: tab.tabId,
2762
- url: tab.url,
2763
- };
2764
- } catch {
2765
- return null;
2766
- }
2767
- }
2768
-
2769
- /**
2770
- * Append one operator-facing action log entry and persist the bounded history.
2771
- *
2772
- * @param {BridgeRequest} request
2773
- * @param {BridgeResponse} response
2774
- * @param {{ tabId: number | null, url: string } | null} actionContext
2775
- * @returns {Promise<void>}
2776
- */
2777
- async function logBridgeAction(request, response, actionContext) {
2778
- if (!shouldLogAction(request.method)) {
2779
- return;
2780
- }
2781
-
2782
- const diagnostics = getResponseDiagnostics(request.method, response);
2783
- const summaryPayload = summarizeBridgeResponse(response, request.method);
2784
- const summaryCost = estimateJsonPayloadCost(summaryPayload);
2785
-
2786
- await appendActionLogEntry({
2787
- method: request.method,
2788
- source: normalizeActionLogSource(request.meta?.source),
2789
- tabId: actionContext?.tabId ?? null,
2790
- url: actionContext?.url ?? '',
2791
- ok: response.ok,
2792
- summary: summarizeActionResult(response),
2793
- responseBytes: diagnostics.responseBytes,
2794
- approxTokens: diagnostics.textApproxTokens,
2795
- imageApproxTokens: diagnostics.imageApproxTokens,
2796
- costClass: diagnostics.costClass,
2797
- imageBytes: diagnostics.imageBytes,
2798
- summaryBytes: summaryCost.bytes,
2799
- summaryTokens: summaryCost.approxTokens,
2800
- summaryCostClass: summaryCost.costClass,
2801
- debuggerBacked: diagnostics.debuggerBacked,
2802
- overBudget: response.meta?.budget_truncated === true,
2803
- hasScreenshot: diagnostics.hasScreenshot,
2804
- nodeCount: diagnostics.nodeCount,
2805
- continuationHint:
2806
- typeof response.meta?.continuation_hint === 'string' ? response.meta.continuation_hint : null,
2807
- });
2808
- await emitUiState();
2809
- }
2810
-
2811
- /**
2812
- * Append one action log entry and persist the bounded history.
2813
- *
2814
- * @param {{
2815
- * method: string,
2816
- * source?: string,
2817
- * tabId?: number | null,
2818
- * url?: string,
2819
- * ok: boolean,
2820
- * summary: string,
2821
- * responseBytes?: number,
2822
- * approxTokens?: number,
2823
- * imageApproxTokens?: number,
2824
- * costClass?: 'cheap' | 'moderate' | 'heavy' | 'extreme',
2825
- * imageBytes?: number,
2826
- * summaryBytes?: number,
2827
- * summaryTokens?: number,
2828
- * summaryCostClass?: 'cheap' | 'moderate' | 'heavy' | 'extreme',
2829
- * debuggerBacked?: boolean,
2830
- * overBudget?: boolean,
2831
- * hasScreenshot?: boolean,
2832
- * nodeCount?: number | null,
2833
- * continuationHint?: string | null
2834
- * }} entry
2835
- * @returns {Promise<void>}
2836
- */
2837
- async function appendActionLogEntry(entry) {
2838
- state.actionLog.push({
2839
- id: crypto.randomUUID(),
2840
- at: Date.now(),
2841
- method: entry.method,
2842
- source: normalizeActionLogSource(entry.source),
2843
- tabId: entry.tabId ?? null,
2844
- url: entry.url ?? '',
2845
- ok: entry.ok,
2846
- summary: entry.summary,
2847
- responseBytes: entry.responseBytes ?? 0,
2848
- approxTokens: entry.approxTokens ?? 0,
2849
- imageApproxTokens: entry.imageApproxTokens ?? 0,
2850
- costClass: entry.costClass ?? 'cheap',
2851
- imageBytes: entry.imageBytes ?? 0,
2852
- summaryBytes: entry.summaryBytes ?? 0,
2853
- summaryTokens: entry.summaryTokens ?? 0,
2854
- summaryCostClass: entry.summaryCostClass ?? 'cheap',
2855
- debuggerBacked: entry.debuggerBacked === true,
2856
- overBudget: entry.overBudget === true,
2857
- hasScreenshot: entry.hasScreenshot ?? false,
2858
- nodeCount: entry.nodeCount ?? null,
2859
- continuationHint: entry.continuationHint ?? null,
2860
- });
2861
- while (state.actionLog.length > MAX_ACTION_LOG_ENTRIES) {
2862
- state.actionLog.shift();
2863
- }
2864
-
2865
- await chrome.storage.session.set({
2866
- [ACTION_LOG_STORAGE_KEY]: state.actionLog,
2867
- });
2868
- }
2869
-
2870
- /**
2871
- * @param {unknown} source
2872
- * @returns {string}
2873
- */
2874
- function normalizeActionLogSource(source) {
2875
- return source === 'cli' || source === 'mcp' ? source : '';
2876
- }
2877
-
2878
- /**
2879
- * @param {unknown} entry
2880
- * @returns {ActionLogEntry | null}
2881
- */
2882
- function normalizeActionLogEntry(entry) {
2883
- if (!entry || typeof entry !== 'object') {
2884
- return null;
2885
- }
2886
-
2887
- const candidate = /** @type {Record<string, unknown>} */ (entry);
2888
- if (typeof candidate.id !== 'string' || typeof candidate.method !== 'string') {
2889
- return null;
2890
- }
2891
-
2892
- return {
2893
- id: candidate.id,
2894
- at: Number(candidate.at) || 0,
2895
- method: candidate.method,
2896
- source: normalizeActionLogSource(candidate.source),
2897
- tabId: typeof candidate.tabId === 'number' ? candidate.tabId : null,
2898
- url: typeof candidate.url === 'string' ? candidate.url : '',
2899
- ok: candidate.ok === true,
2900
- summary: typeof candidate.summary === 'string' ? candidate.summary : '',
2901
- responseBytes: Number(candidate.responseBytes) || 0,
2902
- approxTokens: Number(candidate.approxTokens) || 0,
2903
- imageApproxTokens: Number(candidate.imageApproxTokens) || 0,
2904
- costClass:
2905
- candidate.costClass === 'moderate' ||
2906
- candidate.costClass === 'heavy' ||
2907
- candidate.costClass === 'extreme'
2908
- ? candidate.costClass
2909
- : 'cheap',
2910
- imageBytes: Number(candidate.imageBytes) || 0,
2911
- summaryBytes: Number(candidate.summaryBytes) || 0,
2912
- summaryTokens: Number(candidate.summaryTokens) || 0,
2913
- summaryCostClass:
2914
- candidate.summaryCostClass === 'moderate' ||
2915
- candidate.summaryCostClass === 'heavy' ||
2916
- candidate.summaryCostClass === 'extreme'
2917
- ? candidate.summaryCostClass
2918
- : 'cheap',
2919
- debuggerBacked: candidate.debuggerBacked === true,
2920
- overBudget: candidate.overBudget === true,
2921
- hasScreenshot: candidate.hasScreenshot === true,
2922
- nodeCount: typeof candidate.nodeCount === 'number' ? candidate.nodeCount : null,
2923
- continuationHint:
2924
- typeof candidate.continuationHint === 'string' ? candidate.continuationHint : null,
2925
- };
2926
- }
2927
-
2928
- /**
2929
- * Map thrown runtime errors to structured bridge failures.
2930
- *
2931
- * @param {BridgeRequest} request
2932
- * @param {unknown} error
2933
- * @returns {BridgeResponse}
2934
- */
2935
- function toFailureResponse(request, error) {
2936
- const message = normalizeRuntimeErrorMessage(getErrorMessage(error));
2937
- const knownErrorCodes = /** @type {string[]} */ (Object.values(ERROR_CODES));
2938
- /** @type {ErrorCode} */
2939
- const code = knownErrorCodes.includes(message)
2940
- ? /** @type {ErrorCode} */ (message)
2941
- : message === 'Element reference is stale.'
2942
- ? ERROR_CODES.ELEMENT_STALE
2943
- : ERROR_CODES.INTERNAL_ERROR;
2944
-
2945
- return createFailure(request.id, code, message, null, {
2946
- method: request.method,
2947
- });
2948
- }
2949
-
2950
- /**
2951
- * Apply token-budget truncation and attach cost/debugger metadata for the
2952
- * response that will be sent back to the agent.
2953
- *
2954
- * @param {BridgeRequest} request
2955
- * @param {BridgeResponse} response
2956
- * @returns {BridgeResponse}
2957
- */
2958
- function enrichBridgeResponse(request, response) {
2959
- const budgetedResponse = enforceTokenBudget(request.method, response, request.meta?.token_budget);
2960
- const diagnostics = getResponseDiagnostics(request.method, budgetedResponse);
2961
- return {
2962
- ...budgetedResponse,
2963
- meta: {
2964
- ...budgetedResponse.meta,
2965
- transport_bytes: diagnostics.responseBytes,
2966
- transport_approx_tokens: diagnostics.approxTokens,
2967
- transport_cost_class: diagnostics.costClass,
2968
- text_bytes: diagnostics.textBytes,
2969
- text_approx_tokens: diagnostics.textApproxTokens,
2970
- text_cost_class: diagnostics.textCostClass,
2971
- image_approx_tokens: diagnostics.imageApproxTokens,
2972
- image_bytes: diagnostics.imageBytes,
2973
- response_bytes: diagnostics.responseBytes,
2974
- approx_tokens: diagnostics.approxTokens,
2975
- cost_class: diagnostics.costClass,
2976
- debugger_backed: diagnostics.debuggerBacked,
2977
- },
2978
- };
2979
- }
2980
-
2981
- /**
2982
- * Forward a response to the connected native host if it is present.
2983
- *
2984
- * @param {BridgeResponse} response
2985
- * @returns {void}
2986
- */
2987
- function reply(response) {
2988
- state.nativePort?.postMessage(response);
2989
- }
2990
-
2991
- /**
2992
- * Broadcast a UI event to all connected extension surfaces.
2993
- *
2994
- * @param {Record<string, unknown>} message
2995
- * @returns {void}
2996
- */
2997
- function broadcastUi(message) {
2998
- for (const port of state.uiPorts.keys()) {
2999
- postToUiPort(port, message);
3000
- }
3001
- }
3002
-
3003
- /**
3004
- * Post a message to a UI surface, pruning the port if Chrome has already
3005
- * disconnected it.
3006
- *
3007
- * @param {chrome.runtime.Port} port
3008
- * @param {Record<string, unknown>} message
3009
- * @returns {boolean}
3010
- */
3011
- function postToUiPort(port, message) {
3012
- try {
3013
- port.postMessage(message);
3014
- return true;
3015
- } catch {
3016
- state.uiPorts.delete(port);
3017
- return false;
3018
- }
3019
- }
3020
-
3021
- /**
3022
- * Publish the current connection/session snapshot to the popup and side panel.
3023
- *
3024
- * @returns {Promise<void>}
3025
- */
3026
- async function emitUiState() {
3027
- await Promise.all([...state.uiPorts.keys()].map((port) => emitUiStateForPort(port)));
3028
- }
3029
-
3030
- /**
3031
- * Publish the current connection and tab snapshot to one UI surface.
3032
- *
3033
- * @param {chrome.runtime.Port} port
3034
- * @returns {Promise<void>}
3035
- */
3036
- async function emitUiStateForPort(port) {
3037
- const portState = state.uiPorts.get(port);
3038
- if (!portState) {
3039
- return;
3040
- }
3041
-
3042
- refreshSetupStatus();
3043
-
3044
- const currentTab = portState.scopeTabId
3045
- ? await getTabState(portState.scopeTabId)
3046
- : await getCurrentTabState();
3047
- const scopedTabId = currentTab?.tabId ?? portState.scopeTabId ?? null;
3048
-
3049
- postToUiPort(port, {
3050
- type: 'state.sync',
3051
- state: {
3052
- nativeConnected: Boolean(state.nativePort),
3053
- currentTab,
3054
- setupStatus: state.setupStatus,
3055
- setupStatusPending: state.setupStatusPending,
3056
- setupStatusError: state.setupStatusError,
3057
- setupInstallPendingKey: state.setupInstallPendingKey,
3058
- setupInstallError: state.setupInstallError,
3059
- actionLog: [...state.actionLog]
3060
- .filter((entry) => scopedTabId == null || entry.tabId === scopedTabId)
3061
- .reverse(),
3062
- },
3063
- });
3064
- }
3065
-
3066
- /**
3067
- * @param {unknown} message
3068
- * @returns {boolean}
3069
- */
3070
- function handleHostStatusMessage(message) {
3071
- if (!message || typeof message !== 'object') {
3072
- return false;
3073
- }
3074
-
3075
- const candidate = /** @type {Record<string, unknown>} */ (message);
3076
- if (candidate.type === 'host.bridge_response') {
3077
- const response =
3078
- candidate.response && typeof candidate.response === 'object'
3079
- ? /** @type {BridgeResponse} */ (candidate.response)
3080
- : null;
3081
- if (response?.id === state.setupInstallPendingRequestId) {
3082
- const action = state.setupInstallPendingAction;
3083
- state.setupInstallPendingRequestId = null;
3084
- state.setupInstallPendingAction = null;
3085
- if (response.ok) {
3086
- state.setupInstallError = null;
3087
- if (action) {
3088
- void appendActionLogEntry({
3089
- method: getSetupActionMethodLabel(action),
3090
- ok: true,
3091
- summary: getSetupActionSuccessSummary(action),
3092
- }).catch(reportAsyncError);
3093
- }
3094
- refreshSetupStatus(true);
3095
- } else {
3096
- state.setupInstallError = response.error.message;
3097
- if (action) {
3098
- void appendActionLogEntry({
3099
- method: getSetupActionMethodLabel(action),
3100
- ok: false,
3101
- summary: getSetupActionErrorSummary(action, response.error.message),
3102
- }).catch(reportAsyncError);
3103
- }
3104
- state.setupInstallPendingKey = null;
3105
- }
3106
- void emitUiState().catch(reportAsyncError);
3107
- return true;
3108
- }
3109
- if (response?.id === state.setupStatusPendingRequestId) {
3110
- clearSetupStatusTimer();
3111
- state.setupStatusPending = false;
3112
- state.setupStatusPendingRequestId = null;
3113
- state.setupInstallPendingKey = null;
3114
- if (response.ok) {
3115
- state.setupStatus = isSetupStatus(response.result) ? response.result : null;
3116
- state.setupStatusUpdatedAt = Date.now();
3117
- state.setupStatusError = null;
3118
- } else {
3119
- state.setupStatusError = response.error.message;
3120
- }
3121
- void emitUiState().catch(reportAsyncError);
3122
- }
3123
- return true;
3124
- }
3125
-
3126
- if (candidate.type === 'host.bridge_error') {
3127
- if (candidate.requestId === state.setupInstallPendingRequestId) {
3128
- const action = state.setupInstallPendingAction;
3129
- state.setupInstallPendingRequestId = null;
3130
- state.setupInstallPendingAction = null;
3131
- state.setupInstallPendingKey = null;
3132
- state.setupInstallError =
3133
- typeof candidate.error === 'object' &&
3134
- candidate.error &&
3135
- typeof (/** @type {Record<string, unknown>} */ (candidate.error).message) === 'string'
3136
- ? /** @type {Record<string, string>} */ (candidate.error).message
3137
- : 'Could not install host setup.';
3138
- if (action) {
3139
- void appendActionLogEntry({
3140
- method: getSetupActionMethodLabel(action),
3141
- ok: false,
3142
- summary: getSetupActionErrorSummary(action, state.setupInstallError),
3143
- }).catch(reportAsyncError);
3144
- }
3145
- void emitUiState().catch(reportAsyncError);
3146
- return true;
3147
- }
3148
- if (candidate.requestId === state.setupStatusPendingRequestId) {
3149
- clearSetupStatusTimer();
3150
- state.setupStatusPending = false;
3151
- state.setupStatusPendingRequestId = null;
3152
- state.setupInstallPendingAction = null;
3153
- state.setupInstallPendingKey = null;
3154
- state.setupStatusError =
3155
- typeof candidate.error === 'object' &&
3156
- candidate.error &&
3157
- typeof (/** @type {Record<string, unknown>} */ (candidate.error).message) === 'string'
3158
- ? /** @type {Record<string, string>} */ (candidate.error).message
3159
- : 'Could not inspect host setup.';
3160
- void emitUiState().catch(reportAsyncError);
3161
- }
3162
- return true;
3163
- }
3164
-
3165
- if (candidate.type === 'host.setup_status.response') {
3166
- if (candidate.requestId === state.setupStatusPendingRequestId) {
3167
- clearSetupStatusTimer();
3168
- state.setupStatus = isSetupStatus(candidate.status) ? candidate.status : null;
3169
- state.setupStatusPending = false;
3170
- state.setupStatusPendingRequestId = null;
3171
- state.setupInstallPendingAction = null;
3172
- state.setupInstallPendingKey = null;
3173
- state.setupStatusUpdatedAt = Date.now();
3174
- state.setupStatusError = null;
3175
- void emitUiState().catch(reportAsyncError);
3176
- }
3177
- return true;
3178
- }
3179
-
3180
- if (candidate.type === 'host.setup_status.error') {
3181
- if (candidate.requestId === state.setupStatusPendingRequestId) {
3182
- clearSetupStatusTimer();
3183
- state.setupStatusPending = false;
3184
- state.setupStatusPendingRequestId = null;
3185
- state.setupInstallPendingAction = null;
3186
- state.setupInstallPendingKey = null;
3187
- state.setupStatusError =
3188
- typeof candidate.error === 'object' &&
3189
- candidate.error &&
3190
- typeof (/** @type {Record<string, unknown>} */ (candidate.error).message) === 'string'
3191
- ? /** @type {Record<string, string>} */ (candidate.error).message
3192
- : 'Could not inspect host setup.';
3193
- void emitUiState().catch(reportAsyncError);
3194
- }
3195
- return true;
3196
- }
3197
-
3198
- return false;
3199
- }
3200
-
3201
- /**
3202
- * @param {boolean} [force=false]
3203
- * @returns {void}
3204
- */
3205
- function refreshSetupStatus(force = false) {
3206
- if (!state.nativePort) {
3207
- clearSetupStatus();
3208
- return;
3209
- }
3210
-
3211
- const isFresh =
3212
- state.setupStatusUpdatedAt > 0 &&
3213
- Date.now() - state.setupStatusUpdatedAt < SETUP_STATUS_STALE_MS;
3214
- if (state.setupStatusPending || (!force && isFresh && !state.setupStatusError)) {
3215
- return;
3216
- }
3217
-
3218
- const requestId = crypto.randomUUID();
3219
- state.setupStatusPending = true;
3220
- state.setupStatusPendingRequestId = requestId;
3221
- state.setupStatusError = null;
3222
- clearSetupStatusTimer();
3223
- state.nativePort.postMessage({
3224
- type: 'host.bridge_request',
3225
- request: createRequest({
3226
- id: requestId,
3227
- method: 'setup.get_status',
3228
- }),
3229
- });
3230
- state.setupStatusTimeoutId = setTimeout(() => {
3231
- if (state.setupStatusPendingRequestId !== requestId) {
3232
- return;
3233
- }
3234
- state.setupStatusPending = false;
3235
- state.setupStatusPendingRequestId = null;
3236
- state.setupInstallPendingAction = null;
3237
- state.setupInstallPendingKey = null;
3238
- state.setupStatusError = 'Host setup request timed out.';
3239
- state.setupStatusTimeoutId = null;
3240
- void emitUiState().catch(reportAsyncError);
3241
- }, SETUP_STATUS_TIMEOUT_MS);
3242
- }
3243
-
3244
- /**
3245
- * @param {string | null} [errorMessage=null]
3246
- * @returns {void}
3247
- */
3248
- function clearSetupStatus(errorMessage = null) {
3249
- clearSetupStatusTimer();
3250
- state.setupStatus = null;
3251
- state.setupStatusPending = false;
3252
- state.setupStatusPendingRequestId = null;
3253
- state.setupStatusUpdatedAt = 0;
3254
- state.setupStatusError = errorMessage;
3255
- state.setupInstallPendingRequestId = null;
3256
- state.setupInstallPendingAction = null;
3257
- state.setupInstallPendingKey = null;
3258
- state.setupInstallError = null;
3259
- }
3260
-
3261
- /**
3262
- * @returns {void}
3263
- */
3264
- function clearSetupStatusTimer() {
3265
- if (!state.setupStatusTimeoutId) {
3266
- return;
3267
- }
3268
- clearTimeout(state.setupStatusTimeoutId);
3269
- state.setupStatusTimeoutId = null;
3270
- }
3271
-
3272
- /**
3273
- * @param {unknown} value
3274
- * @returns {value is SetupStatus}
3275
- */
3276
- function isSetupStatus(value) {
3277
- if (!value || typeof value !== 'object') {
3278
- return false;
3279
- }
3280
- const candidate = /** @type {Record<string, unknown>} */ (value);
3281
- return Array.isArray(candidate.mcpClients) && Array.isArray(candidate.skillTargets);
3282
- }
3283
-
3284
- /**
3285
- * Handle commands coming from the popup or side panel.
3286
- *
3287
- * @param {chrome.runtime.Port} port
3288
- * @param {Record<string, any>} message
3289
- * @returns {Promise<void>}
3290
- */
3291
- async function handleUiMessage(port, message) {
3292
- if (message?.type === 'state.request') {
3293
- const scopeTabId = Number(message.scopeTabId);
3294
- const currentPortState = state.uiPorts.get(port);
3295
- if (!currentPortState) {
3296
- return;
3297
- }
3298
- state.uiPorts.set(port, {
3299
- surface: currentPortState.surface,
3300
- scopeTabId: Number.isFinite(scopeTabId) && scopeTabId > 0 ? scopeTabId : null,
3301
- });
3302
- refreshSetupStatus();
3303
- await emitUiStateForPort(port);
3304
- return;
3305
- }
3306
-
3307
- if (message?.type === 'setup.status.refresh') {
3308
- refreshSetupStatus(true);
3309
- await emitUiStateForPort(port);
3310
- return;
3311
- }
3312
-
3313
- if (message?.type === 'scope.set_enabled') {
3314
- const requestedTabId = Number(message.tabId);
3315
- try {
3316
- // ── DEBUG: simulate slow/error toggles. Set to "delay", "error", or "" ──
3317
- const _TOGGLE_SIM = /** @type {'delay' | 'error' | ''} */ ('');
3318
- if (_TOGGLE_SIM === 'delay') {
3319
- await new Promise((r) => setTimeout(r, 6000));
3320
- } else if (_TOGGLE_SIM === 'error') {
3321
- if (Math.random() > 0.3) {
3322
- throw new Error('Something went wrong.');
3323
- }
3324
- }
3325
- // ── END DEBUG ──
3326
- if (Number.isFinite(requestedTabId) && requestedTabId > 0) {
3327
- const tabState = await getTabState(requestedTabId);
3328
- if (!tabState) {
3329
- throw new Error(ERROR_CODES.TAB_MISMATCH);
3330
- }
3331
- await setWindowEnabled(tabState.windowId, tabState.title, Boolean(message.enabled));
3332
- } else {
3333
- await setCurrentWindowEnabled(Boolean(message.enabled));
3334
- }
3335
- } catch (error) {
3336
- const errorMessage = error instanceof Error ? error.message : String(error);
3337
- try {
3338
- port.postMessage({ type: 'toggle.error', error: errorMessage });
3339
- } catch {
3340
- /* port may have disconnected */
3341
- }
3342
- throw error;
3343
- }
3344
- return;
3345
- }
3346
-
3347
- if (message?.type === 'setup.install') {
3348
- await handleSetupInstallAction(message);
3349
- }
3350
- }
3351
-
3352
- /**
3353
- * @param {Record<string, unknown>} message
3354
- * @returns {Promise<void>}
3355
- */
3356
- async function handleSetupInstallAction(message) {
3357
- if (!state.nativePort) {
3358
- state.setupInstallError = 'Native host is not connected.';
3359
- await appendActionLogEntry({
3360
- method: 'Host setup',
3361
- ok: false,
3362
- summary: 'Install failed: Native host is not connected.',
3363
- });
3364
- await emitUiState();
3365
- return;
3366
- }
3367
- if (state.setupInstallPendingRequestId) {
3368
- return;
3369
- }
3370
- const action = normalizeSetupInstallAction(message);
3371
- const requestId = crypto.randomUUID();
3372
- state.setupInstallPendingRequestId = requestId;
3373
- state.setupInstallPendingAction = action;
3374
- state.setupInstallPendingKey = getSetupInstallKey(action);
3375
- state.setupInstallError = null;
3376
- await appendActionLogEntry({
3377
- method: getSetupActionMethodLabel(action),
3378
- ok: true,
3379
- summary: getSetupActionStartSummary(action),
3380
- });
3381
- state.nativePort.postMessage({
3382
- type: 'host.bridge_request',
3383
- request: createRequest({
3384
- id: requestId,
3385
- method: 'setup.install',
3386
- params: action,
3387
- }),
3388
- });
3389
- await emitUiState();
3390
- }
3391
-
3392
- /**
3393
- * @param {Record<string, unknown>} message
3394
- * @returns {SetupInstallAction}
3395
- */
3396
- function normalizeSetupInstallAction(message) {
3397
- const action = message.action === 'uninstall' ? 'uninstall' : 'install';
3398
- const kind = message.kind === 'skill' ? 'skill' : message.kind === 'mcp' ? 'mcp' : null;
3399
- const target = typeof message.target === 'string' ? message.target.trim().toLowerCase() : '';
3400
- if (!kind || !target) {
3401
- throw new Error(ERROR_CODES.INVALID_REQUEST);
3402
- }
3403
- return { action, kind, target };
3404
- }
3405
-
3406
- /**
3407
- * @param {SetupInstallAction} action
3408
- * @returns {string}
3409
- */
3410
- function getSetupInstallKey(action) {
3411
- return `${action.kind}:${action.target}`;
3412
- }
3413
-
3414
- /**
3415
- * @param {SetupInstallAction} action
3416
- * @returns {string}
3417
- */
3418
- function getSetupActionMethodLabel(action) {
3419
- return action.kind === 'mcp' ? 'Host setup: MCP' : 'Host setup: Skills';
3420
- }
3421
-
3422
- /**
3423
- * @param {SetupInstallAction} action
3424
- * @returns {string}
3425
- */
3426
- function getSetupActionTargetLabel(action) {
3427
- return action.target;
3428
- }
3429
-
3430
- /**
3431
- * @param {SetupInstallAction} action
3432
- * @returns {string}
3433
- */
3434
- function getSetupActionStartSummary(action) {
3435
- const verb = action.action === 'uninstall' ? 'Removing' : 'Installing';
3436
- return `${verb} ${action.kind.toUpperCase()} for ${getSetupActionTargetLabel(action)}…`;
3437
- }
3438
-
3439
- /**
3440
- * @param {SetupInstallAction} action
3441
- * @returns {string}
3442
- */
3443
- function getSetupActionSuccessSummary(action) {
3444
- const verb = action.action === 'uninstall' ? 'Removed' : 'Installed';
3445
- return `${verb} ${action.kind.toUpperCase()} for ${getSetupActionTargetLabel(action)}.`;
3446
- }
3447
-
3448
- /**
3449
- * @param {SetupInstallAction} action
3450
- * @param {string} message
3451
- * @returns {string}
3452
- */
3453
- function getSetupActionErrorSummary(action, message) {
3454
- const verb = action.action === 'uninstall' ? 'Removal' : 'Install';
3455
- return `${verb} failed for ${action.kind.toUpperCase()} on ${getSetupActionTargetLabel(action)}: ${message}`;
3456
- }
3457
-
3458
- /**
3459
- * Configure and open the side panel for a single tab so the panel is attached
3460
- * to the current tab instead of acting like a window-global surface.
3461
- *
3462
- * @param {number} tabId
3463
- * @param {number} windowId
3464
- * @returns {Promise<void>}
3465
- */
3466
- async function openSidePanelForTab(tabId, windowId) {
3467
- await chrome.sidePanel.setOptions({
3468
- tabId,
3469
- path: `${SIDEPANEL_PATH}?tabId=${encodeURIComponent(String(tabId))}`,
3470
- enabled: true,
3471
- });
3472
- await chrome.sidePanel.open({
3473
- tabId,
3474
- windowId,
3475
- });
3476
- }
3477
-
3478
- /**
3479
- * Keep fire-and-forget async listener failures out of the browser's uncaught
3480
- * promise surface so extension errors stay actionable and structured.
3481
- *
3482
- * @param {unknown} error
3483
- * @returns {void}
3484
- */
3485
- function reportAsyncError(error) {
3486
- if (normalizeRuntimeErrorMessage(getErrorMessage(error)) === ERROR_CODES.TAB_MISMATCH) {
3487
- return;
3488
- }
3489
- console.error(error);
3490
- }