@flrande/bak-extension 0.3.8 → 0.6.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.
package/src/background.ts CHANGED
@@ -1,616 +1,1096 @@
1
- import type { ConsoleEntry, Locator } from '@flrande/bak-protocol';
1
+ import type {
2
+ ConsoleEntry,
3
+ DebugDumpSection,
4
+ Locator,
5
+ NetworkEntry,
6
+ PageExecutionScope,
7
+ PageFetchResponse,
8
+ PageFrameResult,
9
+ PageFreshnessResult
10
+ } from '@flrande/bak-protocol';
11
+ import {
12
+ clearNetworkEntries,
13
+ dropNetworkCapture,
14
+ ensureNetworkDebugger,
15
+ exportHar,
16
+ getNetworkEntry,
17
+ latestNetworkTimestamp,
18
+ listNetworkEntries,
19
+ recentNetworkSampleIds,
20
+ searchNetworkEntries,
21
+ waitForNetworkEntry
22
+ } from './network-debugger.js';
2
23
  import { isSupportedAutomationUrl } from './url-policy.js';
3
24
  import { computeReconnectDelayMs } from './reconnect.js';
4
25
  import {
5
- DEFAULT_WORKSPACE_ID,
6
- type WorkspaceBrowser,
7
- type WorkspaceColor,
8
- type WorkspaceRecord,
9
- type WorkspaceTab,
10
- type WorkspaceWindow,
11
- WorkspaceManager
12
- } from './workspace.js';
13
-
14
- interface CliRequest {
15
- id: string;
16
- method: string;
17
- params?: Record<string, unknown>;
18
- }
19
-
20
- interface CliResponse {
21
- id: string;
22
- ok: boolean;
23
- result?: unknown;
24
- error?: {
25
- code: string;
26
- message: string;
27
- data?: Record<string, unknown>;
28
- };
29
- }
30
-
31
- interface ExtensionConfig {
32
- token: string;
33
- port: number;
34
- debugRichText: boolean;
35
- }
36
-
37
- interface RuntimeErrorDetails {
38
- message: string;
39
- context: 'config' | 'socket' | 'request' | 'parse';
40
- at: number;
41
- }
42
-
43
- const DEFAULT_PORT = 17373;
44
- const STORAGE_KEY_TOKEN = 'pairToken';
26
+ LEGACY_STORAGE_KEY_WORKSPACE,
27
+ LEGACY_STORAGE_KEY_WORKSPACES,
28
+ resolveSessionBindingStateMap,
29
+ STORAGE_KEY_SESSION_BINDINGS
30
+ } from './session-binding-storage.js';
31
+ import { containsRedactionMarker } from './privacy.js';
32
+ import {
33
+ type WorkspaceBrowser as SessionBindingBrowser,
34
+ type WorkspaceColor as SessionBindingColor,
35
+ type WorkspaceRecord as SessionBindingRecord,
36
+ type WorkspaceTab as SessionBindingTab,
37
+ type WorkspaceWindow as SessionBindingWindow,
38
+ WorkspaceManager as SessionBindingManager
39
+ } from './workspace.js';
40
+
41
+ interface CliRequest {
42
+ id: string;
43
+ method: string;
44
+ params?: Record<string, unknown>;
45
+ }
46
+
47
+ interface CliResponse {
48
+ id: string;
49
+ ok: boolean;
50
+ result?: unknown;
51
+ error?: {
52
+ code: string;
53
+ message: string;
54
+ data?: Record<string, unknown>;
55
+ };
56
+ }
57
+
58
+ interface ExtensionConfig {
59
+ token: string;
60
+ port: number;
61
+ debugRichText: boolean;
62
+ }
63
+
64
+ interface RuntimeErrorDetails {
65
+ message: string;
66
+ context: 'config' | 'socket' | 'request' | 'parse';
67
+ at: number;
68
+ }
69
+
70
+ const DEFAULT_PORT = 17373;
71
+ const STORAGE_KEY_TOKEN = 'pairToken';
45
72
  const STORAGE_KEY_PORT = 'cliPort';
46
73
  const STORAGE_KEY_DEBUG_RICH_TEXT = 'debugRichText';
47
- const STORAGE_KEY_WORKSPACE = 'agentWorkspace';
48
74
  const DEFAULT_TAB_LOAD_TIMEOUT_MS = 40_000;
