@flrande/bak-extension 0.6.15 → 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,
@@ -838,10 +838,18 @@ async function withTab(target: { tabId?: number; bindingId?: string } = {}, opti
838
838
  return validate(tab);
839
839
  }
840
840
 
841
- async function captureAlignedTabScreenshot(tab: chrome.tabs.Tab): Promise<string> {
842
- if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
843
- throw toError('E_NOT_FOUND', 'Tab screenshot requires tab id and window id');
844
- }
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
+ }
845
853
 
846
854
  const activeTabs = await chrome.tabs.query({ windowId: tab.windowId, active: true });
847
855
  const activeTab = activeTabs[0];
@@ -851,13 +859,24 @@ async function captureAlignedTabScreenshot(tab: chrome.tabs.Tab): Promise<string
851
859
  await chrome.tabs.update(tab.id, { active: true });
852
860
  await new Promise((resolve) => setTimeout(resolve, 80));
853
861
  }
854
-
855
- try {
856
- return await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
857
- } finally {
858
- if (shouldSwitch && typeof activeTab?.id === 'number') {
859
- try {
860
- 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 });
861
880
  } catch {
862
881
  // Ignore restore errors if the original tab no longer exists.
863
882
  }
@@ -1012,10 +1031,10 @@ async function executePageWorld<T>(
1012
1031
  target,
1013
1032
  world: 'MAIN',
1014
1033
  args: [
1015
- {
1016
- action,
1017
- scope,
1018
- framePath,
1034
+ {
1035
+ action,
1036
+ scope,
1037
+ framePath,
1019
1038
  expr: typeof params.expr === 'string' ? params.expr : '',
1020
1039
  path: typeof params.path === 'string' ? params.path : '',
1021
1040
  resolver: typeof params.resolver === 'string' ? params.resolver : undefined,
@@ -1023,13 +1042,15 @@ async function executePageWorld<T>(
1023
1042
  method: typeof params.method === 'string' ? params.method : 'GET',
1024
1043
  headers: typeof params.headers === 'object' && params.headers !== null ? params.headers : undefined,
1025
1044
  body: typeof params.body === 'string' ? params.body : undefined,
1026
- contentType: typeof params.contentType === 'string' ? params.contentType : undefined,
1027
- mode: params.mode === 'json' ? 'json' : 'raw',
1028
- maxBytes: typeof params.maxBytes === 'number' ? params.maxBytes : undefined,
1029
- timeoutMs: typeof params.timeoutMs === 'number' ? params.timeoutMs : undefined
1030
- }
1031
- ],
1032
- 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) => {
1033
1054
  const serializeValue = (value: unknown, maxBytes?: number) => {
1034
1055
  let cloned: unknown;
1035
1056
  try {
@@ -1129,10 +1150,10 @@ async function executePageWorld<T>(
1129
1150
  return current;
1130
1151
  };
1131
1152
 
1132
- const resolveExtractValue = (
1133
- targetWindow: Window & { eval: (expr: string) => unknown },
1134
- path: string,
1135
- resolver: unknown
1153
+ const resolveExtractValue = (
1154
+ targetWindow: Window & { eval: (expr: string) => unknown },
1155
+ path: string,
1156
+ resolver: unknown
1136
1157
  ): { resolver: 'globalThis' | 'lexical'; value: unknown } => {
1137
1158
  const strategy = resolver === 'globalThis' || resolver === 'lexical' ? resolver : 'auto';
1138
1159
  const lexicalExpression = buildPathExpression(path);
@@ -1159,11 +1180,82 @@ async function executePageWorld<T>(
1159
1180
  throw error;
1160
1181
  }
1161
1182
  }
1162
- return { resolver: 'lexical', value: readLexical() };
1163
- };
1164
-
1165
- try {
1166
- 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;
1167
1259
  if (payload.action === 'eval') {
1168
1260
  const evaluator = (targetWindow as Window & { eval: (expr: string) => unknown }).eval;
1169
1261
  const serialized = serializeValue(evaluator(payload.expr), payload.maxBytes);
@@ -1179,14 +1271,53 @@ async function executePageWorld<T>(
1179
1271
  bytes: serialized.bytes,
1180
1272
  resolver: extracted.resolver
1181
1273
  };
1182
- }
1183
- if (payload.action === 'fetch') {
1184
- const headers = { ...(payload.headers ?? {}) } as Record<string, string>;
1185
- if (payload.contentType && !headers['Content-Type']) {
1186
- headers['Content-Type'] = payload.contentType;
1187
- }
1188
- const controller = typeof AbortController === 'function' ? new AbortController() : null;
1189
- 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 =
1190
1321
  controller && typeof payload.timeoutMs === 'number' && payload.timeoutMs > 0
1191
1322
  ? window.setTimeout(() => controller.abort(), payload.timeoutMs)
1192
1323
  : null;
@@ -1211,43 +1342,56 @@ async function executePageWorld<T>(
1211
1342
  return {
1212
1343
  url: targetWindow.location.href,
1213
1344
  framePath: payload.scope === 'current' ? payload.framePath ?? [] : [],
1214
- value: (() => {
1215
- const encoder = typeof TextEncoder === 'function' ? new TextEncoder() : null;
1216
- const decoder = typeof TextDecoder === 'function' ? new TextDecoder() : null;
1217
- const previewLimit = typeof payload.maxBytes === 'number' && payload.maxBytes > 0 ? payload.maxBytes : 8192;
1218
- const encodedBody = encoder ? encoder.encode(bodyText) : null;
1219
- const bodyBytes = encodedBody ? encodedBody.byteLength : bodyText.length;
1220
- const truncated = bodyBytes > previewLimit;
1221
- if (payload.mode === 'json' && truncated) {
1222
- throw {
1223
- code: 'E_BODY_TOO_LARGE',
1224
- message: 'JSON response exceeds max-bytes',
1225
- details: {
1226
- bytes: bodyBytes,
1227
- maxBytes: previewLimit
1228
- }
1229
- };
1230
- }
1231
- const previewText =
1232
- encodedBody && decoder
1233
- ? decoder.decode(encodedBody.subarray(0, Math.min(encodedBody.byteLength, previewLimit)))
1234
- : truncated
1235
- ? bodyText.slice(0, previewLimit)
1236
- : bodyText;
1237
- return {
1238
- url: response.url,
1239
- status: response.status,
1240
- ok: response.ok,
1241
- headers: headerMap,
1242
- contentType: response.headers.get('content-type') ?? undefined,
1243
- bodyText: payload.mode === 'json' ? undefined : previewText,
1244
- json: payload.mode === 'json' && bodyText ? JSON.parse(bodyText) : undefined,
1245
- bytes: bodyBytes,
1246
- truncated
1247
- };
1248
- })()
1249
- };
1250
- }
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
+ }
1251
1395
  throw { code: 'E_NOT_FOUND', message: `Unsupported page world action: ${payload.action}` };
1252
1396
  } catch (error) {
1253
1397
  return {
@@ -1327,23 +1471,20 @@ function filterNetworkEntrySections(entry: NetworkEntry, include: unknown): Netw
1327
1471
  return clone;
1328
1472
  }
1329
1473
 
1330
- function replayHeadersFromEntry(entry: NetworkEntry): Record<string, string> | undefined {
1331
- if (!entry.requestHeaders) {
1332
- return undefined;
1333
- }
1334
- const headers: Record<string, string> = {};
1335
- for (const [name, value] of Object.entries(entry.requestHeaders)) {
1336
- const normalizedName = name.toLowerCase();
1337
- if (REPLAY_FORBIDDEN_HEADER_NAMES.has(normalizedName) || normalizedName.startsWith('sec-')) {
1338
- continue;
1339
- }
1340
- if (containsRedactionMarker(value)) {
1341
- continue;
1342
- }
1343
- headers[name] = value;
1344
- }
1345
- return Object.keys(headers).length > 0 ? headers : undefined;
1346
- }
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
+ }
1347
1488
 
1348
1489
  function collectTimestampMatchesFromText(text: string, source: TimestampEvidenceCandidate['source'], patterns?: string[]): TimestampEvidenceCandidate[] {
1349
1490
  const regexes = (
@@ -1461,7 +1602,7 @@ function latestTimestampFromCandidates(
1461
1602
  return latest;
1462
1603
  }
1463
1604
 
1464
- function computeFreshnessAssessment(input: {
1605
+ function computeFreshnessAssessment(input: {
1465
1606
  latestInlineDataTimestamp: number | null;
1466
1607
  latestPageDataTimestamp: number | null;
1467
1608
  latestNetworkDataTimestamp: number | null;
@@ -1498,10 +1639,79 @@ function computeFreshnessAssessment(input: {
1498
1639
  .filter((value): value is number => typeof value === 'number');
1499
1640
  if (staleSignals.length > 0 && staleSignals.every((value) => now - value > input.staleWindowMs)) {
1500
1641
  return 'stale';
1501
- }
1502
- return 'unknown';
1503
- }
1504
-
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
+
1505
1715
  async function collectPageInspection(tabId: number, params: Record<string, unknown> = {}): Promise<PageInspectionState> {
1506
1716
  return (await forwardContentRpc(tabId, 'bak.internal.inspectState', params)) as PageInspectionState;
1507
1717
  }
@@ -1647,24 +1857,32 @@ async function buildFreshnessForTab(tabId: number, params: Record<string, unknow
1647
1857
  now
1648
1858
  );
1649
1859
  const latestInlineDataTimestamp = latestTimestampFromCandidates(inlineCandidates, now);
1650
- const latestPageDataTimestamp = latestTimestampFromCandidates(pageDataCandidates, now);
1651
- const latestNetworkDataTimestamp = latestTimestampFromCandidates(networkCandidates, now);
1652
- const domVisibleTimestamp = latestTimestampFromCandidates(visibleCandidates, now);
1653
- const latestNetworkTs = latestNetworkTimestamp(tabId);
1654
- const lastMutationAt = typeof inspection.lastMutationAt === 'number' ? inspection.lastMutationAt : null;
1655
- const allCandidates = [...visibleCandidates, ...inlineCandidates, ...pageDataCandidates, ...networkCandidates];
1656
- return {
1657
- pageLoadedAt: typeof inspection.pageLoadedAt === 'number' ? inspection.pageLoadedAt : null,
1658
- lastMutationAt,
1659
- latestNetworkTimestamp: latestNetworkTs,
1660
- latestInlineDataTimestamp,
1661
- latestPageDataTimestamp,
1662
- latestNetworkDataTimestamp,
1663
- domVisibleTimestamp,
1664
- assessment: computeFreshnessAssessment({
1665
- latestInlineDataTimestamp,
1666
- latestPageDataTimestamp,
1667
- 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,
1668
1886
  latestNetworkTimestamp: latestNetworkTs,
1669
1887
  domVisibleTimestamp,
1670
1888
  lastMutationAt,
@@ -1673,10 +1891,10 @@ async function buildFreshnessForTab(tabId: number, params: Record<string, unknow
1673
1891
  }),
1674
1892
  evidence: {
1675
1893
  visibleTimestamps: visibleCandidates.map((candidate) => candidate.value),
1676
- inlineTimestamps: inlineCandidates.map((candidate) => candidate.value),
1677
- pageDataTimestamps: pageDataCandidates.map((candidate) => candidate.value),
1678
- networkDataTimestamps: networkCandidates.map((candidate) => candidate.value),
1679
- 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,
1680
1898
  networkSampleIds: recentNetworkSampleIds(tabId)
1681
1899
  }
1682
1900
  };
@@ -2109,32 +2327,37 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
2109
2327
  return await executePageWorld(tab.id!, 'extract', params);
2110
2328
  });
2111
2329
  }
2112
- case 'page.fetch': {
2113
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2114
- const tab = await withTab(target);
2115
- return await executePageWorld<PageFetchResponse>(tab.id!, 'fetch', params);
2116
- });
2117
- }
2118
- case 'page.snapshot': {
2119
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2120
- const tab = await withTab(target);
2121
- if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
2122
- throw toError('E_NOT_FOUND', 'Tab missing id');
2123
- }
2124
- const includeBase64 = params.includeBase64 !== false;
2125
- const config = await getConfig();
2126
- const elements = await sendToContent<{ elements: unknown[] }>(tab.id, {
2127
- type: 'bak.collectElements',
2128
- debugRichText: config.debugRichText
2129
- });
2130
- const imageData = await captureAlignedTabScreenshot(tab);
2131
- return {
2132
- imageBase64: includeBase64 ? imageData.replace(/^data:image\/png;base64,/, '') : '',
2133
- elements: elements.elements,
2134
- tabId: tab.id,
2135
- url: tab.url ?? ''
2136
- };
2137
- });
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
+ });
2138
2361
  }
2139
2362
  case 'element.click': {
2140
2363
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
@@ -2249,19 +2472,13 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
2249
2472
  }
2250
2473
  });
2251
2474
  }
