pi-chrome 0.15.27 → 0.15.29
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 +20 -0
- package/README.md +1 -1
- package/docs/COMPARISON.md +1 -1
- package/docs/EXAMPLES.md +1 -1
- package/docs/FAQ.md +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/manifest.json +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/service_worker.js +149 -103
- package/extensions/chrome-profile-bridge/index.ts +75 -13
- package/package.json +2 -1
- package/test-suite/README.md +2 -1
- package/test-suite/challenges/42-strict-csp-evaluate.html +16 -0
- package/test-suite/challenges/42-strict-csp-evaluate.js +21 -0
- package/test-suite/manifest.json +52 -1
- package/test-suite/unit/csp-eval.test.mjs +171 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
All notable user-facing changes to `pi-chrome`.
|
|
4
4
|
|
|
5
|
+
## 0.15.29 — 2026-05-31
|
|
6
|
+
|
|
7
|
+
Strict-CSP support: `chrome_evaluate`, `chrome_snapshot`, `chrome_wait_for`, and `chrome_navigate initScript` now work on pages that block `unsafe-eval`.
|
|
8
|
+
|
|
9
|
+
- **CDP-based evaluation bypasses page CSP.** `chrome_evaluate`/`chrome_snapshot` (and all snapshot-driven inspection) previously ran user code in the page MAIN world via the **Function constructor**, which is blocked by `script-src 'self'` without `'unsafe-eval'` — so they returned null/empty (or `EvalError`) on github.com and many bank/SaaS apps. They now evaluate through CDP `Runtime.evaluate`, a DevTools protocol command that is not subject to the page's Content-Security-Policy. Rich return values (undefined/function/symbol/bigint/Error markers, DOMRect expansion) and the expression/statement fallback are preserved.
|
|
10
|
+
- **`chrome_wait_for` polls via CDP.** The selector/expression polling loop moved from in-page `new Function()` to service-worker-side CDP evaluation, so waits work under strict CSP too.
|
|
11
|
+
- **`chrome_navigate initScript` injects via CDP.** Document-start init scripts now register with `Page.addScriptToEvaluateOnNewDocument` instead of `new Function()` on `webNavigation.onCommitted`, so seeding localStorage / stubbing `Date.now` works under strict CSP.
|
|
12
|
+
- **Tests.** Added a Node unit harness (`test-suite/unit/csp-eval.test.mjs`, run via `npm test`) validating the evaluate/execute/waitFor refactor, and an in-browser regression challenge (42 `strict-csp-evaluate`) that reads a JS-only secret under strict CSP. Updated challenge 39's notes and docs (FAQ, EXAMPLES, COMPARISON, primer) which previously stated eval/snapshot fail under strict CSP.
|
|
13
|
+
|
|
14
|
+
## 0.15.28 — 2026-05-31
|
|
15
|
+
|
|
16
|
+
Low-risk reliability fixes from a long-session bug report.
|
|
17
|
+
|
|
18
|
+
- **Bridge self-heals when the owning session dies.** A Pi session running in shared-client mode used to fail every `chrome_*` call with a bare `fetch failed` once the session that owned `127.0.0.1:17318` exited. The client now detects the unreachable owner, takes over the bridge port, and re-runs the command locally instead of staying stuck.
|
|
19
|
+
- **Actionable timeout messages.** A 30s timeout now says *why*: extension not polling (not installed/closed), polling but didn't pick up the command, or picked it up but never returned a result (long-running action / failed result post) — instead of one generic message.
|
|
20
|
+
- **`chrome_type` / `chrome_fill` DOM path no longer throws `pressKeyInPage is not defined`.** The helper is now included in the injected MAIN-world helper set and its callers await it.
|
|
21
|
+
- **`getBoundingClientRect()` / DOMRect now serialize in `chrome_evaluate`.** DOMRect-like values return `{x,y,width,height,top,right,bottom,left}` instead of `{}`.
|
|
22
|
+
- **Clearer stale-target errors.** A missing `targetId` now lists the current tabs and suggests re-targeting via `chrome_tab list` or `urlIncludes`/`titleIncludes`, instead of a bare "No matching Chrome tab found".
|
|
23
|
+
- **`pageMutated=false` no longer reads as failure.** Click/type/fill summaries explain it's a coarse heuristic that can miss real effects, and suggest verifying with `includeSnapshot`.
|
|
24
|
+
|
|
5
25
|
## 0.15.26 — 2026-05-16
|
|
6
26
|
|
|
7
27
|
- **Documentation accuracy.** README, FAQ, examples, comparison, and test-suite docs now describe the 41-challenge suite, gate buckets, strict-CSP fallback, and current human-vs-extension limitations.
|
package/README.md
CHANGED
|
@@ -217,7 +217,7 @@ Multiple Pi sessions (planner / worker / audit) can all drive the same Chrome at
|
|
|
217
217
|
|
|
218
218
|
## Built-in benchmark suite
|
|
219
219
|
|
|
220
|
-
[`test-suite/`](./test-suite) is a benchmark for **any** browser-control agent (not just pi-chrome). It includes **
|
|
220
|
+
[`test-suite/`](./test-suite) is a benchmark for **any** browser-control agent (not just pi-chrome). It includes **42 primitive challenges** plus **4 hermetic BrowserGym-style long-horizon tasks**.
|
|
221
221
|
|
|
222
222
|
Scoring tracks expected outcomes per challenge rather than raw PASS count, so tools are judged against their declared browser-control capability. Unit challenges are split into gate buckets:
|
|
223
223
|
|
package/docs/COMPARISON.md
CHANGED
|
@@ -134,7 +134,7 @@ If your threat model excludes extensions with broad permissions, neither approac
|
|
|
134
134
|
|
|
135
135
|
## Public benchmarks worth knowing (for axis 2 / axis 3 comparison)
|
|
136
136
|
|
|
137
|
-
Pi-chrome itself ships a benchmark suite ([`../test-suite/`](../test-suite)) of **
|
|
137
|
+
Pi-chrome itself ships a benchmark suite ([`../test-suite/`](../test-suite)) of **42 primitive challenges** plus **4 hermetic BrowserGym-style long-horizon tasks** covering trusted input, pointer humanization, keyboard fidelity, drag/drop, Shadow DOM, iframes, file uploads, strict-CSP screenshot fallback and CDP eval/snapshot bypass, dynamic waits, tab lifecycle, network observability, fingerprint leaks, and agent-safety honeypots. Scoring tracks expected outcomes per challenge instead of raw PASS count, with `core`, `conditional`, and `quality` gate buckets. That's **driver-level** grading.
|
|
138
138
|
|
|
139
139
|
For **agent-level** comparison (axis 2), the public benchmarks worth citing:
|
|
140
140
|
|
package/docs/EXAMPLES.md
CHANGED
|
@@ -161,6 +161,6 @@ Interactive tools use Chrome's real input layer by default: clicks, typing, fill
|
|
|
161
161
|
- fullscreen and other user-activation checks
|
|
162
162
|
- pages where DOM injection/evaluate is limited, if the agent can use screenshots + coordinates
|
|
163
163
|
|
|
164
|
-
Strict CSP note: `chrome_snapshot`/`chrome_evaluate`
|
|
164
|
+
Strict CSP note: `chrome_snapshot`/`chrome_evaluate` work even on pages that disallow `unsafe-eval`, because they run via CDP `Runtime.evaluate` (not page-level `eval`/`new Function`), which is not subject to page CSP. `chrome_screenshot`, tab/navigation tools, and real input also work under any CSP.
|
|
165
165
|
|
|
166
166
|
Chrome may show its debugger banner while pi-chrome is attached.
|
package/docs/FAQ.md
CHANGED
|
@@ -55,7 +55,7 @@ pi-chrome controls web pages through Chrome extension APIs, page inspection, scr
|
|
|
55
55
|
|
|
56
56
|
## Does `chrome_evaluate` work on strict-CSP pages?
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
Yes. `chrome_evaluate` and `chrome_snapshot` run in the page's MAIN world through CDP `Runtime.evaluate`, which is a DevTools protocol command and is **not** subject to the page's Content-Security-Policy. They work even on pages that block `'unsafe-eval'` (e.g. github.com and many bank/SaaS apps). `chrome_navigate`'s `initScript` injects at document_start via CDP and likewise bypasses CSP. `chrome_screenshot`, tab tools, and real Chrome input also keep working under any CSP.
|
|
59
59
|
|
|
60
60
|
## How do I tell whether a click or type worked?
|
|
61
61
|
|
|
@@ -217,6 +217,38 @@ async function cdp(tabId, method, params) {
|
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
// cdpEval: evaluate a JavaScript expression string in the page's MAIN world via CDP
|
|
221
|
+
// Runtime.evaluate. Runtime.evaluate is a DevTools protocol command and is NOT subject to
|
|
222
|
+
// the page's Content-Security-Policy, so it works on pages that ship `script-src 'self'`
|
|
223
|
+
// without `'unsafe-eval'` (which blocks `eval`/`new Function`). Ensures the debugger is
|
|
224
|
+
// attached first. Returns the raw CDP result ({ result, exceptionDetails }).
|
|
225
|
+
async function cdpEval(tabId, expression, opts) {
|
|
226
|
+
await attachDebugger(tabId);
|
|
227
|
+
return cdp(tabId, "Runtime.evaluate", {
|
|
228
|
+
expression,
|
|
229
|
+
returnByValue: true,
|
|
230
|
+
awaitPromise: true,
|
|
231
|
+
userGesture: true,
|
|
232
|
+
...(opts || {}),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function cdpExceptionText(details) {
|
|
237
|
+
if (!details) return "";
|
|
238
|
+
return String(
|
|
239
|
+
details.exception?.description ||
|
|
240
|
+
details.exception?.value ||
|
|
241
|
+
details.text ||
|
|
242
|
+
"",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function cdpIsSyntaxError(details) {
|
|
247
|
+
if (!details) return false;
|
|
248
|
+
const className = String(details.exception?.className || "");
|
|
249
|
+
return className === "SyntaxError" || /SyntaxError/.test(cdpExceptionText(details));
|
|
250
|
+
}
|
|
251
|
+
|
|
220
252
|
// Resolve target -> {x, y, rect} in viewport coords by running tiny script in tab.
|
|
221
253
|
async function resolveTargetInTab(tabId, params) {
|
|
222
254
|
const results = await chrome.scripting.executeScript({
|
|
@@ -739,8 +771,29 @@ async function dispatch(action, params) {
|
|
|
739
771
|
return executeInTab(params, listNetworkRequests, [params.includePreservedRequests === true, params.clear === true]);
|
|
740
772
|
case "page.network.get":
|
|
741
773
|
return executeInTab(params, getNetworkRequest, [params.requestId]);
|
|
742
|
-
case "page.waitFor":
|
|
743
|
-
|
|
774
|
+
case "page.waitFor": {
|
|
775
|
+
// Poll from the service worker via CDP (bypasses CSP). The old approach ran the polling
|
|
776
|
+
// loop in-page with new Function() for expression checks, which fails under strict CSP.
|
|
777
|
+
const tab = await getTabByParams(params);
|
|
778
|
+
if (params.foreground) await bringToFront(tab);
|
|
779
|
+
const timeoutMs = params.timeoutMs || 10000;
|
|
780
|
+
const intervalMs = params.intervalMs || 250;
|
|
781
|
+
const started = Date.now();
|
|
782
|
+
while (Date.now() - started < timeoutMs) {
|
|
783
|
+
let ok = false;
|
|
784
|
+
try {
|
|
785
|
+
const expr = params.kind === "selector"
|
|
786
|
+
? `!!document.querySelector(${JSON.stringify(params.value)})`
|
|
787
|
+
: params.value;
|
|
788
|
+
ok = Boolean(await evaluateInTab({ ...params, expression: expr, foreground: false }));
|
|
789
|
+
} catch {
|
|
790
|
+
ok = false;
|
|
791
|
+
}
|
|
792
|
+
if (ok) return { elapsedMs: Date.now() - started };
|
|
793
|
+
await sleep(intervalMs);
|
|
794
|
+
}
|
|
795
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${params.kind}: ${params.value}`);
|
|
796
|
+
}
|
|
744
797
|
case "page.probe":
|
|
745
798
|
// Lightweight capability probe for /chrome-doctor. Runs in MAIN world.
|
|
746
799
|
return executeInTab(params, probePage, []);
|
|
@@ -787,6 +840,20 @@ async function getTabByParams(params) {
|
|
|
787
840
|
if (params.targetId !== undefined) {
|
|
788
841
|
const id = Number(params.targetId);
|
|
789
842
|
tab = tabs.find((candidate) => candidate.id === id);
|
|
843
|
+
if (!tab?.id) {
|
|
844
|
+
// Chrome tab ids are not stable across reloads/navigations; a long session can hold a
|
|
845
|
+
// stale id. Surface the current tabs so the caller can re-target instead of guessing.
|
|
846
|
+
const listed = tabs
|
|
847
|
+
.filter((candidate) => candidate.id !== undefined)
|
|
848
|
+
.slice(0, 20)
|
|
849
|
+
.map((candidate) => ` ${candidate.id}${candidate.active ? " *" : ""}\t${(candidate.title || "(untitled)").slice(0, 60)}\t${candidate.url || ""}`)
|
|
850
|
+
.join("\n");
|
|
851
|
+
throw new Error(
|
|
852
|
+
`No Chrome tab with id ${id} (it was likely closed or replaced). ` +
|
|
853
|
+
`Re-target with chrome_tab list, or pass urlIncludes/titleIncludes instead of targetId.\n` +
|
|
854
|
+
`Current tabs:\n${listed || " (none)"}`,
|
|
855
|
+
);
|
|
856
|
+
}
|
|
790
857
|
} else if (params.urlIncludes) {
|
|
791
858
|
tab = tabs.find((candidate) => (candidate.url || "").includes(params.urlIncludes));
|
|
792
859
|
} else if (params.titleIncludes) {
|
|
@@ -826,31 +893,40 @@ const HELPER_FUNCS = [
|
|
|
826
893
|
printableKeyCode,
|
|
827
894
|
dispatchKeyEvent,
|
|
828
895
|
typeCharacter,
|
|
896
|
+
pressKeyInPage,
|
|
829
897
|
scrollPage,
|
|
830
898
|
];
|
|
831
899
|
|
|
832
900
|
async function executeInTab(params, func, args) {
|
|
833
901
|
const tab = await getTabByParams(params);
|
|
834
902
|
if (params.foreground) await bringToFront(tab);
|
|
835
|
-
|
|
903
|
+
|
|
904
|
+
// Phase 1: define the helpers and the action function as page globals via CDP
|
|
905
|
+
// Runtime.evaluate. This bypasses page CSP (no `eval`/`new Function`), which is the
|
|
906
|
+
// root cause of snapshot/click/etc silently failing on `script-src 'self'` sites.
|
|
907
|
+
// Each helper is a named function declaration, assigned to window.<name> so the action
|
|
908
|
+
// (which references helpers by bare name) resolves them as globals at call time.
|
|
909
|
+
const assignments = HELPER_FUNCS.map((helper) => `window.${helper.name}=${helper.toString()}`).join(";\n");
|
|
910
|
+
const actionAssign = `window.__piAction=(${func.toString()})`;
|
|
911
|
+
const defineRes = await cdpEval(tab.id, `(()=>{${assignments};\n${actionAssign};})()`);
|
|
912
|
+
if (defineRes.exceptionDetails) {
|
|
913
|
+
throw new Error(`Failed to inject Chrome page helpers: ${cdpExceptionText(defineRes.exceptionDetails) || "unknown error"}`);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Phase 2: run the action via chrome.scripting.executeScript. The `func:` form is
|
|
917
|
+
// injected by Chrome itself (not `new Function`), so it is CSP-safe, and it lets Chrome
|
|
918
|
+
// serialize the invocation args. The wrapper references window.__piAction defined above.
|
|
836
919
|
const results = await chrome.scripting.executeScript({
|
|
837
920
|
target: { tabId: tab.id },
|
|
838
921
|
world: "MAIN",
|
|
839
|
-
func: async (
|
|
922
|
+
func: async (invocationArgs) => {
|
|
840
923
|
try {
|
|
841
|
-
|
|
842
|
-
// running through `eval` (which is restricted under strict CSP) and keeps them isolated.
|
|
843
|
-
new Function(helperSource).call(globalThis);
|
|
844
|
-
// The action itself is reconstructed from its source text. We use `new Function` rather
|
|
845
|
-
// than `eval` because the latter is blocked by `script-src 'self'` (no `'unsafe-eval'`)
|
|
846
|
-
// CSPs that are common on production sites.
|
|
847
|
-
const injected = new Function(helperSource + "\nreturn (" + source + ");").call(globalThis);
|
|
848
|
-
return { ok: true, value: await injected(...invocationArgs) };
|
|
924
|
+
return { ok: true, value: await window.__piAction(...invocationArgs) };
|
|
849
925
|
} catch (error) {
|
|
850
926
|
return { ok: false, error: error?.stack || error?.message || String(error) };
|
|
851
927
|
}
|
|
852
928
|
},
|
|
853
|
-
args: [
|
|
929
|
+
args: [args || []],
|
|
854
930
|
});
|
|
855
931
|
const first = results?.[0];
|
|
856
932
|
if (first?.error) {
|
|
@@ -864,64 +940,54 @@ async function executeInTab(params, func, args) {
|
|
|
864
940
|
return envelope?.value;
|
|
865
941
|
}
|
|
866
942
|
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
943
|
+
// Serializer for page.evaluate results. Embedded (via .toString()) into the CDP-evaluated
|
|
944
|
+
// expression so we can return rich markers for values that don't survive returnByValue
|
|
945
|
+
// (undefined/function/symbol/bigint/Error), plus expand DOMRect-like objects whose fields
|
|
946
|
+
// are non-enumerable. Kept as a standalone function so it stays editable/lintable.
|
|
947
|
+
function piEvalStringify(v) {
|
|
948
|
+
if (v === undefined) return { kind: "undefined" };
|
|
949
|
+
if (typeof v === "function") return { kind: "function", source: v.toString().slice(0, 500) };
|
|
950
|
+
if (typeof v === "symbol") return { kind: "symbol", description: v.description };
|
|
951
|
+
if (typeof v === "bigint") return { kind: "bigint", value: v.toString() };
|
|
952
|
+
if (v instanceof Error) return { kind: "error", name: v.name, message: v.message, stack: v.stack };
|
|
953
|
+
// DOMRect/DOMRectReadOnly (and getBoundingClientRect results) have non-enumerable
|
|
954
|
+
// properties, so JSON.stringify yields `{}`. Expand the fields explicitly.
|
|
955
|
+
if ((typeof DOMRectReadOnly !== "undefined" && v instanceof DOMRectReadOnly) ||
|
|
956
|
+
(typeof DOMRect !== "undefined" && v instanceof DOMRect) ||
|
|
957
|
+
(v && typeof v === "object" && typeof v.toJSON === "function" &&
|
|
958
|
+
typeof v.width === "number" && typeof v.height === "number" && typeof v.top === "number")) {
|
|
959
|
+
return { x: v.x, y: v.y, width: v.width, height: v.height, top: v.top, right: v.right, bottom: v.bottom, left: v.left };
|
|
960
|
+
}
|
|
961
|
+
return v;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Dedicated executor for page.evaluate. Uses CDP Runtime.evaluate (via cdpEval) which is not
|
|
965
|
+
// subject to the page's CSP, fixing `chrome_evaluate` silently returning null / failing on
|
|
966
|
+
// pages that ship `script-src 'self'` without `'unsafe-eval'` (which blocks `eval`/`new Function`).
|
|
870
967
|
async function evaluateInTab(params) {
|
|
871
968
|
const tab = await getTabByParams(params);
|
|
872
969
|
if (params.foreground) await bringToFront(tab);
|
|
873
970
|
const expression = String(params.expression ?? "");
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
if (e1 && e1.name === "SyntaxError") {
|
|
895
|
-
try {
|
|
896
|
-
return { fn: new Function(`return (async () => { ${src} })();`), mode: "statement" };
|
|
897
|
-
} catch (e2) {
|
|
898
|
-
throw e2;
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
throw e1;
|
|
902
|
-
}
|
|
903
|
-
};
|
|
904
|
-
try {
|
|
905
|
-
const { fn } = compile(expression);
|
|
906
|
-
const value = await fn.call(globalThis);
|
|
907
|
-
const resolved = awaitPromise && value && typeof value.then === "function" ? await value : value;
|
|
908
|
-
return { ok: true, value: stringify(resolved) };
|
|
909
|
-
} catch (error) {
|
|
910
|
-
return { ok: false, error: error?.stack || error?.message || String(error) };
|
|
911
|
-
}
|
|
912
|
-
},
|
|
913
|
-
args: [expression, awaitPromise],
|
|
914
|
-
});
|
|
915
|
-
const first = results?.[0];
|
|
916
|
-
if (first?.error) {
|
|
917
|
-
const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));
|
|
918
|
-
throw new Error(`chrome_evaluate failed: ${message}`);
|
|
919
|
-
}
|
|
920
|
-
const envelope = first?.result;
|
|
921
|
-
if (!envelope) throw new Error("chrome_evaluate returned no envelope from MAIN world");
|
|
922
|
-
if (envelope.ok === false) throw new Error(envelope.error || "chrome_evaluate failed");
|
|
923
|
-
const v = envelope.value;
|
|
924
|
-
// Unwrap special markers from MAIN world
|
|
971
|
+
const stringifySrc = `(${piEvalStringify.toString()})`;
|
|
972
|
+
// Wrap the user expression so the result is run through piEvalStringify in-page before it
|
|
973
|
+
// crosses the returnByValue boundary. Try expression form first (so `1+1` / `document.title`
|
|
974
|
+
// work without `return`); on a SyntaxError fall back to statement form for multi-statement
|
|
975
|
+
// bodies (loops, var decls, etc), matching the previous new Function() two-form behavior.
|
|
976
|
+
const buildWrapper = (form) => `(async () => { const __s=${stringifySrc}; const __v = await ${form}; return __s(__v); })()`;
|
|
977
|
+
const exprForm = `(async () => (${expression}))()`;
|
|
978
|
+
const stmtForm = `(async () => { ${expression} })()`;
|
|
979
|
+
|
|
980
|
+
let res = await cdpEval(tab.id, buildWrapper(exprForm));
|
|
981
|
+
if (res.exceptionDetails && cdpIsSyntaxError(res.exceptionDetails)) {
|
|
982
|
+
res = await cdpEval(tab.id, buildWrapper(stmtForm));
|
|
983
|
+
}
|
|
984
|
+
if (res.exceptionDetails) {
|
|
985
|
+
throw new Error(`chrome_evaluate failed: ${cdpExceptionText(res.exceptionDetails) || "evaluation failed"}`);
|
|
986
|
+
}
|
|
987
|
+
const result = res.result;
|
|
988
|
+
if (!result || result.type === "undefined") return undefined;
|
|
989
|
+
const v = result.value;
|
|
990
|
+
// Unwrap special markers produced by piEvalStringify.
|
|
925
991
|
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
926
992
|
if (v.kind === "undefined") return undefined;
|
|
927
993
|
if (v.kind === "function") return `[Function: ${v.source}]`;
|
|
@@ -941,29 +1007,23 @@ async function withOptionalSnapshot(params, actionFn) {
|
|
|
941
1007
|
return result;
|
|
942
1008
|
}
|
|
943
1009
|
|
|
944
|
-
// One-shot init script registry, scoped per tab. The
|
|
945
|
-
//
|
|
946
|
-
|
|
1010
|
+
// One-shot init script registry, scoped per tab. The source is registered with CDP
|
|
1011
|
+
// Page.addScriptToEvaluateOnNewDocument, which runs it at document_start in the page's MAIN
|
|
1012
|
+
// world and is NOT subject to page CSP (the old func:(code)=>new Function(code) path was
|
|
1013
|
+
// blocked by `script-src 'self'`). page.navigate registers before the nav and unregisters
|
|
1014
|
+
// after load, so only the intended navigation receives the script.
|
|
1015
|
+
const initScriptIds = new Map(); // tabId -> CDP script identifier
|
|
947
1016
|
async function registerInitScript(tabId, source) {
|
|
948
|
-
|
|
1017
|
+
await attachDebugger(tabId);
|
|
1018
|
+
await cdp(tabId, "Page.enable", {}).catch(() => undefined);
|
|
1019
|
+
const result = await cdp(tabId, "Page.addScriptToEvaluateOnNewDocument", { source });
|
|
1020
|
+
if (result && result.identifier !== undefined) initScriptIds.set(tabId, result.identifier);
|
|
949
1021
|
}
|
|
950
1022
|
async function unregisterInitScript(tabId) {
|
|
1023
|
+
const identifier = initScriptIds.get(tabId);
|
|
1024
|
+
if (identifier === undefined) return;
|
|
951
1025
|
initScriptIds.delete(tabId);
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
if (chrome.webNavigation && chrome.webNavigation.onCommitted) {
|
|
955
|
-
chrome.webNavigation.onCommitted.addListener((details) => {
|
|
956
|
-
if (details.frameId !== 0) return;
|
|
957
|
-
const source = initScriptIds.get(details.tabId);
|
|
958
|
-
if (!source) return;
|
|
959
|
-
chrome.scripting.executeScript({
|
|
960
|
-
target: { tabId: details.tabId, frameIds: [0] },
|
|
961
|
-
world: "MAIN",
|
|
962
|
-
injectImmediately: true,
|
|
963
|
-
func: (code) => { try { new Function(code).call(globalThis); } catch (e) { console.error("[pi-chrome init script]", e); } },
|
|
964
|
-
args: [source],
|
|
965
|
-
}).catch(() => undefined);
|
|
966
|
-
});
|
|
1026
|
+
await cdp(tabId, "Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => undefined);
|
|
967
1027
|
}
|
|
968
1028
|
|
|
969
1029
|
// Always inject early console/network capture at document_start on every navigation.
|
|
@@ -1927,7 +1987,7 @@ async function typeIntoPage(selector, uid, text, pressEnter) {
|
|
|
1927
1987
|
element.focus();
|
|
1928
1988
|
if (!(element.isContentEditable || "value" in element)) throw new Error("Focused element is not text-editable");
|
|
1929
1989
|
for (const ch of Array.from(text)) await typeCharacter(element, ch);
|
|
1930
|
-
if (pressEnter) pressKeyInPage("Enter");
|
|
1990
|
+
if (pressEnter) await pressKeyInPage("Enter");
|
|
1931
1991
|
const finalValue = "value" in element ? element.value : element.textContent;
|
|
1932
1992
|
const valueMatches = "value" in element ? element.value.includes(text) : (element.textContent || "").includes(text);
|
|
1933
1993
|
const pageMutated = pageHash() !== before;
|
|
@@ -1947,7 +2007,7 @@ async function typeIntoPage(selector, uid, text, pressEnter) {
|
|
|
1947
2007
|
};
|
|
1948
2008
|
}
|
|
1949
2009
|
|
|
1950
|
-
function fillPage(selector, uid, text, submit) {
|
|
2010
|
+
async function fillPage(selector, uid, text, submit) {
|
|
1951
2011
|
installPiChromeInstrumentation();
|
|
1952
2012
|
const before = pageHash();
|
|
1953
2013
|
let element = elementBySelectorOrUid(selector, uid) || document.activeElement;
|
|
@@ -1964,7 +2024,7 @@ function fillPage(selector, uid, text, submit) {
|
|
|
1964
2024
|
} else {
|
|
1965
2025
|
throw new Error("Focused element is not text-editable");
|
|
1966
2026
|
}
|
|
1967
|
-
if (submit) pressKeyInPage("Enter");
|
|
2027
|
+
if (submit) await pressKeyInPage("Enter");
|
|
1968
2028
|
return {
|
|
1969
2029
|
selector, uid, length: String(text).length, submit,
|
|
1970
2030
|
input: "dom",
|
|
@@ -2053,20 +2113,6 @@ function getNetworkRequest(requestId) {
|
|
|
2053
2113
|
return request;
|
|
2054
2114
|
}
|
|
2055
2115
|
|
|
2056
|
-
async function waitForPage(kind, value, timeoutMs, intervalMs) {
|
|
2057
|
-
const started = Date.now();
|
|
2058
|
-
while (Date.now() - started < timeoutMs) {
|
|
2059
|
-
let ok = false;
|
|
2060
|
-
if (kind === "selector") ok = Boolean(document.querySelector(value));
|
|
2061
|
-
else {
|
|
2062
|
-
try { ok = Boolean(new Function("return (" + value + ");").call(globalThis)); } catch { ok = false; }
|
|
2063
|
-
}
|
|
2064
|
-
if (ok) return { elapsedMs: Date.now() - started };
|
|
2065
|
-
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
2066
|
-
}
|
|
2067
|
-
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${kind}: ${value}`);
|
|
2068
|
-
}
|
|
2069
|
-
|
|
2070
2116
|
function normalizeKey(key) {
|
|
2071
2117
|
const table = {
|
|
2072
2118
|
enter: "Enter",
|
|
@@ -35,6 +35,7 @@ type PendingCommand = {
|
|
|
35
35
|
resolve: (value: unknown) => void;
|
|
36
36
|
reject: (error: Error) => void;
|
|
37
37
|
timer: NodeJS.Timeout;
|
|
38
|
+
deliveredAt?: number;
|
|
38
39
|
};
|
|
39
40
|
|
|
40
41
|
type BridgeResult = {
|
|
@@ -103,7 +104,11 @@ function summarizeActionResult(result: unknown): string | undefined {
|
|
|
103
104
|
if (!result || typeof result !== "object") return undefined;
|
|
104
105
|
const r = result as Record<string, unknown>;
|
|
105
106
|
const parts: string[] = [];
|
|
106
|
-
|
|
107
|
+
// NOTE: pageMutated is a coarse heuristic (a hash over body text + input values + node count).
|
|
108
|
+
// Many real effects — class/aria/data-state toggles, JS-held state, canvas, async updates —
|
|
109
|
+
// don't move it, so a false value is NOT proof the action did nothing. Surface it only as a
|
|
110
|
+
// soft hint, and never present it as a failure on its own.
|
|
111
|
+
if (r.pageMutated === false) parts.push("no coarse DOM change detected (may still have taken effect — verify with includeSnapshot)");
|
|
107
112
|
if (r.defaultPrevented === true) parts.push("defaultPrevented=true");
|
|
108
113
|
if (r.elementVisible === false) parts.push("element NOT visible");
|
|
109
114
|
if (r.occludedBy) {
|
|
@@ -195,23 +200,29 @@ class ChromeProfileBridge {
|
|
|
195
200
|
|
|
196
201
|
async start(): Promise<void> {
|
|
197
202
|
if (this.server || this.mode === "client") return;
|
|
198
|
-
this.
|
|
203
|
+
await this.bindServerOrClient();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Try to own the bridge port. On success we are the server; on EADDRINUSE another Pi
|
|
207
|
+
// session owns it and we run as a client that forwards commands to that owner.
|
|
208
|
+
private async bindServerOrClient(): Promise<void> {
|
|
209
|
+
const server = createServer((request, response) => {
|
|
199
210
|
void this.handle(request, response).catch((error) => {
|
|
200
211
|
sendJson(response, 500, { error: (error as Error).message });
|
|
201
212
|
});
|
|
202
213
|
});
|
|
203
214
|
try {
|
|
204
215
|
await new Promise<void>((resolveStart, rejectStart) => {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
216
|
+
server.once("error", rejectStart);
|
|
217
|
+
server.listen(this.port, this.host, () => {
|
|
218
|
+
server.off("error", rejectStart);
|
|
208
219
|
resolveStart();
|
|
209
220
|
});
|
|
210
221
|
});
|
|
222
|
+
this.server = server;
|
|
211
223
|
this.mode = "server";
|
|
212
224
|
} catch (error) {
|
|
213
|
-
|
|
214
|
-
this.server = undefined;
|
|
225
|
+
server.close();
|
|
215
226
|
if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
|
|
216
227
|
// Another Pi session already owns the bridge port. Use it as the shared
|
|
217
228
|
// machine-local broker so multiple Pi sessions can control Chrome at once.
|
|
@@ -219,6 +230,16 @@ class ChromeProfileBridge {
|
|
|
219
230
|
}
|
|
220
231
|
}
|
|
221
232
|
|
|
233
|
+
// Client-mode self-heal: when the owning Pi session disappears, fetches to its port fail
|
|
234
|
+
// with `fetch failed` / ECONNREFUSED forever. Try to grab the now-free port and become the
|
|
235
|
+
// server ourselves so chrome_* tools recover without a manual restart.
|
|
236
|
+
private async tryPromoteToServer(): Promise<boolean> {
|
|
237
|
+
if (this.mode !== "client") return this.mode === "server";
|
|
238
|
+
this.mode = undefined;
|
|
239
|
+
await this.bindServerOrClient();
|
|
240
|
+
return this.mode === "server";
|
|
241
|
+
}
|
|
242
|
+
|
|
222
243
|
stop(): void {
|
|
223
244
|
if (this.mode === "client") {
|
|
224
245
|
this.mode = undefined;
|
|
@@ -261,14 +282,11 @@ class ChromeProfileBridge {
|
|
|
261
282
|
rejectCommand(new Error("Chrome command aborted"));
|
|
262
283
|
};
|
|
263
284
|
const timer = setTimeout(() => {
|
|
285
|
+
const entry = this.pending.get(id);
|
|
264
286
|
this.pending.delete(id);
|
|
265
287
|
this.queue = this.queue.filter((queued) => queued.id !== id);
|
|
266
288
|
cleanupAbort();
|
|
267
|
-
rejectCommand(
|
|
268
|
-
new Error(
|
|
269
|
-
`Timed out waiting for Chrome extension after ${timeoutMs}ms. Run /chrome onboard, then load the bundled browser-extension folder in your normal Chrome profile.`,
|
|
270
|
-
),
|
|
271
|
-
);
|
|
289
|
+
rejectCommand(new Error(this.timeoutMessage(entry, timeoutMs)));
|
|
272
290
|
}, timeoutMs);
|
|
273
291
|
this.pending.set(id, {
|
|
274
292
|
command,
|
|
@@ -281,6 +299,21 @@ class ChromeProfileBridge {
|
|
|
281
299
|
});
|
|
282
300
|
}
|
|
283
301
|
|
|
302
|
+
// Classify why a local command timed out so the agent isn't left guessing. The three
|
|
303
|
+
// distinct failure modes are: extension never polled (not installed / not running),
|
|
304
|
+
// extension polled but never picked up this command, and extension picked up the command
|
|
305
|
+
// but never posted a result back (long-running action or a failed /result post).
|
|
306
|
+
private timeoutMessage(entry: PendingCommand | undefined, timeoutMs: number): string {
|
|
307
|
+
const pollAgeMs = this.lastSeenAt === undefined ? undefined : Date.now() - this.lastSeenAt;
|
|
308
|
+
if (entry?.deliveredAt) {
|
|
309
|
+
return `Timed out after ${timeoutMs}ms: the Chrome extension received the command but never returned a result. The action may be long-running, or the result post failed. Run /chrome doctor; if it persists, reload 'Pi Chrome Connector' at chrome://extensions.`;
|
|
310
|
+
}
|
|
311
|
+
if (pollAgeMs === undefined || pollAgeMs > 60_000) {
|
|
312
|
+
return `Timed out after ${timeoutMs}ms: the Chrome extension is not polling (last seen ${pollAgeMs === undefined ? "never" : Math.round(pollAgeMs / 1000) + "s ago"}). Run /chrome onboard, then load the bundled browser-extension folder in your normal Chrome profile and keep that Chrome window open.`;
|
|
313
|
+
}
|
|
314
|
+
return `Timed out after ${timeoutMs}ms: the Chrome extension is polling (last seen ${Math.round(pollAgeMs / 1000)}s ago) but did not pick up this command in time. Retry; if it persists, reload 'Pi Chrome Connector' at chrome://extensions.`;
|
|
315
|
+
}
|
|
316
|
+
|
|
284
317
|
private async sendViaOwner(action: string, params: Record<string, unknown>, timeoutMs: number, signal?: AbortSignal): Promise<unknown> {
|
|
285
318
|
const controller = new AbortController();
|
|
286
319
|
const timer = setTimeout(() => controller.abort(), timeoutMs + 2_000);
|
|
@@ -309,6 +342,16 @@ class ChromeProfileBridge {
|
|
|
309
342
|
if (signal?.aborted) throw new Error("Chrome command aborted");
|
|
310
343
|
throw new Error(`Timed out waiting for shared Chrome bridge owner after ${timeoutMs}ms`);
|
|
311
344
|
}
|
|
345
|
+
// `fetch failed` / ECONNREFUSED means the Pi session that owned the bridge port is gone.
|
|
346
|
+
// Try to take over the port ourselves and re-run the command locally instead of staying
|
|
347
|
+
// stuck as a client pointed at a dead owner.
|
|
348
|
+
if (this.isOwnerUnreachable(error)) {
|
|
349
|
+
const promoted = await this.tryPromoteToServer().catch(() => false);
|
|
350
|
+
if (promoted) return this.sendLocal(action, params, timeoutMs, signal);
|
|
351
|
+
throw new Error(
|
|
352
|
+
"The Pi session that owned the Chrome bridge is unreachable and this session could not take over the bridge port. Restart this Pi session, or run /chrome doctor.",
|
|
353
|
+
);
|
|
354
|
+
}
|
|
312
355
|
throw error;
|
|
313
356
|
} finally {
|
|
314
357
|
clearTimeout(timer);
|
|
@@ -316,6 +359,19 @@ class ChromeProfileBridge {
|
|
|
316
359
|
}
|
|
317
360
|
}
|
|
318
361
|
|
|
362
|
+
private isOwnerUnreachable(error: unknown): boolean {
|
|
363
|
+
const message = (error as Error)?.message ?? "";
|
|
364
|
+
const code = (error as NodeJS.ErrnoException)?.code ?? "";
|
|
365
|
+
const cause = (error as { cause?: NodeJS.ErrnoException })?.cause;
|
|
366
|
+
const causeCode = cause?.code ?? "";
|
|
367
|
+
return (
|
|
368
|
+
/fetch failed|ECONNREFUSED|ECONNRESET|other side closed|socket hang up/i.test(message) ||
|
|
369
|
+
code === "ECONNREFUSED" ||
|
|
370
|
+
causeCode === "ECONNREFUSED" ||
|
|
371
|
+
causeCode === "ECONNRESET"
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
319
375
|
private enqueue(command: BridgeCommand): void {
|
|
320
376
|
const waiter = this.waiters.shift();
|
|
321
377
|
if (waiter) waiter(command);
|
|
@@ -384,6 +440,12 @@ class ChromeProfileBridge {
|
|
|
384
440
|
if (command) this.queue.unshift(command);
|
|
385
441
|
return;
|
|
386
442
|
}
|
|
443
|
+
// Mark the command as delivered so a later timeout can distinguish "extension never
|
|
444
|
+
// picked it up" from "extension is running it / failed to post a result".
|
|
445
|
+
if (command) {
|
|
446
|
+
const entry = this.pending.get(command.id);
|
|
447
|
+
if (entry) entry.deliveredAt = Date.now();
|
|
448
|
+
}
|
|
387
449
|
// Re-read version on every /next so bumping package.json takes effect without pi restart.
|
|
388
450
|
const currentVersion = readPiChromeVersion();
|
|
389
451
|
sendJson(
|
|
@@ -603,7 +665,7 @@ Chrome control is available through the chrome_* tools via a companion Chrome ex
|
|
|
603
665
|
Capability model (important):
|
|
604
666
|
- Interactive controls (click/type/fill/key/hover/drag/scroll/tap) use Chrome's real input layer via chrome.debugger / CDP. Events satisfy normal user-activation gates.
|
|
605
667
|
- Input bypasses page CSP because it is injected at browser input layer, not page JavaScript. Chrome may show the “Pi Chrome Connector started debugging this browser” banner while attached.
|
|
606
|
-
- \`chrome_evaluate\` and \`chrome_snapshot\` run in MAIN world via
|
|
668
|
+
- \`chrome_evaluate\` and \`chrome_snapshot\` run in MAIN world via **CDP \`Runtime.evaluate\`**, which is not subject to the page's Content-Security-Policy. They work even on strict-CSP pages (e.g. github.com, many bank/SaaS apps) that block \`'unsafe-eval'\`. \`chrome_navigate initScript\` likewise injects at document_start via CDP and bypasses CSP. \`chrome_screenshot\`, \`chrome_tab\`, and Chrome input also work under any CSP.
|
|
607
669
|
- Input tools return structured details and support \`includeSnapshot=true\` on click/type/fill/key. Use the fresh snapshot to verify state instead of repeating blindly.
|
|
608
670
|
|
|
609
671
|
Usage rules:
|
package/package.json
CHANGED
package/test-suite/README.md
CHANGED
|
@@ -125,7 +125,7 @@ Each unit challenge has a `gate` field:
|
|
|
125
125
|
- `dom-complexity` / `frames` — Shadow DOM and iframe targeting.
|
|
126
126
|
- `files` — file attachment to `<input type=file>`.
|
|
127
127
|
- `observability` — console/network capture tools.
|
|
128
|
-
- `csp` — strict Content Security Policy fallback
|
|
128
|
+
- `csp` — strict Content Security Policy: screenshot/coordinate fallback (39) and the CDP eval/snapshot bypass that works under `script-src 'self'` without `unsafe-eval` (42).
|
|
129
129
|
- `lazy-loading` — dynamic DOM readiness and wait behavior.
|
|
130
130
|
- `fingerprint` — environment and stack fingerprint probes.
|
|
131
131
|
- `agent-safety` — hidden honeypots and safe target selection.
|
|
@@ -175,6 +175,7 @@ The dashboard renders this from `manifest.json`. In brief:
|
|
|
175
175
|
39. strict CSP screenshot/coordinate fallback
|
|
176
176
|
40. dynamic wait/readiness
|
|
177
177
|
41. explicit tab lifecycle
|
|
178
|
+
42. strict CSP eval/snapshot via CDP (regression guard for the CSP bypass)
|
|
178
179
|
|
|
179
180
|
## Design notes
|
|
180
181
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'">
|
|
4
|
+
<title>42 strict CSP evaluate/snapshot</title>
|
|
5
|
+
<link rel="stylesheet" href="../_style.css">
|
|
6
|
+
<script src="../_lib.js"></script>
|
|
7
|
+
<body>
|
|
8
|
+
<main>
|
|
9
|
+
<p>Goal: this page ships a strict CSP (<code>script-src 'self'</code>, no <code>unsafe-eval</code>), which blocks <code>eval</code>/<code>new Function</code>. <code>chrome_evaluate</code> and <code>chrome_snapshot</code> must still work because they run through CDP, which is not subject to page CSP.</p>
|
|
10
|
+
<p id="hint">A secret token is exposed only at <code>window.__cspToken</code> — it is never written into the DOM. Use <code>chrome_evaluate</code> to read it, type it into the field (snapshot/uid to find the field), then click Verify.</p>
|
|
11
|
+
<label for="tokenInput">Token:</label>
|
|
12
|
+
<input id="tokenInput" type="text" autocomplete="off" aria-label="csp token">
|
|
13
|
+
<button id="verify" aria-label="verify token">Verify</button>
|
|
14
|
+
</main>
|
|
15
|
+
<script src="42-strict-csp-evaluate.js"></script>
|
|
16
|
+
</body>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Challenge.init({
|
|
2
|
+
id: "strict-csp-evaluate",
|
|
3
|
+
instructions: "under strict CSP: read window.__cspToken via chrome_evaluate, type it into the field, click Verify",
|
|
4
|
+
});
|
|
5
|
+
|
|
6
|
+
// Secret available only via JS evaluation. It is intentionally NOT rendered into the DOM and
|
|
7
|
+
// is defined non-enumerable, so the only way to obtain it is to evaluate window.__cspToken in
|
|
8
|
+
// the page (which proves chrome_evaluate works despite script-src 'self' blocking eval).
|
|
9
|
+
const token = "csp-" + Math.random().toString(36).slice(2, 10);
|
|
10
|
+
Object.defineProperty(window, "__cspToken", { value: token, enumerable: false, configurable: false, writable: false });
|
|
11
|
+
|
|
12
|
+
document.getElementById("verify").addEventListener("click", (e) => {
|
|
13
|
+
const bad = [];
|
|
14
|
+
if (!e.isTrusted) bad.push("verify click isTrusted=false (use trusted/CDP input)");
|
|
15
|
+
const val = (document.getElementById("tokenInput").value || "").trim();
|
|
16
|
+
if (val !== token) {
|
|
17
|
+
bad.push(`token mismatch: got "${val}" expected "${token}" — chrome_evaluate must read window.__cspToken under strict CSP`);
|
|
18
|
+
}
|
|
19
|
+
if (bad.length) Challenge.fail(...bad);
|
|
20
|
+
else Challenge.pass("strict CSP: chrome_evaluate read the hidden token via CDP and trusted input submitted it");
|
|
21
|
+
});
|
package/test-suite/manifest.json
CHANGED
|
@@ -1510,7 +1510,7 @@
|
|
|
1510
1510
|
"strict-csp"
|
|
1511
1511
|
],
|
|
1512
1512
|
"notes": [
|
|
1513
|
-
"chrome_snapshot/chrome_evaluate
|
|
1513
|
+
"This challenge exercises the pure screenshot + viewport-coordinate path (no snapshot/evaluate needed). Note: as of the CDP CSP bypass, chrome_snapshot/chrome_evaluate DO work here even though script-src omits unsafe-eval (see challenge 42 strict-csp-evaluate). Read verdict from dashboard/localStorage after leaving the CSP page."
|
|
1514
1514
|
],
|
|
1515
1515
|
"manualBaseline": "unverified",
|
|
1516
1516
|
"gradeSource": "page"
|
|
@@ -1626,5 +1626,56 @@
|
|
|
1626
1626
|
],
|
|
1627
1627
|
"manualBaseline": "unverified",
|
|
1628
1628
|
"gradeSource": "page"
|
|
1629
|
+
},
|
|
1630
|
+
{
|
|
1631
|
+
"id": "strict-csp-evaluate",
|
|
1632
|
+
"file": "challenges/42-strict-csp-evaluate.html",
|
|
1633
|
+
"category": "csp",
|
|
1634
|
+
"difficulty": "L2",
|
|
1635
|
+
"gate": "core",
|
|
1636
|
+
"goal": "On a page with strict CSP (script-src 'self', no unsafe-eval), use chrome_evaluate + chrome_snapshot via CDP to read a JS-only secret (window.__cspToken), then submit it with trusted input.",
|
|
1637
|
+
"expected": {
|
|
1638
|
+
"synthetic": "FAIL",
|
|
1639
|
+
"trusted": "PASS",
|
|
1640
|
+
"manual": "PASS"
|
|
1641
|
+
},
|
|
1642
|
+
"recipe": [
|
|
1643
|
+
{
|
|
1644
|
+
"tool": "chrome_evaluate",
|
|
1645
|
+
"params": {
|
|
1646
|
+
"expression": "window.__cspToken"
|
|
1647
|
+
}
|
|
1648
|
+
},
|
|
1649
|
+
{
|
|
1650
|
+
"tool": "chrome_fill",
|
|
1651
|
+
"params": {
|
|
1652
|
+
"selector": "#tokenInput",
|
|
1653
|
+
"value": "$RESULT_OF_chrome_evaluate",
|
|
1654
|
+
"trusted": true
|
|
1655
|
+
}
|
|
1656
|
+
},
|
|
1657
|
+
{
|
|
1658
|
+
"tool": "chrome_click",
|
|
1659
|
+
"params": {
|
|
1660
|
+
"selector": "#verify",
|
|
1661
|
+
"trusted": true
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
],
|
|
1665
|
+
"requires": {
|
|
1666
|
+
"cdp": true
|
|
1667
|
+
},
|
|
1668
|
+
"tags": [
|
|
1669
|
+
"csp",
|
|
1670
|
+
"evaluate",
|
|
1671
|
+
"snapshot",
|
|
1672
|
+
"strict-csp",
|
|
1673
|
+
"cdp-bypass"
|
|
1674
|
+
],
|
|
1675
|
+
"notes": [
|
|
1676
|
+
"Regression guard for the CDP CSP bypass: chrome_evaluate and chrome_snapshot must work even though script-src omits unsafe-eval (eval/new Function are blocked). The token is non-enumerable and never in the DOM, so it can only be obtained via chrome_evaluate. Runner must substitute the evaluate result into the fill value. synthetic FAIL is due to the trusted-click gate, not eval availability."
|
|
1677
|
+
],
|
|
1678
|
+
"manualBaseline": "unverified",
|
|
1679
|
+
"gradeSource": "page"
|
|
1629
1680
|
}
|
|
1630
1681
|
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Unit harness for the CSP-bypass layer in service_worker.js.
|
|
2
|
+
//
|
|
3
|
+
// The real CSP bypass (CDP Runtime.evaluate not being subject to page CSP) can only be
|
|
4
|
+
// proven in a browser — see challenge 39-strict-csp-fallback. These tests instead validate
|
|
5
|
+
// the JS *logic* of the refactor that the bypass depends on:
|
|
6
|
+
// - evaluateInTab: wrapper-string construction, expression/statement fallback, value
|
|
7
|
+
// marker round-trip (undefined/function/symbol/bigint/Error/DOMRect), error propagation.
|
|
8
|
+
// - executeInTab: 2-phase define-then-invoke, envelope unwrap, error propagation, and that
|
|
9
|
+
// all real HELPER_FUNCS serialize+assign without a parse error.
|
|
10
|
+
// - page.waitFor: service-worker-side polling via evaluateInTab (selector + expression).
|
|
11
|
+
//
|
|
12
|
+
// We load the worker into a vm sandbox with mocked chrome.* APIs, then replace `cdp` with a
|
|
13
|
+
// shim that evaluates the expression in a separate "page world" vm context (simulating CDP
|
|
14
|
+
// Runtime.evaluate returnByValue). No browser, no network, no deps.
|
|
15
|
+
|
|
16
|
+
import vm from "node:vm";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const workerPath = path.resolve(__dirname, "../../extensions/chrome-profile-bridge/browser-extension/service_worker.js");
|
|
23
|
+
const src = fs.readFileSync(workerPath, "utf8");
|
|
24
|
+
|
|
25
|
+
let failures = 0;
|
|
26
|
+
let passes = 0;
|
|
27
|
+
function ok(cond, msg) {
|
|
28
|
+
if (cond) { passes++; }
|
|
29
|
+
else { failures++; console.error(` ✗ ${msg}`); }
|
|
30
|
+
}
|
|
31
|
+
async function throwsWith(fn, re, msg) {
|
|
32
|
+
try { await fn(); ok(false, `${msg} (expected throw)`); }
|
|
33
|
+
catch (e) { ok(re.test(String(e.message || e)), `${msg} (got: ${e.message})`); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---- page world: simulates the page's MAIN world for Runtime.evaluate ----
|
|
37
|
+
const pageGlobals = {
|
|
38
|
+
console, JSON, Date, Math, Promise, Object, Array, String, Number, Boolean,
|
|
39
|
+
Error, TypeError, SyntaxError, RangeError, BigInt, Symbol, structuredClone,
|
|
40
|
+
setTimeout, parseInt, parseFloat, isNaN,
|
|
41
|
+
document: {
|
|
42
|
+
title: "page title",
|
|
43
|
+
_present: new Set(),
|
|
44
|
+
querySelector(sel) { return this._present.has(sel) ? { sel } : null; },
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
pageGlobals.window = pageGlobals;
|
|
48
|
+
pageGlobals.globalThis = pageGlobals;
|
|
49
|
+
const pageWorld = vm.createContext(pageGlobals);
|
|
50
|
+
|
|
51
|
+
// Simulate CDP Runtime.evaluate returnByValue serialization.
|
|
52
|
+
function toCdpResult(v) {
|
|
53
|
+
if (v === undefined) return { result: { type: "undefined" } };
|
|
54
|
+
if (v === null) return { result: { type: "object", subtype: "null", value: null } };
|
|
55
|
+
const t = typeof v;
|
|
56
|
+
if (t === "number" || t === "string" || t === "boolean")
|
|
57
|
+
return { result: { type: t, value: v } };
|
|
58
|
+
// object/array: returnByValue deep-clones JSON-able structures
|
|
59
|
+
return { result: { type: "object", value: JSON.parse(JSON.stringify(v)) } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- worker sandbox ----
|
|
63
|
+
const noop = () => {};
|
|
64
|
+
const listener = { addListener: noop, removeListener: noop };
|
|
65
|
+
const sandbox = {
|
|
66
|
+
console, JSON, Date, Math, Promise, Array, Object, String, Number, Boolean,
|
|
67
|
+
Error, TypeError, Map, Set, BigInt, Symbol, structuredClone,
|
|
68
|
+
setTimeout, clearTimeout,
|
|
69
|
+
setInterval: () => 0,
|
|
70
|
+
clearInterval: noop,
|
|
71
|
+
fetch: async () => { throw new Error("no network in unit test"); },
|
|
72
|
+
navigator: { userAgent: "unit-test" },
|
|
73
|
+
WebSocket: function () {},
|
|
74
|
+
chrome: {
|
|
75
|
+
runtime: { id: "unittestextension", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener, lastError: null },
|
|
76
|
+
alarms: { onAlarm: listener, create: noop, clear: noop, clearAll: noop },
|
|
77
|
+
action: { onClicked: listener },
|
|
78
|
+
debugger: { sendCommand: noop, attach: async () => {}, detach: async () => {}, getTargets: (cb) => cb([]) },
|
|
79
|
+
scripting: { executeScript: async () => [{ result: undefined }] },
|
|
80
|
+
tabs: { query: async () => [], get: async () => ({}), create: async () => ({}), update: async () => ({}), remove: async () => {} },
|
|
81
|
+
windows: { update: async () => {} },
|
|
82
|
+
webNavigation: { onCommitted: listener },
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
sandbox.globalThis = sandbox;
|
|
86
|
+
sandbox.self = sandbox;
|
|
87
|
+
vm.createContext(sandbox);
|
|
88
|
+
vm.runInContext(src, sandbox);
|
|
89
|
+
|
|
90
|
+
// ---- override the page-touching primitives with the page-world shim ----
|
|
91
|
+
sandbox.attachDebugger = async () => ({});
|
|
92
|
+
sandbox.bringToFront = async () => {};
|
|
93
|
+
sandbox.getTabByParams = async (p) => ({ id: (p && p.targetId) || 1, windowId: 1 });
|
|
94
|
+
sandbox.cdp = async (_tabId, method, params) => {
|
|
95
|
+
if (method !== "Runtime.evaluate") return {};
|
|
96
|
+
try {
|
|
97
|
+
const value = await vm.runInContext(params.expression, pageWorld);
|
|
98
|
+
return toCdpResult(value);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
return { exceptionDetails: { exception: { className: e.name, description: String(e.stack || e.message) }, text: "Uncaught " + String(e) } };
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
// Phase-2 of executeInTab: run the injected wrapper func against the page world,
|
|
104
|
+
// where Phase-1 (via cdp shim above) already defined window.__piAction + helpers.
|
|
105
|
+
sandbox.chrome.scripting.executeScript = async ({ func, args }) => {
|
|
106
|
+
const fn = vm.runInContext("(" + func.toString() + ")", pageWorld);
|
|
107
|
+
const result = await fn(...(args || []));
|
|
108
|
+
return [{ result }];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const { evaluateInTab, executeInTab, dispatch } = sandbox;
|
|
112
|
+
|
|
113
|
+
async function run() {
|
|
114
|
+
// ===== evaluateInTab: primitives & objects =====
|
|
115
|
+
ok((await evaluateInTab({ expression: "2 + 2" })) === 4, "evaluate: arithmetic expression");
|
|
116
|
+
ok((await evaluateInTab({ expression: "document.title" })) === "page title", "evaluate: expression without return");
|
|
117
|
+
ok((await evaluateInTab({ expression: "'a' + 'b'" })) === "ab", "evaluate: string concat");
|
|
118
|
+
const obj = await evaluateInTab({ expression: "({a:1, b:[2,3]})" });
|
|
119
|
+
ok(obj && obj.a === 1 && obj.b[1] === 3, "evaluate: object literal round-trips");
|
|
120
|
+
|
|
121
|
+
// ===== value markers =====
|
|
122
|
+
ok((await evaluateInTab({ expression: "void 0" })) === undefined, "evaluate: undefined marker -> undefined");
|
|
123
|
+
ok((await evaluateInTab({ expression: "10n" })) === "10", "evaluate: bigint marker -> string");
|
|
124
|
+
ok(/^\[Function:/.test(await evaluateInTab({ expression: "(function foo(){})" })), "evaluate: function marker");
|
|
125
|
+
ok((await evaluateInTab({ expression: "Promise.resolve(42)" })) === 42, "evaluate: promise is awaited");
|
|
126
|
+
|
|
127
|
+
// DOMRect-like (toJSON + width/height/top) is expanded, not flattened to {}
|
|
128
|
+
const rect = await evaluateInTab({ expression: "({ x:1,y:2,width:3,height:4,top:2,right:4,bottom:6,left:1, toJSON(){return {}} })" });
|
|
129
|
+
ok(rect && rect.width === 3 && rect.bottom === 6, "evaluate: DOMRect-like expanded");
|
|
130
|
+
|
|
131
|
+
// ===== statement-form fallback (expression form is a SyntaxError) =====
|
|
132
|
+
// `let x=...; x` is not a valid expression, so the wrapper must retry as a statement body.
|
|
133
|
+
ok((await evaluateInTab({ expression: "let x = 5; x" })) === undefined, "evaluate: statement form falls back (no return -> undefined)");
|
|
134
|
+
ok((await evaluateInTab({ expression: "let y = 7; return y" })) === 7, "evaluate: statement form with explicit return");
|
|
135
|
+
|
|
136
|
+
// ===== error propagation =====
|
|
137
|
+
await throwsWith(() => evaluateInTab({ expression: "throw new Error('boom')" }), /chrome_evaluate failed[\s\S]*boom/, "evaluate: runtime error propagates");
|
|
138
|
+
|
|
139
|
+
// ===== executeInTab: 2-phase define + invoke =====
|
|
140
|
+
// Real HELPER_FUNCS get serialized + assigned in Phase 1; a parse error there would throw here.
|
|
141
|
+
const sum = await executeInTab({ targetId: 1 }, function add(a, b) { return a + b; }, [3, 4]);
|
|
142
|
+
ok(sum === 7, "executeInTab: action runs with args after helper injection");
|
|
143
|
+
|
|
144
|
+
const asyncResult = await executeInTab({ targetId: 1 }, async function asyncEcho(v) { return v * 2; }, [21]);
|
|
145
|
+
ok(asyncResult === 42, "executeInTab: async action awaited");
|
|
146
|
+
|
|
147
|
+
await throwsWith(
|
|
148
|
+
() => executeInTab({ targetId: 1 }, function boom() { throw new Error("action failed"); }, []),
|
|
149
|
+
/action failed/,
|
|
150
|
+
"executeInTab: thrown action error propagates via envelope",
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// ===== page.waitFor (service-worker-side polling) =====
|
|
154
|
+
pageGlobals.document._present.add("#ready");
|
|
155
|
+
const wf = await dispatch("page.waitFor", { targetId: 1, kind: "selector", value: "#ready", timeoutMs: 1000, intervalMs: 20 });
|
|
156
|
+
ok(wf && typeof wf.elapsedMs === "number", "waitFor: selector present resolves");
|
|
157
|
+
|
|
158
|
+
const wfExpr = await dispatch("page.waitFor", { targetId: 1, kind: "expression", value: "1 === 1", timeoutMs: 1000, intervalMs: 20 });
|
|
159
|
+
ok(wfExpr && typeof wfExpr.elapsedMs === "number", "waitFor: truthy expression resolves");
|
|
160
|
+
|
|
161
|
+
await throwsWith(
|
|
162
|
+
() => dispatch("page.waitFor", { targetId: 1, kind: "selector", value: "#never", timeoutMs: 120, intervalMs: 30 }),
|
|
163
|
+
/Timed out after 120ms/,
|
|
164
|
+
"waitFor: missing selector times out",
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
console.log(`\n${passes} passed, ${failures} failed`);
|
|
168
|
+
if (failures) process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
run().catch((e) => { console.error(e); process.exit(1); });
|