75
+ const textEncoder = new TextEncoder();
76
+ const textDecoder = new TextDecoder();
77
+ const REPLAY_FORBIDDEN_HEADER_NAMES = new Set([
78
+ 'accept-encoding',
79
+ 'authorization',
80
+ 'connection',
81
+ 'content-length',
82
+ 'cookie',
83
+ 'host',
84
+ 'origin',
85
+ 'proxy-authorization',
86
+ 'referer',
87
+ 'set-cookie'
88
+ ]);
89
+
90
+ let ws: WebSocket | null = null;
91
+ let reconnectTimer: number | null = null;
92
+ let nextReconnectInMs: number | null = null;
93
+ let reconnectAttempt = 0;
94
+ let lastError: RuntimeErrorDetails | null = null;
95
+ let manualDisconnect = false;
96
+
97
+ async function getConfig(): Promise<ExtensionConfig> {
98
+ const stored = await chrome.storage.local.get([STORAGE_KEY_TOKEN, STORAGE_KEY_PORT, STORAGE_KEY_DEBUG_RICH_TEXT]);
99
+ return {
100
+ token: typeof stored[STORAGE_KEY_TOKEN] === 'string' ? stored[STORAGE_KEY_TOKEN] : '',
101
+ port: typeof stored[STORAGE_KEY_PORT] === 'number' ? stored[STORAGE_KEY_PORT] : DEFAULT_PORT,
102
+ debugRichText: stored[STORAGE_KEY_DEBUG_RICH_TEXT] === true
103
+ };
104
+ }
105
+
106
+ async function setConfig(config: Partial<ExtensionConfig>): Promise<void> {
107
+ const payload: Record<string, unknown> = {};
108
+ if (typeof config.token === 'string') {
109
+ payload[STORAGE_KEY_TOKEN] = config.token;
110
+ }
111
+ if (typeof config.port === 'number') {
112
+ payload[STORAGE_KEY_PORT] = config.port;
113
+ }
114
+ if (typeof config.debugRichText === 'boolean') {
115
+ payload[STORAGE_KEY_DEBUG_RICH_TEXT] = config.debugRichText;
116
+ }
117
+ if (Object.keys(payload).length > 0) {
118
+ await chrome.storage.local.set(payload);
119
+ }
120
+ }
121
+
122
+ function setRuntimeError(message: string, context: RuntimeErrorDetails['context']): void {
123
+ lastError = {
124
+ message,
125
+ context,
126
+ at: Date.now()
127
+ };
128
+ }
129
+
130
+ function clearReconnectTimer(): void {
131
+ if (reconnectTimer !== null) {
132
+ clearTimeout(reconnectTimer);
133
+ reconnectTimer = null;
134
+ }
135
+ nextReconnectInMs = null;
136
+ }
137
+
138
+ function sendResponse(payload: CliResponse): void {
139
+ if (ws && ws.readyState === WebSocket.OPEN) {
140
+ ws.send(JSON.stringify(payload));
141
+ }
142
+ }
143
+
144
+ function toError(code: string, message: string, data?: Record<string, unknown>): CliResponse['error'] {
145
+ return { code, message, data };
146
+ }
147
+
148
+ function normalizeUnhandledError(error: unknown): CliResponse['error'] {
149
+ if (typeof error === 'object' && error !== null && 'code' in error) {
150
+ return error as CliResponse['error'];
151
+ }
152
+
153
+ const message = error instanceof Error ? error.message : String(error);
154
+ const lower = message.toLowerCase();
155
+
156
+ if (lower.includes('no tab with id') || lower.includes('no window with id')) {
157
+ return toError('E_NOT_FOUND', message);
158
+ }
159
+ if (lower.includes('workspace') && lower.includes('does not exist')) {
160
+ return toError('E_NOT_FOUND', message);
161
+ }
162
+ if (lower.includes('does not belong to workspace') || lower.includes('is missing from workspace')) {
163
+ return toError('E_NOT_FOUND', message);
164
+ }
165
+ if (lower.includes('invalid url') || lower.includes('url is invalid')) {
166
+ return toError('E_INVALID_PARAMS', message);
167
+ }
168
+ if (lower.includes('cannot access contents of url') || lower.includes('permission denied')) {
169
+ return toError('E_PERMISSION', message);
170
+ }
171
+
172
+ return toError('E_INTERNAL', message);
173
+ }
174
+
175
+ function toTabInfo(tab: chrome.tabs.Tab): SessionBindingTab {
176
+ if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
177
+ throw new Error('Tab is missing runtime identifiers');
178
+ }
179
+ return {
180
+ id: tab.id,
181
+ title: tab.title ?? '',
182
+ url: tab.url ?? '',
183
+ active: Boolean(tab.active),
184
+ windowId: tab.windowId,
185
+ groupId: typeof tab.groupId === 'number' && tab.groupId >= 0 ? tab.groupId : null
186
+ };
187
+ }
188
+
189
+ async function loadWorkspaceStateMap(): Promise<Record<string, SessionBindingRecord>> {
190
+ const stored = await chrome.storage.local.get([
191
+ STORAGE_KEY_SESSION_BINDINGS,
192
+ LEGACY_STORAGE_KEY_WORKSPACES,
193
+ LEGACY_STORAGE_KEY_WORKSPACE
194
+ ]);
195
+ return resolveSessionBindingStateMap(stored);
196
+ }
197
+
198
+ async function loadWorkspaceState(workspaceId: string): Promise<SessionBindingRecord | null> {
199
+ const stateMap = await loadWorkspaceStateMap();
200
+ return stateMap[workspaceId] ?? null;
201
+ }
202
+
203
+ async function listWorkspaceStates(): Promise<SessionBindingRecord[]> {
204
+ return Object.values(await loadWorkspaceStateMap());
205
+ }
206
+
207
+ async function saveWorkspaceState(state: SessionBindingRecord): Promise<void> {
208
+ const stateMap = await loadWorkspaceStateMap();
209
+ stateMap[state.id] = state;
210
+ await chrome.storage.local.set({ [STORAGE_KEY_SESSION_BINDINGS]: stateMap });
211
+ await chrome.storage.local.remove([LEGACY_STORAGE_KEY_WORKSPACES, LEGACY_STORAGE_KEY_WORKSPACE]);
212
+ }
213
+
214
+ async function deleteWorkspaceState(workspaceId: string): Promise<void> {
215
+ const stateMap = await loadWorkspaceStateMap();
216
+ delete stateMap[workspaceId];
217
+ if (Object.keys(stateMap).length === 0) {
218
+ await chrome.storage.local.remove([STORAGE_KEY_SESSION_BINDINGS, LEGACY_STORAGE_KEY_WORKSPACES, LEGACY_STORAGE_KEY_WORKSPACE]);
219
+ return;
220
+ }
221
+ await chrome.storage.local.set({ [STORAGE_KEY_SESSION_BINDINGS]: stateMap });
222
+ await chrome.storage.local.remove([LEGACY_STORAGE_KEY_WORKSPACES, LEGACY_STORAGE_KEY_WORKSPACE]);
223
+ }
224
+
225
+ const workspaceBrowser: SessionBindingBrowser = {
226
+ async getTab(tabId) {
227
+ try {
228
+ return toTabInfo(await chrome.tabs.get(tabId));
229
+ } catch {
230
+ return null;
231
+ }
232
+ },
233
+ async getActiveTab() {
234
+ const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
235
+ const tab = tabs[0];
236
+ if (!tab) {
237
+ return null;
238
+ }
239
+ return toTabInfo(tab);
240
+ },
241
+ async listTabs(filter) {
242
+ const tabs = await chrome.tabs.query(filter?.windowId ? { windowId: filter.windowId } : {});
243
+ return tabs
244
+ .filter((tab): tab is chrome.tabs.Tab => typeof tab.id === 'number' && typeof tab.windowId === 'number')
245
+ .map((tab) => toTabInfo(tab));
246
+ },
247
+ async createTab(options) {
248
+ const createdTab = await chrome.tabs.create({
249
+ windowId: options.windowId,
250
+ url: options.url ?? 'about:blank',
251
+ active: options.active
252
+ });
253
+ if (!createdTab) {
254
+ throw new Error('Tab creation returned no tab');
255
+ }
256
+ return toTabInfo(createdTab);
257
+ },
258
+ async updateTab(tabId, options) {
259
+ const updatedTab = await chrome.tabs.update(tabId, {
260
+ active: options.active,
261
+ url: options.url
262
+ });
263
+ if (!updatedTab) {
264
+ throw new Error(`Tab update returned no tab for ${tabId}`);
265
+ }
266
+ return toTabInfo(updatedTab);
267
+ },
268
+ async closeTab(tabId) {
269
+ await chrome.tabs.remove(tabId);
270
+ },
271
+ async getWindow(windowId) {
272
+ try {
273
+ const window = await chrome.windows.get(windowId);
274
+ return {
275
+ id: window.id!,
276
+ focused: Boolean(window.focused)
277
+ } satisfies SessionBindingWindow;
278
+ } catch {
279
+ return null;
280
+ }
281
+ },
282
+ async createWindow(options) {
283
+ const previouslyFocusedWindow =
284
+ options.focused === true
285
+ ? null
286
+ : (await chrome.windows.getAll()).find((window) => window.focused === true && typeof window.id === 'number') ?? null;
287
+ const previouslyFocusedTab =
288
+ previouslyFocusedWindow?.id !== undefined
289
+ ? (await chrome.tabs.query({ windowId: previouslyFocusedWindow.id, active: true })).find((tab) => typeof tab.id === 'number') ?? null
290
+ : null;
291
+ const created = await chrome.windows.create({
292
+ url: options.url ?? 'about:blank',
293
+ focused: true
294
+ });
295
+ if (!created || typeof created.id !== 'number') {
296
+ throw new Error('Window missing id');
297
+ }
298
+ if (options.focused !== true && previouslyFocusedWindow?.id && previouslyFocusedWindow.id !== created.id) {
299
+ await chrome.windows.update(previouslyFocusedWindow.id, { focused: true });
300
+ if (typeof previouslyFocusedTab?.id === 'number') {
301
+ await chrome.tabs.update(previouslyFocusedTab.id, { active: true });
302
+ }
303
+ }
304
+ const finalWindow = await chrome.windows.get(created.id);
305
+ return {
306
+ id: finalWindow.id!,
307
+ focused: Boolean(finalWindow.focused)
308
+ };
309
+ },
310
+ async updateWindow(windowId, options) {
311
+ const updated = await chrome.windows.update(windowId, {
312
+ focused: options.focused
313
+ });
314
+ if (!updated || typeof updated.id !== 'number') {
315
+ throw new Error('Window missing id');
316
+ }
317
+ return {
318
+ id: updated.id,
319
+ focused: Boolean(updated.focused)
320
+ };
321
+ },
322
+ async closeWindow(windowId) {
323
+ await chrome.windows.remove(windowId);
324
+ },
325
+ async getGroup(groupId) {
326
+ try {
327
+ const group = await chrome.tabGroups.get(groupId);
328
+ return {
329
+ id: group.id,
330
+ windowId: group.windowId,
331
+ title: group.title ?? '',
332
+ color: group.color as SessionBindingColor,
333
+ collapsed: Boolean(group.collapsed)
334
+ };
335
+ } catch {
336
+ return null;
337
+ }
338
+ },
339
+ async groupTabs(tabIds, groupId) {
340
+ return await chrome.tabs.group({
341
+ tabIds: tabIds as [number, ...number[]],
342
+ groupId
343
+ });
344
+ },
345
+ async updateGroup(groupId, options) {
346
+ const updated = await chrome.tabGroups.update(groupId, {
347
+ title: options.title,
348
+ color: options.color,
349
+ collapsed: options.collapsed
350
+ });
351
+ if (!updated) {
352
+ throw new Error(`Tab group update returned no group for ${groupId}`);
353
+ }
354
+ return {
355
+ id: updated.id,
356
+ windowId: updated.windowId,
357
+ title: updated.title ?? '',
358
+ color: updated.color as SessionBindingColor,
359
+ collapsed: Boolean(updated.collapsed)
360
+ };
361
+ }
362
+ };
363
+
364
+ const bindingManager = new SessionBindingManager(
365
+ {
366
+ load: loadWorkspaceState,
367
+ save: saveWorkspaceState,
368
+ delete: deleteWorkspaceState,
369
+ list: listWorkspaceStates
370
+ },
371
+ workspaceBrowser
372
+ );
373
+
374
+ async function waitForTabComplete(tabId: number, timeoutMs = DEFAULT_TAB_LOAD_TIMEOUT_MS): Promise<void> {
375
+ try {
376
+ const current = await chrome.tabs.get(tabId);
377
+ if (current.status === 'complete') {
378
+ return;
379
+ }
380
+ } catch {
381
+ return;
382
+ }
383
+
384
+ await new Promise<void>((resolve, reject) => {
385
+ let done = false;
386
+ const probeStatus = (): void => {
387
+ void chrome.tabs
388
+ .get(tabId)
389
+ .then((tab) => {
390
+ if (tab.status === 'complete') {
391
+ finish();
392
+ }
393
+ })
394
+ .catch(() => {
395
+ finish(new Error(`tab removed before load complete: ${tabId}`));
396
+ });
397
+ };
398
+
399
+ const finish = (error?: Error): void => {
400
+ if (done) {
401
+ return;
402
+ }
403
+ done = true;
404
+ clearTimeout(timeoutTimer);
405
+ clearInterval(pollTimer);
406
+ chrome.tabs.onUpdated.removeListener(onUpdated);
407
+ chrome.tabs.onRemoved.removeListener(onRemoved);
408
+ if (error) {
409
+ reject(error);
410
+ return;
411
+ }
412
+ resolve();
413
+ };
414
+
415
+ const onUpdated = (updatedTabId: number, changeInfo: { status?: string }): void => {
416
+ if (updatedTabId !== tabId) {
417
+ return;
418
+ }
419
+ if (changeInfo.status === 'complete') {
420
+ finish();
421
+ }
422
+ };
423
+
424
+ const onRemoved = (removedTabId: number): void => {
425
+ if (removedTabId === tabId) {
426
+ finish(new Error(`tab removed before load complete: ${tabId}`));
427
+ }
428
+ };
429
+
430
+ const pollTimer = setInterval(probeStatus, 250);
431
+ const timeoutTimer = setTimeout(() => {
432
+ finish(new Error(`tab load timeout: ${tabId}`));
433
+ }, timeoutMs);
434
+
435
+ chrome.tabs.onUpdated.addListener(onUpdated);
436
+ chrome.tabs.onRemoved.addListener(onRemoved);
437
+ probeStatus();
438
+ });
439
+ }
440
+
441
+ async function waitForTabUrl(tabId: number, expectedUrl: string, timeoutMs = 10_000): Promise<void> {
442
+ const normalizedExpectedUrl = normalizeComparableTabUrl(expectedUrl);
443
+ const deadline = Date.now() + timeoutMs;
444
+ while (Date.now() < deadline) {
445
+ try {
446
+ const tab = await chrome.tabs.get(tabId);
447
+ const currentUrl = tab.url ?? '';
448
+ const pendingUrl = 'pendingUrl' in tab && typeof tab.pendingUrl === 'string' ? tab.pendingUrl : '';
449
+ if (
450
+ normalizeComparableTabUrl(currentUrl) === normalizedExpectedUrl ||
451
+ normalizeComparableTabUrl(pendingUrl) === normalizedExpectedUrl
452
+ ) {
453
+ return;
454
+ }
455
+ } catch {
456
+ // Ignore transient lookup failures while the tab is navigating.
457
+ }
458
+ await new Promise((resolve) => setTimeout(resolve, 100));
459
+ }
460
+
461
+ throw new Error(`tab url timeout: ${tabId} -> ${expectedUrl}`);
462
+ }
463
+
464
+ function normalizeComparableTabUrl(url: string): string {
465
+ const raw = url.trim();
466
+ if (!raw) {
467
+ return raw;
468
+ }
469
+ try {
470
+ const parsed = new URL(raw);
471
+ parsed.hash = '';
472
+ return parsed.href;
473
+ } catch {
474
+ return raw;
475
+ }
476
+ }
477
+
478
+ async function finalizeOpenedWorkspaceTab(
479
+ opened: Awaited<ReturnType<SessionBindingManager['openTab']>>,
480
+ expectedUrl?: string
481
+ ): Promise<Awaited<ReturnType<SessionBindingManager['openTab']>>> {
482
+ if (expectedUrl && expectedUrl !== 'about:blank') {
483
+ await waitForTabUrl(opened.tab.id, expectedUrl).catch(() => undefined);
484
+ }
485
+ let refreshedTab = opened.tab;
486
+ try {
487
+ const rawTab = await chrome.tabs.get(opened.tab.id);
488
+ const pendingUrl = 'pendingUrl' in rawTab && typeof rawTab.pendingUrl === 'string' ? rawTab.pendingUrl : '';
489
+ const currentUrl = rawTab.url ?? '';
490
+ const effectiveUrl =
491
+ currentUrl && currentUrl !== 'about:blank'
492
+ ? currentUrl
493
+ : pendingUrl && pendingUrl !== 'about:blank'
494
+ ? pendingUrl
495
+ : currentUrl || pendingUrl || opened.tab.url;
496
+ refreshedTab = {
497
+ ...toTabInfo(rawTab),
498
+ url: effectiveUrl
499
+ };
500
+ } catch {
501
+ refreshedTab = (await workspaceBrowser.getTab(opened.tab.id)) ?? opened.tab;
502
+ }
503
+ const refreshedWorkspace = (await bindingManager.getWorkspaceInfo(opened.workspace.id)) ?? {
504
+ ...opened.workspace,
505
+ tabs: opened.workspace.tabs.map((tab) => (tab.id === refreshedTab.id ? refreshedTab : tab))
506
+ };
507
+
508
+ return {
509
+ workspace: refreshedWorkspace,
510
+ tab: refreshedTab
511
+ };
512
+ }
513
+
514
+ interface WithTabOptions {
515
+ requireSupportedAutomationUrl?: boolean;
516
+ }
517
+
518
+ async function withTab(target: { tabId?: number; workspaceId?: string } = {}, options: WithTabOptions = {}): Promise<chrome.tabs.Tab> {
519
+ const requireSupportedAutomationUrl = options.requireSupportedAutomationUrl !== false;
520
+ const validate = (tab: chrome.tabs.Tab): chrome.tabs.Tab => {
521
+ if (!tab.id) {
522
+ throw toError('E_NOT_FOUND', 'Tab missing id');
523
+ }
524
+ const pendingUrl = 'pendingUrl' in tab && typeof tab.pendingUrl === 'string' ? tab.pendingUrl : '';
525
+ if (requireSupportedAutomationUrl && !isSupportedAutomationUrl(tab.url) && !isSupportedAutomationUrl(pendingUrl)) {
526
+ throw toError('E_PERMISSION', 'Unsupported tab URL: only http/https pages can be automated', {
527
+ url: tab.url ?? pendingUrl ?? ''
528
+ });
529
+ }
530
+ return tab;
531
+ };
532
+
533
+ if (typeof target.tabId === 'number') {
534
+ const tab = await chrome.tabs.get(target.tabId);
535
+ return validate(tab);
536
+ }
537
+
538
+ const resolved = await bindingManager.resolveTarget({
539
+ tabId: target.tabId,
540
+ workspaceId: typeof target.workspaceId === 'string' ? target.workspaceId : undefined,
541
+ createIfMissing: false
542
+ });
543
+ const tab = await chrome.tabs.get(resolved.tab.id);
544
+ return validate(tab);
545
+ }
546
+
547
+ async function captureAlignedTabScreenshot(tab: chrome.tabs.Tab): Promise<string> {
548
+ if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
549
+ throw toError('E_NOT_FOUND', 'Tab screenshot requires tab id and window id');
550
+ }
551
+
552
+ const activeTabs = await chrome.tabs.query({ windowId: tab.windowId, active: true });
553
+ const activeTab = activeTabs[0];
554
+ const shouldSwitch = activeTab?.id !== tab.id;
555
+
556
+ if (shouldSwitch) {
557
+ await chrome.tabs.update(tab.id, { active: true });
558
+ await new Promise((resolve) => setTimeout(resolve, 80));
559
+ }
560
+
561
+ try {
562
+ return await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
563
+ } finally {
564
+ if (shouldSwitch && typeof activeTab?.id === 'number') {
565
+ try {
566
+ await chrome.tabs.update(activeTab.id, { active: true });
567
+ } catch {
568
+ // Ignore restore errors if the original tab no longer exists.
569
+ }
570
+ }
571
+ }
572
+ }
573
+
574
+ async function sendToContent<TResponse>(tabId: number, message: Record<string, unknown>): Promise<TResponse> {
575
+ const maxAttempts = 10;
576
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
577
+ try {
578
+ const response = await chrome.tabs.sendMessage(tabId, message);
579
+ if (typeof response === 'undefined') {
580
+ throw new Error('Content script returned undefined response');
581
+ }
582
+ return response as TResponse;
583
+ } catch (error) {
584
+ const detail = error instanceof Error ? error.message : String(error);
585
+ const retriable =
586
+ detail.includes('Receiving end does not exist') ||
587
+ detail.includes('Could not establish connection') ||
588
+ detail.includes('No tab with id') ||
589
+ detail.includes('message port closed before a response was received') ||
590
+ detail.includes('Content script returned undefined response');
591
+ if (!retriable || attempt >= maxAttempts) {
592
+ throw toError('E_NOT_READY', 'Content script unavailable', { detail });
593
+ }
594
+ await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
595
+ }
596
+ }
597
+
598
+ throw toError('E_NOT_READY', 'Content script unavailable');
599
+ }
600
+
601
+ interface FocusContext {
602
+ windowId: number | null;
603
+ tabId: number | null;
604
+ }
605
+
606
+ async function captureFocusContext(): Promise<FocusContext> {
607
+ const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
608
+ const activeTab = activeTabs.find((tab) => typeof tab.id === 'number' && typeof tab.windowId === 'number') ?? null;
609
+ return {
610
+ windowId: activeTab?.windowId ?? null,
611
+ tabId: activeTab?.id ?? null
612
+ };
613
+ }
614
+
615
+ async function restoreFocusContext(context: FocusContext): Promise<void> {
616
+ if (context.windowId !== null) {
617
+ try {
618
+ await chrome.windows.update(context.windowId, { focused: true });
619
+ } catch {
620
+ // Ignore restore errors if the original window no longer exists.
621
+ }
622
+ }
623
+ if (context.tabId !== null) {
624
+ try {
625
+ await chrome.tabs.update(context.tabId, { active: true });
626
+ } catch {
627
+ // Ignore restore errors if the original tab no longer exists.
628
+ }
629
+ }
630
+ }
631
+
632
+ async function preserveHumanFocus<T>(enabled: boolean, action: () => Promise<T>): Promise<T> {
633
+ if (!enabled) {
634
+ return action();
635
+ }
636
+
637
+ const focusContext = await captureFocusContext();
638
+ try {
639
+ return await action();
640
+ } finally {
641
+ await restoreFocusContext(focusContext);
642
+ }
643
+ }
644
+
645
+ function requireRpcEnvelope(
646
+ method: string,
647
+ value: unknown
648
+ ): { ok: boolean; result?: unknown; error?: CliResponse['error'] } {
649
+ if (typeof value !== 'object' || value === null || typeof (value as { ok?: unknown }).ok !== 'boolean') {
650
+ throw toError('E_NOT_READY', `Content script returned malformed response for ${method}`);
651
+ }
652
+ return value as { ok: boolean; result?: unknown; error?: CliResponse['error'] };
653
+ }
654
+
655
+ async function forwardContentRpc(
656
+ tabId: number,
657
+ method: string,
658
+ params: Record<string, unknown>
659
+ ): Promise<unknown> {
660
+ const raw = await sendToContent<unknown>(tabId, {
661
+ type: 'bak.rpc',
662
+ method,
663
+ params
664
+ });
665
+ const response = requireRpcEnvelope(method, raw);
666
+
667
+ if (!response.ok) {
668
+ throw response.error ?? toError('E_INTERNAL', `${method} failed`);
669
+ }
49
670
 
50
- let ws: WebSocket | null = null;
51
- let reconnectTimer: number | null = null;
52
- let nextReconnectInMs: number | null = null;
53
- let reconnectAttempt = 0;
54
- let lastError: RuntimeErrorDetails | null = null;
55
- let manualDisconnect = false;
56
-
57
- async function getConfig(): Promise<ExtensionConfig> {
58
- const stored = await chrome.storage.local.get([STORAGE_KEY_TOKEN, STORAGE_KEY_PORT, STORAGE_KEY_DEBUG_RICH_TEXT]);
59
- return {
60
- token: typeof stored[STORAGE_KEY_TOKEN] === 'string' ? stored[STORAGE_KEY_TOKEN] : '',
61
- port: typeof stored[STORAGE_KEY_PORT] === 'number' ? stored[STORAGE_KEY_PORT] : DEFAULT_PORT,
62
- debugRichText: stored[STORAGE_KEY_DEBUG_RICH_TEXT] === true
63
- };
64
- }
65
-
66
- async function setConfig(config: Partial<ExtensionConfig>): Promise<void> {
67
- const payload: Record<string, unknown> = {};
68
- if (typeof config.token === 'string') {
69
- payload[STORAGE_KEY_TOKEN] = config.token;
70
- }
71
- if (typeof config.port === 'number') {
72
- payload[STORAGE_KEY_PORT] = config.port;
73
- }
74
- if (typeof config.debugRichText === 'boolean') {
75
- payload[STORAGE_KEY_DEBUG_RICH_TEXT] = config.debugRichText;
76
- }
77
- if (Object.keys(payload).length > 0) {
78
- await chrome.storage.local.set(payload);
79
- }
80
- }
81
-
82
- function setRuntimeError(message: string, context: RuntimeErrorDetails['context']): void {
83
- lastError = {
84
- message,
85
- context,
86
- at: Date.now()
87
- };
88
- }
89
-
90
- function clearReconnectTimer(): void {
91
- if (reconnectTimer !== null) {
92
- clearTimeout(reconnectTimer);
93
- reconnectTimer = null;
94
- }
95
- nextReconnectInMs = null;
96
- }
97
-
98
- function sendResponse(payload: CliResponse): void {
99
- if (ws && ws.readyState === WebSocket.OPEN) {
100
- ws.send(JSON.stringify(payload));
101
- }
102
- }
103
-
104
- function toError(code: string, message: string, data?: Record<string, unknown>): CliResponse['error'] {
105
- return { code, message, data };
671
+ return response.result;
106
672
  }
107
673
 
108
- function normalizeUnhandledError(error: unknown): CliResponse['error'] {
109
- if (typeof error === 'object' && error !== null && 'code' in error) {
110
- return error as CliResponse['error'];
111
- }
112
-
113
- const message = error instanceof Error ? error.message : String(error);
114
- const lower = message.toLowerCase();
115
-
116
- if (lower.includes('no tab with id') || lower.includes('no window with id')) {
117
- return toError('E_NOT_FOUND', message);
118
- }
119
- if (lower.includes('invalid url') || lower.includes('url is invalid')) {
120
- return toError('E_INVALID_PARAMS', message);
121
- }
122
- if (lower.includes('cannot access contents of url') || lower.includes('permission denied')) {
123
- return toError('E_PERMISSION', message);
674
+ async function ensureTabNetworkCapture(tabId: number): Promise<void> {
675
+ try {
676
+ await ensureNetworkDebugger(tabId);
677
+ } catch (error) {
678
+ throw toError('E_DEBUGGER_NOT_ATTACHED', 'Debugger-backed network capture unavailable', {
679
+ detail: error instanceof Error ? error.message : String(error)
680
+ });
124
681
  }
125
-
126
- return toError('E_INTERNAL', message);
127
682
  }
128
683
 
129
- function toTabInfo(tab: chrome.tabs.Tab): WorkspaceTab {
130
- if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
131
- throw new Error('Tab is missing runtime identifiers');
132
- }
133
- return {
134
- id: tab.id,
135
- title: tab.title ?? '',
136
- url: tab.url ?? '',
137
- active: Boolean(tab.active),
138
- windowId: tab.windowId,
139
- groupId: typeof tab.groupId === 'number' && tab.groupId >= 0 ? tab.groupId : null
140
- };
684
+ function normalizePageExecutionScope(value: unknown): PageExecutionScope {
685
+ return value === 'main' || value === 'all-frames' ? value : 'current';
141
686
  }
142
687
 
143
- async function loadWorkspaceState(): Promise<WorkspaceRecord | null> {
144
- const stored = await chrome.storage.local.get(STORAGE_KEY_WORKSPACE);
145
- const state = stored[STORAGE_KEY_WORKSPACE];
146
- if (!state || typeof state !== 'object') {
147
- return null;
688
+ async function currentContextFramePath(tabId: number): Promise<string[]> {
689
+ try {
690
+ const context = (await forwardContentRpc(tabId, 'context.get', { tabId })) as { framePath?: string[] };
691
+ return Array.isArray(context.framePath) ? context.framePath.map(String) : [];
692
+ } catch {
693
+ return [];
148
694
  }
149
- return state as WorkspaceRecord;
150
695
  }
151
696
 
152
- async function saveWorkspaceState(state: WorkspaceRecord | null): Promise<void> {
153
- if (state === null) {
154
- await chrome.storage.local.remove(STORAGE_KEY_WORKSPACE);
155
- return;
156
- }
157
- await chrome.storage.local.set({ [STORAGE_KEY_WORKSPACE]: state });
158
- }
697
+ async function executePageWorld<T>(
698
+ tabId: number,
699
+ action: 'eval' | 'extract' | 'fetch',
700
+ params: Record<string, unknown>
701
+ ): Promise<{ scope: PageExecutionScope; result?: PageFrameResult<T>; results?: Array<PageFrameResult<T>> }> {
702
+ const scope = normalizePageExecutionScope(params.scope);
703
+ const framePath = scope === 'current' ? await currentContextFramePath(tabId) : [];
704
+ const target: chrome.scripting.InjectionTarget =
705
+ scope === 'all-frames'
706
+ ? { tabId, allFrames: true }
707
+ : {
708
+ tabId,
709
+ frameIds: [0]
710
+ };
159
711
 
160
- const workspaceBrowser: WorkspaceBrowser = {
161
- async getTab(tabId) {
162
- try {
163
- return toTabInfo(await chrome.tabs.get(tabId));
164
- } catch {
165
- return null;
166
- }
167
- },
168
- async getActiveTab() {
169
- const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
170
- const tab = tabs[0];
171
- if (!tab) {
172
- return null;
173
- }
174
- return toTabInfo(tab);
175
- },
176
- async listTabs(filter) {
177
- const tabs = await chrome.tabs.query(filter?.windowId ? { windowId: filter.windowId } : {});
178
- return tabs
179
- .filter((tab): tab is chrome.tabs.Tab => typeof tab.id === 'number' && typeof tab.windowId === 'number')
180
- .map((tab) => toTabInfo(tab));
181
- },
182
- async createTab(options) {
183
- const createdTab = await chrome.tabs.create({
184
- windowId: options.windowId,
185
- url: options.url ?? 'about:blank',
186
- active: options.active
187
- });
188
- if (!createdTab) {
189
- throw new Error('Tab creation returned no tab');
190
- }
191
- return toTabInfo(createdTab);
192
- },
193
- async updateTab(tabId, options) {
194
- const updatedTab = await chrome.tabs.update(tabId, {
195
- active: options.active,
196
- url: options.url
197
- });
198
- if (!updatedTab) {
199
- throw new Error(`Tab update returned no tab for ${tabId}`);
200
- }
201
- return toTabInfo(updatedTab);
202
- },
203
- async closeTab(tabId) {
204
- await chrome.tabs.remove(tabId);
205
- },
206
- async getWindow(windowId) {
207
- try {
208
- const window = await chrome.windows.get(windowId);
209
- return {
210
- id: window.id!,
211
- focused: Boolean(window.focused)
212
- } satisfies WorkspaceWindow;
213
- } catch {
214
- return null;
215
- }
216
- },
217
- async createWindow(options) {
218
- const previouslyFocusedWindow =
219
- options.focused === true
220
- ? null
221
- : (await chrome.windows.getAll()).find((window) => window.focused === true && typeof window.id === 'number') ?? null;
222
- const previouslyFocusedTab =
223
- previouslyFocusedWindow?.id !== undefined
224
- ? (await chrome.tabs.query({ windowId: previouslyFocusedWindow.id, active: true })).find((tab) => typeof tab.id === 'number') ?? null
225
- : null;
226
- const created = await chrome.windows.create({
227
- url: options.url ?? 'about:blank',
228
- focused: true
229
- });
230
- if (!created || typeof created.id !== 'number') {
231
- throw new Error('Window missing id');
232
- }
233
- if (options.focused !== true && previouslyFocusedWindow?.id && previouslyFocusedWindow.id !== created.id) {
234
- await chrome.windows.update(previouslyFocusedWindow.id, { focused: true });
235
- if (typeof previouslyFocusedTab?.id === 'number') {
236
- await chrome.tabs.update(previouslyFocusedTab.id, { active: true });
712
+ const results = await chrome.scripting.executeScript({
713
+ target,
714
+ world: 'MAIN',
715
+ args: [
716
+ {
717
+ action,
718
+ scope,
719
+ framePath,
720
+ expr: typeof params.expr === 'string' ? params.expr : '',
721
+ path: typeof params.path === 'string' ? params.path : '',
722
+ url: typeof params.url === 'string' ? params.url : '',
723
+ method: typeof params.method === 'string' ? params.method : 'GET',
724
+ headers: typeof params.headers === 'object' && params.headers !== null ? params.headers : undefined,
725
+ body: typeof params.body === 'string' ? params.body : undefined,
726
+ contentType: typeof params.contentType === 'string' ? params.contentType : undefined,
727
+ mode: params.mode === 'json' ? 'json' : 'raw',
728
+ maxBytes: typeof params.maxBytes === 'number' ? params.maxBytes : undefined,
729
+ timeoutMs: typeof params.timeoutMs === 'number' ? params.timeoutMs : undefined
237
730
  }
238
- }
239
- const finalWindow = await chrome.windows.get(created.id);
240
- return {
241
- id: finalWindow.id!,
242
- focused: Boolean(finalWindow.focused)
243
- };
244
- },
245
- async updateWindow(windowId, options) {
246
- const updated = await chrome.windows.update(windowId, {
247
- focused: options.focused
248
- });
249
- if (!updated || typeof updated.id !== 'number') {
250
- throw new Error('Window missing id');
251
- }
252
- return {
253
- id: updated.id,
254
- focused: Boolean(updated.focused)
255
- };
256
- },
257
- async closeWindow(windowId) {
258
- await chrome.windows.remove(windowId);
259
- },
260
- async getGroup(groupId) {
261
- try {
262
- const group = await chrome.tabGroups.get(groupId);
263
- return {
264
- id: group.id,
265
- windowId: group.windowId,
266
- title: group.title ?? '',
267
- color: group.color as WorkspaceColor,
268
- collapsed: Boolean(group.collapsed)
731
+ ],
732
+ func: async (payload) => {
733
+ const serializeValue = (value: unknown, maxBytes?: number) => {
734
+ let cloned: unknown;
735
+ try {
736
+ cloned = typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
737
+ } catch (error) {
738
+ throw {
739
+ code: 'E_NOT_SERIALIZABLE',
740
+ message: error instanceof Error ? error.message : String(error)
741
+ };
742
+ }
743
+ const json = JSON.stringify(cloned);
744
+ const bytes = typeof json === 'string' ? json.length : 0;
745
+ if (typeof maxBytes === 'number' && maxBytes > 0 && bytes > maxBytes) {
746
+ throw {
747
+ code: 'E_BODY_TOO_LARGE',
748
+ message: 'serialized value exceeds max-bytes',
749
+ details: { bytes, maxBytes }
750
+ };
751
+ }
752
+ return { value: cloned, bytes };
269
753
  };
270
- } catch {
271
- return null;
272
- }
273
- },
274
- async groupTabs(tabIds, groupId) {
275
- return await chrome.tabs.group({
276
- tabIds: tabIds as [number, ...number[]],
277
- groupId
278
- });
279
- },
280
- async updateGroup(groupId, options) {
281
- const updated = await chrome.tabGroups.update(groupId, {
282
- title: options.title,
283
- color: options.color,
284
- collapsed: options.collapsed
285
- });
286
- if (!updated) {
287
- throw new Error(`Tab group update returned no group for ${groupId}`);
288
- }
289
- return {
290
- id: updated.id,
291
- windowId: updated.windowId,
292
- title: updated.title ?? '',
293
- color: updated.color as WorkspaceColor,
294
- collapsed: Boolean(updated.collapsed)
295
- };
296
- }
297
- };
298
-
299
- const workspaceManager = new WorkspaceManager(
300
- {
301
- load: loadWorkspaceState,
302
- save: saveWorkspaceState
303
- },
304
- workspaceBrowser
305
- );
306
-
307
- async function waitForTabComplete(tabId: number, timeoutMs = DEFAULT_TAB_LOAD_TIMEOUT_MS): Promise<void> {
308
- try {
309
- const current = await chrome.tabs.get(tabId);
310
- if (current.status === 'complete') {
311
- return;
312
- }
313
- } catch {
314
- return;
315
- }
316
754
 
317
- await new Promise<void>((resolve, reject) => {
318
- let done = false;
319
- const probeStatus = (): void => {
320
- void chrome.tabs
321
- .get(tabId)
322
- .then((tab) => {
323
- if (tab.status === 'complete') {
324
- finish();
755
+ const parsePath = (path: string): Array<string | number> => {
756
+ if (typeof path !== 'string' || !path.trim()) {
757
+ throw { code: 'E_INVALID_PARAMS', message: 'path is required' };
758
+ }
759
+ const normalized = path.replace(/^globalThis\.?/, '').replace(/^window\.?/, '').trim();
760
+ if (!normalized) {
761
+ return [];
762
+ }
763
+ const segments: Array<string | number> = [];
764
+ let index = 0;
765
+ while (index < normalized.length) {
766
+ if (normalized[index] === '.') {
767
+ index += 1;
768
+ continue;
325
769
  }
326
- })
327
- .catch(() => {
328
- finish(new Error(`tab removed before load complete: ${tabId}`));
329
- });
330
- };
331
-
332
- const finish = (error?: Error): void => {
333
- if (done) {
334
- return;
335
- }
336
- done = true;
337
- clearTimeout(timeoutTimer);
338
- clearInterval(pollTimer);
339
- chrome.tabs.onUpdated.removeListener(onUpdated);
340
- chrome.tabs.onRemoved.removeListener(onRemoved);
341
- if (error) {
342
- reject(error);
343
- return;
344
- }
345
- resolve();
346
- };
347
-
348
- const onUpdated = (updatedTabId: number, changeInfo: { status?: string }): void => {
349
- if (updatedTabId !== tabId) {
350
- return;
351
- }
352
- if (changeInfo.status === 'complete') {
353
- finish();
354
- }
355
- };
356
-
357
- const onRemoved = (removedTabId: number): void => {
358
- if (removedTabId === tabId) {
359
- finish(new Error(`tab removed before load complete: ${tabId}`));
360
- }
361
- };
362
-
363
- const pollTimer = setInterval(probeStatus, 250);
364
- const timeoutTimer = setTimeout(() => {
365
- finish(new Error(`tab load timeout: ${tabId}`));
366
- }, timeoutMs);
770
+ if (normalized[index] === '[') {
771
+ const bracket = normalized.slice(index).match(/^\[(\d+)\]/);
772
+ if (!bracket) {
773
+ throw { code: 'E_INVALID_PARAMS', message: 'Only numeric bracket paths are supported' };
774
+ }
775
+ segments.push(Number(bracket[1]));
776
+ index += bracket[0].length;
777
+ continue;
778
+ }
779
+ const identifier = normalized.slice(index).match(/^[A-Za-z_$][\w$]*/);
780
+ if (!identifier) {
781
+ throw { code: 'E_INVALID_PARAMS', message: `Unsupported path token near: ${normalized.slice(index, index + 16)}` };
782
+ }
783
+ segments.push(identifier[0]);
784
+ index += identifier[0].length;
785
+ }
786
+ return segments;
787
+ };
367
788
 
368
- chrome.tabs.onUpdated.addListener(onUpdated);
369
- chrome.tabs.onRemoved.addListener(onRemoved);
370
- probeStatus();
371
- });
372
- }
789
+ const resolveFrameWindow = (frameSelectors: string[]) => {
790
+ let currentWindow: Window = window;
791
+ let currentDocument: Document = document;
792
+ for (const selector of frameSelectors) {
793
+ const frame = currentDocument.querySelector(selector);
794
+ if (!frame || !('contentWindow' in frame)) {
795
+ throw { code: 'E_NOT_FOUND', message: `frame not found: ${selector}` };
796
+ }
797
+ const nextWindow = (frame as HTMLIFrameElement).contentWindow;
798
+ if (!nextWindow) {
799
+ throw { code: 'E_NOT_READY', message: `frame window unavailable: ${selector}` };
800
+ }
801
+ currentWindow = nextWindow;
802
+ currentDocument = nextWindow.document;
803
+ }
804
+ return currentWindow;
805
+ };
373
806
 
374
- async function waitForTabUrl(tabId: number, expectedUrl: string, timeoutMs = 10_000): Promise<void> {
375
- const normalizedExpectedUrl = normalizeComparableTabUrl(expectedUrl);
376
- const deadline = Date.now() + timeoutMs;
377
- while (Date.now() < deadline) {
378
- try {
379
- const tab = await chrome.tabs.get(tabId);
380
- const currentUrl = tab.url ?? '';
381
- const pendingUrl = 'pendingUrl' in tab && typeof tab.pendingUrl === 'string' ? tab.pendingUrl : '';
382
- if (
383
- normalizeComparableTabUrl(currentUrl) === normalizedExpectedUrl ||
384
- normalizeComparableTabUrl(pendingUrl) === normalizedExpectedUrl
385
- ) {
386
- return;
807
+ try {
808
+ const targetWindow = payload.scope === 'main' ? window : payload.scope === 'current' ? resolveFrameWindow(payload.framePath ?? []) : window;
809
+ if (payload.action === 'eval') {
810
+ const evaluator = (targetWindow as Window & { eval: (expr: string) => unknown }).eval;
811
+ const serialized = serializeValue(evaluator(payload.expr), payload.maxBytes);
812
+ return { url: targetWindow.location.href, framePath: payload.scope === 'current' ? payload.framePath ?? [] : [], value: serialized.value, bytes: serialized.bytes };
813
+ }
814
+ if (payload.action === 'extract') {
815
+ const segments = parsePath(payload.path);
816
+ let current: unknown = targetWindow;
817
+ for (const segment of segments) {
818
+ if (current === null || current === undefined || !(segment in (current as Record<string | number, unknown>))) {
819
+ throw { code: 'E_NOT_FOUND', message: `path not found: ${payload.path}` };
820
+ }
821
+ current = (current as Record<string | number, unknown>)[segment];
822
+ }
823
+ const serialized = serializeValue(current, payload.maxBytes);
824
+ return { url: targetWindow.location.href, framePath: payload.scope === 'current' ? payload.framePath ?? [] : [], value: serialized.value, bytes: serialized.bytes };
825
+ }
826
+ if (payload.action === 'fetch') {
827
+ const headers = { ...(payload.headers ?? {}) } as Record<string, string>;
828
+ if (payload.contentType && !headers['Content-Type']) {
829
+ headers['Content-Type'] = payload.contentType;
830
+ }
831
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
832
+ const timeoutId =
833
+ controller && typeof payload.timeoutMs === 'number' && payload.timeoutMs > 0
834
+ ? window.setTimeout(() => controller.abort(), payload.timeoutMs)
835
+ : null;
836
+ let response: Response;
837
+ try {
838
+ response = await targetWindow.fetch(payload.url, {
839
+ method: payload.method || 'GET',
840
+ headers,
841
+ body: typeof payload.body === 'string' ? payload.body : undefined,
842
+ signal: controller ? controller.signal : undefined
843
+ });
844
+ } finally {
845
+ if (timeoutId !== null) {
846
+ window.clearTimeout(timeoutId);
847
+ }
848
+ }
849
+ const bodyText = await response.text();
850
+ const headerMap: Record<string, string> = {};
851
+ response.headers.forEach((value, key) => {
852
+ headerMap[key] = value;
853
+ });
854
+ return {
855
+ url: targetWindow.location.href,
856
+ framePath: payload.scope === 'current' ? payload.framePath ?? [] : [],
857
+ value: (() => {
858
+ const encoder = typeof TextEncoder === 'function' ? new TextEncoder() : null;
859
+ const decoder = typeof TextDecoder === 'function' ? new TextDecoder() : null;
860
+ const previewLimit = typeof payload.maxBytes === 'number' && payload.maxBytes > 0 ? payload.maxBytes : 8192;
861
+ const encodedBody = encoder ? encoder.encode(bodyText) : null;
862
+ const bodyBytes = encodedBody ? encodedBody.byteLength : bodyText.length;
863
+ const truncated = bodyBytes > previewLimit;
864
+ if (payload.mode === 'json' && truncated) {
865
+ throw {
866
+ code: 'E_BODY_TOO_LARGE',
867
+ message: 'JSON response exceeds max-bytes',
868
+ details: {
869
+ bytes: bodyBytes,
870
+ maxBytes: previewLimit
871
+ }
872
+ };
873
+ }
874
+ const previewText =
875
+ encodedBody && decoder
876
+ ? decoder.decode(encodedBody.subarray(0, Math.min(encodedBody.byteLength, previewLimit)))
877
+ : truncated
878
+ ? bodyText.slice(0, previewLimit)
879
+ : bodyText;
880
+ return {
881
+ url: response.url,
882
+ status: response.status,
883
+ ok: response.ok,
884
+ headers: headerMap,
885
+ contentType: response.headers.get('content-type') ?? undefined,
886
+ bodyText: payload.mode === 'json' ? undefined : previewText,
887
+ json: payload.mode === 'json' && bodyText ? JSON.parse(bodyText) : undefined,
888
+ bytes: bodyBytes,
889
+ truncated
890
+ };
891
+ })()
892
+ };
893
+ }
894
+ throw { code: 'E_NOT_FOUND', message: `Unsupported page world action: ${payload.action}` };
895
+ } catch (error) {
896
+ return {
897
+ url: window.location.href,
898
+ framePath: payload.scope === 'current' ? payload.framePath ?? [] : [],
899
+ error:
900
+ typeof error === 'object' && error !== null && 'code' in error
901
+ ? (error as { code: string; message: string; details?: Record<string, unknown> })
902
+ : { code: 'E_EXECUTION', message: error instanceof Error ? error.message : String(error) }
903
+ };
387
904
  }
388
- } catch {
389
- // Ignore transient lookup failures while the tab is navigating.
390
905
  }
391
- await new Promise((resolve) => setTimeout(resolve, 100));
392
- }
393
-
394
- throw new Error(`tab url timeout: ${tabId} -> ${expectedUrl}`);
395
- }
396
-
397
- function normalizeComparableTabUrl(url: string): string {
398
- const raw = url.trim();
399
- if (!raw) {
400
- return raw;
401
- }
402
- try {
403
- const parsed = new URL(raw);
404
- parsed.hash = '';
405
- return parsed.href;
406
- } catch {
407
- return raw;
408
- }
409
- }
906
+ });
410
907
 
411
- async function finalizeOpenedWorkspaceTab(
412
- opened: Awaited<ReturnType<WorkspaceManager['openTab']>>,
413
- expectedUrl?: string
414
- ): Promise<Awaited<ReturnType<WorkspaceManager['openTab']>>> {
415
- if (expectedUrl && expectedUrl !== 'about:blank') {
416
- await waitForTabUrl(opened.tab.id, expectedUrl).catch(() => undefined);
417
- }
418
- let refreshedTab = opened.tab;
419
- try {
420
- const rawTab = await chrome.tabs.get(opened.tab.id);
421
- const pendingUrl = 'pendingUrl' in rawTab && typeof rawTab.pendingUrl === 'string' ? rawTab.pendingUrl : '';
422
- const currentUrl = rawTab.url ?? '';
423
- const effectiveUrl =
424
- currentUrl && currentUrl !== 'about:blank'
425
- ? currentUrl
426
- : pendingUrl && pendingUrl !== 'about:blank'
427
- ? pendingUrl
428
- : currentUrl || pendingUrl || opened.tab.url;
429
- refreshedTab = {
430
- ...toTabInfo(rawTab),
431
- url: effectiveUrl
908
+ if (scope === 'all-frames') {
909
+ return {
910
+ scope,
911
+ results: results.map((item) => (item.result ?? { url: '', framePath: [] }) as PageFrameResult<T>)
432
912
  };
433
- } catch {
434
- refreshedTab = (await workspaceBrowser.getTab(opened.tab.id)) ?? opened.tab;
435
913
  }
436
- const refreshedWorkspace = (await workspaceManager.getWorkspaceInfo(opened.workspace.id)) ?? {
437
- ...opened.workspace,
438
- tabs: opened.workspace.tabs.map((tab) => (tab.id === refreshedTab.id ? refreshedTab : tab))
439
- };
440
914
 
441
915
  return {
442
- workspace: refreshedWorkspace,
443
- tab: refreshedTab
916
+ scope,
917
+ result: (results[0]?.result ?? { url: '', framePath }) as PageFrameResult<T>
444
918
  };
445
919
  }
446
920
 
447
- interface WithTabOptions {
448
- requireSupportedAutomationUrl?: boolean;
449
- }
450
-
451
- async function withTab(target: { tabId?: number; workspaceId?: string } = {}, options: WithTabOptions = {}): Promise<chrome.tabs.Tab> {
452
- const requireSupportedAutomationUrl = options.requireSupportedAutomationUrl !== false;
453
- const validate = (tab: chrome.tabs.Tab): chrome.tabs.Tab => {
454
- if (!tab.id) {
455
- throw toError('E_NOT_FOUND', 'Tab missing id');
921
+ function truncateNetworkEntry(entry: NetworkEntry, bodyBytes?: number): NetworkEntry {
922
+ if (typeof bodyBytes !== 'number' || !Number.isFinite(bodyBytes) || bodyBytes <= 0) {
923
+ return entry;
924
+ }
925
+ const maxBytes = Math.max(1, Math.floor(bodyBytes));
926
+ const clone: NetworkEntry = { ...entry };
927
+ if (typeof clone.requestBodyPreview === 'string') {
928
+ const requestBytes = textEncoder.encode(clone.requestBodyPreview);
929
+ if (requestBytes.byteLength > maxBytes) {
930
+ clone.requestBodyPreview = textDecoder.decode(requestBytes.subarray(0, maxBytes));
931
+ clone.requestBodyTruncated = true;
932
+ clone.truncated = true;
456
933
  }
457
- const pendingUrl = 'pendingUrl' in tab && typeof tab.pendingUrl === 'string' ? tab.pendingUrl : '';
458
- if (requireSupportedAutomationUrl && !isSupportedAutomationUrl(tab.url) && !isSupportedAutomationUrl(pendingUrl)) {
459
- throw toError('E_PERMISSION', 'Unsupported tab URL: only http/https pages can be automated', {
460
- url: tab.url ?? pendingUrl ?? ''
461
- });
934
+ }
935
+ if (typeof clone.responseBodyPreview === 'string') {
936
+ const responseBytes = textEncoder.encode(clone.responseBodyPreview);
937
+ if (responseBytes.byteLength > maxBytes) {
938
+ clone.responseBodyPreview = textDecoder.decode(responseBytes.subarray(0, maxBytes));
939
+ clone.responseBodyTruncated = true;
940
+ clone.truncated = true;
462
941
  }
463
- return tab;
464
- };
465
-
466
- if (typeof target.tabId === 'number') {
467
- const tab = await chrome.tabs.get(target.tabId);
468
- return validate(tab);
469
942
  }
470
-
471
- const resolved = await workspaceManager.resolveTarget({
472
- tabId: target.tabId,
473
- workspaceId: typeof target.workspaceId === 'string' ? target.workspaceId : undefined,
474
- createIfMissing: false
475
- });
476
- const tab = await chrome.tabs.get(resolved.tab.id);
477
- return validate(tab);
943
+ return clone;
478
944
  }
479
945
 
480
- async function captureAlignedTabScreenshot(tab: chrome.tabs.Tab): Promise<string> {
481
- if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
482
- throw toError('E_NOT_FOUND', 'Tab screenshot requires tab id and window id');
946
+ function filterNetworkEntrySections(entry: NetworkEntry, include: unknown): NetworkEntry {
947
+ if (!Array.isArray(include)) {
948
+ return entry;
483
949
  }
484
-
485
- const activeTabs = await chrome.tabs.query({ windowId: tab.windowId, active: true });
486
- const activeTab = activeTabs[0];
487
- const shouldSwitch = activeTab?.id !== tab.id;
488
-
489
- if (shouldSwitch) {
490
- await chrome.tabs.update(tab.id, { active: true });
491
- await new Promise((resolve) => setTimeout(resolve, 80));
950
+ const sections = new Set(
951
+ include
952
+ .map(String)
953
+ .filter((section): section is 'request' | 'response' => section === 'request' || section === 'response')
954
+ );
955
+ if (sections.size === 0 || sections.size === 2) {
956
+ return entry;
492
957
  }
493
-
494
- try {
495
- return await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
496
- } finally {
497
- if (shouldSwitch && typeof activeTab?.id === 'number') {
498
- try {
499
- await chrome.tabs.update(activeTab.id, { active: true });
500
- } catch {
501
- // Ignore restore errors if the original tab no longer exists.
502
- }
503
- }
958
+ const clone: NetworkEntry = { ...entry };
959
+ if (!sections.has('request')) {
960
+ delete clone.requestHeaders;
961
+ delete clone.requestBodyPreview;
962
+ delete clone.requestBodyTruncated;
963
+ }
964
+ if (!sections.has('response')) {
965
+ delete clone.responseHeaders;
966
+ delete clone.responseBodyPreview;
967
+ delete clone.responseBodyTruncated;
968
+ delete clone.binary;
504
969
  }
970
+ return clone;
505
971
  }
506
972
 
507
- async function sendToContent<TResponse>(tabId: number, message: Record<string, unknown>): Promise<TResponse> {
508
- const maxAttempts = 10;
509
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
510
- try {
511
- const response = await chrome.tabs.sendMessage(tabId, message);
512
- if (typeof response === 'undefined') {
513
- throw new Error('Content script returned undefined response');
514
- }
515
- return response as TResponse;
516
- } catch (error) {
517
- const detail = error instanceof Error ? error.message : String(error);
518
- const retriable =
519
- detail.includes('Receiving end does not exist') ||
520
- detail.includes('Could not establish connection') ||
521
- detail.includes('No tab with id') ||
522
- detail.includes('message port closed before a response was received') ||
523
- detail.includes('Content script returned undefined response');
524
- if (!retriable || attempt >= maxAttempts) {
525
- throw toError('E_NOT_READY', 'Content script unavailable', { detail });
526
- }
527
- await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
973
+ function replayHeadersFromEntry(entry: NetworkEntry): Record<string, string> | undefined {
974
+ if (!entry.requestHeaders) {
975
+ return undefined;
976
+ }
977
+ const headers: Record<string, string> = {};
978
+ for (const [name, value] of Object.entries(entry.requestHeaders)) {
979
+ const normalizedName = name.toLowerCase();
980
+ if (REPLAY_FORBIDDEN_HEADER_NAMES.has(normalizedName) || normalizedName.startsWith('sec-')) {
981
+ continue;
982
+ }
983
+ if (containsRedactionMarker(value)) {
984
+ continue;
528
985
  }
986
+ headers[name] = value;
529
987
  }
530
-
531
- throw toError('E_NOT_READY', 'Content script unavailable');
532
- }
533
-
534
- interface FocusContext {
535
- windowId: number | null;
536
- tabId: number | null;
988
+ return Object.keys(headers).length > 0 ? headers : undefined;
537
989
  }
538
990
 
539
- async function captureFocusContext(): Promise<FocusContext> {
540
- const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
541
- const activeTab = activeTabs.find((tab) => typeof tab.id === 'number' && typeof tab.windowId === 'number') ?? null;
542
- return {
543
- windowId: activeTab?.windowId ?? null,
544
- tabId: activeTab?.id ?? null
545
- };
991
+ function parseTimestampCandidate(value: string, now = Date.now()): number | null {
992
+ const normalized = value.trim().toLowerCase();
993
+ if (!normalized) {
994
+ return null;
995
+ }
996
+ if (normalized === 'today') {
997
+ return now;
998
+ }
999
+ if (normalized === 'yesterday') {
1000
+ return now - 24 * 60 * 60 * 1000;
1001
+ }
1002
+ const parsed = Date.parse(value);
1003
+ return Number.isNaN(parsed) ? null : parsed;
546
1004
  }
547
1005
 
548
- async function restoreFocusContext(context: FocusContext): Promise<void> {
549
- if (context.windowId !== null) {
550
- try {
551
- await chrome.windows.update(context.windowId, { focused: true });
552
- } catch {
553
- // Ignore restore errors if the original window no longer exists.
554
- }
1006
+ function extractLatestTimestamp(values: string[] | undefined, now = Date.now()): number | null {
1007
+ if (!Array.isArray(values) || values.length === 0) {
1008
+ return null;
555
1009
  }
556
- if (context.tabId !== null) {
557
- try {
558
- await chrome.tabs.update(context.tabId, { active: true });
559
- } catch {
560
- // Ignore restore errors if the original tab no longer exists.
1010
+ let latest: number | null = null;
1011
+ for (const value of values) {
1012
+ const parsed = parseTimestampCandidate(value, now);
1013
+ if (parsed === null) {
1014
+ continue;
561
1015
  }
1016
+ latest = latest === null ? parsed : Math.max(latest, parsed);
562
1017
  }
1018
+ return latest;
563
1019
  }
564
1020
 
565
- async function preserveHumanFocus<T>(enabled: boolean, action: () => Promise<T>): Promise<T> {
566
- if (!enabled) {
567
- return action();
1021
+ function computeFreshnessAssessment(input: {
1022
+ latestInlineDataTimestamp: number | null;
1023
+ latestNetworkTimestamp: number | null;
1024
+ domVisibleTimestamp: number | null;
1025
+ lastMutationAt: number | null;
1026
+ freshWindowMs: number;
1027
+ staleWindowMs: number;
1028
+ }): PageFreshnessResult['assessment'] {
1029
+ const now = Date.now();
1030
+ const latestDataTimestamp = [input.latestInlineDataTimestamp, input.domVisibleTimestamp]
1031
+ .filter((value): value is number => typeof value === 'number')
1032
+ .sort((left, right) => right - left)[0] ?? null;
1033
+ if (latestDataTimestamp !== null && now - latestDataTimestamp <= input.freshWindowMs) {
1034
+ return 'fresh';
568
1035
  }
569
-
570
- const focusContext = await captureFocusContext();
571
- try {
572
- return await action();
573
- } finally {
574
- await restoreFocusContext(focusContext);
1036
+ const recentSignals = [input.latestNetworkTimestamp, input.lastMutationAt]
1037
+ .filter((value): value is number => typeof value === 'number')
1038
+ .some((value) => now - value <= input.freshWindowMs);
1039
+ if (recentSignals && latestDataTimestamp !== null && now - latestDataTimestamp > input.freshWindowMs) {
1040
+ return 'lagged';
575
1041
  }
576
- }
577
-
578
- function requireRpcEnvelope(
579
- method: string,
580
- value: unknown
581
- ): { ok: boolean; result?: unknown; error?: CliResponse['error'] } {
582
- if (typeof value !== 'object' || value === null || typeof (value as { ok?: unknown }).ok !== 'boolean') {
583
- throw toError('E_NOT_READY', `Content script returned malformed response for ${method}`);
1042
+ const staleSignals = [input.latestNetworkTimestamp, input.lastMutationAt, latestDataTimestamp]
1043
+ .filter((value): value is number => typeof value === 'number');
1044
+ if (staleSignals.length > 0 && staleSignals.every((value) => now - value > input.staleWindowMs)) {
1045
+ return 'stale';
584
1046
  }
585
- return value as { ok: boolean; result?: unknown; error?: CliResponse['error'] };
1047
+ return 'unknown';
586
1048
  }
587
1049
 
588
- async function forwardContentRpc(
589
- tabId: number,
590
- method: string,
591
- params: Record<string, unknown>
592
- ): Promise<unknown> {
593
- const raw = await sendToContent<unknown>(tabId, {
594
- type: 'bak.rpc',
595
- method,
596
- params
597
- });
598
- const response = requireRpcEnvelope(method, raw);
599
-
600
- if (!response.ok) {
601
- throw response.error ?? toError('E_INTERNAL', `${method} failed`);
602
- }
603
-
604
- return response.result;
1050
+ async function collectPageInspection(tabId: number, params: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
1051
+ return (await forwardContentRpc(tabId, 'bak.internal.inspectState', params)) as Record<string, unknown>;
605
1052
  }
606
1053
 
607
- async function handleRequest(request: CliRequest): Promise<unknown> {
608
- const params = request.params ?? {};
609
- const target = {
610
- tabId: typeof params.tabId === 'number' ? params.tabId : undefined,
611
- workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined
1054
+ async function buildFreshnessForTab(tabId: number, params: Record<string, unknown> = {}): Promise<PageFreshnessResult> {
1055
+ const inspection = await collectPageInspection(tabId, params);
1056
+ const visibleTimestamps = Array.isArray(inspection.visibleTimestamps) ? inspection.visibleTimestamps.map(String) : [];
1057
+ const inlineTimestamps = Array.isArray(inspection.inlineTimestamps) ? inspection.inlineTimestamps.map(String) : [];
1058
+ const now = Date.now();
1059
+ const freshWindowMs = typeof params.freshWindowMs === 'number' ? Math.max(1, Math.floor(params.freshWindowMs)) : 15 * 60 * 1000;
1060
+ const staleWindowMs = typeof params.staleWindowMs === 'number' ? Math.max(freshWindowMs, Math.floor(params.staleWindowMs)) : 24 * 60 * 60 * 1000;
1061
+ const latestInlineDataTimestamp = extractLatestTimestamp(inlineTimestamps, now);
1062
+ const domVisibleTimestamp = extractLatestTimestamp(visibleTimestamps, now);
1063
+ const latestNetworkTs = latestNetworkTimestamp(tabId);
1064
+ const lastMutationAt = typeof inspection.lastMutationAt === 'number' ? inspection.lastMutationAt : null;
1065
+ return {
1066
+ pageLoadedAt: typeof inspection.pageLoadedAt === 'number' ? inspection.pageLoadedAt : null,
1067
+ lastMutationAt,
1068
+ latestNetworkTimestamp: latestNetworkTs,
1069
+ latestInlineDataTimestamp,
1070
+ domVisibleTimestamp,
1071
+ assessment: computeFreshnessAssessment({
1072
+ latestInlineDataTimestamp,
1073
+ latestNetworkTimestamp: latestNetworkTs,
1074
+ domVisibleTimestamp,
1075
+ lastMutationAt,
1076
+ freshWindowMs,
1077
+ staleWindowMs
1078
+ }),
1079
+ evidence: {
1080
+ visibleTimestamps,
1081
+ inlineTimestamps,
1082
+ networkSampleIds: recentNetworkSampleIds(tabId)
1083
+ }
612
1084
  };
613
-
1085
+ }
1086
+
1087
+ async function handleRequest(request: CliRequest): Promise<unknown> {
1088
+ const params = request.params ?? {};
1089
+ const target = {
1090
+ tabId: typeof params.tabId === 'number' ? params.tabId : undefined,
1091
+ workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined
1092
+ };
1093
+
614
1094
  const rpcForwardMethods = new Set([
615
1095
  'page.title',
616
1096
  'page.url',
@@ -619,542 +1099,832 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
619
1099
  'page.accessibilityTree',
620
1100
  'page.scrollTo',
621
1101
  'page.metrics',
622
- 'element.hover',
623
- 'element.doubleClick',
624
- 'element.rightClick',
625
- 'element.dragDrop',
626
- 'element.select',
627
- 'element.check',
628
- 'element.uncheck',
629
- 'element.scrollIntoView',
630
- 'element.focus',
631
- 'element.blur',
632
- 'element.get',
633
- 'keyboard.press',
634
- 'keyboard.type',
635
- 'keyboard.hotkey',
636
- 'mouse.move',
637
- 'mouse.click',
638
- 'mouse.wheel',
639
- 'file.upload',
1102
+ 'element.hover',
1103
+ 'element.doubleClick',
1104
+ 'element.rightClick',
1105
+ 'element.dragDrop',
1106
+ 'element.select',
1107
+ 'element.check',
1108
+ 'element.uncheck',
1109
+ 'element.scrollIntoView',
1110
+ 'element.focus',
1111
+ 'element.blur',
1112
+ 'element.get',
1113
+ 'keyboard.press',
1114
+ 'keyboard.type',
1115
+ 'keyboard.hotkey',
1116
+ 'mouse.move',
1117
+ 'mouse.click',
1118
+ 'mouse.wheel',
1119
+ 'file.upload',
1120
+ 'context.get',
1121
+ 'context.set',
640
1122
  'context.enterFrame',
641
1123
  'context.exitFrame',
642
1124
  'context.enterShadow',
643
1125
  'context.exitShadow',
644
1126
  'context.reset',
645
- 'network.list',
646
- 'network.get',
647
- 'network.waitFor',
648
- 'network.clear',
649
- 'debug.dumpState'
1127
+ 'table.list',
1128
+ 'table.schema',
1129
+ 'table.rows',
1130
+ 'table.export'
650
1131
  ]);
651
-
652
- switch (request.method) {
653
- case 'session.ping': {
654
- return { ok: true, ts: Date.now() };
655
- }
656
- case 'tabs.list': {
657
- const tabs = await chrome.tabs.query({});
658
- return {
659
- tabs: tabs
660
- .filter((tab): tab is chrome.tabs.Tab => typeof tab.id === 'number' && typeof tab.windowId === 'number')
661
- .map((tab) => toTabInfo(tab))
662
- };
663
- }
664
- case 'tabs.getActive': {
665
- const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
666
- const tab = tabs[0];
667
- if (!tab || typeof tab.id !== 'number') {
668
- return { tab: null };
669
- }
670
- return {
671
- tab: toTabInfo(tab)
672
- };
673
- }
674
- case 'tabs.get': {
675
- const tabId = Number(params.tabId);
676
- const tab = await chrome.tabs.get(tabId);
677
- if (typeof tab.id !== 'number') {
678
- throw toError('E_NOT_FOUND', 'Tab missing id');
679
- }
680
- return {
681
- tab: toTabInfo(tab)
682
- };
683
- }
684
- case 'tabs.focus': {
685
- const tabId = Number(params.tabId);
686
- await chrome.tabs.update(tabId, { active: true });
687
- return { ok: true };
688
- }
689
- case 'tabs.new': {
690
- if (typeof params.workspaceId === 'string' || params.windowId === undefined) {
691
- const expectedUrl = (params.url as string | undefined) ?? 'about:blank';
692
- const opened = await preserveHumanFocus(true, async () => {
693
- return await workspaceManager.openTab({
694
- workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : DEFAULT_WORKSPACE_ID,
695
- url: expectedUrl,
696
- active: params.active === true,
697
- focus: false
698
- });
699
- });
700
- const stabilized = await finalizeOpenedWorkspaceTab(opened, expectedUrl);
701
- return {
702
- tabId: stabilized.tab.id,
703
- windowId: stabilized.tab.windowId,
704
- groupId: stabilized.workspace.groupId,
705
- workspaceId: stabilized.workspace.id
706
- };
707
- }
708
- const tab = await chrome.tabs.create({
709
- url: (params.url as string | undefined) ?? 'about:blank',
710
- windowId: typeof params.windowId === 'number' ? params.windowId : undefined,
711
- active: params.active === true
712
- });
713
- if (params.addToGroup === true && typeof tab.id === 'number') {
714
- const groupId = await chrome.tabs.group({ tabIds: [tab.id] });
715
- return {
716
- tabId: tab.id,
717
- windowId: tab.windowId,
718
- groupId
719
- };
720
- }
721
- return {
722
- tabId: tab.id,
723
- windowId: tab.windowId
724
- };
725
- }
726
- case 'tabs.close': {
727
- const tabId = Number(params.tabId);
728
- await chrome.tabs.remove(tabId);
729
- return { ok: true };
730
- }
1132
+
1133
+ switch (request.method) {
1134
+ case 'session.ping': {
1135
+ return { ok: true, ts: Date.now() };
1136
+ }
1137
+ case 'tabs.list': {
1138
+ const tabs = await chrome.tabs.query({});
1139
+ return {
1140
+ tabs: tabs
1141
+ .filter((tab): tab is chrome.tabs.Tab => typeof tab.id === 'number' && typeof tab.windowId === 'number')
1142
+ .map((tab) => toTabInfo(tab))
1143
+ };
1144
+ }
1145
+ case 'tabs.getActive': {
1146
+ const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
1147
+ const tab = tabs[0];
1148
+ if (!tab || typeof tab.id !== 'number') {
1149
+ return { tab: null };
1150
+ }
1151
+ return {
1152
+ tab: toTabInfo(tab)
1153
+ };
1154
+ }
1155
+ case 'tabs.get': {
1156
+ const tabId = Number(params.tabId);
1157
+ const tab = await chrome.tabs.get(tabId);
1158
+ if (typeof tab.id !== 'number') {
1159
+ throw toError('E_NOT_FOUND', 'Tab missing id');
1160
+ }
1161
+ return {
1162
+ tab: toTabInfo(tab)
1163
+ };
1164
+ }
1165
+ case 'tabs.focus': {
1166
+ const tabId = Number(params.tabId);
1167
+ await chrome.tabs.update(tabId, { active: true });
1168
+ return { ok: true };
1169
+ }
1170
+ case 'tabs.new': {
1171
+ const tab = await chrome.tabs.create({
1172
+ url: (params.url as string | undefined) ?? 'about:blank',
1173
+ windowId: typeof params.windowId === 'number' ? params.windowId : undefined,
1174
+ active: params.active === true
1175
+ });
1176
+ if (params.addToGroup === true && typeof tab.id === 'number') {
1177
+ const groupId = await chrome.tabs.group({ tabIds: [tab.id] });
1178
+ return {
1179
+ tabId: tab.id,
1180
+ windowId: tab.windowId,
1181
+ groupId
1182
+ };
1183
+ }
1184
+ return {
1185
+ tabId: tab.id,
1186
+ windowId: tab.windowId
1187
+ };
1188
+ }
1189
+ case 'tabs.close': {
1190
+ const tabId = Number(params.tabId);
1191
+ await chrome.tabs.remove(tabId);
1192
+ return { ok: true };
1193
+ }
731
1194
  case 'workspace.ensure': {
732
1195
  return preserveHumanFocus(params.focus !== true, async () => {
733
- return await workspaceManager.ensureWorkspace({
734
- workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined,
1196
+ const result = await bindingManager.ensureWorkspace({
1197
+ workspaceId: String(params.workspaceId ?? ''),
735
1198
  focus: params.focus === true,
736
1199
  initialUrl: typeof params.url === 'string' ? params.url : undefined
737
1200
  });
1201
+ for (const tab of result.workspace.tabs) {
1202
+ void ensureNetworkDebugger(tab.id).catch(() => undefined);
1203
+ }
1204
+ return result;
738
1205
  });
739
1206
  }
740
- case 'workspace.info': {
741
- return {
742
- workspace: await workspaceManager.getWorkspaceInfo(typeof params.workspaceId === 'string' ? params.workspaceId : undefined)
743
- };
744
- }
1207
+ case 'workspace.info': {
1208
+ return {
1209
+ workspace: await bindingManager.getWorkspaceInfo(String(params.workspaceId ?? ''))
1210
+ };
1211
+ }
745
1212
  case 'workspace.openTab': {
746
1213
  const expectedUrl = typeof params.url === 'string' ? params.url : undefined;
747
1214
  const opened = await preserveHumanFocus(params.focus !== true, async () => {
748
- return await workspaceManager.openTab({
749
- workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined,
750
- url: expectedUrl,
751
- active: params.active === true,
1215
+ return await bindingManager.openTab({
1216
+ workspaceId: String(params.workspaceId ?? ''),
1217
+ url: expectedUrl,
1218
+ active: params.active === true,
752
1219
  focus: params.focus === true
753
1220
  });
754
1221
  });
755
- return await finalizeOpenedWorkspaceTab(opened, expectedUrl);
756
- }
757
- case 'workspace.listTabs': {
758
- return await workspaceManager.listTabs(typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
759
- }
760
- case 'workspace.getActiveTab': {
761
- return await workspaceManager.getActiveTab(typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
1222
+ const finalized = await finalizeOpenedWorkspaceTab(opened, expectedUrl);
1223
+ void ensureNetworkDebugger(finalized.tab.id).catch(() => undefined);
1224
+ return finalized;
762
1225
  }
1226
+ case 'workspace.listTabs': {
1227
+ return await bindingManager.listTabs(String(params.workspaceId ?? ''));
1228
+ }
1229
+ case 'workspace.getActiveTab': {
1230
+ return await bindingManager.getActiveTab(String(params.workspaceId ?? ''));
1231
+ }
763
1232
  case 'workspace.setActiveTab': {
764
- return await workspaceManager.setActiveTab(Number(params.tabId), typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
765
- }
766
- case 'workspace.focus': {
767
- return await workspaceManager.focus(typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
768
- }
769
- case 'workspace.reset': {
770
- return await preserveHumanFocus(params.focus !== true, async () => {
771
- return await workspaceManager.reset({
772
- workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined,
773
- focus: params.focus === true,
774
- initialUrl: typeof params.url === 'string' ? params.url : undefined
775
- });
776
- });
777
- }
778
- case 'workspace.close': {
779
- return await workspaceManager.close(typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
1233
+ const result = await bindingManager.setActiveTab(Number(params.tabId), String(params.workspaceId ?? ''));
1234
+ void ensureNetworkDebugger(result.tab.id).catch(() => undefined);
1235
+ return result;
780
1236
  }
1237
+ case 'workspace.focus': {
1238
+ return await bindingManager.focus(String(params.workspaceId ?? ''));
1239
+ }
1240
+ case 'workspace.reset': {
1241
+ return await preserveHumanFocus(params.focus !== true, async () => {
1242
+ return await bindingManager.reset({
1243
+ workspaceId: String(params.workspaceId ?? ''),
1244
+ focus: params.focus === true,
1245
+ initialUrl: typeof params.url === 'string' ? params.url : undefined
1246
+ });
1247
+ });
1248
+ }
1249
+ case 'workspace.close': {
1250
+ return await bindingManager.close(String(params.workspaceId ?? ''));
1251
+ }
781
1252
  case 'page.goto': {
782
1253
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
783
1254
  const tab = await withTab(target, {
784
1255
  requireSupportedAutomationUrl: false
785
1256
  });
1257
+ void ensureNetworkDebugger(tab.id!).catch(() => undefined);
786
1258
  const url = String(params.url ?? 'about:blank');
787
1259
  await chrome.tabs.update(tab.id!, { url });
788
- await waitForTabUrl(tab.id!, url);
789
- await forwardContentRpc(tab.id!, 'page.url', { tabId: tab.id }).catch(() => undefined);
790
- await waitForTabComplete(tab.id!, 5_000).catch(() => undefined);
791
- return { ok: true };
792
- });
793
- }
1260
+ await waitForTabUrl(tab.id!, url);
1261
+ await forwardContentRpc(tab.id!, 'page.url', { tabId: tab.id }).catch(() => undefined);
1262
+ await waitForTabComplete(tab.id!, 5_000).catch(() => undefined);
1263
+ return { ok: true };
1264
+ });
1265
+ }
794
1266
  case 'page.back': {
795
1267
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
796
1268
  const tab = await withTab(target);
1269
+ void ensureNetworkDebugger(tab.id!).catch(() => undefined);
797
1270
  await chrome.tabs.goBack(tab.id!);
798
- await waitForTabComplete(tab.id!);
799
- return { ok: true };
800
- });
801
- }
1271
+ await waitForTabComplete(tab.id!);
1272
+ return { ok: true };
1273
+ });
1274
+ }
802
1275
  case 'page.forward': {
803
1276
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
804
1277
  const tab = await withTab(target);
1278
+ void ensureNetworkDebugger(tab.id!).catch(() => undefined);
805
1279
  await chrome.tabs.goForward(tab.id!);
806
- await waitForTabComplete(tab.id!);
807
- return { ok: true };
808
- });
809
- }
1280
+ await waitForTabComplete(tab.id!);
1281
+ return { ok: true };
1282
+ });
1283
+ }
810
1284
  case 'page.reload': {
811
1285
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
812
1286
  const tab = await withTab(target);
1287
+ void ensureNetworkDebugger(tab.id!).catch(() => undefined);
813
1288
  await chrome.tabs.reload(tab.id!);
814
- await waitForTabComplete(tab.id!);
815
- return { ok: true };
816
- });
817
- }
1289
+ await waitForTabComplete(tab.id!);
1290
+ return { ok: true };
1291
+ });
1292
+ }
818
1293
  case 'page.viewport': {
819
1294
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
820
1295
  const tab = await withTab(target, {
821
1296
  requireSupportedAutomationUrl: false
822
1297
  });
823
- if (typeof tab.windowId !== 'number') {
824
- throw toError('E_NOT_FOUND', 'Tab window unavailable');
825
- }
826
-
827
- const width = typeof params.width === 'number' ? Math.max(320, Math.floor(params.width)) : undefined;
828
- const height = typeof params.height === 'number' ? Math.max(320, Math.floor(params.height)) : undefined;
829
- if (width || height) {
830
- await chrome.windows.update(tab.windowId, {
831
- width,
832
- height
833
- });
834
- }
835
-
836
- const viewport = (await forwardContentRpc(tab.id!, 'page.viewport', {})) as {
837
- width: number;
838
- height: number;
839
- devicePixelRatio: number;
840
- };
841
- const viewWidth = typeof width === 'number' ? width : viewport.width ?? tab.width ?? 0;
842
- const viewHeight = typeof height === 'number' ? height : viewport.height ?? tab.height ?? 0;
843
- return {
844
- width: viewWidth,
845
- height: viewHeight,
846
- devicePixelRatio: viewport.devicePixelRatio
1298
+ if (typeof tab.windowId !== 'number') {
1299
+ throw toError('E_NOT_FOUND', 'Tab window unavailable');
1300
+ }
1301
+
1302
+ const width = typeof params.width === 'number' ? Math.max(320, Math.floor(params.width)) : undefined;
1303
+ const height = typeof params.height === 'number' ? Math.max(320, Math.floor(params.height)) : undefined;
1304
+ if (width || height) {
1305
+ await chrome.windows.update(tab.windowId, {
1306
+ width,
1307
+ height
1308
+ });
1309
+ }
1310
+
1311
+ const viewport = (await forwardContentRpc(tab.id!, 'page.viewport', {})) as {
1312
+ width: number;
1313
+ height: number;
1314
+ devicePixelRatio: number;
1315
+ };
1316
+ const viewWidth = typeof width === 'number' ? width : viewport.width ?? tab.width ?? 0;
1317
+ const viewHeight = typeof height === 'number' ? height : viewport.height ?? tab.height ?? 0;
1318
+ return {
1319
+ width: viewWidth,
1320
+ height: viewHeight,
1321
+ devicePixelRatio: viewport.devicePixelRatio
847
1322
  };
848
1323
  });
849
1324
  }
1325
+ case 'page.eval': {
1326
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1327
+ const tab = await withTab(target);
1328
+ return await executePageWorld(tab.id!, 'eval', params);
1329
+ });
1330
+ }
1331
+ case 'page.extract': {
1332
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1333
+ const tab = await withTab(target);
1334
+ return await executePageWorld(tab.id!, 'extract', params);
1335
+ });
1336
+ }
1337
+ case 'page.fetch': {
1338
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1339
+ const tab = await withTab(target);
1340
+ return await executePageWorld<PageFetchResponse>(tab.id!, 'fetch', params);
1341
+ });
1342
+ }
850
1343
  case 'page.snapshot': {
851
1344
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
852
1345
  const tab = await withTab(target);
853
1346
  if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
854
- throw toError('E_NOT_FOUND', 'Tab missing id');
855
- }
856
- const includeBase64 = params.includeBase64 !== false;
857
- const config = await getConfig();
858
- const elements = await sendToContent<{ elements: unknown[] }>(tab.id, {
859
- type: 'bak.collectElements',
860
- debugRichText: config.debugRichText
1347
+ throw toError('E_NOT_FOUND', 'Tab missing id');
1348
+ }
1349
+ const includeBase64 = params.includeBase64 !== false;
1350
+ const config = await getConfig();
1351
+ const elements = await sendToContent<{ elements: unknown[] }>(tab.id, {
1352
+ type: 'bak.collectElements',
1353
+ debugRichText: config.debugRichText
1354
+ });
1355
+ const imageData = await captureAlignedTabScreenshot(tab);
1356
+ return {
1357
+ imageBase64: includeBase64 ? imageData.replace(/^data:image\/png;base64,/, '') : '',
1358
+ elements: elements.elements,
1359
+ tabId: tab.id,
1360
+ url: tab.url ?? ''
1361
+ };
1362
+ });
1363
+ }
1364
+ case 'element.click': {
1365
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1366
+ const tab = await withTab(target);
1367
+ const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
1368
+ type: 'bak.performAction',
1369
+ action: 'click',
1370
+ locator: params.locator as Locator,
1371
+ requiresConfirm: params.requiresConfirm === true
1372
+ });
1373
+ if (!response.ok) {
1374
+ throw response.error ?? toError('E_INTERNAL', 'element.click failed');
1375
+ }
1376
+ return { ok: true };
1377
+ });
1378
+ }
1379
+ case 'element.type': {
1380
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1381
+ const tab = await withTab(target);
1382
+ const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
1383
+ type: 'bak.performAction',
1384
+ action: 'type',
1385
+ locator: params.locator as Locator,
1386
+ text: String(params.text ?? ''),
1387
+ clear: Boolean(params.clear),
1388
+ requiresConfirm: params.requiresConfirm === true
1389
+ });
1390
+ if (!response.ok) {
1391
+ throw response.error ?? toError('E_INTERNAL', 'element.type failed');
1392
+ }
1393
+ return { ok: true };
1394
+ });
1395
+ }
1396
+ case 'element.scroll': {
1397
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1398
+ const tab = await withTab(target);
1399
+ const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
1400
+ type: 'bak.performAction',
1401
+ action: 'scroll',
1402
+ locator: params.locator as Locator,
1403
+ dx: Number(params.dx ?? 0),
1404
+ dy: Number(params.dy ?? 320)
1405
+ });
1406
+ if (!response.ok) {
1407
+ throw response.error ?? toError('E_INTERNAL', 'element.scroll failed');
1408
+ }
1409
+ return { ok: true };
1410
+ });
1411
+ }
1412
+ case 'page.wait': {
1413
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1414
+ const tab = await withTab(target);
1415
+ const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
1416
+ type: 'bak.waitFor',
1417
+ mode: String(params.mode ?? 'selector'),
1418
+ value: String(params.value ?? ''),
1419
+ timeoutMs: Number(params.timeoutMs ?? 5000)
1420
+ });
1421
+ if (!response.ok) {
1422
+ throw response.error ?? toError('E_TIMEOUT', 'page.wait failed');
1423
+ }
1424
+ return { ok: true };
1425
+ });
1426
+ }
1427
+ case 'debug.getConsole': {
1428
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1429
+ const tab = await withTab(target);
1430
+ const response = await sendToContent<{ entries: ConsoleEntry[] }>(tab.id!, {
1431
+ type: 'bak.getConsole',
1432
+ limit: Number(params.limit ?? 50)
861
1433
  });
862
- const imageData = await captureAlignedTabScreenshot(tab);
863
- return {
864
- imageBase64: includeBase64 ? imageData.replace(/^data:image\/png;base64,/, '') : '',
865
- elements: elements.elements,
866
- tabId: tab.id,
867
- url: tab.url ?? ''
868
- };
1434
+ return { entries: response.entries };
869
1435
  });
