pi-chrome 0.15.28 → 0.15.30
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 +18 -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 +2 -1
- package/extensions/chrome-profile-bridge/browser-extension/service_worker.js +209 -115
- package/extensions/chrome-profile-bridge/index.ts +12 -7
- 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,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable user-facing changes to `pi-chrome`.
|
|
4
4
|
|
|
5
|
+
## 0.15.30 — 2026-05-31
|
|
6
|
+
|
|
7
|
+
Tab grouping for `chrome_tab`.
|
|
8
|
+
|
|
9
|
+
- **Pi-opened tabs auto-group.** `action=new` now drops every tab into a shared `Pi` tab group per window by default (created once, then reused), so agent tabs stay visually separated from your own. Opt out per call with `groupTitle:""` or `group:false`.
|
|
10
|
+
- **`chrome_tab` can group/ungroup tabs.** New `action=group` (and `action=ungroup`) plus `groupTitle`/`groupColor` params. Grouping reuses an existing same-title group in the window instead of spawning duplicates. Defaults: title `Pi`, color `blue`; color validated against Chrome's 9 group colors. Target an existing tab with `targetId`/`urlIncludes`/`titleIncludes`.
|
|
11
|
+
- **Tab listings include group info.** `formatTab` now reports `groupId` and a `group` record (`title`, `color`, `collapsed`, `windowId`, `piGroup`), and `chrome_tab list` prefixes grouped tabs with `[Group Title]`.
|
|
12
|
+
- Requires the new `tabGroups` extension permission — reload the companion extension after updating.
|
|
13
|
+
|
|
14
|
+
## 0.15.29 — 2026-05-31
|
|
15
|
+
|
|
16
|
+
Strict-CSP support: `chrome_evaluate`, `chrome_snapshot`, `chrome_wait_for`, and `chrome_navigate initScript` now work on pages that block `unsafe-eval`.
|
|
17
|
+
|
|
18
|
+
- **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.
|
|
19
|
+
- **`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.
|
|
20
|
+
- **`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.
|
|
21
|
+
- **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.
|
|
22
|
+
|
|
5
23
|
## 0.15.28 — 2026-05-31
|
|
6
24
|
|
|
7
25
|
Low-risk reliability fixes from a long-session bug report.
|
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
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Pi Chrome Connector",
|
|
4
|
-
"version": "0.15.
|
|
4
|
+
"version": "0.15.30",
|
|
5
5
|
"description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"tabs",
|
|
8
|
+
"tabGroups",
|
|
8
9
|
"scripting",
|
|
9
10
|
"storage",
|
|
10
11
|
"activeTab",
|
|
@@ -1,6 +1,9 @@
|
|
|
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
|
+
const DEFAULT_GROUP_COLOR = "blue";
|
|
5
|
+
const PI_GROUP_RE = /^Pi(\b|\s*-)/i;
|
|
6
|
+
const VALID_GROUP_COLORS = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]);
|
|
4
7
|
let polling = false;
|
|
5
8
|
|
|
6
9
|
// =================== Chrome input (CDP) layer ===================
|
|
@@ -217,6 +220,38 @@ async function cdp(tabId, method, params) {
|
|
|
217
220
|
}
|
|
218
221
|
}
|
|
219
222
|
|
|
223
|
+
// cdpEval: evaluate a JavaScript expression string in the page's MAIN world via CDP
|
|
224
|
+
// Runtime.evaluate. Runtime.evaluate is a DevTools protocol command and is NOT subject to
|
|
225
|
+
// the page's Content-Security-Policy, so it works on pages that ship `script-src 'self'`
|
|
226
|
+
// without `'unsafe-eval'` (which blocks `eval`/`new Function`). Ensures the debugger is
|
|
227
|
+
// attached first. Returns the raw CDP result ({ result, exceptionDetails }).
|
|
228
|
+
async function cdpEval(tabId, expression, opts) {
|
|
229
|
+
await attachDebugger(tabId);
|
|
230
|
+
return cdp(tabId, "Runtime.evaluate", {
|
|
231
|
+
expression,
|
|
232
|
+
returnByValue: true,
|
|
233
|
+
awaitPromise: true,
|
|
234
|
+
userGesture: true,
|
|
235
|
+
...(opts || {}),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function cdpExceptionText(details) {
|
|
240
|
+
if (!details) return "";
|
|
241
|
+
return String(
|
|
242
|
+
details.exception?.description ||
|
|
243
|
+
details.exception?.value ||
|
|
244
|
+
details.text ||
|
|
245
|
+
"",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function cdpIsSyntaxError(details) {
|
|
250
|
+
if (!details) return false;
|
|
251
|
+
const className = String(details.exception?.className || "");
|
|
252
|
+
return className === "SyntaxError" || /SyntaxError/.test(cdpExceptionText(details));
|
|
253
|
+
}
|
|
254
|
+
|
|
220
255
|
// Resolve target -> {x, y, rect} in viewport coords by running tiny script in tab.
|
|
221
256
|
async function resolveTargetInTab(tabId, params) {
|
|
222
257
|
const results = await chrome.scripting.executeScript({
|
|
@@ -677,6 +712,58 @@ function isVersionOlder(a, b) {
|
|
|
677
712
|
return false;
|
|
678
713
|
}
|
|
679
714
|
|
|
715
|
+
function cleanGroupTitle(value) {
|
|
716
|
+
const text = String(value || "Pi").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
717
|
+
return text || "Pi";
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function cleanGroupColor(value) {
|
|
721
|
+
const color = String(value || DEFAULT_GROUP_COLOR).toLowerCase();
|
|
722
|
+
return VALID_GROUP_COLORS.has(color) ? color : DEFAULT_GROUP_COLOR;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async function groupRecord(groupId) {
|
|
726
|
+
if (typeof groupId !== "number" || groupId < 0 || !chrome.tabGroups) return null;
|
|
727
|
+
const group = await chrome.tabGroups.get(groupId).catch(() => null);
|
|
728
|
+
if (!group) return null;
|
|
729
|
+
return {
|
|
730
|
+
id: group.id,
|
|
731
|
+
title: group.title || "",
|
|
732
|
+
color: group.color || "",
|
|
733
|
+
collapsed: Boolean(group.collapsed),
|
|
734
|
+
windowId: group.windowId,
|
|
735
|
+
piGroup: Boolean(group.title && PI_GROUP_RE.test(group.title)),
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Find an existing tab group in `windowId` whose title matches `title` (case-insensitive).
|
|
740
|
+
// Used so all Pi-opened tabs collect into one group per window instead of spawning new ones.
|
|
741
|
+
async function findGroupByTitle(windowId, title) {
|
|
742
|
+
if (!chrome.tabGroups) return null;
|
|
743
|
+
const wanted = cleanGroupTitle(title).toLowerCase();
|
|
744
|
+
const groups = await chrome.tabGroups.query({ windowId }).catch(() => []);
|
|
745
|
+
const match = groups.find((g) => (g.title || "").trim().toLowerCase() === wanted);
|
|
746
|
+
return match ? match.id : null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Add `tab` to a tab group, then set title/color. If the tab is ungrouped, reuse an
|
|
750
|
+
// existing same-title group in its window when present, otherwise create a new group.
|
|
751
|
+
async function groupTab(tab, title, color) {
|
|
752
|
+
if (!chrome.tabGroups) throw new Error("chrome.tabGroups API unavailable; reload the extension after granting the tabGroups permission");
|
|
753
|
+
if (!tab || typeof tab.id !== "number") throw new Error("No tab to group");
|
|
754
|
+
const groupTitle = cleanGroupTitle(title);
|
|
755
|
+
let groupId = tab.groupId;
|
|
756
|
+
if (typeof groupId !== "number" || groupId < 0) {
|
|
757
|
+
const existing = await findGroupByTitle(tab.windowId, groupTitle);
|
|
758
|
+
groupId = existing !== null
|
|
759
|
+
? await chrome.tabs.group({ groupId: existing, tabIds: [tab.id] })
|
|
760
|
+
: await chrome.tabs.group({ tabIds: [tab.id] });
|
|
761
|
+
}
|
|
762
|
+
await chrome.tabGroups.update(groupId, { title: groupTitle, color: cleanGroupColor(color), collapsed: false });
|
|
763
|
+
const grouped = await chrome.tabs.get(tab.id);
|
|
764
|
+
return { tab: await formatTab(grouped), group: await groupRecord(groupId) };
|
|
765
|
+
}
|
|
766
|
+
|
|
680
767
|
async function dispatch(action, params) {
|
|
681
768
|
switch (action) {
|
|
682
769
|
case "tab.version":
|
|
@@ -686,17 +773,31 @@ async function dispatch(action, params) {
|
|
|
686
773
|
bridgeUrl: BRIDGE_URL,
|
|
687
774
|
userAgent: navigator.userAgent,
|
|
688
775
|
};
|
|
689
|
-
case "tab.list":
|
|
690
|
-
|
|
776
|
+
case "tab.list": {
|
|
777
|
+
const tabs = await chrome.tabs.query({});
|
|
778
|
+
return Promise.all(tabs.map(formatTab));
|
|
779
|
+
}
|
|
691
780
|
case "tab.new": {
|
|
692
781
|
const tab = await chrome.tabs.create({ url: params.url || "about:blank", active: true });
|
|
693
|
-
|
|
782
|
+
// Every Pi-opened tab joins a group by default. Pass groupTitle:"" (or group:false) to opt out.
|
|
783
|
+
const optOut = params.groupTitle === "" || params.group === false;
|
|
784
|
+
if (optOut && !params.groupColor) return formatTab(tab);
|
|
785
|
+
return groupTab(tab, params.groupTitle || "Pi", params.groupColor);
|
|
694
786
|
}
|
|
695
787
|
case "tab.activate": {
|
|
696
788
|
const tab = await getTabByParams(params);
|
|
697
789
|
await chrome.windows.update(tab.windowId, { focused: true });
|
|
698
790
|
return formatTab(await chrome.tabs.update(tab.id, { active: true }));
|
|
699
791
|
}
|
|
792
|
+
case "tab.group": {
|
|
793
|
+
const tab = await getTabByParams(params);
|
|
794
|
+
return groupTab(tab, params.groupTitle || "Pi", params.groupColor);
|
|
795
|
+
}
|
|
796
|
+
case "tab.ungroup": {
|
|
797
|
+
const tab = await getTabByParams(params);
|
|
798
|
+
if (typeof tab.groupId === "number" && tab.groupId >= 0) await chrome.tabs.ungroup(tab.id);
|
|
799
|
+
return formatTab(await chrome.tabs.get(tab.id));
|
|
800
|
+
}
|
|
700
801
|
case "tab.close": {
|
|
701
802
|
const tab = await getTabByParams(params);
|
|
702
803
|
await chrome.tabs.remove(tab.id);
|
|
@@ -739,8 +840,29 @@ async function dispatch(action, params) {
|
|
|
739
840
|
return executeInTab(params, listNetworkRequests, [params.includePreservedRequests === true, params.clear === true]);
|
|
740
841
|
case "page.network.get":
|
|
741
842
|
return executeInTab(params, getNetworkRequest, [params.requestId]);
|
|
742
|
-
case "page.waitFor":
|
|
743
|
-
|
|
843
|
+
case "page.waitFor": {
|
|
844
|
+
// Poll from the service worker via CDP (bypasses CSP). The old approach ran the polling
|
|
845
|
+
// loop in-page with new Function() for expression checks, which fails under strict CSP.
|
|
846
|
+
const tab = await getTabByParams(params);
|
|
847
|
+
if (params.foreground) await bringToFront(tab);
|
|
848
|
+
const timeoutMs = params.timeoutMs || 10000;
|
|
849
|
+
const intervalMs = params.intervalMs || 250;
|
|
850
|
+
const started = Date.now();
|
|
851
|
+
while (Date.now() - started < timeoutMs) {
|
|
852
|
+
let ok = false;
|
|
853
|
+
try {
|
|
854
|
+
const expr = params.kind === "selector"
|
|
855
|
+
? `!!document.querySelector(${JSON.stringify(params.value)})`
|
|
856
|
+
: params.value;
|
|
857
|
+
ok = Boolean(await evaluateInTab({ ...params, expression: expr, foreground: false }));
|
|
858
|
+
} catch {
|
|
859
|
+
ok = false;
|
|
860
|
+
}
|
|
861
|
+
if (ok) return { elapsedMs: Date.now() - started };
|
|
862
|
+
await sleep(intervalMs);
|
|
863
|
+
}
|
|
864
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${params.kind}: ${params.value}`);
|
|
865
|
+
}
|
|
744
866
|
case "page.probe":
|
|
745
867
|
// Lightweight capability probe for /chrome-doctor. Runs in MAIN world.
|
|
746
868
|
return executeInTab(params, probePage, []);
|
|
@@ -758,7 +880,7 @@ async function dispatch(action, params) {
|
|
|
758
880
|
} finally {
|
|
759
881
|
if (params.initScript) await unregisterInitScript(tab.id).catch(() => undefined);
|
|
760
882
|
}
|
|
761
|
-
return formatTab(await chrome.tabs.get(updated.id));
|
|
883
|
+
return await formatTab(await chrome.tabs.get(updated.id));
|
|
762
884
|
}
|
|
763
885
|
case "page.screenshot":
|
|
764
886
|
return takeScreenshot(params);
|
|
@@ -767,7 +889,7 @@ async function dispatch(action, params) {
|
|
|
767
889
|
}
|
|
768
890
|
}
|
|
769
891
|
|
|
770
|
-
function formatTab(tab) {
|
|
892
|
+
async function formatTab(tab) {
|
|
771
893
|
return {
|
|
772
894
|
id: tab.id,
|
|
773
895
|
windowId: tab.windowId,
|
|
@@ -778,6 +900,8 @@ function formatTab(tab) {
|
|
|
778
900
|
status: tab.status,
|
|
779
901
|
pinned: tab.pinned,
|
|
780
902
|
incognito: tab.incognito,
|
|
903
|
+
groupId: typeof tab.groupId === "number" ? tab.groupId : -1,
|
|
904
|
+
group: await groupRecord(tab.groupId),
|
|
781
905
|
};
|
|
782
906
|
}
|
|
783
907
|
|
|
@@ -847,25 +971,33 @@ const HELPER_FUNCS = [
|
|
|
847
971
|
async function executeInTab(params, func, args) {
|
|
848
972
|
const tab = await getTabByParams(params);
|
|
849
973
|
if (params.foreground) await bringToFront(tab);
|
|
850
|
-
|
|
974
|
+
|
|
975
|
+
// Phase 1: define the helpers and the action function as page globals via CDP
|
|
976
|
+
// Runtime.evaluate. This bypasses page CSP (no `eval`/`new Function`), which is the
|
|
977
|
+
// root cause of snapshot/click/etc silently failing on `script-src 'self'` sites.
|
|
978
|
+
// Each helper is a named function declaration, assigned to window.<name> so the action
|
|
979
|
+
// (which references helpers by bare name) resolves them as globals at call time.
|
|
980
|
+
const assignments = HELPER_FUNCS.map((helper) => `window.${helper.name}=${helper.toString()}`).join(";\n");
|
|
981
|
+
const actionAssign = `window.__piAction=(${func.toString()})`;
|
|
982
|
+
const defineRes = await cdpEval(tab.id, `(()=>{${assignments};\n${actionAssign};})()`);
|
|
983
|
+
if (defineRes.exceptionDetails) {
|
|
984
|
+
throw new Error(`Failed to inject Chrome page helpers: ${cdpExceptionText(defineRes.exceptionDetails) || "unknown error"}`);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Phase 2: run the action via chrome.scripting.executeScript. The `func:` form is
|
|
988
|
+
// injected by Chrome itself (not `new Function`), so it is CSP-safe, and it lets Chrome
|
|
989
|
+
// serialize the invocation args. The wrapper references window.__piAction defined above.
|
|
851
990
|
const results = await chrome.scripting.executeScript({
|
|
852
991
|
target: { tabId: tab.id },
|
|
853
992
|
world: "MAIN",
|
|
854
|
-
func: async (
|
|
993
|
+
func: async (invocationArgs) => {
|
|
855
994
|
try {
|
|
856
|
-
|
|
857
|
-
// running through `eval` (which is restricted under strict CSP) and keeps them isolated.
|
|
858
|
-
new Function(helperSource).call(globalThis);
|
|
859
|
-
// The action itself is reconstructed from its source text. We use `new Function` rather
|
|
860
|
-
// than `eval` because the latter is blocked by `script-src 'self'` (no `'unsafe-eval'`)
|
|
861
|
-
// CSPs that are common on production sites.
|
|
862
|
-
const injected = new Function(helperSource + "\nreturn (" + source + ");").call(globalThis);
|
|
863
|
-
return { ok: true, value: await injected(...invocationArgs) };
|
|
995
|
+
return { ok: true, value: await window.__piAction(...invocationArgs) };
|
|
864
996
|
} catch (error) {
|
|
865
997
|
return { ok: false, error: error?.stack || error?.message || String(error) };
|
|
866
998
|
}
|
|
867
999
|
},
|
|
868
|
-
args: [
|
|
1000
|
+
args: [args || []],
|
|
869
1001
|
});
|
|
870
1002
|
const first = results?.[0];
|
|
871
1003
|
if (first?.error) {
|
|
@@ -879,72 +1011,54 @@ async function executeInTab(params, func, args) {
|
|
|
879
1011
|
return envelope?.value;
|
|
880
1012
|
}
|
|
881
1013
|
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
1014
|
+
// Serializer for page.evaluate results. Embedded (via .toString()) into the CDP-evaluated
|
|
1015
|
+
// expression so we can return rich markers for values that don't survive returnByValue
|
|
1016
|
+
// (undefined/function/symbol/bigint/Error), plus expand DOMRect-like objects whose fields
|
|
1017
|
+
// are non-enumerable. Kept as a standalone function so it stays editable/lintable.
|
|
1018
|
+
function piEvalStringify(v) {
|
|
1019
|
+
if (v === undefined) return { kind: "undefined" };
|
|
1020
|
+
if (typeof v === "function") return { kind: "function", source: v.toString().slice(0, 500) };
|
|
1021
|
+
if (typeof v === "symbol") return { kind: "symbol", description: v.description };
|
|
1022
|
+
if (typeof v === "bigint") return { kind: "bigint", value: v.toString() };
|
|
1023
|
+
if (v instanceof Error) return { kind: "error", name: v.name, message: v.message, stack: v.stack };
|
|
1024
|
+
// DOMRect/DOMRectReadOnly (and getBoundingClientRect results) have non-enumerable
|
|
1025
|
+
// properties, so JSON.stringify yields `{}`. Expand the fields explicitly.
|
|
1026
|
+
if ((typeof DOMRectReadOnly !== "undefined" && v instanceof DOMRectReadOnly) ||
|
|
1027
|
+
(typeof DOMRect !== "undefined" && v instanceof DOMRect) ||
|
|
1028
|
+
(v && typeof v === "object" && typeof v.toJSON === "function" &&
|
|
1029
|
+
typeof v.width === "number" && typeof v.height === "number" && typeof v.top === "number")) {
|
|
1030
|
+
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 };
|
|
1031
|
+
}
|
|
1032
|
+
return v;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Dedicated executor for page.evaluate. Uses CDP Runtime.evaluate (via cdpEval) which is not
|
|
1036
|
+
// subject to the page's CSP, fixing `chrome_evaluate` silently returning null / failing on
|
|
1037
|
+
// pages that ship `script-src 'self'` without `'unsafe-eval'` (which blocks `eval`/`new Function`).
|
|
885
1038
|
async function evaluateInTab(params) {
|
|
886
1039
|
const tab = await getTabByParams(params);
|
|
887
1040
|
if (params.foreground) await bringToFront(tab);
|
|
888
1041
|
const expression = String(params.expression ?? "");
|
|
889
|
-
const
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
};
|
|
910
|
-
// Compile via the Function constructor. We try expression form first so callers can pass
|
|
911
|
-
// `1+1` or `document.title` without a `return`; if that's a SyntaxError we retry with the
|
|
912
|
-
// statement form so callers can use multi-statement bodies (loops, var decls, etc).
|
|
913
|
-
const compile = (src) => {
|
|
914
|
-
try {
|
|
915
|
-
return { fn: new Function(`return (async () => (${src}))();`), mode: "expression" };
|
|
916
|
-
} catch (e1) {
|
|
917
|
-
if (e1 && e1.name === "SyntaxError") {
|
|
918
|
-
try {
|
|
919
|
-
return { fn: new Function(`return (async () => { ${src} })();`), mode: "statement" };
|
|
920
|
-
} catch (e2) {
|
|
921
|
-
throw e2;
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
throw e1;
|
|
925
|
-
}
|
|
926
|
-
};
|
|
927
|
-
try {
|
|
928
|
-
const { fn } = compile(expression);
|
|
929
|
-
const value = await fn.call(globalThis);
|
|
930
|
-
const resolved = awaitPromise && value && typeof value.then === "function" ? await value : value;
|
|
931
|
-
return { ok: true, value: stringify(resolved) };
|
|
932
|
-
} catch (error) {
|
|
933
|
-
return { ok: false, error: error?.stack || error?.message || String(error) };
|
|
934
|
-
}
|
|
935
|
-
},
|
|
936
|
-
args: [expression, awaitPromise],
|
|
937
|
-
});
|
|
938
|
-
const first = results?.[0];
|
|
939
|
-
if (first?.error) {
|
|
940
|
-
const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));
|
|
941
|
-
throw new Error(`chrome_evaluate failed: ${message}`);
|
|
942
|
-
}
|
|
943
|
-
const envelope = first?.result;
|
|
944
|
-
if (!envelope) throw new Error("chrome_evaluate returned no envelope from MAIN world");
|
|
945
|
-
if (envelope.ok === false) throw new Error(envelope.error || "chrome_evaluate failed");
|
|
946
|
-
const v = envelope.value;
|
|
947
|
-
// Unwrap special markers from MAIN world
|
|
1042
|
+
const stringifySrc = `(${piEvalStringify.toString()})`;
|
|
1043
|
+
// Wrap the user expression so the result is run through piEvalStringify in-page before it
|
|
1044
|
+
// crosses the returnByValue boundary. Try expression form first (so `1+1` / `document.title`
|
|
1045
|
+
// work without `return`); on a SyntaxError fall back to statement form for multi-statement
|
|
1046
|
+
// bodies (loops, var decls, etc), matching the previous new Function() two-form behavior.
|
|
1047
|
+
const buildWrapper = (form) => `(async () => { const __s=${stringifySrc}; const __v = await ${form}; return __s(__v); })()`;
|
|
1048
|
+
const exprForm = `(async () => (${expression}))()`;
|
|
1049
|
+
const stmtForm = `(async () => { ${expression} })()`;
|
|
1050
|
+
|
|
1051
|
+
let res = await cdpEval(tab.id, buildWrapper(exprForm));
|
|
1052
|
+
if (res.exceptionDetails && cdpIsSyntaxError(res.exceptionDetails)) {
|
|
1053
|
+
res = await cdpEval(tab.id, buildWrapper(stmtForm));
|
|
1054
|
+
}
|
|
1055
|
+
if (res.exceptionDetails) {
|
|
1056
|
+
throw new Error(`chrome_evaluate failed: ${cdpExceptionText(res.exceptionDetails) || "evaluation failed"}`);
|
|
1057
|
+
}
|
|
1058
|
+
const result = res.result;
|
|
1059
|
+
if (!result || result.type === "undefined") return undefined;
|
|
1060
|
+
const v = result.value;
|
|
1061
|
+
// Unwrap special markers produced by piEvalStringify.
|
|
948
1062
|
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
949
1063
|
if (v.kind === "undefined") return undefined;
|
|
950
1064
|
if (v.kind === "function") return `[Function: ${v.source}]`;
|
|
@@ -964,29 +1078,23 @@ async function withOptionalSnapshot(params, actionFn) {
|
|
|
964
1078
|
return result;
|
|
965
1079
|
}
|
|
966
1080
|
|
|
967
|
-
// One-shot init script registry, scoped per tab. The
|
|
968
|
-
//
|
|
969
|
-
|
|
1081
|
+
// One-shot init script registry, scoped per tab. The source is registered with CDP
|
|
1082
|
+
// Page.addScriptToEvaluateOnNewDocument, which runs it at document_start in the page's MAIN
|
|
1083
|
+
// world and is NOT subject to page CSP (the old func:(code)=>new Function(code) path was
|
|
1084
|
+
// blocked by `script-src 'self'`). page.navigate registers before the nav and unregisters
|
|
1085
|
+
// after load, so only the intended navigation receives the script.
|
|
1086
|
+
const initScriptIds = new Map(); // tabId -> CDP script identifier
|
|
970
1087
|
async function registerInitScript(tabId, source) {
|
|
971
|
-
|
|
1088
|
+
await attachDebugger(tabId);
|
|
1089
|
+
await cdp(tabId, "Page.enable", {}).catch(() => undefined);
|
|
1090
|
+
const result = await cdp(tabId, "Page.addScriptToEvaluateOnNewDocument", { source });
|
|
1091
|
+
if (result && result.identifier !== undefined) initScriptIds.set(tabId, result.identifier);
|
|
972
1092
|
}
|
|
973
1093
|
async function unregisterInitScript(tabId) {
|
|
1094
|
+
const identifier = initScriptIds.get(tabId);
|
|
1095
|
+
if (identifier === undefined) return;
|
|
974
1096
|
initScriptIds.delete(tabId);
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
if (chrome.webNavigation && chrome.webNavigation.onCommitted) {
|
|
978
|
-
chrome.webNavigation.onCommitted.addListener((details) => {
|
|
979
|
-
if (details.frameId !== 0) return;
|
|
980
|
-
const source = initScriptIds.get(details.tabId);
|
|
981
|
-
if (!source) return;
|
|
982
|
-
chrome.scripting.executeScript({
|
|
983
|
-
target: { tabId: details.tabId, frameIds: [0] },
|
|
984
|
-
world: "MAIN",
|
|
985
|
-
injectImmediately: true,
|
|
986
|
-
func: (code) => { try { new Function(code).call(globalThis); } catch (e) { console.error("[pi-chrome init script]", e); } },
|
|
987
|
-
args: [source],
|
|
988
|
-
}).catch(() => undefined);
|
|
989
|
-
});
|
|
1097
|
+
await cdp(tabId, "Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => undefined);
|
|
990
1098
|
}
|
|
991
1099
|
|
|
992
1100
|
// Always inject early console/network capture at document_start on every navigation.
|
|
@@ -1058,7 +1166,7 @@ async function takeScreenshot(params) {
|
|
|
1058
1166
|
await executeInTab({ ...params, foreground: false }, scrollToY, [tiles.originalScrollY]);
|
|
1059
1167
|
return {
|
|
1060
1168
|
fullPage: true,
|
|
1061
|
-
tab: formatTab(tab),
|
|
1169
|
+
tab: await formatTab(tab),
|
|
1062
1170
|
dimensions: { width: tiles.width, height: tiles.height, viewportHeight: tiles.viewportHeight, dpr: tiles.dpr },
|
|
1063
1171
|
tiles: captured,
|
|
1064
1172
|
};
|
|
@@ -1067,7 +1175,7 @@ async function takeScreenshot(params) {
|
|
|
1067
1175
|
format: params.format || "png",
|
|
1068
1176
|
quality: params.format === "jpeg" ? params.quality : undefined,
|
|
1069
1177
|
});
|
|
1070
|
-
return { dataUrl, tab: formatTab(tab) };
|
|
1178
|
+
return { dataUrl, tab: await formatTab(tab) };
|
|
1071
1179
|
} finally {
|
|
1072
1180
|
if (previousActiveId !== undefined && previousActiveId !== tab.id) {
|
|
1073
1181
|
await chrome.tabs.update(previousActiveId, { active: true }).catch(() => undefined);
|
|
@@ -2076,20 +2184,6 @@ function getNetworkRequest(requestId) {
|
|
|
2076
2184
|
return request;
|
|
2077
2185
|
}
|
|
2078
2186
|
|
|
2079
|
-
async function waitForPage(kind, value, timeoutMs, intervalMs) {
|
|
2080
|
-
const started = Date.now();
|
|
2081
|
-
while (Date.now() - started < timeoutMs) {
|
|
2082
|
-
let ok = false;
|
|
2083
|
-
if (kind === "selector") ok = Boolean(document.querySelector(value));
|
|
2084
|
-
else {
|
|
2085
|
-
try { ok = Boolean(new Function("return (" + value + ");").call(globalThis)); } catch { ok = false; }
|
|
2086
|
-
}
|
|
2087
|
-
if (ok) return { elapsedMs: Date.now() - started };
|
|
2088
|
-
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
2089
|
-
}
|
|
2090
|
-
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${kind}: ${value}`);
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2093
2187
|
function normalizeKey(key) {
|
|
2094
2188
|
const table = {
|
|
2095
2189
|
enter: "Enter",
|
|
@@ -500,7 +500,7 @@ class ChromeProfileBridge {
|
|
|
500
500
|
}
|
|
501
501
|
}
|
|
502
502
|
|
|
503
|
-
const tabActionValues = ["list", "new", "activate", "close", "version"] as const;
|
|
503
|
+
const tabActionValues = ["list", "new", "activate", "close", "group", "ungroup", "version"] as const;
|
|
504
504
|
const imageFormatValues = ["png", "jpeg"] as const;
|
|
505
505
|
const waitForValues = ["selector", "expression"] as const;
|
|
506
506
|
const CHROME_TOOL_NAMES = [
|
|
@@ -665,7 +665,7 @@ Chrome control is available through the chrome_* tools via a companion Chrome ex
|
|
|
665
665
|
Capability model (important):
|
|
666
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.
|
|
667
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.
|
|
668
|
-
- \`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.
|
|
669
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.
|
|
670
670
|
|
|
671
671
|
Usage rules:
|
|
@@ -1029,20 +1029,25 @@ Usage rules:
|
|
|
1029
1029
|
pi.registerTool({
|
|
1030
1030
|
name: "chrome_tab",
|
|
1031
1031
|
label: "Chrome Tab",
|
|
1032
|
-
description: "List, create, activate, close, or inspect tabs in the user's existing Chrome profile via the companion extension.",
|
|
1033
|
-
promptSnippet: "List/open/activate/close existing Chrome tabs through the companion extension.",
|
|
1032
|
+
description: "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension.",
|
|
1033
|
+
promptSnippet: "List/open/activate/close/group existing Chrome tabs through the companion extension.",
|
|
1034
1034
|
parameters: Type.Object({
|
|
1035
1035
|
action: StringEnum(tabActionValues),
|
|
1036
1036
|
url: Type.Optional(Type.String({ description: "URL for action=new." })),
|
|
1037
|
-
targetId: Type.Optional(Type.String({ description: "Chrome tab id for activate/close." })),
|
|
1037
|
+
targetId: Type.Optional(Type.String({ description: "Chrome tab id for activate/close/group/ungroup." })),
|
|
1038
|
+
urlIncludes: Type.Optional(Type.String({ description: "Match the target tab by URL substring for activate/close/group/ungroup." })),
|
|
1039
|
+
titleIncludes: Type.Optional(Type.String({ description: "Match the target tab by title substring for activate/close/group/ungroup." })),
|
|
1040
|
+
group: Type.Optional(Type.Boolean({ description: "action=new only: pass false to open an ungrouped tab. By default every Pi-opened tab joins the window's 'Pi' tab group." })),
|
|
1041
|
+
groupTitle: Type.Optional(Type.String({ description: "Tab group title for action=group (or action=new to open into a named group). Defaults to 'Pi'. Pass an empty string on action=new to opt out of grouping." })),
|
|
1042
|
+
groupColor: Type.Optional(Type.String({ description: "Tab group color for action=group/new: grey, blue, red, yellow, green, pink, purple, cyan, or orange. Defaults to blue." })),
|
|
1038
1043
|
host: Type.Optional(Type.String()),
|
|
1039
1044
|
port: Type.Optional(Type.Number()),
|
|
1040
1045
|
}),
|
|
1041
1046
|
async execute(_id, params, signal): Promise<ToolTextResult> {
|
|
1042
1047
|
const result = await authorizedBridgeSend(`tab.${params.action}`, params, DEFAULT_TIMEOUT_MS, signal);
|
|
1043
1048
|
if (params.action === "list") {
|
|
1044
|
-
const tabs = result as Array<{ id: number; title: string; url: string; active: boolean; windowId: number }>;
|
|
1045
|
-
const text = tabs.map((tab) => `${tab.id}\t${tab.active ? "*" : " "}\t${tab.title || "(untitled)"}\t${tab.url}`).join("\n") || "No tabs.";
|
|
1049
|
+
const tabs = result as Array<{ id: number; title: string; url: string; active: boolean; windowId: number; group?: { title?: string } | null }>;
|
|
1050
|
+
const text = tabs.map((tab) => `${tab.id}\t${tab.active ? "*" : " "}\t${tab.group?.title ? `[${tab.group.title}] ` : ""}${tab.title || "(untitled)"}\t${tab.url}`).join("\n") || "No tabs.";
|
|
1046
1051
|
return { content: [{ type: "text", text }], details: { tabs } };
|
|
1047
1052
|
}
|
|
1048
1053
|
return { content: [{ type: "text", text: safeJson(result) }], details: { result: result as Json } };
|
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); });
|