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

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
```
## 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,9 +359,13 @@ Notes:
## Chrome existing-session via MCP
OpenClaw can also attach to a running Chrome profile through the official
Chrome DevTools MCP server. This reuses the tabs and login state already open in
that Chrome profile.
OpenClaw can also use the official Chrome DevTools MCP server for two different
flows:
- desktop attach via `--autoConnect`, which reuses a running Chrome profile and
its existing tabs/login state
- headless or remote attach, where MCP either launches headless Chrome itself
or connects to a running debuggable browser URL/WS endpoint
Official background and setup references:
@ -375,7 +379,7 @@ Built-in profile:
Optional: create your own custom existing-session profile if you want a
different name or color.
Then in Chrome:
Desktop attach flow:
1. Open `chrome://inspect/#remote-debugging`
2. Enable remote debugging
@ -398,30 +402,66 @@ What success looks like:
- `tabs` lists your already-open Chrome tabs
- `snapshot` returns refs from the selected live tab
What to check if attach does not work:
What to check if desktop attach does not work:
- Chrome is version `144+`
- remote debugging is enabled at `chrome://inspect/#remote-debugging`
- Chrome showed and you accepted the attach consent prompt
Headless / Linux / VPS flow:
- Set `browser.headless: true`
- Set `browser.noSandbox: true` when running as root or in common container/VPS setups
- Optional: set `browser.executablePath` to a stable Chrome/Chromium binary path
- Optional: set `browser.profiles.<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.
- Only choose this mode when the user is at the computer to approve the attach
prompt.
- the Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect`
- On desktop `--autoConnect`, only choose this mode when the user is at the
computer to approve the attach prompt.
- The Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect`
for desktop attach, or use MCP headless/browserUrl/wsEndpoint modes for Linux/VPS paths.
Notes:
- This path is higher-risk than the isolated `openclaw` profile because it can
act inside your signed-in browser session.
- OpenClaw does not launch Chrome for this driver; it attaches to an existing
session only.
- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not
the legacy default-profile remote debugging port workflow.
- OpenClaw uses the official Chrome DevTools MCP server for this driver.
- On desktop, OpenClaw uses MCP `--autoConnect`.
- In headless mode, OpenClaw can launch Chrome through MCP or connect MCP to a
configured browser URL/WS endpoint.
- Existing-session screenshots support page captures and `--ref` element
captures from snapshots, but not CSS `--element` selectors.
- Existing-session `wait --url` supports exact, substring, and glob patterns

View File

@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadConfig } from "../config/config.js";
import {
buildChromeMcpLaunchPlanForTest,
evaluateChromeMcpScript,
listChromeMcpTabs,
openChromeMcpTab,
@ -7,6 +9,10 @@ import {
setChromeMcpSessionFactoryForTest,
} from "./chrome-mcp.js";
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn(),
}));
type ToolCall = {
name: string;
arguments?: Record<string, unknown>;
@ -79,6 +85,99 @@ function createFakeSession(): ChromeMcpSession {
describe("chrome MCP page parsing", () => {
beforeEach(async () => {
await resetChromeMcpSessionsForTest();
vi.mocked(loadConfig).mockReturnValue({
browser: {
profiles: {
"chrome-live": {
driver: "existing-session",
attachOnly: true,
color: "#00AA00",
},
},
},
});
});
it("uses autoConnect for desktop existing-session profiles", () => {
const plan = buildChromeMcpLaunchPlanForTest("chrome-live");
expect(plan.mode).toBe("autoConnect");
expect(plan.args).toContain("--autoConnect");
});
it("uses headless launch flags for headless existing-session profiles", () => {
vi.mocked(loadConfig).mockReturnValue({
browser: {
headless: true,
noSandbox: true,
executablePath: "/usr/bin/google-chrome-stable",
extraArgs: ["--disable-dev-shm-usage"],
profiles: {
"chrome-live": {
driver: "existing-session",
attachOnly: true,
color: "#00AA00",
},
},
},
});
const plan = buildChromeMcpLaunchPlanForTest("chrome-live");
expect(plan.mode).toBe("headless");
expect(plan.args).toEqual(
expect.arrayContaining([
"--headless",
"--userDataDir",
expect.stringContaining("/browser/chrome-live/user-data"),
"--executablePath",
"/usr/bin/google-chrome-stable",
"--chromeArg",
"--no-sandbox",
"--chromeArg",
"--disable-setuid-sandbox",
"--chromeArg",
"--disable-dev-shm-usage",
]),
);
});
it("uses browserUrl for MCP profiles configured with an HTTP target", () => {
vi.mocked(loadConfig).mockReturnValue({
browser: {
profiles: {
"chrome-live": {
driver: "existing-session",
attachOnly: true,
cdpUrl: "http://127.0.0.1:9222",
color: "#00AA00",
},
},
},
});
const plan = buildChromeMcpLaunchPlanForTest("chrome-live");
expect(plan.mode).toBe("browserUrl");
expect(plan.args).toEqual(expect.arrayContaining(["--browserUrl", "http://127.0.0.1:9222"]));
});
it("uses wsEndpoint for MCP profiles configured with a WebSocket target", () => {
vi.mocked(loadConfig).mockReturnValue({
browser: {
profiles: {
"chrome-live": {
driver: "existing-session",
attachOnly: true,
cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc",
color: "#00AA00",
},
},
},
});
const plan = buildChromeMcpLaunchPlanForTest("chrome-live");
expect(plan.mode).toBe("wsEndpoint");
expect(plan.args).toEqual(
expect.arrayContaining(["--wsEndpoint", "ws://127.0.0.1:9222/devtools/browser/abc"]),
);
});
it("parses list_pages text responses when structuredContent is missing", async () => {

View File

@ -4,8 +4,11 @@ import os from "node:os";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { loadConfig } from "../config/config.js";
import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js";
import { resolveOpenClawUserDataDir } from "./chrome.js";
import type { BrowserTab } from "./client.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js";
type ChromeMcpStructuredPage = {
@ -32,7 +35,6 @@ const DEFAULT_CHROME_MCP_COMMAND = "npx";
const DEFAULT_CHROME_MCP_ARGS = [
"-y",
"chrome-devtools-mcp@latest",
"--autoConnect",
// Direct chrome-devtools-mcp launches do not enable structuredContent by default.
"--experimentalStructuredContent",
"--experimental-page-id-routing",
@ -42,6 +44,51 @@ const sessions = new Map<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>)
@ -169,9 +216,10 @@ 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: DEFAULT_CHROME_MCP_ARGS,
args: launchPlan.args,
stderr: "pipe",
});
const client = new Client(
@ -191,9 +239,15 @@ 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}". ` +
`Make sure Chrome is running, enable chrome://inspect/#remote-debugging, and approve the connection. ` +
`${hint} ` +
`Details: ${String(err)}`,
);
}
@ -531,6 +585,10 @@ export async function waitForChromeMcpText(params: {
});
}
export function buildChromeMcpLaunchPlanForTest(profileName: string): ChromeMcpLaunchPlan {
return buildChromeMcpLaunchPlan(profileName);
}
export function setChromeMcpSessionFactoryForTest(factory: ChromeMcpSessionFactory | null): void {
sessionFactory = factory;
}