870
1436
  }
871
- case 'element.click': {
1437
+ case 'network.list': {
872
1438
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
873
1439
  const tab = await withTab(target);
874
- const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
875
- type: 'bak.performAction',
876
- action: 'click',
877
- locator: params.locator as Locator,
878
- requiresConfirm: params.requiresConfirm === true
879
- });
880
- if (!response.ok) {
881
- throw response.error ?? toError('E_INTERNAL', 'element.click failed');
1440
+ try {
1441
+ await ensureTabNetworkCapture(tab.id!);
1442
+ return {
1443
+ entries: listNetworkEntries(tab.id!, {
1444
+ limit: typeof params.limit === 'number' ? params.limit : undefined,
1445
+ urlIncludes: typeof params.urlIncludes === 'string' ? params.urlIncludes : undefined,
1446
+ status: typeof params.status === 'number' ? params.status : undefined,
1447
+ method: typeof params.method === 'string' ? params.method : undefined
1448
+ })
1449
+ };
1450
+ } catch {
1451
+ return await forwardContentRpc(tab.id!, 'network.list', params);
882
1452
  }
883
- return { ok: true };
884
1453
  });
885
1454
  }
886
- case 'element.type': {
1455
+ case 'network.get': {
887
1456
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
888
1457
  const tab = await withTab(target);
889
- const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
890
- type: 'bak.performAction',
891
- action: 'type',
892
- locator: params.locator as Locator,
893
- text: String(params.text ?? ''),
894
- clear: Boolean(params.clear),
895
- requiresConfirm: params.requiresConfirm === true
896
- });
897
- if (!response.ok) {
898
- throw response.error ?? toError('E_INTERNAL', 'element.type failed');
1458
+ try {
1459
+ await ensureTabNetworkCapture(tab.id!);
1460
+ const entry = getNetworkEntry(tab.id!, String(params.id ?? ''));
1461
+ if (!entry) {
1462
+ throw toError('E_NOT_FOUND', `network entry not found: ${String(params.id ?? '')}`);
1463
+ }
1464
+ const filtered = filterNetworkEntrySections(
1465
+ truncateNetworkEntry(entry, typeof params.bodyBytes === 'number' ? params.bodyBytes : undefined),
1466
+ params.include
1467
+ );
1468
+ return { entry: filtered };
1469
+ } catch (error) {
1470
+ if ((error as { code?: string } | undefined)?.code === 'E_NOT_FOUND') {
1471
+ throw error;
1472
+ }
1473
+ return await forwardContentRpc(tab.id!, 'network.get', params);
899
1474
  }
900
- return { ok: true };
901
1475
  });
