pi-chrome 0.15.49 → 0.15.53

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.
@@ -1,6 +1,13 @@
1
1
  const BRIDGE_URL = "http://127.0.0.1:17318";
2
2
  const CLIENT_NAME = `Pi Chrome Connector ${chrome.runtime.id}`;
3
3
  const POLL_ERROR_BACKOFF_MS = 2000;
4
+ // /next is a server-side long poll held for up to 25s (see waitForCommand in index.ts). A fetch that
5
+ // outlives this deadline means the socket is half-open (the owning Pi process died, the machine
6
+ // slept, the network changed) and will never settle; abort it so pollLoop backs off and reconnects
7
+ // instead of parking on a zombie connection until the extension is reloaded by hand.
8
+ const POLL_REQUEST_TIMEOUT_MS = 45_000;
9
+ const RESULT_REQUEST_TIMEOUT_MS = 15_000;
10
+ const RESULT_POST_ATTEMPTS = 3;
4
11
  const DEFAULT_GROUP_COLOR = "blue";
5
12
  const PI_GROUP_RE = /^Pi(\b|\s*-)/i;
6
13
  const VALID_GROUP_COLORS = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]);
@@ -8,6 +15,8 @@ const COMMAND_TIMEOUT_MS = 25_000;
8
15
  const CDP_COMMAND_TIMEOUT_MS = 5_000;
9
16
  const SCRIPTING_TIMEOUT_MS = 8_000;
10
17
  const ATTACH_TIMEOUT_MS = 3_000;
18
+ // Raw CDP methods that explicitly focus or activate a tab. Blocked by hard background mode.
19
+ const CDP_FOCUS_METHODS = new Set(["Page.bringToFront", "Target.activateTarget"]);
11
20
  let polling = false;
12
21
 
13
22
  // =================== pi-chrome automation target ownership ===================
