Compare commits

...

3 Commits

Author SHA1 Message Date
Vincent Koc
e329757f02 browser: add local cdp bridge for remote mcp attach 2026-03-14 16:13:18 -07:00
Vincent Koc
7e6dfddaf8 ci: add docker existing-session mcp smoke 2026-03-14 16:11:26 -07:00
Vincent Koc
6aee522008 docker: add lsof to runtime image 2026-03-14 16:10:14 -07:00
15 changed files with 1050 additions and 20 deletions

View File

@ -81,6 +81,24 @@ jobs:
run: | run: |
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc 'which openclaw && openclaw --version' docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc 'which openclaw && openclaw --version'
- name: Build browser MCP smoke image
uses: useblacksmith/build-push-action@v2
with:
context: .
file: ./Dockerfile
build-args: |
OPENCLAW_INSTALL_BROWSER=1
tags: openclaw-browser-mcp-smoke:local
load: true
push: false
provenance: false
- name: Run browser existing-session MCP docker smoke
env:
OPENCLAW_BROWSER_SMOKE_IMAGE: openclaw-browser-mcp-smoke:local
OPENCLAW_BROWSER_SMOKE_SKIP_IMAGE_BUILD: "1"
run: bash scripts/test-browser-existing-session-docker.sh
- name: Build installer smoke image - name: Build installer smoke image
uses: useblacksmith/build-push-action@v2 uses: useblacksmith/build-push-action@v2
with: with:

View File

@ -134,7 +134,7 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
apt-get update && \ apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends && \ DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
procps hostname curl git openssl procps hostname curl git lsof openssl
RUN chown node:node /app RUN chown node:node /app

View File

@ -145,6 +145,38 @@ Notes:
- keep `attachOnly: true` for externally managed browsers - keep `attachOnly: true` for externally managed browsers
- test the same URL with `curl` before expecting OpenClaw to succeed - 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 ### 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. 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? 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? 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? 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? 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`. - On desktop, OpenClaw uses MCP `--autoConnect`.
- In headless mode, OpenClaw can launch Chrome through MCP or connect MCP to a - In headless mode, OpenClaw can launch Chrome through MCP or connect MCP to a
configured browser URL/WS endpoint. 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 - Existing-session screenshots support page captures and `--ref` element
captures from snapshots, but not CSS `--element` selectors. captures from snapshots, but not CSS `--element` selectors.
- Existing-session `wait --url` supports exact, substring, and glob patterns - Existing-session `wait --url` supports exact, substring, and glob patterns
@ -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 ## Isolation guarantees
- **Dedicated user data dir**: never touches your personal browser profile. - **Dedicated user data dir**: never touches your personal browser profile.

View File

