From b0998f7d152d0e82de20dd28377b52b08d9618d9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 9 Jun 2026 04:42:02 +0900 Subject: [PATCH] fix(browser): accept statement evaluate bodies --- docs/cli/browser.md | 7 ++- docs/tools/browser-control.md | 7 ++- .../src/browser/evaluate-source.test.ts | 63 +++++++++++++++++++ .../browser/src/browser/evaluate-source.ts | 42 +++++++++++++ .../pw-tools-core.interactions.batch.test.ts | 6 +- ...core.interactions.navigation-guard.test.ts | 46 ++++++++++++++ .../src/browser/pw-tools-core.interactions.ts | 31 ++++++--- ....existing-session-navigation-guard.test.ts | 35 +++++++++++ .../browser/src/browser/routes/agent.act.ts | 6 +- .../register.form-wait-eval.ts | 7 ++- .../browser/src/cli/browser-cli-examples.ts | 1 + 11 files changed, 231 insertions(+), 20 deletions(-) create mode 100644 extensions/browser/src/browser/evaluate-source.test.ts create mode 100644 extensions/browser/src/browser/evaluate-source.ts diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 31cf787c65e6..5059287d585f 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -194,11 +194,14 @@ openclaw browser select OptionA OptionB openclaw browser fill --fields '[{"ref":"1","value":"Ada"}]' openclaw browser wait --text "Done" openclaw browser evaluate --fn '(el) => el.textContent' --ref +openclaw browser evaluate --fn 'const title = document.title; return title;' openclaw browser evaluate --timeout-ms 30000 --fn 'async () => { await window.ready; return true; }' ``` -Use `evaluate --timeout-ms ` when the page-side function may need longer -than the default evaluate timeout. +`evaluate --fn` accepts a function source, an expression, or a statement body. +Statement bodies are wrapped as async functions, so use `return` for the value +you want back. Use `evaluate --timeout-ms ` when the page-side function may +need longer than the default evaluate timeout. Action responses return the current raw `targetId` after action-triggered page replacement when OpenClaw can prove the replacement tab. Scripts should still diff --git a/docs/tools/browser-control.md b/docs/tools/browser-control.md index 86dc71285704..9ab421417c9d 100644 --- a/docs/tools/browser-control.md +++ b/docs/tools/browser-control.md @@ -203,6 +203,7 @@ openclaw browser dialog --dismiss --dialog-id d1 openclaw browser wait --text "Done" openclaw browser wait "#main" --url "**/dash" --load networkidle --fn "window.ready===true" openclaw browser evaluate --fn '(el) => el.textContent' --ref 7 +openclaw browser evaluate --fn 'const title = document.title; return title;' openclaw browser evaluate --timeout-ms 30000 --fn 'async () => { await window.ready; return true; }' openclaw browser highlight e12 openclaw browser trace start @@ -374,8 +375,10 @@ These are useful for "make the site behave like X" workflows: - `browser act kind=evaluate` / `openclaw browser evaluate` and `wait --fn` execute arbitrary JavaScript in the page context. Prompt injection can steer this. Disable it with `browser.evaluateEnabled=false` if you do not need it. -- Use `openclaw browser evaluate --timeout-ms ` when the page-side function - may need longer than the default evaluate timeout. +- `openclaw browser evaluate --fn` accepts a function source, an expression, or + a statement body. Statement bodies are wrapped as async functions, so use + `return` for the value you want back. Use `--timeout-ms ` when the + page-side function may need longer than the default evaluate timeout. - For logins and anti-bot notes (X/Twitter, etc.), see [Browser login + X/Twitter posting](/tools/browser-login). - Keep the Gateway/node host private (loopback or tailnet-only). - Remote CDP endpoints are powerful; tunnel and protect them. diff --git a/extensions/browser/src/browser/evaluate-source.test.ts b/extensions/browser/src/browser/evaluate-source.test.ts new file mode 100644 index 000000000000..d337fb37d54b --- /dev/null +++ b/extensions/browser/src/browser/evaluate-source.test.ts @@ -0,0 +1,63 @@ +// Browser tests cover evaluate source normalization. +import { describe, expect, it } from "vitest"; +import { normalizeBrowserEvaluateFunctionSource } from "./evaluate-source.js"; + +describe("normalizeBrowserEvaluateFunctionSource", () => { + it("preserves function sources", () => { + expect(normalizeBrowserEvaluateFunctionSource("() => document.title")).toBe( + "() => document.title", + ); + expect(normalizeBrowserEvaluateFunctionSource("async (el) => el.textContent")).toBe( + "async (el) => el.textContent", + ); + }); + + it("wraps expressions as page functions", () => { + expect(normalizeBrowserEvaluateFunctionSource("document.title")).toBe( + [ + "() => {", + "const __openclawEvaluateExpressionResult = (document.title);", + 'return typeof __openclawEvaluateExpressionResult === "function" ? __openclawEvaluateExpressionResult() : __openclawEvaluateExpressionResult;', + "}", + ].join("\n"), + ); + }); + + it("preserves function-valued expression invocation", () => { + expect(normalizeBrowserEvaluateFunctionSource("extractTitle")).toBe( + [ + "() => {", + "const __openclawEvaluateExpressionResult = (extractTitle);", + 'return typeof __openclawEvaluateExpressionResult === "function" ? __openclawEvaluateExpressionResult() : __openclawEvaluateExpressionResult;', + "}", + ].join("\n"), + ); + expect(normalizeBrowserEvaluateFunctionSource("extractText", { argumentName: "el" })).toBe( + [ + "(el) => {", + "const __openclawEvaluateExpressionResult = (extractText);", + 'return typeof __openclawEvaluateExpressionResult === "function" ? __openclawEvaluateExpressionResult(el) : __openclawEvaluateExpressionResult;', + "}", + ].join("\n"), + ); + }); + + it("wraps statement bodies as async page functions", () => { + expect(normalizeBrowserEvaluateFunctionSource("const x = 41; return x + 1;")).toBe( + "async () => {\nconst x = 41; return x + 1;\n}", + ); + expect( + normalizeBrowserEvaluateFunctionSource( + "function helper() { return 41; }\nreturn helper() + 1;", + ), + ).toBe("async () => {\nfunction helper() { return 41; }\nreturn helper() + 1;\n}"); + }); + + it("wraps statement bodies as async element functions when a ref is present", () => { + expect( + normalizeBrowserEvaluateFunctionSource("const text = el.textContent; return text;", { + argumentName: "el", + }), + ).toBe("async (el) => {\nconst text = el.textContent; return text;\n}"); + }); +}); diff --git a/extensions/browser/src/browser/evaluate-source.ts b/extensions/browser/src/browser/evaluate-source.ts new file mode 100644 index 000000000000..08b275b069c2 --- /dev/null +++ b/extensions/browser/src/browser/evaluate-source.ts @@ -0,0 +1,42 @@ +// Normalizes browser evaluate input while preserving the public `fn` string API. +import { Script } from "node:vm"; + +const FUNCTION_SOURCE_PATTERN = /^(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/; +const EXPRESSION_RESULT_NAME = "__openclawEvaluateExpressionResult"; + +function canParseAsExpression(source: string): boolean { + try { + // Parse only. Browser evaluate input is intentionally executable, but the + // Gateway should not run caller-provided page JavaScript while routing. + const parseExpression = new Script(`"use strict";\n(${source});`); + void parseExpression; + return true; + } catch { + return false; + } +} + +export function normalizeBrowserEvaluateFunctionSource( + source: string, + params: { argumentName?: string } = {}, +): string { + const trimmed = source.trim(); + if (!trimmed) { + return ""; + } + if (FUNCTION_SOURCE_PATTERN.test(trimmed) && canParseAsExpression(trimmed)) { + return trimmed; + } + const argumentName = params.argumentName; + const args = argumentName ? `(${argumentName})` : "()"; + if (canParseAsExpression(trimmed)) { + const invokeArgs = argumentName ? argumentName : ""; + return [ + `${args} => {`, + `const ${EXPRESSION_RESULT_NAME} = (${trimmed});`, + `return typeof ${EXPRESSION_RESULT_NAME} === "function" ? ${EXPRESSION_RESULT_NAME}(${invokeArgs}) : ${EXPRESSION_RESULT_NAME};`, + "}", + ].join("\n"); + } + return `async ${args} => {\n${trimmed}\n}`; +} diff --git a/extensions/browser/src/browser/pw-tools-core.interactions.batch.test.ts b/extensions/browser/src/browser/pw-tools-core.interactions.batch.test.ts index 6c5ed85ffe30..4aa55d02db63 100644 --- a/extensions/browser/src/browser/pw-tools-core.interactions.batch.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.interactions.batch.test.ts @@ -43,7 +43,7 @@ vi.mock("./pw-tools-core.snapshot.js", () => ({ const { batchViaPlaywright } = await import("./pw-tools-core.interactions.js"); -function firstEvaluateCall(): [unknown, { fnBody?: string; timeoutMs?: number }] { +function firstEvaluateCall(): [unknown, { fnSource?: string; timeoutMs?: number }] { if (!page) { throw new Error("expected test page"); } @@ -51,7 +51,7 @@ function firstEvaluateCall(): [unknown, { fnBody?: string; timeoutMs?: number }] if (!call) { throw new Error("expected page.evaluate call"); } - return call as [unknown, { fnBody?: string; timeoutMs?: number }]; + return call as [unknown, { fnSource?: string; timeoutMs?: number }]; } describe("batchViaPlaywright", () => { @@ -74,7 +74,7 @@ describe("batchViaPlaywright", () => { expect(result).toEqual({ results: [{ ok: true }] }); const [evaluateFn, evaluateOptions] = firstEvaluateCall(); expect(typeof evaluateFn).toBe("function"); - expect(evaluateOptions?.fnBody).toBe("() => 1"); + expect(evaluateOptions?.fnSource).toBe("() => 1"); expect(evaluateOptions?.timeoutMs).toBe(4500); }); diff --git a/extensions/browser/src/browser/pw-tools-core.interactions.navigation-guard.test.ts b/extensions/browser/src/browser/pw-tools-core.interactions.navigation-guard.test.ts index 495a7c632ece..936f971f1af3 100644 --- a/extensions/browser/src/browser/pw-tools-core.interactions.navigation-guard.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.interactions.navigation-guard.test.ts @@ -919,6 +919,52 @@ describe("pw-tools-core interaction navigation guard", () => { }); }); + it("runs statement-body page evaluate sources", async () => { + const page = { + evaluate: vi.fn(async (evaluateFn: (args: unknown) => unknown, args: unknown) => + evaluateFn(args), + ), + url: vi.fn(() => "http://127.0.0.1:9222/json/version"), + }; + setPwToolsCoreCurrentPage(page); + + const result = await mod.evaluateViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + fn: "const value = 41; return value + 1;", + }); + + expect(result).toBe(42); + expect(page.evaluate.mock.calls[0]?.[1]).toMatchObject({ + fnSource: "async () => {\nconst value = 41; return value + 1;\n}", + }); + }); + + it("runs statement-body ref evaluate sources", async () => { + const page = { + url: vi.fn(() => "http://127.0.0.1:9222/json/version"), + }; + const locator = { + evaluate: vi.fn(async (evaluateFn: (el: Element, args: unknown) => unknown, args: unknown) => + evaluateFn({ textContent: "Ada" } as Element, args), + ), + }; + setPwToolsCoreCurrentPage(page); + setPwToolsCoreCurrentRefLocator(locator); + + const result = await mod.evaluateViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + ref: "1", + fn: "const text = el.textContent; return text;", + }); + + expect(result).toBe("Ada"); + expect(locator.evaluate.mock.calls[0]?.[1]).toMatchObject({ + fnSource: "async (el) => {\nconst text = el.textContent; return text;\n}", + }); + }); + it("runs the post-keypress navigation guard when navigation starts shortly after the keypress resolves", async () => { vi.useFakeTimers(); try { diff --git a/extensions/browser/src/browser/pw-tools-core.interactions.ts b/extensions/browser/src/browser/pw-tools-core.interactions.ts index 0283867af934..7d94710aaee3 100644 --- a/extensions/browser/src/browser/pw-tools-core.interactions.ts +++ b/extensions/browser/src/browser/pw-tools-core.interactions.ts @@ -16,6 +16,7 @@ import { resolveActWaitTimeoutMs, } from "./act-policy.js"; import type { BrowserActRequest, BrowserFormField } from "./client-actions.types.js"; +import { normalizeBrowserEvaluateFunctionSource } from "./evaluate-source.js"; import { DEFAULT_FILL_FIELD_TYPE } from "./form-fields.js"; import { assertBrowserNavigationResultAllowed, @@ -998,6 +999,10 @@ export async function evaluateViaPlaywright(opts: { if (!fnText) { throw new Error("function is required"); } + const fnSource = normalizeBrowserEvaluateFunctionSource( + fnText, + opts.ref ? { argumentName: "el" } : undefined, + ); const page = await getRestoredPageForTarget(opts); // Clamp evaluate timeout to prevent permanently blocking Playwright's command queue. // Without this, a long-running async evaluate blocks all subsequent page operations @@ -1047,10 +1052,13 @@ export async function evaluateViaPlaywright(opts: { "args", ` "use strict"; - var fnBody = args.fnBody, timeoutMs = args.timeoutMs; + var fnSource = args.fnSource, timeoutMs = args.timeoutMs; try { - var candidate = eval("(" + fnBody + ")"); - var result = typeof candidate === "function" ? candidate(el) : candidate; + var candidate = eval("(" + fnSource + ")"); + if (typeof candidate !== "function") { + throw new Error("evaluate source did not produce a function"); + } + var result = candidate(el); if (result && typeof result.then === "function") { return Promise.race([ result, @@ -1064,9 +1072,9 @@ export async function evaluateViaPlaywright(opts: { throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err))); } `, - ) as (el: Element, args: { fnBody: string; timeoutMs: number }) => unknown; + ) as (el: Element, args: { fnSource: string; timeoutMs: number }) => unknown; const evalPromise = locator.evaluate(elementEvaluator, { - fnBody: fnText, + fnSource, timeoutMs: evaluateTimeout, }); const reconcileRemoteDialog = () => reconcileRemoteDialogAfterActionSettled(page, signal); @@ -1086,10 +1094,13 @@ export async function evaluateViaPlaywright(opts: { "args", ` "use strict"; - var fnBody = args.fnBody, timeoutMs = args.timeoutMs; + var fnSource = args.fnSource, timeoutMs = args.timeoutMs; try { - var candidate = eval("(" + fnBody + ")"); - var result = typeof candidate === "function" ? candidate() : candidate; + var candidate = eval("(" + fnSource + ")"); + if (typeof candidate !== "function") { + throw new Error("evaluate source did not produce a function"); + } + var result = candidate(); if (result && typeof result.then === "function") { return Promise.race([ result, @@ -1103,9 +1114,9 @@ export async function evaluateViaPlaywright(opts: { throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err))); } `, - ) as (args: { fnBody: string; timeoutMs: number }) => unknown; + ) as (args: { fnSource: string; timeoutMs: number }) => unknown; const evalPromise = page.evaluate(browserEvaluator, { - fnBody: fnText, + fnSource, timeoutMs: evaluateTimeout, }); const reconcileRemoteDialog = () => reconcileRemoteDialogAfterActionSettled(page, signal); diff --git a/extensions/browser/src/browser/routes/agent.act.existing-session-navigation-guard.test.ts b/extensions/browser/src/browser/routes/agent.act.existing-session-navigation-guard.test.ts index 147e5a61d10d..cb6593de3eaa 100644 --- a/extensions/browser/src/browser/routes/agent.act.existing-session-navigation-guard.test.ts +++ b/extensions/browser/src/browser/routes/agent.act.existing-session-navigation-guard.test.ts @@ -166,6 +166,41 @@ describe("existing-session interaction navigation guard", () => { ]); }); + it("normalizes statement-body evaluate sources before Chrome MCP execution", async () => { + chromeMcpMocks.evaluateChromeMcpScript.mockResolvedValueOnce(42 as never); + + const response = await runAction( + { kind: "evaluate", fn: "const value = 41; return value + 1;" }, + null, + ); + + expect(response.statusCode).toBe(200); + expect(chromeMcpMocks.evaluateChromeMcpScript).toHaveBeenCalledOnce(); + expect(chromeMcpMocks.evaluateChromeMcpScript).toHaveBeenCalledWith( + expect.objectContaining({ + fn: "async () => {\nconst value = 41; return value + 1;\n}", + }), + ); + }); + + it("normalizes ref-scoped statement-body evaluate sources before Chrome MCP execution", async () => { + chromeMcpMocks.evaluateChromeMcpScript.mockResolvedValueOnce("Ada" as never); + + const response = await runAction( + { kind: "evaluate", ref: "7", fn: "const text = el.textContent; return text;" }, + null, + ); + + expect(response.statusCode).toBe(200); + expect(chromeMcpMocks.evaluateChromeMcpScript).toHaveBeenCalledOnce(); + expect(chromeMcpMocks.evaluateChromeMcpScript).toHaveBeenCalledWith( + expect.objectContaining({ + args: ["7"], + fn: "async (el) => {\nconst text = el.textContent; return text;\n}", + }), + ); + }); + it("blocks evaluate before execution when the current tab URL is disallowed", async () => { routeState.tab.url = "http://169.254.169.254/latest/meta-data/"; navigationGuardMocks.assertBrowserNavigationResultAllowed.mockImplementation( diff --git a/extensions/browser/src/browser/routes/agent.act.ts b/extensions/browser/src/browser/routes/agent.act.ts index 91ce8c901f3a..3270c7e2a8eb 100644 --- a/extensions/browser/src/browser/routes/agent.act.ts +++ b/extensions/browser/src/browser/routes/agent.act.ts @@ -19,6 +19,7 @@ import { type ChromeMcpProfileOptions, } from "../chrome-mcp.js"; import type { BrowserActRequest } from "../client-actions.types.js"; +import { normalizeBrowserEvaluateFunctionSource } from "../evaluate-source.js"; import { assertBrowserNavigationResultAllowed, type BrowserNavigationPolicyOptions, @@ -633,7 +634,10 @@ export function registerBrowserAgentActRoutes( profileName, profile: profileCtx.profile, targetId: tab.targetId, - fn: action.fn, + fn: normalizeBrowserEvaluateFunctionSource( + action.fn, + action.ref ? { argumentName: "el" } : undefined, + ), args: action.ref ? [action.ref] : undefined, }), guard: existingSessionNavigationGuard, diff --git a/extensions/browser/src/cli/browser-cli-actions-input/register.form-wait-eval.ts b/extensions/browser/src/cli/browser-cli-actions-input/register.form-wait-eval.ts index dccfc283f6fb..96ca94843a3f 100644 --- a/extensions/browser/src/cli/browser-cli-actions-input/register.form-wait-eval.ts +++ b/extensions/browser/src/cli/browser-cli-actions-input/register.form-wait-eval.ts @@ -127,8 +127,11 @@ export function registerBrowserFormWaitEvalCommands( browser .command("evaluate") - .description("Evaluate a function against the page or a ref") - .option("--fn ", "Function source, e.g. (el) => el.textContent") + .description("Evaluate JavaScript against the page or a ref") + .option( + "--fn ", + "Function source, expression, or statement body, e.g. const text = el.textContent; return text;", + ) .option("--ref ", "Ref from snapshot") .option( "--timeout-ms ", diff --git a/extensions/browser/src/cli/browser-cli-examples.ts b/extensions/browser/src/cli/browser-cli-examples.ts index 213dc082cca3..d9302f9939b2 100644 --- a/extensions/browser/src/cli/browser-cli-examples.ts +++ b/extensions/browser/src/cli/browser-cli-examples.ts @@ -37,6 +37,7 @@ export const browserActionExamples = [ "openclaw browser dialog --accept", 'openclaw browser wait --text "Done"', "openclaw browser evaluate --fn '(el) => el.textContent' --ref 7", + "openclaw browser evaluate --fn 'const title = document.title; return title;'", "openclaw browser console --level error", "openclaw browser pdf", ];