From b88eeddeed1581817b715febc38b66c142d3ee09 Mon Sep 17 00:00:00 2001 From: FMLS Date: Fri, 24 Jul 2026 11:33:45 +0800 Subject: [PATCH] feat(browser): add `openclaw browser batch` CLI subcommand (#111457) * feat(browser): add `openclaw browser batch` CLI subcommand Expose the existing `act:batch` runtime through a dedicated CLI subcommand so users and scripts can run nested act requests in one call without going through the agent tool. - `--actions ` for inline JSON, `--actions-file ` for file input, `--actions-file -` for stdin (1MB cap to bound a runaway pipe) - `--continue` sets `stopOnError=false`; default keeps the runtime fail-fast (stop on first error) behavior, matching the existing batch contract - `--target-id` forwards to the batch body - Outer request timeout uses `resolveBrowserActExecutionBudgetMs` so the batch budget covers nested actions - Docs (`docs/tools/browser-control.md` + bundled `browser-automation` skill) document the batch CLI, ref lifecycle, targetId conflict handling, and error summary format Co-authored-by: Cursor * fix(browser): fail batch CLI on action errors * fix(browser): keep batch stdin reader private * docs(browser): refresh batch heading map * test(browser): assert batch JSON via runtime mock --------- Co-authored-by: Cursor Co-authored-by: Mason Huang Co-authored-by: Mason Huang --- docs/cli/browser.md | 10 + docs/docs_map.md | 1 + docs/tools/browser-control.md | 35 +++ .../skills/browser-automation/SKILL.md | 9 + ...agent.act.normalize.batch-contract.test.ts | 58 +++++ .../register.batch.test.ts | 239 ++++++++++++++++++ .../register.batch.ts | 96 +++++++ .../cli/browser-cli-actions-input/register.ts | 4 +- .../cli/browser-cli-actions-input/shared.ts | 32 +++ .../browser/src/cli/browser-cli-examples.ts | 3 + extensions/browser/src/cli/browser-cli.ts | 1 + 11 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 extensions/browser/src/browser/routes/agent.act.normalize.batch-contract.test.ts create mode 100644 extensions/browser/src/cli/browser-cli-actions-input/register.batch.test.ts create mode 100644 extensions/browser/src/cli/browser-cli-actions-input/register.batch.ts diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 355855eb7f8a..0ed070a19a98 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -197,6 +197,16 @@ Managed Chrome profiles save ordinary click-triggered downloads into the OpenCla When an action opens a modal dialog, the action response returns `blockedByDialog` with `browserState.dialogs.pending`; pass `--dialog-id` to answer it directly. Dialogs handled outside OpenClaw appear under `browserState.dialogs.recent`. +Batch actions: + +```bash +openclaw browser batch --actions '[{"kind":"wait","timeMs":500},{"kind":"click","ref":"12"},{"kind":"type","ref":"23","text":"hello"}]' +openclaw browser batch --actions-file plan.json +openclaw browser batch --actions-file - --continue +``` + +`openclaw browser batch` sends a `kind="batch"` `/act` request with nested `BrowserActRequest` actions (`wait`, `click`, `type`, `evaluate`, ...) — not `open`/`navigate`/`snapshot`/`screenshot`, which are CLI subcommands, not `/act` kinds. `--continue` sets `stopOnError=false` (default stops on first error); `--target-id` scopes the whole batch to one tab. A failed nested action makes the command exit nonzero; use `--json` to retain the ordered `results` response. See [Browser batch CLI](/tools/browser-control#browser-batch-cli) for the full contract (ref lifecycle, target id conflicts, error summary). `batch` is not supported on `profile="user"` / existing-session profiles. + ## State and storage Viewport + emulation: diff --git a/docs/docs_map.md b/docs/docs_map.md index eab6611cf3d7..fb3761f7d74a 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -9714,6 +9714,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: How it works (internal) - H2: CLI quick reference - H2: Snapshots and refs + - H2: Browser batch CLI - H2: Wait power-ups - H2: Debug workflows - H2: JSON output diff --git a/docs/tools/browser-control.md b/docs/tools/browser-control.md index 192021681776..72ccf35255db 100644 --- a/docs/tools/browser-control.md +++ b/docs/tools/browser-control.md @@ -343,6 +343,41 @@ Ref behavior: Playwright's `aria-ref` selector. Run a fresh snapshot on the same tab when that happens. +## Browser batch CLI + +`openclaw browser batch` runs an array of nested `/act` actions in one `/act` +call (the same `kind="batch"` runtime reached through the agent tool), so CLI +users and scripts can combine actions like `wait`, `click`, `type`, and +`evaluate` into a single replayable plan without per-action round trips. Each +entry in `actions[]` is a `BrowserActRequest` — the closed union the `/act` +route accepts (`click`, `clickCoords`, `type`, `press`, `hover`, +`scrollIntoView`, `drag`, `select`, `fill`, `resize`, `wait`, `evaluate`, +`close`, `batch`) — not arbitrary `openclaw browser` subcommands. `batch` is +not supported on `profile="user"` and other existing-session (chrome-mcp) +profiles; send actions individually there. + +- CLI: `openclaw browser batch --actions ''`, `openclaw browser batch +--actions-file plan.json`, or `openclaw browser batch --actions-file -` to + read the JSON array from stdin. `--continue` sets `stopOnError=false`; the + default is to stop on first error. `--target-id` scopes the whole batch to + one tab. +- Ref lifecycle: refs come from a `snapshot` run before the batch (snapshot is + not a nested action). A nested action that changes page state — such as a + `click` that triggers navigation, or an `evaluate` that mutates the DOM — can + invalidate earlier refs for the rest of the batch. Put state-changing actions + first, or split into a follow-up batch after re-snapshotting. Navigation and + re-snapshotting happen outside the batch (`openclaw browser navigate` / + `snapshot`), since `open`, `navigate`, and `snapshot` are not `/act` kinds. +- Target id conflicts: a nested action may omit `targetId` or repeat the + request-level `targetId`; an explicit nested `targetId` that resolves to a + different tab is rejected with `ACT_TARGET_ID_MISMATCH` before any action + runs. Batched actions share the request's tab by design. +- Error summary: the response is `{ "results": [{ "ok": true }, { "ok": false, +"error": "" }, ...] }`, one entry per action in order. When + `stopOnError` is the default, the array ends at the first failure; with + `--continue` it covers every action. Any failed entry makes the CLI exit + nonzero; pass `--json` to preserve the full ordered response for scripts. + ## Wait power-ups You can wait on more than just time/text: diff --git a/extensions/browser/skills/browser-automation/SKILL.md b/extensions/browser/skills/browser-automation/SKILL.md index c7ddcdc79471..71e68dc197d1 100644 --- a/extensions/browser/skills/browser-automation/SKILL.md +++ b/extensions/browser/skills/browser-automation/SKILL.md @@ -34,6 +34,15 @@ Use this skill when you need the `browser` tool for anything beyond a single pag - If the page needs login, permission, captcha, 2FA, camera/microphone approval, or another manual step, stop and tell the user exactly what is needed. - Do not claim the browser is not logged in just because the current page shows a permission or onboarding dialog. Inspect the visible UI first. +## Browser batch CLI + +`openclaw browser batch` runs an array of nested `/act` actions in one `/act` call (the same `kind="batch"` runtime reached through the agent tool), so CLI users and scripts can combine actions like `wait`, `click`, `type`, and `evaluate` into a single replayable plan without per-action round trips. Each entry in `actions[]` is a `BrowserActRequest` — the closed union the `/act` route accepts — not arbitrary `openclaw browser` subcommands. `batch` is not supported on `profile="user"` and other existing-session (chrome-mcp) profiles; send actions individually there. + +- CLI: `openclaw browser batch --actions ''`, `--actions-file plan.json`, or `--actions-file -` for stdin. `--continue` sets `stopOnError=false`; default stops on first error. +- Ref lifecycle: refs come from a `snapshot` run before the batch (snapshot is not a nested action). A nested action that changes page state — such as a `click` that triggers navigation, or an `evaluate` that mutates the DOM — can invalidate earlier refs for the rest of the batch; put state-changing actions first, or split into a follow-up batch after re-snapshotting. Navigation and re-snapshotting happen outside the batch, since `open`, `navigate`, and `snapshot` are not `/act` kinds. +- Target id: nested actions share the request's tab; an explicit nested `targetId` that resolves to a different tab is rejected with `ACT_TARGET_ID_MISMATCH`. +- Response: `{ "results": [{ "ok": true } | { "ok": false, "error": "..." }, ...] }` in order; with default `stopOnError` the array ends at the first failure. Any failed entry exits nonzero; use `--json` to preserve the full response in scripts. + ## Tab Hygiene Before creating a tab for a named task, list tabs and reuse an existing matching label or URL when it is still usable. diff --git a/extensions/browser/src/browser/routes/agent.act.normalize.batch-contract.test.ts b/extensions/browser/src/browser/routes/agent.act.normalize.batch-contract.test.ts new file mode 100644 index 000000000000..17762e9889b0 --- /dev/null +++ b/extensions/browser/src/browser/routes/agent.act.normalize.batch-contract.test.ts @@ -0,0 +1,58 @@ +// Browser tests cover agent.act.normalize batch contract behavior. +// Locks the documented `openclaw browser batch` examples against the real +// /act normalizer so a doc example the route rejects cannot slip back in. +import { describe, expect, it } from "vitest"; +import { normalizeActRequest } from "./agent.act.normalize.js"; + +// The documented example shipped in `browser-cli-examples.ts`, `docs/cli/browser.md`, +// and `docs/tools/browser-control.md`. Every entry must be a real BrowserActRequest +// kind or the /act normalizer rejects it before dispatch. +const DOCUMENTED_BATCH_ACTIONS = [ + { kind: "wait", timeMs: 500 }, + { kind: "click", ref: "12" }, + { kind: "type", ref: "23", text: "hello" }, +]; + +describe("normalizeActRequest batch contract", () => { + it("accepts the documented batch example through the real /act normalizer", () => { + const normalized = normalizeActRequest({ + kind: "batch", + actions: DOCUMENTED_BATCH_ACTIONS, + }); + expect(normalized).toMatchObject({ + kind: "batch", + actions: [ + { kind: "wait", timeMs: 500 }, + { kind: "click", ref: "12" }, + { kind: "type", ref: "23", text: "hello" }, + ], + }); + }); + + it("rejects open/navigate/snapshot/screenshot as nested batch actions", () => { + // These are CLI subcommands, not BrowserActRequest kinds; the route normalizer + // must reject them so documented examples cannot describe a non-reproducible workflow. + for (const kind of ["open", "navigate", "snapshot", "screenshot"]) { + expect(() => + normalizeActRequest({ + kind: "batch", + actions: [{ kind, url: "https://example.com" }], + }), + ).toThrow("kind is required"); + } + }); + + it("forwards --continue as stopOnError=false and --target-id on the outer batch", () => { + const normalized = normalizeActRequest({ + kind: "batch", + actions: DOCUMENTED_BATCH_ACTIONS, + targetId: "tab-1", + stopOnError: false, + }); + expect(normalized).toMatchObject({ + kind: "batch", + targetId: "tab-1", + stopOnError: false, + }); + }); +}); diff --git a/extensions/browser/src/cli/browser-cli-actions-input/register.batch.test.ts b/extensions/browser/src/cli/browser-cli-actions-input/register.batch.test.ts new file mode 100644 index 000000000000..838a8a3d6fe7 --- /dev/null +++ b/extensions/browser/src/cli/browser-cli-actions-input/register.batch.test.ts @@ -0,0 +1,239 @@ +// Browser tests cover register.batch plugin behavior. +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as browserCliSharedModule from "../browser-cli-shared.js"; +import { + createBrowserProgram, + getBrowserCliRuntime, + getBrowserCliRuntimeCapture, +} from "../browser-cli.test-support.js"; +import * as cliCoreApiModule from "../core-api.js"; +import * as batchSharedModule from "./shared.js"; + +const mocks = vi.hoisted(() => ({ + callBrowserRequest: vi.fn< + ( + opts?: unknown, + req?: unknown, + extra?: { timeoutMs?: number }, + ) => Promise> + >(async () => ({ results: [{ ok: true }] })), + readActionsPayload: vi.fn(async () => ""), +})); + +vi.spyOn(browserCliSharedModule, "callBrowserRequest").mockImplementation(mocks.callBrowserRequest); +vi.spyOn(batchSharedModule, "readActionsPayload").mockImplementation(mocks.readActionsPayload); +const browserCliRuntime = getBrowserCliRuntime(); +vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(browserCliRuntime.log); +vi.spyOn(cliCoreApiModule.defaultRuntime, "writeJson").mockImplementation( + browserCliRuntime.writeJson, +); +vi.spyOn(cliCoreApiModule.defaultRuntime, "error").mockImplementation(browserCliRuntime.error); +vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(browserCliRuntime.exit); + +const { registerBrowserActionInputCommands } = await import("./register.js"); + +function createActionInputProgram(): Command { + const { program, browser, parentOpts } = createBrowserProgram(); + registerBrowserActionInputCommands(browser, parentOpts); + return program; +} + +function getLastActionBody(): Record | undefined { + return (mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as { body?: Record }) + ?.body; +} + +const SAMPLE_ACTIONS = [ + { kind: "open", url: "https://example.com" }, + { kind: "click", ref: "12" }, +]; + +describe("browser action input batch command", () => { + beforeEach(() => { + mocks.callBrowserRequest.mockClear(); + mocks.readActionsPayload.mockClear(); + getBrowserCliRuntimeCapture().resetRuntimeCapture(); + }); + + it("sends normalized batch body with inline actions and target id", async () => { + mocks.readActionsPayload.mockResolvedValueOnce(JSON.stringify(SAMPLE_ACTIONS)); + const program = createActionInputProgram(); + + await program.parseAsync( + ["browser", "batch", "--actions", JSON.stringify(SAMPLE_ACTIONS), "--target-id", "tab-1"], + { from: "user" }, + ); + + expect(getLastActionBody()).toMatchObject({ + kind: "batch", + actions: SAMPLE_ACTIONS, + targetId: "tab-1", + }); + }); + + it("omits stopOnError by default so the route applies its fail-fast default", async () => { + mocks.readActionsPayload.mockResolvedValueOnce(JSON.stringify(SAMPLE_ACTIONS)); + const program = createActionInputProgram(); + + await program.parseAsync(["browser", "batch", "--actions", JSON.stringify(SAMPLE_ACTIONS)], { + from: "user", + }); + + const body = getLastActionBody(); + expect(body).toMatchObject({ kind: "batch" }); + expect(body).not.toHaveProperty("stopOnError"); + }); + + it("sets stopOnError=false when --continue is passed", async () => { + mocks.readActionsPayload.mockResolvedValueOnce(JSON.stringify(SAMPLE_ACTIONS)); + const program = createActionInputProgram(); + + await program.parseAsync( + ["browser", "batch", "--actions", JSON.stringify(SAMPLE_ACTIONS), "--continue"], + { from: "user" }, + ); + + expect(getLastActionBody()).toMatchObject({ kind: "batch", stopOnError: false }); + }); + + it("reports a failed batch action and exits nonzero in text mode", async () => { + mocks.readActionsPayload.mockResolvedValueOnce(JSON.stringify(SAMPLE_ACTIONS)); + mocks.callBrowserRequest.mockResolvedValueOnce({ + results: [{ ok: true }, { ok: false, error: "ref is stale" }], + }); + const program = createActionInputProgram(); + + await expect( + program.parseAsync(["browser", "batch", "--actions", JSON.stringify(SAMPLE_ACTIONS)], { + from: "user", + }), + ).rejects.toThrow("__exit__:1"); + + expect(getBrowserCliRuntimeCapture().runtimeErrors.join("\n")).toContain( + "batch failed: action 2: ref is stale", + ); + }); + + it("preserves failed batch results in JSON mode before exiting nonzero", async () => { + const result = { results: [{ ok: false, error: "ref is stale" }] }; + mocks.readActionsPayload.mockResolvedValueOnce(JSON.stringify(SAMPLE_ACTIONS)); + mocks.callBrowserRequest.mockResolvedValueOnce(result); + const program = createActionInputProgram(); + + await expect( + program.parseAsync( + ["browser", "--json", "batch", "--actions", JSON.stringify(SAMPLE_ACTIONS)], + { from: "user" }, + ), + ).rejects.toThrow("__exit__:1"); + + expect(getBrowserCliRuntimeCapture().defaultRuntime.writeJson).toHaveBeenCalledWith(result); + }); + + it("reads actions from a file via --actions-file", async () => { + mocks.readActionsPayload.mockResolvedValueOnce(JSON.stringify(SAMPLE_ACTIONS)); + const program = createActionInputProgram(); + + await program.parseAsync( + ["browser", "batch", "--actions-file", "/tmp/openclaw/batch-actions.json"], + { from: "user" }, + ); + + expect(mocks.readActionsPayload).toHaveBeenCalledWith({ + actions: undefined, + actionsFile: "/tmp/openclaw/batch-actions.json", + }); + expect(getLastActionBody()).toMatchObject({ kind: "batch", actions: SAMPLE_ACTIONS }); + }); + + it("reads actions from stdin when --actions-file is -", async () => { + mocks.readActionsPayload.mockResolvedValueOnce(JSON.stringify(SAMPLE_ACTIONS)); + const program = createActionInputProgram(); + + await program.parseAsync(["browser", "batch", "--actions-file", "-"], { from: "user" }); + + expect(mocks.readActionsPayload).toHaveBeenCalledWith({ + actions: undefined, + actionsFile: "-", + }); + expect(getLastActionBody()).toMatchObject({ kind: "batch", actions: SAMPLE_ACTIONS }); + }); + + it("rejects malformed actions JSON before dispatch", async () => { + mocks.readActionsPayload.mockResolvedValueOnce("NOT JSON {{{"); + const program = createActionInputProgram(); + + await expect( + program.parseAsync(["browser", "batch", "--actions", "NOT JSON {{{"], { from: "user" }), + ).rejects.toThrow("__exit__:1"); + + expect(getBrowserCliRuntimeCapture().runtimeErrors.join("\n")).toContain( + "actions must be valid JSON", + ); + expect(mocks.callBrowserRequest).not.toHaveBeenCalled(); + }); + + it("rejects non-array actions before dispatch", async () => { + mocks.readActionsPayload.mockResolvedValueOnce(JSON.stringify({ kind: "click" })); + const program = createActionInputProgram(); + + await expect( + program.parseAsync(["browser", "batch", "--actions", JSON.stringify({ kind: "click" })], { + from: "user", + }), + ).rejects.toThrow("__exit__:1"); + + expect(getBrowserCliRuntimeCapture().runtimeErrors.join("\n")).toContain( + "actions must be a JSON array", + ); + expect(mocks.callBrowserRequest).not.toHaveBeenCalled(); + }); + + it("rejects empty actions before dispatch", async () => { + mocks.readActionsPayload.mockResolvedValueOnce("[]"); + const program = createActionInputProgram(); + + await expect( + program.parseAsync(["browser", "batch", "--actions", "[]"], { from: "user" }), + ).rejects.toThrow("__exit__:1"); + + expect(getBrowserCliRuntimeCapture().runtimeErrors.join("\n")).toContain( + "actions must contain at least one entry", + ); + expect(mocks.callBrowserRequest).not.toHaveBeenCalled(); + }); + + it("requires actions from --actions, --actions-file, or stdin", async () => { + const program = createActionInputProgram(); + + await expect(program.parseAsync(["browser", "batch"], { from: "user" })).rejects.toThrow( + "__exit__:1", + ); + + expect(getBrowserCliRuntimeCapture().runtimeErrors.join("\n")).toContain( + "Provide --actions, --actions-file, or --actions-file -", + ); + expect(mocks.callBrowserRequest).not.toHaveBeenCalled(); + }); + + it("budgets the outer request from the batch execution budget", async () => { + mocks.readActionsPayload.mockResolvedValueOnce( + JSON.stringify([ + { kind: "wait", timeMs: 5000 }, + { kind: "wait", timeMs: 5000 }, + ]), + ); + const program = createActionInputProgram(); + + await program.parseAsync( + ["browser", "batch", "--actions", JSON.stringify([{ kind: "wait", timeMs: 5000 }])], + { from: "user" }, + ); + + const options = mocks.callBrowserRequest.mock.calls.at(-1)?.[2] as + | { timeoutMs?: number } + | undefined; + expect(options?.timeoutMs).toBeGreaterThan(10_000); + }); +}); diff --git a/extensions/browser/src/cli/browser-cli-actions-input/register.batch.ts b/extensions/browser/src/cli/browser-cli-actions-input/register.batch.ts new file mode 100644 index 000000000000..27787fabb2d3 --- /dev/null +++ b/extensions/browser/src/cli/browser-cli-actions-input/register.batch.ts @@ -0,0 +1,96 @@ +/** + * Browser CLI batch command: runs nested act requests in one /act call. + */ +import type { Command } from "commander"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { resolveBrowserActExecutionBudgetMs } from "../../browser/act-policy.js"; +import type { BrowserActRequest } from "../../browser/client-actions.types.js"; +import { BROWSER_TAB_REFERENCE_HELP, type BrowserParentOpts } from "../browser-cli-shared.js"; +import { danger, defaultRuntime } from "../core-api.js"; +import { + callBrowserAct, + logBrowserActionResult, + readActionsPayload, + resolveBrowserActionContext, +} from "./shared.js"; + +/** Registers the Browser CLI batch command. */ +export function registerBrowserBatchCommands( + browser: Command, + parentOpts: (cmd: Command) => BrowserParentOpts, +) { + browser + .command("batch") + .description("Run a batch of browser actions in one call (default: stop on first error)") + .option("--actions ", "JSON array of act requests") + .option("--actions-file ", "Read JSON array from a file (- for stdin)") + .option("--continue", "Continue through all actions instead of stopping on first error") + .option("--target-id ", BROWSER_TAB_REFERENCE_HELP) + .action(async (opts, cmd) => { + const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts); + if (!opts.actions && !opts.actionsFile) { + defaultRuntime.error(danger("Provide --actions, --actions-file, or --actions-file -")); + defaultRuntime.exit(1); + return; + } + let actions: unknown[]; + let result: { results?: Array<{ ok: boolean; error?: string }> }; + try { + const payload = await readActionsPayload({ + actions: opts.actions, + actionsFile: opts.actionsFile, + }); + if (!payload.trim()) { + throw new Error("actions are required"); + } + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch (cause) { + throw new Error("actions must be valid JSON", { cause }); + } + if (!Array.isArray(parsed)) { + throw new Error("actions must be a JSON array"); + } + if (!parsed.length) { + throw new Error("actions must contain at least one entry"); + } + actions = parsed; + const targetId = normalizeOptionalString(opts.targetId); + const body: Record = { + kind: "batch", + actions, + ...(targetId ? { targetId } : {}), + ...(opts.continue ? { stopOnError: false } : {}), + }; + const request = body as unknown as BrowserActRequest; + result = await callBrowserAct<{ + results?: Array<{ ok: boolean; error?: string }>; + }>({ + parent, + profile, + body, + timeoutMs: resolveBrowserActExecutionBudgetMs(request), + }); + } catch (err) { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); + return; + } + const failures = (result.results ?? []).flatMap((entry, index) => + entry.ok ? [] : [`action ${index + 1}: ${entry.error ?? "failed"}`], + ); + // /act represents recoverable child errors in a successful response. + // Surface them as a command failure so text-mode scripts do not report a false success. + if (failures.length) { + if (parent?.json) { + defaultRuntime.writeJson(result); + } else { + defaultRuntime.error(danger(`batch failed: ${failures.join("; ")}`)); + } + defaultRuntime.exit(1); + return; + } + logBrowserActionResult(parent, result, `batch ran ${actions.length} action(s)`); + }); +} diff --git a/extensions/browser/src/cli/browser-cli-actions-input/register.ts b/extensions/browser/src/cli/browser-cli-actions-input/register.ts index 9e3fe4036b7c..17326addb73f 100644 --- a/extensions/browser/src/cli/browser-cli-actions-input/register.ts +++ b/extensions/browser/src/cli/browser-cli-actions-input/register.ts @@ -3,12 +3,13 @@ */ import type { Command } from "commander"; import type { BrowserParentOpts } from "../browser-cli-shared.js"; +import { registerBrowserBatchCommands } from "./register.batch.js"; import { registerBrowserElementCommands } from "./register.element.js"; import { registerBrowserFilesAndDownloadsCommands } from "./register.files-downloads.js"; import { registerBrowserFormWaitEvalCommands } from "./register.form-wait-eval.js"; import { registerBrowserNavigationCommands } from "./register.navigation.js"; -/** Registers navigation, element, file/download, form, wait, and evaluate commands. */ +/** Registers navigation, element, file/download, form, wait, evaluate, and batch commands. */ export function registerBrowserActionInputCommands( browser: Command, parentOpts: (cmd: Command) => BrowserParentOpts, @@ -17,4 +18,5 @@ export function registerBrowserActionInputCommands( registerBrowserElementCommands(browser, parentOpts); registerBrowserFilesAndDownloadsCommands(browser, parentOpts); registerBrowserFormWaitEvalCommands(browser, parentOpts); + registerBrowserBatchCommands(browser, parentOpts); } diff --git a/extensions/browser/src/cli/browser-cli-actions-input/shared.ts b/extensions/browser/src/cli/browser-cli-actions-input/shared.ts index 037f4c4bbb29..2246401407bc 100644 --- a/extensions/browser/src/cli/browser-cli-actions-input/shared.ts +++ b/extensions/browser/src/cli/browser-cli-actions-input/shared.ts @@ -125,3 +125,35 @@ export async function readFields(opts: { throw new Error(`fields[${index}].value must be string, number, boolean, or null`); }); } + +/** Cap on batch action JSON read from stdin; keeps a runaway pipe from filling memory. */ +const ACTIONS_STDIN_MAX_BYTES = 1_000_000; + +/** Reads stdin to a UTF-8 string, throwing once the byte cap is exceeded. */ +async function readStdinText( + stream: NodeJS.ReadableStream = process.stdin, + maxBytes = ACTIONS_STDIN_MAX_BYTES, +): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of stream) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buf.length; + if (total > maxBytes) { + throw new Error(`actions stdin exceeds ${maxBytes} bytes.`); + } + chunks.push(buf); + } + return Buffer.concat(chunks).toString("utf8"); +} + +/** Reads raw batch actions JSON from inline text, a file path, or stdin (`-`). */ +export async function readActionsPayload(opts: { + actions?: string; + actionsFile?: string; +}): Promise { + if (opts.actionsFile) { + return opts.actionsFile === "-" ? await readStdinText() : await readFile(opts.actionsFile); + } + return opts.actions ?? ""; +} diff --git a/extensions/browser/src/cli/browser-cli-examples.ts b/extensions/browser/src/cli/browser-cli-examples.ts index d9302f9939b2..a975c34d2fe5 100644 --- a/extensions/browser/src/cli/browser-cli-examples.ts +++ b/extensions/browser/src/cli/browser-cli-examples.ts @@ -40,4 +40,7 @@ export const browserActionExamples = [ "openclaw browser evaluate --fn 'const title = document.title; return title;'", "openclaw browser console --level error", "openclaw browser pdf", + "openclaw browser batch --actions-file plan.json", + 'openclaw browser batch --actions \'[{"kind":"wait","timeMs":500},{"kind":"click","ref":"12"},{"kind":"type","ref":"23","text":"hello"}]\'', + "openclaw browser batch --actions-file plan.json --continue", ]; diff --git a/extensions/browser/src/cli/browser-cli.ts b/extensions/browser/src/cli/browser-cli.ts index dfa86160ae09..bc5b18d9a7ee 100644 --- a/extensions/browser/src/cli/browser-cli.ts +++ b/extensions/browser/src/cli/browser-cli.ts @@ -108,6 +108,7 @@ const browserCommandGroupDefinitions: readonly BrowserCommandGroupDefinition[] = command("fill", "Fill a form with JSON field descriptors"), command("wait", "Wait for time, selector, URL, load state, or JS conditions"), command("evaluate", "Evaluate a function against the page or a ref"), + command("batch", "Run a batch of browser actions in one call"), ], register: async (args) => { const module = await import("./browser-cli-actions-input.js");