@@ -250,9 +259,12 @@ function withTimeout(promise, ms, label, onTimeout) {
250
259
  return Promise.race([
251
260
  Promise.resolve(promise).finally(() => clearTimeout(timer)),
252
261
  new Promise((_, reject) => {
253
- timer = setTimeout(async () => {
254
- try { await onTimeout?.(); } catch {}
262
+ timer = setTimeout(() => {
263
+ // Settle with the timeout first. Cleanup (e.g. debugger detach) makes the pending command
264
+ // fail with "Detached while handling command"; if that won the race, cdp() would treat it as
265
+ // a stale session and re-send a command that may already have run.
255
266
  reject(new Error(`${label} timed out after ${ms}ms`));
267
+ Promise.resolve().then(() => onTimeout?.()).catch(() => {});
256
268
  }, ms);
257
269
  }),
258
270
  ]);
@@ -435,14 +447,28 @@ setInterval(() => {
435
447
  }
436
448
  }, 5000);
437
449
 
438
- function cdpRaw(tabId, method, params) {
450
+ // Deadline policy for cdp.call. Callers may widen the per-command CDP deadline via params.timeoutMs,
451
+ // clamped below the MV3 service-worker lifetime so a stuck command cannot pin the debugger forever.
452
+ const CDP_CALL_MAX_TIMEOUT_MS = 120_000;
453
+ const CDP_CALL_GRACE_MS = 5_000;
454
+ function cdpCallTimeoutMs(params) {
455
+ const requested = Number(params?.timeoutMs);
456
+ if (!Number.isFinite(requested) || requested <= 0) return CDP_COMMAND_TIMEOUT_MS;
457
+ return Math.min(Math.floor(requested), CDP_CALL_MAX_TIMEOUT_MS);
458
+ }
459
+
460
+ // `opts.timeoutMs` overrides the default per-command deadline. On timeout the session is detached
461
+ // and forgotten so the next call re-attaches cleanly.
462
+ function cdpRaw(tabId, method, params, opts) {
439
463
  const debuggee = attachedTabs.get(tabId)?.debuggee || { tabId };
464
+ const requested = Number(opts?.timeoutMs);
465
+ const timeoutMs = Number.isFinite(requested) && requested > 0 ? requested : CDP_COMMAND_TIMEOUT_MS;
440
466
  return withTimeout(new Promise((resolve, reject) => {
441
467
  chrome.debugger.sendCommand(debuggee, method, params || {}, (result) => {
442
468
  if (chrome.runtime.lastError) reject(new Error(`${method}: ${chrome.runtime.lastError.message}`));
443
469
  else resolve(result);
444
470
  });
445
- }), CDP_COMMAND_TIMEOUT_MS, `CDP ${method}`, async () => {
471
+ }), timeoutMs, `CDP ${method}`, async () => {
446
472
  attachedTabs.delete(tabId);
447
473
  try { await chrome.debugger.detach(debuggee); } catch {}
448
474
  });
@@ -452,6 +478,51 @@ function executeScriptTimed(options, label) {
452
478
  return withTimeout(chrome.scripting.executeScript(options), SCRIPTING_TIMEOUT_MS, label || "chrome.scripting.executeScript");
453
479
  }
454
480
 
481
+ // Chrome refuses chrome.scripting on pages outside the extension's host permissions. Notably a
482
+ // top-level about:blank (pi-chrome's fresh automation tab) has an opaque origin that <all_urls>
483
+ // does not cover, so snapshot/inspect/probe/console/click-resolution failed there with
484
+ // "Cannot access contents of url \"about:blank\"". The debugger API still attaches to about:blank.
485
+ function isScriptingAccessDenied(error) {
486
+ const message = String(error?.message || error);
487
+ return /Cannot access contents of url|Cannot access .+ at origin|Extension manifest must request permission|matchAboutBlank must be true/i.test(message);
488
+ }
489
+
490
+ // chrome.scripting.executeScript with a CDP Runtime.evaluate fallback when Chrome refuses scripting
491
+ // for a permission reason. Scripting stays primary: it is what ordinary pages use and the only path
492
+ // that works while DevTools holds the tab's debugger. The fallback runs in the same MAIN world and
493
+ // covers both forms: `func` is stringified and invoked with its args; `files` are read from the
494
+ // extension package and evaluated. Any other scripting failure surfaces unchanged.
495
+ const packagedFileSources = new Map();
496
+ async function executeScriptWithFallback(options, label) {
497
+ try {
498
+ return await executeScriptTimed(options, label);
499
+ } catch (error) {
500
+ if (!isScriptingAccessDenied(error) || !chrome.debugger) throw error;
501
+ const tabId = options?.target?.tabId;
502
+ recordAttachEvent({ kind: "scripting-denied-cdp-fallback", tabId, files: options?.files || null, message: String(error?.message || error).slice(0, 300) });
503
+ if (Array.isArray(options?.files) && options.files.length) {
504
+ for (const file of options.files) {
505
+ if (!packagedFileSources.has(file)) {
506
+ const response = await fetch(chrome.runtime.getURL(file));
507
+ if (!response.ok) throw new Error(`Could not read ${file} for CDP injection (HTTP ${response.status})`);
508
+ packagedFileSources.set(file, await response.text());
509
+ }
510
+ const injected = await cdpEval(tabId, packagedFileSources.get(file));
511
+ if (injected.exceptionDetails) {
512
+ throw new Error(`${label || file}: ${cdpExceptionText(injected.exceptionDetails) || "CDP injection failed"}`);
513
+ }
514
+ }
515
+ return [{ result: undefined }];
516
+ }
517
+ const expression = `(${options.func.toString()})(...${JSON.stringify(Array.isArray(options.args) ? options.args : [])})`;
518
+ const result = await cdpEval(tabId, expression);
519
+ if (result.exceptionDetails) {
520
+ throw new Error(`${label || "page script"}: ${cdpExceptionText(result.exceptionDetails) || "evaluation failed"}`);
521
+ }
522
+ return [{ result: result.result?.value }];
523
+ }
524
+ }
525
+
455
526
  // Wraps cdpRaw with one auto-recover on detached/closed sessions:
456
527
  // chrome.debugger.attach can stay cached in attachedTabs even after Chrome killed
457
528
  // the session (tab nav, devtools opened/closed, etc). Recover by detaching the
@@ -494,9 +565,9 @@ async function dismissOverlayViaEscape(tabId) {
494
565
  } catch {}
495
566
  }
496
567
 
497
- async function cdp(tabId, method, params) {
568
+ async function cdp(tabId, method, params, opts) {
498
569
  try {
499
- return await cdpRaw(tabId, method, params);
570
+ return await cdpRaw(tabId, method, params, opts);
500
571
  } catch (error) {
501
572
  const msg = String(error?.message || error);
502
573
  const isStale = /Debugger is not attached|Detached while|Target closed|No tab with id/i.test(msg);
@@ -508,7 +579,7 @@ async function cdp(tabId, method, params) {
508
579
  recordAttachEvent({ kind: "foreign-ext-detected", tabId, method, foreignExtId: extractForeignExtId(before), targetCount: before.length });
509
580
  await dismissOverlayViaEscape(tabId);
510
581
  try {
511
- return await cdpRaw(tabId, method, params);
582
+ return await cdpRaw(tabId, method, params, opts);
512
583
  } catch (retryErr) {
513
584
  const retryMsg = String(retryErr?.message || retryErr);
514
585
  if (/Cannot access a chrome-extension:\/\/ URL of different extension/i.test(retryMsg)) {
@@ -525,7 +596,7 @@ async function cdp(tabId, method, params) {
525
596
  if (!isStale) throw error;
526
597
  attachedTabs.delete(tabId);
527
598
  await attachDebugger(tabId).catch(() => undefined);
528
- return cdpRaw(tabId, method, params);
599
+ return cdpRaw(tabId, method, params, opts);
529
600
  }
530
601
  }
531
602
 
@@ -563,7 +634,7 @@ function cdpIsSyntaxError(details) {
563
634
 
564
635
  // Resolve target -> {x, y, rect} in viewport coords by running tiny script in tab.
565
636
  async function resolveTargetInTab(tabId, params) {
566
- const results = await executeScriptTimed({
637
+ const results = await executeScriptWithFallback({
567
638
  target: { tabId, frameIds: [0] },
568
639
  world: "MAIN",
569
640
  func: (selector, uid, x, y) => {
@@ -721,7 +792,7 @@ async function cdpTypeChar(tabId, ch) {
721
792
  }
722
793
 
723
794
  async function domClickFallback(tabId, params, cause) {
724
- const results = await executeScriptTimed({
795
+ const results = await executeScriptWithFallback({
725
796
  target: { tabId, frameIds: [0] },
726
797
  world: "MAIN",
727
798
  func: (selector, uid, x, y) => {
@@ -764,7 +835,7 @@ async function chromeInputClick(params) {
764
835
  // focus can leave :focus-visible=true in Chromium, which trips heuristics that expect
765
836
  // Reset focus styling after pointer click when possible.
766
837
  if (params.selector || params.uid) {
767
- await executeScriptTimed({
838
+ await executeScriptWithFallback({
768
839
  target: { tabId: tab.id, frameIds: [0] },
769
840
  world: "MAIN",
770
841
  func: (sel, uid) => {
@@ -840,7 +911,7 @@ async function chromeInputKey(params) {
840
911
  // the requested editor's entire contents: triple-click only selects a paragraph.
841
912
  // Selection uses the DOM; deletion and insertion still use Chrome's input layer.
842
913
  async function contentEditableInTab(tabId, selectAllParams = null) {
843
- const results = await executeScriptTimed({
914
+ const results = await executeScriptWithFallback({
844
915
  target: { tabId, frameIds: [0] },
845
916
  world: "MAIN",
846
917
  func: (selector, uid, selectAll) => {
@@ -894,14 +965,143 @@ async function chromeInputType(params) {
894
965
  await sleep(rng(50, 120));
895
966
  }
896
967
  const text = String(params.text || "");
968
+ // chrome_type inserts at the caret. Read the field before and after so a splice into existing
969
+ // text is reported instead of hidden behind a bare character count. The "before" read precedes
970
+ // replace's select-all/delete. Reads are best-effort and never block typing.
971
+ const before = await readInputStateInTab(tab.id, params).catch(() => null);
972
+ if (params.replace === true) await selectAllAndDelete(tab.id);
897
973
  const typing = await typeTextInTab(tab.id, text, params.perCharacter);
974
+ const after = await readInputStateInTab(tab.id, params).catch(() => null);
898
975
  if (params.pressEnter) await chromeInputKey({ ...params, targetId: tab.id, key: "Enter" });
899
- return { input: "chrome", length: text.length, typing };
976
+ return {
977
+ input: "chrome",
978
+ length: text.length,
979
+ typing,
980
+ ...inputValueEvidence(before, after, { replaced: params.replace === true }),
981
+ };
982
+ }
983
+
984
+ // Select the focused field's whole contents with Chrome's editing command, then delete it with real
985
+ // key events. The selectAll editing command is platform-neutral (Ctrl+A moves to line start on macOS).
986
+ async function selectAllAndDelete(tabId) {
987
+ const mac = /Mac/i.test(String(globalThis.navigator?.platform || globalThis.navigator?.userAgent || ""));
988
+ const modifiers = mac ? 4 : 2;
989
+ const key = mac ? { key: "Meta", code: "MetaLeft", vk: 91 } : { key: "Control", code: "ControlLeft", vk: 17 };
990
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "rawKeyDown", key: key.key, code: key.code, windowsVirtualKeyCode: key.vk, modifiers });
991
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "rawKeyDown", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers, commands: ["selectAll"] });
992
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers });
993
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: key.key, code: key.code, windowsVirtualKeyCode: key.vk, modifiers: 0 });
994
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
995
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
996
+ await sleep(rng(20, 60));
997
+ }
998
+
999
+ // Read the typing target's value and caret. Sensitive fields (password/OTP/card/etc., matching the
1000
+ // snapshot redaction convention) report only valueRedacted + length, never contents. Displayed values
1001
+ // are truncated to 120 chars like snapshots; lengths and caret offsets are exact.
1002
+ async function readInputStateInTab(tabId, params) {
1003
+ const results = await executeScriptWithFallback({
1004
+ target: { tabId, frameIds: [0] },
1005
+ world: "MAIN",
1006
+ func: (selector, uid) => {
1007
+ const state = window.__PI_CHROME_STATE__;
1008
+ const carrierOf = (node) => {
1009
+ if (!node) return null;
1010
+ if ("value" in node && typeof node.value === "string" && /^(INPUT|TEXTAREA)$/.test(node.tagName)) return "value";
1011
+ if (node.isContentEditable === true) return "contenteditable";
1012
+ return null;
1013
+ };
1014
+ let el = uid ? (state && state.elements ? state.elements[uid] : null) : null;
1015
+ if (uid && (!el || !el.isConnected)) el = null;
1016
+ if (!el && selector) el = document.querySelector(selector);
1017
+ const active = document.activeElement;
1018
+ // The uid/selector can name a wrapper (role=textbox, label) rather than the element that
1019
+ // receives text. Prefer the focused carrier in that case so we do not report a false "" -> "".
1020
+ if (!carrierOf(el) && carrierOf(active)) el = active;
1021
+ const carrier = carrierOf(el);
1022
+ if (!carrier) return { found: false };
1023
+ const raw = carrier === "value" ? el.value : String(el.textContent || "");
1024
+ const attr = (name) => (el.getAttribute ? el.getAttribute(name) : null);
1025
+ const type = String(el.type || attr("type") || "").toLowerCase();
1026
+ const haystack = [type, el.name, attr("name"), el.id, attr("autocomplete"), attr("aria-label"), attr("placeholder"), attr("data-testid")].filter(Boolean).join(" ").toLowerCase();
1027
+ const sensitive = type === "password" || /password|passwd|\bpwd\b|secret|token|bearer|api[-_ ]?key|access[-_ ]?key|auth[-_ ]?code|one[-_ ]?time|otp|2fa|mfa|verification[-_ ]?code|recovery[-_ ]?code|credit[-_ ]?card|card[-_ ]?number|cc-number|cc-csc|cvc|cvv|security[-_ ]?code|ssn|social[-_ ]?security/.test(haystack);
1028
+ let selectionStart = null;
1029
+ let selectionEnd = null;
1030
+ try {
1031
+ if (typeof el.selectionStart === "number") selectionStart = el.selectionStart;
1032
+ if (typeof el.selectionEnd === "number") selectionEnd = el.selectionEnd;
1033
+ } catch {}
1034
+ let caretOffset = null;
1035
+ if (carrier === "contenteditable") {
1036
+ try {
1037
+ const selection = window.getSelection && window.getSelection();
1038
+ if (selection && selection.rangeCount > 0 && selection.anchorNode && el.contains(selection.anchorNode)) {
1039
+ const range = document.createRange();
1040
+ range.selectNodeContents(el);
1041
+ range.setEnd(selection.anchorNode, selection.anchorOffset);
1042
+ caretOffset = range.toString().length;
1043
+ if (!selection.isCollapsed) selectionEnd = caretOffset + String(selection.toString()).length;
1044
+ }
1045
+ } catch {}
1046
+ }
1047
+ return {
1048
+ found: true,
1049
+ carrier,
1050
+ value: sensitive ? undefined : raw.slice(0, 120),
1051
+ valueLength: raw.length,
1052
+ valueRedacted: sensitive ? true : undefined,
1053
+ selectionStart,
1054
+ selectionEnd,
1055
+ caretOffset,
1056
+ };
1057
+ },
1058
+ args: [params.selector ?? null, params.uid ?? null],
1059
+ }, `read input value in tab ${tabId}`);
1060
+ const v = results?.[0]?.result;
1061
+ return v && typeof v === "object" ? v : { found: false };
1062
+ }
1063
+
1064
+ // Where typed text landed relative to the field's existing contents.
1065
+ function inputInsertPosition(before) {
1066
+ const length = before.valueLength ?? 0;
1067
+ if (length === 0) return "empty";
1068
+ if (typeof before.selectionStart === "number" && typeof before.selectionEnd === "number") {
1069
+ if (before.selectionEnd > before.selectionStart) {
1070
+ return before.selectionStart === 0 && before.selectionEnd >= length ? "replaced-all" : "replaced-selection";
1071
+ }
1072
+ return before.selectionStart < length ? "caret-middle" : "caret-end";
1073
+ }
1074
+ if (typeof before.caretOffset === "number") {
1075
+ if (typeof before.selectionEnd === "number" && before.selectionEnd > before.caretOffset) {
1076
+ return before.caretOffset === 0 && before.selectionEnd >= length ? "replaced-all" : "replaced-selection";
1077
+ }
1078
+ return before.caretOffset < length ? "caret-middle" : "caret-end";
1079
+ }
1080
+ return undefined;
1081
+ }
1082
+
1083
+ function inputValueEvidence(before, after, { replaced = false } = {}) {
1084
+ const hasBefore = Boolean(before?.found);
1085
+ const hasAfter = Boolean(after?.found);
1086
+ if (!hasBefore && !hasAfter) return replaced ? { replaced: true } : {};
1087
+ const redacted = Boolean(before?.valueRedacted || after?.valueRedacted);
1088
+ const evidence = {
1089
+ existingTextLengthBefore: hasBefore ? before.valueLength : undefined,
1090
+ valueLengthAfter: hasAfter ? after.valueLength : undefined,
1091
+ insertedAt: replaced ? "replaced-all" : hasBefore ? inputInsertPosition(before) : undefined,
1092
+ };
1093
+ if (redacted) evidence.valueRedacted = true;
1094
+ else {
1095
+ if (hasBefore) evidence.valueBefore = before.value;
1096
+ if (hasAfter) evidence.valueAfter = after.value;
1097
+ }
1098
+ if (replaced) evidence.replaced = true;
1099
+ return evidence;
900
1100
  }
901
1101
 
902
1102
  async function domFillFallback(tabId, params, cause) {
903
1103
  if (!(params.selector || params.uid)) throw cause;
904
- const results = await executeScriptTimed({
1104
+ const results = await executeScriptWithFallback({
905
1105
  target: { tabId, frameIds: [0] },
906
1106
  world: "MAIN",
907
1107
  func: async (selector, uid, text, submit) => {
@@ -1138,9 +1338,9 @@ async function pollLoop() {
1138
1338
  polling = true;
1139
1339
  try {
1140
1340
  while (true) {
1141
- const response = await fetch(`${BRIDGE_URL}/next?name=${encodeURIComponent(CLIENT_NAME)}`, {
1341
+ const response = await fetchWithTimeout(`${BRIDGE_URL}/next?name=${encodeURIComponent(CLIENT_NAME)}`, {
1142
1342
  cache: "no-store",
1143
- });
1343
+ }, POLL_REQUEST_TIMEOUT_MS, "bridge /next");
1144
1344
  if (!response.ok) throw new Error(`bridge /next HTTP ${response.status}`);
1145
1345
  const expected = response.headers.get("x-pi-chrome-version");
1146
1346
  const ours = chrome.runtime.getManifest().version;
@@ -1159,26 +1359,70 @@ async function pollLoop() {
1159
1359
  }
1160
1360
  }
1161
1361
 
1362
+ // fetch() with an abort deadline that covers connecting and receiving response headers. The timer is
1363
+ // cleared as soon as headers arrive so a late abort can never fail an already-delivered response.
1364
+ async function fetchWithTimeout(url, options, timeoutMs, label) {
1365
+ const controller = new AbortController();
1366
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1367
+ try {
1368
+ return await fetch(url, { ...(options || {}), signal: controller.signal });
1369
+ } catch (error) {
1370
+ if (error?.name === "AbortError") throw new Error(`${label || "fetch"} timed out after ${timeoutMs}ms`);
1371
+ throw error;
1372
+ } finally {
1373
+ clearTimeout(timer);
1374
+ }
1375
+ }
1376
+
1377
+ function commandTimeoutMs(action, params) {
1378
+ if (action !== "cdp.call") return COMMAND_TIMEOUT_MS;
1379
+ // Let the inner CDP deadline fire first so callers see "CDP <method> timed out" instead of the
1380
+ // generic command wrapper timeout.
1381
+ return Math.max(COMMAND_TIMEOUT_MS, cdpCallTimeoutMs(params) + CDP_CALL_GRACE_MS);
1382
+ }
1383
+
1162
1384
  async function handleCommand(command) {
1385
+ let payload;
1163
1386
  try {
1164
1387
  const result = await withTimeout(
1165
1388
  dispatch(command.action, command.params ?? {}),
1166
- COMMAND_TIMEOUT_MS,
1389
+ commandTimeoutMs(command.action, command.params ?? {}),
1167
1390
  command.action || "Chrome command",
1168
1391
  () => detachAll(),
1169
1392
  );
1170
- await postResult({ id: command.id, ok: true, result });
1393
+ payload = { id: command.id, ok: true, result };
1171
1394
  } catch (error) {
1172
- await postResult({ id: command.id, ok: false, error: error?.message ?? String(error) });
1395
+ payload = { id: command.id, ok: false, error: error?.message ?? String(error) };
1396
+ }
1397
+ // Post exactly one result. A failed success-post used to fall into the catch above and post a
1398
+ // second (error) result for the same command id.
1399
+ try {
1400
+ await postResult(payload);
1401
+ } catch (error) {
1402
+ console.warn(`[pi-chrome] failed to post result for ${command.action}: ${error?.message ?? String(error)}`);
1173
1403
  }
1174
1404
  }
1175
1405
 
1176
1406
  async function postResult(result) {
1177
- await fetch(`${BRIDGE_URL}/result`, {
1178
- method: "POST",
1179
- headers: { "content-type": "application/json" },
1180
- body: JSON.stringify(result),
1181
- });
1407
+ const body = JSON.stringify(result);
1408
+ let lastError;
1409
+ for (let attempt = 1; attempt <= RESULT_POST_ATTEMPTS; attempt++) {
1410
+ try {
1411
+ const response = await fetchWithTimeout(`${BRIDGE_URL}/result`, {
1412
+ method: "POST",
1413
+ headers: { "content-type": "application/json" },
1414
+ body,
1415
+ }, RESULT_REQUEST_TIMEOUT_MS, "bridge /result");
1416
+ // 4xx is final: the bridge no longer knows this command (it already timed out) or rejected
1417
+ // the request. Retrying cannot help.
1418
+ if (response.ok || (response.status >= 400 && response.status < 500)) return;
1419
+ lastError = new Error(`bridge /result HTTP ${response.status}`);
1420
+ } catch (error) {
1421
+ lastError = error;
1422
+ }
1423
+ if (attempt < RESULT_POST_ATTEMPTS) await sleep(250 * attempt);
1424
+ }
1425
+ throw lastError;
1182
1426
  }
1183
1427
 
1184
1428
  function isVersionOlder(a, b) {
@@ -1285,7 +1529,19 @@ async function dispatch(action, params) {
1285
1529
  await trackSessionTab(sessionKeyOf(params), tab.id, true);
1286
1530
  try {
1287
1531
  await bringToFront(tab, params);
1288
- return await groupTab(tab, groupTitle, params.groupColor);
1532
+ const grouped = await groupTab(tab, groupTitle, params.groupColor);
1533
+ // chrome.tabs.create returns the t=0 tab ({url:"", status:"loading"}), which reads as "the
1534
+ // URL never loaded". Wait (bounded, never throwing) and report the settled tab instead.
1535
+ if (!params.url || params.url === "about:blank") return grouped;
1536
+ const started = Date.now();
1537
+ const settled = await waitForTabSettled(tab.id, SNAPSHOT_NAVIGATION_WAIT_MS);
1538
+ const latest = await chrome.tabs.get(tab.id).catch(() => null);
1539
+ return {
1540
+ ...grouped,
1541
+ tab: latest ? await formatTab(latest) : grouped.tab,
1542
+ loadStatus: settled ? "complete" : "timedOut",
1543
+ waitedMs: Date.now() - started,
1544
+ };
1289
1545
  } catch (error) {
1290
1546
  if (typeof tab.id === "number") await chrome.tabs.remove(tab.id).catch(() => {});
1291
1547
  throw error;
@@ -1395,6 +1651,43 @@ async function dispatch(action, params) {
1395
1651
  }
1396
1652
  case "page.screenshot":
1397
1653
  return takeScreenshot(params);
1654
+ case "cdp.targets": {
1655
+ // Diagnostics: CDP targets anchored to the resolved tab, including foreign overlay targets
1656
+ // (password managers, autofill, DevTools front-ends) that explain "Detached while handling
1657
+ // command" failures. Never creates an automation window. Only the resolved tab's targets are
1658
+ // echoed; targets on other tabs are counted, not listed.
1659
+ const hasSelector = params.targetId !== undefined || Boolean(params.urlIncludes) || Boolean(params.titleIncludes);
1660
+ const tab = await getTabByParams(params, { createOwnedTarget: false }).catch((error) => {
1661
+ if (hasSelector) throw error;
1662
+ return null;
1663
+ });
1664
+ const allTargets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))).catch(() => []);
1665
+ const targets = tab ? allTargets.filter((t) => t.tabId === tab.id) : [];
1666
+ return {
1667
+ tab: tab ? { id: tab.id, windowId: tab.windowId, url: tab.url, title: tab.title } : null,
1668
+ targets: targets.map((t) => ({ id: t.id, tabId: t.tabId, type: t.type, url: t.url, title: t.title, attached: t.attached, extensionId: t.extensionId })),
1669
+ otherTabTargetCount: allTargets.filter((t) => typeof t.tabId === "number" && t.tabId !== tab?.id).length,
1670
+ };
1671
+ }
1672
+ case "cdp.call": {
1673
+ // Validate before touching the debugger: a clear error beats Chrome's opaque
1674
+ // "Invalid parameters" for a missing method or non-object params.
1675
+ if (typeof params.method !== "string" || !params.method.trim()) {
1676
+ throw new Error('cdp.call requires a non-empty string "method" (for example "Runtime.evaluate" or "DOM.getDocument").');
1677
+ }
1678
+ if (params.params !== undefined && params.params !== null && (typeof params.params !== "object" || Array.isArray(params.params))) {
1679
+ throw new Error('cdp.call "params" must be a plain object of CDP parameters when provided.');
1680
+ }
1681
+ const method = params.method.trim();
1682
+ // Background mode is a hard policy. Raw CDP must not become a way around it.
1683
+ if (!foregroundRequested(params) && CDP_FOCUS_METHODS.has(method)) {
1684
+ throw new Error(`${method} is blocked by background mode because it focuses or activates a tab. Ask the user to run /chrome background off to allow foreground work.`);
1685
+ }
1686
+ const tab = await getTabByParams(params);
1687
+ await bringToFront(tab, params);
1688
+ await attachDebugger(tab.id);
1689
+ return await cdp(tab.id, method, params.params ?? {}, { timeoutMs: cdpCallTimeoutMs(params) });
1690
+ }
1398
1691
  case "automation.status": {
1399
1692
  // Report this session's owned automation target (ids only). Used for diagnostics/tests.
1400
1693
  await hydrateAutomationTargets();
@@ -1557,7 +1850,7 @@ async function executeInTab(params, func, args) {
1557
1850
  // Phase 2: run the action via chrome.scripting.executeScript. The `func:` form is
1558
1851
  // injected by Chrome itself (not `new Function`), so it is CSP-safe, and it lets Chrome
1559
1852
  // serialize the invocation args. The wrapper references window.__piAction defined above.
1560
- const results = await executeScriptTimed({
1853
+ const results = await executeScriptWithFallback({
1561
1854
  target: { tabId: tab.id },
1562
1855
  world: "MAIN",
1563
1856
  func: async (invocationArgs) => {
@@ -1639,13 +1932,45 @@ async function evaluateInTab(params) {
1639
1932
  return v;
1640
1933
  }
1641
1934
 
1935
+ // Run an input action, then optionally snapshot. An action that starts a navigation (Enter submits a
1936
+ // form, a click follows a link) used to be snapshotted immediately, so the snapshot described the
1937
+ // OUTGOING document and read as "the action did nothing". Anchor the tab's url/status first; when
1938
+ // the action plausibly started a load, wait (bounded) for it before snapshotting and report
1939
+ // navigation {from, to, settled, waitedMs}. A timeout never fails the action. A tab that was already
1940
+ // loading with the same URL is reported but not waited on, so a hanging subresource cannot add a wait
1941
+ // to every action.
1942
+ const SNAPSHOT_NAVIGATION_WAIT_MS = 5_000;
1642
1943
  async function withOptionalSnapshot(params, actionFn) {
1643
- const result = await actionFn(params);
1644
- if (params.includeSnapshot) {
1645
- const snapshot = await snapshotInTab({ ...params, foreground: false });
1646
- return { result, snapshot };
1944
+ if (!params.includeSnapshot) return actionFn(params);
1945
+ let anchor = null;
1946
+ try {
1947
+ const tab = await getTabByParams(params);
1948
+ if (tab && typeof tab.id === "number") {
1949
+ anchor = { tabId: tab.id, params: { ...params, targetId: String(tab.id) }, url: String(tab.url ?? ""), status: String(tab.status ?? "") };
1950
+ }
1951
+ } catch {
1952
+ anchor = null; // Let the action surface the targeting error itself.
1953
+ }
1954
+ const result = await actionFn(anchor ? anchor.params : params);
1955
+ let navigation;
1956
+ if (anchor) {
1957
+ const after = await chrome.tabs.get(anchor.tabId).catch(() => null);
1958
+ const afterUrl = String(after?.url ?? "");
1959
+ const urlChanged = Boolean(afterUrl && afterUrl !== anchor.url);
1960
+ const loading = after?.status === "loading";
1961
+ if (urlChanged || loading) {
1962
+ const started = Date.now();
1963
+ const startedLoading = loading && (urlChanged || anchor.status === "complete");
1964
+ let settled = !loading;
1965
+ if (startedLoading) {
1966
+ settled = await waitForTabSettled(anchor.tabId, SNAPSHOT_NAVIGATION_WAIT_MS);
1967
+ }
1968
+ const finalTab = await chrome.tabs.get(anchor.tabId).catch(() => after);
1969
+ navigation = { from: anchor.url, to: String(finalTab?.url ?? afterUrl), settled, waitedMs: Date.now() - started };
1970
+ }
1647
1971
  }
1648
- return result;
1972
+ const snapshot = await snapshotInTab({ ...(anchor ? anchor.params : params), foreground: false });
1973
+ return navigation ? { result, snapshot, navigation } : { result, snapshot };
1649
1974
  }
1650
1975
 
1651
1976
  // Snapshot/inspect run from a packaged MAIN-world script (snapshot_injected.js) injected via
@@ -1664,12 +1989,12 @@ async function snapshotInTab(params) {
1664
1989
  params.query ?? null,
1665
1990
  params.maxTextChars ?? null,
1666
1991
  ];
1667
- await executeScriptTimed({
1992
+ await executeScriptWithFallback({
1668
1993
  target: { tabId: tab.id, frameIds: [0] },
1669
1994
  world: "MAIN",
1670
1995
  files: ["snapshot_injected.js"],
1671
1996
  }, `inject snapshot script in tab ${tab.id}`);
1672
- const results = await executeScriptTimed({
1997
+ const results = await executeScriptWithFallback({
1673
1998
  target: { tabId: tab.id, frameIds: [0] },
1674
1999
  world: "MAIN",
1675
2000
  func: async (invocationArgs) => {
@@ -1700,12 +2025,12 @@ async function inspectInTab(params) {
1700
2025
  const tab = await getTabByParams(params);
1701
2026
  await bringToFront(tab, params);
1702
2027
  const args = [params.uid ?? null, params.selector ?? null, params.scrollIntoView === true];
1703
- await executeScriptTimed({
2028
+ await executeScriptWithFallback({
1704
2029
  target: { tabId: tab.id, frameIds: [0] },
1705
2030
  world: "MAIN",
1706
2031
  files: ["snapshot_injected.js"],
1707
2032
  }, `inject inspect script in tab ${tab.id}`);
1708
- const results = await executeScriptTimed({
2033
+ const results = await executeScriptWithFallback({
1709
2034
  target: { tabId: tab.id, frameIds: [0] },
1710
2035
  world: "MAIN",
1711
2036
  func: async (invocationArgs) => {
@@ -1796,6 +2121,31 @@ function waitForTabComplete(tabId, timeoutMs) {
1796
2121
  });
1797
2122
  }
1798
2123
 
2124
+ // Like waitForTabComplete, but for a load that may already be underway (or already finished):
2125
+ // re-checks the tab after subscribing so a completion between the caller's read and the listener
2126
+ // registration is not missed. Resolves true when complete, false on timeout; never throws.
2127
+ function waitForTabSettled(tabId, timeoutMs) {
2128
+ return new Promise((resolve) => {
2129
+ let done = false;
2130
+ const finish = (value) => {
2131
+ if (done) return;
2132
+ done = true;
2133
+ clearTimeout(timer);
2134
+ chrome.tabs.onUpdated.removeListener(listener);
2135
+ resolve(value);
2136
+ };
2137
+ const timer = setTimeout(() => finish(false), timeoutMs);
2138
+ const listener = (updatedTabId, changeInfo) => {
2139
+ if (updatedTabId === tabId && changeInfo.status === "complete") finish(true);
2140
+ };
2141
+ chrome.tabs.onUpdated.addListener(listener);
2142
+ chrome.tabs.get(tabId).then((tab) => {
2143
+ if (!tab) finish(false);
2144
+ else if (tab.status === "complete") finish(true);
2145
+ }, () => finish(false));
2146
+ });
2147
+ }
2148
+
1799
2149
  async function captureTabScreenshot(tabId, params) {
1800
2150
  const format = params.format || "png";
1801
2151
  try {