diff --git a/docs/cli/browser.md b/docs/cli/browser.md index d1de7e444216..31cf787c65e6 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -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 diff --git a/docs/tools/browser-control.md b/docs/tools/browser-control.md index 164cb2ddc7ea..86dc71285704 100644 --- a/docs/tools/browser-control.md +++ b/docs/tools/browser-control.md @@ -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 ` diff --git a/extensions/browser/skills/browser-automation/SKILL.md b/extensions/browser/skills/browser-automation/SKILL.md index 506d8c215ea4..b0516b4e3336 100644 --- a/extensions/browser/skills/browser-automation/SKILL.md +++ b/extensions/browser/skills/browser-automation/SKILL.md @@ -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. diff --git a/extensions/browser/src/browser-tool.schema.test.ts b/extensions/browser/src/browser-tool.schema.test.ts index 9ba9abad8f46..4b1c76354884 100644 --- a/extensions/browser/src/browser-tool.schema.test.ts +++ b/extensions/browser/src/browser-tool.schema.test.ts @@ -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; +type SchemaProperty = { + description?: string; + maximum?: number; + properties?: SchemaRecord; +}; +type BrowserSchemaRecord = Record; 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); + }); }); diff --git a/extensions/browser/src/browser-tool.schema.ts b/extensions/browser/src/browser-tool.schema.ts index 7c42978857e6..e85a843012c3 100644 --- a/extensions/browser/src/browser-tool.schema.ts +++ b/extensions/browser/src/browser-tool.schema.ts @@ -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(), diff --git a/extensions/browser/src/browser/routes/tabs.test.ts b/extensions/browser/src/browser/routes/tabs.test.ts index eb189b51e17e..1b60fc65f1bf 100644 --- a/extensions/browser/src/browser/routes/tabs.test.ts +++ b/extensions/browser/src/browser/routes/tabs.test.ts @@ -16,6 +16,9 @@ const { registerBrowserTabRoutes } = await import("./tabs.js"); type ProfileContext = ReturnType; 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"), diff --git a/extensions/browser/src/browser/server-context.tab-selection-state.test.ts b/extensions/browser/src/browser/server-context.tab-selection-state.test.ts index 8231bcb6af4c..ce427443e63f 100644 --- a/extensions/browser/src/browser/server-context.tab-selection-state.test.ts +++ b/extensions/browser/src/browser/server-context.tab-selection-state.test.ts @@ -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); + }); }); diff --git a/extensions/browser/src/browser/target-id.test.ts b/extensions/browser/src/browser/target-id.test.ts new file mode 100644 index 000000000000..5a655f9d52c4 --- /dev/null +++ b/extensions/browser/src/browser/target-id.test.ts @@ -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"], + }); + }); +}); diff --git a/extensions/browser/src/cli/browser-cli-actions-input/register.element.ts b/extensions/browser/src/cli/browser-cli-actions-input/register.element.ts index d9727c0e8b94..63d06e84148b 100644 --- a/extensions/browser/src/cli/browser-cli-actions-input/register.element.ts +++ b/extensions/browser/src/cli/browser-cli-actions-input/register.element.ts @@ -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 id from snapshot") - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-id ", BROWSER_TAB_REFERENCE_HELP) .option("--double", "Double click", false) .option("--button ", "Mouse button to use") .option("--modifiers ", "Comma-separated modifiers (Shift,Alt,Meta)") @@ -103,7 +104,7 @@ export function registerBrowserElementCommands( .description("Click viewport coordinates") .argument("", "Viewport x coordinate") .argument("", "Viewport y coordinate") - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-id ", BROWSER_TAB_REFERENCE_HELP) .option("--double", "Double click", false) .option("--button ", "Mouse button to use") .option("--delay-ms ", "Delay between mouse down/up", (v: string) => @@ -141,7 +142,7 @@ export function registerBrowserElementCommands( .argument("", "Text to type") .option("--submit", "Press Enter after typing", false) .option("--slowly", "Type slowly (human-like)", false) - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-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 to press (e.g. Enter)") - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-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 id from snapshot") - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-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 id from snapshot") - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-id ", BROWSER_TAB_REFERENCE_HELP) .option("--timeout-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("", "Start ref id") .argument("", "End ref id") - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-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 id from snapshot") .argument("", "Option values to select") - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-id ", BROWSER_TAB_REFERENCE_HELP) .action(async (ref: string, values: string[], opts, cmd) => { await runElementAction({ cmd, diff --git a/extensions/browser/src/cli/browser-cli-actions-input/register.files-downloads.ts b/extensions/browser/src/cli/browser-cli-actions-input/register.files-downloads.ts index b6297807fd6a..cfef0bb22a6b 100644 --- a/extensions/browser/src/cli/browser-cli-actions-input/register.files-downloads.ts +++ b/extensions/browser/src/cli/browser-cli-actions-input/register.files-downloads.ts @@ -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 id from snapshot to click after arming") .option("--input-ref ", "Ref id for to set directly") .option("--element ", "CSS selector for ") - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-id ", BROWSER_TAB_REFERENCE_HELP) .option( "--timeout-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 ", "CDP target id (or unique prefix)") + .option("--target-id ", BROWSER_TAB_REFERENCE_HELP) .option( "--timeout-ms ", "How long to wait for the next download (default: 120000)", @@ -157,7 +158,7 @@ export function registerBrowserFilesAndDownloadsCommands( "", "Save path within openclaw temp downloads dir (e.g. report.pdf or /tmp/openclaw/downloads/report.pdf)", ) - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-id ", BROWSER_TAB_REFERENCE_HELP) .option( "--timeout-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 ", "Prompt response text") .option("--dialog-id ", "Pending dialog id from snapshot/browser state") - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-id ", BROWSER_TAB_REFERENCE_HELP) .option( "--timeout-ms ", "How long to wait for the next dialog (default: 120000)", diff --git a/extensions/browser/src/cli/browser-cli-actions-input/register.form-wait-eval.ts b/extensions/browser/src/cli/browser-cli-actions-input/register.form-wait-eval.ts index 09766f38734d..ed3da64babdf 100644 --- a/extensions/browser/src/cli/browser-cli-actions-input/register.form-wait-eval.ts +++ b/extensions/browser/src/cli/browser-cli-actions-input/register.form-wait-eval.ts @@ -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 array of field objects") .option("--fields-file ", "Read JSON array from a file") - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-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 ", "CDP target id (or unique prefix)") + .option("--target-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 ", "CDP target id (or unique prefix)") + .option("--target-id ", BROWSER_TAB_REFERENCE_HELP) .action(async (opts, cmd) => { const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts); if (!opts.fn) { diff --git a/extensions/browser/src/cli/browser-cli-actions-input/register.navigation.ts b/extensions/browser/src/cli/browser-cli-actions-input/register.navigation.ts index d504bc6cec99..73d027ff4657 100644 --- a/extensions/browser/src/cli/browser-cli-actions-input/register.navigation.ts +++ b/extensions/browser/src/cli/browser-cli-actions-input/register.navigation.ts @@ -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 to navigate to") - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-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("", "Viewport width") .argument("", "Viewport height") - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-id ", BROWSER_TAB_REFERENCE_HELP) .action(async (width: string, height: string, opts, cmd) => { const normalizedWidth = parsePositiveInteger(width, "width"); const normalizedHeight = parsePositiveInteger(height, "height"); diff --git a/extensions/browser/src/cli/browser-cli-actions-observe.ts b/extensions/browser/src/cli/browser-cli-actions-observe.ts index 6afc60361f48..e4c06879c468 100644 --- a/extensions/browser/src/cli/browser-cli-actions-observe.ts +++ b/extensions/browser/src/cli/browser-cli-actions-observe.ts @@ -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 ", "Filter by level (error, warn, info)") - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-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 ", "CDP target id (or unique prefix)") + .option("--target-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 (exact, substring, or glob like **/api)") - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-id ", BROWSER_TAB_REFERENCE_HELP) .option( "--timeout-ms ", "How long to wait for the response (default: 20000)", diff --git a/extensions/browser/src/cli/browser-cli-debug.ts b/extensions/browser/src/cli/browser-cli-debug.ts index 09cd2865a3a8..e7b9d0c5a0b5 100644 --- a/extensions/browser/src/cli/browser-cli-debug.ts +++ b/extensions/browser/src/cli/browser-cli-debug.ts @@ -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 id from snapshot") - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-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 ", "CDP target id (or unique prefix)") + .option("--target-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 ", "Only show URLs that contain this substring") .option("--clear", "Clear stored requests after reading", false) - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-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 ", "CDP target id (or unique prefix)") + .option("--target-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 ", "Output path within openclaw temp dir (e.g. trace.zip or /tmp/openclaw/trace.zip)", ) - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-id ", BROWSER_TAB_REFERENCE_HELP) .action(async (opts, cmd) => { await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => { const result = await callDebugRequest<{ path: string }>(parent, { diff --git a/extensions/browser/src/cli/browser-cli-inspect.ts b/extensions/browser/src/cli/browser-cli-inspect.ts index f230080473d3..8ad81ab67a15 100644 --- a/extensions/browser/src/cli/browser-cli-inspect.ts +++ b/extensions/browser/src/cli/browser-cli-inspect.ts @@ -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 ", "ARIA ref from ai snapshot") .option("--element ", "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 ", "Snapshot format (default: ai)", "ai") - .option("--target-id ", "CDP target id (or unique prefix)") + .option("--target-id ", BROWSER_TAB_REFERENCE_HELP) .option("--limit ", "Max nodes (default: 500/800)") .option("--mode ", "Snapshot preset (efficient)") .option("--efficient", "Use the efficient snapshot preset", false) diff --git a/extensions/browser/src/cli/browser-cli-manage.test.ts b/extensions/browser/src/cli/browser-cli-manage.test.ts index 002bdf50162e..80dfbaac2d9f 100644 --- a/extensions/browser/src/cli/browser-cli-manage.test.ts +++ b/extensions/browser/src/cli/browser-cli-manage.test.ts @@ -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"); }); }); diff --git a/extensions/browser/src/cli/browser-cli-manage.ts b/extensions/browser/src/cli/browser-cli-manage.ts index b620e25965f6..e138b78b96ca 100644 --- a/extensions/browser/src/cli/browser-cli-manage.ts +++ b/extensions/browser/src/cli/browser-cli-manage.ts @@ -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("", "Target id, tab id, label, or unique target id prefix") + .argument("", BROWSER_TAB_REFERENCE_HELP) .argument("