@flrande/bak-extension 0.6.14 → 0.6.16

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
@@ -25,18 +25,18 @@ import {
25
25
  clearNetworkEntries,
26
26
  dropNetworkCapture,
27
27
  ensureNetworkDebugger,
28
- exportHar,
29
- getNetworkEntry,
30
- latestNetworkTimestamp,
31
- listNetworkEntries,
32
- recentNetworkSampleIds,
33
- searchNetworkEntries,
34
- waitForNetworkEntry
28
+ exportHar,
29
+ getNetworkEntry,
30
+ getReplayableNetworkRequest,
31
+ latestNetworkTimestamp,
32
+ listNetworkEntries,
33
+ recentNetworkSampleIds,
34
+ searchNetworkEntries,
35
+ waitForNetworkEntry
35
36
  } from './network-debugger.js';
36
37
  import { isSupportedAutomationUrl } from './url-policy.js';
37
38
  import { computeReconnectDelayMs } from './reconnect.js';
38
39
  import { resolveSessionBindingStateMap, STORAGE_KEY_SESSION_BINDINGS } from './session-binding-storage.js';
39
- import { containsRedactionMarker } from './privacy.js';
40
40
  import {
41
41
  type SessionBindingBrowser,
42
42
  type SessionBindingColor,
@@ -70,12 +70,14 @@ interface ExtensionConfig {
70
70
  debugRichText: boolean;
71
71
  }
72
72
 
73
- interface RuntimeErrorDetails {
74
- message: string;
75
- context: 'config' | 'socket' | 'request' | 'parse';
76
- at: number;
77
- }
78
-
73
+ interface RuntimeErrorDetails {
74
+ message: string;
75
+ context: 'config' | 'socket' | 'request' | 'parse';
76
+ at: number;
77
+ }
78
+
79
+ type PopupSessionBindingStatus = 'attached' | 'window-only' | 'detached';
80
+
79
81
  interface PopupSessionBindingSummary {
80
82
  id: string;
81
83
  label: string;
@@ -85,7 +87,7 @@ interface PopupSessionBindingSummary {
85
87
  activeTabUrl: string | null;
86
88
  windowId: number | null;
87
89
  groupId: number | null;
88
- detached: boolean;
90
+ status: PopupSessionBindingStatus;
89
91
  lastBindingUpdateAt: number | null;
90
92
  lastBindingUpdateReason: string | null;
91
93
  }
@@ -107,14 +109,15 @@ interface PopupState {
107
109
  extensionVersion: string;
108
110
  lastBindingUpdateAt: number | null;
109
111
  lastBindingUpdateReason: string | null;
110
- sessionBindings: {
111
- count: number;
112
- attachedCount: number;
113
- detachedCount: number;
114
- tabCount: number;
115
- items: PopupSessionBindingSummary[];
116
- };
117
- }
112
+ sessionBindings: {
113
+ count: number;
114
+ attachedCount: number;
115
+ windowOnlyCount: number;
116
+ detachedCount: number;
117
+ tabCount: number;
118
+ items: PopupSessionBindingSummary[];
119
+ };
120
+ }
118
121
 
119
122
  const DEFAULT_PORT = 17373;
120
123
  const STORAGE_KEY_TOKEN = 'pairToken';
@@ -344,44 +347,99 @@ async function loadSessionBindingState(bindingId: string): Promise<SessionBindin
344
347
  return stateMap[bindingId] ?? null;
345
348
  }
346
349
 
