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:
parent
3e360ec8cb
commit
7deb543624
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.',
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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 } : {}),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 ??= {});
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" : ""}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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. */
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user