mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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 <json>` for inline JSON, `--actions-file <path>` 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> Co-authored-by: Mason Huang <masonxhuang@icloud.com> Co-authored-by: Mason Huang <masonxhuang@proton.me>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '<json>'`, `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": "<message>" }, ...] }`, 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:
|
||||
|
||||
@@ -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 '<json>'`, `--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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, unknown>>
|
||||
>(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<string, unknown> | undefined {
|
||||
return (mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as { body?: Record<string, unknown> })
|
||||
?.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);
|
||||
});
|
||||
});
|
||||
@@ -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>", "JSON array of act requests")
|
||||
.option("--actions-file <path>", "Read JSON array from a file (- for stdin)")
|
||||
.option("--continue", "Continue through all actions instead of stopping on first error")
|
||||
.option("--target-id <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<string, unknown> = {
|
||||
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)`);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
if (opts.actionsFile) {
|
||||
return opts.actionsFile === "-" ? await readStdinText() : await readFile(opts.actionsFile);
|
||||
}
|
||||
return opts.actions ?? "";
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user