fix(browser): configure Chrome MCP existing-session launch (#71560)

This commit is contained in:
Vincent Koc
2026-04-25 05:46:39 -07:00
committed by GitHub
parent dd78b7f773
commit ab1d1a5c9e
22 changed files with 482 additions and 135 deletions

View File

@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
- Browser/CDP: make readiness diagnostics use the same discovery-first fallback as reachability for bare `ws://` Browserless and Browserbase CDP URLs. Fixes #69532.
- ACP/OpenCode: update the bundled acpx runtime to 0.6.0 and cover the OpenCode ACP bind path in Docker live tests.
- Browser/existing-session: support per-profile Chrome MCP command/args, map `cdpUrl` to `--browserUrl` or `--wsEndpoint`, and avoid combining endpoint flags with `--userDataDir`. Fixes #47879, #48037, and #62706. Thanks @puneet1409, @zhehao, and @madkow1001.
- Memory-host SDK: use trusted env-proxy mode for remote embedding and batch HTTP calls only when Undici will proxy that target, preserving SSRF DNS pinning for `ALL_PROXY`-only and `NO_PROXY` bypass cases. Fixes #52162. (#71506) Thanks @DhtIsCoding.
- Gateway/dashboard: render Control UI and WebSocket links with `https://`/`wss://` when `gateway.tls.enabled=true`, including `openclaw gateway status`. Fixes #71494. (#71499) Thanks @deepkilo.
- Agents/OpenAI-compatible: default proxy/local completions tool requests to `tool_choice: "auto"` when tools are present, so providers enter native tool-calling mode instead of replying with plain-text tool directives. (#71472) Thanks @Speed-maker.

View File

@@ -1,4 +1,4 @@
445663bd6907368befbfd76f6fcc58f9dc282244697f44e9860391e51e6f2f83 config-baseline.json
f54f808dc85123a5ba788618a6dff7f2c869ced639dd0db34a86802985730dc6 config-baseline.core.json
9a012a9c87b9010683289dc7d68ba5446a4b78beedf381e2c5f9d486f25a9213 config-baseline.json
6128d6eff8c28d17194d1ae9ee7f72abae48da1c6476ab16e6378f1898e4373a config-baseline.core.json
7cd9c908f066c143eab2a201efbc9640f483ab28bba92ddeca1d18cc2b528bc3 config-baseline.channel.json
7825b56a5b3fcdbe2e09ef8fe5d9f12ac3598435afebe20413051e45b0d1968e config-baseline.plugin.json

View File

@@ -139,6 +139,68 @@ describe("chrome MCP page parsing", () => {
]);
});
it("uses browserUrl for existing-session cdpUrl without also passing userDataDir", () => {
expect(
buildChromeMcpArgs({
cdpUrl: "http://127.0.0.1:9222",
userDataDir: "/tmp/brave-profile",
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--browserUrl",
"http://127.0.0.1:9222",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
]);
});
it("uses wsEndpoint for direct existing-session websocket cdpUrl", () => {
expect(
buildChromeMcpArgs({
cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc",
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--wsEndpoint",
"ws://127.0.0.1:9222/devtools/browser/abc",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
]);
});
it("appends custom Chrome MCP args and lets explicit endpoint args override auto-connect", () => {
expect(
buildChromeMcpArgs({
userDataDir: "/tmp/brave-profile",
mcpArgs: ["--browserUrl", "http://127.0.0.1:9222", "--no-usage-statistics"],
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--browserUrl",
"http://127.0.0.1:9222",
"--no-usage-statistics",
]);
});
it("omits the npx package prefix for a custom Chrome MCP command", () => {
expect(
buildChromeMcpArgs({
mcpCommand: "/usr/local/bin/chrome-devtools-mcp",
cdpUrl: "http://127.0.0.1:9222",
}),
).toEqual([
"--browserUrl",
"http://127.0.0.1:9222",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
]);
});
it("parses new_page text responses and returns the created tab", async () => {
const factory: ChromeMcpSessionFactory = async () => createFakeSession();
setChromeMcpSessionFactoryForTest(factory);
@@ -435,8 +497,8 @@ describe("chrome MCP page parsing", () => {
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 factory: ChromeMcpSessionFactory = async (profileName, options) => {
factoryCalls.push({ profileName, userDataDir: options?.userDataDir });
const session = createFakeSession();
const closeMock = vi.fn().mockResolvedValue(undefined);
session.client.close = closeMock as typeof session.client.close;

View File

@@ -37,6 +37,21 @@ type ChromeMcpCallOptions = {
signal?: AbortSignal;
};
export type ChromeMcpProfileOptions = {
userDataDir?: string;
cdpUrl?: string;
mcpCommand?: string;
mcpArgs?: string[];
};
type NormalizedChromeMcpProfileOptions = {
userDataDir?: string;
browserUrl?: string;
command: string;
extraArgs: string[];
};
type ChromeMcpOptionsInput = string | ChromeMcpProfileOptions | NormalizedChromeMcpProfileOptions;
type ChromeMcpSessionLease = {
session: ChromeMcpSession;
cacheKey: string;
@@ -45,18 +60,26 @@ type ChromeMcpSessionLease = {
type ChromeMcpSessionFactory = (
profileName: string,
userDataDir?: string,
options?: NormalizedChromeMcpProfileOptions,
) => Promise<ChromeMcpSession>;
const DEFAULT_CHROME_MCP_COMMAND = "npx";
const DEFAULT_CHROME_MCP_ARGS = [
"-y",
"chrome-devtools-mcp@latest",
"--autoConnect",
const DEFAULT_CHROME_MCP_PACKAGE_ARGS = ["-y", "chrome-devtools-mcp@latest"];
const DEFAULT_CHROME_MCP_FEATURE_ARGS = [
// Direct chrome-devtools-mcp launches do not enable structuredContent by default.
"--experimentalStructuredContent",
"--experimental-page-id-routing",
];
const CHROME_MCP_CONNECTION_FLAGS = new Set([
"--autoConnect",
"--auto-connect",
"--browserUrl",
"--browser-url",
"--wsEndpoint",
"--ws-endpoint",
"-w",
]);
const CHROME_MCP_USER_DATA_DIR_FLAGS = new Set(["--userDataDir", "--user-data-dir"]);
const CHROME_MCP_NEW_PAGE_TIMEOUT_MS = 5_000;
const CHROME_MCP_NAVIGATE_TIMEOUT_MS = 20_000;
const CHROME_MCP_HANDSHAKE_TIMEOUT_MS = 30_000;
@@ -197,8 +220,83 @@ function normalizeChromeMcpUserDataDir(userDataDir?: string): string | undefined
return trimmed ? trimmed : undefined;
}
function buildChromeMcpSessionCacheKey(profileName: string, userDataDir?: string): string {
return JSON.stringify([profileName, normalizeChromeMcpUserDataDir(userDataDir) ?? ""]);
function normalizeChromeMcpStringList(values?: string[]): string[] {
return Array.isArray(values)
? values.filter(
(value): value is string => typeof value === "string" && value.trim().length > 0,
)
: [];
}
function normalizeChromeMcpOptions(
input?: ChromeMcpOptionsInput,
): NormalizedChromeMcpProfileOptions {
if (typeof input === "object" && input && "command" in input && "extraArgs" in input) {
return input;
}
const options = typeof input === "string" ? { userDataDir: input } : (input ?? {});
const command = normalizeOptionalString(options.mcpCommand) ?? DEFAULT_CHROME_MCP_COMMAND;
return {
command,
userDataDir: normalizeChromeMcpUserDataDir(options.userDataDir),
browserUrl: normalizeOptionalString(options.cdpUrl),
extraArgs: normalizeChromeMcpStringList(options.mcpArgs),
};
}
function hasFlag(args: string[], flags: Set<string>): boolean {
return args.some((arg) => {
const [name] = arg.split("=", 1);
return flags.has(name ?? arg);
});
}
function isChromeMcpWebSocketEndpoint(url: string): boolean {
return /^wss?:\/\//i.test(url);
}
function buildChromeMcpConnectionArgs(options: NormalizedChromeMcpProfileOptions): string[] {
if (hasFlag(options.extraArgs, CHROME_MCP_CONNECTION_FLAGS)) {
return [];
}
if (options.browserUrl) {
return isChromeMcpWebSocketEndpoint(options.browserUrl)
? ["--wsEndpoint", options.browserUrl]
: ["--browserUrl", options.browserUrl];
}
return ["--autoConnect"];
}
function buildChromeMcpUserDataDirArgs(options: NormalizedChromeMcpProfileOptions): string[] {
if (
!options.userDataDir ||
options.browserUrl ||
hasFlag(options.extraArgs, CHROME_MCP_CONNECTION_FLAGS) ||
hasFlag(options.extraArgs, CHROME_MCP_USER_DATA_DIR_FLAGS)
) {
return [];
}
return ["--userDataDir", options.userDataDir];
}
function buildChromeMcpSessionCacheKey(
profileName: string,
options: NormalizedChromeMcpProfileOptions,
): string {
return JSON.stringify([
profileName,
options.userDataDir ?? "",
options.browserUrl ?? "",
options.command,
options.extraArgs,
]);
}
function chromeMcpProfileOptionsFromParams(params: {
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
}): string | ChromeMcpProfileOptions | undefined {
return params.profile ?? params.userDataDir;
}
function cacheKeyMatchesProfileName(cacheKey: string, profileName: string): boolean {
@@ -234,11 +332,20 @@ async function closeChromeMcpSessionsForProfile(
return closed;
}
export function buildChromeMcpArgs(userDataDir?: string): string[] {
const normalizedUserDataDir = normalizeChromeMcpUserDataDir(userDataDir);
return normalizedUserDataDir
? [...DEFAULT_CHROME_MCP_ARGS, "--userDataDir", normalizedUserDataDir]
: [...DEFAULT_CHROME_MCP_ARGS];
function buildChromeMcpArgsFromOptions(options: NormalizedChromeMcpProfileOptions): string[] {
const commandPrefix =
options.command === DEFAULT_CHROME_MCP_COMMAND ? DEFAULT_CHROME_MCP_PACKAGE_ARGS : [];
return [
...commandPrefix,
...buildChromeMcpConnectionArgs(options),
...DEFAULT_CHROME_MCP_FEATURE_ARGS,
...buildChromeMcpUserDataDirArgs(options),
...options.extraArgs,
];
}
export function buildChromeMcpArgs(input?: string | ChromeMcpProfileOptions): string[] {
return buildChromeMcpArgsFromOptions(normalizeChromeMcpOptions(input));
}
function drainStderr(transport: StdioClientTransport): () => string {
@@ -289,11 +396,11 @@ async function withChromeMcpHandshakeTimeout<T>(task: Promise<T>): Promise<T> {
async function createRealSession(
profileName: string,
userDataDir?: string,
options: NormalizedChromeMcpProfileOptions = normalizeChromeMcpOptions(),
): Promise<ChromeMcpSession> {
const transport = new StdioClientTransport({
command: DEFAULT_CHROME_MCP_COMMAND,
args: buildChromeMcpArgs(userDataDir),
command: options.command,
args: buildChromeMcpArgsFromOptions(options),
stderr: "pipe",
});
const client = new Client(
@@ -325,9 +432,11 @@ async function createRealSession(
`Chrome MCP attach failed for profile "${profileName}". Subprocess stderr:\n${stderr}`,
);
}
const targetLabel = userDataDir
? `the configured Chromium user data dir (${userDataDir})`
: "Google Chrome's default profile";
const targetLabel = options.browserUrl
? `the configured Chrome endpoint (${options.browserUrl})`
: options.userDataDir
? `the configured Chromium user data dir (${options.userDataDir})`
: "Google Chrome's default profile";
throw new BrowserProfileUnavailableError(
`Chrome MCP existing-session attach failed for profile "${profileName}". ` +
`Make sure ${targetLabel} is running locally with remote debugging enabled. ` +
@@ -377,10 +486,11 @@ async function waitForChromeMcpReady(
async function getSession(
profileName: string,
userDataDir?: string,
profileOptions?: ChromeMcpOptionsInput,
timeoutMs?: number,
): Promise<ChromeMcpSession> {
const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir);
const options = normalizeChromeMcpOptions(profileOptions);
const cacheKey = buildChromeMcpSessionCacheKey(profileName, options);
await closeChromeMcpSessionsForProfile(profileName, cacheKey);
let session = sessions.get(cacheKey);
@@ -392,7 +502,7 @@ async function getSession(
let pending = pendingSessions.get(cacheKey);
if (!pending) {
pending = (async () => {
const created = await (sessionFactory ?? createRealSession)(profileName, userDataDir);
const created = await (sessionFactory ?? createRealSession)(profileName, options);
if (pendingSessions.get(cacheKey) === pending) {
sessions.set(cacheKey, created);
} else {
@@ -465,10 +575,11 @@ async function getExistingSession(
async function createEphemeralSession(
profileName: string,
userDataDir?: string,
profileOptions?: ChromeMcpOptionsInput,
timeoutMs?: number,
): Promise<ChromeMcpSession> {
const session = await (sessionFactory ?? createRealSession)(profileName, userDataDir);
const options = normalizeChromeMcpOptions(profileOptions);
const session = await (sessionFactory ?? createRealSession)(profileName, options);
try {
await waitForChromeMcpReady(session, profileName, timeoutMs);
return session;
@@ -480,13 +591,14 @@ async function createEphemeralSession(
async function leaseSession(
profileName: string,
userDataDir?: string,
profileOptions?: ChromeMcpOptionsInput,
options: ChromeMcpCallOptions = {},
): Promise<ChromeMcpSessionLease> {
const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir);
const normalizedProfileOptions = normalizeChromeMcpOptions(profileOptions);
const cacheKey = buildChromeMcpSessionCacheKey(profileName, normalizedProfileOptions);
if (!options.ephemeral) {
return {
session: await getSession(profileName, userDataDir, options.timeoutMs),
session: await getSession(profileName, normalizedProfileOptions, options.timeoutMs),
cacheKey,
temporary: false,
};
@@ -504,7 +616,7 @@ async function leaseSession(
}
return {
session: await createEphemeralSession(profileName, userDataDir, options.timeoutMs),
session: await createEphemeralSession(profileName, normalizedProfileOptions, options.timeoutMs),
cacheKey,
temporary: true,
};
@@ -512,7 +624,7 @@ async function leaseSession(
async function callTool(
profileName: string,
userDataDir: string | undefined,
profileOptions: ChromeMcpOptionsInput | undefined,
name: string,
args: Record<string, unknown> = {},
options: ChromeMcpCallOptions = {},
@@ -524,7 +636,7 @@ async function callTool(
}
for (let attempt = 0; attempt < 2; attempt += 1) {
const lease = await leaseSession(profileName, userDataDir, options);
const lease = await leaseSession(profileName, profileOptions, options);
const rawCall = lease.session.client.callTool({
name,
arguments: args,
@@ -620,9 +732,9 @@ async function withTempFile<T>(fn: (filePath: string) => Promise<T>): Promise<T>
async function findPageById(
profileName: string,
pageId: number,
userDataDir?: string,
profileOptions?: string | ChromeMcpProfileOptions,
): Promise<ChromeMcpStructuredPage> {
const pages = await listChromeMcpPages(profileName, userDataDir);
const pages = await listChromeMcpPages(profileName, profileOptions);
const page = pages.find((entry) => entry.id === pageId);
if (!page) {
throw new BrowserTabNotFoundError();
@@ -632,10 +744,10 @@ async function findPageById(
export async function ensureChromeMcpAvailable(
profileName: string,
userDataDir?: string,
profileOptions?: string | ChromeMcpProfileOptions,
options: ChromeMcpCallOptions = {},
): Promise<void> {
const lease = await leaseSession(profileName, userDataDir, options);
const lease = await leaseSession(profileName, profileOptions, options);
if (lease.temporary) {
await lease.session.client.close().catch(() => {});
}
@@ -663,28 +775,28 @@ export async function stopAllChromeMcpSessions(): Promise<void> {
export async function listChromeMcpPages(
profileName: string,
userDataDir?: string,
profileOptions?: string | ChromeMcpProfileOptions,
options: ChromeMcpCallOptions = {},
): Promise<ChromeMcpStructuredPage[]> {
const result = await callTool(profileName, userDataDir, "list_pages", {}, options);
const result = await callTool(profileName, profileOptions, "list_pages", {}, options);
return extractStructuredPages(result);
}
export async function listChromeMcpTabs(
profileName: string,
userDataDir?: string,
profileOptions?: string | ChromeMcpProfileOptions,
options: ChromeMcpCallOptions = {},
): Promise<BrowserTab[]> {
return toBrowserTabs(await listChromeMcpPages(profileName, userDataDir, options));
return toBrowserTabs(await listChromeMcpPages(profileName, profileOptions, options));
}
export async function openChromeMcpTab(
profileName: string,
url: string,
userDataDir?: string,
profileOptions?: string | ChromeMcpProfileOptions,
): Promise<BrowserTab> {
const targetUrl = url.trim() || "about:blank";
const result = await callTool(profileName, userDataDir, "new_page", {
const result = await callTool(profileName, profileOptions, "new_page", {
url: "about:blank",
timeout: CHROME_MCP_NEW_PAGE_TIMEOUT_MS,
});
@@ -700,7 +812,8 @@ export async function openChromeMcpTab(
: (
await navigateChromeMcpPage({
profileName,
userDataDir,
profile: typeof profileOptions === "string" ? undefined : profileOptions,
userDataDir: typeof profileOptions === "string" ? profileOptions : undefined,
targetId,
url: targetUrl,
timeoutMs: CHROME_MCP_NAVIGATE_TIMEOUT_MS,
@@ -717,9 +830,9 @@ export async function openChromeMcpTab(
export async function focusChromeMcpTab(
profileName: string,
targetId: string,
userDataDir?: string,
profileOptions?: string | ChromeMcpProfileOptions,
): Promise<void> {
await callTool(profileName, userDataDir, "select_page", {
await callTool(profileName, profileOptions, "select_page", {
pageId: parsePageId(targetId),
bringToFront: true,
});
@@ -728,13 +841,14 @@ export async function focusChromeMcpTab(
export async function closeChromeMcpTab(
profileName: string,
targetId: string,
userDataDir?: string,
profileOptions?: string | ChromeMcpProfileOptions,
): Promise<void> {
await callTool(profileName, userDataDir, "close_page", { pageId: parsePageId(targetId) });
await callTool(profileName, profileOptions, "close_page", { pageId: parsePageId(targetId) });
}
export async function navigateChromeMcpPage(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
url: string;
@@ -743,7 +857,7 @@ export async function navigateChromeMcpPage(params: {
const resolvedTimeoutMs = params.timeoutMs ?? CHROME_MCP_NAVIGATE_TIMEOUT_MS;
await callTool(
params.profileName,
params.userDataDir,
chromeMcpProfileOptionsFromParams(params),
"navigate_page",
{
pageId: parsePageId(params.targetId),
@@ -756,24 +870,31 @@ export async function navigateChromeMcpPage(params: {
const page = await findPageById(
params.profileName,
parsePageId(params.targetId),
params.userDataDir,
chromeMcpProfileOptionsFromParams(params),
);
return { url: page.url ?? params.url };
}
export async function takeChromeMcpSnapshot(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
}): Promise<ChromeMcpSnapshotNode> {
const result = await callTool(params.profileName, params.userDataDir, "take_snapshot", {
pageId: parsePageId(params.targetId),
});
const result = await callTool(
params.profileName,
chromeMcpProfileOptionsFromParams(params),
"take_snapshot",
{
pageId: parsePageId(params.targetId),
},
);
return extractSnapshot(result);
}
export async function takeChromeMcpScreenshot(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
uid?: string;
@@ -784,7 +905,7 @@ export async function takeChromeMcpScreenshot(params: {
return await withTempFile(async (filePath) => {
await callTool(
params.profileName,
params.userDataDir,
chromeMcpProfileOptionsFromParams(params),
"take_screenshot",
{
pageId: parsePageId(params.targetId),
@@ -801,6 +922,7 @@ export async function takeChromeMcpScreenshot(params: {
export async function clickChromeMcpElement(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
uid: string;
@@ -810,7 +932,7 @@ export async function clickChromeMcpElement(params: {
}): Promise<void> {
await callTool(
params.profileName,
params.userDataDir,
chromeMcpProfileOptionsFromParams(params),
"click",
{
pageId: parsePageId(params.targetId),
@@ -826,6 +948,7 @@ export async function clickChromeMcpElement(params: {
export async function clickChromeMcpCoords(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
x: number;
@@ -843,6 +966,7 @@ export async function clickChromeMcpCoords(params: {
const doubleClick = params.doubleClick ? "true" : "false";
await evaluateChromeMcpScript({
profileName: params.profileName,
profile: params.profile,
userDataDir: params.userDataDir,
targetId: params.targetId,
fn: `async () => {
@@ -885,12 +1009,13 @@ export async function clickChromeMcpCoords(params: {
export async function fillChromeMcpElement(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
uid: string;
value: string;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "fill", {
await callTool(params.profileName, chromeMcpProfileOptionsFromParams(params), "fill", {
pageId: parsePageId(params.targetId),
uid: params.uid,
value: params.value,
@@ -899,11 +1024,12 @@ export async function fillChromeMcpElement(params: {
export async function fillChromeMcpForm(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
elements: Array<{ uid: string; value: string }>;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "fill_form", {
await callTool(params.profileName, chromeMcpProfileOptionsFromParams(params), "fill_form", {
pageId: parsePageId(params.targetId),
elements: params.elements,
});
@@ -911,11 +1037,12 @@ export async function fillChromeMcpForm(params: {
export async function hoverChromeMcpElement(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
uid: string;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "hover", {
await callTool(params.profileName, chromeMcpProfileOptionsFromParams(params), "hover", {
pageId: parsePageId(params.targetId),
uid: params.uid,
});
@@ -923,12 +1050,13 @@ export async function hoverChromeMcpElement(params: {
export async function dragChromeMcpElement(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
fromUid: string;
toUid: string;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "drag", {
await callTool(params.profileName, chromeMcpProfileOptionsFromParams(params), "drag", {
pageId: parsePageId(params.targetId),
from_uid: params.fromUid,
to_uid: params.toUid,
@@ -937,12 +1065,13 @@ export async function dragChromeMcpElement(params: {
export async function uploadChromeMcpFile(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
uid: string;
filePath: string;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "upload_file", {
await callTool(params.profileName, chromeMcpProfileOptionsFromParams(params), "upload_file", {
pageId: parsePageId(params.targetId),
uid: params.uid,
filePath: params.filePath,
@@ -951,11 +1080,12 @@ export async function uploadChromeMcpFile(params: {
export async function pressChromeMcpKey(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
key: string;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "press_key", {
await callTool(params.profileName, chromeMcpProfileOptionsFromParams(params), "press_key", {
pageId: parsePageId(params.targetId),
key: params.key,
});
@@ -963,12 +1093,13 @@ export async function pressChromeMcpKey(params: {
export async function resizeChromeMcpPage(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
width: number;
height: number;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "resize_page", {
await callTool(params.profileName, chromeMcpProfileOptionsFromParams(params), "resize_page", {
pageId: parsePageId(params.targetId),
width: params.width,
height: params.height,
@@ -977,12 +1108,13 @@ export async function resizeChromeMcpPage(params: {
export async function handleChromeMcpDialog(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
action: "accept" | "dismiss";
promptText?: string;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "handle_dialog", {
await callTool(params.profileName, chromeMcpProfileOptionsFromParams(params), "handle_dialog", {
pageId: parsePageId(params.targetId),
action: params.action,
...(params.promptText ? { promptText: params.promptText } : {}),
@@ -991,27 +1123,34 @@ export async function handleChromeMcpDialog(params: {
export async function evaluateChromeMcpScript(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
fn: string;
args?: string[];
}): Promise<unknown> {
const result = await callTool(params.profileName, params.userDataDir, "evaluate_script", {
pageId: parsePageId(params.targetId),
function: params.fn,
...(params.args?.length ? { args: params.args } : {}),
});
const result = await callTool(
params.profileName,
chromeMcpProfileOptionsFromParams(params),
"evaluate_script",
{
pageId: parsePageId(params.targetId),
function: params.fn,
...(params.args?.length ? { args: params.args } : {}),
},
);
return extractJsonMessage(result);
}
export async function waitForChromeMcpText(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
text: string[];
timeoutMs?: number;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "wait_for", {
await callTool(params.profileName, chromeMcpProfileOptionsFromParams(params), "wait_for", {
pageId: parsePageId(params.targetId),
text: params.text,
...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}),

View File

@@ -735,6 +735,47 @@ describe("browser config", () => {
);
});
it("resolves Chrome MCP command, args, and endpoint URL for existing-session profiles", () => {
const resolved = resolveBrowserConfig({
profiles: {
"chrome-live": {
driver: "existing-session",
attachOnly: true,
cdpUrl: "http://127.0.0.1:9222/",
mcpCommand: " /usr/local/bin/chrome-devtools-mcp ",
mcpArgs: ["--no-usage-statistics", " ", "--performanceCrux", "false"],
color: "#00AA00",
},
},
});
const profile = resolveProfile(resolved, "chrome-live");
expect(profile?.driver).toBe("existing-session");
expect(profile?.cdpUrl).toBe("http://127.0.0.1:9222");
expect(profile?.cdpHost).toBe("127.0.0.1");
expect(profile?.cdpIsLoopback).toBe(true);
expect(profile?.mcpCommand).toBe("/usr/local/bin/chrome-devtools-mcp");
expect(profile?.mcpArgs).toEqual(["--no-usage-statistics", "--performanceCrux", "false"]);
});
it("preserves direct websocket cdpUrl for existing-session profiles", () => {
const resolved = resolveBrowserConfig({
profiles: {
"chrome-live": {
driver: "existing-session",
attachOnly: true,
cdpUrl: "ws://127.0.0.1:9222/devtools/browser/ABC?token=test-key",
color: "#00AA00",
},
},
});
const profile = resolveProfile(resolved, "chrome-live");
expect(profile?.cdpUrl).toBe("ws://127.0.0.1:9222/devtools/browser/ABC?token=test-key");
expect(profile?.cdpHost).toBe("127.0.0.1");
expect(profile?.cdpIsLoopback).toBe(true);
});
it("sets usesChromeMcp only for existing-session profiles", () => {
const resolved = resolveBrowserConfig({
profiles: {

View File

@@ -103,6 +103,8 @@ export type ResolvedBrowserProfile = {
cdpHost: string;
cdpIsLoopback: boolean;
userDataDir?: string;
mcpCommand?: string;
mcpArgs?: string[];
color: string;
driver: "openclaw" | "existing-session";
executablePath?: string;
@@ -180,6 +182,37 @@ function normalizeExecutablePath(raw: string | undefined): string | undefined {
return path.resolve(value.replace(/^~(?=$|[\\/])/, os.homedir()));
}
function normalizeExistingSessionCdpUrl(
raw: string | undefined,
profileName: string,
): { cdpUrl: string; cdpHost: string; cdpIsLoopback: boolean } | undefined {
const value = normalizeOptionalString(raw);
if (!value) {
return undefined;
}
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`browser.profiles.${profileName}.cdpUrl must be a valid URL.`);
}
if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
throw new Error(`browser.profiles.${profileName}.cdpUrl must use http, https, ws, or wss.`);
}
const normalized =
parsed.protocol === "http:" || parsed.protocol === "https:"
? parsed.toString().replace(/\/$/, "")
: parsed.toString();
return {
cdpUrl: normalized,
cdpHost: parsed.hostname,
cdpIsLoopback: isLoopbackHost(parsed.hostname),
};
}
function hasLinuxDisplay(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.DISPLAY?.trim() || env.WAYLAND_DISPLAY?.trim());
}
@@ -442,13 +475,16 @@ export function resolveProfile(
const executablePath = normalizeExecutablePath(profile.executablePath) ?? resolved.executablePath;
if (driver === "existing-session") {
const existingSessionCdp = normalizeExistingSessionCdpUrl(rawProfileUrl, profileName);
return {
name: profileName,
cdpPort: 0,
cdpUrl: "",
cdpHost: "",
cdpIsLoopback: true,
cdpUrl: existingSessionCdp?.cdpUrl ?? "",
cdpHost: existingSessionCdp?.cdpHost ?? "",
cdpIsLoopback: existingSessionCdp?.cdpIsLoopback ?? true,
userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined,
mcpCommand: normalizeOptionalString(profile.mcpCommand),
mcpArgs: normalizeStringList(profile.mcpArgs) ?? undefined,
color: profile.color,
driver,
executablePath,

View File

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

View File

@@ -10,6 +10,7 @@ import {
hoverChromeMcpElement,
pressChromeMcpKey,
resizeChromeMcpPage,
type ChromeMcpProfileOptions,
} from "../chrome-mcp.js";
import type { BrowserActRequest } from "../client-actions.types.js";
import {
@@ -48,11 +49,13 @@ const EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS = [0, 250, 500]
async function readExistingSessionLocationHref(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
}): Promise<string> {
const currentUrl = await evaluateChromeMcpScript({
profileName: params.profileName,
profile: params.profile,
userDataDir: params.userDataDir,
targetId: params.targetId,
fn: "() => window.location.href",
@@ -69,6 +72,7 @@ async function readExistingSessionLocationHref(params: {
async function assertExistingSessionPostInteractionNavigationAllowed(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
ssrfPolicy?: BrowserNavigationPolicyOptions["ssrfPolicy"];
@@ -208,6 +212,7 @@ function buildExistingSessionWaitPredicate(params: {
async function waitForExistingSessionCondition(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
timeMs?: number;
@@ -234,6 +239,7 @@ async function waitForExistingSessionCondition(params: {
ready = Boolean(
await evaluateChromeMcpScript({
profileName: params.profileName,
profile: params.profile,
userDataDir: params.userDataDir,
targetId: params.targetId,
fn: `async () => ${predicate}`,
@@ -243,6 +249,7 @@ async function waitForExistingSessionCondition(params: {
if (ready && params.url) {
const currentUrl = await evaluateChromeMcpScript({
profileName: params.profileName,
profile: params.profile,
userDataDir: params.userDataDir,
targetId: params.targetId,
fn: "() => window.location.href",
@@ -406,7 +413,7 @@ export function registerBrowserAgentActRoutes(
: new Set<string>();
const existingSessionNavigationGuard = {
profileName,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
ssrfPolicy,
listTabs: () => profileCtx.listTabs(),
@@ -427,7 +434,7 @@ export function registerBrowserAgentActRoutes(
execute: () =>
clickChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
uid: action.ref!,
doubleClick: action.doubleClick ?? false,
@@ -442,7 +449,7 @@ export function registerBrowserAgentActRoutes(
execute: () =>
clickChromeMcpCoords({
profileName,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
x: action.x,
y: action.y,
@@ -458,7 +465,7 @@ export function registerBrowserAgentActRoutes(
execute: async () => {
await fillChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
uid: action.ref!,
value: action.text,
@@ -466,7 +473,7 @@ export function registerBrowserAgentActRoutes(
if (action.submit) {
await pressChromeMcpKey({
profileName,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
key: "Enter",
});
@@ -480,7 +487,7 @@ export function registerBrowserAgentActRoutes(
execute: () =>
pressChromeMcpKey({
profileName,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
key: action.key,
}),
@@ -492,7 +499,7 @@ export function registerBrowserAgentActRoutes(
execute: () =>
hoverChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
uid: action.ref!,
}),
@@ -504,7 +511,7 @@ export function registerBrowserAgentActRoutes(
execute: () =>
evaluateChromeMcpScript({
profileName,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`,
args: [action.ref!],
@@ -517,7 +524,7 @@ export function registerBrowserAgentActRoutes(
execute: () =>
dragChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
fromUid: action.startRef!,
toUid: action.endRef!,
@@ -530,7 +537,7 @@ export function registerBrowserAgentActRoutes(
execute: () =>
fillChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
uid: action.ref!,
value: action.values[0] ?? "",
@@ -543,7 +550,7 @@ export function registerBrowserAgentActRoutes(
execute: () =>
fillChromeMcpForm({
profileName,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
elements: action.fields.map((field) => ({
uid: field.ref,
@@ -556,7 +563,7 @@ export function registerBrowserAgentActRoutes(
case "resize":
await resizeChromeMcpPage({
profileName,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
width: action.width,
height: action.height,
@@ -565,7 +572,7 @@ export function registerBrowserAgentActRoutes(
case "wait":
await waitForExistingSessionCondition({
profileName,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
timeMs: action.timeMs,
text: action.text,
@@ -582,7 +589,7 @@ export function registerBrowserAgentActRoutes(
execute: () =>
evaluateChromeMcpScript({
profileName,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
fn: action.fn,
args: action.ref ? [action.ref] : undefined,
@@ -592,7 +599,7 @@ export function registerBrowserAgentActRoutes(
return await jsonOk({ result });
}
case "close":
await closeChromeMcpTab(profileName, tab.targetId, profileCtx.profile.userDataDir);
await closeChromeMcpTab(profileName, tab.targetId, profileCtx.profile);
return await jsonOk();
case "batch":
return jsonActError(
@@ -713,7 +720,7 @@ export function registerBrowserAgentActRoutes(
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
await evaluateChromeMcpScript({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
args: [ref],
fn: `(el) => {

View File

@@ -140,6 +140,7 @@ describe("existing-session browser routes", () => {
});
expect(chromeMcpMocks.takeChromeMcpSnapshot).toHaveBeenCalledWith({
profileName: "chrome-live",
profile: expect.objectContaining({ name: "chrome-live", driver: "existing-session" }),
targetId: "7",
});
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
@@ -166,6 +167,7 @@ describe("existing-session browser routes", () => {
});
expect(chromeMcpMocks.takeChromeMcpScreenshot).toHaveBeenCalledWith({
profileName: "chrome-live",
profile: expect.objectContaining({ name: "chrome-live", driver: "existing-session" }),
targetId: "7",
uid: "btn-1",
fullPage: false,
@@ -285,6 +287,8 @@ describe("existing-session browser routes", () => {
expect(response.body).toMatchObject({ ok: true, targetId: "7" });
expect(chromeMcpMocks.evaluateChromeMcpScript).toHaveBeenCalledWith({
profileName: "chrome-live",
profile: expect.objectContaining({ name: "chrome-live", driver: "existing-session" }),
userDataDir: undefined,
targetId: "7",
fn: "() => window.location.href",
});
@@ -308,7 +312,7 @@ describe("existing-session browser routes", () => {
expect(response.statusCode).toBe(200);
expect(chromeMcpMocks.clickChromeMcpElement).toHaveBeenCalledWith({
profileName: "chrome-live",
userDataDir: undefined,
profile: expect.objectContaining({ name: "chrome-live", driver: "existing-session" }),
targetId: "7",
uid: "btn-1",
doubleClick: false,
@@ -334,7 +338,7 @@ describe("existing-session browser routes", () => {
expect(response.body).toMatchObject({ ok: true, targetId: "7", url: "https://example.com" });
expect(chromeMcpMocks.clickChromeMcpCoords).toHaveBeenCalledWith({
profileName: "chrome-live",
userDataDir: undefined,
profile: expect.objectContaining({ name: "chrome-live", driver: "existing-session" }),
targetId: "7",
x: 25,
y: 32,

View File

@@ -7,6 +7,7 @@ import {
navigateChromeMcpPage,
takeChromeMcpScreenshot,
takeChromeMcpSnapshot,
type ChromeMcpProfileOptions,
} from "../chrome-mcp.js";
import {
buildAiSnapshotFromChromeMcpSnapshot,
@@ -57,11 +58,13 @@ function browserNavigationPolicyForProfile(ctx: BrowserRouteContext, profileCtx:
async function collectChromeMcpSnapshotUrls(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
}): Promise<Array<{ text: string; url: string }>> {
const result = await evaluateChromeMcpScript({
profileName: params.profileName,
profile: params.profile,
userDataDir: params.userDataDir,
targetId: params.targetId,
fn: `() => {
@@ -102,11 +105,13 @@ function appendSnapshotUrls(snapshot: string, urls: Array<{ text: string; url: s
async function clearChromeMcpOverlay(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
}): Promise<void> {
await evaluateChromeMcpScript({
profileName: params.profileName,
profile: params.profile,
userDataDir: params.userDataDir,
targetId: params.targetId,
fn: `() => {
@@ -118,6 +123,7 @@ async function clearChromeMcpOverlay(params: {
async function renderChromeMcpLabels(params: {
profileName: string;
profile?: ChromeMcpProfileOptions;
userDataDir?: string;
targetId: string;
refs: string[];
@@ -125,6 +131,7 @@ async function renderChromeMcpLabels(params: {
const refList = JSON.stringify(params.refs);
const result = await evaluateChromeMcpScript({
profileName: params.profileName,
profile: params.profile,
userDataDir: params.userDataDir,
targetId: params.targetId,
args: params.refs,
@@ -265,7 +272,7 @@ export function registerBrowserAgentSnapshotRoutes(
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const result = await navigateChromeMcpPage({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
url,
});
@@ -369,20 +376,20 @@ export function registerBrowserAgentSnapshotRoutes(
if (labels) {
const snapshot = await takeChromeMcpSnapshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
});
const built = buildAiSnapshotFromChromeMcpSnapshot({ root: snapshot });
const labelResult = await renderChromeMcpLabels({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
refs: Object.keys(built.refs),
});
try {
const buffer = await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
fullPage,
format: type,
@@ -401,7 +408,7 @@ export function registerBrowserAgentSnapshotRoutes(
} finally {
await clearChromeMcpOverlay({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
});
}
@@ -409,7 +416,7 @@ export function registerBrowserAgentSnapshotRoutes(
}
const buffer = await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
uid: ref,
fullPage,
@@ -531,7 +538,7 @@ export function registerBrowserAgentSnapshotRoutes(
}
const snapshot = await takeChromeMcpSnapshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
});
if (plan.format === "aria") {
@@ -559,7 +566,7 @@ export function registerBrowserAgentSnapshotRoutes(
built.snapshot,
await collectChromeMcpSnapshotUrls({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
}),
),
@@ -569,14 +576,14 @@ export function registerBrowserAgentSnapshotRoutes(
const refs = Object.keys(builtWithUrls.refs);
const labelResult = await renderChromeMcpLabels({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
refs,
});
try {
const labeled = await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
format: "png",
});
@@ -606,7 +613,7 @@ export function registerBrowserAgentSnapshotRoutes(
} finally {
await clearChromeMcpOverlay({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
profile: profileCtx.profile,
targetId: tab.targetId,
});
}

View File

@@ -90,7 +90,7 @@ export function createProfileAvailability({
if (capabilities.usesChromeMcp) {
// listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required
const { listChromeMcpTabs } = await getChromeMcpModule();
await listChromeMcpTabs(profile.name, profile.userDataDir);
await listChromeMcpTabs(profile.name, profile);
return true;
}
const { httpTimeoutMs, wsTimeoutMs } = resolveTimeouts(timeoutMs);
@@ -105,7 +105,7 @@ export function createProfileAvailability({
const isTransportAvailable = async (timeoutMs?: number) => {
if (capabilities.usesChromeMcp) {
const { ensureChromeMcpAvailable } = await getChromeMcpModule();
await ensureChromeMcpAvailable(profile.name, profile.userDataDir, {
await ensureChromeMcpAvailable(profile.name, profile, {
ephemeral: true,
timeoutMs,
});
@@ -218,7 +218,7 @@ export function createProfileAvailability({
while (Date.now() < deadlineMs) {
try {
const { listChromeMcpTabs } = await getChromeMcpModule();
await listChromeMcpTabs(profile.name, profile.userDataDir);
await listChromeMcpTabs(profile.name, profile);
return;
} catch (err) {
lastError = err;
@@ -239,7 +239,7 @@ export function createProfileAvailability({
);
}
const { ensureChromeMcpAvailable } = await getChromeMcpModule();
await ensureChromeMcpAvailable(profile.name, profile.userDataDir);
await ensureChromeMcpAvailable(profile.name, profile);
await waitForChromeMcpReadyAfterAttach();
return;
}

View File

@@ -74,6 +74,14 @@ function makeState(): BrowserServerState {
};
}
function expectChromeLiveProfile() {
return expect.objectContaining({
name: "chrome-live",
driver: "existing-session",
userDataDir: "/tmp/brave-profile",
});
}
beforeEach(() => {
for (const key of [
"ALL_PROXY",
@@ -114,12 +122,16 @@ describe("browser server-context existing-session profile", () => {
expect(chromeMcp.ensureChromeMcpAvailable).toHaveBeenCalledWith(
"chrome-live",
"/tmp/brave-profile",
expectChromeLiveProfile(),
{ ephemeral: true, timeoutMs: 300 },
);
expect(chromeMcp.listChromeMcpTabs).toHaveBeenCalledWith("chrome-live", "/tmp/brave-profile", {
ephemeral: true,
});
expect(chromeMcp.listChromeMcpTabs).toHaveBeenCalledWith(
"chrome-live",
expectChromeLiveProfile(),
{
ephemeral: true,
},
);
});
it("keeps the next real attach on the normal sticky session path after an idle status probe", async () => {
@@ -146,17 +158,17 @@ describe("browser server-context existing-session profile", () => {
expect(tabs.map((tab) => tab.targetId)).toEqual(["7"]);
expect(chromeMcp.ensureChromeMcpAvailable).toHaveBeenLastCalledWith(
"chrome-live",
"/tmp/brave-profile",
expectChromeLiveProfile(),
);
expect(chromeMcp.listChromeMcpTabs).toHaveBeenNthCalledWith(
1,
"chrome-live",
"/tmp/brave-profile",
expectChromeLiveProfile(),
);
expect(chromeMcp.listChromeMcpTabs).toHaveBeenNthCalledWith(
2,
"chrome-live",
"/tmp/brave-profile",
expectChromeLiveProfile(),
);
});
@@ -201,18 +213,21 @@ describe("browser server-context existing-session profile", () => {
expect(chromeMcp.ensureChromeMcpAvailable).toHaveBeenCalledWith(
"chrome-live",
"/tmp/brave-profile",
expectChromeLiveProfile(),
);
expect(chromeMcp.listChromeMcpTabs).toHaveBeenCalledWith(
"chrome-live",
expectChromeLiveProfile(),
);
expect(chromeMcp.listChromeMcpTabs).toHaveBeenCalledWith("chrome-live", "/tmp/brave-profile");
expect(chromeMcp.openChromeMcpTab).toHaveBeenCalledWith(
"chrome-live",
"about:blank",
"/tmp/brave-profile",
expectChromeLiveProfile(),
);
expect(chromeMcp.focusChromeMcpTab).toHaveBeenCalledWith(
"chrome-live",
"7",
"/tmp/brave-profile",
expectChromeLiveProfile(),
);
expect(chromeMcp.closeChromeMcpSession).toHaveBeenCalledWith("chrome-live");
});

View File

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

View File

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

View File

@@ -182,7 +182,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
try {
running = await profileCtx.isTransportAvailable(300);
if (running) {
const tabs = await listChromeMcpTabs(profile.name, profile.userDataDir, {
const tabs = await listChromeMcpTabs(profile.name, profile, {
ephemeral: true,
}).catch(() => [] as BrowserTab[]);
tabCount = tabs.filter((t) => t.type === "page").length;

View File

@@ -8,7 +8,7 @@ function buildBridgeFromPersistedBundledRecord(
// Relocation is derived from the previous persisted registry, not a hardcoded
// table. A plugin moving from bundled to npm keeps the same plugin id; the old
// registry row is the proof that this user actually had it bundled/enabled.
if (record.origin !== "bundled" || record.enabled === false) {
if (record.origin !== "bundled" || !record.enabled) {
return null;
}
const npmSpec = record.packageInstall?.npm?.spec;
@@ -19,7 +19,7 @@ function buildBridgeFromPersistedBundledRecord(
bundledPluginId: record.pluginId,
pluginId: record.pluginId,
npmSpec,
...(record.enabledByDefault === true ? { enabledByDefault: true } : {}),
...(record.enabledByDefault ? { enabledByDefault: true } : {}),
channelIds: record.contributions.channels,
};
}

View File

@@ -780,6 +780,21 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
"Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory on the selected host or browser node.",
},
mcpCommand: {
type: "string",
title: "Browser Profile Chrome MCP Command",
description:
"Per-profile Chrome DevTools MCP command for existing-session attachment. Defaults to npx.",
},
mcpArgs: {
type: "array",
items: {
type: "string",
},
title: "Browser Profile Chrome MCP Args",
description:
"Extra per-profile Chrome DevTools MCP arguments for existing-session attachment, such as --no-usage-statistics. Endpoint arguments here override the built-in auto-connect or browser URL selection.",
},
driver: {
anyOf: [
{
@@ -24061,6 +24076,16 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: "Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory on the selected host or browser node.",
tags: ["storage"],
},
"browser.profiles.*.mcpCommand": {
label: "Browser Profile Chrome MCP Command",
help: "Per-profile Chrome DevTools MCP command for existing-session attachment. Defaults to npx.",
tags: ["storage"],
},
"browser.profiles.*.mcpArgs": {
label: "Browser Profile Chrome MCP Args",
help: "Extra per-profile Chrome DevTools MCP arguments for existing-session attachment, such as --no-usage-statistics. Endpoint arguments here override the built-in auto-connect or browser URL selection.",
tags: ["storage"],
},
"browser.profiles.*.driver": {
label: "Browser Profile Driver",
help: 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for Chrome DevTools MCP attachment on the selected host or browser node.',

View File

@@ -286,6 +286,10 @@ export const FIELD_HELP: Record<string, string> = {
"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 Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory on the selected host or browser node.",
"browser.profiles.*.mcpCommand":
"Per-profile Chrome DevTools MCP command for existing-session attachment. Defaults to npx.",
"browser.profiles.*.mcpArgs":
"Extra per-profile Chrome DevTools MCP arguments for existing-session attachment, such as --no-usage-statistics. Endpoint arguments here override the built-in auto-connect or browser URL selection.",
"browser.profiles.*.driver":
'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for Chrome DevTools MCP attachment on the selected host or browser node.',
"browser.profiles.*.headless":

View File

@@ -154,6 +154,8 @@ export const FIELD_LABELS: Record<string, string> = {
"browser.profiles.*.cdpPort": "Browser Profile CDP Port",
"browser.profiles.*.cdpUrl": "Browser Profile CDP URL",
"browser.profiles.*.userDataDir": "Browser Profile User Data Dir",
"browser.profiles.*.mcpCommand": "Browser Profile Chrome MCP Command",
"browser.profiles.*.mcpArgs": "Browser Profile Chrome MCP Args",
"browser.profiles.*.driver": "Browser Profile Driver",
"browser.profiles.*.headless": "Browser Profile Headless Mode",
"browser.profiles.*.attachOnly": "Browser Profile Attach-only Mode",

View File

@@ -5,6 +5,10 @@ export type BrowserProfileConfig = {
cdpUrl?: string;
/** Explicit user data directory for existing-session Chrome MCP attachment. */
userDataDir?: string;
/** Override the Chrome MCP command for existing-session profiles. */
mcpCommand?: string;
/** Extra Chrome MCP arguments for existing-session profiles. */
mcpArgs?: string[];
/** Profile driver (default: openclaw). */
driver?: "openclaw" | "clawd" | "existing-session";
/** If true, launch this profile in headless mode. Falls back to browser.headless. */

View File

@@ -425,6 +425,8 @@ export const OpenClawSchema = z
cdpPort: z.number().int().min(1).max(65535).optional(),
cdpUrl: z.string().optional(),
userDataDir: z.string().optional(),
mcpCommand: z.string().optional(),
mcpArgs: z.array(z.string()).optional(),
driver: z
.union([z.literal("openclaw"), z.literal("clawd"), z.literal("existing-session")])
.optional(),

View File

@@ -222,6 +222,10 @@ function pathEndsWithSegment(params: {
return Boolean(value && segment && (value === segment || value.endsWith(`/${segment}`)));
}
function bundledExtensionPathSegment(bundledDirName: string): string {
return ["extensions", bundledDirName].join("/");
}
function isBridgeBundledPathRecord(params: {
bridge: ExternalizedBundledPluginBridge;
bundledLocalPath?: string;
@@ -242,12 +246,12 @@ function isBridgeBundledPathRecord(params: {
return (
pathEndsWithSegment({
value: params.record.sourcePath,
segment: `extensions/${bundledDirName}`,
segment: bundledExtensionPathSegment(bundledDirName),
env: params.env,
}) ||
pathEndsWithSegment({
value: params.record.installPath,
segment: `extensions/${bundledDirName}`,
segment: bundledExtensionPathSegment(bundledDirName),
env: params.env,
})
);
@@ -262,7 +266,7 @@ function removeBridgeBundledLoadPaths(params: {
params.loadPaths.removeMatching((entry) =>
pathEndsWithSegment({
value: entry,
segment: `extensions/${bundledDirName}`,
segment: bundledExtensionPathSegment(bundledDirName),
env: params.env,
}),
);
@@ -896,9 +900,6 @@ export async function syncPluginsForUpdateChannel(params: {
installs = next.plugins?.installs ?? {};
changed = true;
}
if (bundledInfo?.localPath) {
loadHelpers.removePath(bundledInfo.localPath);
}
removeBridgeBundledLoadPaths({ bridge, loadPaths: loadHelpers, env });
continue;
}
@@ -907,7 +908,7 @@ export async function syncPluginsForUpdateChannel(params: {
existing &&
!isBridgeBundledPathRecord({
bridge,
bundledLocalPath: bundledInfo?.localPath,
bundledLocalPath: undefined,
record: existing.record,
env,
})
@@ -947,9 +948,6 @@ export async function syncPluginsForUpdateChannel(params: {
...buildNpmResolutionInstallFields(result.npmResolution),
});
installs = next.plugins?.installs ?? {};
if (bundledInfo?.localPath) {
loadHelpers.removePath(bundledInfo.localPath);
}
if (existing?.record.sourcePath) {
loadHelpers.removePath(existing.record.sourcePath);
}