@ -0,0 +1,192 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE_NAME="${OPENCLAW_BROWSER_SMOKE_IMAGE:-${CLAWDBOT_BROWSER_SMOKE_IMAGE:-openclaw-browser-mcp-smoke:local}}"
SKIP_IMAGE_BUILD="${OPENCLAW_BROWSER_SMOKE_SKIP_IMAGE_BUILD:-${CLAWDBOT_BROWSER_SMOKE_SKIP_IMAGE_BUILD:-0}}"
GATEWAY_PORT="${OPENCLAW_BROWSER_SMOKE_GATEWAY_PORT:-18789}"
DEBUG_PORT="${OPENCLAW_BROWSER_SMOKE_DEBUG_PORT:-9222}"
TOKEN="${OPENCLAW_BROWSER_SMOKE_TOKEN:-browser-smoke-token}"
if [[ "$SKIP_IMAGE_BUILD" == "1" ]]; then
echo "==> Reuse prebuilt browser smoke image: $IMAGE_NAME"
else
echo "==> Build browser smoke image: $IMAGE_NAME"
docker build \
--build-arg OPENCLAW_INSTALL_BROWSER=1 \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/Dockerfile" \
"$ROOT_DIR"
fi
echo "==> Run Docker existing-session MCP smoke"
docker run --rm -t \
--entrypoint /bin/bash \
-e OPENCLAW_BROWSER_SMOKE_TOKEN="$TOKEN" \
-e OPENCLAW_BROWSER_SMOKE_GATEWAY_PORT="$GATEWAY_PORT" \
-e OPENCLAW_BROWSER_SMOKE_DEBUG_PORT="$DEBUG_PORT" \
"$IMAGE_NAME" -lc '
set -euo pipefail
SMOKE_STEP="bootstrap"
SMOKE_ROOT="$(mktemp -d /tmp/openclaw-browser-smoke.XXXXXX)"
CHROME_LOG="$SMOKE_ROOT/chrome.log"
GATEWAY_LOG="$SMOKE_ROOT/gateway.log"
START_LOG="$SMOKE_ROOT/browser-start.json"
STATUS_LOG="$SMOKE_ROOT/browser-status.json"
TABS_LOG="$SMOKE_ROOT/browser-tabs.json"
CHROME_PID=""
GATEWAY_PID=""
TOKEN="${OPENCLAW_BROWSER_SMOKE_TOKEN:-browser-smoke-token}"
GATEWAY_PORT="${OPENCLAW_BROWSER_SMOKE_GATEWAY_PORT:-18789}"
DEBUG_PORT="${OPENCLAW_BROWSER_SMOKE_DEBUG_PORT:-9222}"
GATEWAY_URL="ws://127.0.0.1:${GATEWAY_PORT}"
cleanup() {
local exit_code=$?
if [[ $exit_code -ne 0 ]]; then
echo "Smoke failed during step: ${SMOKE_STEP}" >&2
if [[ -f "$CHROME_LOG" ]]; then
echo "--- chrome.log ---" >&2
cat "$CHROME_LOG" >&2
fi
if [[ -f "$GATEWAY_LOG" ]]; then
echo "--- gateway.log ---" >&2
cat "$GATEWAY_LOG" >&2
fi
if [[ -f "$START_LOG" ]]; then
echo "--- browser-start.json ---" >&2
cat "$START_LOG" >&2
fi
if [[ -f "$STATUS_LOG" ]]; then
echo "--- browser-status.json ---" >&2
cat "$STATUS_LOG" >&2
fi
if [[ -f "$TABS_LOG" ]]; then
echo "--- browser-tabs.json ---" >&2
cat "$TABS_LOG" >&2
fi
fi
if [[ -n "$GATEWAY_PID" ]]; then
kill "$GATEWAY_PID" >/dev/null 2>&1 || true
fi
if [[ -n "$CHROME_PID" ]]; then
kill "$CHROME_PID" >/dev/null 2>&1 || true
fi
rm -rf "$SMOKE_ROOT"
}
trap cleanup EXIT
mkdir -p "$HOME/.openclaw"
cat > "$HOME/.openclaw/openclaw.json" <<EOF
{
"gateway": {
"mode": "local",
"bind": "loopback",
"port": ${GATEWAY_PORT},
"auth": {
"mode": "token",
"token": "${TOKEN}"
}
},
"browser": {
"enabled": true,
"headless": true,
"noSandbox": true,
"defaultProfile": "user",
"profiles": {
"user": {
"driver": "existing-session",
"cdpUrl": "http://127.0.0.1:${DEBUG_PORT}",
"color": "#00AA00"
}
}
}
}
EOF
SMOKE_STEP="resolve chrome executable"
CHROME_BIN="$(node -e "import(\"playwright-core\").then((m)=>process.stdout.write(m.chromium.executablePath()))")"
if [[ -z "$CHROME_BIN" || ! -x "$CHROME_BIN" ]]; then
echo "Unable to resolve Playwright Chromium executable" >&2
exit 1
fi
SMOKE_STEP="start chrome"
"$CHROME_BIN" \
--headless=new \
--remote-debugging-address=127.0.0.1 \
--remote-debugging-port="${DEBUG_PORT}" \
--user-data-dir="$SMOKE_ROOT/profile" \
--no-sandbox \
--disable-dev-shm-usage \
about:blank >"$CHROME_LOG" 2>&1 &
CHROME_PID=$!
SMOKE_STEP="wait for chrome debug endpoint"
for _ in $(seq 1 60); do
if curl -fsS "http://127.0.0.1:${DEBUG_PORT}/json/version" >/dev/null 2>&1; then
break
fi
sleep 1
done
curl -fsS "http://127.0.0.1:${DEBUG_PORT}/json/version" >/dev/null
SMOKE_STEP="start gateway"
openclaw gateway run --bind loopback --port "${GATEWAY_PORT}" --force >"$GATEWAY_LOG" 2>&1 &
GATEWAY_PID=$!
SMOKE_STEP="wait for gateway rpc readiness"
for _ in $(seq 1 60); do
if openclaw gateway status --url "$GATEWAY_URL" --token "$TOKEN" --deep --require-rpc >/dev/null 2>&1; then
break
fi
sleep 1
done
openclaw gateway status --url "$GATEWAY_URL" --token "$TOKEN" --deep --require-rpc >/dev/null
SMOKE_STEP="browser start"
openclaw browser --url "$GATEWAY_URL" --token "$TOKEN" --browser-profile user --json start >"$START_LOG"
SMOKE_STEP="browser status"
openclaw browser --url "$GATEWAY_URL" --token "$TOKEN" --browser-profile user --json status >"$STATUS_LOG"
SMOKE_STEP="browser tabs"
openclaw browser --url "$GATEWAY_URL" --token "$TOKEN" --browser-profile user --json tabs >"$TABS_LOG"
SMOKE_STEP="validate outputs"
node - "$START_LOG" "$STATUS_LOG" "$TABS_LOG" <<'"'"'EOF'"'"'
const fs = require("node:fs");
const [startPath, statusPath, tabsPath] = process.argv.slice(2);
const start = JSON.parse(fs.readFileSync(startPath, "utf8"));
const status = JSON.parse(fs.readFileSync(statusPath, "utf8"));
const tabs = JSON.parse(fs.readFileSync(tabsPath, "utf8"));
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
for (const [label, payload] of [
["start", start],
["status", status],
]) {
assert(payload.profile === "user", `${label}: expected profile=user`);
assert(payload.driver === "existing-session", `${label}: expected driver existing-session`);
assert(payload.transport === "chrome-mcp", `${label}: expected transport chrome-mcp`);
assert(payload.running === true, `${label}: expected running=true`);
assert(payload.cdpReady === true, `${label}: expected cdpReady=true`);
}
assert(Array.isArray(tabs.tabs), "tabs: expected tabs array");
assert(tabs.tabs.length >= 1, "tabs: expected at least one tab");
assert(
tabs.tabs.some((tab) => typeof tab?.url === "string" && tab.url.startsWith("about:blank")),
"tabs: expected about:blank tab",
);
EOF
echo "Browser existing-session Docker smoke passed."
'

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?.cdpPort).toBe(0);
expect(user?.cdpUrl).toBe(""); expect(user?.cdpUrl).toBe("");
expect(user?.mcpTargetUrl).toBeUndefined(); expect(user?.mcpTargetUrl).toBeUndefined();
// chrome-relay is no longer auto-created const chromeRelay = resolveProfile(resolved, "chrome-relay");
expect(resolveProfile(resolved, "chrome-relay")).toBe(null); 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.remoteCdpTimeoutMs).toBe(1500);
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000); expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000);
}); });
@ -37,7 +39,10 @@ describe("browser config", () => {
withEnv({ OPENCLAW_GATEWAY_PORT: "19001" }, () => { withEnv({ OPENCLAW_GATEWAY_PORT: "19001" }, () => {
const resolved = resolveBrowserConfig(undefined); const resolved = resolveBrowserConfig(undefined);
expect(resolved.controlPort).toBe(19003); 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"); const openclaw = resolveProfile(resolved, "openclaw");
expect(openclaw?.cdpPort).toBe(19012); expect(openclaw?.cdpPort).toBe(19012);
@ -49,7 +54,10 @@ describe("browser config", () => {
withEnv({ OPENCLAW_GATEWAY_PORT: undefined }, () => { withEnv({ OPENCLAW_GATEWAY_PORT: undefined }, () => {
const resolved = resolveBrowserConfig(undefined, { gateway: { port: 19011 } }); const resolved = resolveBrowserConfig(undefined, { gateway: { port: 19011 } });
expect(resolved.controlPort).toBe(19013); 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"); const openclaw = resolveProfile(resolved, "openclaw");
expect(openclaw?.cdpPort).toBe(19022); expect(openclaw?.cdpPort).toBe(19022);
@ -214,12 +222,57 @@ describe("browser config", () => {
expect(resolved.relayBindHost).toBe("0.0.0.0"); 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", () => { it("rejects unsupported protocols", () => {
expect(() => resolveBrowserConfig({ cdpUrl: "ftp://127.0.0.1:18791" })).toThrow( expect(() => resolveBrowserConfig({ cdpUrl: "ftp://127.0.0.1:18791" })).toThrow(
"must be http(s) or ws(s)", "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", () => { it("defaults extraArgs to empty array when not provided", () => {
const resolved = resolveBrowserConfig(undefined); const resolved = resolveBrowserConfig(undefined);
expect(resolved.extraArgs).toEqual([]); expect(resolved.extraArgs).toEqual([]);
@ -308,7 +361,6 @@ describe("browser config", () => {
const resolved = resolveBrowserConfig({ const resolved = resolveBrowserConfig({
profiles: { profiles: {
"chrome-live": { driver: "existing-session", attachOnly: true, color: "#00AA00" }, "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" }, work: { cdpPort: 18801, color: "#0066CC" },
}, },
}); });
@ -319,7 +371,7 @@ describe("browser config", () => {
const managed = resolveProfile(resolved, "openclaw")!; const managed = resolveProfile(resolved, "openclaw")!;
expect(getBrowserProfileCapabilities(managed).usesChromeMcp).toBe(false); expect(getBrowserProfileCapabilities(managed).usesChromeMcp).toBe(false);
const extension = resolveProfile(resolved, "relay")!; const extension = resolveProfile(resolved, "chrome-relay")!;
expect(getBrowserProfileCapabilities(extension).usesChromeMcp).toBe(false); expect(getBrowserProfileCapabilities(extension).usesChromeMcp).toBe(false);
const work = resolveProfile(resolved, "work")!; const work = resolveProfile(resolved, "work")!;
@ -360,17 +412,17 @@ describe("browser config", () => {
it("explicit defaultProfile config overrides defaults in headless mode", () => { it("explicit defaultProfile config overrides defaults in headless mode", () => {
const resolved = resolveBrowserConfig({ const resolved = resolveBrowserConfig({
headless: true, 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", () => { it("explicit defaultProfile config overrides defaults in noSandbox mode", () => {
const resolved = resolveBrowserConfig({ const resolved = resolveBrowserConfig({
noSandbox: true, 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", () => { it("allows custom profile as default even in headless mode", () => {

View File

@ -14,7 +14,7 @@ import {
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME, DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js"; } from "./constants.js";
import { CDP_PORT_RANGE_START } from "./profiles.js"; import { CDP_PORT_RANGE_START, getUsedPorts } from "./profiles.js";
export type ResolvedBrowserConfig = { export type ResolvedBrowserConfig = {
enabled: boolean; enabled: boolean;
@ -37,6 +37,14 @@ export type ResolvedBrowserConfig = {
ssrfPolicy?: SsrFPolicy; ssrfPolicy?: SsrFPolicy;
extraArgs: string[]; extraArgs: string[];
relayBindHost?: string; relayBindHost?: string;
cdpBridge?: ResolvedBrowserCdpBridgeConfig;
};
export type ResolvedBrowserCdpBridgeConfig = {
enabled: boolean;
upstreamUrl?: string;
bindHost: string;
port: number;
}; };
export type ResolvedBrowserProfile = { export type ResolvedBrowserProfile = {
@ -99,6 +107,50 @@ function normalizeStringList(raw: string[] | undefined): string[] | undefined {
return values.length > 0 ? values : 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 { function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined {
const allowPrivateNetwork = cfg?.ssrfPolicy?.allowPrivateNetwork; const allowPrivateNetwork = cfg?.ssrfPolicy?.allowPrivateNetwork;
const dangerouslyAllowPrivateNetwork = cfg?.ssrfPolicy?.dangerouslyAllowPrivateNetwork; const dangerouslyAllowPrivateNetwork = cfg?.ssrfPolicy?.dangerouslyAllowPrivateNetwork;
@ -198,6 +250,36 @@ function ensureDefaultUserBrowserProfile(
return result; 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( export function resolveBrowserConfig(
cfg: BrowserConfig | undefined, cfg: BrowserConfig | undefined,
rootConfig?: OpenClawConfig, rootConfig?: OpenClawConfig,
@ -257,14 +339,17 @@ export function resolveBrowserConfig(
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined; const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:"; const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:";
const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined; const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined;
const profiles = ensureDefaultUserBrowserProfile( const profiles = ensureDefaultChromeRelayProfile(
ensureDefaultProfile( ensureDefaultUserBrowserProfile(
cfg?.profiles, ensureDefaultProfile(
defaultColor, cfg?.profiles,
legacyCdpPort, defaultColor,
cdpPortRangeStart, legacyCdpPort,
legacyCdpUrl, cdpPortRangeStart,
legacyCdpUrl,
),
), ),
controlPort,
); );
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http"; const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
@ -281,6 +366,7 @@ export function resolveBrowserConfig(
: []; : [];
const ssrfPolicy = resolveBrowserSsrFPolicy(cfg); const ssrfPolicy = resolveBrowserSsrFPolicy(cfg);
const relayBindHost = cfg?.relayBindHost?.trim() || undefined; const relayBindHost = cfg?.relayBindHost?.trim() || undefined;
const cdpBridge = resolveCdpBridgeConfig(cfg, controlPort);
return { return {
enabled, enabled,
@ -303,6 +389,7 @@ export function resolveBrowserConfig(
ssrfPolicy, ssrfPolicy,
extraArgs, extraArgs,
relayBindHost, relayBindHost,
cdpBridge,
}; };
} }

View File

@ -1,4 +1,5 @@
import type { Server } from "node:http"; import type { Server } from "node:http";
import { startLocalCdpBridge } from "./cdp-bridge.js";
import { isPwAiLoaded } from "./pw-ai-state.js"; import { isPwAiLoaded } from "./pw-ai-state.js";
import type { BrowserServerState } from "./server-context.js"; import type { BrowserServerState } from "./server-context.js";
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js"; import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
@ -14,8 +15,17 @@ export async function createBrowserRuntimeState(params: {
port: params.port, port: params.port,
resolved: params.resolved, resolved: params.resolved,
profiles: new Map(), 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({ await ensureExtensionRelayForProfiles({
resolved: params.resolved, resolved: params.resolved,
onWarn: params.onWarn, onWarn: params.onWarn,
@ -40,6 +50,12 @@ export async function stopBrowserRuntime(params: {
onWarn: params.onWarn, 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) { if (params.closeServer && params.current.server) {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
params.current?.server?.close(() => resolve()); params.current?.server?.close(() => resolve());

View File

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

View File

@ -119,6 +119,11 @@ export const FIELD_LABELS: Record<string, string> = {
"browser.cdpPortRangeStart": "Browser CDP Port Range Start", "browser.cdpPortRangeStart": "Browser CDP Port Range Start",
"browser.defaultProfile": "Browser Default Profile", "browser.defaultProfile": "Browser Default Profile",
"browser.relayBindHost": "Browser Relay Bind Address", "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": "Browser Profiles",
"browser.profiles.*.cdpPort": "Browser Profile CDP Port", "browser.profiles.*.cdpPort": "Browser Profile CDP Port",
"browser.profiles.*.cdpUrl": "Browser Profile CDP URL", "browser.profiles.*.cdpUrl": "Browser Profile CDP URL",

View File

@ -10,6 +10,16 @@ export type BrowserProfileConfig = {
/** Profile color (hex). Auto-assigned at creation. */ /** Profile color (hex). Auto-assigned at creation. */
color: string; 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 = { export type BrowserSnapshotDefaults = {
/** Default snapshot mode (applies when mode is not provided). */ /** Default snapshot mode (applies when mode is not provided). */
mode?: "efficient"; mode?: "efficient";
@ -72,4 +82,6 @@ export type BrowserConfig = {
* the relay must be reachable from a different network namespace. * the relay must be reachable from a different network namespace.
*/ */
relayBindHost?: string; relayBindHost?: string;
/** Optional local CDP bridge for WSL2/remote-browser topologies. */
cdpBridge?: BrowserCdpBridgeConfig;
}; };

View File

@ -381,6 +381,15 @@ export const OpenClawSchema = z
.optional(), .optional(),
extraArgs: z.array(z.string()).optional(), extraArgs: z.array(z.string()).optional(),
relayBindHost: z.union([z.string().ipv4(), z.string().ipv6()]).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() .strict()
.optional(), .optional(),