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