From 7deb543624e5b4f76ecb44678a88a54acf4e8de3 Mon Sep 17 00:00:00 2001 From: Radek Sienkiewicz Date: Mon, 16 Mar 2026 14:21:22 +0100 Subject: [PATCH] Browser: support non-Chrome existing-session profiles via userDataDir (#48170) Merged via squash. Prepared head SHA: e490035a24a3a7f0c17f681250b7ffe2b0dcd3d3 Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Reviewed-by: @velvet-shark --- CHANGELOG.md | 1 + docs/cli/browser.md | 1 + docs/gateway/configuration-reference.md | 8 + docs/gateway/doctor.md | 16 +- docs/tools/browser.md | 75 ++++-- src/agents/tools/browser-tool.actions.ts | 2 +- src/agents/tools/browser-tool.ts | 2 +- src/browser/chrome-mcp.test.ts | 40 ++++ src/browser/chrome-mcp.ts | 220 +++++++++++++----- src/browser/client.ts | 3 + src/browser/config.test.ts | 22 ++ src/browser/config.ts | 3 + src/browser/profiles-service.test.ts | 46 ++++ src/browser/profiles-service.ts | 18 ++ src/browser/resolved-config-refresh.ts | 3 + src/browser/routes/agent.act.hooks.ts | 2 + src/browser/routes/agent.act.ts | 30 ++- src/browser/routes/agent.snapshot.ts | 10 + .../routes/basic.existing-session.test.ts | 3 + src/browser/routes/basic.ts | 4 +- src/browser/server-context.availability.ts | 10 +- .../server-context.existing-session.test.ts | 22 +- src/browser/server-context.selection.ts | 4 +- src/browser/server-context.tab-ops.ts | 4 +- ...s-open-profile-unknown-returns-404.test.ts | 38 +++ src/cli/browser-cli-manage.test.ts | 37 +++ src/cli/browser-cli-manage.ts | 27 ++- src/commands/doctor-browser.test.ts | 29 ++- src/commands/doctor-browser.ts | 82 +++++-- src/config/schema.help.quality.test.ts | 1 + src/config/schema.help.ts | 4 +- src/config/schema.labels.ts | 1 + src/config/types.browser.ts | 2 + src/config/zod-schema.ts | 6 +- 34 files changed, 650 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c2f0cc487a..090f6046334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. - Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. +- Browser/existing-session: support `browser.profiles..userDataDir` so Chrome DevTools MCP can attach to Brave, Edge, and other Chromium-based browsers through their own user data directories. (#48170) thanks @velvet-shark. ### Breaking diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 42af08f84f3..c5cb5ab9984 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -91,6 +91,7 @@ Use the built-in `user` profile, or create your own `existing-session` profile: ```bash openclaw browser --browser-profile user tabs openclaw browser create-profile --name chrome-live --driver existing-session +openclaw browser create-profile --name brave-live --driver existing-session --user-data-dir "~/Library/Application Support/BraveSoftware/Brave-Browser" openclaw browser --browser-profile chrome-live tabs ``` diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index a46f342a360..170c0a94219 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2443,6 +2443,12 @@ See [Plugins](/tools/plugin). openclaw: { cdpPort: 18800, color: "#FF4500" }, work: { cdpPort: 18801, color: "#0066CC" }, user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, + brave: { + driver: "existing-session", + attachOnly: true, + userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + }, remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }, }, color: "#FF4500", @@ -2463,6 +2469,8 @@ See [Plugins](/tools/plugin). - In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions. - Remote profiles are attach-only (start/stop/reset disabled). - `existing-session` profiles are host-only and use Chrome MCP instead of CDP. +- `existing-session` profiles can set `userDataDir` to target a specific + Chromium-based browser profile such as Brave or Edge. - Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary. - Control service: loopback only (port derived from `gateway.port`, default `18791`). - `extraArgs` appends extra launch flags to local Chromium startup (for example diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 6c0711c7aea..78430476051 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -155,18 +155,20 @@ normalizes it to the current host-local Chrome MCP attach model: Doctor also audits the host-local Chrome MCP path when you use `defaultProfile: "user"` or a configured `existing-session` profile: -- checks whether Google Chrome is installed on the same host +- checks whether Google Chrome is installed on the same host for default + auto-connect profiles - checks the detected Chrome version and warns when it is below Chrome 144 -- reminds you to enable remote debugging in Chrome at - `chrome://inspect/#remote-debugging` +- reminds you to enable remote debugging in the browser inspect page (for + example `chrome://inspect/#remote-debugging`, `brave://inspect/#remote-debugging`, + or `edge://inspect/#remote-debugging`) Doctor cannot enable the Chrome-side setting for you. Host-local Chrome MCP still requires: -- Google Chrome 144+ on the gateway/node host -- Chrome running locally -- remote debugging enabled in Chrome -- approving the first attach consent prompt in Chrome +- a Chromium-based browser 144+ on the gateway/node host +- the browser running locally +- remote debugging enabled in that browser +- approving the first attach consent prompt in the browser This check does **not** apply to Docker, sandbox, remote-browser, or other headless flows. Those continue to use raw CDP. diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 19ee23a25ca..0b8f89bc3d8 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -88,6 +88,12 @@ Browser settings live in `~/.openclaw/openclaw.json`. attachOnly: true, color: "#00AA00", }, + brave: { + driver: "existing-session", + attachOnly: true, + userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + }, remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }, }, }, @@ -114,6 +120,8 @@ Notes: - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP. - `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. Do not set `cdpUrl` for that driver. +- Set `browser.profiles..userDataDir` when an existing-session profile + should attach to a non-default Chromium user profile such as Brave or Edge. ## Use Brave (or another Chromium-based browser) @@ -289,11 +297,11 @@ Defaults: All control endpoints accept `?profile=`; the CLI uses `--browser-profile`. -## Chrome existing-session via MCP +## Existing-session via Chrome DevTools MCP -OpenClaw can also attach to a running Chrome profile through the official -Chrome DevTools MCP server. This reuses the tabs and login state already open in -that Chrome profile. +OpenClaw can also attach to a running Chromium-based browser profile through the +official Chrome DevTools MCP server. This reuses the tabs and login state +already open in that browser profile. Official background and setup references: @@ -305,13 +313,41 @@ Built-in profile: - `user` Optional: create your own custom existing-session profile if you want a -different name or color. +different name, color, or browser data directory. -Then in Chrome: +Default behavior: -1. Open `chrome://inspect/#remote-debugging` -2. Enable remote debugging -3. Keep Chrome running and approve the connection prompt when OpenClaw attaches +- The built-in `user` profile uses Chrome MCP auto-connect, which targets the + default local Google Chrome profile. + +Use `userDataDir` for Brave, Edge, Chromium, or a non-default Chrome profile: + +```json5 +{ + browser: { + profiles: { + brave: { + driver: "existing-session", + attachOnly: true, + userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + }, + }, + }, +} +``` + +Then in the matching browser: + +1. Open that browser's inspect page for remote debugging. +2. Enable remote debugging. +3. Keep the browser running and approve the connection prompt when OpenClaw attaches. + +Common inspect pages: + +- Chrome: `chrome://inspect/#remote-debugging` +- Brave: `brave://inspect/#remote-debugging` +- Edge: `edge://inspect/#remote-debugging` Live attach smoke test: @@ -327,17 +363,17 @@ What success looks like: - `status` shows `driver: existing-session` - `status` shows `transport: chrome-mcp` - `status` shows `running: true` -- `tabs` lists your already-open Chrome tabs +- `tabs` lists your already-open browser tabs - `snapshot` returns refs from the selected live tab What to check if attach does not work: -- Chrome is version `144+` -- remote debugging is enabled at `chrome://inspect/#remote-debugging` -- Chrome showed and you accepted the attach consent prompt +- the target Chromium-based browser is version `144+` +- remote debugging is enabled in that browser's inspect page +- the browser showed and you accepted the attach consent prompt - `openclaw doctor` migrates old extension-based browser config and checks that - Chrome is installed locally with a compatible version, but it cannot enable - Chrome-side remote debugging for you + Chrome is installed locally for default auto-connect profiles, but it cannot + enable browser-side remote debugging for you Agent use: @@ -351,10 +387,11 @@ Notes: - This path is higher-risk than the isolated `openclaw` profile because it can act inside your signed-in browser session. -- OpenClaw does not launch Chrome for this driver; it attaches to an existing - session only. -- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not - the legacy default-profile remote debugging port workflow. +- OpenClaw does not launch the browser for this driver; it attaches to an + existing session only. +- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here. If + `userDataDir` is set, OpenClaw passes it through to target that explicit + Chromium user data directory. - Existing-session screenshots support page captures and `--ref` element captures from snapshots, but not CSS `--element` selectors. - Existing-session `wait --url` supports exact, substring, and glob patterns diff --git a/src/agents/tools/browser-tool.actions.ts b/src/agents/tools/browser-tool.actions.ts index dc78557eab2..12cb54e323d 100644 --- a/src/agents/tools/browser-tool.actions.ts +++ b/src/agents/tools/browser-tool.actions.ts @@ -347,7 +347,7 @@ export async function executeActAction(params: { } if (!tabs.length) { throw new Error( - `No Chrome tabs found for profile="${profile}". Make sure Chrome (v144+) is running and has open tabs, then retry.`, + `No browser tabs found for profile="${profile}". Make sure the configured Chromium-based browser (v144+) is running and has open tabs, then retry.`, { cause: err }, ); } diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index c0111ab9977..f16e7e5d969 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -307,7 +307,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". Chrome (v144+) must be running. Use only when existing logins/cookies matter and the user is present.', + '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.', '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.', diff --git a/src/browser/chrome-mcp.test.ts b/src/browser/chrome-mcp.test.ts index a77149d7a72..03204cf3b87 100644 --- a/src/browser/chrome-mcp.test.ts +++ b/src/browser/chrome-mcp.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { + buildChromeMcpArgs, evaluateChromeMcpScript, listChromeMcpTabs, openChromeMcpTab, @@ -103,6 +104,18 @@ describe("chrome MCP page parsing", () => { ]); }); + it("adds --userDataDir when an explicit Chromium profile path is configured", () => { + expect(buildChromeMcpArgs("/tmp/brave-profile")).toEqual([ + "-y", + "chrome-devtools-mcp@latest", + "--autoConnect", + "--experimentalStructuredContent", + "--experimental-page-id-routing", + "--userDataDir", + "/tmp/brave-profile", + ]); + }); + it("parses new_page text responses and returns the created tab", async () => { const factory: ChromeMcpSessionFactory = async () => createFakeSession(); setChromeMcpSessionFactoryForTest(factory); @@ -250,6 +263,33 @@ describe("chrome MCP page parsing", () => { expect(tabs).toHaveLength(2); }); + it("creates a fresh session when userDataDir changes for the same profile", async () => { + const createdSessions: ChromeMcpSession[] = []; + const closeMocks: Array> = []; + const factoryCalls: Array<{ profileName: string; userDataDir?: string }> = []; + const factory: ChromeMcpSessionFactory = async (profileName, userDataDir) => { + factoryCalls.push({ profileName, userDataDir }); + const session = createFakeSession(); + const closeMock = vi.fn().mockResolvedValue(undefined); + session.client.close = closeMock as typeof session.client.close; + createdSessions.push(session); + closeMocks.push(closeMock); + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + await listChromeMcpTabs("chrome-live", "/tmp/brave-a"); + await listChromeMcpTabs("chrome-live", "/tmp/brave-b"); + + expect(factoryCalls).toEqual([ + { profileName: "chrome-live", userDataDir: "/tmp/brave-a" }, + { profileName: "chrome-live", userDataDir: "/tmp/brave-b" }, + ]); + expect(createdSessions).toHaveLength(2); + expect(closeMocks[0]).toHaveBeenCalledTimes(1); + expect(closeMocks[1]).not.toHaveBeenCalled(); + }); + it("clears failed pending sessions so the next call can retry", async () => { let factoryCalls = 0; const factory: ChromeMcpSessionFactory = async () => { diff --git a/src/browser/chrome-mcp.ts b/src/browser/chrome-mcp.ts index a673feb2c27..bc724d2eaea 100644 --- a/src/browser/chrome-mcp.ts +++ b/src/browser/chrome-mcp.ts @@ -26,7 +26,10 @@ type ChromeMcpSession = { ready: Promise; }; -type ChromeMcpSessionFactory = (profileName: string) => Promise; +type ChromeMcpSessionFactory = ( + profileName: string, + userDataDir?: string, +) => Promise; const DEFAULT_CHROME_MCP_COMMAND = "npx"; const DEFAULT_CHROME_MCP_ARGS = [ @@ -168,10 +171,62 @@ function extractJsonMessage(result: ChromeMcpToolResult): unknown { return null; } -async function createRealSession(profileName: string): Promise { +function normalizeChromeMcpUserDataDir(userDataDir?: string): string | undefined { + const trimmed = userDataDir?.trim(); + return trimmed ? trimmed : undefined; +} + +function buildChromeMcpSessionCacheKey(profileName: string, userDataDir?: string): string { + return JSON.stringify([profileName, normalizeChromeMcpUserDataDir(userDataDir) ?? ""]); +} + +function cacheKeyMatchesProfileName(cacheKey: string, profileName: string): boolean { + try { + const parsed = JSON.parse(cacheKey); + return Array.isArray(parsed) && parsed[0] === profileName; + } catch { + return false; + } +} + +async function closeChromeMcpSessionsForProfile( + profileName: string, + keepKey?: string, +): Promise { + let closed = false; + + for (const key of Array.from(pendingSessions.keys())) { + if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) { + pendingSessions.delete(key); + closed = true; + } + } + + for (const [key, session] of Array.from(sessions.entries())) { + if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) { + sessions.delete(key); + closed = true; + await session.client.close().catch(() => {}); + } + } + + return closed; +} + +export function buildChromeMcpArgs(userDataDir?: string): string[] { + const normalizedUserDataDir = normalizeChromeMcpUserDataDir(userDataDir); + return normalizedUserDataDir + ? [...DEFAULT_CHROME_MCP_ARGS, "--userDataDir", normalizedUserDataDir] + : [...DEFAULT_CHROME_MCP_ARGS]; +} + +async function createRealSession( + profileName: string, + userDataDir?: string, +): Promise { const transport = new StdioClientTransport({ command: DEFAULT_CHROME_MCP_COMMAND, - args: DEFAULT_CHROME_MCP_ARGS, + args: buildChromeMcpArgs(userDataDir), stderr: "pipe", }); const client = new Client( @@ -191,9 +246,12 @@ async function createRealSession(profileName: string): Promise } } catch (err) { await client.close().catch(() => {}); + const targetLabel = userDataDir + ? `the configured Chromium user data dir (${userDataDir})` + : "Google Chrome's default profile"; throw new BrowserProfileUnavailableError( `Chrome MCP existing-session attach failed for profile "${profileName}". ` + - `Make sure Chrome (v144+) is running. ` + + `Make sure ${targetLabel} is running locally with remote debugging enabled. ` + `Details: ${String(err)}`, ); } @@ -206,27 +264,34 @@ async function createRealSession(profileName: string): Promise }; } -async function getSession(profileName: string): Promise { - let session = sessions.get(profileName); +async function getSession(profileName: string, userDataDir?: string): Promise { + const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir); + await closeChromeMcpSessionsForProfile(profileName, cacheKey); + + let session = sessions.get(cacheKey); if (session && session.transport.pid === null) { - sessions.delete(profileName); + sessions.delete(cacheKey); session = undefined; } if (!session) { - let pending = pendingSessions.get(profileName); + let pending = pendingSessions.get(cacheKey); if (!pending) { pending = (async () => { - const created = await (sessionFactory ?? createRealSession)(profileName); - sessions.set(profileName, created); + const created = await (sessionFactory ?? createRealSession)(profileName, userDataDir); + if (pendingSessions.get(cacheKey) === pending) { + sessions.set(cacheKey, created); + } else { + await created.client.close().catch(() => {}); + } return created; })(); - pendingSessions.set(profileName, pending); + pendingSessions.set(cacheKey, pending); } try { session = await pending; } finally { - if (pendingSessions.get(profileName) === pending) { - pendingSessions.delete(profileName); + if (pendingSessions.get(cacheKey) === pending) { + pendingSessions.delete(cacheKey); } } } @@ -234,9 +299,9 @@ async function getSession(profileName: string): Promise { await session.ready; return session; } catch (err) { - const current = sessions.get(profileName); + const current = sessions.get(cacheKey); if (current?.transport === session.transport) { - sessions.delete(profileName); + sessions.delete(cacheKey); } throw err; } @@ -244,10 +309,12 @@ async function getSession(profileName: string): Promise { async function callTool( profileName: string, + userDataDir: string | undefined, name: string, args: Record = {}, ): Promise { - const session = await getSession(profileName); + const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir); + const session = await getSession(profileName, userDataDir); let result: ChromeMcpToolResult; try { result = (await session.client.callTool({ @@ -256,7 +323,7 @@ async function callTool( })) as ChromeMcpToolResult; } catch (err) { // Transport/connection error — tear down session so it reconnects on next call - sessions.delete(profileName); + sessions.delete(cacheKey); await session.client.close().catch(() => {}); throw err; } @@ -278,8 +345,12 @@ async function withTempFile(fn: (filePath: string) => Promise): Promise } } -async function findPageById(profileName: string, pageId: number): Promise { - const pages = await listChromeMcpPages(profileName); +async function findPageById( + profileName: string, + pageId: number, + userDataDir?: string, +): Promise { + const pages = await listChromeMcpPages(profileName, userDataDir); const page = pages.find((entry) => entry.id === pageId); if (!page) { throw new BrowserTabNotFoundError(); @@ -287,43 +358,54 @@ async function findPageById(profileName: string, pageId: number): Promise { - await getSession(profileName); +export async function ensureChromeMcpAvailable( + profileName: string, + userDataDir?: string, +): Promise { + await getSession(profileName, userDataDir); } export function getChromeMcpPid(profileName: string): number | null { - return sessions.get(profileName)?.transport.pid ?? null; + for (const [key, session] of sessions.entries()) { + if (cacheKeyMatchesProfileName(key, profileName)) { + return session.transport.pid ?? null; + } + } + return null; } export async function closeChromeMcpSession(profileName: string): Promise { - pendingSessions.delete(profileName); - const session = sessions.get(profileName); - if (!session) { - return false; - } - sessions.delete(profileName); - await session.client.close().catch(() => {}); - return true; + return await closeChromeMcpSessionsForProfile(profileName); } export async function stopAllChromeMcpSessions(): Promise { - const names = [...sessions.keys()]; + const names = [...new Set([...sessions.keys()].map((key) => JSON.parse(key)[0] as string))]; for (const name of names) { await closeChromeMcpSession(name).catch(() => {}); } } -export async function listChromeMcpPages(profileName: string): Promise { - const result = await callTool(profileName, "list_pages"); +export async function listChromeMcpPages( + profileName: string, + userDataDir?: string, +): Promise { + const result = await callTool(profileName, userDataDir, "list_pages"); return extractStructuredPages(result); } -export async function listChromeMcpTabs(profileName: string): Promise { - return toBrowserTabs(await listChromeMcpPages(profileName)); +export async function listChromeMcpTabs( + profileName: string, + userDataDir?: string, +): Promise { + return toBrowserTabs(await listChromeMcpPages(profileName, userDataDir)); } -export async function openChromeMcpTab(profileName: string, url: string): Promise { - const result = await callTool(profileName, "new_page", { url }); +export async function openChromeMcpTab( + profileName: string, + url: string, + userDataDir?: string, +): Promise { + const result = await callTool(profileName, userDataDir, "new_page", { url }); const pages = extractStructuredPages(result); const chosen = pages.find((page) => page.selected) ?? pages.at(-1); if (!chosen) { @@ -337,38 +419,52 @@ export async function openChromeMcpTab(profileName: string, url: string): Promis }; } -export async function focusChromeMcpTab(profileName: string, targetId: string): Promise { - await callTool(profileName, "select_page", { +export async function focusChromeMcpTab( + profileName: string, + targetId: string, + userDataDir?: string, +): Promise { + await callTool(profileName, userDataDir, "select_page", { pageId: parsePageId(targetId), bringToFront: true, }); } -export async function closeChromeMcpTab(profileName: string, targetId: string): Promise { - await callTool(profileName, "close_page", { pageId: parsePageId(targetId) }); +export async function closeChromeMcpTab( + profileName: string, + targetId: string, + userDataDir?: string, +): Promise { + await callTool(profileName, userDataDir, "close_page", { pageId: parsePageId(targetId) }); } export async function navigateChromeMcpPage(params: { profileName: string; + userDataDir?: string; targetId: string; url: string; timeoutMs?: number; }): Promise<{ url: string }> { - await callTool(params.profileName, "navigate_page", { + await callTool(params.profileName, params.userDataDir, "navigate_page", { pageId: parsePageId(params.targetId), type: "url", url: params.url, ...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}), }); - const page = await findPageById(params.profileName, parsePageId(params.targetId)); + const page = await findPageById( + params.profileName, + parsePageId(params.targetId), + params.userDataDir, + ); return { url: page.url ?? params.url }; } export async function takeChromeMcpSnapshot(params: { profileName: string; + userDataDir?: string; targetId: string; }): Promise { - const result = await callTool(params.profileName, "take_snapshot", { + const result = await callTool(params.profileName, params.userDataDir, "take_snapshot", { pageId: parsePageId(params.targetId), }); return extractSnapshot(result); @@ -376,13 +472,14 @@ export async function takeChromeMcpSnapshot(params: { export async function takeChromeMcpScreenshot(params: { profileName: string; + userDataDir?: string; targetId: string; uid?: string; fullPage?: boolean; format?: "png" | "jpeg"; }): Promise { return await withTempFile(async (filePath) => { - await callTool(params.profileName, "take_screenshot", { + await callTool(params.profileName, params.userDataDir, "take_screenshot", { pageId: parsePageId(params.targetId), filePath, format: params.format ?? "png", @@ -395,11 +492,12 @@ export async function takeChromeMcpScreenshot(params: { export async function clickChromeMcpElement(params: { profileName: string; + userDataDir?: string; targetId: string; uid: string; doubleClick?: boolean; }): Promise { - await callTool(params.profileName, "click", { + await callTool(params.profileName, params.userDataDir, "click", { pageId: parsePageId(params.targetId), uid: params.uid, ...(params.doubleClick ? { dblClick: true } : {}), @@ -408,11 +506,12 @@ export async function clickChromeMcpElement(params: { export async function fillChromeMcpElement(params: { profileName: string; + userDataDir?: string; targetId: string; uid: string; value: string; }): Promise { - await callTool(params.profileName, "fill", { + await callTool(params.profileName, params.userDataDir, "fill", { pageId: parsePageId(params.targetId), uid: params.uid, value: params.value, @@ -421,10 +520,11 @@ export async function fillChromeMcpElement(params: { export async function fillChromeMcpForm(params: { profileName: string; + userDataDir?: string; targetId: string; elements: Array<{ uid: string; value: string }>; }): Promise { - await callTool(params.profileName, "fill_form", { + await callTool(params.profileName, params.userDataDir, "fill_form", { pageId: parsePageId(params.targetId), elements: params.elements, }); @@ -432,10 +532,11 @@ export async function fillChromeMcpForm(params: { export async function hoverChromeMcpElement(params: { profileName: string; + userDataDir?: string; targetId: string; uid: string; }): Promise { - await callTool(params.profileName, "hover", { + await callTool(params.profileName, params.userDataDir, "hover", { pageId: parsePageId(params.targetId), uid: params.uid, }); @@ -443,11 +544,12 @@ export async function hoverChromeMcpElement(params: { export async function dragChromeMcpElement(params: { profileName: string; + userDataDir?: string; targetId: string; fromUid: string; toUid: string; }): Promise { - await callTool(params.profileName, "drag", { + await callTool(params.profileName, params.userDataDir, "drag", { pageId: parsePageId(params.targetId), from_uid: params.fromUid, to_uid: params.toUid, @@ -456,11 +558,12 @@ export async function dragChromeMcpElement(params: { export async function uploadChromeMcpFile(params: { profileName: string; + userDataDir?: string; targetId: string; uid: string; filePath: string; }): Promise { - await callTool(params.profileName, "upload_file", { + await callTool(params.profileName, params.userDataDir, "upload_file", { pageId: parsePageId(params.targetId), uid: params.uid, filePath: params.filePath, @@ -469,10 +572,11 @@ export async function uploadChromeMcpFile(params: { export async function pressChromeMcpKey(params: { profileName: string; + userDataDir?: string; targetId: string; key: string; }): Promise { - await callTool(params.profileName, "press_key", { + await callTool(params.profileName, params.userDataDir, "press_key", { pageId: parsePageId(params.targetId), key: params.key, }); @@ -480,11 +584,12 @@ export async function pressChromeMcpKey(params: { export async function resizeChromeMcpPage(params: { profileName: string; + userDataDir?: string; targetId: string; width: number; height: number; }): Promise { - await callTool(params.profileName, "resize_page", { + await callTool(params.profileName, params.userDataDir, "resize_page", { pageId: parsePageId(params.targetId), width: params.width, height: params.height, @@ -493,11 +598,12 @@ export async function resizeChromeMcpPage(params: { export async function handleChromeMcpDialog(params: { profileName: string; + userDataDir?: string; targetId: string; action: "accept" | "dismiss"; promptText?: string; }): Promise { - await callTool(params.profileName, "handle_dialog", { + await callTool(params.profileName, params.userDataDir, "handle_dialog", { pageId: parsePageId(params.targetId), action: params.action, ...(params.promptText ? { promptText: params.promptText } : {}), @@ -506,11 +612,12 @@ export async function handleChromeMcpDialog(params: { export async function evaluateChromeMcpScript(params: { profileName: string; + userDataDir?: string; targetId: string; fn: string; args?: string[]; }): Promise { - const result = await callTool(params.profileName, "evaluate_script", { + const result = await callTool(params.profileName, params.userDataDir, "evaluate_script", { pageId: parsePageId(params.targetId), function: params.fn, ...(params.args?.length ? { args: params.args } : {}), @@ -520,11 +627,12 @@ export async function evaluateChromeMcpScript(params: { export async function waitForChromeMcpText(params: { profileName: string; + userDataDir?: string; targetId: string; text: string[]; timeoutMs?: number; }): Promise { - await callTool(params.profileName, "wait_for", { + await callTool(params.profileName, params.userDataDir, "wait_for", { pageId: parsePageId(params.targetId), text: params.text, ...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}), diff --git a/src/browser/client.ts b/src/browser/client.ts index 7791b4405be..d7d8690147f 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -162,6 +162,7 @@ export type BrowserCreateProfileResult = { transport?: BrowserTransport; cdpPort: number | null; cdpUrl: string | null; + userDataDir: string | null; color: string; isRemote: boolean; }; @@ -172,6 +173,7 @@ export async function browserCreateProfile( name: string; color?: string; cdpUrl?: string; + userDataDir?: string; driver?: "openclaw" | "existing-session"; }, ): Promise { @@ -184,6 +186,7 @@ export async function browserCreateProfile( name: opts.name, color: opts.color, cdpUrl: opts.cdpUrl, + userDataDir: opts.userDataDir, driver: opts.driver, }), timeoutMs: 10000, diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index 7f80c4389a1..8ca609f13b6 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { withEnv } from "../test-utils/env.js"; +import { resolveUserPath } from "../utils.js"; import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; @@ -26,6 +27,7 @@ describe("browser config", () => { expect(user?.driver).toBe("existing-session"); expect(user?.cdpPort).toBe(0); expect(user?.cdpUrl).toBe(""); + expect(user?.userDataDir).toBeUndefined(); // chrome-relay is no longer auto-created expect(resolveProfile(resolved, "chrome-relay")).toBe(null); expect(resolved.remoteCdpTimeoutMs).toBe(1500); @@ -275,9 +277,29 @@ describe("browser config", () => { expect(profile?.cdpPort).toBe(0); expect(profile?.cdpUrl).toBe(""); expect(profile?.cdpIsLoopback).toBe(true); + expect(profile?.userDataDir).toBeUndefined(); expect(profile?.color).toBe("#00AA00"); }); + it("expands tilde-prefixed userDataDir for existing-session profiles", () => { + const resolved = resolveBrowserConfig({ + profiles: { + brave: { + driver: "existing-session", + attachOnly: true, + userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + }, + }, + }); + + const profile = resolveProfile(resolved, "brave"); + expect(profile?.driver).toBe("existing-session"); + expect(profile?.userDataDir).toBe( + resolveUserPath("~/Library/Application Support/BraveSoftware/Brave-Browser"), + ); + }); + it("sets usesChromeMcp only for existing-session profiles", () => { const resolved = resolveBrowserConfig({ profiles: { diff --git a/src/browser/config.ts b/src/browser/config.ts index 64fffce865c..a5bc131766a 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -7,6 +7,7 @@ import { } from "../config/port-defaults.js"; import { isLoopbackHost } from "../gateway/net.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { resolveUserPath } from "../utils.js"; import { DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_ENABLED, @@ -44,6 +45,7 @@ export type ResolvedBrowserProfile = { cdpUrl: string; cdpHost: string; cdpIsLoopback: boolean; + userDataDir?: string; color: string; driver: "openclaw" | "existing-session"; attachOnly: boolean; @@ -328,6 +330,7 @@ export function resolveProfile( cdpUrl: "", cdpHost: "", cdpIsLoopback: true, + userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined, color: profile.color, driver, attachOnly: true, diff --git a/src/browser/profiles-service.test.ts b/src/browser/profiles-service.test.ts index b726ad3fbdb..e36ae0ce695 100644 --- a/src/browser/profiles-service.test.ts +++ b/src/browser/profiles-service.test.ts @@ -150,6 +150,7 @@ describe("BrowserProfilesService", () => { expect(result.transport).toBe("chrome-mcp"); expect(result.cdpPort).toBeNull(); expect(result.cdpUrl).toBeNull(); + expect(result.userDataDir).toBeNull(); expect(result.isRemote).toBe(false); expect(state.resolved.profiles["chrome-live"]).toEqual({ driver: "existing-session", @@ -186,6 +187,51 @@ describe("BrowserProfilesService", () => { ).rejects.toThrow(/does not accept cdpUrl/i); }); + it("creates existing-session profiles with an explicit userDataDir", async () => { + const resolved = resolveBrowserConfig({}); + const { ctx, state } = createCtx(resolved); + vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); + + const tempDir = fs.mkdtempSync(path.join("/tmp", "openclaw-profile-")); + const userDataDir = path.join(tempDir, "BraveSoftware", "Brave-Browser"); + fs.mkdirSync(userDataDir, { recursive: true }); + + const service = createBrowserProfilesService(ctx); + const result = await service.createProfile({ + name: "brave-live", + driver: "existing-session", + userDataDir, + }); + + expect(result.transport).toBe("chrome-mcp"); + expect(result.userDataDir).toBe(userDataDir); + expect(state.resolved.profiles["brave-live"]).toEqual({ + driver: "existing-session", + attachOnly: true, + userDataDir, + color: expect.any(String), + }); + }); + + it("rejects userDataDir for non-existing-session profiles", async () => { + const resolved = resolveBrowserConfig({}); + const { ctx } = createCtx(resolved); + vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); + + const tempDir = fs.mkdtempSync(path.join("/tmp", "openclaw-profile-")); + const userDataDir = path.join(tempDir, "BraveSoftware", "Brave-Browser"); + fs.mkdirSync(userDataDir, { recursive: true }); + + const service = createBrowserProfilesService(ctx); + + await expect( + service.createProfile({ + name: "brave-live", + userDataDir, + }), + ).rejects.toThrow(/driver=existing-session is required/i); + }); + it("deletes remote profiles without stopping or removing local data", async () => { const resolved = resolveBrowserConfig({ profiles: { diff --git a/src/browser/profiles-service.ts b/src/browser/profiles-service.ts index af747015e45..ea1f3b674c6 100644 --- a/src/browser/profiles-service.ts +++ b/src/browser/profiles-service.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js"; +import { resolveUserPath } from "../utils.js"; import { resolveOpenClawUserDataDir } from "./chrome.js"; import { parseHttpUrl, resolveProfile } from "./config.js"; import { @@ -26,6 +27,7 @@ export type CreateProfileParams = { name: string; color?: string; cdpUrl?: string; + userDataDir?: string; driver?: "openclaw" | "existing-session"; }; @@ -35,6 +37,7 @@ export type CreateProfileResult = { transport: "cdp" | "chrome-mcp"; cdpPort: number | null; cdpUrl: string | null; + userDataDir: string | null; color: string; isRemote: boolean; }; @@ -79,6 +82,8 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { const createProfile = async (params: CreateProfileParams): Promise => { const name = params.name.trim(); const rawCdpUrl = params.cdpUrl?.trim() || undefined; + const rawUserDataDir = params.userDataDir?.trim() || undefined; + const normalizedUserDataDir = rawUserDataDir ? resolveUserPath(rawUserDataDir) : undefined; const driver = params.driver === "existing-session" ? "existing-session" : undefined; if (!isValidProfileName(name)) { @@ -104,6 +109,17 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { params.color && HEX_COLOR_RE.test(params.color) ? params.color : allocateColor(usedColors); let profileConfig: BrowserProfileConfig; + if (normalizedUserDataDir && driver !== "existing-session") { + throw new BrowserValidationError( + "driver=existing-session is required when userDataDir is provided", + ); + } + if (normalizedUserDataDir && !fs.existsSync(normalizedUserDataDir)) { + throw new BrowserValidationError( + `browser user data directory not found: ${normalizedUserDataDir}`, + ); + } + if (rawCdpUrl) { let parsed: ReturnType; try { @@ -127,6 +143,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { profileConfig = { driver, attachOnly: true, + ...(normalizedUserDataDir ? { userDataDir: normalizedUserDataDir } : {}), color: profileColor, }; } else { @@ -170,6 +187,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp", cdpPort: capabilities.usesChromeMcp ? null : resolved.cdpPort, cdpUrl: capabilities.usesChromeMcp ? null : resolved.cdpUrl, + userDataDir: resolved.userDataDir ?? null, color: resolved.color, isRemote: !resolved.cdpIsLoopback, }; diff --git a/src/browser/resolved-config-refresh.ts b/src/browser/resolved-config-refresh.ts index 999a7ca1229..1d20eecec94 100644 --- a/src/browser/resolved-config-refresh.ts +++ b/src/browser/resolved-config-refresh.ts @@ -22,6 +22,9 @@ function changedProfileInvariants( if (current.cdpIsLoopback !== next.cdpIsLoopback) { changed.push("cdpIsLoopback"); } + if ((current.userDataDir ?? "") !== (next.userDataDir ?? "")) { + changed.push("userDataDir"); + } return changed; } diff --git a/src/browser/routes/agent.act.hooks.ts b/src/browser/routes/agent.act.hooks.ts index a141a9cbe5a..a55e2f9b21e 100644 --- a/src/browser/routes/agent.act.hooks.ts +++ b/src/browser/routes/agent.act.hooks.ts @@ -65,6 +65,7 @@ export function registerBrowserAgentActHookRoutes( } await uploadChromeMcpFile({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, uid, filePath: resolvedPaths[0] ?? "", @@ -134,6 +135,7 @@ export function registerBrowserAgentActHookRoutes( } await evaluateChromeMcpScript({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, fn: `() => { const state = (window.__openclawDialogHook ??= {}); diff --git a/src/browser/routes/agent.act.ts b/src/browser/routes/agent.act.ts index 1b444d1b963..af0d8e40794 100644 --- a/src/browser/routes/agent.act.ts +++ b/src/browser/routes/agent.act.ts @@ -78,6 +78,7 @@ function buildExistingSessionWaitPredicate(params: { async function waitForExistingSessionCondition(params: { profileName: string; + userDataDir?: string; targetId: string; timeMs?: number; text?: string; @@ -103,6 +104,7 @@ async function waitForExistingSessionCondition(params: { ready = Boolean( await evaluateChromeMcpScript({ profileName: params.profileName, + userDataDir: params.userDataDir, targetId: params.targetId, fn: `async () => ${predicate}`, }), @@ -111,6 +113,7 @@ async function waitForExistingSessionCondition(params: { if (ready && params.url) { const currentUrl = await evaluateChromeMcpScript({ profileName: params.profileName, + userDataDir: params.userDataDir, targetId: params.targetId, fn: "() => window.location.href", }); @@ -520,6 +523,7 @@ export function registerBrowserAgentActRoutes( } await clickChromeMcpElement({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, uid: ref!, doubleClick, @@ -586,6 +590,7 @@ export function registerBrowserAgentActRoutes( } await fillChromeMcpElement({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, uid: ref!, value: text, @@ -593,6 +598,7 @@ export function registerBrowserAgentActRoutes( if (submit) { await pressChromeMcpKey({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, key: "Enter", }); @@ -632,7 +638,12 @@ export function registerBrowserAgentActRoutes( if (delayMs) { return jsonError(res, 501, "existing-session press does not support delayMs."); } - await pressChromeMcpKey({ profileName, targetId: tab.targetId, key }); + await pressChromeMcpKey({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + key, + }); return res.json({ ok: true, targetId: tab.targetId }); } const pw = await requirePwAi(res, `act:${kind}`); @@ -669,7 +680,12 @@ export function registerBrowserAgentActRoutes( "existing-session hover does not support timeoutMs overrides.", ); } - await hoverChromeMcpElement({ profileName, targetId: tab.targetId, uid: ref! }); + await hoverChromeMcpElement({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + uid: ref!, + }); return res.json({ ok: true, targetId: tab.targetId }); } const pw = await requirePwAi(res, `act:${kind}`); @@ -709,6 +725,7 @@ export function registerBrowserAgentActRoutes( } await evaluateChromeMcpScript({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`, args: [ref!], @@ -764,6 +781,7 @@ export function registerBrowserAgentActRoutes( } await dragChromeMcpElement({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, fromUid: startRef!, toUid: endRef!, @@ -817,6 +835,7 @@ export function registerBrowserAgentActRoutes( } await fillChromeMcpElement({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, uid: ref!, value: values[0] ?? "", @@ -861,6 +880,7 @@ export function registerBrowserAgentActRoutes( } await fillChromeMcpForm({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, elements: fields.map((field) => ({ uid: field.ref, @@ -890,6 +910,7 @@ export function registerBrowserAgentActRoutes( if (isExistingSession) { await resizeChromeMcpPage({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, width, height, @@ -951,6 +972,7 @@ export function registerBrowserAgentActRoutes( } await waitForExistingSessionCondition({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, timeMs, text, @@ -1001,6 +1023,7 @@ export function registerBrowserAgentActRoutes( } const result = await evaluateChromeMcpScript({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, fn, args: ref ? [ref] : undefined, @@ -1036,7 +1059,7 @@ export function registerBrowserAgentActRoutes( } case "close": { if (isExistingSession) { - await closeChromeMcpTab(profileName, tab.targetId); + await closeChromeMcpTab(profileName, tab.targetId, profileCtx.profile.userDataDir); return res.json({ ok: true, targetId: tab.targetId }); } const pw = await requirePwAi(res, `act:${kind}`); @@ -1151,6 +1174,7 @@ export function registerBrowserAgentActRoutes( if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { await evaluateChromeMcpScript({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, args: [ref], fn: `(el) => { diff --git a/src/browser/routes/agent.snapshot.ts b/src/browser/routes/agent.snapshot.ts index 80c11693a11..7cb73049389 100644 --- a/src/browser/routes/agent.snapshot.ts +++ b/src/browser/routes/agent.snapshot.ts @@ -44,10 +44,12 @@ const CHROME_MCP_OVERLAY_ATTR = "data-openclaw-mcp-overlay"; async function clearChromeMcpOverlay(params: { profileName: string; + userDataDir?: string; targetId: string; }): Promise { await evaluateChromeMcpScript({ profileName: params.profileName, + userDataDir: params.userDataDir, targetId: params.targetId, fn: `() => { document.querySelectorAll("[${CHROME_MCP_OVERLAY_ATTR}]").forEach((node) => node.remove()); @@ -58,12 +60,14 @@ async function clearChromeMcpOverlay(params: { async function renderChromeMcpLabels(params: { profileName: string; + userDataDir?: string; targetId: string; refs: string[]; }): Promise<{ labels: number; skipped: number }> { const refList = JSON.stringify(params.refs); const result = await evaluateChromeMcpScript({ profileName: params.profileName, + userDataDir: params.userDataDir, targetId: params.targetId, args: params.refs, fn: `(...elements) => { @@ -231,6 +235,7 @@ export function registerBrowserAgentSnapshotRoutes( await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); const result = await navigateChromeMcpPage({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, url, }); @@ -322,6 +327,7 @@ export function registerBrowserAgentSnapshotRoutes( } const buffer = await takeChromeMcpScreenshot({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, uid: ref, fullPage, @@ -406,6 +412,7 @@ export function registerBrowserAgentSnapshotRoutes( } const snapshot = await takeChromeMcpSnapshot({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, }); if (plan.format === "aria") { @@ -430,12 +437,14 @@ export function registerBrowserAgentSnapshotRoutes( const refs = Object.keys(built.refs); const labelResult = await renderChromeMcpLabels({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, refs, }); try { const labeled = await takeChromeMcpScreenshot({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, format: "png", }); @@ -465,6 +474,7 @@ export function registerBrowserAgentSnapshotRoutes( } finally { await clearChromeMcpOverlay({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, }); } diff --git a/src/browser/routes/basic.existing-session.test.ts b/src/browser/routes/basic.existing-session.test.ts index 34bcd9ee00b..b96596c6fbe 100644 --- a/src/browser/routes/basic.existing-session.test.ts +++ b/src/browser/routes/basic.existing-session.test.ts @@ -27,6 +27,7 @@ describe("basic browser routes", () => { driver: "existing-session", cdpPort: 0, cdpUrl: "", + userDataDir: "/tmp/brave-profile", color: "#00AA00", attachOnly: true, }, @@ -66,6 +67,7 @@ describe("basic browser routes", () => { driver: "existing-session", cdpPort: 0, cdpUrl: "", + userDataDir: "/tmp/brave-profile", color: "#00AA00", attachOnly: true, }, @@ -88,6 +90,7 @@ describe("basic browser routes", () => { running: true, cdpPort: null, cdpUrl: null, + userDataDir: "/tmp/brave-profile", pid: 4321, }); }); diff --git a/src/browser/routes/basic.ts b/src/browser/routes/basic.ts index c4f5db47a59..b781bc62694 100644 --- a/src/browser/routes/basic.ts +++ b/src/browser/routes/basic.ts @@ -112,7 +112,7 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow detectedBrowser, detectedExecutablePath, detectError, - userDataDir: profileState?.running?.userDataDir ?? null, + userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null, color: profileCtx.profile.color, headless: current.resolved.headless, noSandbox: current.resolved.noSandbox, @@ -176,6 +176,7 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow const name = toStringOrEmpty((req.body as { name?: unknown })?.name); const color = toStringOrEmpty((req.body as { color?: unknown })?.color); const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl); + const userDataDir = toStringOrEmpty((req.body as { userDataDir?: unknown })?.userDataDir); const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver); if (!name) { @@ -197,6 +198,7 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow name, color: color || undefined, cdpUrl: cdpUrl || undefined, + userDataDir: userDataDir || undefined, driver: driver === "existing-session" ? "existing-session" diff --git a/src/browser/server-context.availability.ts b/src/browser/server-context.availability.ts index d7d33fd0fde..6630c17a4c0 100644 --- a/src/browser/server-context.availability.ts +++ b/src/browser/server-context.availability.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { PROFILE_ATTACH_RETRY_TIMEOUT_MS, PROFILE_POST_RESTART_WS_TIMEOUT_MS, @@ -63,7 +64,7 @@ export function createProfileAvailability({ const isReachable = async (timeoutMs?: number) => { if (capabilities.usesChromeMcp) { // listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required - await listChromeMcpTabs(profile.name); + await listChromeMcpTabs(profile.name, profile.userDataDir); return true; } const { httpTimeoutMs, wsTimeoutMs } = resolveTimeouts(timeoutMs); @@ -153,7 +154,12 @@ export function createProfileAvailability({ const ensureBrowserAvailable = async (): Promise => { await reconcileProfileRuntime(); if (capabilities.usesChromeMcp) { - await ensureChromeMcpAvailable(profile.name); + if (profile.userDataDir && !fs.existsSync(profile.userDataDir)) { + throw new BrowserProfileUnavailableError( + `Browser user data directory not found for profile "${profile.name}": ${profile.userDataDir}`, + ); + } + await ensureChromeMcpAvailable(profile.name, profile.userDataDir); return; } const current = state(); diff --git a/src/browser/server-context.existing-session.test.ts b/src/browser/server-context.existing-session.test.ts index abbd222342e..7092bbf1fd9 100644 --- a/src/browser/server-context.existing-session.test.ts +++ b/src/browser/server-context.existing-session.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createBrowserRouteContext } from "./server-context.js"; import type { BrowserServerState } from "./server-context.js"; @@ -47,6 +48,7 @@ function makeState(): BrowserServerState { color: "#0066CC", driver: "existing-session", attachOnly: true, + userDataDir: "/tmp/brave-profile", }, }, extraArgs: [], @@ -62,6 +64,7 @@ afterEach(() => { describe("browser server-context existing-session profile", () => { it("routes tab operations through the Chrome MCP backend", async () => { + fs.mkdirSync("/tmp/brave-profile", { recursive: true }); const state = makeState(); const ctx = createBrowserRouteContext({ getState: () => state }); const live = ctx.forProfile("chrome-live"); @@ -93,10 +96,21 @@ describe("browser server-context existing-session profile", () => { await live.focusTab("7"); await live.stopRunningBrowser(); - expect(chromeMcp.ensureChromeMcpAvailable).toHaveBeenCalledWith("chrome-live"); - expect(chromeMcp.listChromeMcpTabs).toHaveBeenCalledWith("chrome-live"); - expect(chromeMcp.openChromeMcpTab).toHaveBeenCalledWith("chrome-live", "https://openclaw.ai"); - expect(chromeMcp.focusChromeMcpTab).toHaveBeenCalledWith("chrome-live", "7"); + expect(chromeMcp.ensureChromeMcpAvailable).toHaveBeenCalledWith( + "chrome-live", + "/tmp/brave-profile", + ); + expect(chromeMcp.listChromeMcpTabs).toHaveBeenCalledWith("chrome-live", "/tmp/brave-profile"); + expect(chromeMcp.openChromeMcpTab).toHaveBeenCalledWith( + "chrome-live", + "https://openclaw.ai", + "/tmp/brave-profile", + ); + expect(chromeMcp.focusChromeMcpTab).toHaveBeenCalledWith( + "chrome-live", + "7", + "/tmp/brave-profile", + ); expect(chromeMcp.closeChromeMcpSession).toHaveBeenCalledWith("chrome-live"); }); }); diff --git a/src/browser/server-context.selection.ts b/src/browser/server-context.selection.ts index 1a744e06b09..24248cebfd8 100644 --- a/src/browser/server-context.selection.ts +++ b/src/browser/server-context.selection.ts @@ -94,7 +94,7 @@ export function createProfileSelectionOps({ const resolvedTargetId = await resolveTargetIdOrThrow(targetId); if (capabilities.usesChromeMcp) { - await focusChromeMcpTab(profile.name, resolvedTargetId); + await focusChromeMcpTab(profile.name, resolvedTargetId, profile.userDataDir); const profileState = getProfileState(); profileState.lastTargetId = resolvedTargetId; return; @@ -124,7 +124,7 @@ export function createProfileSelectionOps({ const resolvedTargetId = await resolveTargetIdOrThrow(targetId); if (capabilities.usesChromeMcp) { - await closeChromeMcpTab(profile.name, resolvedTargetId); + await closeChromeMcpTab(profile.name, resolvedTargetId, profile.userDataDir); return; } diff --git a/src/browser/server-context.tab-ops.ts b/src/browser/server-context.tab-ops.ts index 66a134564c6..747082a7ff5 100644 --- a/src/browser/server-context.tab-ops.ts +++ b/src/browser/server-context.tab-ops.ts @@ -67,7 +67,7 @@ export function createProfileTabOps({ const listTabs = async (): Promise => { if (capabilities.usesChromeMcp) { - return await listChromeMcpTabs(profile.name); + return await listChromeMcpTabs(profile.name, profile.userDataDir); } if (capabilities.usesPersistentPlaywright) { @@ -141,7 +141,7 @@ export function createProfileTabOps({ if (capabilities.usesChromeMcp) { await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); - const page = await openChromeMcpTab(profile.name, url); + const page = await openChromeMcpTab(profile.name, url, profile.userDataDir); const profileState = getProfileState(); profileState.lastTargetId = page.targetId; await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts }); diff --git a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts index 5ad1d5f7bd2..8b997b8ac30 100644 --- a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts +++ b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { fetch as realFetch } from "undici"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -126,10 +127,47 @@ describe("profile CRUD endpoints", () => { profile?: string; transport?: string; cdpPort?: number | null; + userDataDir?: string | null; }; expect(createClawdBody.profile).toBe("legacyclawd"); expect(createClawdBody.transport).toBe("cdp"); expect(createClawdBody.cdpPort).toBeTypeOf("number"); + expect(createClawdBody.userDataDir).toBeNull(); + + const explicitUserDataDir = "/tmp/openclaw-brave-profile"; + await fs.promises.mkdir(explicitUserDataDir, { recursive: true }); + const createExistingSession = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "brave-live", + driver: "existing-session", + userDataDir: explicitUserDataDir, + }), + }); + expect(createExistingSession.status).toBe(200); + const createExistingSessionBody = (await createExistingSession.json()) as { + profile?: string; + transport?: string; + userDataDir?: string | null; + }; + expect(createExistingSessionBody.profile).toBe("brave-live"); + expect(createExistingSessionBody.transport).toBe("chrome-mcp"); + expect(createExistingSessionBody.userDataDir).toBe(explicitUserDataDir); + + const createBadExistingSession = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "bad-live", + userDataDir: explicitUserDataDir, + }), + }); + expect(createBadExistingSession.status).toBe(400); + const createBadExistingSessionBody = (await createBadExistingSession.json()) as { + error: string; + }; + expect(createBadExistingSessionBody.error).toContain("driver=existing-session is required"); const createLegacyDriver = await realFetch(`${base}/profiles/create`, { method: "POST", diff --git a/src/cli/browser-cli-manage.test.ts b/src/cli/browser-cli-manage.test.ts index deeb0d9e73a..86c10ac75ae 100644 --- a/src/cli/browser-cli-manage.test.ts +++ b/src/cli/browser-cli-manage.test.ts @@ -91,6 +91,42 @@ describe("browser manage output", () => { expect(output).not.toContain("cdpUrl:"); }); + it("shows configured userDataDir for existing-session status", async () => { + mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) => + req.path === "/" + ? { + enabled: true, + profile: "brave-live", + driver: "existing-session", + transport: "chrome-mcp", + running: true, + cdpReady: true, + cdpHttp: true, + pid: 4321, + cdpPort: null, + cdpUrl: null, + chosenBrowser: null, + userDataDir: "/Users/test/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + headless: false, + noSandbox: false, + executablePath: null, + attachOnly: true, + } + : {}, + ); + + const program = createProgram(); + await program.parseAsync(["browser", "--browser-profile", "brave-live", "status"], { + from: "user", + }); + + const output = mocks.runtimeLog.mock.calls.at(-1)?.[0] as string; + expect(output).toContain( + "userDataDir: /Users/test/Library/Application Support/BraveSoftware/Brave-Browser", + ); + }); + it("shows chrome-mcp transport in browser profiles output", async () => { mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) => req.path === "/profiles" @@ -131,6 +167,7 @@ describe("browser manage output", () => { transport: "chrome-mcp", cdpPort: null, cdpUrl: null, + userDataDir: null, color: "#00AA00", isRemote: false, } diff --git a/src/cli/browser-cli-manage.ts b/src/cli/browser-cli-manage.ts index e13b7af003a..1c096b1a73b 100644 --- a/src/cli/browser-cli-manage.ts +++ b/src/cli/browser-cli-manage.ts @@ -116,9 +116,13 @@ function formatBrowserConnectionSummary(params: { isRemote?: boolean; cdpPort?: number | null; cdpUrl?: string | null; + userDataDir?: string | null; }): string { if (usesChromeMcpTransport(params)) { - return "transport: chrome-mcp"; + const userDataDir = params.userDataDir ? shortenHomePath(params.userDataDir) : null; + return userDataDir + ? `transport: chrome-mcp, userDataDir: ${userDataDir}` + : "transport: chrome-mcp"; } if (params.isRemote) { return `cdpUrl: ${params.cdpUrl ?? "(unset)"}`; @@ -155,7 +159,9 @@ export function registerBrowserManageCommands( `cdpPort: ${status.cdpPort ?? "(unset)"}`, `cdpUrl: ${redactCdpUrl(status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`)}`, ] - : []), + : status.userDataDir + ? [`userDataDir: ${shortenHomePath(status.userDataDir)}`] + : []), `browser: ${status.chosenBrowser ?? "unknown"}`, `detectedBrowser: ${status.detectedBrowser ?? "unknown"}`, `detectedPath: ${detectedDisplay}`, @@ -455,9 +461,19 @@ export function registerBrowserManageCommands( .requiredOption("--name ", "Profile name (lowercase, numbers, hyphens)") .option("--color ", "Profile color (hex format, e.g. #0066CC)") .option("--cdp-url ", "CDP URL for remote Chrome (http/https)") + .option("--user-data-dir ", "User data dir for existing-session Chromium attach") .option("--driver ", "Profile driver (openclaw|existing-session). Default: openclaw") .action( - async (opts: { name: string; color?: string; cdpUrl?: string; driver?: string }, cmd) => { + async ( + opts: { + name: string; + color?: string; + cdpUrl?: string; + userDataDir?: string; + driver?: string; + }, + cmd, + ) => { const parent = parentOpts(cmd); await runBrowserCommand(async () => { const result = await callBrowserRequest( @@ -469,6 +485,7 @@ export function registerBrowserManageCommands( name: opts.name, color: opts.color, cdpUrl: opts.cdpUrl, + userDataDir: opts.userDataDir, driver: opts.driver === "existing-session" ? "existing-session" : undefined, }, }, @@ -481,8 +498,8 @@ export function registerBrowserManageCommands( defaultRuntime.log( info( `🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}${ - opts.driver === "existing-session" ? "\n driver: existing-session" : "" - }`, + result.userDataDir ? `\n userDataDir: ${shortenHomePath(result.userDataDir)}` : "" + }${opts.driver === "existing-session" ? "\n driver: existing-session" : ""}`, ), ); }); diff --git a/src/commands/doctor-browser.test.ts b/src/commands/doctor-browser.test.ts index da59fe5ed9a..948562eaf17 100644 --- a/src/commands/doctor-browser.test.ts +++ b/src/commands/doctor-browser.test.ts @@ -36,7 +36,7 @@ describe("doctor browser readiness", () => { expect(noteFn).toHaveBeenCalledTimes(1); expect(String(noteFn.mock.calls[0]?.[0])).toContain("Google Chrome was not found"); - expect(String(noteFn.mock.calls[0]?.[0])).toContain("chrome://inspect/#remote-debugging"); + expect(String(noteFn.mock.calls[0]?.[0])).toContain("brave://inspect/#remote-debugging"); }); it("warns when detected Chrome is too old for Chrome MCP", async () => { @@ -93,4 +93,31 @@ describe("doctor browser readiness", () => { "Detected Chrome Google Chrome 144.0.7534.0", ); }); + + it("skips Chrome auto-detection when profiles use explicit userDataDir", async () => { + const noteFn = vi.fn(); + await noteChromeMcpBrowserReadiness( + { + browser: { + profiles: { + braveLive: { + driver: "existing-session", + userDataDir: "/Users/test/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + }, + }, + }, + }, + { + noteFn, + resolveChromeExecutable: () => { + throw new Error("should not look up Chrome"); + }, + }, + ); + + expect(noteFn).toHaveBeenCalledTimes(1); + expect(String(noteFn.mock.calls[0]?.[0])).toContain("explicit Chromium user data directory"); + expect(String(noteFn.mock.calls[0]?.[0])).toContain("brave://inspect/#remote-debugging"); + }); }); diff --git a/src/commands/doctor-browser.ts b/src/commands/doctor-browser.ts index 482e370b052..028bfc50fb0 100644 --- a/src/commands/doctor-browser.ts +++ b/src/commands/doctor-browser.ts @@ -7,6 +7,11 @@ import type { OpenClawConfig } from "../config/config.js"; import { note } from "../terminal/note.js"; const CHROME_MCP_MIN_MAJOR = 144; +const REMOTE_DEBUGGING_PAGES = [ + "chrome://inspect/#remote-debugging", + "brave://inspect/#remote-debugging", + "edge://inspect/#remote-debugging", +].join(", "); function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) @@ -14,33 +19,40 @@ function asRecord(value: unknown): Record | null { : null; } -function collectChromeMcpProfileNames(cfg: OpenClawConfig): string[] { +type ExistingSessionProfile = { + name: string; + userDataDir?: string; +}; + +function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] { const browser = asRecord(cfg.browser); if (!browser) { return []; } - const names = new Set(); + const profiles = new Map(); const defaultProfile = typeof browser.defaultProfile === "string" ? browser.defaultProfile.trim() : ""; if (defaultProfile === "user") { - names.add("user"); + profiles.set("user", { name: "user" }); } - const profiles = asRecord(browser.profiles); - if (!profiles) { - return [...names]; + const configuredProfiles = asRecord(browser.profiles); + if (!configuredProfiles) { + return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name)); } - for (const [profileName, rawProfile] of Object.entries(profiles)) { + for (const [profileName, rawProfile] of Object.entries(configuredProfiles)) { const profile = asRecord(rawProfile); const driver = typeof profile?.driver === "string" ? profile.driver.trim() : ""; if (driver === "existing-session") { - names.add(profileName); + const userDataDir = + typeof profile?.userDataDir === "string" ? profile.userDataDir.trim() : undefined; + profiles.set(profileName, { name: profileName, userDataDir: userDataDir || undefined }); } } - return [...names].toSorted((a, b) => a.localeCompare(b)); + return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name)); } export async function noteChromeMcpBrowserReadiness( @@ -52,7 +64,7 @@ export async function noteChromeMcpBrowserReadiness( readVersion?: (executablePath: string) => string | null; }, ) { - const profiles = collectChromeMcpProfileNames(cfg); + const profiles = collectChromeMcpProfiles(cfg); if (profiles.length === 0) { return; } @@ -62,24 +74,47 @@ export async function noteChromeMcpBrowserReadiness( const resolveChromeExecutable = deps?.resolveChromeExecutable ?? resolveGoogleChromeExecutableForPlatform; const readVersion = deps?.readVersion ?? readBrowserVersion; - const chrome = resolveChromeExecutable(platform); - const profileLabel = profiles.join(", "); + const explicitProfiles = profiles.filter((profile) => profile.userDataDir); + const autoConnectProfiles = profiles.filter((profile) => !profile.userDataDir); + const profileLabel = profiles.map((profile) => profile.name).join(", "); - if (!chrome) { + if (autoConnectProfiles.length === 0) { noteFn( [ `- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`, - "- Google Chrome was not found on this host. OpenClaw does not bundle Chrome.", - `- Install Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node.`, - "- In Chrome, enable remote debugging at chrome://inspect/#remote-debugging.", - "- Keep Chrome running and accept the attach consent prompt the first time OpenClaw connects.", - "- Docker, headless, and sandbox browser flows stay on raw CDP; this check only applies to host-local Chrome MCP attach.", + "- These profiles use an explicit Chromium user data directory instead of Chrome's default auto-connect path.", + `- Verify the matching Chromium-based browser is version ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node.`, + `- Enable remote debugging in that browser's inspect page (${REMOTE_DEBUGGING_PAGES}).`, + "- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.", ].join("\n"), "Browser", ); return; } + const chrome = resolveChromeExecutable(platform); + const autoProfileLabel = autoConnectProfiles.map((profile) => profile.name).join(", "); + + if (!chrome) { + const lines = [ + `- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`, + `- Google Chrome was not found on this host for auto-connect profile(s): ${autoProfileLabel}. OpenClaw does not bundle Chrome.`, + `- Install Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node, or set browser.profiles..userDataDir for a different Chromium-based browser.`, + `- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`, + "- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.", + "- Docker, headless, and sandbox browser flows stay on raw CDP; this check only applies to host-local Chrome MCP attach.", + ]; + if (explicitProfiles.length > 0) { + lines.push( + `- Profiles with explicit userDataDir skip Chrome auto-detection: ${explicitProfiles + .map((profile) => profile.name) + .join(", ")}.`, + ); + } + noteFn(lines.join("\n"), "Browser"); + return; + } + const versionRaw = readVersion(chrome.path); const major = parseBrowserMajorVersion(versionRaw); const lines = [ @@ -99,10 +134,17 @@ export async function noteChromeMcpBrowserReadiness( lines.push(`- Detected Chrome ${versionRaw}.`); } - lines.push("- In Chrome, enable remote debugging at chrome://inspect/#remote-debugging."); + lines.push(`- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`); lines.push( - "- Keep Chrome running and accept the attach consent prompt the first time OpenClaw connects.", + "- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.", ); + if (explicitProfiles.length > 0) { + lines.push( + `- Profiles with explicit userDataDir still need manual validation of the matching Chromium-based browser: ${explicitProfiles + .map((profile) => profile.name) + .join(", ")}.`, + ); + } noteFn(lines.join("\n"), "Browser"); } diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 2680013a717..e915350ee62 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -271,6 +271,7 @@ const TARGET_KEYS = [ "browser.headless", "browser.noSandbox", "browser.profiles", + "browser.profiles.*.userDataDir", "browser.profiles.*.driver", "browser.profiles.*.attachOnly", "tools", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 3054b3f2ed2..1b048bc9aa1 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -260,8 +260,10 @@ export const FIELD_HELP: Record = { "Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.", "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.", "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 MCP attachment.', + '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.', "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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 2e9ebe1189c..a88cdc1ded5 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -123,6 +123,7 @@ export const FIELD_LABELS: Record = { "browser.profiles": "Browser Profiles", "browser.profiles.*.cdpPort": "Browser Profile CDP Port", "browser.profiles.*.cdpUrl": "Browser Profile CDP URL", + "browser.profiles.*.userDataDir": "Browser Profile User Data Dir", "browser.profiles.*.driver": "Browser Profile Driver", "browser.profiles.*.attachOnly": "Browser Profile Attach-only Mode", "browser.profiles.*.color": "Browser Profile Accent Color", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index b50795fd9d0..558b0ed529f 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -3,6 +3,8 @@ export type BrowserProfileConfig = { cdpPort?: number; /** CDP URL for this profile (use for remote Chrome). */ cdpUrl?: string; + /** Explicit user data directory for existing-session Chrome MCP attachment. */ + userDataDir?: string; /** Profile driver (default: openclaw). */ driver?: "openclaw" | "clawd" | "existing-session"; /** If true, never launch a browser for this profile; only attach. Falls back to browser.attachOnly. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index d1bce17b575..817183cab5d 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -359,6 +359,7 @@ export const OpenClawSchema = z .object({ cdpPort: z.number().int().min(1).max(65535).optional(), cdpUrl: z.string().optional(), + userDataDir: z.string().optional(), driver: z .union([z.literal("openclaw"), z.literal("clawd"), z.literal("existing-session")]) .optional(), @@ -371,7 +372,10 @@ export const OpenClawSchema = z { message: "Profile must set cdpPort or cdpUrl", }, - ), + ) + .refine((value) => value.driver === "existing-session" || !value.userDataDir, { + message: 'Profile userDataDir is only supported with driver="existing-session"', + }), ) .optional(), extraArgs: z.array(z.string()).optional(),