diff --git a/CHANGELOG.md b/CHANGELOG.md index d8f9888f254..3f641449b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman. - Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. +- Browser/existing-session: add headless Chrome DevTools MCP support for Linux, Docker, and VPS setups, including explicit browser URL and WebSocket endpoint attach modes for `existing-session`. Thanks @vincentkoc. ### Fixes diff --git a/docs/tools/browser-linux-troubleshooting.md b/docs/tools/browser-linux-troubleshooting.md index 1ab51657044..d46e338edad 100644 --- a/docs/tools/browser-linux-troubleshooting.md +++ b/docs/tools/browser-linux-troubleshooting.md @@ -110,6 +110,48 @@ curl -s -X POST http://127.0.0.1:18791/start curl -s http://127.0.0.1:18791/tabs ``` +## Existing-session MCP on Linux / VPS + +If you want Chrome DevTools MCP instead of the managed `openclaw` CDP profile, +you now have two Linux-safe options: + +1. Let MCP launch headless Chrome for an `existing-session` profile: + +```json +{ + "browser": { + "headless": true, + "noSandbox": true, + "executablePath": "/usr/bin/google-chrome-stable", + "defaultProfile": "user" + } +} +``` + +2. Attach MCP to a running debuggable Chrome instance: + +```json +{ + "browser": { + "headless": true, + "defaultProfile": "user", + "profiles": { + "user": { + "driver": "existing-session", + "cdpUrl": "http://127.0.0.1:9222", + "color": "#00AA00" + } + } + } +} +``` + +Notes: + +- `driver: "existing-session"` still uses Chrome MCP transport, not the extension relay. +- `cdpUrl` on an `existing-session` profile is interpreted as the MCP browser target (`browserUrl` or `wsEndpoint`), not the normal OpenClaw CDP driver. +- If you omit `cdpUrl`, headless MCP launches Chrome itself. + ### Config Reference | Option | Description | Default | diff --git a/docs/tools/browser.md b/docs/tools/browser.md index ebe352036c5..60a6f285b10 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -359,9 +359,13 @@ Notes: ## Chrome existing-session via MCP -OpenClaw can also attach to a running Chrome profile through the official -Chrome DevTools MCP server. This reuses the tabs and login state already open in -that Chrome profile. +OpenClaw can also use the official Chrome DevTools MCP server for two different +flows: + +- desktop attach via `--autoConnect`, which reuses a running Chrome profile and + its existing tabs/login state +- headless or remote attach, where MCP either launches headless Chrome itself + or connects to a running debuggable browser URL/WS endpoint Official background and setup references: @@ -375,7 +379,7 @@ Built-in profile: Optional: create your own custom existing-session profile if you want a different name or color. -Then in Chrome: +Desktop attach flow: 1. Open `chrome://inspect/#remote-debugging` 2. Enable remote debugging @@ -398,30 +402,66 @@ What success looks like: - `tabs` lists your already-open Chrome tabs - `snapshot` returns refs from the selected live tab -What to check if attach does not work: +What to check if desktop attach does not work: - Chrome is version `144+` - remote debugging is enabled at `chrome://inspect/#remote-debugging` - Chrome showed and you accepted the attach consent prompt +Headless / Linux / VPS flow: + +- Set `browser.headless: true` +- Set `browser.noSandbox: true` when running as root or in common container/VPS setups +- Optional: set `browser.executablePath` to a stable Chrome/Chromium binary path +- Optional: set `browser.profiles..cdpUrl` on an `existing-session` profile to an + MCP target like `http://127.0.0.1:9222` or + `ws://127.0.0.1:9222/devtools/browser/` + +Example: + +```json5 +{ + browser: { + headless: true, + noSandbox: true, + executablePath: "/usr/bin/google-chrome-stable", + defaultProfile: "user", + profiles: { + user: { + driver: "existing-session", + cdpUrl: "http://127.0.0.1:9222", + color: "#00AA00", + }, + }, + }, +} +``` + +Behavior: + +- without `browser.profiles..cdpUrl`, headless `existing-session` launches Chrome through MCP +- with `browser.profiles..cdpUrl`, MCP connects to that running browser URL +- non-headless `existing-session` keeps using the interactive `--autoConnect` flow + Agent use: - Use `profile="user"` when you need the user’s logged-in browser state. - If you use a custom existing-session profile, pass that explicit profile name. - Prefer `profile="user"` over `profile="chrome-relay"` unless the user explicitly wants the extension / attach-tab flow. -- Only choose this mode when the user is at the computer to approve the attach - prompt. -- the Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect` +- On desktop `--autoConnect`, only choose this mode when the user is at the + computer to approve the attach prompt. +- The Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect` + for desktop attach, or use MCP headless/browserUrl/wsEndpoint modes for Linux/VPS paths. Notes: - This path is higher-risk than the isolated `openclaw` profile because it can act inside your signed-in browser session. -- OpenClaw does not launch Chrome for this driver; it attaches to an existing - session only. -- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not - the legacy default-profile remote debugging port workflow. +- OpenClaw uses the official Chrome DevTools MCP server for this driver. +- On desktop, OpenClaw uses MCP `--autoConnect`. +- In headless mode, OpenClaw can launch Chrome through MCP or connect MCP to a + configured browser URL/WS endpoint. - Existing-session screenshots support page captures and `--ref` element captures from snapshots, but not CSS `--element` selectors. - Existing-session `wait --url` supports exact, substring, and glob patterns diff --git a/src/browser/chrome-mcp.test.ts b/src/browser/chrome-mcp.test.ts index a77149d7a72..8a71563ad33 100644 --- a/src/browser/chrome-mcp.test.ts +++ b/src/browser/chrome-mcp.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { loadConfig } from "../config/config.js"; import { + buildChromeMcpLaunchPlanForTest, evaluateChromeMcpScript, listChromeMcpTabs, openChromeMcpTab, @@ -7,6 +9,10 @@ import { setChromeMcpSessionFactoryForTest, } from "./chrome-mcp.js"; +vi.mock("../config/config.js", () => ({ + loadConfig: vi.fn(), +})); + type ToolCall = { name: string; arguments?: Record; @@ -79,6 +85,99 @@ function createFakeSession(): ChromeMcpSession { describe("chrome MCP page parsing", () => { beforeEach(async () => { await resetChromeMcpSessionsForTest(); + vi.mocked(loadConfig).mockReturnValue({ + browser: { + profiles: { + "chrome-live": { + driver: "existing-session", + attachOnly: true, + color: "#00AA00", + }, + }, + }, + }); + }); + + it("uses autoConnect for desktop existing-session profiles", () => { + const plan = buildChromeMcpLaunchPlanForTest("chrome-live"); + expect(plan.mode).toBe("autoConnect"); + expect(plan.args).toContain("--autoConnect"); + }); + + it("uses headless launch flags for headless existing-session profiles", () => { + vi.mocked(loadConfig).mockReturnValue({ + browser: { + headless: true, + noSandbox: true, + executablePath: "/usr/bin/google-chrome-stable", + extraArgs: ["--disable-dev-shm-usage"], + profiles: { + "chrome-live": { + driver: "existing-session", + attachOnly: true, + color: "#00AA00", + }, + }, + }, + }); + + const plan = buildChromeMcpLaunchPlanForTest("chrome-live"); + expect(plan.mode).toBe("headless"); + expect(plan.args).toEqual( + expect.arrayContaining([ + "--headless", + "--userDataDir", + expect.stringContaining("/browser/chrome-live/user-data"), + "--executablePath", + "/usr/bin/google-chrome-stable", + "--chromeArg", + "--no-sandbox", + "--chromeArg", + "--disable-setuid-sandbox", + "--chromeArg", + "--disable-dev-shm-usage", + ]), + ); + }); + + it("uses browserUrl for MCP profiles configured with an HTTP target", () => { + vi.mocked(loadConfig).mockReturnValue({ + browser: { + profiles: { + "chrome-live": { + driver: "existing-session", + attachOnly: true, + cdpUrl: "http://127.0.0.1:9222", + color: "#00AA00", + }, + }, + }, + }); + + const plan = buildChromeMcpLaunchPlanForTest("chrome-live"); + expect(plan.mode).toBe("browserUrl"); + expect(plan.args).toEqual(expect.arrayContaining(["--browserUrl", "http://127.0.0.1:9222"])); + }); + + it("uses wsEndpoint for MCP profiles configured with a WebSocket target", () => { + vi.mocked(loadConfig).mockReturnValue({ + browser: { + profiles: { + "chrome-live": { + driver: "existing-session", + attachOnly: true, + cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc", + color: "#00AA00", + }, + }, + }, + }); + + const plan = buildChromeMcpLaunchPlanForTest("chrome-live"); + expect(plan.mode).toBe("wsEndpoint"); + expect(plan.args).toEqual( + expect.arrayContaining(["--wsEndpoint", "ws://127.0.0.1:9222/devtools/browser/abc"]), + ); }); it("parses list_pages text responses when structuredContent is missing", async () => { diff --git a/src/browser/chrome-mcp.ts b/src/browser/chrome-mcp.ts index 25ae39b2293..16c6e73b825 100644 --- a/src/browser/chrome-mcp.ts +++ b/src/browser/chrome-mcp.ts @@ -4,8 +4,11 @@ import os from "node:os"; import path from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { loadConfig } from "../config/config.js"; import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js"; +import { resolveOpenClawUserDataDir } from "./chrome.js"; import type { BrowserTab } from "./client.js"; +import { resolveBrowserConfig, resolveProfile } from "./config.js"; import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js"; type ChromeMcpStructuredPage = { @@ -32,7 +35,6 @@ const DEFAULT_CHROME_MCP_COMMAND = "npx"; const DEFAULT_CHROME_MCP_ARGS = [ "-y", "chrome-devtools-mcp@latest", - "--autoConnect", // Direct chrome-devtools-mcp launches do not enable structuredContent by default. "--experimentalStructuredContent", "--experimental-page-id-routing", @@ -42,6 +44,51 @@ const sessions = new Map(); const pendingSessions = new Map>(); let sessionFactory: ChromeMcpSessionFactory | null = null; +type ChromeMcpLaunchPlan = { + args: string[]; + mode: "autoConnect" | "browserUrl" | "wsEndpoint" | "headless"; +}; + +function buildChromeMcpLaunchPlan(profileName: string): ChromeMcpLaunchPlan { + const cfg = loadConfig(); + const resolved = resolveBrowserConfig(cfg.browser, cfg); + const profile = resolveProfile(resolved, profileName); + if (!profile || profile.driver !== "existing-session") { + throw new BrowserProfileUnavailableError( + `Chrome MCP profile "${profileName}" is missing or is not driver=existing-session.`, + ); + } + + const args = [...DEFAULT_CHROME_MCP_ARGS]; + if (profile.mcpTargetUrl) { + const parsed = new URL(profile.mcpTargetUrl); + if (parsed.protocol === "ws:" || parsed.protocol === "wss:") { + args.push("--wsEndpoint", profile.mcpTargetUrl); + return { args, mode: "wsEndpoint" }; + } + args.push("--browserUrl", profile.mcpTargetUrl); + return { args, mode: "browserUrl" }; + } + + if (!resolved.headless) { + args.push("--autoConnect"); + return { args, mode: "autoConnect" }; + } + + args.push("--headless"); + args.push("--userDataDir", resolveOpenClawUserDataDir(profile.name)); + if (resolved.executablePath) { + args.push("--executablePath", resolved.executablePath); + } + if (resolved.noSandbox) { + args.push("--chromeArg", "--no-sandbox", "--chromeArg", "--disable-setuid-sandbox"); + } + for (const arg of resolved.extraArgs) { + args.push("--chromeArg", arg); + } + return { args, mode: "headless" }; +} + function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -169,9 +216,10 @@ function extractJsonMessage(result: ChromeMcpToolResult): unknown { } async function createRealSession(profileName: string): Promise { + const launchPlan = buildChromeMcpLaunchPlan(profileName); const transport = new StdioClientTransport({ command: DEFAULT_CHROME_MCP_COMMAND, - args: DEFAULT_CHROME_MCP_ARGS, + args: launchPlan.args, stderr: "pipe", }); const client = new Client( @@ -191,9 +239,15 @@ async function createRealSession(profileName: string): Promise } } catch (err) { await client.close().catch(() => {}); + const hint = + launchPlan.mode === "autoConnect" + ? "Make sure Chrome is running, enable chrome://inspect/#remote-debugging, and approve the connection." + : launchPlan.mode === "browserUrl" || launchPlan.mode === "wsEndpoint" + ? "Make sure the configured browserUrl/wsEndpoint is reachable and Chrome is running with remote debugging enabled." + : "Make sure a Chrome executable is available, and use browser.noSandbox=true on Linux containers/root setups when needed."; throw new BrowserProfileUnavailableError( `Chrome MCP existing-session attach failed for profile "${profileName}". ` + - `Make sure Chrome is running, enable chrome://inspect/#remote-debugging, and approve the connection. ` + + `${hint} ` + `Details: ${String(err)}`, ); } @@ -531,6 +585,10 @@ export async function waitForChromeMcpText(params: { }); } +export function buildChromeMcpLaunchPlanForTest(profileName: string): ChromeMcpLaunchPlan { + return buildChromeMcpLaunchPlan(profileName); +} + export function setChromeMcpSessionFactoryForTest(factory: ChromeMcpSessionFactory | null): void { sessionFactory = factory; } diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index 5c16dd54dc6..09a54af27a1 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -26,6 +26,7 @@ describe("browser config", () => { expect(user?.driver).toBe("existing-session"); expect(user?.cdpPort).toBe(0); expect(user?.cdpUrl).toBe(""); + expect(user?.mcpTargetUrl).toBeUndefined(); const chromeRelay = resolveProfile(resolved, "chrome-relay"); expect(chromeRelay?.driver).toBe("extension"); expect(chromeRelay?.cdpPort).toBe(18792); @@ -121,6 +122,24 @@ describe("browser config", () => { expect(profile?.cdpIsLoopback).toBe(false); }); + it("supports MCP browser URLs for existing-session profiles", () => { + const resolved = resolveBrowserConfig({ + profiles: { + user: { + driver: "existing-session", + cdpUrl: "http://127.0.0.1:9222", + color: "#00AA00", + }, + }, + }); + + const profile = resolveProfile(resolved, "user"); + expect(profile?.driver).toBe("existing-session"); + expect(profile?.cdpUrl).toBe(""); + expect(profile?.mcpTargetUrl).toBe("http://127.0.0.1:9222"); + expect(profile?.cdpIsLoopback).toBe(true); + }); + it("uses profile cdpUrl when provided", () => { const resolved = resolveBrowserConfig({ profiles: { diff --git a/src/browser/config.ts b/src/browser/config.ts index 8bcd51d0a68..9845ebc3f56 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -45,6 +45,7 @@ export type ResolvedBrowserProfile = { cdpUrl: string; cdpHost: string; cdpIsLoopback: boolean; + mcpTargetUrl?: string; color: string; driver: "openclaw" | "extension" | "existing-session"; attachOnly: boolean; @@ -363,13 +364,18 @@ export function resolveProfile( : "openclaw"; if (driver === "existing-session") { - // existing-session uses Chrome MCP auto-connect; no CDP port/URL needed + const parsed = rawProfileUrl + ? parseHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`) + : null; + // existing-session uses Chrome MCP. It can either auto-connect to a local desktop + // session or connect to a debuggable browser URL/WS endpoint when explicitly configured. return { name: profileName, cdpPort: 0, cdpUrl: "", - cdpHost: "", - cdpIsLoopback: true, + cdpHost: parsed?.parsed.hostname ?? "", + cdpIsLoopback: parsed ? isLoopbackHost(parsed.parsed.hostname) : true, + ...(parsed ? { mcpTargetUrl: parsed.normalized } : {}), color: profile.color, driver, attachOnly: true, diff --git a/src/browser/profile-capabilities.ts b/src/browser/profile-capabilities.ts index b736a77d943..7543bcc7c13 100644 --- a/src/browser/profile-capabilities.ts +++ b/src/browser/profile-capabilities.ts @@ -41,7 +41,7 @@ export function getBrowserProfileCapabilities( if (profile.driver === "existing-session") { return { mode: "local-existing-session", - isRemote: false, + isRemote: !profile.cdpIsLoopback, usesChromeMcp: true, requiresRelay: false, requiresAttachedTab: false, diff --git a/src/browser/profiles-service.test.ts b/src/browser/profiles-service.test.ts index 13bbdf27c49..029488dd527 100644 --- a/src/browser/profiles-service.test.ts +++ b/src/browser/profiles-service.test.ts @@ -201,20 +201,27 @@ describe("BrowserProfilesService", () => { ); }); - it("rejects driver=existing-session when cdpUrl is provided", async () => { + it("allows driver=existing-session when cdpUrl is provided as an MCP target", async () => { const resolved = resolveBrowserConfig({}); - const { ctx } = createCtx(resolved); + const { ctx, state } = createCtx(resolved); vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); const service = createBrowserProfilesService(ctx); + const result = await service.createProfile({ + name: "chrome-live", + driver: "existing-session", + cdpUrl: "http://127.0.0.1:9222", + }); - await expect( - service.createProfile({ - name: "chrome-live", - driver: "existing-session", - cdpUrl: "http://127.0.0.1:9222", - }), - ).rejects.toThrow(/does not accept cdpUrl/i); + expect(result.transport).toBe("chrome-mcp"); + expect(result.cdpUrl).toBeNull(); + expect(result.isRemote).toBe(false); + expect(state.resolved.profiles["chrome-live"]).toEqual({ + cdpUrl: "http://127.0.0.1:9222", + driver: "existing-session", + attachOnly: true, + color: expect.any(String), + }); }); it("deletes remote profiles without stopping or removing local data", async () => { diff --git a/src/browser/profiles-service.ts b/src/browser/profiles-service.ts index 86321006e98..27ad1b75120 100644 --- a/src/browser/profiles-service.ts +++ b/src/browser/profiles-service.ts @@ -130,15 +130,19 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { } } if (driver === "existing-session") { - throw new BrowserValidationError( - "driver=existing-session does not accept cdpUrl; it attaches via the Chrome MCP auto-connect flow", - ); + profileConfig = { + cdpUrl: parsed.normalized, + driver, + attachOnly: true, + color: profileColor, + }; + } else { + profileConfig = { + cdpUrl: parsed.normalized, + ...(driver ? { driver } : {}), + color: profileColor, + }; } - profileConfig = { - cdpUrl: parsed.normalized, - ...(driver ? { driver } : {}), - color: profileColor, - }; } else { if (driver === "extension") { throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl"); diff --git a/src/browser/pw-tools-core.clamps-timeoutms-scrollintoview.test.ts b/src/browser/pw-tools-core.clamps-timeoutms-scrollintoview.test.ts index fa1e0c01e7d..d23fe027573 100644 --- a/src/browser/pw-tools-core.clamps-timeoutms-scrollintoview.test.ts +++ b/src/browser/pw-tools-core.clamps-timeoutms-scrollintoview.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { installPwToolsCoreTestHooks, + getPwToolsCoreSessionMocks, setPwToolsCoreCurrentPage, setPwToolsCoreCurrentRefLocator, } from "./pw-tools-core.test-harness.js"; @@ -92,4 +93,24 @@ describe("pw-tools-core", () => { }), ).rejects.toThrow(/not interactable/i); }); + + it("keeps Playwright strictness for selector-based actions", async () => { + const click = vi.fn(async () => {}); + const first = vi.fn(() => { + throw new Error("selector actions should not call locator.first()"); + }); + const locator = vi.fn(() => ({ click, first })); + setPwToolsCoreCurrentPage({ locator }); + + await mod.clickViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + selector: "button.submit", + }); + + expect(locator).toHaveBeenCalledWith("button.submit"); + expect(first).not.toHaveBeenCalled(); + expect(getPwToolsCoreSessionMocks().refLocator).not.toHaveBeenCalled(); + expect(click).toHaveBeenCalled(); + }); }); diff --git a/src/browser/pw-tools-core.interactions.ts b/src/browser/pw-tools-core.interactions.ts index 01abc5338f0..1065c70b386 100644 --- a/src/browser/pw-tools-core.interactions.ts +++ b/src/browser/pw-tools-core.interactions.ts @@ -43,6 +43,24 @@ async function getRestoredPageForTarget(opts: TargetOpts) { return page; } +function resolveLocatorForInteraction( + page: Awaited>, + params: { ref?: string; selector?: string }, +) { + const resolved = requireRefOrSelector(params.ref, params.selector); + if (resolved.ref) { + return { + locator: refLocator(page, resolved.ref), + label: resolved.ref, + }; + } + const selector = resolved.selector!; + return { + locator: page.locator(selector), + label: selector, + }; +} + function resolveInteractionTimeoutMs(timeoutMs?: number): number { return Math.max(500, Math.min(60_000, Math.floor(timeoutMs ?? 8000))); } @@ -88,12 +106,8 @@ export async function clickViaPlaywright(opts: { delayMs?: number; timeoutMs?: number; }): Promise { - const resolved = requireRefOrSelector(opts.ref, opts.selector); const page = await getRestoredPageForTarget(opts); - const label = resolved.ref ?? resolved.selector!; - const locator = resolved.ref - ? refLocator(page, requireRef(resolved.ref)) - : page.locator(resolved.selector!); + const { locator, label } = resolveLocatorForInteraction(page, opts); const timeout = resolveInteractionTimeoutMs(opts.timeoutMs); try { const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS); @@ -106,12 +120,14 @@ export async function clickViaPlaywright(opts: { timeout, button: opts.button, modifiers: opts.modifiers, + delay: opts.delayMs, }); } else { await locator.click({ timeout, button: opts.button, modifiers: opts.modifiers, + delay: opts.delayMs, }); } } catch (err) { @@ -126,12 +142,8 @@ export async function hoverViaPlaywright(opts: { selector?: string; timeoutMs?: number; }): Promise { - const resolved = requireRefOrSelector(opts.ref, opts.selector); const page = await getRestoredPageForTarget(opts); - const label = resolved.ref ?? resolved.selector!; - const locator = resolved.ref - ? refLocator(page, requireRef(resolved.ref)) - : page.locator(resolved.selector!); + const { locator, label } = resolveLocatorForInteraction(page, opts); try { await locator.hover({ timeout: resolveInteractionTimeoutMs(opts.timeoutMs), @@ -150,23 +162,21 @@ export async function dragViaPlaywright(opts: { endSelector?: string; timeoutMs?: number; }): Promise { - const resolvedStart = requireRefOrSelector(opts.startRef, opts.startSelector); - const resolvedEnd = requireRefOrSelector(opts.endRef, opts.endSelector); const page = await getRestoredPageForTarget(opts); - const startLocator = resolvedStart.ref - ? refLocator(page, requireRef(resolvedStart.ref)) - : page.locator(resolvedStart.selector!); - const endLocator = resolvedEnd.ref - ? refLocator(page, requireRef(resolvedEnd.ref)) - : page.locator(resolvedEnd.selector!); - const startLabel = resolvedStart.ref ?? resolvedStart.selector!; - const endLabel = resolvedEnd.ref ?? resolvedEnd.selector!; + const from = resolveLocatorForInteraction(page, { + ref: opts.startRef, + selector: opts.startSelector, + }); + const to = resolveLocatorForInteraction(page, { + ref: opts.endRef, + selector: opts.endSelector, + }); try { - await startLocator.dragTo(endLocator, { + await from.locator.dragTo(to.locator, { timeout: resolveInteractionTimeoutMs(opts.timeoutMs), }); } catch (err) { - throw toAIFriendlyError(err, `${startLabel} -> ${endLabel}`); + throw toAIFriendlyError(err, `${from.label} -> ${to.label}`); } } @@ -178,15 +188,11 @@ export async function selectOptionViaPlaywright(opts: { values: string[]; timeoutMs?: number; }): Promise { - const resolved = requireRefOrSelector(opts.ref, opts.selector); if (!opts.values?.length) { throw new Error("values are required"); } const page = await getRestoredPageForTarget(opts); - const label = resolved.ref ?? resolved.selector!; - const locator = resolved.ref - ? refLocator(page, requireRef(resolved.ref)) - : page.locator(resolved.selector!); + const { locator, label } = resolveLocatorForInteraction(page, opts); try { await locator.selectOption(opts.values, { timeout: resolveInteractionTimeoutMs(opts.timeoutMs), @@ -223,13 +229,9 @@ export async function typeViaPlaywright(opts: { slowly?: boolean; timeoutMs?: number; }): Promise { - const resolved = requireRefOrSelector(opts.ref, opts.selector); const text = String(opts.text ?? ""); const page = await getRestoredPageForTarget(opts); - const label = resolved.ref ?? resolved.selector!; - const locator = resolved.ref - ? refLocator(page, requireRef(resolved.ref)) - : page.locator(resolved.selector!); + const { locator, label } = resolveLocatorForInteraction(page, opts); const timeout = resolveInteractionTimeoutMs(opts.timeoutMs); try { if (opts.slowly) { @@ -423,14 +425,9 @@ export async function scrollIntoViewViaPlaywright(opts: { selector?: string; timeoutMs?: number; }): Promise { - const resolved = requireRefOrSelector(opts.ref, opts.selector); const page = await getRestoredPageForTarget(opts); const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); - - const label = resolved.ref ?? resolved.selector!; - const locator = resolved.ref - ? refLocator(page, requireRef(resolved.ref)) - : page.locator(resolved.selector!); + const { locator, label } = resolveLocatorForInteraction(page, opts); try { await locator.scrollIntoViewIfNeeded({ timeout }); } catch (err) { diff --git a/src/browser/resolved-config-refresh.ts b/src/browser/resolved-config-refresh.ts index 999a7ca1229..010c6270258 100644 --- a/src/browser/resolved-config-refresh.ts +++ b/src/browser/resolved-config-refresh.ts @@ -7,6 +7,9 @@ function changedProfileInvariants( next: ResolvedBrowserProfile, ): string[] { const changed: string[] = []; + if (current.mcpTargetUrl !== next.mcpTargetUrl) { + changed.push("mcpTargetUrl"); + } if (current.cdpUrl !== next.cdpUrl) { changed.push("cdpUrl"); } diff --git a/src/browser/server-context.headless-default-profile.test.ts b/src/browser/server-context.headless-default-profile.test.ts new file mode 100644 index 00000000000..654a66af2cc --- /dev/null +++ b/src/browser/server-context.headless-default-profile.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { createBrowserRouteContext } from "./server-context.js"; +import type { BrowserServerState } from "./server-context.js"; + +function makeState(defaultProfile: string): BrowserServerState { + return { + server: null, + port: 0, + resolved: { + enabled: true, + evaluateEnabled: true, + controlPort: 18791, + cdpPortRangeStart: 18800, + cdpPortRangeEnd: 18899, + cdpProtocol: "http", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + remoteCdpTimeoutMs: 1500, + remoteCdpHandshakeTimeoutMs: 3000, + color: "#FF4500", + headless: true, + noSandbox: true, + attachOnly: false, + defaultProfile, + profiles: { + openclaw: { + cdpPort: 18800, + color: "#FF4500", + }, + user: { + driver: "existing-session", + attachOnly: true, + color: "#00AA00", + }, + "chrome-relay": { + driver: "extension", + cdpUrl: "http://127.0.0.1:18792", + color: "#00AA00", + }, + }, + extraArgs: [], + ssrfPolicy: { dangerouslyAllowPrivateNetwork: true }, + }, + profiles: new Map(), + }; +} + +describe("browser server-context headless implicit default profile", () => { + it("falls back from extension relay to openclaw when no profile is specified", () => { + const ctx = createBrowserRouteContext({ + getState: () => makeState("chrome-relay"), + }); + + expect(ctx.forProfile().profile.name).toBe("openclaw"); + }); + + it("keeps existing-session as the implicit default in headless mode", () => { + const ctx = createBrowserRouteContext({ + getState: () => makeState("user"), + }); + + expect(ctx.forProfile().profile.name).toBe("user"); + }); + + it("keeps explicit interactive profile requests unchanged in headless mode", () => { + const ctx = createBrowserRouteContext({ + getState: () => makeState("chrome-relay"), + }); + + expect(ctx.forProfile("chrome-relay").profile.name).toBe("chrome-relay"); + expect(ctx.forProfile("user").profile.name).toBe("user"); + }); +}); diff --git a/src/browser/server-context.hot-reload-profiles.test.ts b/src/browser/server-context.hot-reload-profiles.test.ts index f9eb2452ce2..031a43e72f9 100644 --- a/src/browser/server-context.hot-reload-profiles.test.ts +++ b/src/browser/server-context.hot-reload-profiles.test.ts @@ -6,7 +6,16 @@ import { } from "./resolved-config-refresh.js"; import type { BrowserServerState } from "./server-context.types.js"; -let cfgProfiles: Record = {}; +let cfgProfiles: Record< + string, + { + cdpPort?: number; + cdpUrl?: string; + color?: string; + driver?: "openclaw" | "existing-session"; + attachOnly?: boolean; + } +> = {}; // Simulate module-level cache behavior let cachedConfig: ReturnType | null = null; @@ -206,4 +215,59 @@ describe("server-context hot-reload profiles", () => { expect(runtime?.lastTargetId).toBeNull(); expect(runtime?.reconcile?.reason).toContain("cdpPort"); }); + + it("marks existing-session runtime state for reconcile when MCP target URL changes", async () => { + cfgProfiles = { + user: { + cdpUrl: "http://127.0.0.1:9222", + color: "#00AA00", + driver: "existing-session", + attachOnly: true, + }, + }; + cachedConfig = null; + + const cfg = loadConfig(); + const resolved = resolveBrowserConfig({ ...cfg.browser, defaultProfile: "user" }, cfg); + const userProfile = resolveProfile(resolved, "user"); + expect(userProfile).toBeTruthy(); + expect(userProfile?.mcpTargetUrl).toBe("http://127.0.0.1:9222"); + + const state: BrowserServerState = { + server: null, + port: 18791, + resolved, + profiles: new Map([ + [ + "user", + { + profile: userProfile!, + running: { pid: 123 } as never, + lastTargetId: "tab-1", + reconcile: null, + }, + ], + ]), + }; + + cfgProfiles.user = { + cdpUrl: "http://127.0.0.1:9333", + color: "#00AA00", + driver: "existing-session", + attachOnly: true, + }; + cachedConfig = null; + + refreshResolvedBrowserConfigFromDisk({ + current: state, + refreshConfigFromDisk: true, + mode: "cached", + }); + + const runtime = state.profiles.get("user"); + expect(runtime).toBeTruthy(); + expect(runtime?.profile.mcpTargetUrl).toBe("http://127.0.0.1:9333"); + expect(runtime?.lastTargetId).toBeNull(); + expect(runtime?.reconcile?.reason).toContain("mcpTargetUrl"); + }); }); diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index 0ba29ad38cf..6c8efb35b8b 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -2,6 +2,7 @@ import { SsrFBlockedError } from "../infra/net/ssrf.js"; import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js"; import type { ResolvedBrowserProfile } from "./config.js"; import { resolveProfile } from "./config.js"; +import { DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME } from "./constants.js"; import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js"; import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; @@ -40,6 +41,35 @@ export function listKnownProfileNames(state: BrowserServerState): string[] { return [...names]; } +function resolveImplicitProfileName(state: BrowserServerState): string { + const defaultProfileName = state.resolved.defaultProfile; + if (!state.resolved.headless) { + return defaultProfileName; + } + + const defaultProfile = resolveProfile(state.resolved, defaultProfileName); + if (!defaultProfile) { + return defaultProfileName; + } + + const capabilities = getBrowserProfileCapabilities(defaultProfile); + if (!capabilities.requiresRelay) { + return defaultProfileName; + } + + const managedProfile = resolveProfile(state.resolved, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); + if (!managedProfile) { + return defaultProfileName; + } + + const managedCapabilities = getBrowserProfileCapabilities(managedProfile); + if (managedCapabilities.requiresRelay) { + return defaultProfileName; + } + + return managedProfile.name; +} + /** * Create a profile-scoped context for browser operations. */ @@ -129,7 +159,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon const forProfile = (profileName?: string): ProfileContext => { const current = state(); - const name = profileName ?? current.resolved.defaultProfile; + const name = profileName ?? resolveImplicitProfileName(current); const profile = resolveBrowserProfileWithHotReload({ current, refreshConfigFromDisk, diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 555ee02b8eb..63a6657165e 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -257,7 +257,7 @@ export const FIELD_HELP: Record = { "browser.profiles.*.cdpPort": "Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.", "browser.profiles.*.cdpUrl": - "Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.", + "Per-profile browser endpoint URL. For openclaw/extension drivers this is the CDP URL; for existing-session it is passed to Chrome DevTools MCP as browserUrl/wsEndpoint so headless or remote MCP attach can target a running debuggable browser.", "browser.profiles.*.driver": 'Per-profile browser driver mode: "openclaw" (or legacy "clawd") or "extension" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.', "browser.profiles.*.attachOnly": diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index 5f8e28a0ebe..fcf73073fb6 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -1,7 +1,7 @@ export type BrowserProfileConfig = { /** CDP port for this profile. Allocated once at creation, persisted permanently. */ cdpPort?: number; - /** CDP URL for this profile (use for remote Chrome). */ + /** CDP URL for this profile (use for remote Chrome, or as browserUrl/wsEndpoint for existing-session MCP attach). */ cdpUrl?: string; /** Profile driver (default: openclaw). */ driver?: "openclaw" | "clawd" | "extension" | "existing-session";