diff --git a/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md index 2e7844860aa..6496885775f 100644 --- a/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md +++ b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md @@ -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..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..cdpUrl` use that exact WSL2-reachable address? +3. OpenClaw config: does `browser.profiles..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? diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 60a6f285b10..ecd6ce5b9c0 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -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..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. diff --git a/src/browser/cdp-bridge.test.ts b/src/browser/cdp-bridge.test.ts new file mode 100644 index 00000000000..a9b25fb9b39 --- /dev/null +++ b/src/browser/cdp-bridge.test.ts @@ -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> = []; + + afterEach(async () => { + while (closers.length > 0) { + const close = closers.pop(); + await close?.(); + } + }); + + async function closeHttpServer(server: HttpServer): Promise { + server.closeIdleConnections?.(); + server.closeAllConnections?.(); + await Promise.race([ + new Promise((resolve) => server.close(() => resolve())), + new Promise((resolve) => setTimeout(resolve, 250)), + ]); + } + + async function closeWebSocketServer(server: WebSocketServer): Promise { + for (const client of server.clients) { + try { + client.terminate(); + } catch { + // ignore + } + } + await Promise.race([ + new Promise((resolve) => server.close(() => resolve())), + new Promise((resolve) => setTimeout(resolve, 250)), + ]); + } + + async function withWebSocket(url: string, fn: (socket: WsClient) => Promise): Promise { + const socket = new WebSocket(url); + await new Promise((resolve, reject) => { + socket.once("open", () => resolve()); + socket.once("error", reject); + }); + try { + return await fn(socket); + } finally { + await new Promise((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((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((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((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((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((resolve, reject) => { + socket.once("message", (data) => resolve(rawDataToString(data))); + socket.once("error", reject); + socket.send("pong"); + }), + ); + + expect(reply).toBe("direct:pong"); + }); +}); diff --git a/src/browser/cdp-bridge.ts b/src/browser/cdp-bridge.ts new file mode 100644 index 00000000000..a9d58baf099 --- /dev/null +++ b/src/browser/cdp-bridge.ts @@ -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; +}; + +function sleep(ms: number): Promise { + 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 { + const filtered: Record = {}; + 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 { + 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 { + await new Promise((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 { + const upstreamBase = new URL(opts.upstreamUrl); + const wsServer = new WebSocketServer({ noServer: true }); + const activeSockets = new Set(); + 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((resolve) => wsServer.close(() => resolve())), + sleep(250), + ]); + await Promise.race([ + new Promise((resolve) => server.close(() => resolve())), + sleep(250), + ]); + }, + }; +} diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index 57b17c56add..3e0c175bffb 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -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", () => { diff --git a/src/browser/config.ts b/src/browser/config.ts index ab59f7539f6..7c3bb54e728 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -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, + controlPort: number, +): Record { + 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, }; } diff --git a/src/browser/runtime-lifecycle.ts b/src/browser/runtime-lifecycle.ts index 7b181faea6e..f6b520194d6 100644 --- a/src/browser/runtime-lifecycle.ts +++ b/src/browser/runtime-lifecycle.ts @@ -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((resolve) => { params.current?.server?.close(() => resolve()); diff --git a/src/browser/server-context.types.ts b/src/browser/server-context.types.ts index b8ad7aa329d..e26e6f59ab8 100644 --- a/src/browser/server-context.types.ts +++ b/src/browser/server-context.types.ts @@ -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; + cdpBridge?: LocalCdpBridgeServer | null; }; type BrowserProfileActions = { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 63a6657165e..c8e477ca192 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -252,6 +252,16 @@ export const FIELD_HELP: Record = { "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..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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 9b1fdb73445..c7ff9fc21dc 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -119,6 +119,11 @@ export const FIELD_LABELS: Record = { "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", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index fcf73073fb6..8d7321aa916 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -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; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 8c78d049d0e..453a9de9756 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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(),