902
1476
  }
903
- case 'element.scroll': {
1477
+ case 'network.search': {
904
1478
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
905
1479
  const tab = await withTab(target);
906
- const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
907
- type: 'bak.performAction',
908
- action: 'scroll',
909
- locator: params.locator as Locator,
910
- dx: Number(params.dx ?? 0),
911
- dy: Number(params.dy ?? 320)
912
- });
913
- if (!response.ok) {
914
- throw response.error ?? toError('E_INTERNAL', 'element.scroll failed');
1480
+ await ensureTabNetworkCapture(tab.id!);
1481
+ return {
1482
+ entries: searchNetworkEntries(
1483
+ tab.id!,
1484
+ String(params.pattern ?? ''),
1485
+ typeof params.limit === 'number' ? params.limit : 50
1486
+ )
1487
+ };
1488
+ });
1489
+ }
1490
+ case 'network.waitFor': {
1491
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1492
+ const tab = await withTab(target);
1493
+ try {
1494
+ await ensureTabNetworkCapture(tab.id!);
1495
+ } catch {
1496
+ return await forwardContentRpc(tab.id!, 'network.waitFor', params);
915
1497
  }
1498
+ return {
1499
+ entry: await waitForNetworkEntry(tab.id!, {
1500
+ limit: 1,
1501
+ urlIncludes: typeof params.urlIncludes === 'string' ? params.urlIncludes : undefined,
1502
+ status: typeof params.status === 'number' ? params.status : undefined,
1503
+ method: typeof params.method === 'string' ? params.method : undefined,
1504
+ timeoutMs: typeof params.timeoutMs === 'number' ? params.timeoutMs : undefined
1505
+ })
1506
+ };
1507
+ });
1508
+ }
1509
+ case 'network.clear': {
1510
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1511
+ const tab = await withTab(target);
1512
+ clearNetworkEntries(tab.id!);
1513
+ await forwardContentRpc(tab.id!, 'network.clear', params).catch(() => undefined);
916
1514
  return { ok: true };
917
1515
  });