2252
- case 'network.search': {
2253
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2254
- const tab = await withTab(target);
2255
- await ensureTabNetworkCapture(tab.id!);
2256
- return {
2257
- entries: searchNetworkEntries(
2258
- tab.id!,
2259
- String(params.pattern ?? ''),
2260
- typeof params.limit === 'number' ? params.limit : 50
2261
- )
2262
- };
2263
- });
2264
- }
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
+ }
2265
2482
  case 'network.waitFor': {
2266
2483
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2267
2484
  const tab = await withTab(target);
@@ -2289,48 +2506,52 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
2289
2506
  return { ok: true };
2290
2507
  });
2291
2508
  }
2292
- case 'network.replay': {
2293
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2294
- const tab = await withTab(target);
2295
- await ensureTabNetworkCapture(tab.id!);
2296
- const entry = getNetworkEntry(tab.id!, String(params.id ?? ''));
2297
- if (!entry) {
2298
- throw toError('E_NOT_FOUND', `network entry not found: ${String(params.id ?? '')}`);
2299
- }
2300
- if (entry.requestBodyTruncated === true) {
2301
- throw toError('E_BODY_TOO_LARGE', 'captured request body was truncated and cannot be replayed safely', {
2302
- requestId: entry.id,
2303
- requestBytes: entry.requestBytes
2304
- });
2305
- }
2306
- if (containsRedactionMarker(entry.requestBodyPreview)) {
2307
- throw toError('E_EXECUTION', 'captured request body was redacted and cannot be replayed safely', {
2308
- requestId: entry.id
2309
- });
2310
- }
2311
- const replayed = await executePageWorld<PageFetchResponse>(tab.id!, 'fetch', {
2312
- url: entry.url,
2313
- method: entry.method,
2314
- headers: replayHeadersFromEntry(entry),
2315
- body: entry.requestBodyPreview,
2316
- contentType: (() => {
2317
- const requestHeaders = entry.requestHeaders ?? {};
2318
- const contentTypeHeader = Object.keys(requestHeaders).find((name) => name.toLowerCase() === 'content-type');
2319
- return contentTypeHeader ? requestHeaders[contentTypeHeader] : undefined;
2320
- })(),
2321
- mode: params.mode,
2322
- timeoutMs: params.timeoutMs,
2323
- maxBytes: params.maxBytes,
2324
- scope: 'current'
2325
- });
2326
- const frameResult = replayed.result ?? replayed.results?.find((candidate) => candidate.value || candidate.error);
2327
- if (frameResult?.error) {
2328
- throw toError(frameResult.error.code ?? 'E_EXECUTION', frameResult.error.message, frameResult.error.details);
2329
- }
2330
- const first = frameResult?.value;
2331
- if (!first) {
2332
- throw toError('E_EXECUTION', 'network replay returned no response payload');
2333
- }
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
+ }
2334
2555
  return params.withSchema === 'auto' && params.mode === 'json'
2335
2556
  ? await enrichReplayWithSchema(tab.id!, String(params.id ?? ''), first)
2336
2557
  : first;