View File

@ -26,6 +26,7 @@ describe("browser config", () => {
expect(user?.driver).toBe("existing-session");
expect(user?.cdpPort).toBe(0);
expect(user?.cdpUrl).toBe("");
expect(user?.mcpTargetUrl).toBeUndefined();
const chromeRelay = resolveProfile(resolved, "chrome-relay");
expect(chromeRelay?.driver).toBe("extension");
expect(chromeRelay?.cdpPort).toBe(18792);
@ -121,6 +122,24 @@ describe("browser config", () => {
expect(profile?.cdpIsLoopback).toBe(false);
});
it("supports MCP browser URLs for existing-session profiles", () => {
const resolved = resolveBrowserConfig({
profiles: {
user: {
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
color: "#00AA00",
},
},
});
const profile = resolveProfile(resolved, "user");
expect(profile?.driver).toBe("existing-session");
expect(profile?.cdpUrl).toBe("");
expect(profile?.mcpTargetUrl).toBe("http://127.0.0.1:9222");
expect(profile?.cdpIsLoopback).toBe(true);
});
it("uses profile cdpUrl when provided", () => {
const resolved = resolveBrowserConfig({
profiles: {

View File

@ -45,6 +45,7 @@ export type ResolvedBrowserProfile = {
cdpUrl: string;
cdpHost: string;
cdpIsLoopback: boolean;
mcpTargetUrl?: string;
color: string;
driver: "openclaw" | "extension" | "existing-session";
attachOnly: boolean;
@ -363,13 +364,18 @@ export function resolveProfile(
: "openclaw";
if (driver === "existing-session") {
// existing-session uses Chrome MCP auto-connect; no CDP port/URL needed
const parsed = rawProfileUrl
? parseHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`)
: null;
// existing-session uses Chrome MCP. It can either auto-connect to a local desktop
// session or connect to a debuggable browser URL/WS endpoint when explicitly configured.
return {
name: profileName,
cdpPort: 0,
cdpUrl: "",
cdpHost: "",
cdpIsLoopback: true,
cdpHost: parsed?.parsed.hostname ?? "",
cdpIsLoopback: parsed ? isLoopbackHost(parsed.parsed.hostname) : true,
...(parsed ? { mcpTargetUrl: parsed.normalized } : {}),
color: profile.color,
driver,
attachOnly: true,

View File

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

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 { ctx } = createCtx(resolved);
const { ctx, state } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({
name: "chrome-live",
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
});
await expect(
service.createProfile({
name: "chrome-live",
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
}),
).rejects.toThrow(/does not accept cdpUrl/i);
expect(result.transport).toBe("chrome-mcp");
expect(result.cdpUrl).toBeNull();
expect(result.isRemote).toBe(false);
expect(state.resolved.profiles["chrome-live"]).toEqual({
cdpUrl: "http://127.0.0.1:9222",
driver: "existing-session",
attachOnly: true,
color: expect.any(String),
});
});
it("deletes remote profiles without stopping or removing local data", async () => {

View File

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

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import {
installPwToolsCoreTestHooks,
getPwToolsCoreSessionMocks,
setPwToolsCoreCurrentPage,
setPwToolsCoreCurrentRefLocator,
} from "./pw-tools-core.test-harness.js";
@ -92,4 +93,24 @@ describe("pw-tools-core", () => {
}),
).rejects.toThrow(/not interactable/i);
});
it("keeps Playwright strictness for selector-based actions", async () => {
const click = vi.fn(async () => {});
const first = vi.fn(() => {
throw new Error("selector actions should not call locator.first()");
});
const locator = vi.fn(() => ({ click, first }));
setPwToolsCoreCurrentPage({ locator });
await mod.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
selector: "button.submit",
});
expect(locator).toHaveBeenCalledWith("button.submit");
expect(first).not.toHaveBeenCalled();
expect(getPwToolsCoreSessionMocks().refLocator).not.toHaveBeenCalled();
expect(click).toHaveBeenCalled();
});
});

View File

@ -43,6 +43,24 @@ 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)));
}
@ -88,12 +106,8 @@ export async function clickViaPlaywright(opts: {
delayMs?: number;
timeoutMs?: number;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const page = await getRestoredPageForTarget(opts);
const label = resolved.ref ?? resolved.selector!;
const locator = resolved.ref
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
const { locator, label } = resolveLocatorForInteraction(page, opts);
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
try {
const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS);
@ -106,12 +120,14 @@ export async function clickViaPlaywright(opts: {
timeout,
button: opts.button,
modifiers: opts.modifiers,
delay: opts.delayMs,
});
} else {
await locator.click({
timeout,
button: opts.button,
modifiers: opts.modifiers,
delay: opts.delayMs,
});
}
} catch (err) {
@ -126,12 +142,8 @@ export async function hoverViaPlaywright(opts: {
selector?: string;
timeoutMs?: number;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const page = await getRestoredPageForTarget(opts);
const label = resolved.ref ?? resolved.selector!;
const locator = resolved.ref
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
const { locator, label } = resolveLocatorForInteraction(page, opts);
try {
await locator.hover({
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
@ -150,23 +162,21 @@ export async function dragViaPlaywright(opts: {
endSelector?: string;
timeoutMs?: number;
}): Promise<void> {
const resolvedStart = requireRefOrSelector(opts.startRef, opts.startSelector);
const resolvedEnd = requireRefOrSelector(opts.endRef, opts.endSelector);
const page = await getRestoredPageForTarget(opts);
const startLocator = resolvedStart.ref
? refLocator(page, requireRef(resolvedStart.ref))
: page.locator(resolvedStart.selector!);
const endLocator = resolvedEnd.ref
? refLocator(page, requireRef(resolvedEnd.ref))
: page.locator(resolvedEnd.selector!);
const startLabel = resolvedStart.ref ?? resolvedStart.selector!;
const endLabel = resolvedEnd.ref ?? resolvedEnd.selector!;
const from = resolveLocatorForInteraction(page, {
ref: opts.startRef,
selector: opts.startSelector,
});
const to = resolveLocatorForInteraction(page, {
ref: opts.endRef,
selector: opts.endSelector,
});
try {
await startLocator.dragTo(endLocator, {
await from.locator.dragTo(to.locator, {
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
});
} catch (err) {
throw toAIFriendlyError(err, `${startLabel} -> ${endLabel}`);
throw toAIFriendlyError(err, `${from.label} -> ${to.label}`);
}
}
@ -178,15 +188,11 @@ export async function selectOptionViaPlaywright(opts: {
values: string[];
timeoutMs?: number;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
if (!opts.values?.length) {
throw new Error("values are required");
}
const page = await getRestoredPageForTarget(opts);
const label = resolved.ref ?? resolved.selector!;
const locator = resolved.ref
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
const { locator, label } = resolveLocatorForInteraction(page, opts);
try {
await locator.selectOption(opts.values, {
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
@ -223,13 +229,9 @@ export async function typeViaPlaywright(opts: {
slowly?: boolean;
timeoutMs?: number;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const text = String(opts.text ?? "");
const page = await getRestoredPageForTarget(opts);
const label = resolved.ref ?? resolved.selector!;
const locator = resolved.ref
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
const { locator, label } = resolveLocatorForInteraction(page, opts);
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
try {
if (opts.slowly) {
@ -423,14 +425,9 @@ export async function scrollIntoViewViaPlaywright(opts: {
selector?: string;
timeoutMs?: number;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const page = await getRestoredPageForTarget(opts);
const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
const label = resolved.ref ?? resolved.selector!;
const locator = resolved.ref
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
const { locator, label } = resolveLocatorForInteraction(page, opts);
try {
await locator.scrollIntoViewIfNeeded({ timeout });
} catch (err) {

View File

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

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";
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
let cachedConfig: ReturnType<typeof buildConfig> | null = null;
@ -206,4 +215,59 @@ describe("server-context hot-reload profiles", () => {
expect(runtime?.lastTargetId).toBeNull();
expect(runtime?.reconcile?.reason).toContain("cdpPort");
});
it("marks existing-session runtime state for reconcile when MCP target URL changes", async () => {
cfgProfiles = {
user: {
cdpUrl: "http://127.0.0.1:9222",
color: "#00AA00",
driver: "existing-session",
attachOnly: true,
},
};
cachedConfig = null;
const cfg = loadConfig();
const resolved = resolveBrowserConfig({ ...cfg.browser, defaultProfile: "user" }, cfg);
const userProfile = resolveProfile(resolved, "user");
expect(userProfile).toBeTruthy();
expect(userProfile?.mcpTargetUrl).toBe("http://127.0.0.1:9222");
const state: BrowserServerState = {
server: null,
port: 18791,
resolved,
profiles: new Map([
[
"user",
{
profile: userProfile!,
running: { pid: 123 } as never,
lastTargetId: "tab-1",
reconcile: null,
},
],
]),
};
cfgProfiles.user = {
cdpUrl: "http://127.0.0.1:9333",
color: "#00AA00",
driver: "existing-session",
attachOnly: true,
};
cachedConfig = null;
refreshResolvedBrowserConfigFromDisk({
current: state,
refreshConfigFromDisk: true,
mode: "cached",
});
const runtime = state.profiles.get("user");
expect(runtime).toBeTruthy();
expect(runtime?.profile.mcpTargetUrl).toBe("http://127.0.0.1:9333");
expect(runtime?.lastTargetId).toBeNull();
expect(runtime?.reconcile?.reason).toContain("mcpTargetUrl");
});
});

View File

@ -2,6 +2,7 @@ import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { resolveProfile } from "./config.js";
import { DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME } from "./constants.js";
import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
@ -40,6 +41,35 @@ export function listKnownProfileNames(state: BrowserServerState): string[] {
return [...names];
}
function resolveImplicitProfileName(state: BrowserServerState): string {
const defaultProfileName = state.resolved.defaultProfile;
if (!state.resolved.headless) {
return defaultProfileName;
}
const defaultProfile = resolveProfile(state.resolved, defaultProfileName);
if (!defaultProfile) {
return defaultProfileName;
}
const capabilities = getBrowserProfileCapabilities(defaultProfile);
if (!capabilities.requiresRelay) {
return defaultProfileName;
}
const managedProfile = resolveProfile(state.resolved, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
if (!managedProfile) {
return defaultProfileName;
}
const managedCapabilities = getBrowserProfileCapabilities(managedProfile);
if (managedCapabilities.requiresRelay) {
return defaultProfileName;
}
return managedProfile.name;
}
/**
* Create a profile-scoped context for browser operations.
*/
@ -129,7 +159,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
const forProfile = (profileName?: string): ProfileContext => {
const current = state();
const name = profileName ?? current.resolved.defaultProfile;
const name = profileName ?? resolveImplicitProfileName(current);
const profile = resolveBrowserProfileWithHotReload({
current,
refreshConfigFromDisk,

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 CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.",
"Per-profile browser endpoint URL. For openclaw/extension drivers this is the CDP URL; for existing-session it is passed to Chrome DevTools MCP as browserUrl/wsEndpoint so headless or remote MCP attach can target a running debuggable browser.",
"browser.profiles.*.driver":
'Per-profile browser driver mode: "openclaw" (or legacy "clawd") or "extension" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.',
"browser.profiles.*.attachOnly":

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). */
/** CDP URL for this profile (use for remote Chrome, or as browserUrl/wsEndpoint for existing-session MCP attach). */
cdpUrl?: string;
/** Profile driver (default: openclaw). */
driver?: "openclaw" | "clawd" | "extension" | "existing-session";