diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs
index 4c8fcbaaa0..93e6bd61d1 100644
--- a/packages/desktop/e2e/browser-tabs.e2e.mjs
+++ b/packages/desktop/e2e/browser-tabs.e2e.mjs
@@ -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("
CSP pageCSP page heading
");
+ return;
+ }
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(`
@@ -351,6 +361,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`),
@@ -736,6 +782,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");
@@ -1168,6 +1216,7 @@ async function runRegression({
snapshot: "passed",
click: "passed",
localPageSelectors: "passed",
+ evaluateWithoutUnsafeEval: "passed",
};
}
diff --git a/packages/desktop/src/features/browser-automation/service.test.ts b/packages/desktop/src/features/browser-automation/service.test.ts
index dfaa5331d7..d793e138b7 100644
--- a/packages/desktop/src/features/browser-automation/service.test.ts
+++ b/packages/desktop/src/features/browser-automation/service.test.ts
@@ -455,6 +455,34 @@ function browserWithTimingEntries(entries: { resource: unknown[]; navigation: un
return browser;
}
+/**
+ * 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;
+}) {
+ 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>) {
expect(result).toEqual({
requestId: "req-snapshot",
@@ -1645,6 +1673,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();
diff --git a/packages/desktop/src/features/browser-automation/service.ts b/packages/desktop/src/features/browser-automation/service.ts
index fd5b12f83b..34a01ed074 100644
--- a/packages/desktop/src/features/browser-automation/service.ts
+++ b/packages/desktop/src/features/browser-automation/service.ts
@@ -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(