browser: add local cdp bridge for remote mcp attach
This commit is contained in:
parent
7e6dfddaf8
commit
e329757f02
@ -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?
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
189
src/browser/cdp-bridge.test.ts
Normal file
189
src/browser/cdp-bridge.test.ts
Normal 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
379
src/browser/cdp-bridge.ts
Normal 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),
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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", () => {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user