From 8cb73844c8005b8db9008dcc69a33ce7b4d3482d Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:21:23 +0200 Subject: [PATCH] browser: route existing-session user profile through browser nodes (#68891) * browser: route user profile through browser nodes * browser: align existing-session node docs * browser: preserve host fallback on node discovery errors * browser: preserve configured node pin errors * browser: widen config mock in node pin test --- CHANGELOG.md | 1 + docs/gateway/configuration-reference.md | 3 +- docs/help/faq.md | 2 +- docs/tools/browser.md | 5 +- extensions/browser/src/browser-tool.test.ts | 120 +++++++++++++++++++- extensions/browser/src/browser-tool.ts | 34 +++--- src/config/schema.base.generated.ts | 8 +- src/config/schema.help.ts | 4 +- 8 files changed, 150 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c9c3b20e54..a13fe9db87e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Control UI/cron: keep the runtime-only `last` delivery sentinel from being materialized into persisted cron delivery and failure-alert channel configs when jobs are created or edited. (#68829) Thanks @tianhaocui. - OpenAI/Responses: strip orphaned reasoning blocks before outbound Responses API calls so compacted or restored histories no longer fail on standalone reasoning items. (#55787) Thanks @suboss87. - Cron/CLI: parse PowerShell-style `--tools` allow-lists the same way as comma-separated input, so `cron add` and `cron edit` no longer persist `exec read write` as one combined tool entry on Windows. (#68858) Thanks @chen-zhang-cs-code. +- Browser/user-profile: let existing-session `profile="user"` tool calls auto-route to a connected browser node or use explicit `target="node"`, while still honoring explicit `target="host"` pinning. (#48677) ## 2026.4.19-beta.2 diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 6a0355888d3..f2a28276944 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2967,7 +2967,8 @@ See [Plugins](/tools/plugin). - `profiles.*.cdpUrl` accepts `http://`, `https://`, `ws://`, and `wss://`. Use HTTP(S) when you want OpenClaw to discover `/json/version`; use WS(S) when your provider gives you a direct DevTools WebSocket URL. -- `existing-session` profiles are host-only and use Chrome MCP instead of CDP. +- `existing-session` profiles use Chrome MCP instead of CDP and can attach on + the selected host or through a connected browser node. - `existing-session` profiles can set `userDataDir` to target a specific Chromium-based browser profile such as Brave or Edge. - `existing-session` profiles keep the current Chrome MCP route limits: diff --git a/docs/help/faq.md b/docs/help/faq.md index 3a30285fca6..a5c51c5a9b3 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1258,7 +1258,7 @@ for usage/billing and raise limits as needed. openclaw browser --browser-profile chrome-live tabs ``` - This path is host-local. If the Gateway runs elsewhere, either run a node host on the browser machine or use remote CDP instead. + This path can use the local host browser or a connected browser node. If the Gateway runs elsewhere, either run a node host on the browser machine or use remote CDP instead. Current limits on `existing-session` / `user`: diff --git a/docs/tools/browser.md b/docs/tools/browser.md index f64991a595f..5c6bea6f4ad 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -532,8 +532,9 @@ Notes: - Existing-session dialog hooks do not support timeout overrides. - Some features still require the managed browser path, including batch actions, PDF export, download interception, and `responsebody`. -- Existing-session is host-local. If Chrome lives on a different machine or a - different network namespace, use remote CDP or a node host instead. +- Existing-session can attach on the selected host or through a connected + browser node. If Chrome lives elsewhere and no browser node is connected, use + remote CDP or a node host instead. ## Isolation guarantees diff --git a/extensions/browser/src/browser-tool.test.ts b/extensions/browser/src/browser-tool.test.ts index 8fc6f3025c8..89236d29edb 100644 --- a/extensions/browser/src/browser-tool.test.ts +++ b/extensions/browser/src/browser-tool.test.ts @@ -113,7 +113,12 @@ const gatewayMocks = vi.hoisted(() => ({ vi.mock("../../../src/agents/tools/gateway.js", () => gatewayMocks); const configMocks = vi.hoisted(() => ({ - loadConfig: vi.fn(() => ({ browser: {} })), + loadConfig: vi.fn< + () => { + browser: Record; + gateway?: { nodes?: { browser?: { node?: string } } }; + } + >(() => ({ browser: {} })), })); vi.mock("openclaw/plugin-sdk/config-runtime", async () => { const actual = await vi.importActual( @@ -340,7 +345,7 @@ describe("browser tool snapshot maxChars", () => { expect(opts?.mode).toBeUndefined(); }); - it("defaults to host when using profile=user (even in sandboxed sessions)", async () => { + it("keeps profile=user off the sandbox browser when no node is selected", async () => { setResolvedBrowserProfiles({ user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, }); @@ -360,7 +365,7 @@ describe("browser tool snapshot maxChars", () => { ); }); - it("defaults to host for custom existing-session profiles too", async () => { + it("keeps custom existing-session profiles off the sandbox browser too", async () => { setResolvedBrowserProfiles({ "chrome-live": { driver: "existing-session", attachOnly: true, color: "#00AA00" }, }); @@ -470,7 +475,7 @@ describe("browser tool snapshot maxChars", () => { expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled(); }); - it("keeps user profile on host when node proxy is available", async () => { + it("routes profile=user through the node proxy when one is available", async () => { mockSingleBrowserProxyNode(); setResolvedBrowserProfiles({ user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, @@ -478,6 +483,113 @@ describe("browser tool snapshot maxChars", () => { const tool = createBrowserTool(); await tool.execute?.("call-1", { action: "status", profile: "user" }); + expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( + "node.invoke", + { timeoutMs: 25000 }, + expect.objectContaining({ + nodeId: "node-1", + command: "browser.proxy", + params: expect.objectContaining({ + profile: "user", + path: "/", + method: "GET", + timeoutMs: 20000, + }), + }), + ); + expect(browserClientMocks.browserStatus).not.toHaveBeenCalled(); + }); + + it("falls back to the host for profile=user when node discovery errors", async () => { + nodesUtilsMocks.listNodes.mockRejectedValueOnce(new Error("gateway unavailable")); + setResolvedBrowserProfiles({ + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, + }); + const tool = createBrowserTool(); + await tool.execute?.("call-1", { action: "status", profile: "user" }); + + expect(browserClientMocks.browserStatus).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ profile: "user" }), + ); + expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled(); + }); + + it("preserves configured node pins when profile=user node discovery errors", async () => { + nodesUtilsMocks.listNodes.mockRejectedValueOnce(new Error("gateway unavailable")); + configMocks.loadConfig.mockReturnValue({ + browser: {}, + gateway: { nodes: { browser: { node: "node-1" } } }, + }); + setResolvedBrowserProfiles({ + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, + }); + const tool = createBrowserTool(); + + await expect(tool.execute?.("call-1", { action: "status", profile: "user" })).rejects.toThrow( + /gateway unavailable/i, + ); + + expect(browserClientMocks.browserStatus).not.toHaveBeenCalled(); + expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled(); + }); + + it('allows profile="user" with target="node"', async () => { + mockSingleBrowserProxyNode(); + setResolvedBrowserProfiles({ + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, + }); + const tool = createBrowserTool(); + await tool.execute?.("call-1", { action: "status", profile: "user", target: "node" }); + + expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( + "node.invoke", + { timeoutMs: 25000 }, + expect.objectContaining({ + nodeId: "node-1", + command: "browser.proxy", + params: expect.objectContaining({ + profile: "user", + path: "/", + method: "GET", + }), + }), + ); + expect(browserClientMocks.browserStatus).not.toHaveBeenCalled(); + }); + + it('allows profile="user" with an explicit node pin', async () => { + mockSingleBrowserProxyNode(); + setResolvedBrowserProfiles({ + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, + }); + const tool = createBrowserTool(); + await tool.execute?.("call-1", { action: "status", profile: "user", node: "node-1" }); + + expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( + "node.invoke", + { timeoutMs: 25000 }, + expect.objectContaining({ + nodeId: "node-1", + command: "browser.proxy", + params: expect.objectContaining({ + profile: "user", + path: "/", + method: "GET", + }), + }), + ); + expect(browserClientMocks.browserStatus).not.toHaveBeenCalled(); + }); + + it('keeps profile="user" on the host when target="host" is explicit', async () => { + mockSingleBrowserProxyNode(); + setResolvedBrowserProfiles({ + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, + }); + const tool = createBrowserTool(); + await tool.execute?.("call-1", { action: "status", profile: "user", target: "host" }); + expect(browserClientMocks.browserStatus).toHaveBeenCalledWith( undefined, expect.objectContaining({ profile: "user" }), diff --git a/extensions/browser/src/browser-tool.ts b/extensions/browser/src/browser-tool.ts index 0cc9ba04f02..04008089891 100644 --- a/extensions/browser/src/browser-tool.ts +++ b/extensions/browser/src/browser-tool.ts @@ -380,7 +380,7 @@ export function createBrowserTool(opts?: { description: [ "Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).", "Browser choice: omit profile by default for the isolated OpenClaw-managed browser (`openclaw`).", - 'For the logged-in user browser on the local host, use profile="user". A supported Chromium-based browser (v144+) must be running. Use only when existing logins/cookies matter and the user is present.', + 'For the logged-in user browser, use profile="user". A supported Chromium-based browser (v144+) must be running on the selected host or browser node. Use only when existing logins/cookies matter and the user is present.', 'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node= or target="node".', "When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).", 'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.', @@ -395,31 +395,39 @@ export function createBrowserTool(opts?: { const profile = readStringParam(params, "profile"); const requestedNode = readStringParam(params, "node"); let target = readStringParam(params, "target") as "sandbox" | "host" | "node" | undefined; + const configuredNode = browserToolDeps.loadConfig().gateway?.nodes?.browser?.node?.trim(); if (requestedNode && target && target !== "node") { throw new Error('node is only supported with target="node".'); } - // User-browser profiles (existing-session) are host-only. + // existing-session profiles can attach through the selected host or browser node, + // but they must never fall back into the sandbox browser. const isUserBrowserProfile = shouldPreferHostForProfile(profile); if (isUserBrowserProfile) { - if (requestedNode || target === "node") { - throw new Error(`profile="${profile}" only supports the local host browser.`); - } if (target === "sandbox") { throw new Error( `profile="${profile}" cannot use the sandbox browser; use target="host" or omit target.`, ); } - if (!target && !requestedNode) { - target = "host"; - } } - const nodeTarget = await resolveBrowserNodeTarget({ - requestedNode: requestedNode ?? undefined, - target, - sandboxBridgeUrl: opts?.sandboxBridgeUrl, - }); + let nodeTarget: BrowserNodeTarget | null = null; + try { + nodeTarget = await resolveBrowserNodeTarget({ + requestedNode: requestedNode ?? undefined, + target, + sandboxBridgeUrl: opts?.sandboxBridgeUrl, + }); + } catch (error) { + // Keep the logged-in user browser usable on the host when auto-discovery + // of browser nodes fails transiently. Explicit node requests still fail. + if (!(isUserBrowserProfile && !target && !requestedNode && !configuredNode)) { + throw error; + } + } + if (isUserBrowserProfile && !target && !requestedNode && !nodeTarget) { + target = "host"; + } const resolvedTarget = target === "node" ? undefined : target; const baseUrl = nodeTarget diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 43999c32eaf..fe89ba1a930 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -671,7 +671,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { type: "string", title: "Browser Profile User Data Dir", description: - "Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for host-local Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory.", + "Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory on the selected host or browser node.", }, driver: { anyOf: [ @@ -690,7 +690,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { ], title: "Browser Profile Driver", description: - 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for host-local Chrome DevTools MCP attachment.', + 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for Chrome DevTools MCP attachment on the selected host or browser node.', }, attachOnly: { type: "boolean", @@ -23583,12 +23583,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, "browser.profiles.*.userDataDir": { label: "Browser Profile User Data Dir", - help: "Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for host-local Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory.", + help: "Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory on the selected host or browser node.", tags: ["storage"], }, "browser.profiles.*.driver": { label: "Browser Profile Driver", - help: 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for host-local Chrome DevTools MCP attachment.', + help: 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for Chrome DevTools MCP attachment on the selected host or browser node.', tags: ["storage"], }, "browser.profiles.*.attachOnly": { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 6e840fac318..786e204a701 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -279,9 +279,9 @@ export const FIELD_HELP: Record = { "browser.profiles.*.cdpUrl": "Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.", "browser.profiles.*.userDataDir": - "Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for host-local Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory.", + "Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory on the selected host or browser node.", "browser.profiles.*.driver": - 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for host-local Chrome DevTools MCP attachment.', + 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for Chrome DevTools MCP attachment on the selected host or browser node.', "browser.profiles.*.attachOnly": "Per-profile attach-only override that skips local browser launch and only attaches to an existing CDP endpoint. Useful when one profile is externally managed but others are locally launched.", "browser.profiles.*.color":