feat(browser): add headless existing-session MCP support esp for Linux/Docker/VPS (#45769)

* fix(browser): prefer managed default profile in headless mode

* test(browser): cover headless default profile fallback

* feat(browser): support headless MCP profile resolution

* feat(browser): add headless and target-url Chrome MCP modes

* feat(browser): allow MCP target URLs in profile creation

* docs(browser): document headless MCP existing-session flows

* fix(browser): restore playwright browser act helpers

* fix(browser): preserve strict selector actions

* docs(changelog): add existing-session MCP note
This commit is contained in:
Vincent Koc 2026-03-14 14:59:30 -07:00 committed by GitHub
parent 92834c8440
commit 173fe3cb54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 542 additions and 78 deletions

View File

@ -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. - 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. - 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 ### Fixes

View File

@ -110,6 +110,48 @@ curl -s -X POST http://127.0.0.1:18791/start
curl -s http://127.0.0.1:18791/tabs 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 ### Config Reference
| Option | Description | Default | | Option | Description | Default |

View File

@ -359,9 +359,13 @@ Notes:
## Chrome existing-session via MCP ## Chrome existing-session via MCP
OpenClaw can also attach to a running Chrome profile through the official OpenClaw can also use the official Chrome DevTools MCP server for two different
Chrome DevTools MCP server. This reuses the tabs and login state already open in flows:
that Chrome profile.
- 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: Official background and setup references:
@ -375,7 +379,7 @@ Built-in profile:
Optional: create your own custom existing-session profile if you want a Optional: create your own custom existing-session profile if you want a
different name or color. different name or color.
Then in Chrome: Desktop attach flow:
1. Open `chrome://inspect/#remote-debugging` 1. Open `chrome://inspect/#remote-debugging`
2. Enable remote debugging 2. Enable remote debugging
@ -398,30 +402,66 @@ What success looks like:
- `tabs` lists your already-open Chrome tabs - `tabs` lists your already-open Chrome tabs
- `snapshot` returns refs from the selected live tab - `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+` - Chrome is version `144+`
- remote debugging is enabled at `chrome://inspect/#remote-debugging` - remote debugging is enabled at `chrome://inspect/#remote-debugging`
- Chrome showed and you accepted the attach consent prompt - 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: Agent use:
- Use `profile="user"` when you need the users logged-in browser state. - 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. - If you use a custom existing-session profile, pass that explicit profile name.
- Prefer `profile="user"` over `profile="chrome-relay"` unless the user - Prefer `profile="user"` over `profile="chrome-relay"` unless the user
explicitly wants the extension / attach-tab flow. explicitly wants the extension / attach-tab flow.
- Only choose this mode when the user is at the computer to approve the attach - On desktop `--autoConnect`, only choose this mode when the user is at the
prompt. computer to approve the attach prompt.
- the Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect` - 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: Notes:
- This path is higher-risk than the isolated `openclaw` profile because it can - This path is higher-risk than the isolated `openclaw` profile because it can
act inside your signed-in browser session. act inside your signed-in browser session.
- OpenClaw does not launch Chrome for this driver; it attaches to an existing - OpenClaw uses the official Chrome DevTools MCP server for this driver.
session only. - On desktop, OpenClaw uses MCP `--autoConnect`.
- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not - In headless mode, OpenClaw can launch Chrome through MCP or connect MCP to a
the legacy default-profile remote debugging port workflow. configured browser URL/WS endpoint.
- Existing-session screenshots support page captures and `--ref` element - Existing-session screenshots support page captures and `--ref` element
captures from snapshots, but not CSS `--element` selectors. captures from snapshots, but not CSS `--element` selectors.
- Existing-session `wait --url` supports exact, substring, and glob patterns - Existing-session `wait --url` supports exact, substring, and glob patterns

View File

@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadConfig } from "../config/config.js";
import { import {
buildChromeMcpLaunchPlanForTest,
evaluateChromeMcpScript, evaluateChromeMcpScript,
listChromeMcpTabs, listChromeMcpTabs,
openChromeMcpTab, openChromeMcpTab,
@ -7,6 +9,10 @@ import {
setChromeMcpSessionFactoryForTest, setChromeMcpSessionFactoryForTest,
} from "./chrome-mcp.js"; } from "./chrome-mcp.js";
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn(),
}));
type ToolCall = { type ToolCall = {
name: string; name: string;
arguments?: Record<string, unknown>; arguments?: Record<string, unknown>;
@ -79,6 +85,99 @@ function createFakeSession(): ChromeMcpSession {
describe("chrome MCP page parsing", () => { describe("chrome MCP page parsing", () => {
beforeEach(async () => { beforeEach(async () => {
await resetChromeMcpSessionsForTest(); 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 () => { it("parses list_pages text responses when structuredContent is missing", async () => {

View File

@ -4,8 +4,11 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { loadConfig } from "../config/config.js";
import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js"; import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js";
import { resolveOpenClawUserDataDir } from "./chrome.js";
import type { BrowserTab } from "./client.js"; import type { BrowserTab } from "./client.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js"; import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js";
type ChromeMcpStructuredPage = { type ChromeMcpStructuredPage = {
@ -32,7 +35,6 @@ const DEFAULT_CHROME_MCP_COMMAND = "npx";
const DEFAULT_CHROME_MCP_ARGS = [ const DEFAULT_CHROME_MCP_ARGS = [
"-y", "-y",
"chrome-devtools-mcp@latest", "chrome-devtools-mcp@latest",
"--autoConnect",
// Direct chrome-devtools-mcp launches do not enable structuredContent by default. // Direct chrome-devtools-mcp launches do not enable structuredContent by default.
"--experimentalStructuredContent", "--experimentalStructuredContent",
"--experimental-page-id-routing", "--experimental-page-id-routing",
@ -42,6 +44,51 @@ const sessions = new Map<string, ChromeMcpSession>();
const pendingSessions = new Map<string, Promise<ChromeMcpSession>>(); const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
let sessionFactory: ChromeMcpSessionFactory | null = null; 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 { function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>) ? (value as Record<string, unknown>)
@ -169,9 +216,10 @@ function extractJsonMessage(result: ChromeMcpToolResult): unknown {
} }
async function createRealSession(profileName: string): Promise<ChromeMcpSession> { async function createRealSession(profileName: string): Promise<ChromeMcpSession> {
const launchPlan = buildChromeMcpLaunchPlan(profileName);
const transport = new StdioClientTransport({ const transport = new StdioClientTransport({
command: DEFAULT_CHROME_MCP_COMMAND, command: DEFAULT_CHROME_MCP_COMMAND,
args: DEFAULT_CHROME_MCP_ARGS, args: launchPlan.args,
stderr: "pipe", stderr: "pipe",
}); });
const client = new Client( const client = new Client(
@ -191,9 +239,15 @@ async function createRealSession(profileName: string): Promise<ChromeMcpSession>
} }
} catch (err) { } catch (err) {
await client.close().catch(() => {}); 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( throw new BrowserProfileUnavailableError(
`Chrome MCP existing-session attach failed for profile "${profileName}". ` + `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)}`, `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 { export function setChromeMcpSessionFactoryForTest(factory: ChromeMcpSessionFactory | null): void {
sessionFactory = factory; sessionFactory = factory;
} }

