From ab1d1a5c9eb60d24b1e90ca3b844b1c5d31b815e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 05:46:39 -0700 Subject: [PATCH] fix(browser): configure Chrome MCP existing-session launch (#71560) --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 4 +- .../browser/src/browser/chrome-mcp.test.ts | 66 ++++- extensions/browser/src/browser/chrome-mcp.ts | 269 +++++++++++++----- extensions/browser/src/browser/config.test.ts | 41 +++ extensions/browser/src/browser/config.ts | 42 ++- .../src/browser/routes/agent.act.hooks.ts | 4 +- .../browser/src/browser/routes/agent.act.ts | 39 +-- .../routes/agent.existing-session.test.ts | 8 +- .../src/browser/routes/agent.snapshot.ts | 29 +- .../browser/server-context.availability.ts | 8 +- .../server-context.existing-session.test.ts | 37 ++- .../src/browser/server-context.selection.ts | 4 +- .../src/browser/server-context.tab-ops.ts | 4 +- .../browser/src/browser/server-context.ts | 2 +- src/cli/plugins-location-bridges.ts | 4 +- src/config/schema.base.generated.ts | 25 ++ src/config/schema.help.ts | 4 + src/config/schema.labels.ts | 2 + src/config/types.browser.ts | 4 + src/config/zod-schema.ts | 2 + src/plugins/update.ts | 18 +- 22 files changed, 482 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd149ea397..0460c3d2d72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 9cac01eab02..8cd430eeadb 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/extensions/browser/src/browser/chrome-mcp.test.ts b/extensions/browser/src/browser/chrome-mcp.test.ts index 38a0995189b..ddcce2cc8cb 100644 --- a/extensions/browser/src/browser/chrome-mcp.test.ts +++ b/extensions/browser/src/browser/chrome-mcp.test.ts @@ -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> = []; 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; diff --git a/extensions/browser/src/browser/chrome-mcp.ts b/extensions/browser/src/browser/chrome-mcp.ts index 3853f982eca..56e307b0e9d 100644 --- a/extensions/browser/src/browser/chrome-mcp.ts +++ b/extensions/browser/src/browser/chrome-mcp.ts @@ -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; 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): 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(task: Promise): Promise { async function createRealSession( profileName: string, - userDataDir?: string, + options: NormalizedChromeMcpProfileOptions = normalizeChromeMcpOptions(), ): Promise { 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 { - 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 { - 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 { - 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 = {}, 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(fn: (filePath: string) => Promise): Promise async function findPageById( profileName: string, pageId: number, - userDataDir?: string, + profileOptions?: string | ChromeMcpProfileOptions, ): Promise { - 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 { - 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 { export async function listChromeMcpPages( profileName: string, - userDataDir?: string, + profileOptions?: string | ChromeMcpProfileOptions, options: ChromeMcpCallOptions = {}, ): Promise { - 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 { - 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 { 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 { - 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 { - 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 { - 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 { 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 } : {}), diff --git a/extensions/browser/src/browser/config.test.ts b/extensions/browser/src/browser/config.test.ts index 44ba25180a0..59005ed7ef1 100644 --- a/extensions/browser/src/browser/config.test.ts +++ b/extensions/browser/src/browser/config.test.ts @@ -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: { diff --git a/extensions/browser/src/browser/config.ts b/extensions/browser/src/browser/config.ts index 2f7ebba6dd9..27cd276cc02 100644 --- a/extensions/browser/src/browser/config.ts +++ b/extensions/browser/src/browser/config.ts @@ -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, diff --git a/extensions/browser/src/browser/routes/agent.act.hooks.ts b/extensions/browser/src/browser/routes/agent.act.hooks.ts index 6b6e0b1e392..48b03ad93ad 100644 --- a/extensions/browser/src/browser/routes/agent.act.hooks.ts +++ b/extensions/browser/src/browser/routes/agent.act.hooks.ts @@ -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 ??= {}); diff --git a/extensions/browser/src/browser/routes/agent.act.ts b/extensions/browser/src/browser/routes/agent.act.ts index 28d28131081..2fd0cf85a75 100644 --- a/extensions/browser/src/browser/routes/agent.act.ts +++ b/extensions/browser/src/browser/routes/agent.act.ts @@ -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 { 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(); 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) => { diff --git a/extensions/browser/src/browser/routes/agent.existing-session.test.ts b/extensions/browser/src/browser/routes/agent.existing-session.test.ts index d48a70dfb8c..12e0ea946fa 100644 --- a/extensions/browser/src/browser/routes/agent.existing-session.test.ts +++ b/extensions/browser/src/browser/routes/agent.existing-session.test.ts @@ -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, diff --git a/extensions/browser/src/browser/routes/agent.snapshot.ts b/extensions/browser/src/browser/routes/agent.snapshot.ts index b00195d2c28..424fb6c1a51 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.ts @@ -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> { 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 { 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, }); } diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index 268d7104c69..135b07d8bcf 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -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; } diff --git a/extensions/browser/src/browser/server-context.existing-session.test.ts b/extensions/browser/src/browser/server-context.existing-session.test.ts index 4ae28baf2c7..b6fb1f20283 100644 --- a/extensions/browser/src/browser/server-context.existing-session.test.ts +++ b/extensions/browser/src/browser/server-context.existing-session.test.ts @@ -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"); }); diff --git a/extensions/browser/src/browser/server-context.selection.ts b/extensions/browser/src/browser/server-context.selection.ts index c15ea8cdeb4..a0085069481 100644 --- a/extensions/browser/src/browser/server-context.selection.ts +++ b/extensions/browser/src/browser/server-context.selection.ts @@ -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; } diff --git a/extensions/browser/src/browser/server-context.tab-ops.ts b/extensions/browser/src/browser/server-context.tab-ops.ts index 3237fa093c7..be034ed13be 100644 --- a/extensions/browser/src/browser/server-context.tab-ops.ts +++ b/extensions/browser/src/browser/server-context.tab-ops.ts @@ -144,7 +144,7 @@ export function createProfileTabOps({ const readTabs = async (): Promise => { 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 }); diff --git a/extensions/browser/src/browser/server-context.ts b/extensions/browser/src/browser/server-context.ts index a67d8730969..c56094b5fca 100644 --- a/extensions/browser/src/browser/server-context.ts +++ b/extensions/browser/src/browser/server-context.ts @@ -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; diff --git a/src/cli/plugins-location-bridges.ts b/src/cli/plugins-location-bridges.ts index 1aad295e7a2..994784e7ae1 100644 --- a/src/cli/plugins-location-bridges.ts +++ b/src/cli/plugins-location-bridges.ts @@ -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, }; } diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 46cfb0e890a..7f886b4d375 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -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.', diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 787747bd881..23afe539e55 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -286,6 +286,10 @@ export const FIELD_HELP: Record = { "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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 461b2883ec1..4b77148e99f 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -154,6 +154,8 @@ export const FIELD_LABELS: Record = { "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", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index 55d542464cc..75a471054bb 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -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. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 2ab5678e980..420f818e2a6 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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(), diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 458d1fc4b18..61342371dc5 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -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); }