browser: add local cdp bridge for remote mcp attach

This commit is contained in:
Vincent Koc 2026-03-14 16:13:18 -07:00
parent 7e6dfddaf8
commit e329757f02
12 changed files with 839 additions and 19 deletions

View File

@ -145,6 +145,38 @@ Notes:
- keep `attachOnly: true` for externally managed browsers
- test the same URL with `curl` before expecting OpenClaw to succeed
Optional: if you want a stable local loopback endpoint inside WSL2, enable the
CDP bridge and point the profile at the bridge instead of the Windows host
directly:
```json5
{
browser: {
enabled: true,
defaultProfile: "remote",
cdpBridge: {
upstreamUrl: "http://WINDOWS_HOST_OR_IP:9222",
bindHost: "127.0.0.1",
port: 18794,
},
profiles: {
remote: {
cdpUrl: "http://127.0.0.1:18794",
attachOnly: true,
color: "#00AA00",
},
},
},
}
```
Bridge notes:
- `browser.cdpBridge.upstreamUrl` is the Windows-reachable Chrome debug endpoint
- `browser.profiles.<name>.cdpUrl` stays local and is what MCP connects to
- use this when the Windows host/IP is annoying to keep track of or you want a
single local attach URL inside WSL2
### Layer 4: If you use the Chrome extension relay instead
If the browser machine and the Gateway are separated by a namespace boundary, the relay may need a non-loopback bind address.
@ -227,7 +259,8 @@ Treat each message as a layer-specific clue:
1. Windows: does `curl http://127.0.0.1:9222/json/version` work?
2. WSL2: does `curl http://WINDOWS_HOST_OR_IP:9222/json/version` work?
3. OpenClaw config: does `browser.profiles.<name>.cdpUrl` use that exact WSL2-reachable address?
3. OpenClaw config: does `browser.profiles.<name>.cdpUrl` use the right endpoint for your mode
(direct Windows address, or the local `browser.cdpBridge.port` endpoint if you enabled the bridge)?
4. Control UI: are you opening `http://127.0.0.1:18789/` instead of a LAN IP?
5. Extension relay only: do you actually need `browser.relayBindHost`, and if so is it set explicitly?

View File

