Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andAbdulhakeem Mustapha 7a324ddedd fix(desktop): preserve page globals in browser evaluation
Define the supplied function outside every wrapper binding so strict-policy evaluation keeps the original page scope.

Co-authored-by: Abdulhakeem Mustapha <abdulhakeemmustapha@gmail.com>
2026-11-06 23:24:54 +00:00
Abdulhakeem Mustapha 5ca7a1afea test(desktop): e2e opens a page under a CSP without 'unsafe-eval' and evaluates on it 2026-11-06 19:23:00 +01:00
Abdulhakeem Mustapha 7d146fd6f8 test(desktop): browser_evaluate on pages that allow and forbid eval 2026-11-06 19:22:37 +01:00
Abdulhakeem Mustapha 03c531dfeb fix(desktop): run browser_evaluate on pages whose CSP omits 'unsafe-eval' 2026-11-06 19:22:14 +01:00
3 changed files with 203 additions and 4 deletions
+50 -1
View File
@@ -213,8 +213,18 @@ async function settleDesktopStartup(page) {
}
}
const CSP_PAGE_PATH = "/csp-no-eval";
async function startTargetPage() {
const server = createServer((_request, response) => {
const server = createServer((request, response) => {
if (request.url === CSP_PAGE_PATH) {
response.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"content-security-policy": "default-src 'self'; script-src 'self'",
});
response.end("<!doctype html><title>CSP page</title><h1>CSP page heading</h1>");
return;
}
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(`<!doctype html>
<html>
@@ -290,6 +300,42 @@ async function waitForGuestActiveElement(client, browserId, elementId) {
return false;
}
// A page whose Content Security Policy omits 'unsafe-eval' refuses eval(); the tools must still work.
async function verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl) {
const created = await callBrowserTool(client, "browser_new_tab", {
url: new URL(CSP_PAGE_PATH, targetUrl).href,
});
const browserId = created.browserId;
assert(typeof browserId === "string", "browser_new_tab returned no browserId for the CSP page");
try {
await callBrowserTool(client, "browser_wait", {
browserId,
text: "CSP page heading",
timeoutMs: 15_000,
});
const title = await callBrowserTool(client, "browser_evaluate", {
browserId,
function: "() => document.title",
});
assert(
JSON.parse(title.resultJson) === "CSP page",
`browser_evaluate on a page without 'unsafe-eval' returned ${title.resultJson}`,
);
// The control: the page itself really does refuse eval().
const probe = await callBrowserTool(client, "browser_evaluate", {
browserId,
function:
'() => { try { (0, eval)("1"); return "eval allowed"; } catch (error) { return error.message; } }',
});
assert(
/Content Security Policy/.test(JSON.parse(probe.resultJson)),
`The CSP page did not refuse eval(): ${probe.resultJson}`,
);
} finally {
await callBrowserTool(client, "browser_close_tab", { browserId });
}
}
async function createCallerAgent(daemonPort, workspaceId = workspaceIds[0]) {
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${daemonPort}/mcp/agents`),
@@ -675,6 +721,8 @@ async function runRegression({
document.body.appendChild(overlayRoot);
});
await verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl);
const created = await callBrowserTool(client, "browser_new_tab", { url: targetUrl });
const browserId = created.browserId;
assert(typeof browserId === "string", "browser_new_tab returned no browserId");
@@ -1106,6 +1154,7 @@ async function runRegression({
snapshot: "passed",
click: "passed",
localPageSelectors: "passed",
evaluateWithoutUnsafeEval: "passed",
};
}
@@ -1,4 +1,5 @@
import { resolve as resolvePath } from "node:path";
import { createContext, runInContext } from "node:vm";
import { describe, expect, test, vi } from "vitest";
import type {
@@ -434,6 +435,34 @@ function containsScript(tab: FakeTab, ...parts: string[]): boolean {
return tab.scripts.some((script) => parts.every((part) => script.includes(part)));
}
/**
* Runs the script the host injects into a page the way Chromium runs it on a page whose Content
* Security Policy omits 'unsafe-eval': injected code runs, but code in the page cannot turn a
* string into code (eval, new Function). Node's vm refuses string code generation the same way.
*/
function pageRunningInjectedScripts(input: {
allowEval: boolean;
globals?: Record<string, unknown>;
}) {
const page = createContext(
{ document: { title: "Fixture" }, ...input.globals },
{ codeGeneration: { strings: input.allowEval, wasm: input.allowEval } },
);
return (script: string): unknown => runInContext(script, page);
}
/** A browser whose tab runs the evaluate script in `page`; its other scripts keep the fake answers. */
function browserEvaluatingIn(page: (script: string) => unknown) {
const browser = new BrowserAutomationHarness();
const fakeAnswer = browser.tab.executeJavaScript.bind(browser.tab);
browser.tab.executeJavaScript = async (script: string) => {
if (!script.includes("__PASEO_BROWSER_EVALUATE__")) return fakeAnswer(script);
browser.tab.scripts.push(script);
return page(script);
};
return browser;
}
function requireSnapshotRefs(result: Awaited<ReturnType<BrowserAutomationHarness["snapshot"]>>) {
expect(result).toEqual({
requestId: "req-snapshot",
@@ -1558,6 +1587,99 @@ describe("executeAutomationCommand", () => {
expect(containsScript(browser.tab, "resultJson.slice(0, 79000)")).toBe(true);
});
describe.each([
{ page: "allows eval", allowEval: true },
{ page: "forbids eval (its Content Security Policy omits 'unsafe-eval')", allowEval: false },
])("evaluate on a page that $page", ({ allowEval }) => {
const evaluate = (browser: BrowserAutomationHarness, source: string) =>
browser.execute({
command: "evaluate",
args: { browserId: BROWSER_A, function: source },
});
test("runs the function and returns its result as JSON", async () => {
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
expect(await evaluate(browser, "() => document.title")).toEqual({
requestId: "req-evaluate",
ok: true,
result: {
command: "evaluate",
browserId: BROWSER_A,
resultJson: '"Fixture"',
truncated: false,
},
});
});
test("awaits an async function", async () => {
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
const result = await evaluate(
browser,
"async () => { await Promise.resolve(); return 6 * 7; }",
);
expect(result).toMatchObject({ ok: true, result: { resultJson: "42" } });
});
test("resolves names against the page's globals, never the wrapper's own", async () => {
const globals = {
element: "page element",
args: "page args",
value: "page value",
resultJson: "page json",
userFunction: "page function",
__paseoEvaluate: "page wrapper",
readFunction: "page reader",
};
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval, globals }));
const result = await evaluate(
browser,
"() => [element, args, value, resultJson, userFunction, __paseoEvaluate, readFunction]",
);
expect(result).toMatchObject({
ok: true,
result: { resultJson: JSON.stringify(Object.values(globals)) },
});
});
test("reports what the function throws", async () => {
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
expect(await evaluate(browser, "() => { throw new Error('boom'); }")).toMatchObject({
ok: false,
error: { code: "browser_unknown_error", message: "boom" },
});
});
test("reports an expression that is not a function", async () => {
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
expect(await evaluate(browser, "document.title")).toMatchObject({
ok: false,
error: {
code: "browser_unknown_error",
message: "browser_evaluate input must evaluate to a function.",
},
});
});
test("reports a syntax error with the compiler's own message, without touching the page", async () => {
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
const result = await evaluate(browser, "() => {");
expect(result).toMatchObject({
ok: false,
error: { code: "browser_unknown_error", message: expect.stringMatching(/^Unexpected /) },
});
expect(containsScript(browser.tab, "__PASEO_BROWSER_EVALUATE__")).toBe(false);
});
});
test("screenshot captures the painted viewport", async () => {
const browser = new BrowserAutomationHarness();
@@ -1,4 +1,5 @@
import { isAbsolute, relative, resolve as resolvePath } from "node:path";
import { Script } from "node:vm";
import type {
BrowserAutomationCommand,
@@ -849,6 +850,11 @@ async function executeEvaluate(
elementExpression = expression;
}
const syntaxError = functionSyntaxError(functionSource);
if (syntaxError !== null) {
return fail(requestId, "browser_unknown_error", capEvaluateErrorMessage(syntaxError));
}
let rawResult: unknown;
try {
rawResult = await target.contents.executeJavaScript(
@@ -1550,14 +1556,36 @@ function capEvaluateErrorMessage(message: string): string {
: message.slice(0, MAX_EVALUATE_ERROR_MESSAGE_LENGTH);
}
/**
* The syntax error in the agent's function, or null. It is compiled here because a script that
* fails to compile in the page only reports "Script failed to execute", and a typo deserves the
* compiler's own message. Compiling runs nothing.
*/
function functionSyntaxError(functionSource: string): string | null {
try {
void new Script(`(${functionSource})`);
return null;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
/**
* The page script that runs the agent's function and returns its result as bounded JSON.
*
* The function goes into the script as code, not as a string for the page to eval(): a page whose
* Content Security Policy omits 'unsafe-eval' refuses eval() but still runs what the host injects.
* The function-producing thunk is an argument defined outside the wrapper, so its names resolve
* against the page's globals without capturing any wrapper parameters or locals.
*/
function buildEvaluateScript(
functionSource: string,
elementExpression: string | undefined,
): string {
return String.raw`(async () => {
return String.raw`(async (readFunction) => {
const __PASEO_BROWSER_EVALUATE__ = true;
try {
const userFunction = (0, eval)(${JSON.stringify(`(${functionSource})`)});
const userFunction = readFunction();
if (typeof userFunction !== 'function') {
throw new Error('browser_evaluate input must evaluate to a function.');
}
@@ -1582,7 +1610,7 @@ function buildEvaluateScript(
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
}
})()`;
})(() => (${functionSource}))`;
}
async function focusKeypressTarget(