918
1516
  }
919
- case 'page.wait': {
1517
+ case 'network.replay': {
920
1518
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
921
1519
  const tab = await withTab(target);
922
- const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
923
- type: 'bak.waitFor',
924
- mode: String(params.mode ?? 'selector'),
925
- value: String(params.value ?? ''),
926
- timeoutMs: Number(params.timeoutMs ?? 5000)
1520
+ await ensureTabNetworkCapture(tab.id!);
1521
+ const entry = getNetworkEntry(tab.id!, String(params.id ?? ''));
1522
+ if (!entry) {
1523
+ throw toError('E_NOT_FOUND', `network entry not found: ${String(params.id ?? '')}`);
1524
+ }
1525
+ if (entry.requestBodyTruncated === true) {
1526
+ throw toError('E_BODY_TOO_LARGE', 'captured request body was truncated and cannot be replayed safely', {
1527
+ requestId: entry.id,
1528
+ requestBytes: entry.requestBytes
1529
+ });
1530
+ }
1531
+ if (containsRedactionMarker(entry.requestBodyPreview)) {
1532
+ throw toError('E_EXECUTION', 'captured request body was redacted and cannot be replayed safely', {
1533
+ requestId: entry.id
1534
+ });
1535
+ }
1536
+ const replayed = await executePageWorld<PageFetchResponse>(tab.id!, 'fetch', {
1537
+ url: entry.url,
1538
+ method: entry.method,
1539
+ headers: replayHeadersFromEntry(entry),
1540
+ body: entry.requestBodyPreview,
1541
+ contentType: (() => {
1542
+ const requestHeaders = entry.requestHeaders ?? {};
1543
+ const contentTypeHeader = Object.keys(requestHeaders).find((name) => name.toLowerCase() === 'content-type');
1544
+ return contentTypeHeader ? requestHeaders[contentTypeHeader] : undefined;
1545
+ })(),
1546
+ mode: params.mode,
1547
+ timeoutMs: params.timeoutMs,
1548
+ maxBytes: params.maxBytes,
1549
+ scope: 'current'
927
1550
  });
928
- if (!response.ok) {
929
- throw response.error ?? toError('E_TIMEOUT', 'page.wait failed');
1551
+ const frameResult = replayed.result ?? replayed.results?.find((candidate) => candidate.value || candidate.error);
1552
+ if (frameResult?.error) {
1553
+ throw toError(frameResult.error.code ?? 'E_EXECUTION', frameResult.error.message, frameResult.error.details);
930
1554
  }
931
- return { ok: true };
1555
+ const first = frameResult?.value;
1556
+ if (!first) {
1557
+ throw toError('E_EXECUTION', 'network replay returned no response payload');
1558
+ }
1559
+ return first;
932
1560
  });
933
1561
  }