347
- async function listSessionBindingStates(): Promise<SessionBindingRecord[]> {
348
- return Object.values(await loadSessionBindingStateMap());
349
- }
350
-
351
- async function summarizeSessionBindings(states: SessionBindingRecord[]): Promise<PopupState['sessionBindings']> {
352
- const items = await Promise.all(
353
- states.map(async (state) => {
354
- const detached = state.windowId === null || state.tabIds.length === 0;
355
- const activeTab =
356
- typeof state.activeTabId === 'number' ? await sessionBindingBrowser.getTab(state.activeTabId) : null;
357
- const bindingUpdate = bindingUpdateMetadata.get(state.id);
358
- return {
359
- id: state.id,
360
- label: state.label,
361
- tabCount: state.tabIds.length,
362
- activeTabId: state.activeTabId,
363
- activeTabTitle: activeTab?.title ?? null,
364
- activeTabUrl: activeTab?.url ?? null,
365
- windowId: state.windowId,
366
- groupId: state.groupId,
367
- detached,
368
- lastBindingUpdateAt: bindingUpdate?.at ?? null,
369
- lastBindingUpdateReason: bindingUpdate?.reason ?? null
370
- } satisfies PopupSessionBindingSummary;
371
- })
372
- );
350
+ async function listSessionBindingStates(): Promise<SessionBindingRecord[]> {
351
+ return Object.values(await loadSessionBindingStateMap());
352
+ }
353
+
354
+ function collectPopupSessionBindingTabIds(state: SessionBindingRecord): number[] {
355
+ return [
356
+ ...new Set(state.tabIds.concat([state.activeTabId, state.primaryTabId].filter((value): value is number => typeof value === 'number')))
357
+ ];
358
+ }
359
+
360
+ async function inspectPopupSessionBinding(
361
+ state: SessionBindingRecord
362
+ ): Promise<{ summary: PopupSessionBindingSummary; prune: boolean }> {
363
+ const trackedTabs = (
364
+ await Promise.all(
365
+ collectPopupSessionBindingTabIds(state).map(async (tabId) => {
366
+ return await sessionBindingBrowser.getTab(tabId);
367
+ })
368
+ )
369
+ ).filter((tab): tab is SessionBindingTab => tab !== null);
370
+ const liveWindow = typeof state.windowId === 'number' ? await sessionBindingBrowser.getWindow(state.windowId) : null;
371
+ const activeTab = trackedTabs.find((tab) => tab.id === state.activeTabId) ?? trackedTabs.find((tab) => tab.active) ?? trackedTabs[0] ?? null;
372
+ const status: PopupSessionBindingStatus = trackedTabs.length > 0 ? 'attached' : liveWindow ? 'window-only' : 'detached';
373
+ const bindingUpdate = bindingUpdateMetadata.get(state.id);
374
+ return {
375
+ summary: {
376
+ id: state.id,
377
+ label: state.label,
378
+ tabCount: trackedTabs.length,
379
+ activeTabId: activeTab?.id ?? null,
380
+ activeTabTitle: activeTab?.title ?? null,
381
+ activeTabUrl: activeTab?.url ?? null,
382
+ windowId: activeTab?.windowId ?? trackedTabs[0]?.windowId ?? liveWindow?.id ?? state.windowId,
383
+ groupId:
384
+ trackedTabs.length > 0
385
+ ? activeTab?.groupId ?? trackedTabs.find((tab) => tab.groupId !== null)?.groupId ?? state.groupId
386
+ : null,
387
+ status,
388
+ lastBindingUpdateAt: bindingUpdate?.at ?? null,
389
+ lastBindingUpdateReason: bindingUpdate?.reason ?? null
390
+ },
391
+ prune: status === 'detached'
392
+ };
393
+ }
394
+
395
+ async function summarizeSessionBindings(): Promise<PopupState['sessionBindings']> {
396
+ const statusRank: Record<PopupSessionBindingStatus, number> = {
397
+ attached: 0,
398
+ 'window-only': 1,
399
+ detached: 2
400
+ };
401
+ const items = await mutateSessionBindingStateMap(async (stateMap) => {
402
+ const inspected = await Promise.all(
403
+ Object.entries(stateMap).map(async ([bindingId, state]) => {
404
+ return {
405
+ bindingId,
406
+ inspected: await inspectPopupSessionBinding(state)
407
+ };
408
+ })
409
+ );
410
+ for (const entry of inspected) {
411
+ if (entry.inspected.prune) {
412
+ delete stateMap[entry.bindingId];
413
+ }
414
+ }
415
+ return inspected
416
+ .filter((entry) => !entry.inspected.prune)
417
+ .map((entry) => entry.inspected.summary)
418
+ .sort((left, right) => {
419
+ const byStatus = statusRank[left.status] - statusRank[right.status];
420
+ if (byStatus !== 0) {
421
+ return byStatus;
422
+ }
423
+ const byUpdate = (right.lastBindingUpdateAt ?? 0) - (left.lastBindingUpdateAt ?? 0);
424
+ if (byUpdate !== 0) {
425
+ return byUpdate;
426
+ }
427
+ return left.label.localeCompare(right.label);
428
+ });
429
+ });
373
430
  return {
374
431
  count: items.length,
375
- attachedCount: items.filter((item) => !item.detached).length,
376
- detachedCount: items.filter((item) => item.detached).length,
377
- tabCount: items.reduce((sum, item) => sum + item.tabCount, 0),
378
- items
379
- };
380
- }
381
-
432
+ attachedCount: items.filter((item) => item.status === 'attached').length,
433
+ windowOnlyCount: items.filter((item) => item.status === 'window-only').length,
434
+ detachedCount: items.filter((item) => item.status === 'detached').length,
435
+ tabCount: items.reduce((sum, item) => sum + item.tabCount, 0),
436
+ items
437
+ };
438
+ }
439
+
382
440
  async function buildPopupState(): Promise<PopupState> {
383
441
  const config = await getConfig();
384
- const sessionBindings = await summarizeSessionBindings(await listSessionBindingStates());
442
+ const sessionBindings = await summarizeSessionBindings();
385
443
  const reconnectRemainingMs = nextReconnectAt === null ? null : Math.max(0, nextReconnectAt - Date.now());
386
444
  let connectionState: PopupState['connectionState'];
387
445
  if (!config.token) {
@@ -780,10 +838,18 @@ async function withTab(target: { tabId?: number; bindingId?: string } = {}, opti
780
838
  return validate(tab);
781
839
  }
782
840
 
783
- async function captureAlignedTabScreenshot(tab: chrome.tabs.Tab): Promise<string> {
784
- if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
785
- throw toError('E_NOT_FOUND', 'Tab screenshot requires tab id and window id');
786
- }
841
+ async function captureAlignedTabScreenshot(tab: chrome.tabs.Tab): Promise<{
842
+ captureStatus: 'complete' | 'degraded' | 'skipped';
843
+ imageData?: string;
844
+ captureError?: {
845
+ code?: string;
846
+ message: string;
847
+ details?: Record<string, unknown>;
848
+ };
849
+ }> {
850
+ if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
851
+ throw toError('E_NOT_FOUND', 'Tab screenshot requires tab id and window id');
852
+ }
787
853
 
788
854
  const activeTabs = await chrome.tabs.query({ windowId: tab.windowId, active: true });
789
855
  const activeTab = activeTabs[0];
@@ -793,13 +859,24 @@ async function captureAlignedTabScreenshot(tab: chrome.tabs.Tab): Promise<string
793
859
  await chrome.tabs.update(tab.id, { active: true });
794
860
  await new Promise((resolve) => setTimeout(resolve, 80));
795
861
  }
796
-
797
- try {
798
- return await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
799
- } finally {
800
- if (shouldSwitch && typeof activeTab?.id === 'number') {
801
- try {
802
- await chrome.tabs.update(activeTab.id, { active: true });
862
+
863
+ try {
864
+ return {
865
+ captureStatus: 'complete',
866
+ imageData: await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' })
867
+ };
868
+ } catch (error) {
869
+ return {
870
+ captureStatus: 'degraded',
871
+ captureError: {
872
+ code: 'E_CAPTURE_FAILED',
873
+ message: error instanceof Error ? error.message : String(error)
874
+ }
875
+ };
876
+ } finally {
877
+ if (shouldSwitch && typeof activeTab?.id === 'number') {
878
+ try {
879
+ await chrome.tabs.update(activeTab.id, { active: true });
803
880
  } catch {
804
881
  // Ignore restore errors if the original tab no longer exists.
805
882
  }
@@ -954,10 +1031,10 @@ async function executePageWorld<T>(
954
1031
  target,
955
1032
  world: 'MAIN',
956
1033
  args: [
957
- {
958
- action,
959
- scope,
960
- framePath,
1034
+ {
1035
+ action,
1036
+ scope,
1037
+ framePath,
961
1038
  expr: typeof params.expr === 'string' ? params.expr : '',
962
1039
  path: typeof params.path === 'string' ? params.path : '',
963
1040
  resolver: typeof params.resolver === 'string' ? params.resolver : undefined,
@@ -965,13 +1042,15 @@ async function executePageWorld<T>(
965
1042
  method: typeof params.method === 'string' ? params.method : 'GET',
966
1043
  headers: typeof params.headers === 'object' && params.headers !== null ? params.headers : undefined,
967
1044
  body: typeof params.body === 'string' ? params.body : undefined,
968
- contentType: typeof params.contentType === 'string' ? params.contentType : undefined,
969
- mode: params.mode === 'json' ? 'json' : 'raw',
970
- maxBytes: typeof params.maxBytes === 'number' ? params.maxBytes : undefined,
971
- timeoutMs: typeof params.timeoutMs === 'number' ? params.timeoutMs : undefined
972
- }
973
- ],
974
- func: async (payload) => {
1045
+ contentType: typeof params.contentType === 'string' ? params.contentType : undefined,
1046
+ mode: params.mode === 'json' ? 'json' : 'raw',
1047
+ maxBytes: typeof params.maxBytes === 'number' ? params.maxBytes : undefined,
1048
+ timeoutMs: typeof params.timeoutMs === 'number' ? params.timeoutMs : undefined,
1049
+ fullResponse: params.fullResponse === true,
1050
+ auth: params.auth === 'manual' || params.auth === 'off' ? params.auth : 'auto'
1051
+ }
1052
+ ],
1053
+ func: async (payload) => {
975
1054
  const serializeValue = (value: unknown, maxBytes?: number) => {
976
1055
  let cloned: unknown;
977
1056
  try {
@@ -1071,10 +1150,10 @@ async function executePageWorld<T>(
1071
1150
  return current;
1072
1151
  };
1073
1152
 
1074
- const resolveExtractValue = (
1075
- targetWindow: Window & { eval: (expr: string) => unknown },
1076
- path: string,
1077
- resolver: unknown
1153
+ const resolveExtractValue = (
1154
+ targetWindow: Window & { eval: (expr: string) => unknown },
1155
+ path: string,
1156
+ resolver: unknown
1078
1157
  ): { resolver: 'globalThis' | 'lexical'; value: unknown } => {
1079
1158
  const strategy = resolver === 'globalThis' || resolver === 'lexical' ? resolver : 'auto';
1080
1159
  const lexicalExpression = buildPathExpression(path);
@@ -1101,11 +1180,82 @@ async function executePageWorld<T>(
1101
1180
  throw error;
1102
1181
  }
1103
1182
  }
1104
- return { resolver: 'lexical', value: readLexical() };
1105
- };
1106
-
1107
- try {
1108
- const targetWindow = payload.scope === 'main' ? window : payload.scope === 'current' ? resolveFrameWindow(payload.framePath ?? []) : window;
1183
+ return { resolver: 'lexical', value: readLexical() };
1184
+ };
1185
+
1186
+ const findHeaderName = (headers: Record<string, string>, name: string): string | undefined =>
1187
+ Object.keys(headers).find((key) => key.toLowerCase() === name.toLowerCase());
1188
+
1189
+ const findCookieValue = (cookieString: string, name: string): string | undefined => {
1190
+ const targetName = `${name}=`;
1191
+ for (const segment of cookieString.split(';')) {
1192
+ const trimmed = segment.trim();
1193
+ if (trimmed.toLowerCase().startsWith(targetName.toLowerCase())) {
1194
+ return trimmed.slice(targetName.length);
1195
+ }
1196
+ }
1197
+ return undefined;
1198
+ };
1199
+
1200
+ const buildJsonSummary = (
1201
+ value: unknown
1202
+ ): { schema?: { columns: Array<{ key: string; label: string }> }; mappedRows?: Array<Record<string, unknown>> } => {
1203
+ const rowsCandidate = (() => {
1204
+ if (Array.isArray(value)) {
1205
+ return value;
1206
+ }
1207
+ if (typeof value !== 'object' || value === null) {
1208
+ return null;
1209
+ }
1210
+ const record = value as Record<string, unknown>;
1211
+ for (const key of ['data', 'rows', 'results', 'items']) {
1212
+ if (Array.isArray(record[key])) {
1213
+ return record[key] as unknown[];
1214
+ }
1215
+ }
1216
+ return null;
1217
+ })();
1218
+ if (Array.isArray(rowsCandidate) && rowsCandidate.length > 0) {
1219
+ const objectRows = rowsCandidate
1220
+ .filter((row): row is Record<string, unknown> => typeof row === 'object' && row !== null && !Array.isArray(row))
1221
+ .slice(0, 25);
1222
+ if (objectRows.length > 0) {
1223
+ const columns = [...new Set(objectRows.flatMap((row) => Object.keys(row)))].slice(0, 20);
1224
+ return {
1225
+ schema: {
1226
+ columns: columns.map((label, index) => ({
1227
+ key: `col_${index + 1}`,
1228
+ label
1229
+ }))
1230
+ },
1231
+ mappedRows: objectRows.map((row) => {
1232
+ const mapped: Record<string, unknown> = {};
1233
+ for (const column of columns) {
1234
+ mapped[column] = row[column];
1235
+ }
1236
+ return mapped;
1237
+ })
1238
+ };
1239
+ }
1240
+ }
1241
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
1242
+ const columns = Object.keys(value as Record<string, unknown>).slice(0, 20);
1243
+ if (columns.length > 0) {
1244
+ return {
1245
+ schema: {
1246
+ columns: columns.map((label, index) => ({
1247
+ key: `col_${index + 1}`,
1248
+ label
1249
+ }))
1250
+ }
1251
+ };
1252
+ }
1253
+ }
1254
+ return {};
1255
+ };
1256
+
1257
+ try {
1258
+ const targetWindow = payload.scope === 'main' ? window : payload.scope === 'current' ? resolveFrameWindow(payload.framePath ?? []) : window;
1109
1259
  if (payload.action === 'eval') {
1110
1260
  const evaluator = (targetWindow as Window & { eval: (expr: string) => unknown }).eval;
1111
1261
  const serialized = serializeValue(evaluator(payload.expr), payload.maxBytes);
@@ -1121,14 +1271,53 @@ async function executePageWorld<T>(
1121
1271
  bytes: serialized.bytes,
1122
1272
  resolver: extracted.resolver
1123
1273
  };
1124
- }
1125
- if (payload.action === 'fetch') {
1126
- const headers = { ...(payload.headers ?? {}) } as Record<string, string>;
1127
- if (payload.contentType && !headers['Content-Type']) {
1128
- headers['Content-Type'] = payload.contentType;
1129
- }
1130
- const controller = typeof AbortController === 'function' ? new AbortController() : null;
1131
- const timeoutId =
1274
+ }
1275
+ if (payload.action === 'fetch') {
1276
+ const headers = { ...(payload.headers ?? {}) } as Record<string, string>;
1277
+ if (payload.contentType && !findHeaderName(headers, 'Content-Type')) {
1278
+ headers['Content-Type'] = payload.contentType;
1279
+ }
1280
+ const fullResponse = payload.fullResponse === true;
1281
+ const authApplied: string[] = [];
1282
+ const authSources = new Set<string>();
1283
+ const requestUrl = new URL(payload.url, targetWindow.location.href);
1284
+ const sameOrigin = requestUrl.origin === targetWindow.location.origin;
1285
+ const authMode = payload.auth === 'manual' || payload.auth === 'off' ? payload.auth : 'auto';
1286
+ const maybeApplyHeader = (name: string, value: string | undefined, source: string): void => {
1287
+ if (!value || findHeaderName(headers, name)) {
1288
+ return;
1289
+ }
1290
+ headers[name] = value;
1291
+ authApplied.push(name);
1292
+ authSources.add(source);
1293
+ };
1294
+ if (sameOrigin && authMode === 'auto') {
1295
+ const xsrfCookie = findCookieValue(targetWindow.document.cookie ?? '', 'XSRF-TOKEN');
1296
+ if (xsrfCookie) {
1297
+ maybeApplyHeader('X-XSRF-TOKEN', decodeURIComponent(xsrfCookie), 'cookie:XSRF-TOKEN');
1298
+ }
1299
+ const metaTokens = [
1300
+ {
1301
+ selector: 'meta[name="xsrf-token"], meta[name="x-xsrf-token"]',
1302
+ header: 'X-XSRF-TOKEN',
1303
+ source: 'meta:xsrf-token'
1304
+ },
1305
+ {
1306
+ selector: 'meta[name="csrf-token"], meta[name="csrf_token"], meta[name="_csrf"]',
1307
+ header: 'X-CSRF-TOKEN',
1308
+ source: 'meta:csrf-token'
1309
+ }
1310
+ ];
1311
+ for (const token of metaTokens) {
1312
+ const meta = targetWindow.document.querySelector<HTMLMetaElement>(token.selector);
1313
+ const content = meta?.content?.trim();
1314
+ if (content) {
1315
+ maybeApplyHeader(token.header, content, token.source);
1316
+ }
1317
+ }
1318
+ }
1319
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
1320
+ const timeoutId =
1132
1321
  controller && typeof payload.timeoutMs === 'number' && payload.timeoutMs > 0
1133
1322
  ? window.setTimeout(() => controller.abort(), payload.timeoutMs)
1134
1323
  : null;
@@ -1153,43 +1342,56 @@ async function executePageWorld<T>(
1153
1342
  return {
1154
1343
  url: targetWindow.location.href,
1155
1344
  framePath: payload.scope === 'current' ? payload.framePath ?? [] : [],
1156
- value: (() => {
1157
- const encoder = typeof TextEncoder === 'function' ? new TextEncoder() : null;
1158
- const decoder = typeof TextDecoder === 'function' ? new TextDecoder() : null;
1159
- const previewLimit = typeof payload.maxBytes === 'number' && payload.maxBytes > 0 ? payload.maxBytes : 8192;
1160
- const encodedBody = encoder ? encoder.encode(bodyText) : null;
1161
- const bodyBytes = encodedBody ? encodedBody.byteLength : bodyText.length;
1162
- const truncated = bodyBytes > previewLimit;
1163
- if (payload.mode === 'json' && truncated) {
1164
- throw {
1165
- code: 'E_BODY_TOO_LARGE',
1166
- message: 'JSON response exceeds max-bytes',
1167
- details: {
1168
- bytes: bodyBytes,
1169
- maxBytes: previewLimit
1170
- }
1171
- };
1172
- }
1173
- const previewText =
1174
- encodedBody && decoder
1175
- ? decoder.decode(encodedBody.subarray(0, Math.min(encodedBody.byteLength, previewLimit)))
1176
- : truncated
1177
- ? bodyText.slice(0, previewLimit)
1178
- : bodyText;
1179
- return {
1180
- url: response.url,
1181
- status: response.status,
1182
- ok: response.ok,
1183
- headers: headerMap,
1184
- contentType: response.headers.get('content-type') ?? undefined,
1185
- bodyText: payload.mode === 'json' ? undefined : previewText,
1186
- json: payload.mode === 'json' && bodyText ? JSON.parse(bodyText) : undefined,
1187
- bytes: bodyBytes,
1188
- truncated
1189
- };
1190
- })()
1191
- };
1192
- }
1345
+ value: (() => {
1346
+ const encoder = typeof TextEncoder === 'function' ? new TextEncoder() : null;
1347
+ const decoder = typeof TextDecoder === 'function' ? new TextDecoder() : null;
1348
+ const previewLimit = !fullResponse && typeof payload.maxBytes === 'number' && payload.maxBytes > 0 ? payload.maxBytes : 8192;
1349
+ const encodedBody = encoder ? encoder.encode(bodyText) : null;
1350
+ const bodyBytes = encodedBody ? encodedBody.byteLength : bodyText.length;
1351
+ const truncated = !fullResponse && bodyBytes > previewLimit;
1352
+ const previewText =
1353
+ fullResponse
1354
+ ? bodyText
1355
+ : encodedBody && decoder
1356
+ ? decoder.decode(encodedBody.subarray(0, Math.min(encodedBody.byteLength, previewLimit)))
1357
+ : truncated
1358
+ ? bodyText.slice(0, previewLimit)
1359
+ : bodyText;
1360
+ const result: Record<string, unknown> = {
1361
+ url: response.url,
1362
+ status: response.status,
1363
+ ok: response.ok,
1364
+ headers: headerMap,
1365
+ contentType: response.headers.get('content-type') ?? undefined,
1366
+ bytes: bodyBytes,
1367
+ truncated,
1368
+ authApplied: authApplied.length > 0 ? authApplied : undefined,
1369
+ authSources: authSources.size > 0 ? [...authSources] : undefined
1370
+ };
1371
+ if (payload.mode === 'json') {
1372
+ const parsedJson = bodyText ? JSON.parse(bodyText) : undefined;
1373
+ const summary = buildJsonSummary(parsedJson);
1374
+ if (fullResponse || !truncated) {
1375
+ result.json = parsedJson;
1376
+ } else {
1377
+ result.degradedReason = 'response body exceeded max-bytes and was summarized';
1378
+ }
1379
+ if (summary.schema) {
1380
+ result.schema = summary.schema;
1381
+ }
1382
+ if (summary.mappedRows) {
1383
+ result.mappedRows = summary.mappedRows;
1384
+ }
1385
+ } else {
1386
+ result.bodyText = previewText;
1387
+ if (truncated) {
1388
+ result.degradedReason = 'response body exceeded max-bytes and was truncated';
1389
+ }
1390
+ }
1391
+ return result;
1392
+ })()
1393
+ };
1394
+ }
1193
1395
  throw { code: 'E_NOT_FOUND', message: `Unsupported page world action: ${payload.action}` };
1194
1396
  } catch (error) {
1195
1397
  return {
@@ -1269,23 +1471,20 @@ function filterNetworkEntrySections(entry: NetworkEntry, include: unknown): Netw
1269
1471
  return clone;
1270
1472
  }
1271
1473
 
1272
- function replayHeadersFromEntry(entry: NetworkEntry): Record<string, string> | undefined {
1273
- if (!entry.requestHeaders) {
1274
- return undefined;
1275
- }
1276
- const headers: Record<string, string> = {};
1277
- for (const [name, value] of Object.entries(entry.requestHeaders)) {
1278
- const normalizedName = name.toLowerCase();
1279
- if (REPLAY_FORBIDDEN_HEADER_NAMES.has(normalizedName) || normalizedName.startsWith('sec-')) {
1280
- continue;
1281
- }
1282
- if (containsRedactionMarker(value)) {
1283
- continue;
1284
- }
1285
- headers[name] = value;
1286
- }
1287
- return Object.keys(headers).length > 0 ? headers : undefined;
1288
- }
1474
+ function replayHeadersFromRequestHeaders(requestHeaders: Record<string, string> | undefined): Record<string, string> | undefined {
1475
+ if (!requestHeaders) {
1476
+ return undefined;
1477
+ }
1478
+ const headers: Record<string, string> = {};
1479
+ for (const [name, value] of Object.entries(requestHeaders)) {
1480
+ const normalizedName = name.toLowerCase();
1481
+ if (REPLAY_FORBIDDEN_HEADER_NAMES.has(normalizedName) || normalizedName.startsWith('sec-')) {
1482
+ continue;
1483
+ }
1484
+ headers[name] = value;
1485
+ }
1486
+ return Object.keys(headers).length > 0 ? headers : undefined;
1487
+ }
1289
1488
 
1290
1489
  function collectTimestampMatchesFromText(text: string, source: TimestampEvidenceCandidate['source'], patterns?: string[]): TimestampEvidenceCandidate[] {
1291
1490
  const regexes = (
@@ -1403,7 +1602,7 @@ function latestTimestampFromCandidates(
1403
1602
  return latest;
1404
1603
  }
1405
1604
 
1406
- function computeFreshnessAssessment(input: {
1605
+ function computeFreshnessAssessment(input: {
1407
1606
  latestInlineDataTimestamp: number | null;
1408
1607
  latestPageDataTimestamp: number | null;
1409
1608
  latestNetworkDataTimestamp: number | null;
@@ -1440,10 +1639,79 @@ function computeFreshnessAssessment(input: {
1440
1639
  .filter((value): value is number => typeof value === 'number');
1441
1640
  if (staleSignals.length > 0 && staleSignals.every((value) => now - value > input.staleWindowMs)) {
1442
1641
  return 'stale';
1443
- }
1444
- return 'unknown';
1445
- }
1446
-
1642
+ }
1643
+ return 'unknown';
1644
+ }
1645
+
1646
+ function freshnessCategoryPriority(category: PageFreshnessResult['evidence']['classifiedTimestamps'][number]['category']): number {
1647
+ switch (category) {
1648
+ case 'data':
1649
+ return 0;
1650
+ case 'unknown':
1651
+ return 1;
1652
+ case 'event':
1653
+ return 2;
1654
+ case 'contract':
1655
+ return 3;
1656
+ default:
1657
+ return 4;
1658
+ }
1659
+ }
1660
+
1661
+ function freshnessSourcePriority(source: PageFreshnessResult['evidence']['classifiedTimestamps'][number]['source']): number {
1662
+ switch (source) {
1663
+ case 'network':
1664
+ return 0;
1665
+ case 'page-data':
1666
+ return 1;
1667
+ case 'visible':
1668
+ return 2;
1669
+ case 'inline':
1670
+ return 3;
1671
+ default:
1672
+ return 4;
1673
+ }
1674
+ }
1675
+
1676
+ function rankFreshnessEvidence(
1677
+ candidates: PageFreshnessResult['evidence']['classifiedTimestamps'],
1678
+ now = Date.now()
1679
+ ): PageFreshnessResult['evidence']['classifiedTimestamps'] {
1680
+ return candidates
1681
+ .slice()
1682
+ .sort((left, right) => {
1683
+ const byCategory = freshnessCategoryPriority(left.category) - freshnessCategoryPriority(right.category);
1684
+ if (byCategory !== 0) {
1685
+ return byCategory;
1686
+ }
1687
+ const bySource = freshnessSourcePriority(left.source) - freshnessSourcePriority(right.source);
1688
+ if (bySource !== 0) {
1689
+ return bySource;
1690
+ }
1691
+ const leftTimestamp = parseTimestampCandidate(left.value, now) ?? Number.NEGATIVE_INFINITY;
1692
+ const rightTimestamp = parseTimestampCandidate(right.value, now) ?? Number.NEGATIVE_INFINITY;
1693
+ if (leftTimestamp !== rightTimestamp) {
1694
+ return rightTimestamp - leftTimestamp;
1695
+ }
1696
+ return left.value.localeCompare(right.value);
1697
+ });
1698
+ }
1699
+
1700
+ function deriveFreshnessConfidence(
1701
+ primary: PageFreshnessResult['evidence']['classifiedTimestamps'][number] | null
1702
+ ): PageFreshnessResult['confidence'] {
1703
+ if (!primary) {
1704
+ return 'low';
1705
+ }
1706
+ if (primary.category === 'data' && (primary.source === 'network' || primary.source === 'page-data')) {
1707
+ return 'high';
1708
+ }
1709
+ if (primary.category === 'data') {
1710
+ return 'medium';
1711
+ }
1712
+ return 'low';
1713
+ }
1714
+
1447
1715
  async function collectPageInspection(tabId: number, params: Record<string, unknown> = {}): Promise<PageInspectionState> {
1448
1716
  return (await forwardContentRpc(tabId, 'bak.internal.inspectState', params)) as PageInspectionState;
1449
1717
  }
@@ -1589,24 +1857,32 @@ async function buildFreshnessForTab(tabId: number, params: Record<string, unknow
1589
1857
  now
1590
1858
  );
1591
1859
  const latestInlineDataTimestamp = latestTimestampFromCandidates(inlineCandidates, now);
1592
- const latestPageDataTimestamp = latestTimestampFromCandidates(pageDataCandidates, now);
1593
- const latestNetworkDataTimestamp = latestTimestampFromCandidates(networkCandidates, now);
1594
- const domVisibleTimestamp = latestTimestampFromCandidates(visibleCandidates, now);
1595
- const latestNetworkTs = latestNetworkTimestamp(tabId);
1596
- const lastMutationAt = typeof inspection.lastMutationAt === 'number' ? inspection.lastMutationAt : null;
1597
- const allCandidates = [...visibleCandidates, ...inlineCandidates, ...pageDataCandidates, ...networkCandidates];
1598
- return {
1599
- pageLoadedAt: typeof inspection.pageLoadedAt === 'number' ? inspection.pageLoadedAt : null,
1600
- lastMutationAt,
1601
- latestNetworkTimestamp: latestNetworkTs,
1602
- latestInlineDataTimestamp,
1603
- latestPageDataTimestamp,
1604
- latestNetworkDataTimestamp,
1605
- domVisibleTimestamp,
1606
- assessment: computeFreshnessAssessment({
1607
- latestInlineDataTimestamp,
1608
- latestPageDataTimestamp,
1609
- latestNetworkDataTimestamp,
1860
+ const latestPageDataTimestamp = latestTimestampFromCandidates(pageDataCandidates, now);
1861
+ const latestNetworkDataTimestamp = latestTimestampFromCandidates(networkCandidates, now);
1862
+ const domVisibleTimestamp = latestTimestampFromCandidates(visibleCandidates, now);
1863
+ const latestNetworkTs = latestNetworkTimestamp(tabId);
1864
+ const lastMutationAt = typeof inspection.lastMutationAt === 'number' ? inspection.lastMutationAt : null;
1865
+ const allCandidates = rankFreshnessEvidence([...visibleCandidates, ...inlineCandidates, ...pageDataCandidates, ...networkCandidates], now);
1866
+ const primaryEvidence =
1867
+ allCandidates.find((candidate) => parseTimestampCandidate(candidate.value, now) !== null) ?? null;
1868
+ const primaryTimestamp = primaryEvidence ? parseTimestampCandidate(primaryEvidence.value, now) : null;
1869
+ return {
1870
+ pageLoadedAt: typeof inspection.pageLoadedAt === 'number' ? inspection.pageLoadedAt : null,
1871
+ lastMutationAt,
1872
+ latestNetworkTimestamp: latestNetworkTs,
1873
+ latestInlineDataTimestamp,
1874
+ latestPageDataTimestamp,
1875
+ latestNetworkDataTimestamp,
1876
+ domVisibleTimestamp,
1877
+ primaryTimestamp,
1878
+ primaryCategory: primaryEvidence?.category ?? null,
1879
+ primarySource: primaryEvidence?.source ?? null,
1880
+ confidence: deriveFreshnessConfidence(primaryEvidence),
1881
+ suppressedEvidenceCount: Math.max(0, allCandidates.length - (primaryEvidence ? 1 : 0)),
1882
+ assessment: computeFreshnessAssessment({
1883
+ latestInlineDataTimestamp,
1884
+ latestPageDataTimestamp,
1885
+ latestNetworkDataTimestamp,
1610
1886
  latestNetworkTimestamp: latestNetworkTs,
1611
1887
  domVisibleTimestamp,
1612
1888
  lastMutationAt,
@@ -1615,10 +1891,10 @@ async function buildFreshnessForTab(tabId: number, params: Record<string, unknow
1615
1891
  }),
1616
1892
  evidence: {
1617
1893
  visibleTimestamps: visibleCandidates.map((candidate) => candidate.value),
1618
- inlineTimestamps: inlineCandidates.map((candidate) => candidate.value),
1619
- pageDataTimestamps: pageDataCandidates.map((candidate) => candidate.value),
1620
- networkDataTimestamps: networkCandidates.map((candidate) => candidate.value),
1621
- classifiedTimestamps: allCandidates,
1894
+ inlineTimestamps: inlineCandidates.map((candidate) => candidate.value),
1895
+ pageDataTimestamps: pageDataCandidates.map((candidate) => candidate.value),
1896
+ networkDataTimestamps: networkCandidates.map((candidate) => candidate.value),
1897
+ classifiedTimestamps: allCandidates,
1622
1898
  networkSampleIds: recentNetworkSampleIds(tabId)
1623
1899
  }
1624
1900
  };
@@ -2051,32 +2327,37 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
2051
2327
  return await executePageWorld(tab.id!, 'extract', params);
2052
2328
  });
2053
2329
  }
2054
- case 'page.fetch': {
2055
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2056
- const tab = await withTab(target);
2057
- return await executePageWorld<PageFetchResponse>(tab.id!, 'fetch', params);
2058
- });
2059
- }
2060
- case 'page.snapshot': {
2061
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2062
- const tab = await withTab(target);
2063
- if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
2064
- throw toError('E_NOT_FOUND', 'Tab missing id');
2065
- }
2066
- const includeBase64 = params.includeBase64 !== false;
2067
- const config = await getConfig();
2068
- const elements = await sendToContent<{ elements: unknown[] }>(tab.id, {
2069
- type: 'bak.collectElements',
2070
- debugRichText: config.debugRichText
2071
- });
2072
- const imageData = await captureAlignedTabScreenshot(tab);
2073
- return {
2074
- imageBase64: includeBase64 ? imageData.replace(/^data:image\/png;base64,/, '') : '',
2075
- elements: elements.elements,
2076
- tabId: tab.id,
2077
- url: tab.url ?? ''
2078
- };
2079
- });
2330
+ case 'page.fetch': {
2331
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2332
+ const tab = await withTab(target);
2333
+ return await executePageWorld<PageFetchResponse>(tab.id!, 'fetch', params);
2334
+ });
2335
+ }
2336
+ case 'page.snapshot': {
2337
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2338
+ const tab = await withTab(target);
2339
+ if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
2340
+ throw toError('E_NOT_FOUND', 'Tab missing id');
2341
+ }
2342
+ const includeBase64 = params.includeBase64 !== false;
2343
+ const config = await getConfig();
2344
+ const elements = await sendToContent<{ elements: unknown[] }>(tab.id, {
2345
+ type: 'bak.collectElements',
2346
+ debugRichText: config.debugRichText
2347
+ });
2348
+ const screenshot = params.capture === false ? { captureStatus: 'skipped' as const } : await captureAlignedTabScreenshot(tab);
2349
+ return {
2350
+ captureStatus: screenshot.captureStatus,
2351
+ captureError: screenshot.captureError,
2352
+ imageBase64:
2353
+ includeBase64 && typeof screenshot.imageData === 'string'
2354
+ ? screenshot.imageData.replace(/^data:image\/png;base64,/, '')
2355
+ : undefined,
2356
+ elements: elements.elements,
2357
+ tabId: tab.id,
2358
+ url: tab.url ?? ''
2359
+ };
2360
+ });
2080
2361
  }
2081
2362
  case 'element.click': {
2082
2363
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
@@ -2191,19 +2472,13 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
2191
2472
  }
2192
2473
  });
2193
2474
  }
2194
- case 'network.search': {
2195
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2196
- const tab = await withTab(target);
2197
- await ensureTabNetworkCapture(tab.id!);
2198
- return {
2199
- entries: searchNetworkEntries(
2200
- tab.id!,
2201
- String(params.pattern ?? ''),
2202
- typeof params.limit === 'number' ? params.limit : 50
2203
- )
2204
- };
2205
- });
2206
- }
2475
+ case 'network.search': {
2476
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2477
+ const tab = await withTab(target);
2478
+ await ensureTabNetworkCapture(tab.id!);
2479
+ return searchNetworkEntries(tab.id!, String(params.pattern ?? ''), typeof params.limit === 'number' ? params.limit : 50);
2480
+ });
2481
+ }
2207
2482
  case 'network.waitFor': {
2208
2483
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2209
2484
  const tab = await withTab(target);
@@ -2231,48 +2506,52 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
2231
2506
  return { ok: true };
2232
2507
  });
