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.
- package/CHANGELOG.md +26 -0
- package/README.md +63 -109
- package/SECURITY.md +1 -0
- package/docs/FAQ.md +3 -1
- package/extensions/chrome-profile-bridge/browser-extension/manifest.json +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/service_worker.js +385 -35
- package/extensions/chrome-profile-bridge/index.ts +183 -34
- package/package.json +2 -2
- package/test-suite/unit/background-policy.test.mjs +3 -1
- package/test-suite/unit/bridge-resilience.test.mjs +138 -0
- package/test-suite/unit/cdp-passthrough.test.mjs +183 -0
- package/test-suite/unit/chrome-command.test.mjs +163 -0
- package/test-suite/unit/type-evidence.test.mjs +208 -0
|
@@ -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(
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
}),
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
1389
|
+
commandTimeoutMs(command.action, command.params ?? {}),
|
|
1167
1390
|
command.action || "Chrome command",
|
|
1168
1391
|
() => detachAll(),
|
|
1169
1392
|
);
|
|
1170
|
-
|
|
1393
|
+
payload = { id: command.id, ok: true, result };
|
|
1171
1394
|
} catch (error) {
|
|
1172
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|