@ -462,6 +462,9 @@ Notes:
- 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.
- For split-host setups such as WSL2 Gateway + Windows Chrome, you can keep
`browser.profiles.<name>.cdpUrl` local and set `browser.cdpBridge.upstreamUrl`
to the remote browser debug 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
@ -482,6 +485,29 @@ WSL2 / cross-namespace example:
}
```
WSL2 / remote CDP bridge example:
```json5
{
browser: {
enabled: true,
defaultProfile: "user",
cdpBridge: {
upstreamUrl: "http://WINDOWS_HOST_OR_IP:9222",
bindHost: "127.0.0.1",
port: 18794,
},
profiles: {
user: {
driver: "existing-session",
cdpUrl: "http://127.0.0.1:18794",
color: "#00AA00",
},
},
},
}
```
## Isolation guarantees
- **Dedicated user data dir**: never touches your personal browser profile.

View File

@ -0,0 +1,189 @@
import { createServer, type Server as HttpServer } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import WebSocket, { type WebSocket as WsClient, WebSocketServer } from "ws";
import { rawDataToString } from "../infra/ws.js";
import {
rewriteCdpBridgePayload,
startLocalCdpBridge,
type LocalCdpBridgeServer,
} from "./cdp-bridge.js";
describe("cdp bridge", () => {
const closers: Array<() => Promise<void>> = [];
afterEach(async () => {
while (closers.length > 0) {
const close = closers.pop();
await close?.();
}
});
async function closeHttpServer(server: HttpServer): Promise<void> {
server.closeIdleConnections?.();
server.closeAllConnections?.();
await Promise.race([
new Promise<void>((resolve) => server.close(() => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, 250)),
]);
}
async function closeWebSocketServer(server: WebSocketServer): Promise<void> {
for (const client of server.clients) {
try {
client.terminate();
} catch {
// ignore
}
}
await Promise.race([
new Promise<void>((resolve) => server.close(() => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, 250)),
]);
}
async function withWebSocket<T>(url: string, fn: (socket: WsClient) => Promise<T>): Promise<T> {
const socket = new WebSocket(url);
await new Promise<void>((resolve, reject) => {
socket.once("open", () => resolve());
socket.once("error", reject);
});
try {
return await fn(socket);
} finally {
await new Promise<void>((resolve) => {
socket.once("close", () => resolve());
socket.close();
});
}
}
it("forwards HTTP browserUrl requests through the bridge", async () => {
const upstreamWss = new WebSocketServer({ noServer: true });
const upstreamHttp = createServer((req, res) => {
if (req.url === "/json/version") {
const { port } = upstreamHttp.address() as { port: number };
res.setHeader("content-type", "application/json");
res.end(
JSON.stringify({
Browser: "Chrome",
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/UPSTREAM`,
}),
);
return;
}
res.statusCode = 404;
res.end("not found");
});
upstreamHttp.on("upgrade", (req, socket, head) => {
upstreamWss.handleUpgrade(req, socket, head, (ws) => {
upstreamWss.emit("connection", ws, req);
});
});
await new Promise<void>((resolve) => upstreamHttp.listen(0, "127.0.0.1", resolve));
closers.push(
async () => await closeWebSocketServer(upstreamWss),
async () => await closeHttpServer(upstreamHttp),
);
const { port: upstreamPort } = upstreamHttp.address() as { port: number };
const bridge = await startLocalCdpBridge({
upstreamUrl: `http://127.0.0.1:${upstreamPort}`,
bindHost: "127.0.0.1",
port: 0,
});
closers.push(bridge.stop);
const res = await fetch(`${bridge.baseUrl}/json/version`);
const payload = (await res.json()) as { Browser: string; webSocketDebuggerUrl: string };
expect(payload.Browser).toBe("Chrome");
expect(payload.webSocketDebuggerUrl).toContain("/devtools/browser/UPSTREAM");
});
it("rewrites websocket debugger URLs to the local bridge endpoint", () => {
const payload = rewriteCdpBridgePayload({
payload: {
Browser: "Chrome",
webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/browser/UPSTREAM",
},
upstreamUrl: "http://127.0.0.1:9222",
localHttpBaseUrl: "http://127.0.0.1:18794",
}) as { webSocketDebuggerUrl: string };
expect(payload.webSocketDebuggerUrl).toBe("ws://127.0.0.1:18794/devtools/browser/UPSTREAM");
});
it("forwards websocket traffic for HTTP browserUrl upstreams", async () => {
const upstreamWss = new WebSocketServer({ noServer: true });
const upstreamHttp = createServer((_req, res) => {
res.statusCode = 404;
res.end("not found");
});
upstreamHttp.on("upgrade", (req, socket, head) => {
upstreamWss.handleUpgrade(req, socket, head, (ws) => {
upstreamWss.emit("connection", ws, req);
});
});
upstreamWss.on("connection", (socket) => {
socket.on("message", (data) => {
socket.send(`upstream:${rawDataToString(data)}`);
});
});
await new Promise<void>((resolve) => upstreamHttp.listen(0, "127.0.0.1", resolve));
closers.push(
async () => await closeWebSocketServer(upstreamWss),
async () => await closeHttpServer(upstreamHttp),
);
const { port: upstreamPort } = upstreamHttp.address() as { port: number };
const bridge = await startLocalCdpBridge({
upstreamUrl: `http://127.0.0.1:${upstreamPort}`,
bindHost: "127.0.0.1",
port: 0,
});
closers.push(bridge.stop);
const reply = await withWebSocket(
`ws://127.0.0.1:${bridge.port}/devtools/browser/UPSTREAM`,
async (socket) =>
await new Promise<string>((resolve, reject) => {
socket.once("message", (data) => resolve(rawDataToString(data)));
socket.once("error", reject);
socket.send("ping");
}),
);
expect(reply).toBe("upstream:ping");
});
it("forwards websocket traffic for direct wsEndpoint upstreams", async () => {
const upstreamWss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
upstreamWss.on("connection", (socket) => {
socket.on("message", (data) => {
socket.send(`direct:${rawDataToString(data)}`);
});
});
await new Promise<void>((resolve) => upstreamWss.once("listening", resolve));
closers.push(async () => await closeWebSocketServer(upstreamWss));
const upstreamPort = (upstreamWss.address() as { port: number }).port;
const bridge: LocalCdpBridgeServer = await startLocalCdpBridge({
upstreamUrl: `ws://127.0.0.1:${upstreamPort}/devtools/browser/DIRECT`,
bindHost: "127.0.0.1",
port: 0,
});
closers.push(bridge.stop);
const reply = await withWebSocket(
`ws://127.0.0.1:${bridge.port}/devtools/browser/DIRECT`,
async (socket) =>
await new Promise<string>((resolve, reject) => {
socket.once("message", (data) => resolve(rawDataToString(data)));
socket.once("error", reject);
socket.send("pong");
}),
);
expect(reply).toBe("direct:pong");
});
});

379
src/browser/cdp-bridge.ts Normal file
View File