View File

@ -26,6 +26,7 @@ describe("browser config", () => {
expect(user?.driver).toBe("existing-session"); expect(user?.driver).toBe("existing-session");
expect(user?.cdpPort).toBe(0); expect(user?.cdpPort).toBe(0);
expect(user?.cdpUrl).toBe(""); expect(user?.cdpUrl).toBe("");
expect(user?.mcpTargetUrl).toBeUndefined();
const chromeRelay = resolveProfile(resolved, "chrome-relay"); const chromeRelay = resolveProfile(resolved, "chrome-relay");
expect(chromeRelay?.driver).toBe("extension"); expect(chromeRelay?.driver).toBe("extension");
expect(chromeRelay?.cdpPort).toBe(18792); expect(chromeRelay?.cdpPort).toBe(18792);
@ -121,6 +122,24 @@ describe("browser config", () => {
expect(profile?.cdpIsLoopback).toBe(false); 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", () => { it("uses profile cdpUrl when provided", () => {
const resolved = resolveBrowserConfig({ const resolved = resolveBrowserConfig({
profiles: { profiles: {

View File

@ -45,6 +45,7 @@ export type ResolvedBrowserProfile = {
cdpUrl: string; cdpUrl: string;
cdpHost: string; cdpHost: string;
cdpIsLoopback: boolean; cdpIsLoopback: boolean;
mcpTargetUrl?: string;
color: string; color: string;
driver: "openclaw" | "extension" | "existing-session"; driver: "openclaw" | "extension" | "existing-session";
attachOnly: boolean; attachOnly: boolean;
@ -363,13 +364,18 @@ export function resolveProfile(
: "openclaw"; : "openclaw";
if (driver === "existing-session") { 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 { return {
name: profileName, name: profileName,
cdpPort: 0, cdpPort: 0,
cdpUrl: "", cdpUrl: "",
cdpHost: "", cdpHost: parsed?.parsed.hostname ?? "",
cdpIsLoopback: true, cdpIsLoopback: parsed ? isLoopbackHost(parsed.parsed.hostname) : true,
...(parsed ? { mcpTargetUrl: parsed.normalized } : {}),
color: profile.color, color: profile.color,
driver, driver,
attachOnly: true, attachOnly: true,

View File

@ -41,7 +41,7 @@ export function getBrowserProfileCapabilities(
if (profile.driver === "existing-session") { if (profile.driver === "existing-session") {
return { return {
mode: "local-existing-session", mode: "local-existing-session",
isRemote: false, isRemote: !profile.cdpIsLoopback,
usesChromeMcp: true, usesChromeMcp: true,
requiresRelay: false, requiresRelay: false,
requiresAttachedTab: false, requiresAttachedTab: false,

View File

@ -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 resolved = resolveBrowserConfig({});
const { ctx } = createCtx(resolved); const { ctx, state } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx); const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({
await expect(
service.createProfile({
name: "chrome-live", name: "chrome-live",
driver: "existing-session", driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222", 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 () => { it("deletes remote profiles without stopping or removing local data", async () => {

View File

@ -130,15 +130,19 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
} }
} }
if (driver === "existing-session") { if (driver === "existing-session") {
throw new BrowserValidationError( profileConfig = {
"driver=existing-session does not accept cdpUrl; it attaches via the Chrome MCP auto-connect flow", cdpUrl: parsed.normalized,
); driver,
} attachOnly: true,
color: profileColor,
};
} else {
profileConfig = { profileConfig = {
cdpUrl: parsed.normalized, cdpUrl: parsed.normalized,
...(driver ? { driver } : {}), ...(driver ? { driver } : {}),
color: profileColor, color: profileColor,
}; };
}
} else { } else {
if (driver === "extension") { if (driver === "extension") {
throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl"); throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl");

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
installPwToolsCoreTestHooks, installPwToolsCoreTestHooks,
getPwToolsCoreSessionMocks,
setPwToolsCoreCurrentPage, setPwToolsCoreCurrentPage,
setPwToolsCoreCurrentRefLocator, setPwToolsCoreCurrentRefLocator,
} from "./pw-tools-core.test-harness.js"; } from "./pw-tools-core.test-harness.js";
@ -92,4 +93,24 @@ describe("pw-tools-core", () => {
}), }),
).rejects.toThrow(/not interactable/i); ).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,6 +43,24 @@ async function getRestoredPageForTarget(opts: TargetOpts) {
return page; 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 { function resolveInteractionTimeoutMs(timeoutMs?: number): number {
return Math.max(500, Math.min(60_000, Math.floor(timeoutMs ?? 8000))); return Math.max(500, Math.min(60_000, Math.floor(timeoutMs ?? 8000)));
} }
@ -88,12 +106,8 @@ export async function clickViaPlaywright(opts: {
delayMs?: number; delayMs?: number;
timeoutMs?: number; timeoutMs?: number;
}): Promise<void> { }): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const page = await getRestoredPageForTarget(opts); const page = await getRestoredPageForTarget(opts);
const label = resolved.ref ?? resolved.selector!; const { locator, label } = resolveLocatorForInteraction(page, opts);
const locator = resolved.ref
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs); const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
try { try {
const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS); const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS);
@ -106,12 +120,14 @@ export async function clickViaPlaywright(opts: {
timeout, timeout,
button: opts.button, button: opts.button,
modifiers: opts.modifiers, modifiers: opts.modifiers,
delay: opts.delayMs,
}); });
} else { } else {
await locator.click({ await locator.click({
timeout, timeout,
button: opts.button, button: opts.button,
modifiers: opts.modifiers, modifiers: opts.modifiers,
delay: opts.delayMs,
}); });
} }
} catch (err) { } catch (err) {
@ -126,12 +142,8 @@ export async function hoverViaPlaywright(opts: {
selector?: string; selector?: string;
timeoutMs?: number; timeoutMs?: number;
}): Promise<void> { }): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const page = await getRestoredPageForTarget(opts); const page = await getRestoredPageForTarget(opts);
const label = resolved.ref ?? resolved.selector!; const { locator, label } = resolveLocatorForInteraction(page, opts);
const locator = resolved.ref
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
try { try {
await locator.hover({ await locator.hover({
timeout: resolveInteractionTimeoutMs(opts.timeoutMs), timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
@ -150,23 +162,21 @@ export async function dragViaPlaywright(opts: {
endSelector?: string; endSelector?: string;
timeoutMs?: number; timeoutMs?: number;
}): Promise<void> { }): Promise<void> {
const resolvedStart = requireRefOrSelector(opts.startRef, opts.startSelector);
const resolvedEnd = requireRefOrSelector(opts.endRef, opts.endSelector);
const page = await getRestoredPageForTarget(opts); const page = await getRestoredPageForTarget(opts);
const startLocator = resolvedStart.ref const from = resolveLocatorForInteraction(page, {
? refLocator(page, requireRef(resolvedStart.ref)) ref: opts.startRef,
: page.locator(resolvedStart.selector!); selector: opts.startSelector,
const endLocator = resolvedEnd.ref });
? refLocator(page, requireRef(resolvedEnd.ref)) const to = resolveLocatorForInteraction(page, {
: page.locator(resolvedEnd.selector!); ref: opts.endRef,
const startLabel = resolvedStart.ref ?? resolvedStart.selector!; selector: opts.endSelector,
const endLabel = resolvedEnd.ref ?? resolvedEnd.selector!; });
try { try {
await startLocator.dragTo(endLocator, { await from.locator.dragTo(to.locator, {
timeout: resolveInteractionTimeoutMs(opts.timeoutMs), timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
}); });
} catch (err) { } 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[]; values: string[];
timeoutMs?: number; timeoutMs?: number;
}): Promise<void> { }): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
if (!opts.values?.length) { if (!opts.values?.length) {
throw new Error("values are required"); throw new Error("values are required");
} }
const page = await getRestoredPageForTarget(opts); const page = await getRestoredPageForTarget(opts);
const label = resolved.ref ?? resolved.selector!; const { locator, label } = resolveLocatorForInteraction(page, opts);
const locator = resolved.ref
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
try { try {
await locator.selectOption(opts.values, { await locator.selectOption(opts.values, {
timeout: resolveInteractionTimeoutMs(opts.timeoutMs), timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
@ -223,13 +229,9 @@ export async function typeViaPlaywright(opts: {
slowly?: boolean; slowly?: boolean;
timeoutMs?: number; timeoutMs?: number;
}): Promise<void> { }): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const text = String(opts.text ?? ""); const text = String(opts.text ?? "");
const page = await getRestoredPageForTarget(opts); const page = await getRestoredPageForTarget(opts);
const label = resolved.ref ?? resolved.selector!; const { locator, label } = resolveLocatorForInteraction(page, opts);
const locator = resolved.ref
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs); const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
try { try {
if (opts.slowly) { if (opts.slowly) {
@ -423,14 +425,9 @@ export async function scrollIntoViewViaPlaywright(opts: {
selector?: string; selector?: string;
timeoutMs?: number; timeoutMs?: number;
}): Promise<void> { }): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const page = await getRestoredPageForTarget(opts); const page = await getRestoredPageForTarget(opts);
const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); 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 { try {
await locator.scrollIntoViewIfNeeded({ timeout }); await locator.scrollIntoViewIfNeeded({ timeout });
} catch (err) { } catch (err) {

View File

@ -7,6 +7,9 @@ function changedProfileInvariants(
next: ResolvedBrowserProfile, next: ResolvedBrowserProfile,
): string[] { ): string[] {
const changed: string[] = []; const changed: string[] = [];
if (current.mcpTargetUrl !== next.mcpTargetUrl) {
changed.push("mcpTargetUrl");
}
if (current.cdpUrl !== next.cdpUrl) { if (current.cdpUrl !== next.cdpUrl) {
changed.push("cdpUrl"); changed.push("cdpUrl");
} }

View File

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

View File

@ -6,7 +6,16 @@ import {
} from "./resolved-config-refresh.js"; } from "./resolved-config-refresh.js";
import type { BrowserServerState } from "./server-context.types.js"; import type { BrowserServerState } from "./server-context.types.js";
let cfgProfiles: Record<string, { cdpPort?: number; cdpUrl?: string; color?: string }> = {}; let cfgProfiles: Record<
string,
{
cdpPort?: number;
cdpUrl?: string;
color?: string;
driver?: "openclaw" | "existing-session";
attachOnly?: boolean;
}
> = {};
// Simulate module-level cache behavior // Simulate module-level cache behavior
let cachedConfig: ReturnType<typeof buildConfig> | null = null; let cachedConfig: ReturnType<typeof buildConfig> | null = null;
@ -206,4 +215,59 @@ describe("server-context hot-reload profiles", () => {
expect(runtime?.lastTargetId).toBeNull(); expect(runtime?.lastTargetId).toBeNull();
expect(runtime?.reconcile?.reason).toContain("cdpPort"); 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,6 +2,7 @@ import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js"; import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
import type { ResolvedBrowserProfile } from "./config.js"; import type { ResolvedBrowserProfile } from "./config.js";
import { resolveProfile } from "./config.js"; import { resolveProfile } from "./config.js";
import { DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME } from "./constants.js";
import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js"; import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
@ -40,6 +41,35 @@ export function listKnownProfileNames(state: BrowserServerState): string[] {
return [...names]; 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. * Create a profile-scoped context for browser operations.
*/ */
@ -129,7 +159,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
const forProfile = (profileName?: string): ProfileContext => { const forProfile = (profileName?: string): ProfileContext => {
const current = state(); const current = state();
const name = profileName ?? current.resolved.defaultProfile; const name = profileName ?? resolveImplicitProfileName(current);
const profile = resolveBrowserProfileWithHotReload({ const profile = resolveBrowserProfileWithHotReload({
current, current,
refreshConfigFromDisk, refreshConfigFromDisk,

View File

@ -257,7 +257,7 @@ export const FIELD_HELP: Record<string, string> = {
"browser.profiles.*.cdpPort": "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.", "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": "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": "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.', '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": "browser.profiles.*.attachOnly":

View File

@ -1,7 +1,7 @@
export type BrowserProfileConfig = { export type BrowserProfileConfig = {
/** CDP port for this profile. Allocated once at creation, persisted permanently. */ /** CDP port for this profile. Allocated once at creation, persisted permanently. */
cdpPort?: number; 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; cdpUrl?: string;
/** Profile driver (default: openclaw). */ /** Profile driver (default: openclaw). */
driver?: "openclaw" | "clawd" | "extension" | "existing-session"; driver?: "openclaw" | "clawd" | "extension" | "existing-session";