934
- case 'debug.getConsole': {
1562
+ case 'page.freshness': {
935
1563
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
936
1564
  const tab = await withTab(target);
937
- const response = await sendToContent<{ entries: ConsoleEntry[] }>(tab.id!, {
938
- type: 'bak.getConsole',
939
- limit: Number(params.limit ?? 50)
940
- });
941
- return { entries: response.entries };
1565
+ await ensureNetworkDebugger(tab.id!).catch(() => undefined);
1566
+ return await buildFreshnessForTab(tab.id!, params);
942
1567
  });
943
1568
  }
944
- case 'ui.selectCandidate': {
1569
+ case 'debug.dumpState': {
945
1570
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
946
1571
  const tab = await withTab(target);
947
- const response = await sendToContent<{ ok: boolean; selectedEid?: string; error?: CliResponse['error'] }>(
948
- tab.id!,
949
- {
950
- type: 'bak.selectCandidate',
951
- candidates: params.candidates
1572
+ await ensureNetworkDebugger(tab.id!).catch(() => undefined);
1573
+ const dump = (await forwardContentRpc(tab.id!, 'debug.dumpState', params)) as Record<string, unknown>;
1574
+ const inspection = await collectPageInspection(tab.id!, params);
1575
+ const network = listNetworkEntries(tab.id!, {
1576
+ limit: typeof params.networkLimit === 'number' ? params.networkLimit : 80
1577
+ });
1578
+ const sections = Array.isArray(params.section) ? new Set(params.section.map(String) as DebugDumpSection[]) : null;
1579
+ const result: Record<string, unknown> = {
1580
+ ...dump,
1581
+ network,
1582
+ scripts: inspection.scripts,
1583
+ globalsPreview: inspection.globalsPreview,
1584
+ storage: inspection.storage,
1585
+ frames: inspection.frames,
1586
+ networkSummary: {
1587
+ total: network.length,
1588
+ recent: network.slice(0, Math.min(10, network.length))
952
1589
  }
953
- );
954
- if (!response.ok || !response.selectedEid) {
955
- throw response.error ?? toError('E_NEED_USER_CONFIRM', 'User did not confirm candidate');
1590
+ };
1591
+ if (!sections || sections.size === 0) {
1592
+ return result;
1593
+ }
1594
+ const filtered: Record<string, unknown> = {
1595
+ url: result.url,
1596
+ title: result.title,
1597
+ context: result.context
1598
+ };
1599
+ if (sections.has('dom')) {
1600
+ filtered.dom = result.dom;
1601
+ }
1602
+ if (sections.has('visible-text')) {
1603
+ filtered.text = result.text;
1604
+ filtered.elements = result.elements;
1605
+ }
1606
+ if (sections.has('scripts')) {
1607
+ filtered.scripts = result.scripts;
1608
+ }
1609
+ if (sections.has('globals-preview')) {
1610
+ filtered.globalsPreview = result.globalsPreview;
1611
+ }
1612
+ if (sections.has('network-summary')) {
1613
+ filtered.networkSummary = result.networkSummary;
956
1614
  }
957
- return { selectedEid: response.selectedEid };
1615
+ if (sections.has('storage')) {
1616
+ filtered.storage = result.storage;
1617
+ }
1618
+ if (sections.has('frames')) {
1619
+ filtered.frames = result.frames;
1620
+ }
1621
+ if (params.includeAccessibility === true && 'accessibility' in result) {
1622
+ filtered.accessibility = result.accessibility;
1623
+ }
1624
+ if ('snapshot' in result) {
1625
+ filtered.snapshot = result.snapshot;
1626
+ }
1627
+ return filtered;
958
1628
  });
959
1629
  }
960
- default:
961
- if (rpcForwardMethods.has(request.method)) {
962
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
963
- const tab = await withTab(target);
964
- return await forwardContentRpc(tab.id!, request.method, {
965
- ...params,
966
- tabId: tab.id
967
- });
968
- });
969
- }
970
- throw toError('E_NOT_FOUND', `Unsupported method from CLI bridge: ${request.method}`);
971
- }
972
- }
973
-
974
- function scheduleReconnect(reason: string): void {
975
- if (manualDisconnect) {
976
- return;
977
- }
978
- if (reconnectTimer !== null) {
979
- return;
980
- }
981
-
982
- const delayMs = computeReconnectDelayMs(reconnectAttempt);
983
- reconnectAttempt += 1;
984
- nextReconnectInMs = delayMs;
985
- reconnectTimer = setTimeout(() => {
986
- reconnectTimer = null;
987
- nextReconnectInMs = null;
988
- void connectWebSocket();
989
- }, delayMs) as unknown as number;
990
-
991
- if (!lastError) {
992
- setRuntimeError(`Reconnect scheduled: ${reason}`, 'socket');
993
- }
994
- }
995
-
996
- async function connectWebSocket(): Promise<void> {
997
- clearReconnectTimer();
998
- if (manualDisconnect) {
999
- return;
1000
- }
1001
-
1002
- if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
1003
- return;
1004
- }
1005
-
1006
- const config = await getConfig();
1007
- if (!config.token) {
1008
- setRuntimeError('Pair token is empty', 'config');
1009
- return;
1010
- }
1011
-
1012
- const url = `ws://127.0.0.1:${config.port}/extension?token=${encodeURIComponent(config.token)}`;
1013
- ws = new WebSocket(url);
1014
-
1015
- ws.addEventListener('open', () => {
1016
- manualDisconnect = false;
1017
- reconnectAttempt = 0;
1018
- lastError = null;
1019
- ws?.send(JSON.stringify({
1020
- type: 'hello',
1021
- role: 'extension',
1022
- version: '0.3.8',
1023
- ts: Date.now()
1024
- }));
1025
- });
1026
-
1027
- ws.addEventListener('message', (event) => {
1028
- try {
1029
- const request = JSON.parse(String(event.data)) as CliRequest;
1030
- if (!request.id || !request.method) {
1031
- return;
1032
- }
1033
- void handleRequest(request)
1034
- .then((result) => {
1035
- sendResponse({ id: request.id, ok: true, result });
1036
- })
1037
- .catch((error: unknown) => {
1038
- const normalized = normalizeUnhandledError(error);
1039
- sendResponse({ id: request.id, ok: false, error: normalized });
1040
- });
1041
- } catch (error) {
1042
- setRuntimeError(error instanceof Error ? error.message : String(error), 'parse');
1043
- sendResponse({
1044
- id: 'parse-error',
1045
- ok: false,
1046
- error: toError('E_INTERNAL', error instanceof Error ? error.message : String(error))
1630
+ case 'inspect.pageData': {
1631
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1632
+ const tab = await withTab(target);
1633
+ await ensureNetworkDebugger(tab.id!).catch(() => undefined);
1634
+ const inspection = await collectPageInspection(tab.id!, params);
1635
+ const network = listNetworkEntries(tab.id!, { limit: 10 });
1636
+ return {
1637
+ suspiciousGlobals: inspection.suspiciousGlobals ?? [],
1638
+ tables: inspection.tables ?? [],
1639
+ visibleTimestamps: inspection.visibleTimestamps ?? [],
1640
+ inlineTimestamps: inspection.inlineTimestamps ?? [],
1641
+ recentNetwork: network,
1642
+ recommendedNextSteps: [
1643
+ 'bak page extract --path table_data',
1644
+ 'bak network search --pattern table_data',
1645
+ 'bak page freshness'
1646
+ ]
1647
+ };
1047
1648
  });
1048
1649
  }
1049
- });
1050
-
1051
- ws.addEventListener('close', () => {
1052
- ws = null;
1053
- scheduleReconnect('socket-closed');
1054
- });
1055
-
1056
- ws.addEventListener('error', () => {
1057
- setRuntimeError('Cannot connect to bak cli', 'socket');
1058
- ws?.close();
1059
- });
1060
- }
1061
-
1062
- chrome.tabs.onRemoved.addListener((tabId) => {
1063
- void loadWorkspaceState().then(async (state) => {
1064
- if (!state || !state.tabIds.includes(tabId)) {
1065
- return;
1650
+ case 'inspect.liveUpdates': {
1651
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1652
+ const tab = await withTab(target);
1653
+ await ensureNetworkDebugger(tab.id!).catch(() => undefined);
1654
+ const inspection = await collectPageInspection(tab.id!, params);
1655
+ const network = listNetworkEntries(tab.id!, { limit: 25 });
1656
+ return {
1657
+ lastMutationAt: inspection.lastMutationAt ?? null,
1658
+ timers: inspection.timers ?? { timeouts: 0, intervals: 0 },
1659
+ networkCount: network.length,
1660
+ recentNetwork: network.slice(0, 10)
1661
+ };
1662
+ });
1066
1663
  }
1067
- const nextTabIds = state.tabIds.filter((id) => id !== tabId);
1068
- await saveWorkspaceState({
1069
- ...state,
1070
- tabIds: nextTabIds,
1071
- activeTabId: state.activeTabId === tabId ? null : state.activeTabId,
1072
- primaryTabId: state.primaryTabId === tabId ? null : state.primaryTabId
1073
- });
1074
- });
1075
- });
1076
-
1077
- chrome.tabs.onActivated.addListener((activeInfo) => {
1078
- void loadWorkspaceState().then(async (state) => {
1079
- if (!state || state.windowId !== activeInfo.windowId || !state.tabIds.includes(activeInfo.tabId)) {
1080
- return;
1664
+ case 'inspect.freshness': {
1665
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1666
+ const tab = await withTab(target);
1667
+ const freshness = await buildFreshnessForTab(tab.id!, params);
1668
+ return {
1669
+ ...freshness,
1670
+ lagMs:
1671
+ typeof freshness.latestNetworkTimestamp === 'number' && typeof freshness.latestInlineDataTimestamp === 'number'
1672
+ ? Math.max(0, freshness.latestNetworkTimestamp - freshness.latestInlineDataTimestamp)
1673
+ : null
1674
+ };
1675
+ });
1081
1676
  }
1082
- await saveWorkspaceState({
1083
- ...state,
1084
- activeTabId: activeInfo.tabId
1085
- });
1086
- });
1087
- });
1088
-
1089
- chrome.windows.onRemoved.addListener((windowId) => {
1090
- void loadWorkspaceState().then(async (state) => {
1091
- if (!state || state.windowId !== windowId) {
1092
- return;
1677
+ case 'capture.snapshot': {
1678
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1679
+ const tab = await withTab(target);
1680
+ await ensureNetworkDebugger(tab.id!).catch(() => undefined);
1681
+ const inspection = await collectPageInspection(tab.id!, params);
1682
+ return {
1683
+ url: inspection.url ?? tab.url ?? '',
1684
+ title: inspection.title ?? tab.title ?? '',
1685
+ html: inspection.html ?? '',
1686
+ visibleText: inspection.visibleText ?? [],
1687
+ cookies: inspection.cookies ?? [],
1688
+ storage: inspection.storage ?? { localStorageKeys: [], sessionStorageKeys: [] },
1689
+ context: inspection.context ?? { tabId: tab.id, framePath: [], shadowPath: [] },
1690
+ freshness: await buildFreshnessForTab(tab.id!, params),
1691
+ network: listNetworkEntries(tab.id!, {
1692
+ limit: typeof params.networkLimit === 'number' ? params.networkLimit : 20
1693
+ }),
1694
+ capturedAt: Date.now()
1695
+ };
1696
+ });
1093
1697
  }
1094
- await saveWorkspaceState({
1095
- ...state,
1096
- windowId: null,
1097
- groupId: null,
1098
- tabIds: [],
1099
- activeTabId: null,
1100
- primaryTabId: null
1101
- });
1102
- });
1103
- });
1104
-
1105
- chrome.runtime.onInstalled.addListener(() => {
1106
- void setConfig({ port: DEFAULT_PORT, debugRichText: false });
1107
- });
1108
-
1109
- chrome.runtime.onStartup.addListener(() => {
1110
- void connectWebSocket();
1111
- });
1112
-
1113
- void connectWebSocket();
1114
-
1115
- chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
1116
- if (message?.type === 'bak.updateConfig') {
1117
- manualDisconnect = false;
1118
- void setConfig({
1119
- token: message.token,
1120
- port: Number(message.port ?? DEFAULT_PORT),
1121
- debugRichText: message.debugRichText === true
1122
- }).then(() => {
1123
- ws?.close();
1124
- void connectWebSocket().then(() => sendResponse({ ok: true }));
1125
- });
1126
- return true;
1127
- }
1128
-
1129
- if (message?.type === 'bak.getState') {
1130
- void getConfig().then((config) => {
1131
- sendResponse({
1132
- ok: true,
1133
- connected: ws?.readyState === WebSocket.OPEN,
1134
- hasToken: Boolean(config.token),
1135
- port: config.port,
1136
- debugRichText: config.debugRichText,
1137
- lastError: lastError?.message ?? null,
1138
- lastErrorAt: lastError?.at ?? null,
1139
- lastErrorContext: lastError?.context ?? null,
1140
- reconnectAttempt,
1141
- nextReconnectInMs
1698
+ case 'capture.har': {
1699
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1700
+ const tab = await withTab(target);
1701
+ await ensureTabNetworkCapture(tab.id!);
1702
+ return {
1703
+ har: exportHar(tab.id!, typeof params.limit === 'number' ? params.limit : undefined)
1704
+ };
1142
1705
  });
1143
- });
1144
- return true;
1145
- }
1146
-
1147
- if (message?.type === 'bak.disconnect') {
1148
- manualDisconnect = true;
1149
- clearReconnectTimer();
1150
- reconnectAttempt = 0;
1151
- ws?.close();
1152
- ws = null;
1153
- sendResponse({ ok: true });
1154
- return false;
1155
- }
1156
-
1157
- return false;
1706
+ }
1707
+ case 'ui.selectCandidate': {
1708
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1709
+ const tab = await withTab(target);
1710
+ const response = await sendToContent<{ ok: boolean; selectedEid?: string; error?: CliResponse['error'] }>(
1711
+ tab.id!,
1712
+ {
1713
+ type: 'bak.selectCandidate',
1714
+ candidates: params.candidates
1715
+ }
1716
+ );
1717
+ if (!response.ok || !response.selectedEid) {
1718
+ throw response.error ?? toError('E_NEED_USER_CONFIRM', 'User did not confirm candidate');
1719
+ }
1720
+ return { selectedEid: response.selectedEid };
1721
+ });
1722
+ }
1723
+ default:
1724
+ if (rpcForwardMethods.has(request.method)) {
1725
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1726
+ const tab = await withTab(target);
1727
+ return await forwardContentRpc(tab.id!, request.method, {
1728
+ ...params,
1729
+ tabId: tab.id
1730
+ });
1731
+ });
1732
+ }
1733
+ throw toError('E_NOT_FOUND', `Unsupported method from CLI bridge: ${request.method}`);
1734
+ }
1735
+ }
1736
+
1737
+ function scheduleReconnect(reason: string): void {
1738
+ if (manualDisconnect) {
1739
+ return;
1740
+ }
1741
+ if (reconnectTimer !== null) {
1742
+ return;
1743
+ }
1744
+
1745
+ const delayMs = computeReconnectDelayMs(reconnectAttempt);
1746
+ reconnectAttempt += 1;
1747
+ nextReconnectInMs = delayMs;
1748
+ reconnectTimer = setTimeout(() => {
1749
+ reconnectTimer = null;
1750
+ nextReconnectInMs = null;
1751
+ void connectWebSocket();
1752
+ }, delayMs) as unknown as number;
1753
+
1754
+ if (!lastError) {
1755
+ setRuntimeError(`Reconnect scheduled: ${reason}`, 'socket');
1756
+ }
1757
+ }
1758
+
1759
+ async function connectWebSocket(): Promise<void> {
1760
+ clearReconnectTimer();
1761
+ if (manualDisconnect) {
1762
+ return;
1763
+ }
1764
+
1765
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
1766
+ return;
1767
+ }
1768
+
1769
+ const config = await getConfig();
1770
+ if (!config.token) {
1771
+ setRuntimeError('Pair token is empty', 'config');
1772
+ return;
1773
+ }
1774
+
1775
+ const url = `ws://127.0.0.1:${config.port}/extension?token=${encodeURIComponent(config.token)}`;
1776
+ ws = new WebSocket(url);
1777
+
1778
+ ws.addEventListener('open', () => {
1779
+ manualDisconnect = false;
1780
+ reconnectAttempt = 0;
1781
+ lastError = null;
1782
+ ws?.send(JSON.stringify({
1783
+ type: 'hello',
1784
+ role: 'extension',
1785
+ version: '0.6.0',
1786
+ ts: Date.now()
1787
+ }));
1788
+ });
1789
+
1790
+ ws.addEventListener('message', (event) => {
1791
+ try {
1792
+ const request = JSON.parse(String(event.data)) as CliRequest;
1793
+ if (!request.id || !request.method) {
1794
+ return;
1795
+ }
1796
+ void handleRequest(request)
1797
+ .then((result) => {
1798
+ sendResponse({ id: request.id, ok: true, result });
1799
+ })
1800
+ .catch((error: unknown) => {
1801
+ const normalized = normalizeUnhandledError(error);
1802
+ sendResponse({ id: request.id, ok: false, error: normalized });
1803
+ });
1804
+ } catch (error) {
1805
+ setRuntimeError(error instanceof Error ? error.message : String(error), 'parse');
1806
+ sendResponse({
1807
+ id: 'parse-error',
1808
+ ok: false,
1809
+ error: toError('E_INTERNAL', error instanceof Error ? error.message : String(error))
1810
+ });
1811
+ }
1812
+ });
1813
+
1814
+ ws.addEventListener('close', () => {
1815
+ ws = null;
1816
+ scheduleReconnect('socket-closed');
1817
+ });
1818
+
1819
+ ws.addEventListener('error', () => {
1820
+ setRuntimeError('Cannot connect to bak cli', 'socket');
1821
+ ws?.close();
1822
+ });
1823
+ }
1824
+
1825
+ chrome.tabs.onRemoved.addListener((tabId) => {
1826
+ dropNetworkCapture(tabId);
1827
+ void listWorkspaceStates().then(async (states) => {
1828
+ for (const state of states) {
1829
+ if (!state.tabIds.includes(tabId)) {
1830
+ continue;
1831
+ }
1832
+ const nextTabIds = state.tabIds.filter((id) => id !== tabId);
1833
+ await saveWorkspaceState({
1834
+ ...state,
1835
+ tabIds: nextTabIds,
1836
+ activeTabId: state.activeTabId === tabId ? null : state.activeTabId,
1837
+ primaryTabId: state.primaryTabId === tabId ? null : state.primaryTabId
1838
+ });
1839
+ }
1840
+ });
1841
+ });
1842
+
1843
+ chrome.tabs.onActivated.addListener((activeInfo) => {
1844
+ void listWorkspaceStates().then(async (states) => {
1845
+ for (const state of states) {
1846
+ if (state.windowId !== activeInfo.windowId || !state.tabIds.includes(activeInfo.tabId)) {
1847
+ continue;
1848
+ }
1849
+ await saveWorkspaceState({
1850
+ ...state,
1851
+ activeTabId: activeInfo.tabId
1852
+ });
1853
+ }
1854
+ });
1855
+ });
1856
+
1857
+ chrome.windows.onRemoved.addListener((windowId) => {
1858
+ void listWorkspaceStates().then(async (states) => {
1859
+ for (const state of states) {
1860
+ if (state.windowId !== windowId) {
1861
+ continue;
1862
+ }
1863
+ await saveWorkspaceState({
1864
+ ...state,
1865
+ windowId: null,
1866
+ groupId: null,
1867
+ tabIds: [],
1868
+ activeTabId: null,
1869
+ primaryTabId: null
1870
+ });
1871
+ }
1872
+ });
1873
+ });
1874
+
1875
+ chrome.runtime.onInstalled.addListener(() => {
1876
+ void setConfig({ port: DEFAULT_PORT, debugRichText: false });
1877
+ });
1878
+
1879
+ chrome.runtime.onStartup.addListener(() => {
1880
+ void connectWebSocket();
1881
+ });
1882
+
1883
+ void connectWebSocket();
1884
+
1885
+ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
1886
+ if (message?.type === 'bak.updateConfig') {
1887
+ manualDisconnect = false;
1888
+ void setConfig({
1889
+ token: message.token,
1890
+ port: Number(message.port ?? DEFAULT_PORT),
1891
+ debugRichText: message.debugRichText === true
1892
+ }).then(() => {
1893
+ ws?.close();
1894
+ void connectWebSocket().then(() => sendResponse({ ok: true }));
1895
+ });
1896
+ return true;
1897
+ }
1898
+
1899
+ if (message?.type === 'bak.getState') {
1900
+ void getConfig().then((config) => {
1901
+ sendResponse({
1902
+ ok: true,
1903
+ connected: ws?.readyState === WebSocket.OPEN,
1904
+ hasToken: Boolean(config.token),
1905
+ port: config.port,
1906
+ debugRichText: config.debugRichText,
1907
+ lastError: lastError?.message ?? null,
1908
+ lastErrorAt: lastError?.at ?? null,
1909
+ lastErrorContext: lastError?.context ?? null,
1910
+ reconnectAttempt,
1911
+ nextReconnectInMs
1912
+ });
1913
+ });
1914
+ return true;
1915
+ }
1916
+
1917
+ if (message?.type === 'bak.disconnect') {
1918
+ manualDisconnect = true;
1919
+ clearReconnectTimer();
1920
+ reconnectAttempt = 0;
1921
+ ws?.close();
1922
+ ws = null;
1923
+ sendResponse({ ok: true });
1924
+ return false;
1925
+ }
1926
+
1927
+ return false;
1158
1928
  });
1159
1929
 
1160
1930