Compare commits
3 Commits
main
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e329757f02 | ||
|
|
7e6dfddaf8 | ||
|
|
6aee522008 |
18
.github/workflows/install-smoke.yml
vendored
18
.github/workflows/install-smoke.yml
vendored
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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?
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
192
scripts/test-browser-existing-session-docker.sh
Executable file
192
scripts/test-browser-existing-session-docker.sh
Executable 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."
|
||||||
|
'
|
||||||
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?.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", () => {
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user