browser: drop headless/remote MCP attach modes, simplify existing-session to autoConnect-only (#46628)

This commit is contained in:
George Zhang 2026-03-14 15:54:22 -07:00 committed by GitHub
parent 2f7e548a57
commit 3704293e6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 86 additions and 545 deletions

View File

@ -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

View File

@ -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 |

View File

@ -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.<name>.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/<id>`
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.<name>.cdpUrl`, headless `existing-session` launches Chrome through MCP
- with `browser.profiles.<name>.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 users 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

View File

@ -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 },
);
}

View File

@ -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=<id|name> or target="node".',
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).",
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',

View File

@ -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<string, unknown>;
@ -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 () => {

View File

@ -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<string, ChromeMcpSession>();
const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
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<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
@ -216,10 +169,9 @@ function extractJsonMessage(result: ChromeMcpToolResult): unknown {
}
async function createRealSession(profileName: string): Promise<ChromeMcpSession> {
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<ChromeMcpSession>
}
} 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;
}

View File

@ -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: {

View File

@ -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,

View File

@ -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,

View File

@ -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 () => {

View File

@ -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");

View File

@ -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();
});
});

View File

@ -43,24 +43,6 @@ async function getRestoredPageForTarget(opts: TargetOpts) {
return page;
}
function resolveLocatorForInteraction(
page: Awaited<ReturnType<typeof getRestoredPageForTarget>>,
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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) {

View File

@ -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");
}

View File

@ -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");
});
});

View File

@ -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<string, { cdpPort?: number; cdpUrl?: string; color?: string }> = {};
// Simulate module-level cache behavior
let cachedConfig: ReturnType<typeof buildConfig> | 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");
});
});

View File

@ -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,

View File

@ -257,7 +257,7 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@ -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";

View File

@ -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) {