@ -0,0 +1,379 @@
import { createServer, type IncomingMessage, type Server as HttpServer } from "node:http";
import type { AddressInfo } from "node:net";
import type { Duplex } from "node:stream";
import WebSocket, { WebSocketServer } from "ws";
import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
import { getHeadersWithAuth, openCdpWebSocket } from "./cdp.helpers.js";
export type LocalCdpBridgeServer = {
bindHost: string;
port: number;
baseUrl: string;
upstreamUrl: string;
stop: () => Promise<void>;
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
type UrlRewriteMapping = {
upstreamBase: URL;
localBase: URL;
};
function isWebSocketProtocol(protocol: string): boolean {
return protocol === "ws:" || protocol === "wss:";
}
function normalizeBasePath(pathname: string): string {
const normalized = pathname.replace(/\/$/, "");
return normalized === "" ? "/" : normalized;
}
function joinPaths(basePath: string, suffixPath: string): string {
const normalizedBase = normalizeBasePath(basePath);
const normalizedSuffix = suffixPath.startsWith("/") ? suffixPath : `/${suffixPath}`;
if (normalizedBase === "/") {
return normalizedSuffix;
}
return `${normalizedBase}${normalizedSuffix}`;
}
function filterHopByHopHeaders(headers: IncomingMessage["headers"]): Record<string, string> {
const filtered: Record<string, string> = {};
const hopByHop = new Set([
"connection",
"content-length",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]);
for (const [key, value] of Object.entries(headers)) {
if (hopByHop.has(key.toLowerCase()) || value === undefined) {
continue;
}
filtered[key] = Array.isArray(value) ? value.join(", ") : value;
}
return filtered;
}
async function readRequestBody(req: IncomingMessage): Promise<Buffer | undefined> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
if (chunks.length === 0) {
return undefined;
}
return Buffer.concat(chunks);
}
function buildUpstreamHttpUrl(upstreamBase: URL, reqUrl: string): string {
if (isWebSocketProtocol(upstreamBase.protocol)) {
throw new Error("HTTP forwarding is unavailable for WebSocket-only CDP bridge upstreams");
}
const incoming = new URL(reqUrl, "http://127.0.0.1");
const target = new URL(upstreamBase.toString());
target.pathname = joinPaths(upstreamBase.pathname, incoming.pathname);
target.search = incoming.search;
return target.toString();
}
function buildUpstreamWsUrl(upstreamBase: URL, reqUrl: string): string {
const incoming = new URL(reqUrl, "http://127.0.0.1");
const target = new URL(upstreamBase.toString());
if (isWebSocketProtocol(upstreamBase.protocol)) {
const shouldUseUpstreamPath = incoming.pathname === "/" || incoming.pathname === "";
target.pathname = shouldUseUpstreamPath ? upstreamBase.pathname : incoming.pathname;
target.search =
shouldUseUpstreamPath && incoming.search === "" ? upstreamBase.search : incoming.search;
return target.toString();
}
target.protocol = upstreamBase.protocol === "https:" ? "wss:" : "ws:";
target.pathname = joinPaths(upstreamBase.pathname, incoming.pathname);
target.search = incoming.search;
return target.toString();
}
function rewriteUrl(raw: string, mapping: UrlRewriteMapping): string {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return raw;
}
const sameHost = parsed.host === mapping.upstreamBase.host;
const sameTransportFamily =
isWebSocketProtocol(parsed.protocol) === isWebSocketProtocol(mapping.upstreamBase.protocol);
if (!sameHost || !sameTransportFamily) {
return raw;
}
const upstreamBasePath = normalizeBasePath(mapping.upstreamBase.pathname);
const normalizedPath = normalizeBasePath(parsed.pathname);
const matchesBase =
upstreamBasePath === "/" ||
normalizedPath === upstreamBasePath ||
normalizedPath.startsWith(`${upstreamBasePath}/`);
if (!matchesBase) {
return raw;
}
const relativePath =
upstreamBasePath === "/"
? parsed.pathname
: parsed.pathname.slice(upstreamBasePath.length) || "/";
const rewritten = new URL(mapping.localBase.toString());
rewritten.pathname = joinPaths(mapping.localBase.pathname, relativePath);
rewritten.search = parsed.search;
rewritten.hash = parsed.hash;
return rewritten.toString();
}
function rewritePayloadUrls(value: unknown, mappings: UrlRewriteMapping[]): unknown {
if (typeof value === "string") {
return mappings.reduce((current, mapping) => rewriteUrl(current, mapping), value);
}
if (Array.isArray(value)) {
return value.map((entry) => rewritePayloadUrls(entry, mappings));
}
if (!value || typeof value !== "object") {
return value;
}
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, rewritePayloadUrls(entry, mappings)]),
);
}
export function rewriteCdpBridgePayload(params: {
payload: unknown;
upstreamUrl: string;
localHttpBaseUrl: string;
localWsBaseUrl?: string;
}): unknown {
const upstreamBase = new URL(params.upstreamUrl);
const localHttpBase = new URL(params.localHttpBaseUrl);
const localWsBase = new URL(
params.localWsBaseUrl ??
params.localHttpBaseUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:"),
);
return rewritePayloadUrls(params.payload, [
{
upstreamBase,
localBase: localHttpBase,
},
{
upstreamBase: new URL(buildUpstreamWsUrl(upstreamBase, "/")),
localBase: localWsBase,
},
]);
}
function closeSocket(socket: Duplex, status: number, message: string) {
const body = Buffer.from(message);
socket.write(
`HTTP/1.1 ${status} ERR\r\n` +
"Content-Type: text/plain; charset=utf-8\r\n" +
`Content-Length: ${body.length}\r\n` +
"Connection: close\r\n\r\n",
);
socket.write(body);
socket.destroy();
}
async function startServer(
server: HttpServer,
bindHost: string,
port: number,
): Promise<AddressInfo> {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, bindHost, () => resolve());
});
return server.address() as AddressInfo;
}
export async function startLocalCdpBridge(opts: {
upstreamUrl: string;
bindHost: string;
port: number;
}): Promise<LocalCdpBridgeServer> {
const upstreamBase = new URL(opts.upstreamUrl);
const wsServer = new WebSocketServer({ noServer: true });
const activeSockets = new Set<WebSocket>();
let localHttpBase!: URL;
let localWsBase!: URL;
const server = createServer(async (req, res) => {
try {
const upstreamUrl = buildUpstreamHttpUrl(upstreamBase, req.url ?? "/");
const body = await readRequestBody(req);
const headers = getHeadersWithAuth(upstreamUrl, filterHopByHopHeaders(req.headers));
const upstreamRes = await withNoProxyForCdpUrl(
upstreamUrl,
async () =>
await fetch(upstreamUrl, {
method: req.method,
headers,
...(body ? { body } : {}),
}),
);
const contentType = upstreamRes.headers.get("content-type") ?? "";
const shouldRewriteJson =
contentType.includes("application/json") || (req.url ?? "").startsWith("/json/");
res.statusCode = upstreamRes.status;
res.statusMessage = upstreamRes.statusText;
if (!shouldRewriteJson) {
upstreamRes.headers.forEach((value, key) => {
if (key.toLowerCase() === "content-length") {
return;
}
res.setHeader(key, value);
});
const buffer = Buffer.from(await upstreamRes.arrayBuffer());
res.setHeader("content-length", String(buffer.length));
res.end(buffer);
return;
}
const raw = await upstreamRes.text();
let rewritten = raw;
try {
const payload = JSON.parse(raw) as unknown;
const rewrittenPayload = rewriteCdpBridgePayload({
payload,
upstreamUrl: upstreamBase.toString(),
localHttpBaseUrl: localHttpBase.toString(),
localWsBaseUrl: localWsBase.toString(),
});
rewritten = JSON.stringify(rewrittenPayload);
} catch {
// Preserve the original body if the upstream claimed JSON but returned invalid content.
}
upstreamRes.headers.forEach((value, key) => {
if (key.toLowerCase() === "content-length") {
return;
}
res.setHeader(key, value);
});
res.setHeader("content-length", String(Buffer.byteLength(rewritten)));
res.end(rewritten);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
res.statusCode = 502;
res.setHeader("content-type", "text/plain; charset=utf-8");
res.end(message);
}
});
server.on("upgrade", (req, socket, head) => {
let targetWsUrl: string;
try {
targetWsUrl = buildUpstreamWsUrl(upstreamBase, req.url ?? "/");
} catch (err) {
closeSocket(socket, 502, err instanceof Error ? err.message : String(err));
return;
}
wsServer.handleUpgrade(req, socket, head, (clientSocket) => {
activeSockets.add(clientSocket);
const pendingClientMessages: Array<{ data: WebSocket.RawData; isBinary: boolean }> = [];
let closed = false;
const closeBoth = () => {
if (closed) {
return;
}
closed = true;
activeSockets.delete(clientSocket);
try {
clientSocket.terminate();
} catch {
// ignore
}
};
const upstreamSocket = openCdpWebSocket(targetWsUrl);
activeSockets.add(upstreamSocket);
const terminateBoth = () => {
closeBoth();
activeSockets.delete(upstreamSocket);
pendingClientMessages.length = 0;
try {
upstreamSocket.terminate();
} catch {
// ignore
}
};
clientSocket.on("message", (data, isBinary) => {
if (upstreamSocket.readyState === WebSocket.OPEN) {
upstreamSocket.send(data, { binary: isBinary });
return;
}
pendingClientMessages.push({ data, isBinary });
});
upstreamSocket.on("open", () => {
for (const pending of pendingClientMessages.splice(0)) {
upstreamSocket.send(pending.data, { binary: pending.isBinary });
}
});
upstreamSocket.on("message", (data, isBinary) => {
if (clientSocket.readyState === WebSocket.OPEN) {
clientSocket.send(data, { binary: isBinary });
}
});
upstreamSocket.on("close", terminateBoth);
upstreamSocket.on("error", terminateBoth);
clientSocket.on("close", terminateBoth);
clientSocket.on("error", terminateBoth);
});
});
server.on("clientError", (err, socket) => {
closeSocket(socket, 400, err instanceof Error ? err.message : String(err));
});
const address = await startServer(server, opts.bindHost, opts.port);
localHttpBase = new URL(`http://${opts.bindHost}:${address.port}`);
localWsBase = new URL(`ws://${opts.bindHost}:${address.port}`);
return {
bindHost: opts.bindHost,
port: address.port,
baseUrl: localHttpBase.toString().replace(/\/$/, ""),
upstreamUrl: opts.upstreamUrl,
stop: async () => {
for (const socket of activeSockets) {
try {
socket.terminate();
} catch {
// ignore
}
}
activeSockets.clear();
server.closeIdleConnections?.();
server.closeAllConnections?.();
await Promise.race([
new Promise<void>((resolve) => wsServer.close(() => resolve())),
sleep(250),
]);
await Promise.race([
new Promise<void>((resolve) => server.close(() => resolve())),
sleep(250),
]);
},
};
}

