diff --git a/CHANGELOG.md b/CHANGELOG.md index b1619c36384..df6ad73de1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,6 @@ 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 @@ -97,6 +96,7 @@ Docs: https://docs.openclaw.ai - Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08. - Agents/failover: normalize abort-wrapped `429 RESOURCE_EXHAUSTED` provider failures before abort short-circuiting so wrapped Google/Vertex rate limits continue across configured fallback models, including the embedded runner prompt-error path. (#39820) Thanks @lupuletic. - Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix +- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement when `gateway.auth.mode=none` so Control UI connections behind reverse proxies no longer get stuck on `pairing required` (code 1008) despite auth being explicitly disabled. (#42931) - Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057) ## 2026.3.12 diff --git a/docs/tools/browser-linux-troubleshooting.md b/docs/tools/browser-linux-troubleshooting.md index 4162b3cc50c..6f9940c1c67 100644 --- a/docs/tools/browser-linux-troubleshooting.md +++ b/docs/tools/browser-linux-troubleshooting.md @@ -110,48 +110,6 @@ 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 60a6f285b10..ebe352036c5 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -359,13 +359,9 @@ Notes: ## Chrome existing-session via MCP -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 +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. Official background and setup references: @@ -379,7 +375,7 @@ Built-in profile: Optional: create your own custom existing-session profile if you want a different name or color. -Desktop attach flow: +Then in Chrome: 1. Open `chrome://inspect/#remote-debugging` 2. Enable remote debugging @@ -402,66 +398,30 @@ What success looks like: - `tabs` lists your already-open Chrome tabs - `snapshot` returns refs from the selected live tab -What to check if desktop attach does not work: +What to check if 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. -- 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. +- 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` Notes: - This path is higher-risk than the isolated `openclaw` profile because it can act inside your signed-in browser session. -- 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. +- 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. - 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/agents/tools/browser-tool.actions.ts b/src/agents/tools/browser-tool.actions.ts index f76b3690238..0d0f5e26abb 100644 --- a/src/agents/tools/browser-tool.actions.ts +++ b/src/agents/tools/browser-tool.actions.ts @@ -339,7 +339,7 @@ export async function executeActAction(params: { throw new Error( isRelayProfile ? "No Chrome tabs are attached via the OpenClaw Browser Relay extension. Click the toolbar icon on the tab you want to control (badge ON), then retry." - : `No Chrome tabs found for profile="${profile}". Make sure Chrome is running with remote debugging enabled (chrome://inspect/#remote-debugging), approve any attach prompt, and verify open tabs. Then retry.`, + : `No Chrome tabs found for profile="${profile}". Make sure Chrome (v146+) is running and has open tabs, then retry.`, { cause: err }, ); } diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index b922bed98a3..54ddab2cb1f 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -307,7 +307,7 @@ export function createBrowserTool(opts?: { description: [ "Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).", "Browser choice: omit profile by default for the isolated OpenClaw-managed browser (`openclaw`).", - 'For the logged-in user browser on the local host, use profile="user". Chrome must be running with remote debugging enabled (chrome://inspect/#remote-debugging). The user must approve the browser attach prompt. Use only when existing logins/cookies matter and the user is present.', + 'For the logged-in user browser on the local host, use profile="user". Chrome (v146+) must be running. Use only when existing logins/cookies matter and the user is present.', 'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node= or target="node".', "When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).", 'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.', diff --git a/src/browser/chrome-mcp.test.ts b/src/browser/chrome-mcp.test.ts index 8a71563ad33..a77149d7a72 100644 --- a/src/browser/chrome-mcp.test.ts +++ b/src/browser/chrome-mcp.test.ts @@ -1,7 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { loadConfig } from "../config/config.js"; import { - buildChromeMcpLaunchPlanForTest, evaluateChromeMcpScript, listChromeMcpTabs, openChromeMcpTab, @@ -9,10 +7,6 @@ import { setChromeMcpSessionFactoryForTest, } from "./chrome-mcp.js"; -vi.mock("../config/config.js", () => ({ - loadConfig: vi.fn(), -})); - type ToolCall = { name: string; arguments?: Record; @@ -85,99 +79,6 @@ 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 16c6e73b825..c649fe53633 100644 --- a/src/browser/chrome-mcp.ts +++ b/src/browser/chrome-mcp.ts @@ -4,11 +4,8 @@ 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 = { @@ -35,6 +32,7 @@ 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", @@ -44,51 +42,6 @@ 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) @@ -216,10 +169,9 @@ 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: launchPlan.args, + args: DEFAULT_CHROME_MCP_ARGS, stderr: "pipe", }); const client = new Client( @@ -239,15 +191,9 @@ 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}". ` + - `${hint} ` + + `Make sure Chrome (v146+) is running. ` + `Details: ${String(err)}`, ); } @@ -585,10 +531,6 @@ 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 57b17c56add..947cf10c0fa 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -26,7 +26,6 @@ describe("browser config", () => { expect(user?.driver).toBe("existing-session"); expect(user?.cdpPort).toBe(0); expect(user?.cdpUrl).toBe(""); - expect(user?.mcpTargetUrl).toBeUndefined(); // chrome-relay is no longer auto-created expect(resolveProfile(resolved, "chrome-relay")).toBe(null); expect(resolved.remoteCdpTimeoutMs).toBe(1500); @@ -114,24 +113,6 @@ 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 ab59f7539f6..e535b926a96 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -45,7 +45,6 @@ export type ResolvedBrowserProfile = { cdpUrl: string; cdpHost: string; cdpIsLoopback: boolean; - mcpTargetUrl?: string; color: string; driver: "openclaw" | "extension" | "existing-session"; attachOnly: boolean; @@ -331,18 +330,13 @@ export function resolveProfile( : "openclaw"; if (driver === "existing-session") { - 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. + // existing-session uses Chrome MCP auto-connect; no CDP port/URL needed return { name: profileName, cdpPort: 0, cdpUrl: "", - cdpHost: parsed?.parsed.hostname ?? "", - cdpIsLoopback: parsed ? isLoopbackHost(parsed.parsed.hostname) : true, - ...(parsed ? { mcpTargetUrl: parsed.normalized } : {}), + cdpHost: "", + cdpIsLoopback: true, color: profile.color, driver, attachOnly: true, diff --git a/src/browser/profile-capabilities.ts b/src/browser/profile-capabilities.ts index 7543bcc7c13..b736a77d943 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: !profile.cdpIsLoopback, + isRemote: false, usesChromeMcp: true, requiresRelay: false, requiresAttachedTab: false, diff --git a/src/browser/profiles-service.test.ts b/src/browser/profiles-service.test.ts index 029488dd527..13bbdf27c49 100644 --- a/src/browser/profiles-service.test.ts +++ b/src/browser/profiles-service.test.ts @@ -201,27 +201,20 @@ describe("BrowserProfilesService", () => { ); }); - it("allows driver=existing-session when cdpUrl is provided as an MCP target", async () => { + it("rejects driver=existing-session when cdpUrl is provided", async () => { const resolved = resolveBrowserConfig({}); - const { ctx, state } = createCtx(resolved); + const { ctx } = 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", - }); - 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), - }); + await expect( + service.createProfile({ + name: "chrome-live", + driver: "existing-session", + cdpUrl: "http://127.0.0.1:9222", + }), + ).rejects.toThrow(/does not accept cdpUrl/i); }); 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 27ad1b75120..86321006e98 100644 --- a/src/browser/profiles-service.ts +++ b/src/browser/profiles-service.ts @@ -130,19 +130,15 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { } } if (driver === "existing-session") { - profileConfig = { - cdpUrl: parsed.normalized, - driver, - attachOnly: true, - color: profileColor, - }; - } else { - profileConfig = { - cdpUrl: parsed.normalized, - ...(driver ? { driver } : {}), - color: profileColor, - }; + throw new BrowserValidationError( + "driver=existing-session does not accept cdpUrl; it attaches via the Chrome MCP auto-connect flow", + ); } + 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 d23fe027573..fa1e0c01e7d 100644 --- a/src/browser/pw-tools-core.clamps-timeoutms-scrollintoview.test.ts +++ b/src/browser/pw-tools-core.clamps-timeoutms-scrollintoview.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { installPwToolsCoreTestHooks, - getPwToolsCoreSessionMocks, setPwToolsCoreCurrentPage, setPwToolsCoreCurrentRefLocator, } from "./pw-tools-core.test-harness.js"; @@ -93,24 +92,4 @@ 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 1065c70b386..01abc5338f0 100644 --- a/src/browser/pw-tools-core.interactions.ts +++ b/src/browser/pw-tools-core.interactions.ts @@ -43,24 +43,6 @@ 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))); } @@ -106,8 +88,12 @@ export async function clickViaPlaywright(opts: { delayMs?: number; timeoutMs?: number; }): Promise { + const resolved = requireRefOrSelector(opts.ref, opts.selector); const page = await getRestoredPageForTarget(opts); - const { locator, label } = resolveLocatorForInteraction(page, opts); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector!); const timeout = resolveInteractionTimeoutMs(opts.timeoutMs); try { const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS); @@ -120,14 +106,12 @@ 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) { @@ -142,8 +126,12 @@ export async function hoverViaPlaywright(opts: { selector?: string; timeoutMs?: number; }): Promise { + const resolved = requireRefOrSelector(opts.ref, opts.selector); const page = await getRestoredPageForTarget(opts); - const { locator, label } = resolveLocatorForInteraction(page, opts); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector!); try { await locator.hover({ timeout: resolveInteractionTimeoutMs(opts.timeoutMs), @@ -162,21 +150,23 @@ 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 from = resolveLocatorForInteraction(page, { - ref: opts.startRef, - selector: opts.startSelector, - }); - const to = resolveLocatorForInteraction(page, { - ref: opts.endRef, - selector: opts.endSelector, - }); + 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!; try { - await from.locator.dragTo(to.locator, { + await startLocator.dragTo(endLocator, { timeout: resolveInteractionTimeoutMs(opts.timeoutMs), }); } catch (err) { - throw toAIFriendlyError(err, `${from.label} -> ${to.label}`); + throw toAIFriendlyError(err, `${startLabel} -> ${endLabel}`); } } @@ -188,11 +178,15 @@ 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 { locator, label } = resolveLocatorForInteraction(page, opts); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector!); try { await locator.selectOption(opts.values, { timeout: resolveInteractionTimeoutMs(opts.timeoutMs), @@ -229,9 +223,13 @@ 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 { locator, label } = resolveLocatorForInteraction(page, opts); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector!); const timeout = resolveInteractionTimeoutMs(opts.timeoutMs); try { if (opts.slowly) { @@ -425,9 +423,14 @@ 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 { locator, label } = resolveLocatorForInteraction(page, opts); + + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector!); try { await locator.scrollIntoViewIfNeeded({ timeout }); } catch (err) { diff --git a/src/browser/resolved-config-refresh.ts b/src/browser/resolved-config-refresh.ts index 010c6270258..999a7ca1229 100644 --- a/src/browser/resolved-config-refresh.ts +++ b/src/browser/resolved-config-refresh.ts @@ -7,9 +7,6 @@ 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 deleted file mode 100644 index 654a66af2cc..00000000000 --- a/src/browser/server-context.headless-default-profile.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 031a43e72f9..f9eb2452ce2 100644 --- a/src/browser/server-context.hot-reload-profiles.test.ts +++ b/src/browser/server-context.hot-reload-profiles.test.ts @@ -6,16 +6,7 @@ import { } from "./resolved-config-refresh.js"; import type { BrowserServerState } from "./server-context.types.js"; -let cfgProfiles: Record< - string, - { - cdpPort?: number; - cdpUrl?: string; - color?: string; - driver?: "openclaw" | "existing-session"; - attachOnly?: boolean; - } -> = {}; +let cfgProfiles: Record = {}; // Simulate module-level cache behavior let cachedConfig: ReturnType | null = null; @@ -215,59 +206,4 @@ 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 6c8efb35b8b..0ba29ad38cf 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -2,7 +2,6 @@ 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"; @@ -41,35 +40,6 @@ 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. */ @@ -159,7 +129,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon const forProfile = (profileName?: string): ProfileContext => { const current = state(); - const name = profileName ?? resolveImplicitProfileName(current); + const name = profileName ?? current.resolved.defaultProfile; const profile = resolveBrowserProfileWithHotReload({ current, refreshConfigFromDisk, diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 63a6657165e..555ee02b8eb 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 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.", + "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.*.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 fcf73073fb6..5f8e28a0ebe 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, or as browserUrl/wsEndpoint for existing-session MCP attach). */ + /** CDP URL for this profile (use for remote Chrome). */ cdpUrl?: string; /** Profile driver (default: openclaw). */ driver?: "openclaw" | "clawd" | "extension" | "existing-session"; diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index e0116190009..49f70915992 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -674,14 +674,18 @@ export function attachGatewayWsMessageHandler(params: { authOk, authMethod, }); + // auth.mode=none disables all authentication — device pairing is an + // auth mechanism and must also be skipped when the operator opted out. const skipPairing = + resolvedAuth.mode === "none" || shouldSkipBackendSelfPairing({ connectParams, isLocalClient, hasBrowserOriginHeader, sharedAuthOk, authMethod, - }) || shouldSkipControlUiPairing(controlUiAuthPolicy, role, trustedProxyAuthOk); + }) || + shouldSkipControlUiPairing(controlUiAuthPolicy, role, trustedProxyAuthOk); if (device && devicePublicKey && !skipPairing) { const formatAuditList = (items: string[] | undefined): string => { if (!items || items.length === 0) {