fix(browser): document stable tab references (#88393)

Summary:
- The branch documents friendly browser tab references across docs, the browser skill, CLI help, and tool schema descriptions, and adds tests for target reference resolution and tab alias behavior.
- PR surface: Source +24, Tests +328, Docs +9. Total +361 across 21 files.
- Reproducibility: yes. for the documentation mismatch by source inspection: current main supports friendly ta ... schema/help surfaces still emphasize raw CDP target ids. Runtime behavior itself is not a new failing path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: refactor(browser): share tab reference CLI help

Validation:
- ClawSweeper review passed for head 118af80b0b.
- Required merge gates passed before the squash merge.

Prepared head SHA: 118af80b0b
Review: https://github.com/openclaw/openclaw/pull/88393#issuecomment-4583558133

Co-authored-by: FMLS <kfliuyang@gmail.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
This commit is contained in:
FMLS
2026-05-31 20:09:50 +08:00
committed by GitHub
parent 94b1427fdf
commit 3a88142ddd
21 changed files with 447 additions and 86 deletions

View File

@@ -142,6 +142,9 @@ the optional label, and the raw `targetId`. Agents should pass
`suggestedTargetId` back into `focus`, `close`, snapshots, and actions. You can
assign a label with `open --label`, `tab new --label`, or `tab label`; labels,
tab ids, raw target ids, and unique target-id prefixes are all accepted.
The request field is still named `targetId` for compatibility, but it accepts
these tab references. Treat raw target ids as diagnostic handles, not durable
agent memory.
When Chromium replaces the underlying raw target during a navigation or form
submit, OpenClaw keeps the stable `tabId`/label attached to the replacement tab
when it can prove the match. Raw target ids remain volatile; prefer

View File

@@ -34,6 +34,11 @@ one-shot headless launch for local managed profiles without changing persisted
browser config; attach-only, remote CDP, and existing-session profiles reject
that override because OpenClaw does not launch those browser processes.
For tab endpoints, `targetId` is the compatibility field name. Prefer passing
`suggestedTargetId` from `GET /tabs` or `POST /tabs/open`; labels and `tabId`
handles such as `t1` are also accepted. Raw CDP target ids and unique raw
target-id prefixes still work, but they are volatile diagnostic handles.
If shared-secret gateway auth is configured, browser HTTP routes require auth too:
- `Authorization: Bearer <gateway token>`

View File

@@ -17,8 +17,9 @@ Use this skill when you need the `browser` tool for anything beyond a single pag
- `action="tabs"` before opening a new tab if retries/timeouts may have left windows behind.
2. Prefer stable tab handles:
- Open important tabs with `label`, for example `label="meet"`.
- Use `tabId` handles like `t1` or labels like `meet` as `targetId` in later calls.
- Avoid relying on raw DevTools `targetId` unless the tool just returned it.
- After `action="tabs"` or `action="open"`, store `suggestedTargetId` and pass it as `targetId` in later calls.
- `suggestedTargetId` is the label when one exists, otherwise the stable `tabId` handle like `t1`.
- Avoid relying on raw DevTools `targetId` except for immediate diagnostics; it can change under Chromium target replacement.
3. Read before you click:
- Use `action="snapshot"` on the intended `targetId`.
- Use the same `targetId` for follow-up actions so refs stay on the same tab.

View File

@@ -1,8 +1,14 @@
import { describe, expect, it } from "vitest";
import { ACT_MAX_VIEWPORT_DIMENSION } from "./browser/act-policy.js";
import { BrowserToolSchema } from "./browser-tool.schema.js";
import { ACT_MAX_VIEWPORT_DIMENSION } from "./browser/act-policy.js";
type SchemaRecord = Record<string, { maximum?: number; properties?: SchemaRecord }>;
type SchemaProperty = {
description?: string;
maximum?: number;
properties?: SchemaRecord;
};
type BrowserSchemaRecord = Record<string, SchemaProperty>;
describe("browser tool schema", () => {
it("advertises the viewport resize maximum on nested and flattened act params", () => {
@@ -14,4 +20,13 @@ describe("browser tool schema", () => {
expect(requestProperties.width.maximum).toBe(ACT_MAX_VIEWPORT_DIMENSION);
expect(requestProperties.height.maximum).toBe(ACT_MAX_VIEWPORT_DIMENSION);
});
it("describes targetId as a compatible tab reference", () => {
const properties = BrowserToolSchema.properties as BrowserSchemaRecord;
const requestProperties = properties.request.properties as BrowserSchemaRecord;
expect(properties.targetId.description).toContain("Prefer suggestedTargetId");
expect(properties.targetId.description).toContain("raw CDP targetId");
expect(requestProperties.targetId.description).toBe(properties.targetId.description);
});
});

View File

@@ -51,13 +51,16 @@ const BROWSER_SNAPSHOT_REFS = ["role", "aria"] as const;
const BROWSER_IMAGE_TYPES = ["png", "jpeg"] as const;
const TAB_REFERENCE_DESCRIPTION =
"Tab reference. Prefer suggestedTargetId, tabId, or label from tabs output; raw CDP targetId and unique raw prefixes remain supported for compatibility.";
// NOTE: Using a flattened object schema instead of Type.Union([Type.Object(...), ...])
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
// The discriminator (kind) determines which properties are relevant; runtime validates.
const BrowserActSchema = Type.Object({
kind: stringEnum(BROWSER_ACT_KINDS),
// Common fields
targetId: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String({ description: TAB_REFERENCE_DESCRIPTION })),
ref: Type.Optional(Type.String()),
// click
doubleClick: Type.Optional(Type.Boolean()),
@@ -103,7 +106,7 @@ export const BrowserToolSchema = Type.Object({
profile: Type.Optional(Type.String()),
targetUrl: Type.Optional(Type.String()),
url: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String({ description: TAB_REFERENCE_DESCRIPTION })),
label: Type.Optional(Type.String()),
limit: optionalPositiveIntegerSchema(),
maxChars: optionalNonNegativeIntegerSchema(),

View File

@@ -16,6 +16,9 @@ const { registerBrowserTabRoutes } = await import("./tabs.js");
type ProfileContext = ReturnType<typeof createProfileContext>;
type TabFixture = {
targetId: string;
suggestedTargetId?: string;
tabId?: string;
label?: string;
title: string;
url: string;
type: "page";
@@ -286,6 +289,31 @@ describe("browser tab routes", () => {
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
});
it("resolves friendly tab references before focusing tabs", async () => {
const profileCtx = createProfileWithTabs([
publicTab({
targetId: "T1_RAW",
suggestedTargetId: "docs",
tabId: "t1",
label: "docs",
}),
]);
const labelResponse = await callTabsFocus({
profileCtx,
body: { targetId: "docs" },
});
const tabIdResponse = await callTabsFocus({
profileCtx,
body: { targetId: "t1" },
});
expect(labelResponse.statusCode).toBe(200);
expect(tabIdResponse.statusCode).toBe(200);
expect(profileCtx.focusTab).toHaveBeenNthCalledWith(1, "T1_RAW");
expect(profileCtx.focusTab).toHaveBeenNthCalledWith(2, "T1_RAW");
});
it("blocks /tabs/action select when target tab URL fails SSRF checks", async () => {
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockRejectedValueOnce(
new Error("blocked"),

View File

@@ -396,4 +396,212 @@ describe("browser server-context tab selection state", () => {
undefined,
]);
});
it("assigns stable tab ids and prefers labels as suggested target ids", async () => {
const fetchMock = vi.fn(async (url: unknown) => {
const value = String(url);
if (!value.includes("/json/list")) {
throw new Error(`unexpected fetch: ${value}`);
}
return {
ok: true,
json: async () => [
{
id: "DOCS_RAW",
title: "Docs",
url: "https://docs.example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/DOCS_RAW",
type: "page",
},
{
id: "APP_RAW",
title: "App",
url: "https://app.example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/APP_RAW",
type: "page",
},
],
} as unknown as Response;
});
global.fetch = withBrowserFetchPreconnect(fetchMock);
const state = makeState("openclaw");
const ctx = createTestBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
expect(await openclaw.listTabs()).toEqual([
expect.objectContaining({
targetId: "DOCS_RAW",
tabId: "t1",
suggestedTargetId: "t1",
}),
expect.objectContaining({
targetId: "APP_RAW",
tabId: "t2",
suggestedTargetId: "t2",
}),
]);
await expect(openclaw.labelTab("t1", "docs")).resolves.toEqual(
expect.objectContaining({
targetId: "DOCS_RAW",
tabId: "t1",
label: "docs",
suggestedTargetId: "docs",
}),
);
});
it("carries a stale alias to a single replacement target", async () => {
let listCount = 0;
const fetchMock = vi.fn(async (url: unknown) => {
const value = String(url);
if (!value.includes("/json/list")) {
throw new Error(`unexpected fetch: ${value}`);
}
listCount += 1;
const secondList = listCount > 1;
return {
ok: true,
json: async () =>
secondList
? [
{
id: "FIRST_RAW",
title: "First",
url: "https://first.example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/FIRST_RAW",
type: "page",
},
{
id: "THIRD_RAW",
title: "Third",
url: "https://third.example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/THIRD_RAW",
type: "page",
},
]
: [
{
id: "FIRST_RAW",
title: "First",
url: "https://first.example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/FIRST_RAW",
type: "page",
},
{
id: "SECOND_RAW",
title: "Second",
url: "https://second.example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/SECOND_RAW",
type: "page",
},
],
} as unknown as Response;
});
global.fetch = withBrowserFetchPreconnect(fetchMock);
const state = makeState("openclaw");
const ctx = createTestBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
expect((await openclaw.listTabs()).map((tab) => tab.tabId)).toEqual(["t1", "t2"]);
expect(await openclaw.listTabs()).toEqual([
expect.objectContaining({ targetId: "FIRST_RAW", tabId: "t1" }),
expect.objectContaining({ targetId: "THIRD_RAW", tabId: "t2" }),
]);
});
it("carries stable aliases across confident raw target replacement", async () => {
let listCount = 0;
const fetchMock = vi.fn(async (url: unknown) => {
const value = String(url);
if (!value.includes("/json/list")) {
throw new Error(`unexpected fetch: ${value}`);
}
listCount += 1;
const targetId = listCount > 1 ? "NEW_RAW" : "OLD_RAW";
return {
ok: true,
json: async () => [
{
id: targetId,
title: "Checkout",
url: "https://shop.example.com/checkout",
webSocketDebuggerUrl: `ws://127.0.0.1/devtools/page/${targetId}`,
type: "page",
},
],
} as unknown as Response;
});
global.fetch = withBrowserFetchPreconnect(fetchMock);
const state = makeState("openclaw");
const ctx = createTestBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
await expect(openclaw.labelTab("OLD_RAW", "checkout")).resolves.toEqual(
expect.objectContaining({
targetId: "OLD_RAW",
tabId: "t1",
suggestedTargetId: "checkout",
}),
);
const profileState = state.profiles.get("openclaw");
if (!profileState) {
throw new Error("expected profile state");
}
profileState.lastTargetId = "OLD_RAW";
await expect(openclaw.listTabs()).resolves.toEqual([
expect.objectContaining({
targetId: "NEW_RAW",
tabId: "t1",
label: "checkout",
suggestedTargetId: "checkout",
}),
]);
expect(state.profiles.get("openclaw")?.lastTargetId).toBe("NEW_RAW");
});
it("resolves friendly tab references before backend focus and close calls", async () => {
const fetchMock = vi.fn(async (url: unknown) => {
const value = String(url);
if (value.includes("/json/list")) {
return {
ok: true,
json: async () => [
{
id: "DOCS_RAW",
title: "Docs",
url: "https://docs.example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/DOCS_RAW",
type: "page",
},
],
} as unknown as Response;
}
if (value.includes("/json/activate/DOCS_RAW") || value.includes("/json/close/DOCS_RAW")) {
return { ok: true } as unknown as Response;
}
throw new Error(`unexpected fetch: ${value}`);
});
global.fetch = withBrowserFetchPreconnect(fetchMock);
const state = makeState("openclaw");
const ctx = createTestBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
await openclaw.labelTab("DOCS_RAW", "docs");
await expect(openclaw.ensureTabAvailable("t1")).resolves.toEqual(
expect.objectContaining({ targetId: "DOCS_RAW" }),
);
await openclaw.focusTab("docs");
await openclaw.closeTab("t1");
expect(fetchCallUrls(fetchMock).some((url) => url.includes("/json/activate/DOCS_RAW"))).toBe(
true,
);
expect(fetchCallUrls(fetchMock).some((url) => url.includes("/json/close/DOCS_RAW"))).toBe(true);
});
});

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { resolveTargetIdFromTabs } from "./target-id.js";
const tabs = [
{
targetId: "ABCDEF123456",
suggestedTargetId: "docs",
tabId: "t1",
label: "docs",
},
{
targetId: "ABC999",
suggestedTargetId: "t2",
tabId: "t2",
},
];
describe("resolveTargetIdFromTabs", () => {
it("resolves friendly tab references before falling back to raw target prefixes", () => {
expect(resolveTargetIdFromTabs("docs", tabs)).toEqual({
ok: true,
targetId: "ABCDEF123456",
});
expect(resolveTargetIdFromTabs("t2", tabs)).toEqual({
ok: true,
targetId: "ABC999",
});
expect(resolveTargetIdFromTabs("ABCDEF123456", tabs)).toEqual({
ok: true,
targetId: "ABCDEF123456",
});
});
it("keeps unique raw target-id prefixes as compatibility input", () => {
expect(resolveTargetIdFromTabs("ABCDEF", tabs)).toEqual({
ok: true,
targetId: "ABCDEF123456",
});
});
it("rejects ambiguous raw target-id prefixes", () => {
expect(resolveTargetIdFromTabs("ABC", tabs)).toEqual({
ok: false,
reason: "ambiguous",
matches: ["ABCDEF123456", "ABC999"],
});
});
});

View File

@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
BROWSER_TAB_REFERENCE_HELP,
parseBrowserNonNegativeIntegerOption,
parseBrowserPositiveIntegerOption,
type BrowserParentOpts,
@@ -65,7 +66,7 @@ export function registerBrowserElementCommands(
.command("click")
.description("Click an element by ref from snapshot")
.argument("<ref>", "Ref id from snapshot")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.option("--double", "Double click", false)
.option("--button <left|right|middle>", "Mouse button to use")
.option("--modifiers <list>", "Comma-separated modifiers (Shift,Alt,Meta)")
@@ -103,7 +104,7 @@ export function registerBrowserElementCommands(
.description("Click viewport coordinates")
.argument("<x>", "Viewport x coordinate")
.argument("<y>", "Viewport y coordinate")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.option("--double", "Double click", false)
.option("--button <left|right|middle>", "Mouse button to use")
.option("--delay-ms <ms>", "Delay between mouse down/up", (v: string) =>
@@ -141,7 +142,7 @@ export function registerBrowserElementCommands(
.argument("<text>", "Text to type")
.option("--submit", "Press Enter after typing", false)
.option("--slowly", "Type slowly (human-like)", false)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (ref: string | undefined, text: string, opts, cmd) => {
const refValue = requireRef(ref);
if (!refValue) {
@@ -165,7 +166,7 @@ export function registerBrowserElementCommands(
.command("press")
.description("Press a key")
.argument("<key>", "Key to press (e.g. Enter)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (key: string, opts, cmd) => {
await runElementAction({
cmd,
@@ -178,7 +179,7 @@ export function registerBrowserElementCommands(
.command("hover")
.description("Hover an element by ai ref")
.argument("<ref>", "Ref id from snapshot")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (ref: string, opts, cmd) => {
await runElementAction({
cmd,
@@ -191,7 +192,7 @@ export function registerBrowserElementCommands(
.command("scrollintoview")
.description("Scroll an element into view by ref from snapshot")
.argument("<ref>", "Ref id from snapshot")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.option("--timeout-ms <ms>", "How long to wait for scroll (default: 20000)", (v: string) =>
parseBrowserPositiveIntegerOption(v, "--timeout-ms"),
)
@@ -219,7 +220,7 @@ export function registerBrowserElementCommands(
.description("Drag from one ref to another")
.argument("<startRef>", "Start ref id")
.argument("<endRef>", "End ref id")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (startRef: string, endRef: string, opts, cmd) => {
await runElementAction({
cmd,
@@ -238,7 +239,7 @@ export function registerBrowserElementCommands(
.description("Select option(s) in a select element")
.argument("<ref>", "Ref id from snapshot")
.argument("<values...>", "Option values to select")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (ref: string, values: string[], opts, cmd) => {
await runElementAction({
cmd,

View File

@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
BROWSER_TAB_REFERENCE_HELP,
callBrowserRequest,
parseBrowserPositiveIntegerOption,
type BrowserParentOpts,
@@ -95,7 +96,7 @@ export function registerBrowserFilesAndDownloadsCommands(
.option("--ref <ref>", "Ref id from snapshot to click after arming")
.option("--input-ref <ref>", "Ref id for <input type=file> to set directly")
.option("--element <selector>", "CSS selector for <input type=file>")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.option(
"--timeout-ms <ms>",
"How long to wait for the next file chooser (default: 120000)",
@@ -134,7 +135,7 @@ export function registerBrowserFilesAndDownloadsCommands(
"[path]",
"Save path within openclaw temp downloads dir (default: /tmp/openclaw/downloads/...; fallback: os.tmpdir()/openclaw/downloads/...)",
)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.option(
"--timeout-ms <ms>",
"How long to wait for the next download (default: 120000)",
@@ -157,7 +158,7 @@ export function registerBrowserFilesAndDownloadsCommands(
"<path>",
"Save path within openclaw temp downloads dir (e.g. report.pdf or /tmp/openclaw/downloads/report.pdf)",
)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.option(
"--timeout-ms <ms>",
"How long to wait for the download to start (default: 120000)",
@@ -180,7 +181,7 @@ export function registerBrowserFilesAndDownloadsCommands(
.option("--dismiss", "Dismiss the dialog", false)
.option("--prompt <text>", "Prompt response text")
.option("--dialog-id <id>", "Pending dialog id from snapshot/browser state")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.option(
"--timeout-ms <ms>",
"How long to wait for the next dialog (default: 120000)",

View File

@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
BROWSER_TAB_REFERENCE_HELP,
parseBrowserNonNegativeIntegerOption,
parseBrowserPositiveIntegerOption,
type BrowserParentOpts,
@@ -39,7 +40,7 @@ export function registerBrowserFormWaitEvalCommands(
.description("Fill a form with JSON field descriptors")
.option("--fields <json>", "JSON array of field objects")
.option("--fields-file <path>", "Read JSON array from a file")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
@@ -80,7 +81,7 @@ export function registerBrowserFormWaitEvalCommands(
"How long to wait for each condition (default: 20000)",
(v: string) => parseBrowserPositiveIntegerOption(v, "--timeout-ms"),
)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (selector: string | undefined, opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
@@ -130,7 +131,7 @@ export function registerBrowserFormWaitEvalCommands(
"How long to allow the evaluate function to run (default: 20000)",
(v: string) => parseBrowserPositiveIntegerOption(v, "--timeout-ms"),
)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
if (!opts.fn) {

View File

@@ -3,6 +3,7 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runti
import { ACT_MAX_VIEWPORT_DIMENSION } from "../../browser/act-policy.js";
import { runBrowserResizeWithOutput } from "../browser-cli-resize.js";
import {
BROWSER_TAB_REFERENCE_HELP,
callBrowserRequest,
parseBrowserPositiveIntegerValue,
type BrowserParentOpts,
@@ -33,7 +34,7 @@ export function registerBrowserNavigationCommands(
.command("navigate")
.description("Navigate the current tab to a URL")
.argument("<url>", "URL to navigate to")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (url: string, opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
@@ -66,7 +67,7 @@ export function registerBrowserNavigationCommands(
.description("Resize the viewport")
.argument("<width>", "Viewport width")
.argument("<height>", "Viewport height")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (width: string, height: string, opts, cmd) => {
const normalizedWidth = parsePositiveInteger(width, "width");
const normalizedHeight = parsePositiveInteger(height, "height");

View File

@@ -2,6 +2,7 @@ import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { runCommandWithRuntime } from "../core-api.js";
import {
BROWSER_TAB_REFERENCE_HELP,
callBrowserRequest,
parseBrowserPositiveIntegerOption,
type BrowserParentOpts,
@@ -23,7 +24,7 @@ export function registerBrowserActionObserveCommands(
.command("console")
.description("Get recent console messages")
.option("--level <level>", "Filter by level (error, warn, info)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
@@ -52,7 +53,7 @@ export function registerBrowserActionObserveCommands(
browser
.command("pdf")
.description("Save page as PDF")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
@@ -79,7 +80,7 @@ export function registerBrowserActionObserveCommands(
.command("responsebody")
.description("Wait for a network response and return its body")
.argument("<url>", "URL (exact, substring, or glob like **/api)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.option(
"--timeout-ms <ms>",
"How long to wait for the response (default: 20000)",

View File

@@ -1,7 +1,11 @@
import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { runCommandWithRuntime } from "../core-api.js";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import {
BROWSER_TAB_REFERENCE_HELP,
callBrowserRequest,
type BrowserParentOpts,
} from "./browser-cli-shared.js";
import { danger, defaultRuntime, shortenHomePath } from "./core-api.js";
const BROWSER_DEBUG_TIMEOUT_MS = 20000;
@@ -75,7 +79,7 @@ export function registerBrowserDebugCommands(
.command("highlight")
.description("Highlight an element by ref")
.argument("<ref>", "Ref id from snapshot")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (ref: string, opts, cmd) => {
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callDebugRequest(parent, {
@@ -98,7 +102,7 @@ export function registerBrowserDebugCommands(
.command("errors")
.description("Get recent page errors")
.option("--clear", "Clear stored errors after reading", false)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd) => {
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callDebugRequest<{
@@ -132,7 +136,7 @@ export function registerBrowserDebugCommands(
.description("Get recent network requests (best-effort)")
.option("--filter <text>", "Only show URLs that contain this substring")
.option("--clear", "Clear stored requests after reading", false)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd) => {
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callDebugRequest<{
@@ -179,7 +183,7 @@ export function registerBrowserDebugCommands(
trace
.command("start")
.description("Start trace recording")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.option("--no-screenshots", "Disable screenshots")
.option("--no-snapshots", "Disable snapshots")
.option("--sources", "Include sources (bigger traces)", false)
@@ -210,7 +214,7 @@ export function registerBrowserDebugCommands(
"--out <path>",
"Output path within openclaw temp dir (e.g. trace.zip or /tmp/openclaw/trace.zip)",
)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd) => {
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callDebugRequest<{ path: string }>(parent, {

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
BROWSER_TAB_REFERENCE_HELP,
callBrowserRequest,
parseBrowserNonNegativeIntegerValue,
parseBrowserPositiveIntegerValue,
@@ -42,7 +43,7 @@ export function registerBrowserInspectCommands(
browser
.command("screenshot")
.description("Capture a screenshot (prints the saved path)")
.argument("[targetId]", "CDP target id (or unique prefix)")
.argument("[targetId]", BROWSER_TAB_REFERENCE_HELP)
.option("--full-page", "Capture full scrollable page", false)
.option("--ref <ref>", "ARIA ref from ai snapshot")
.option("--element <selector>", "CSS selector for element screenshot")
@@ -84,7 +85,7 @@ export function registerBrowserInspectCommands(
.command("snapshot")
.description("Capture a snapshot (default: ai; aria is the accessibility tree)")
.option("--format <aria|ai>", "Snapshot format (default: ai)", "ai")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.option("--limit <n>", "Max nodes (default: 500/800)")
.option("--mode <efficient>", "Snapshot preset (efficient)")
.option("--efficient", "Use the efficient snapshot preset", false)

View File

@@ -261,6 +261,35 @@ describe("browser manage output", () => {
expect(output).not.toContain("supersecrettokenvalue1234567890");
});
it("prints suggested tab references while keeping raw target ids visible", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
req.path === "/tabs"
? {
running: true,
tabs: [
{
targetId: "RAW_TARGET_1",
suggestedTargetId: "docs",
tabId: "t1",
label: "docs",
title: "Docs",
url: "https://docs.example.com",
},
],
}
: {},
);
const program = createBrowserManageProgram();
await program.parseAsync(["browser", "tabs"], { from: "user" });
const output = lastRuntimeLog();
expect(output).toContain("use: docs");
expect(output).toContain("tab: t1");
expect(output).toContain("label:docs");
expect(output).toContain("id: RAW_TARGET_1");
});
it("rejects non-integer tab indexes without calling browser actions", async () => {
const program = createBrowserManageProgram();
@@ -348,6 +377,6 @@ describe("browser manage output", () => {
const output = lastRuntimeLog();
expect(output).toContain("OK gateway: browser control endpoint reachable");
expect(output).toContain("OK tabs: 1 visible, use target t1");
expect(output).toContain("OK tabs: 1 visible, use tab reference t1");
});
});

View File

@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { runCommandWithRuntime } from "../core-api.js";
import {
BROWSER_TAB_REFERENCE_HELP,
callBrowserRequest,
parseBrowserPositiveIntegerValue,
type BrowserParentOpts,
@@ -132,8 +133,12 @@ function logBrowserTabs(tabs: BrowserTab[], json?: boolean) {
defaultRuntime.log(
tabs
.map((t, i) => {
const alias = [t.tabId, t.label ? `label:${t.label}` : undefined].filter(Boolean).join(" ");
return `${i + 1}. ${t.title || "(untitled)"}${alias ? ` [${alias}]` : ""}\n ${t.url}\n id: ${t.targetId}`;
const labelHandle = t.label ? `label:${t.label}` : undefined;
const suggested = t.suggestedTargetId ? `use: ${t.suggestedTargetId}` : undefined;
const handles = [suggested, t.tabId ? `tab: ${t.tabId}` : undefined, labelHandle]
.filter(Boolean)
.join(" ");
return `${i + 1}. ${t.title || "(untitled)"}${handles ? ` [${handles}]` : ""}\n ${t.url}\n id: ${t.targetId}`;
})
.join("\n"),
);
@@ -215,7 +220,7 @@ async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, dee
checks.push({
name: "tabs",
ok: true,
detail: `${tabs.length} visible${tabs.length > 0 && tabs[0]?.suggestedTargetId ? `, use target ${tabs[0].suggestedTargetId}` : ""}`,
detail: `${tabs.length} visible${tabs.length > 0 && tabs[0]?.suggestedTargetId ? `, use tab reference ${tabs[0].suggestedTargetId}` : ""}`,
});
} catch (err) {
checks.push({
@@ -480,7 +485,7 @@ export function registerBrowserManageCommands(
tab
.command("label")
.description("Assign a friendly label to a tab")
.argument("<targetId>", "Target id, tab id, label, or unique target id prefix")
.argument("<targetId>", BROWSER_TAB_REFERENCE_HELP)
.argument("<label>", "Friendly label")
.action(async (targetId: string, label: string, _opts, cmd) => {
const parent = parentOpts(cmd);
@@ -571,8 +576,8 @@ export function registerBrowserManageCommands(
browser
.command("focus")
.description("Focus a tab by target id, tab id, label, or unique target id prefix")
.argument("<targetId>", "Target id, tab id, label, or unique target id prefix")
.description("Focus a tab by tab reference")
.argument("<targetId>", BROWSER_TAB_REFERENCE_HELP)
.action(async (targetId: string, _opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
@@ -596,8 +601,8 @@ export function registerBrowserManageCommands(
browser
.command("close")
.description("Close a tab (target id optional)")
.argument("[targetId]", "Target id, tab id, label, or unique target id prefix (optional)")
.description("Close a tab (tab reference optional)")
.argument("[targetId]", `${BROWSER_TAB_REFERENCE_HELP} (optional)`)
.action(async (targetId: string | undefined, _opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;

View File

@@ -15,6 +15,9 @@ export type BrowserParentOpts = GatewayRpcOpts & {
browserProfile?: string;
};
export const BROWSER_TAB_REFERENCE_HELP =
"Tab reference: suggested target id, tab id, label, raw target id, or unique raw prefix";
type BrowserRequestParams = {
method: "GET" | "POST" | "DELETE";
path: string;

View File

@@ -1,6 +1,10 @@
import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import {
BROWSER_TAB_REFERENCE_HELP,
callBrowserRequest,
type BrowserParentOpts,
} from "./browser-cli-shared.js";
import { danger, defaultRuntime, inheritOptionFromParent } from "./core-api.js";
function resolveUrl(opts: { url?: string }, command: Command): string | undefined {
@@ -41,35 +45,33 @@ export function registerBrowserCookiesAndStorageCommands(
) {
const cookies = browser.command("cookies").description("Read/write cookies");
cookies
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd);
try {
const result = await callBrowserRequest<{ cookies?: unknown[] }>(
parent,
{
method: "GET",
path: "/cookies",
query: {
targetId,
profile,
},
cookies.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP).action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd);
try {
const result = await callBrowserRequest<{ cookies?: unknown[] }>(
parent,
{
method: "GET",
path: "/cookies",
query: {
targetId,
profile,
},
{ timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.writeJson(result.cookies ?? []);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
},
{ timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.writeJson(result);
return;
}
});
defaultRuntime.writeJson(result.cookies ?? []);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
cookies
.command("set")
@@ -77,7 +79,7 @@ export function registerBrowserCookiesAndStorageCommands(
.argument("<name>", "Cookie name")
.argument("<value>", "Cookie value")
.option("--url <url>", "Cookie URL scope (recommended)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (name: string, value: string, opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
@@ -106,7 +108,7 @@ export function registerBrowserCookiesAndStorageCommands(
cookies
.command("clear")
.description("Clear all cookies")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
@@ -134,7 +136,7 @@ export function registerBrowserCookiesAndStorageCommands(
.command("get")
.description(`Get ${kind}Storage (all keys or one key)`)
.argument("[key]", "Key (optional)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (key: string | undefined, opts, cmd2) => {
const parent = parentOpts(cmd2);
const profile = parent?.browserProfile;
@@ -169,7 +171,7 @@ export function registerBrowserCookiesAndStorageCommands(
.description(`Set a ${kind}Storage key`)
.argument("<key>", "Key")
.argument("<value>", "Value")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (key: string, value: string, opts, cmd2) => {
const parent = parentOpts(cmd2);
const profile = parent?.browserProfile;
@@ -193,7 +195,7 @@ export function registerBrowserCookiesAndStorageCommands(
cmd
.command("clear")
.description(`Clear all ${kind}Storage keys`)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd2) => {
const parent = parentOpts(cmd2);
const profile = parent?.browserProfile;

View File

@@ -7,6 +7,7 @@ import { ACT_MAX_VIEWPORT_DIMENSION } from "../browser/act-policy.js";
import { runCommandWithRuntime } from "../core-api.js";
import { runBrowserResizeWithOutput } from "./browser-cli-resize.js";
import {
BROWSER_TAB_REFERENCE_HELP,
callBrowserRequest,
parseBrowserPositiveIntegerValue,
type BrowserParentOpts,
@@ -96,7 +97,7 @@ export function registerBrowserStateCommands(
.description("Set viewport size (alias for resize)")
.argument("<width>", "Viewport width")
.argument("<height>", "Viewport height")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (widthRaw: string, heightRaw: string, opts, cmd) => {
const width = parsePositiveInteger(widthRaw, "width");
const height = parsePositiveInteger(heightRaw, "height");
@@ -122,7 +123,7 @@ export function registerBrowserStateCommands(
.command("offline")
.description("Toggle offline mode")
.argument("<on|off>", "on/off")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (value: string, opts, cmd) => {
const parent = parentOpts(cmd);
const offline = parseOnOff(value);
@@ -147,7 +148,7 @@ export function registerBrowserStateCommands(
.description("Set extra HTTP headers (JSON object)")
.argument("[headersJson]", "JSON object of headers (alternative to --headers-json)")
.option("--headers-json <json>", "JSON object of headers")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (headersJson: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserCommand(async () => {
@@ -194,7 +195,7 @@ export function registerBrowserStateCommands(
.option("--clear", "Clear credentials", false)
.argument("[username]", "Username")
.argument("[password]", "Password")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (username: string | undefined, password: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserSetRequest({
@@ -218,7 +219,7 @@ export function registerBrowserStateCommands(
.argument("[longitude]", "Longitude")
.option("--accuracy <m>", "Accuracy in meters")
.option("--origin <origin>", "Origin to grant permissions for")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(
async (latitudeRaw: string | undefined, longitudeRaw: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
@@ -252,7 +253,7 @@ export function registerBrowserStateCommands(
.command("media")
.description("Emulate prefers-color-scheme")
.argument("<dark|light|none>", "dark/light/none")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (value: string, opts, cmd) => {
const parent = parentOpts(cmd);
const v = normalizeOptionalLowercaseString(value);
@@ -278,7 +279,7 @@ export function registerBrowserStateCommands(
.command("timezone")
.description("Override timezone (CDP)")
.argument("<timezoneId>", "Timezone ID (e.g. America/New_York)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (timezoneId: string, opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserSetRequest({
@@ -296,7 +297,7 @@ export function registerBrowserStateCommands(
.command("locale")
.description("Override locale (CDP)")
.argument("<locale>", "Locale (e.g. en-US)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (locale: string, opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserSetRequest({
@@ -314,7 +315,7 @@ export function registerBrowserStateCommands(
.command("device")
.description('Apply a Playwright device descriptor (e.g. "iPhone 14")')
.argument("<name>", "Device name (Playwright devices)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (name: string, opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserSetRequest({

View File

@@ -53,8 +53,8 @@ const browserCommandGroupDefinitions: readonly BrowserCommandGroupDefinition[] =
command("tabs", "List open tabs"),
command("tab", "Tab shortcuts (index-based)"),
command("open", "Open a URL in a new tab"),
command("focus", "Focus a tab by target id, tab id, label, or unique target id prefix"),
command("close", "Close a tab (target id optional)"),
command("focus", "Focus a tab by tab reference"),
command("close", "Close a tab (tab reference optional)"),
command("profiles", "List all browser profiles"),
command("create-profile", "Create a new browser profile"),
command("delete-profile", "Delete a browser profile"),