2233
2508
  }
2234
- case 'network.replay': {
2235
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2236
- const tab = await withTab(target);
2237
- await ensureTabNetworkCapture(tab.id!);
2238
- const entry = getNetworkEntry(tab.id!, String(params.id ?? ''));
2239
- if (!entry) {
2240
- throw toError('E_NOT_FOUND', `network entry not found: ${String(params.id ?? '')}`);
2241
- }
2242
- if (entry.requestBodyTruncated === true) {
2243
- throw toError('E_BODY_TOO_LARGE', 'captured request body was truncated and cannot be replayed safely', {
2244
- requestId: entry.id,
2245
- requestBytes: entry.requestBytes
2246
- });
2247
- }
2248
- if (containsRedactionMarker(entry.requestBodyPreview)) {
2249
- throw toError('E_EXECUTION', 'captured request body was redacted and cannot be replayed safely', {
2250
- requestId: entry.id
2251
- });
2252
- }
2253
- const replayed = await executePageWorld<PageFetchResponse>(tab.id!, 'fetch', {
2254
- url: entry.url,
2255
- method: entry.method,
2256
- headers: replayHeadersFromEntry(entry),
2257
- body: entry.requestBodyPreview,
2258
- contentType: (() => {
2259
- const requestHeaders = entry.requestHeaders ?? {};
2260
- const contentTypeHeader = Object.keys(requestHeaders).find((name) => name.toLowerCase() === 'content-type');
2261
- return contentTypeHeader ? requestHeaders[contentTypeHeader] : undefined;
2262
- })(),
2263
- mode: params.mode,
2264
- timeoutMs: params.timeoutMs,
2265
- maxBytes: params.maxBytes,
2266
- scope: 'current'
2267
- });
2268
- const frameResult = replayed.result ?? replayed.results?.find((candidate) => candidate.value || candidate.error);
2269
- if (frameResult?.error) {
2270
- throw toError(frameResult.error.code ?? 'E_EXECUTION', frameResult.error.message, frameResult.error.details);
2271
- }
2272
- const first = frameResult?.value;
2273
- if (!first) {
2274
- throw toError('E_EXECUTION', 'network replay returned no response payload');
2275
- }
2509
+ case 'network.replay': {
2510
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2511
+ const tab = await withTab(target);
2512
+ await ensureTabNetworkCapture(tab.id!);
2513
+ const replayable = getReplayableNetworkRequest(tab.id!, String(params.id ?? ''));
2514
+ if (!replayable) {
2515
+ throw toError('E_NOT_FOUND', `network entry not found: ${String(params.id ?? '')}`);
2516
+ }
2517
+ if (replayable.degradedReason) {
2518
+ return {
2519
+ url: replayable.entry.url,
2520
+ status: 0,
2521
+ ok: false,
2522
+ headers: {},
2523
+ bytes: replayable.entry.requestBytes,
2524
+ truncated: true,
2525
+ degradedReason: replayable.degradedReason
2526
+ } satisfies PageFetchResponse;
2527
+ }
2528
+ const replayed = await executePageWorld<PageFetchResponse>(tab.id!, 'fetch', {
2529
+ url: replayable.entry.url,
2530
+ method: replayable.entry.method,
2531
+ headers: replayHeadersFromRequestHeaders(replayable.headers),
2532
+ body: replayable.body,
2533
+ contentType: replayable.contentType,
2534
+ mode: params.mode,
2535
+ timeoutMs: params.timeoutMs,
2536
+ maxBytes: params.maxBytes,
2537
+ fullResponse: params.fullResponse === true,
2538
+ auth: params.auth,
2539
+ scope: 'current'
2540
+ });
2541
+ const frameResult = replayed.result ?? replayed.results?.find((candidate) => candidate.value || candidate.error);
2542
+ if (frameResult?.error) {
2543
+ throw toError(frameResult.error.code ?? 'E_EXECUTION', frameResult.error.message, frameResult.error.details);
2544
+ }
2545
+ const first = frameResult?.value;
2546
+ if (!first) {
2547
+ return {
2548
+ url: replayable.entry.url,
2549
+ status: 0,
2550
+ ok: false,
2551
+ headers: {},
2552
+ degradedReason: 'network replay returned no response payload'
2553
+ } satisfies PageFetchResponse;
2554
+ }
2276
2555
  return params.withSchema === 'auto' && params.mode === 'json'
2277
2556
  ? await enrichReplayWithSchema(tab.id!, String(params.id ?? ''), first)
2278
2557
  : first;