From 751dc82e6c4d113b357a80593089b935113aa65c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 06:03:12 +0000 Subject: [PATCH] Keep browser evaluation working on strict-policy pages without shadowing page globals (#8973) * fix(desktop): run browser_evaluate on pages whose CSP omits 'unsafe-eval' * test(desktop): browser_evaluate on pages that allow and forbid eval * test(desktop): e2e opens a page under a CSP without 'unsafe-eval' and evaluates on it * 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 --------- Co-authored-by: Abdulhakeem Mustapha Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/desktop/e2e/browser-tabs.e2e.mjs | 51 +++++++- .../browser-automation/service.test.ts | 121 ++++++++++++++++++ .../features/browser-automation/service.ts | 34 ++++- 3 files changed, 202 insertions(+), 4 deletions(-) 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 page

CSP 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(