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
This commit is contained in:
Radek Sienkiewicz 2026-03-16 14:21:22 +01:00 committed by GitHub
parent 3e360ec8cb
commit 7deb543624
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 650 additions and 126 deletions

View File

@ -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. - 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. - 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. - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
- Browser/existing-session: support `browser.profiles.<name>.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 ### Breaking

View File

@ -91,6 +91,7 @@ Use the built-in `user` profile, or create your own `existing-session` profile:
```bash ```bash
openclaw browser --browser-profile user tabs openclaw browser --browser-profile user tabs
openclaw browser create-profile --name chrome-live --driver existing-session 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 openclaw browser --browser-profile chrome-live tabs
``` ```

View File

@ -2443,6 +2443,12 @@ See [Plugins](/tools/plugin).
openclaw: { cdpPort: 18800, color: "#FF4500" }, openclaw: { cdpPort: 18800, color: "#FF4500" },
work: { cdpPort: 18801, color: "#0066CC" }, work: { cdpPort: 18801, color: "#0066CC" },
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, 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" }, remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" },
}, },
color: "#FF4500", color: "#FF4500",
@ -2463,6 +2469,8 @@ See [Plugins](/tools/plugin).
- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions. - In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions.
- Remote profiles are attach-only (start/stop/reset disabled). - 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 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. - 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`). - Control service: loopback only (port derived from `gateway.port`, default `18791`).
- `extraArgs` appends extra launch flags to local Chromium startup (for example - `extraArgs` appends extra launch flags to local Chromium startup (for example

View File

@ -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: Doctor also audits the host-local Chrome MCP path when you use `defaultProfile:
"user"` or a configured `existing-session` profile: "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 - checks the detected Chrome version and warns when it is below Chrome 144
- reminds you to enable remote debugging in Chrome at - reminds you to enable remote debugging in the browser inspect page (for
`chrome://inspect/#remote-debugging` 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 Doctor cannot enable the Chrome-side setting for you. Host-local Chrome MCP
still requires: still requires:
- Google Chrome 144+ on the gateway/node host - a Chromium-based browser 144+ on the gateway/node host
- Chrome running locally - the browser running locally
- remote debugging enabled in Chrome - remote debugging enabled in that browser
- approving the first attach consent prompt in Chrome - approving the first attach consent prompt in the browser
This check does **not** apply to Docker, sandbox, remote-browser, or other This check does **not** apply to Docker, sandbox, remote-browser, or other
headless flows. Those continue to use raw CDP. headless flows. Those continue to use raw CDP.

View File

@ -88,6 +88,12 @@ Browser settings live in `~/.openclaw/openclaw.json`.
attachOnly: true, attachOnly: true,
color: "#00AA00", 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" }, 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. - 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 - `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. Do
not set `cdpUrl` for that driver. not set `cdpUrl` for that driver.
- Set `browser.profiles.<name>.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) ## Use Brave (or another Chromium-based browser)
@ -289,11 +297,11 @@ Defaults:
All control endpoints accept `?profile=<name>`; the CLI uses `--browser-profile`. All control endpoints accept `?profile=<name>`; 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 OpenClaw can also attach to a running Chromium-based browser profile through the
Chrome DevTools MCP server. This reuses the tabs and login state already open in official Chrome DevTools MCP server. This reuses the tabs and login state
that Chrome profile. already open in that browser profile.
Official background and setup references: Official background and setup references:
@ -305,13 +313,41 @@ Built-in profile:
- `user` - `user`
Optional: create your own custom existing-session profile if you want a 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` - The built-in `user` profile uses Chrome MCP auto-connect, which targets the
2. Enable remote debugging default local Google Chrome profile.
3. Keep Chrome running and approve the connection prompt when OpenClaw attaches
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: Live attach smoke test:
@ -327,17 +363,17 @@ What success looks like:
- `status` shows `driver: existing-session` - `status` shows `driver: existing-session`
- `status` shows `transport: chrome-mcp` - `status` shows `transport: chrome-mcp`
- `status` shows `running: true` - `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 - `snapshot` returns refs from the selected live tab
What to check if attach does not work: What to check if attach does not work:
- Chrome is version `144+` - the target Chromium-based browser is version `144+`
- remote debugging is enabled at `chrome://inspect/#remote-debugging` - remote debugging is enabled in that browser's inspect page
- Chrome showed and you accepted the attach consent prompt - the browser showed and you accepted the attach consent prompt
- `openclaw doctor` migrates old extension-based browser config and checks that - `openclaw doctor` migrates old extension-based browser config and checks that
Chrome is installed locally with a compatible version, but it cannot enable Chrome is installed locally for default auto-connect profiles, but it cannot
Chrome-side remote debugging for you enable browser-side remote debugging for you
Agent use: Agent use:
@ -351,10 +387,11 @@ Notes:
- This path is higher-risk than the isolated `openclaw` profile because it can - This path is higher-risk than the isolated `openclaw` profile because it can
act inside your signed-in browser session. act inside your signed-in browser session.
- OpenClaw does not launch Chrome for this driver; it attaches to an existing - OpenClaw does not launch the browser for this driver; it attaches to an
session only. existing session only.
- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not - OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here. If
the legacy default-profile remote debugging port workflow. `userDataDir` is set, OpenClaw passes it through to target that explicit
Chromium user data directory.
- Existing-session screenshots support page captures and `--ref` element - Existing-session screenshots support page captures and `--ref` element
captures from snapshots, but not CSS `--element` selectors. captures from snapshots, but not CSS `--element` selectors.
- Existing-session `wait --url` supports exact, substring, and glob patterns - Existing-session `wait --url` supports exact, substring, and glob patterns

View File

@ -347,7 +347,7 @@ export async function executeActAction(params: {
} }
if (!tabs.length) { if (!tabs.length) {
throw new Error( 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 }, { cause: err },
); );
} }

View File

@ -307,7 +307,7 @@ export function createBrowserTool(opts?: {
description: [ description: [
"Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).", "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`).", "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=<id|name> or target="node".', 'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node=<id|name> 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).", "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.', 'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',

View File

@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
buildChromeMcpArgs,
evaluateChromeMcpScript, evaluateChromeMcpScript,
listChromeMcpTabs, listChromeMcpTabs,
openChromeMcpTab, 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 () => { it("parses new_page text responses and returns the created tab", async () => {
const factory: ChromeMcpSessionFactory = async () => createFakeSession(); const factory: ChromeMcpSessionFactory = async () => createFakeSession();
setChromeMcpSessionFactoryForTest(factory); setChromeMcpSessionFactoryForTest(factory);
@ -250,6 +263,33 @@ describe("chrome MCP page parsing", () => {
expect(tabs).toHaveLength(2); expect(tabs).toHaveLength(2);
}); });
it("creates a fresh session when userDataDir changes for the same profile", async () => {
const createdSessions: ChromeMcpSession[] = [];
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
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 () => { it("clears failed pending sessions so the next call can retry", async () => {
let factoryCalls = 0; let factoryCalls = 0;
const factory: ChromeMcpSessionFactory = async () => { const factory: ChromeMcpSessionFactory = async () => {

View File

@ -26,7 +26,10 @@ type ChromeMcpSession = {
ready: Promise<void>; ready: Promise<void>;
}; };
type ChromeMcpSessionFactory = (profileName: string) => Promise<ChromeMcpSession>; type ChromeMcpSessionFactory = (
profileName: string,
userDataDir?: string,
) => Promise<ChromeMcpSession>;
const DEFAULT_CHROME_MCP_COMMAND = "npx"; const DEFAULT_CHROME_MCP_COMMAND = "npx";
const DEFAULT_CHROME_MCP_ARGS = [ const DEFAULT_CHROME_MCP_ARGS = [
@ -168,10 +171,62 @@ function extractJsonMessage(result: ChromeMcpToolResult): unknown {
return null; return null;
} }
async function createRealSession(profileName: string): Promise<ChromeMcpSession> { 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<boolean> {
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<ChromeMcpSession> {
const transport = new StdioClientTransport({ const transport = new StdioClientTransport({
command: DEFAULT_CHROME_MCP_COMMAND, command: DEFAULT_CHROME_MCP_COMMAND,
args: DEFAULT_CHROME_MCP_ARGS, args: buildChromeMcpArgs(userDataDir),
stderr: "pipe", stderr: "pipe",
}); });
const client = new Client( const client = new Client(
@ -191,9 +246,12 @@ async function createRealSession(profileName: string): Promise<ChromeMcpSession>
} }
} catch (err) { } catch (err) {
await client.close().catch(() => {}); await client.close().catch(() => {});
const targetLabel = userDataDir
? `the configured Chromium user data dir (${userDataDir})`
: "Google Chrome's default profile";
throw new BrowserProfileUnavailableError( throw new BrowserProfileUnavailableError(
`Chrome MCP existing-session attach failed for profile "${profileName}". ` + `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)}`, `Details: ${String(err)}`,
); );
} }
@ -206,27 +264,34 @@ async function createRealSession(profileName: string): Promise<ChromeMcpSession>
}; };
} }
async function getSession(profileName: string): Promise<ChromeMcpSession> { async function getSession(profileName: string, userDataDir?: string): Promise<ChromeMcpSession> {
let session = sessions.get(profileName); const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir);
await closeChromeMcpSessionsForProfile(profileName, cacheKey);
let session = sessions.get(cacheKey);
if (session && session.transport.pid === null) { if (session && session.transport.pid === null) {
sessions.delete(profileName); sessions.delete(cacheKey);
session = undefined; session = undefined;
} }
if (!session) { if (!session) {
let pending = pendingSessions.get(profileName); let pending = pendingSessions.get(cacheKey);
if (!pending) { if (!pending) {
pending = (async () => { pending = (async () => {
const created = await (sessionFactory ?? createRealSession)(profileName); const created = await (sessionFactory ?? createRealSession)(profileName, userDataDir);
sessions.set(profileName, created); if (pendingSessions.get(cacheKey) === pending) {
sessions.set(cacheKey, created);
} else {
await created.client.close().catch(() => {});
}
return created; return created;
})(); })();
pendingSessions.set(profileName, pending); pendingSessions.set(cacheKey, pending);
} }
try { try {
session = await pending; session = await pending;
} finally { } finally {
if (pendingSessions.get(profileName) === pending) { if (pendingSessions.get(cacheKey) === pending) {
pendingSessions.delete(profileName); pendingSessions.delete(cacheKey);
} }
} }
} }
@ -234,9 +299,9 @@ async function getSession(profileName: string): Promise<ChromeMcpSession> {
await session.ready; await session.ready;
return session; return session;
} catch (err) { } catch (err) {
const current = sessions.get(profileName); const current = sessions.get(cacheKey);
if (current?.transport === session.transport) { if (current?.transport === session.transport) {
sessions.delete(profileName); sessions.delete(cacheKey);
} }
throw err; throw err;
} }
@ -244,10 +309,12 @@ async function getSession(profileName: string): Promise<ChromeMcpSession> {
async function callTool( async function callTool(
profileName: string, profileName: string,
userDataDir: string | undefined,
name: string, name: string,
args: Record<string, unknown> = {}, args: Record<string, unknown> = {},
): Promise<ChromeMcpToolResult> { ): Promise<ChromeMcpToolResult> {
const session = await getSession(profileName); const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir);
const session = await getSession(profileName, userDataDir);
let result: ChromeMcpToolResult; let result: ChromeMcpToolResult;
try { try {
result = (await session.client.callTool({ result = (await session.client.callTool({
@ -256,7 +323,7 @@ async function callTool(
})) as ChromeMcpToolResult; })) as ChromeMcpToolResult;
} catch (err) { } catch (err) {
// Transport/connection error — tear down session so it reconnects on next call // Transport/connection error — tear down session so it reconnects on next call
sessions.delete(profileName); sessions.delete(cacheKey);
await session.client.close().catch(() => {}); await session.client.close().catch(() => {});
throw err; throw err;
} }
@ -278,8 +345,12 @@ async function withTempFile<T>(fn: (filePath: string) => Promise<T>): Promise<T>
} }
} }
async function findPageById(profileName: string, pageId: number): Promise<ChromeMcpStructuredPage> { async function findPageById(
const pages = await listChromeMcpPages(profileName); profileName: string,
pageId: number,
userDataDir?: string,
): Promise<ChromeMcpStructuredPage> {
const pages = await listChromeMcpPages(profileName, userDataDir);
const page = pages.find((entry) => entry.id === pageId); const page = pages.find((entry) => entry.id === pageId);
if (!page) { if (!page) {
throw new BrowserTabNotFoundError(); throw new BrowserTabNotFoundError();
@ -287,43 +358,54 @@ async function findPageById(profileName: string, pageId: number): Promise<Chrome
return page; return page;
} }
export async function ensureChromeMcpAvailable(profileName: string): Promise<void> { export async function ensureChromeMcpAvailable(
await getSession(profileName); profileName: string,
userDataDir?: string,
): Promise<void> {
await getSession(profileName, userDataDir);
} }
export function getChromeMcpPid(profileName: string): number | null { 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<boolean> { export async function closeChromeMcpSession(profileName: string): Promise<boolean> {
pendingSessions.delete(profileName); return await closeChromeMcpSessionsForProfile(profileName);
const session = sessions.get(profileName);
if (!session) {
return false;
}
sessions.delete(profileName);
await session.client.close().catch(() => {});
return true;
} }
export async function stopAllChromeMcpSessions(): Promise<void> { export async function stopAllChromeMcpSessions(): Promise<void> {
const names = [...sessions.keys()]; const names = [...new Set([...sessions.keys()].map((key) => JSON.parse(key)[0] as string))];
for (const name of names) { for (const name of names) {
await closeChromeMcpSession(name).catch(() => {}); await closeChromeMcpSession(name).catch(() => {});
} }
} }
export async function listChromeMcpPages(profileName: string): Promise<ChromeMcpStructuredPage[]> { export async function listChromeMcpPages(
const result = await callTool(profileName, "list_pages"); profileName: string,
userDataDir?: string,
): Promise<ChromeMcpStructuredPage[]> {
const result = await callTool(profileName, userDataDir, "list_pages");
return extractStructuredPages(result); return extractStructuredPages(result);
} }
export async function listChromeMcpTabs(profileName: string): Promise<BrowserTab[]> { export async function listChromeMcpTabs(
return toBrowserTabs(await listChromeMcpPages(profileName)); profileName: string,
userDataDir?: string,
): Promise<BrowserTab[]> {
return toBrowserTabs(await listChromeMcpPages(profileName, userDataDir));
} }
export async function openChromeMcpTab(profileName: string, url: string): Promise<BrowserTab> { export async function openChromeMcpTab(
const result = await callTool(profileName, "new_page", { url }); profileName: string,
url: string,
userDataDir?: string,
): Promise<BrowserTab> {
const result = await callTool(profileName, userDataDir, "new_page", { url });
const pages = extractStructuredPages(result); const pages = extractStructuredPages(result);
const chosen = pages.find((page) => page.selected) ?? pages.at(-1); const chosen = pages.find((page) => page.selected) ?? pages.at(-1);
if (!chosen) { if (!chosen) {
@ -337,38 +419,52 @@ export async function openChromeMcpTab(profileName: string, url: string): Promis
}; };
} }
export async function focusChromeMcpTab(profileName: string, targetId: string): Promise<void> { export async function focusChromeMcpTab(
await callTool(profileName, "select_page", { profileName: string,
targetId: string,
userDataDir?: string,
): Promise<void> {
await callTool(profileName, userDataDir, "select_page", {
pageId: parsePageId(targetId), pageId: parsePageId(targetId),
bringToFront: true, bringToFront: true,
}); });
} }
export async function closeChromeMcpTab(profileName: string, targetId: string): Promise<void> { export async function closeChromeMcpTab(
await callTool(profileName, "close_page", { pageId: parsePageId(targetId) }); profileName: string,
targetId: string,
userDataDir?: string,
): Promise<void> {
await callTool(profileName, userDataDir, "close_page", { pageId: parsePageId(targetId) });
} }
export async function navigateChromeMcpPage(params: { export async function navigateChromeMcpPage(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
url: string; url: string;
timeoutMs?: number; timeoutMs?: number;
}): Promise<{ url: string }> { }): Promise<{ url: string }> {
await callTool(params.profileName, "navigate_page", { await callTool(params.profileName, params.userDataDir, "navigate_page", {
pageId: parsePageId(params.targetId), pageId: parsePageId(params.targetId),
type: "url", type: "url",
url: params.url, url: params.url,
...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}), ...(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 }; return { url: page.url ?? params.url };
} }
export async function takeChromeMcpSnapshot(params: { export async function takeChromeMcpSnapshot(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
}): Promise<ChromeMcpSnapshotNode> { }): Promise<ChromeMcpSnapshotNode> {
const result = await callTool(params.profileName, "take_snapshot", { const result = await callTool(params.profileName, params.userDataDir, "take_snapshot", {
pageId: parsePageId(params.targetId), pageId: parsePageId(params.targetId),
}); });
return extractSnapshot(result); return extractSnapshot(result);
@ -376,13 +472,14 @@ export async function takeChromeMcpSnapshot(params: {
export async function takeChromeMcpScreenshot(params: { export async function takeChromeMcpScreenshot(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
uid?: string; uid?: string;
fullPage?: boolean; fullPage?: boolean;
format?: "png" | "jpeg"; format?: "png" | "jpeg";
}): Promise<Buffer> { }): Promise<Buffer> {
return await withTempFile(async (filePath) => { return await withTempFile(async (filePath) => {
await callTool(params.profileName, "take_screenshot", { await callTool(params.profileName, params.userDataDir, "take_screenshot", {
pageId: parsePageId(params.targetId), pageId: parsePageId(params.targetId),
filePath, filePath,
format: params.format ?? "png", format: params.format ?? "png",
@ -395,11 +492,12 @@ export async function takeChromeMcpScreenshot(params: {
export async function clickChromeMcpElement(params: { export async function clickChromeMcpElement(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
uid: string; uid: string;
doubleClick?: boolean; doubleClick?: boolean;
}): Promise<void> { }): Promise<void> {
await callTool(params.profileName, "click", { await callTool(params.profileName, params.userDataDir, "click", {
pageId: parsePageId(params.targetId), pageId: parsePageId(params.targetId),
uid: params.uid, uid: params.uid,
...(params.doubleClick ? { dblClick: true } : {}), ...(params.doubleClick ? { dblClick: true } : {}),
@ -408,11 +506,12 @@ export async function clickChromeMcpElement(params: {
export async function fillChromeMcpElement(params: { export async function fillChromeMcpElement(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
uid: string; uid: string;
value: string; value: string;
}): Promise<void> { }): Promise<void> {
await callTool(params.profileName, "fill", { await callTool(params.profileName, params.userDataDir, "fill", {
pageId: parsePageId(params.targetId), pageId: parsePageId(params.targetId),
uid: params.uid, uid: params.uid,
value: params.value, value: params.value,
@ -421,10 +520,11 @@ export async function fillChromeMcpElement(params: {
export async function fillChromeMcpForm(params: { export async function fillChromeMcpForm(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
elements: Array<{ uid: string; value: string }>; elements: Array<{ uid: string; value: string }>;
}): Promise<void> { }): Promise<void> {
await callTool(params.profileName, "fill_form", { await callTool(params.profileName, params.userDataDir, "fill_form", {
pageId: parsePageId(params.targetId), pageId: parsePageId(params.targetId),
elements: params.elements, elements: params.elements,
}); });
@ -432,10 +532,11 @@ export async function fillChromeMcpForm(params: {
export async function hoverChromeMcpElement(params: { export async function hoverChromeMcpElement(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
uid: string; uid: string;
}): Promise<void> { }): Promise<void> {
await callTool(params.profileName, "hover", { await callTool(params.profileName, params.userDataDir, "hover", {
pageId: parsePageId(params.targetId), pageId: parsePageId(params.targetId),
uid: params.uid, uid: params.uid,
}); });
@ -443,11 +544,12 @@ export async function hoverChromeMcpElement(params: {
export async function dragChromeMcpElement(params: { export async function dragChromeMcpElement(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
fromUid: string; fromUid: string;
toUid: string; toUid: string;
}): Promise<void> { }): Promise<void> {
await callTool(params.profileName, "drag", { await callTool(params.profileName, params.userDataDir, "drag", {
pageId: parsePageId(params.targetId), pageId: parsePageId(params.targetId),
from_uid: params.fromUid, from_uid: params.fromUid,
to_uid: params.toUid, to_uid: params.toUid,
@ -456,11 +558,12 @@ export async function dragChromeMcpElement(params: {
export async function uploadChromeMcpFile(params: { export async function uploadChromeMcpFile(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
uid: string; uid: string;
filePath: string; filePath: string;
}): Promise<void> { }): Promise<void> {
await callTool(params.profileName, "upload_file", { await callTool(params.profileName, params.userDataDir, "upload_file", {
pageId: parsePageId(params.targetId), pageId: parsePageId(params.targetId),
uid: params.uid, uid: params.uid,
filePath: params.filePath, filePath: params.filePath,
@ -469,10 +572,11 @@ export async function uploadChromeMcpFile(params: {
export async function pressChromeMcpKey(params: { export async function pressChromeMcpKey(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
key: string; key: string;
}): Promise<void> { }): Promise<void> {
await callTool(params.profileName, "press_key", { await callTool(params.profileName, params.userDataDir, "press_key", {
pageId: parsePageId(params.targetId), pageId: parsePageId(params.targetId),
key: params.key, key: params.key,
}); });
@ -480,11 +584,12 @@ export async function pressChromeMcpKey(params: {
export async function resizeChromeMcpPage(params: { export async function resizeChromeMcpPage(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
width: number; width: number;
height: number; height: number;
}): Promise<void> { }): Promise<void> {
await callTool(params.profileName, "resize_page", { await callTool(params.profileName, params.userDataDir, "resize_page", {
pageId: parsePageId(params.targetId), pageId: parsePageId(params.targetId),
width: params.width, width: params.width,
height: params.height, height: params.height,
@ -493,11 +598,12 @@ export async function resizeChromeMcpPage(params: {
export async function handleChromeMcpDialog(params: { export async function handleChromeMcpDialog(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
action: "accept" | "dismiss"; action: "accept" | "dismiss";
promptText?: string; promptText?: string;
}): Promise<void> { }): Promise<void> {
await callTool(params.profileName, "handle_dialog", { await callTool(params.profileName, params.userDataDir, "handle_dialog", {
pageId: parsePageId(params.targetId), pageId: parsePageId(params.targetId),
action: params.action, action: params.action,
...(params.promptText ? { promptText: params.promptText } : {}), ...(params.promptText ? { promptText: params.promptText } : {}),
@ -506,11 +612,12 @@ export async function handleChromeMcpDialog(params: {
export async function evaluateChromeMcpScript(params: { export async function evaluateChromeMcpScript(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
fn: string; fn: string;
args?: string[]; args?: string[];
}): Promise<unknown> { }): Promise<unknown> {
const result = await callTool(params.profileName, "evaluate_script", { const result = await callTool(params.profileName, params.userDataDir, "evaluate_script", {
pageId: parsePageId(params.targetId), pageId: parsePageId(params.targetId),
function: params.fn, function: params.fn,
...(params.args?.length ? { args: params.args } : {}), ...(params.args?.length ? { args: params.args } : {}),
@ -520,11 +627,12 @@ export async function evaluateChromeMcpScript(params: {
export async function waitForChromeMcpText(params: { export async function waitForChromeMcpText(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
text: string[]; text: string[];
timeoutMs?: number; timeoutMs?: number;
}): Promise<void> { }): Promise<void> {
await callTool(params.profileName, "wait_for", { await callTool(params.profileName, params.userDataDir, "wait_for", {
pageId: parsePageId(params.targetId), pageId: parsePageId(params.targetId),
text: params.text, text: params.text,
...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}), ...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}),

View File

@ -162,6 +162,7 @@ export type BrowserCreateProfileResult = {
transport?: BrowserTransport; transport?: BrowserTransport;
cdpPort: number | null; cdpPort: number | null;
cdpUrl: string | null; cdpUrl: string | null;
userDataDir: string | null;
color: string; color: string;
isRemote: boolean; isRemote: boolean;
}; };
@ -172,6 +173,7 @@ export async function browserCreateProfile(
name: string; name: string;
color?: string; color?: string;
cdpUrl?: string; cdpUrl?: string;
userDataDir?: string;
driver?: "openclaw" | "existing-session"; driver?: "openclaw" | "existing-session";
}, },
): Promise<BrowserCreateProfileResult> { ): Promise<BrowserCreateProfileResult> {
@ -184,6 +186,7 @@ export async function browserCreateProfile(
name: opts.name, name: opts.name,
color: opts.color, color: opts.color,
cdpUrl: opts.cdpUrl, cdpUrl: opts.cdpUrl,
userDataDir: opts.userDataDir,
driver: opts.driver, driver: opts.driver,
}), }),
timeoutMs: 10000, timeoutMs: 10000,

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { withEnv } from "../test-utils/env.js"; import { withEnv } from "../test-utils/env.js";
import { resolveUserPath } from "../utils.js";
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js"; import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
@ -26,6 +27,7 @@ describe("browser config", () => {
expect(user?.driver).toBe("existing-session"); expect(user?.driver).toBe("existing-session");
expect(user?.cdpPort).toBe(0); expect(user?.cdpPort).toBe(0);
expect(user?.cdpUrl).toBe(""); expect(user?.cdpUrl).toBe("");
expect(user?.userDataDir).toBeUndefined();
// chrome-relay is no longer auto-created // chrome-relay is no longer auto-created
expect(resolveProfile(resolved, "chrome-relay")).toBe(null); expect(resolveProfile(resolved, "chrome-relay")).toBe(null);
expect(resolved.remoteCdpTimeoutMs).toBe(1500); expect(resolved.remoteCdpTimeoutMs).toBe(1500);
@ -275,9 +277,29 @@ describe("browser config", () => {
expect(profile?.cdpPort).toBe(0); expect(profile?.cdpPort).toBe(0);
expect(profile?.cdpUrl).toBe(""); expect(profile?.cdpUrl).toBe("");
expect(profile?.cdpIsLoopback).toBe(true); expect(profile?.cdpIsLoopback).toBe(true);
expect(profile?.userDataDir).toBeUndefined();
expect(profile?.color).toBe("#00AA00"); 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", () => { it("sets usesChromeMcp only for existing-session profiles", () => {
const resolved = resolveBrowserConfig({ const resolved = resolveBrowserConfig({
profiles: { profiles: {

View File

@ -7,6 +7,7 @@ import {
} from "../config/port-defaults.js"; } from "../config/port-defaults.js";
import { isLoopbackHost } from "../gateway/net.js"; import { isLoopbackHost } from "../gateway/net.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { resolveUserPath } from "../utils.js";
import { import {
DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_ENABLED, DEFAULT_OPENCLAW_BROWSER_ENABLED,
@ -44,6 +45,7 @@ export type ResolvedBrowserProfile = {
cdpUrl: string; cdpUrl: string;
cdpHost: string; cdpHost: string;
cdpIsLoopback: boolean; cdpIsLoopback: boolean;
userDataDir?: string;
color: string; color: string;
driver: "openclaw" | "existing-session"; driver: "openclaw" | "existing-session";
attachOnly: boolean; attachOnly: boolean;
@ -328,6 +330,7 @@ export function resolveProfile(
cdpUrl: "", cdpUrl: "",
cdpHost: "", cdpHost: "",
cdpIsLoopback: true, cdpIsLoopback: true,
userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined,
color: profile.color, color: profile.color,
driver, driver,
attachOnly: true, attachOnly: true,

View File

@ -150,6 +150,7 @@ describe("BrowserProfilesService", () => {
expect(result.transport).toBe("chrome-mcp"); expect(result.transport).toBe("chrome-mcp");
expect(result.cdpPort).toBeNull(); expect(result.cdpPort).toBeNull();
expect(result.cdpUrl).toBeNull(); expect(result.cdpUrl).toBeNull();
expect(result.userDataDir).toBeNull();
expect(result.isRemote).toBe(false); expect(result.isRemote).toBe(false);
expect(state.resolved.profiles["chrome-live"]).toEqual({ expect(state.resolved.profiles["chrome-live"]).toEqual({
driver: "existing-session", driver: "existing-session",
@ -186,6 +187,51 @@ describe("BrowserProfilesService", () => {
).rejects.toThrow(/does not accept cdpUrl/i); ).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 () => { it("deletes remote profiles without stopping or removing local data", async () => {
const resolved = resolveBrowserConfig({ const resolved = resolveBrowserConfig({
profiles: { profiles: {

View File

@ -3,6 +3,7 @@ import path from "node:path";
import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js";
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js"; import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
import { resolveUserPath } from "../utils.js";
import { resolveOpenClawUserDataDir } from "./chrome.js"; import { resolveOpenClawUserDataDir } from "./chrome.js";
import { parseHttpUrl, resolveProfile } from "./config.js"; import { parseHttpUrl, resolveProfile } from "./config.js";
import { import {
@ -26,6 +27,7 @@ export type CreateProfileParams = {
name: string; name: string;
color?: string; color?: string;
cdpUrl?: string; cdpUrl?: string;
userDataDir?: string;
driver?: "openclaw" | "existing-session"; driver?: "openclaw" | "existing-session";
}; };
@ -35,6 +37,7 @@ export type CreateProfileResult = {
transport: "cdp" | "chrome-mcp"; transport: "cdp" | "chrome-mcp";
cdpPort: number | null; cdpPort: number | null;
cdpUrl: string | null; cdpUrl: string | null;
userDataDir: string | null;
color: string; color: string;
isRemote: boolean; isRemote: boolean;
}; };
@ -79,6 +82,8 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
const createProfile = async (params: CreateProfileParams): Promise<CreateProfileResult> => { const createProfile = async (params: CreateProfileParams): Promise<CreateProfileResult> => {
const name = params.name.trim(); const name = params.name.trim();
const rawCdpUrl = params.cdpUrl?.trim() || undefined; 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; const driver = params.driver === "existing-session" ? "existing-session" : undefined;
if (!isValidProfileName(name)) { if (!isValidProfileName(name)) {
@ -104,6 +109,17 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
params.color && HEX_COLOR_RE.test(params.color) ? params.color : allocateColor(usedColors); params.color && HEX_COLOR_RE.test(params.color) ? params.color : allocateColor(usedColors);
let profileConfig: BrowserProfileConfig; 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) { if (rawCdpUrl) {
let parsed: ReturnType<typeof parseHttpUrl>; let parsed: ReturnType<typeof parseHttpUrl>;
try { try {
@ -127,6 +143,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
profileConfig = { profileConfig = {
driver, driver,
attachOnly: true, attachOnly: true,
...(normalizedUserDataDir ? { userDataDir: normalizedUserDataDir } : {}),
color: profileColor, color: profileColor,
}; };
} else { } else {
@ -170,6 +187,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp", transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
cdpPort: capabilities.usesChromeMcp ? null : resolved.cdpPort, cdpPort: capabilities.usesChromeMcp ? null : resolved.cdpPort,
cdpUrl: capabilities.usesChromeMcp ? null : resolved.cdpUrl, cdpUrl: capabilities.usesChromeMcp ? null : resolved.cdpUrl,
userDataDir: resolved.userDataDir ?? null,
color: resolved.color, color: resolved.color,
isRemote: !resolved.cdpIsLoopback, isRemote: !resolved.cdpIsLoopback,
}; };

View File

@ -22,6 +22,9 @@ function changedProfileInvariants(
if (current.cdpIsLoopback !== next.cdpIsLoopback) { if (current.cdpIsLoopback !== next.cdpIsLoopback) {
changed.push("cdpIsLoopback"); changed.push("cdpIsLoopback");
} }
if ((current.userDataDir ?? "") !== (next.userDataDir ?? "")) {
changed.push("userDataDir");
}
return changed; return changed;
} }

View File

@ -65,6 +65,7 @@ export function registerBrowserAgentActHookRoutes(
} }
await uploadChromeMcpFile({ await uploadChromeMcpFile({
profileName: profileCtx.profile.name, profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
uid, uid,
filePath: resolvedPaths[0] ?? "", filePath: resolvedPaths[0] ?? "",
@ -134,6 +135,7 @@ export function registerBrowserAgentActHookRoutes(
} }
await evaluateChromeMcpScript({ await evaluateChromeMcpScript({
profileName: profileCtx.profile.name, profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
fn: `() => { fn: `() => {
const state = (window.__openclawDialogHook ??= {}); const state = (window.__openclawDialogHook ??= {});

View File

@ -78,6 +78,7 @@ function buildExistingSessionWaitPredicate(params: {
async function waitForExistingSessionCondition(params: { async function waitForExistingSessionCondition(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
timeMs?: number; timeMs?: number;
text?: string; text?: string;
@ -103,6 +104,7 @@ async function waitForExistingSessionCondition(params: {
ready = Boolean( ready = Boolean(
await evaluateChromeMcpScript({ await evaluateChromeMcpScript({
profileName: params.profileName, profileName: params.profileName,
userDataDir: params.userDataDir,
targetId: params.targetId, targetId: params.targetId,
fn: `async () => ${predicate}`, fn: `async () => ${predicate}`,
}), }),
@ -111,6 +113,7 @@ async function waitForExistingSessionCondition(params: {
if (ready && params.url) { if (ready && params.url) {
const currentUrl = await evaluateChromeMcpScript({ const currentUrl = await evaluateChromeMcpScript({
profileName: params.profileName, profileName: params.profileName,
userDataDir: params.userDataDir,
targetId: params.targetId, targetId: params.targetId,
fn: "() => window.location.href", fn: "() => window.location.href",
}); });
@ -520,6 +523,7 @@ export function registerBrowserAgentActRoutes(
} }
await clickChromeMcpElement({ await clickChromeMcpElement({
profileName, profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
uid: ref!, uid: ref!,
doubleClick, doubleClick,
@ -586,6 +590,7 @@ export function registerBrowserAgentActRoutes(
} }
await fillChromeMcpElement({ await fillChromeMcpElement({
profileName, profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
uid: ref!, uid: ref!,
value: text, value: text,
@ -593,6 +598,7 @@ export function registerBrowserAgentActRoutes(
if (submit) { if (submit) {
await pressChromeMcpKey({ await pressChromeMcpKey({
profileName, profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
key: "Enter", key: "Enter",
}); });
@ -632,7 +638,12 @@ export function registerBrowserAgentActRoutes(
if (delayMs) { if (delayMs) {
return jsonError(res, 501, "existing-session press does not support 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 }); return res.json({ ok: true, targetId: tab.targetId });
} }
const pw = await requirePwAi(res, `act:${kind}`); const pw = await requirePwAi(res, `act:${kind}`);
@ -669,7 +680,12 @@ export function registerBrowserAgentActRoutes(
"existing-session hover does not support timeoutMs overrides.", "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 }); return res.json({ ok: true, targetId: tab.targetId });
} }
const pw = await requirePwAi(res, `act:${kind}`); const pw = await requirePwAi(res, `act:${kind}`);
@ -709,6 +725,7 @@ export function registerBrowserAgentActRoutes(
} }
await evaluateChromeMcpScript({ await evaluateChromeMcpScript({
profileName, profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`, fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`,
args: [ref!], args: [ref!],
@ -764,6 +781,7 @@ export function registerBrowserAgentActRoutes(
} }
await dragChromeMcpElement({ await dragChromeMcpElement({
profileName, profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
fromUid: startRef!, fromUid: startRef!,
toUid: endRef!, toUid: endRef!,
@ -817,6 +835,7 @@ export function registerBrowserAgentActRoutes(
} }
await fillChromeMcpElement({ await fillChromeMcpElement({
profileName, profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
uid: ref!, uid: ref!,
value: values[0] ?? "", value: values[0] ?? "",
@ -861,6 +880,7 @@ export function registerBrowserAgentActRoutes(
} }
await fillChromeMcpForm({ await fillChromeMcpForm({
profileName, profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
elements: fields.map((field) => ({ elements: fields.map((field) => ({
uid: field.ref, uid: field.ref,
@ -890,6 +910,7 @@ export function registerBrowserAgentActRoutes(
if (isExistingSession) { if (isExistingSession) {
await resizeChromeMcpPage({ await resizeChromeMcpPage({
profileName, profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
width, width,
height, height,
@ -951,6 +972,7 @@ export function registerBrowserAgentActRoutes(
} }
await waitForExistingSessionCondition({ await waitForExistingSessionCondition({
profileName, profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
timeMs, timeMs,
text, text,
@ -1001,6 +1023,7 @@ export function registerBrowserAgentActRoutes(
} }
const result = await evaluateChromeMcpScript({ const result = await evaluateChromeMcpScript({
profileName, profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
fn, fn,
args: ref ? [ref] : undefined, args: ref ? [ref] : undefined,
@ -1036,7 +1059,7 @@ export function registerBrowserAgentActRoutes(
} }
case "close": { case "close": {
if (isExistingSession) { if (isExistingSession) {
await closeChromeMcpTab(profileName, tab.targetId); await closeChromeMcpTab(profileName, tab.targetId, profileCtx.profile.userDataDir);
return res.json({ ok: true, targetId: tab.targetId }); return res.json({ ok: true, targetId: tab.targetId });
} }
const pw = await requirePwAi(res, `act:${kind}`); const pw = await requirePwAi(res, `act:${kind}`);
@ -1151,6 +1174,7 @@ export function registerBrowserAgentActRoutes(
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
await evaluateChromeMcpScript({ await evaluateChromeMcpScript({
profileName: profileCtx.profile.name, profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
args: [ref], args: [ref],
fn: `(el) => { fn: `(el) => {

View File

@ -44,10 +44,12 @@ const CHROME_MCP_OVERLAY_ATTR = "data-openclaw-mcp-overlay";
async function clearChromeMcpOverlay(params: { async function clearChromeMcpOverlay(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
}): Promise<void> { }): Promise<void> {
await evaluateChromeMcpScript({ await evaluateChromeMcpScript({
profileName: params.profileName, profileName: params.profileName,
userDataDir: params.userDataDir,
targetId: params.targetId, targetId: params.targetId,
fn: `() => { fn: `() => {
document.querySelectorAll("[${CHROME_MCP_OVERLAY_ATTR}]").forEach((node) => node.remove()); document.querySelectorAll("[${CHROME_MCP_OVERLAY_ATTR}]").forEach((node) => node.remove());
@ -58,12 +60,14 @@ async function clearChromeMcpOverlay(params: {
async function renderChromeMcpLabels(params: { async function renderChromeMcpLabels(params: {
profileName: string; profileName: string;
userDataDir?: string;
targetId: string; targetId: string;
refs: string[]; refs: string[];
}): Promise<{ labels: number; skipped: number }> { }): Promise<{ labels: number; skipped: number }> {
const refList = JSON.stringify(params.refs); const refList = JSON.stringify(params.refs);
const result = await evaluateChromeMcpScript({ const result = await evaluateChromeMcpScript({
profileName: params.profileName, profileName: params.profileName,
userDataDir: params.userDataDir,
targetId: params.targetId, targetId: params.targetId,
args: params.refs, args: params.refs,
fn: `(...elements) => { fn: `(...elements) => {
@ -231,6 +235,7 @@ export function registerBrowserAgentSnapshotRoutes(
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const result = await navigateChromeMcpPage({ const result = await navigateChromeMcpPage({
profileName: profileCtx.profile.name, profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
url, url,
}); });
@ -322,6 +327,7 @@ export function registerBrowserAgentSnapshotRoutes(
} }
const buffer = await takeChromeMcpScreenshot({ const buffer = await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name, profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
uid: ref, uid: ref,
fullPage, fullPage,
@ -406,6 +412,7 @@ export function registerBrowserAgentSnapshotRoutes(
} }
const snapshot = await takeChromeMcpSnapshot({ const snapshot = await takeChromeMcpSnapshot({
profileName: profileCtx.profile.name, profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
}); });
if (plan.format === "aria") { if (plan.format === "aria") {
@ -430,12 +437,14 @@ export function registerBrowserAgentSnapshotRoutes(
const refs = Object.keys(built.refs); const refs = Object.keys(built.refs);
const labelResult = await renderChromeMcpLabels({ const labelResult = await renderChromeMcpLabels({
profileName: profileCtx.profile.name, profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
refs, refs,
}); });
try { try {
const labeled = await takeChromeMcpScreenshot({ const labeled = await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name, profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
format: "png", format: "png",
}); });
@ -465,6 +474,7 @@ export function registerBrowserAgentSnapshotRoutes(
} finally { } finally {
await clearChromeMcpOverlay({ await clearChromeMcpOverlay({
profileName: profileCtx.profile.name, profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId, targetId: tab.targetId,
}); });
} }

View File

@ -27,6 +27,7 @@ describe("basic browser routes", () => {
driver: "existing-session", driver: "existing-session",
cdpPort: 0, cdpPort: 0,
cdpUrl: "", cdpUrl: "",
userDataDir: "/tmp/brave-profile",
color: "#00AA00", color: "#00AA00",
attachOnly: true, attachOnly: true,
}, },
@ -66,6 +67,7 @@ describe("basic browser routes", () => {
driver: "existing-session", driver: "existing-session",
cdpPort: 0, cdpPort: 0,
cdpUrl: "", cdpUrl: "",
userDataDir: "/tmp/brave-profile",
color: "#00AA00", color: "#00AA00",
attachOnly: true, attachOnly: true,
}, },
@ -88,6 +90,7 @@ describe("basic browser routes", () => {
running: true, running: true,
cdpPort: null, cdpPort: null,
cdpUrl: null, cdpUrl: null,
userDataDir: "/tmp/brave-profile",
pid: 4321, pid: 4321,
}); });
}); });

View File

@ -112,7 +112,7 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
detectedBrowser, detectedBrowser,
detectedExecutablePath, detectedExecutablePath,
detectError, detectError,
userDataDir: profileState?.running?.userDataDir ?? null, userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null,
color: profileCtx.profile.color, color: profileCtx.profile.color,
headless: current.resolved.headless, headless: current.resolved.headless,
noSandbox: current.resolved.noSandbox, 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 name = toStringOrEmpty((req.body as { name?: unknown })?.name);
const color = toStringOrEmpty((req.body as { color?: unknown })?.color); const color = toStringOrEmpty((req.body as { color?: unknown })?.color);
const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl); 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); const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver);
if (!name) { if (!name) {
@ -197,6 +198,7 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
name, name,
color: color || undefined, color: color || undefined,
cdpUrl: cdpUrl || undefined, cdpUrl: cdpUrl || undefined,
userDataDir: userDataDir || undefined,
driver: driver:
driver === "existing-session" driver === "existing-session"
? "existing-session" ? "existing-session"

View File

@ -1,3 +1,4 @@
import fs from "node:fs";
import { import {
PROFILE_ATTACH_RETRY_TIMEOUT_MS, PROFILE_ATTACH_RETRY_TIMEOUT_MS,
PROFILE_POST_RESTART_WS_TIMEOUT_MS, PROFILE_POST_RESTART_WS_TIMEOUT_MS,
@ -63,7 +64,7 @@ export function createProfileAvailability({
const isReachable = async (timeoutMs?: number) => { const isReachable = async (timeoutMs?: number) => {
if (capabilities.usesChromeMcp) { if (capabilities.usesChromeMcp) {
// listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required // listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required
await listChromeMcpTabs(profile.name); await listChromeMcpTabs(profile.name, profile.userDataDir);
return true; return true;
} }
const { httpTimeoutMs, wsTimeoutMs } = resolveTimeouts(timeoutMs); const { httpTimeoutMs, wsTimeoutMs } = resolveTimeouts(timeoutMs);
@ -153,7 +154,12 @@ export function createProfileAvailability({
const ensureBrowserAvailable = async (): Promise<void> => { const ensureBrowserAvailable = async (): Promise<void> => {
await reconcileProfileRuntime(); await reconcileProfileRuntime();
if (capabilities.usesChromeMcp) { 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; return;
} }
const current = state(); const current = state();

View File

@ -1,3 +1,4 @@
import fs from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createBrowserRouteContext } from "./server-context.js"; import { createBrowserRouteContext } from "./server-context.js";
import type { BrowserServerState } from "./server-context.js"; import type { BrowserServerState } from "./server-context.js";
@ -47,6 +48,7 @@ function makeState(): BrowserServerState {
color: "#0066CC", color: "#0066CC",
driver: "existing-session", driver: "existing-session",
attachOnly: true, attachOnly: true,
userDataDir: "/tmp/brave-profile",
}, },
}, },
extraArgs: [], extraArgs: [],
@ -62,6 +64,7 @@ afterEach(() => {
describe("browser server-context existing-session profile", () => { describe("browser server-context existing-session profile", () => {
it("routes tab operations through the Chrome MCP backend", async () => { it("routes tab operations through the Chrome MCP backend", async () => {
fs.mkdirSync("/tmp/brave-profile", { recursive: true });
const state = makeState(); const state = makeState();
const ctx = createBrowserRouteContext({ getState: () => state }); const ctx = createBrowserRouteContext({ getState: () => state });
const live = ctx.forProfile("chrome-live"); const live = ctx.forProfile("chrome-live");
@ -93,10 +96,21 @@ describe("browser server-context existing-session profile", () => {
await live.focusTab("7"); await live.focusTab("7");
await live.stopRunningBrowser(); await live.stopRunningBrowser();
expect(chromeMcp.ensureChromeMcpAvailable).toHaveBeenCalledWith("chrome-live"); expect(chromeMcp.ensureChromeMcpAvailable).toHaveBeenCalledWith(
expect(chromeMcp.listChromeMcpTabs).toHaveBeenCalledWith("chrome-live"); "chrome-live",
expect(chromeMcp.openChromeMcpTab).toHaveBeenCalledWith("chrome-live", "https://openclaw.ai"); "/tmp/brave-profile",
expect(chromeMcp.focusChromeMcpTab).toHaveBeenCalledWith("chrome-live", "7"); );
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"); expect(chromeMcp.closeChromeMcpSession).toHaveBeenCalledWith("chrome-live");
}); });
}); });

View File

@ -94,7 +94,7 @@ export function createProfileSelectionOps({
const resolvedTargetId = await resolveTargetIdOrThrow(targetId); const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
if (capabilities.usesChromeMcp) { if (capabilities.usesChromeMcp) {
await focusChromeMcpTab(profile.name, resolvedTargetId); await focusChromeMcpTab(profile.name, resolvedTargetId, profile.userDataDir);
const profileState = getProfileState(); const profileState = getProfileState();
profileState.lastTargetId = resolvedTargetId; profileState.lastTargetId = resolvedTargetId;
return; return;
@ -124,7 +124,7 @@ export function createProfileSelectionOps({
const resolvedTargetId = await resolveTargetIdOrThrow(targetId); const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
if (capabilities.usesChromeMcp) { if (capabilities.usesChromeMcp) {
await closeChromeMcpTab(profile.name, resolvedTargetId); await closeChromeMcpTab(profile.name, resolvedTargetId, profile.userDataDir);
return; return;
} }

View File

@ -67,7 +67,7 @@ export function createProfileTabOps({
const listTabs = async (): Promise<BrowserTab[]> => { const listTabs = async (): Promise<BrowserTab[]> => {
if (capabilities.usesChromeMcp) { if (capabilities.usesChromeMcp) {
return await listChromeMcpTabs(profile.name); return await listChromeMcpTabs(profile.name, profile.userDataDir);
} }
if (capabilities.usesPersistentPlaywright) { if (capabilities.usesPersistentPlaywright) {
@ -141,7 +141,7 @@ export function createProfileTabOps({
if (capabilities.usesChromeMcp) { if (capabilities.usesChromeMcp) {
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const page = await openChromeMcpTab(profile.name, url); const page = await openChromeMcpTab(profile.name, url, profile.userDataDir);
const profileState = getProfileState(); const profileState = getProfileState();
profileState.lastTargetId = page.targetId; profileState.lastTargetId = page.targetId;
await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts }); await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts });

View File

@ -1,3 +1,4 @@
import fs from "node:fs";
import { fetch as realFetch } from "undici"; import { fetch as realFetch } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { import {
@ -126,10 +127,47 @@ describe("profile CRUD endpoints", () => {
profile?: string; profile?: string;
transport?: string; transport?: string;
cdpPort?: number | null; cdpPort?: number | null;
userDataDir?: string | null;
}; };
expect(createClawdBody.profile).toBe("legacyclawd"); expect(createClawdBody.profile).toBe("legacyclawd");
expect(createClawdBody.transport).toBe("cdp"); expect(createClawdBody.transport).toBe("cdp");
expect(createClawdBody.cdpPort).toBeTypeOf("number"); 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`, { const createLegacyDriver = await realFetch(`${base}/profiles/create`, {
method: "POST", method: "POST",

View File

@ -91,6 +91,42 @@ describe("browser manage output", () => {
expect(output).not.toContain("cdpUrl:"); 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 () => { it("shows chrome-mcp transport in browser profiles output", async () => {
mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) => mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) =>
req.path === "/profiles" req.path === "/profiles"
@ -131,6 +167,7 @@ describe("browser manage output", () => {
transport: "chrome-mcp", transport: "chrome-mcp",
cdpPort: null, cdpPort: null,
cdpUrl: null, cdpUrl: null,
userDataDir: null,
color: "#00AA00", color: "#00AA00",
isRemote: false, isRemote: false,
} }

View File

@ -116,9 +116,13 @@ function formatBrowserConnectionSummary(params: {
isRemote?: boolean; isRemote?: boolean;
cdpPort?: number | null; cdpPort?: number | null;
cdpUrl?: string | null; cdpUrl?: string | null;
userDataDir?: string | null;
}): string { }): string {
if (usesChromeMcpTransport(params)) { 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) { if (params.isRemote) {
return `cdpUrl: ${params.cdpUrl ?? "(unset)"}`; return `cdpUrl: ${params.cdpUrl ?? "(unset)"}`;
@ -155,7 +159,9 @@ export function registerBrowserManageCommands(
`cdpPort: ${status.cdpPort ?? "(unset)"}`, `cdpPort: ${status.cdpPort ?? "(unset)"}`,
`cdpUrl: ${redactCdpUrl(status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`)}`, `cdpUrl: ${redactCdpUrl(status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`)}`,
] ]
: []), : status.userDataDir
? [`userDataDir: ${shortenHomePath(status.userDataDir)}`]
: []),
`browser: ${status.chosenBrowser ?? "unknown"}`, `browser: ${status.chosenBrowser ?? "unknown"}`,
`detectedBrowser: ${status.detectedBrowser ?? "unknown"}`, `detectedBrowser: ${status.detectedBrowser ?? "unknown"}`,
`detectedPath: ${detectedDisplay}`, `detectedPath: ${detectedDisplay}`,
@ -455,9 +461,19 @@ export function registerBrowserManageCommands(
.requiredOption("--name <name>", "Profile name (lowercase, numbers, hyphens)") .requiredOption("--name <name>", "Profile name (lowercase, numbers, hyphens)")
.option("--color <hex>", "Profile color (hex format, e.g. #0066CC)") .option("--color <hex>", "Profile color (hex format, e.g. #0066CC)")
.option("--cdp-url <url>", "CDP URL for remote Chrome (http/https)") .option("--cdp-url <url>", "CDP URL for remote Chrome (http/https)")
.option("--user-data-dir <path>", "User data dir for existing-session Chromium attach")
.option("--driver <driver>", "Profile driver (openclaw|existing-session). Default: openclaw") .option("--driver <driver>", "Profile driver (openclaw|existing-session). Default: openclaw")
.action( .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); const parent = parentOpts(cmd);
await runBrowserCommand(async () => { await runBrowserCommand(async () => {
const result = await callBrowserRequest<BrowserCreateProfileResult>( const result = await callBrowserRequest<BrowserCreateProfileResult>(
@ -469,6 +485,7 @@ export function registerBrowserManageCommands(
name: opts.name, name: opts.name,
color: opts.color, color: opts.color,
cdpUrl: opts.cdpUrl, cdpUrl: opts.cdpUrl,
userDataDir: opts.userDataDir,
driver: opts.driver === "existing-session" ? "existing-session" : undefined, driver: opts.driver === "existing-session" ? "existing-session" : undefined,
}, },
}, },
@ -481,8 +498,8 @@ export function registerBrowserManageCommands(
defaultRuntime.log( defaultRuntime.log(
info( info(
`🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}${ `🦞 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" : ""}`,
), ),
); );
}); });

View File

@ -36,7 +36,7 @@ describe("doctor browser readiness", () => {
expect(noteFn).toHaveBeenCalledTimes(1); 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("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 () => { 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", "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");
});
}); });

View File

@ -7,6 +7,11 @@ import type { OpenClawConfig } from "../config/config.js";
import { note } from "../terminal/note.js"; import { note } from "../terminal/note.js";
const CHROME_MCP_MIN_MAJOR = 144; 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<string, unknown> | null { function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) return value && typeof value === "object" && !Array.isArray(value)
@ -14,33 +19,40 @@ function asRecord(value: unknown): Record<string, unknown> | null {
: null; : null;
} }
function collectChromeMcpProfileNames(cfg: OpenClawConfig): string[] { type ExistingSessionProfile = {
name: string;
userDataDir?: string;
};
function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] {
const browser = asRecord(cfg.browser); const browser = asRecord(cfg.browser);
if (!browser) { if (!browser) {
return []; return [];
} }
const names = new Set<string>(); const profiles = new Map<string, ExistingSessionProfile>();
const defaultProfile = const defaultProfile =
typeof browser.defaultProfile === "string" ? browser.defaultProfile.trim() : ""; typeof browser.defaultProfile === "string" ? browser.defaultProfile.trim() : "";
if (defaultProfile === "user") { if (defaultProfile === "user") {
names.add("user"); profiles.set("user", { name: "user" });
} }
const profiles = asRecord(browser.profiles); const configuredProfiles = asRecord(browser.profiles);
if (!profiles) { if (!configuredProfiles) {
return [...names]; 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 profile = asRecord(rawProfile);
const driver = typeof profile?.driver === "string" ? profile.driver.trim() : ""; const driver = typeof profile?.driver === "string" ? profile.driver.trim() : "";
if (driver === "existing-session") { 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( export async function noteChromeMcpBrowserReadiness(
@ -52,7 +64,7 @@ export async function noteChromeMcpBrowserReadiness(
readVersion?: (executablePath: string) => string | null; readVersion?: (executablePath: string) => string | null;
}, },
) { ) {
const profiles = collectChromeMcpProfileNames(cfg); const profiles = collectChromeMcpProfiles(cfg);
if (profiles.length === 0) { if (profiles.length === 0) {
return; return;
} }
@ -62,24 +74,47 @@ export async function noteChromeMcpBrowserReadiness(
const resolveChromeExecutable = const resolveChromeExecutable =
deps?.resolveChromeExecutable ?? resolveGoogleChromeExecutableForPlatform; deps?.resolveChromeExecutable ?? resolveGoogleChromeExecutableForPlatform;
const readVersion = deps?.readVersion ?? readBrowserVersion; const readVersion = deps?.readVersion ?? readBrowserVersion;
const chrome = resolveChromeExecutable(platform); const explicitProfiles = profiles.filter((profile) => profile.userDataDir);
const profileLabel = profiles.join(", "); const autoConnectProfiles = profiles.filter((profile) => !profile.userDataDir);
const profileLabel = profiles.map((profile) => profile.name).join(", ");
if (!chrome) { if (autoConnectProfiles.length === 0) {
noteFn( noteFn(
[ [
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`, `- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
"- Google Chrome was not found on this host. OpenClaw does not bundle Chrome.", "- These profiles use an explicit Chromium user data directory instead of Chrome's default auto-connect path.",
`- Install Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node.`, `- Verify the matching Chromium-based browser is version ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node.`,
"- In Chrome, enable remote debugging at chrome://inspect/#remote-debugging.", `- Enable remote debugging in that browser's inspect page (${REMOTE_DEBUGGING_PAGES}).`,
"- 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.",
"- Docker, headless, and sandbox browser flows stay on raw CDP; this check only applies to host-local Chrome MCP attach.",
].join("\n"), ].join("\n"),
"Browser", "Browser",
); );
return; 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.<name>.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 versionRaw = readVersion(chrome.path);
const major = parseBrowserMajorVersion(versionRaw); const major = parseBrowserMajorVersion(versionRaw);
const lines = [ const lines = [
@ -99,10 +134,17 @@ export async function noteChromeMcpBrowserReadiness(
lines.push(`- Detected Chrome ${versionRaw}.`); 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( 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"); noteFn(lines.join("\n"), "Browser");
} }

View File

@ -271,6 +271,7 @@ const TARGET_KEYS = [
"browser.headless", "browser.headless",
"browser.noSandbox", "browser.noSandbox",
"browser.profiles", "browser.profiles",
"browser.profiles.*.userDataDir",
"browser.profiles.*.driver", "browser.profiles.*.driver",
"browser.profiles.*.attachOnly", "browser.profiles.*.attachOnly",
"tools", "tools",

View File

@ -260,8 +260,10 @@ export const FIELD_HELP: Record<string, string> = {
"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.", "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": "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.", "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": "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": "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.", "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": "browser.profiles.*.color":

View File

@ -123,6 +123,7 @@ export const FIELD_LABELS: Record<string, string> = {
"browser.profiles": "Browser Profiles", "browser.profiles": "Browser Profiles",
"browser.profiles.*.cdpPort": "Browser Profile CDP Port", "browser.profiles.*.cdpPort": "Browser Profile CDP Port",
"browser.profiles.*.cdpUrl": "Browser Profile CDP URL", "browser.profiles.*.cdpUrl": "Browser Profile CDP URL",
"browser.profiles.*.userDataDir": "Browser Profile User Data Dir",
"browser.profiles.*.driver": "Browser Profile Driver", "browser.profiles.*.driver": "Browser Profile Driver",
"browser.profiles.*.attachOnly": "Browser Profile Attach-only Mode", "browser.profiles.*.attachOnly": "Browser Profile Attach-only Mode",
"browser.profiles.*.color": "Browser Profile Accent Color", "browser.profiles.*.color": "Browser Profile Accent Color",

View File

@ -3,6 +3,8 @@ export type BrowserProfileConfig = {
cdpPort?: number; cdpPort?: number;
/** CDP URL for this profile (use for remote Chrome). */ /** CDP URL for this profile (use for remote Chrome). */
cdpUrl?: string; cdpUrl?: string;
/** Explicit user data directory for existing-session Chrome MCP attachment. */
userDataDir?: string;
/** Profile driver (default: openclaw). */ /** Profile driver (default: openclaw). */
driver?: "openclaw" | "clawd" | "existing-session"; driver?: "openclaw" | "clawd" | "existing-session";
/** If true, never launch a browser for this profile; only attach. Falls back to browser.attachOnly. */ /** If true, never launch a browser for this profile; only attach. Falls back to browser.attachOnly. */

View File

@ -359,6 +359,7 @@ export const OpenClawSchema = z
.object({ .object({
cdpPort: z.number().int().min(1).max(65535).optional(), cdpPort: z.number().int().min(1).max(65535).optional(),
cdpUrl: z.string().optional(), cdpUrl: z.string().optional(),
userDataDir: z.string().optional(),
driver: z driver: z
.union([z.literal("openclaw"), z.literal("clawd"), z.literal("existing-session")]) .union([z.literal("openclaw"), z.literal("clawd"), z.literal("existing-session")])
.optional(), .optional(),
@ -371,7 +372,10 @@ export const OpenClawSchema = z
{ {
message: "Profile must set cdpPort or cdpUrl", 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(), .optional(),
extraArgs: z.array(z.string()).optional(), extraArgs: z.array(z.string()).optional(),