View File

@ -27,8 +27,10 @@ describe("browser config", () => {
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);
const chromeRelay = resolveProfile(resolved, "chrome-relay");
expect(chromeRelay?.driver).toBe("extension");
expect(chromeRelay?.cdpPort).toBe(18792);
expect(chromeRelay?.cdpUrl).toBe("http://127.0.0.1:18792");
expect(resolved.remoteCdpTimeoutMs).toBe(1500);
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000);
});
@ -37,7 +39,10 @@ describe("browser config", () => {
withEnv({ OPENCLAW_GATEWAY_PORT: "19001" }, () => {
const resolved = resolveBrowserConfig(undefined);
expect(resolved.controlPort).toBe(19003);
expect(resolveProfile(resolved, "chrome-relay")).toBe(null);
const chromeRelay = resolveProfile(resolved, "chrome-relay");
expect(chromeRelay?.driver).toBe("extension");
expect(chromeRelay?.cdpPort).toBe(19004);
expect(chromeRelay?.cdpUrl).toBe("http://127.0.0.1:19004");
const openclaw = resolveProfile(resolved, "openclaw");
expect(openclaw?.cdpPort).toBe(19012);
@ -49,7 +54,10 @@ describe("browser config", () => {
withEnv({ OPENCLAW_GATEWAY_PORT: undefined }, () => {
const resolved = resolveBrowserConfig(undefined, { gateway: { port: 19011 } });
expect(resolved.controlPort).toBe(19013);
expect(resolveProfile(resolved, "chrome-relay")).toBe(null);
const chromeRelay = resolveProfile(resolved, "chrome-relay");
expect(chromeRelay?.driver).toBe("extension");
expect(chromeRelay?.cdpPort).toBe(19014);
expect(chromeRelay?.cdpUrl).toBe("http://127.0.0.1:19014");
const openclaw = resolveProfile(resolved, "openclaw");
expect(openclaw?.cdpPort).toBe(19022);
@ -214,12 +222,57 @@ describe("browser config", () => {
expect(resolved.relayBindHost).toBe("0.0.0.0");
});
it("resolves cdpBridge defaults when configured", () => {
const resolved = resolveBrowserConfig({
cdpBridge: {
upstreamUrl: " http://host.docker.internal:9222/ ",
},
});
expect(resolved.cdpBridge).toEqual({
enabled: true,
upstreamUrl: "http://host.docker.internal:9222",
bindHost: "127.0.0.1",
port: resolved.controlPort + 3,
});
});
it("rejects non-loopback cdpBridge bind hosts", () => {
expect(() =>
resolveBrowserConfig({
cdpBridge: {
upstreamUrl: "http://host.docker.internal:9222",
bindHost: "0.0.0.0",
},
}),
).toThrow(/browser\.cdpBridge\.bindHost must be a loopback host/);
});
it("requires cdpBridge upstreamUrl when enabled", () => {
expect(() =>
resolveBrowserConfig({
cdpBridge: {
enabled: true,
},
}),
).toThrow(/browser\.cdpBridge\.upstreamUrl is required/);
});
it("rejects unsupported protocols", () => {
expect(() => resolveBrowserConfig({ cdpUrl: "ftp://127.0.0.1:18791" })).toThrow(
"must be http(s) or ws(s)",
);
});
it("does not add the built-in chrome-relay profile if the derived relay port is already used", () => {
const resolved = resolveBrowserConfig({
profiles: {
openclaw: { cdpPort: 18792, color: "#FF4500" },
},
});
expect(resolveProfile(resolved, "chrome-relay")).toBe(null);
expect(resolved.defaultProfile).toBe("openclaw");
});
it("defaults extraArgs to empty array when not provided", () => {
const resolved = resolveBrowserConfig(undefined);
expect(resolved.extraArgs).toEqual([]);
@ -308,7 +361,6 @@ describe("browser config", () => {
const resolved = resolveBrowserConfig({
profiles: {
"chrome-live": { driver: "existing-session", attachOnly: true, color: "#00AA00" },
relay: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" },
work: { cdpPort: 18801, color: "#0066CC" },
},
});
@ -319,7 +371,7 @@ describe("browser config", () => {
const managed = resolveProfile(resolved, "openclaw")!;
expect(getBrowserProfileCapabilities(managed).usesChromeMcp).toBe(false);
const extension = resolveProfile(resolved, "relay")!;
const extension = resolveProfile(resolved, "chrome-relay")!;
expect(getBrowserProfileCapabilities(extension).usesChromeMcp).toBe(false);
const work = resolveProfile(resolved, "work")!;
@ -360,17 +412,17 @@ describe("browser config", () => {
it("explicit defaultProfile config overrides defaults in headless mode", () => {
const resolved = resolveBrowserConfig({
headless: true,
defaultProfile: "user",
defaultProfile: "chrome-relay",
});
expect(resolved.defaultProfile).toBe("user");
expect(resolved.defaultProfile).toBe("chrome-relay");
});
it("explicit defaultProfile config overrides defaults in noSandbox mode", () => {
const resolved = resolveBrowserConfig({
noSandbox: true,
defaultProfile: "user",
defaultProfile: "chrome-relay",
});
expect(resolved.defaultProfile).toBe("user");
expect(resolved.defaultProfile).toBe("chrome-relay");
});
it("allows custom profile as default even in headless mode", () => {

View File

@ -14,7 +14,7 @@ import {
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";
import { CDP_PORT_RANGE_START } from "./profiles.js";
import { CDP_PORT_RANGE_START, getUsedPorts } from "./profiles.js";
export type ResolvedBrowserConfig = {
enabled: boolean;
@ -37,6 +37,14 @@ export type ResolvedBrowserConfig = {
ssrfPolicy?: SsrFPolicy;
extraArgs: string[];
relayBindHost?: string;
cdpBridge?: ResolvedBrowserCdpBridgeConfig;
};
export type ResolvedBrowserCdpBridgeConfig = {
enabled: boolean;
upstreamUrl?: string;
bindHost: string;
port: number;
};
export type ResolvedBrowserProfile = {
@ -99,6 +107,50 @@ function normalizeStringList(raw: string[] | undefined): string[] | undefined {
return values.length > 0 ? values : undefined;
}
function resolveCdpBridgePort(rawPort: number | undefined, controlPort: number): number {
const fallback = controlPort + 3;
const port =
typeof rawPort === "number" && Number.isFinite(rawPort) ? Math.floor(rawPort) : fallback;
if (port < 1 || port > 65535) {
throw new Error(`browser.cdpBridge.port must be between 1 and 65535, got: ${port}`);
}
return port;
}
function resolveCdpBridgeConfig(
cfg: BrowserConfig | undefined,
controlPort: number,
): ResolvedBrowserCdpBridgeConfig | undefined {
const bridge = cfg?.cdpBridge;
if (!bridge) {
return undefined;
}
const bindHost = bridge.bindHost?.trim() || "127.0.0.1";
if (!isLoopbackHost(bindHost)) {
throw new Error(
`browser.cdpBridge.bindHost must be a loopback host (got ${bridge.bindHost ?? "(empty)"})`,
);
}
const upstreamUrlRaw = bridge.upstreamUrl?.trim() || "";
const enabled = bridge.enabled !== false;
if (enabled && !upstreamUrlRaw) {
throw new Error("browser.cdpBridge.upstreamUrl is required when browser.cdpBridge is enabled");
}
const parsedUpstream = upstreamUrlRaw
? parseHttpUrl(upstreamUrlRaw, "browser.cdpBridge.upstreamUrl")
: undefined;
return {
enabled,
upstreamUrl: parsedUpstream?.normalized,
bindHost,
port: resolveCdpBridgePort(bridge.port, controlPort),
};
}
function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined {
const allowPrivateNetwork = cfg?.ssrfPolicy?.allowPrivateNetwork;
const dangerouslyAllowPrivateNetwork = cfg?.ssrfPolicy?.dangerouslyAllowPrivateNetwork;
@ -198,6 +250,36 @@ function ensureDefaultUserBrowserProfile(
return result;
}
/**
* Ensure a built-in "chrome-relay" profile exists for the Chrome extension relay.
*
* Note: this is an OpenClaw browser profile (routing config), not a Chrome user profile.
* It points at the local relay CDP endpoint (controlPort + 1).
*/
function ensureDefaultChromeRelayProfile(
profiles: Record<string, BrowserProfileConfig>,
controlPort: number,
): Record<string, BrowserProfileConfig> {
const result = { ...profiles };
if (result["chrome-relay"]) {
return result;
}
const relayPort = controlPort + 1;
if (!Number.isFinite(relayPort) || relayPort <= 0 || relayPort > 65535) {
return result;
}
// Avoid adding the built-in profile if the derived relay port is already used by another profile
// (legacy single-profile configs may use controlPort+1 for openclaw/openclaw CDP).
if (getUsedPorts(result).has(relayPort)) {
return result;
}
result["chrome-relay"] = {
driver: "extension",
cdpUrl: `http://127.0.0.1:${relayPort}`,
color: "#00AA00",
};
return result;
}
export function resolveBrowserConfig(
cfg: BrowserConfig | undefined,
rootConfig?: OpenClawConfig,
@ -257,14 +339,17 @@ export function resolveBrowserConfig(
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:";
const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined;
const profiles = ensureDefaultUserBrowserProfile(
ensureDefaultProfile(
cfg?.profiles,
defaultColor,
legacyCdpPort,
cdpPortRangeStart,
legacyCdpUrl,
const profiles = ensureDefaultChromeRelayProfile(
ensureDefaultUserBrowserProfile(
ensureDefaultProfile(
cfg?.profiles,
defaultColor,
legacyCdpPort,
cdpPortRangeStart,
legacyCdpUrl,
),
),
controlPort,
);
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
@ -281,6 +366,7 @@ export function resolveBrowserConfig(
: [];
const ssrfPolicy = resolveBrowserSsrFPolicy(cfg);
const relayBindHost = cfg?.relayBindHost?.trim() || undefined;
const cdpBridge = resolveCdpBridgeConfig(cfg, controlPort);
return {
enabled,
@ -303,6 +389,7 @@ export function resolveBrowserConfig(
ssrfPolicy,
extraArgs,
relayBindHost,
cdpBridge,
};
}

View File

@ -1,4 +1,5 @@
import type { Server } from "node:http";
import { startLocalCdpBridge } from "./cdp-bridge.js";
import { isPwAiLoaded } from "./pw-ai-state.js";
import type { BrowserServerState } from "./server-context.js";
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
@ -14,8 +15,17 @@ export async function createBrowserRuntimeState(params: {
port: params.port,
resolved: params.resolved,
profiles: new Map(),
cdpBridge: null,
};
if (params.resolved.cdpBridge?.enabled && params.resolved.cdpBridge.upstreamUrl) {
state.cdpBridge = await startLocalCdpBridge({
upstreamUrl: params.resolved.cdpBridge.upstreamUrl,
bindHost: params.resolved.cdpBridge.bindHost,
port: params.resolved.cdpBridge.port,
});
}
await ensureExtensionRelayForProfiles({
resolved: params.resolved,
onWarn: params.onWarn,
@ -40,6 +50,12 @@ export async function stopBrowserRuntime(params: {
onWarn: params.onWarn,
});
try {
await params.current.cdpBridge?.stop();
} catch (err) {
params.onWarn(`Failed to stop local CDP bridge: ${String(err)}`);
}
if (params.closeServer && params.current.server) {
await new Promise<void>((resolve) => {
params.current?.server?.close(() => resolve());

View File

@ -1,4 +1,5 @@
import type { Server } from "node:http";
import type { LocalCdpBridgeServer } from "./cdp-bridge.js";
import type { RunningChrome } from "./chrome.js";
import type { BrowserTransport } from "./client.js";
import type { BrowserTab } from "./client.js";
@ -25,6 +26,7 @@ export type BrowserServerState = {
port: number;
resolved: ResolvedBrowserConfig;
profiles: Map<string, ProfileRuntimeState>;
cdpBridge?: LocalCdpBridgeServer | null;
};
type BrowserProfileActions = {

View File

@ -252,6 +252,16 @@ export const FIELD_HELP: Record<string, string> = {
"Default browser profile name selected when callers do not explicitly choose a profile. Use a stable low-privilege profile as the default to reduce accidental cross-context state use.",
"browser.relayBindHost":
"Bind IP address for the Chrome extension relay listener. Leave unset for loopback-only access, or set an explicit non-loopback IP such as 0.0.0.0 only when the relay must be reachable across network namespaces (for example WSL2) and the surrounding network is already trusted.",
"browser.cdpBridge":
"Optional local CDP bridge that forwards Chrome DevTools HTTP/WebSocket traffic to a remote browser debug endpoint. Use this for WSL2 or split-host setups where MCP should attach to a stable local loopback URL while the actual browser runs elsewhere.",
"browser.cdpBridge.enabled":
"Enables the local CDP bridge when browser.cdpBridge is configured. Leave enabled for stable local attach flows, or disable temporarily while keeping the bridge settings in config for later reuse.",
"browser.cdpBridge.upstreamUrl":
"Remote browser debug endpoint that the local CDP bridge forwards to. Use an http(s) browser URL when MCP should discover WebSocket targets through /json/version, or a ws(s) endpoint when you want a direct bridged WebSocket path.",
"browser.cdpBridge.bindHost":
"Loopback bind host for the local CDP bridge listener. Keep this on 127.0.0.1 or ::1 so the bridge is not exposed beyond the local machine or WSL2 namespace.",
"browser.cdpBridge.port":
"Local port for the CDP bridge listener. Point browser.profiles.<name>.cdpUrl at this local endpoint so existing-session MCP attaches through the bridge instead of the remote host directly.",
"browser.profiles":
"Named browser profile connection map used for explicit routing to CDP ports or URLs with optional metadata. Keep profile names consistent and avoid overlapping endpoint definitions.",
"browser.profiles.*.cdpPort":

View File

@ -119,6 +119,11 @@ export const FIELD_LABELS: Record<string, string> = {
"browser.cdpPortRangeStart": "Browser CDP Port Range Start",
"browser.defaultProfile": "Browser Default Profile",
"browser.relayBindHost": "Browser Relay Bind Address",
"browser.cdpBridge": "Browser CDP Bridge",
"browser.cdpBridge.enabled": "Browser CDP Bridge Enabled",
"browser.cdpBridge.upstreamUrl": "Browser CDP Bridge Upstream URL",
"browser.cdpBridge.bindHost": "Browser CDP Bridge Bind Address",
"browser.cdpBridge.port": "Browser CDP Bridge Port",
"browser.profiles": "Browser Profiles",
"browser.profiles.*.cdpPort": "Browser Profile CDP Port",
"browser.profiles.*.cdpUrl": "Browser Profile CDP URL",

View File

@ -10,6 +10,16 @@ export type BrowserProfileConfig = {
/** Profile color (hex). Auto-assigned at creation. */
color: string;
};
export type BrowserCdpBridgeConfig = {
/** Enable the local CDP bridge that forwards to a remote debug endpoint. Default: true when configured. */
enabled?: boolean;
/** Upstream remote debug endpoint (http(s) browserUrl or ws(s) wsEndpoint). */
upstreamUrl?: string;
/** Loopback bind address for the local bridge. Default: 127.0.0.1 */
bindHost?: string;
/** Local bridge port. Default: derived from browser control port. */
port?: number;
};
export type BrowserSnapshotDefaults = {
/** Default snapshot mode (applies when mode is not provided). */
mode?: "efficient";
@ -72,4 +82,6 @@ export type BrowserConfig = {
* the relay must be reachable from a different network namespace.
*/
relayBindHost?: string;
/** Optional local CDP bridge for WSL2/remote-browser topologies. */
cdpBridge?: BrowserCdpBridgeConfig;
};

View File

@ -381,6 +381,15 @@ export const OpenClawSchema = z
.optional(),
extraArgs: z.array(z.string()).optional(),
relayBindHost: z.union([z.string().ipv4(), z.string().ipv6()]).optional(),
cdpBridge: z
.object({
enabled: z.boolean().optional(),
upstreamUrl: z.string().optional(),
bindHost: z.union([z.string().ipv4(), z.string().ipv6()]).optional(),
port: z.number().int().min(1).max(65535).optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),