feat(browser): support direct WebSocket CDP URLs for Browserbase

Browserbase uses direct WebSocket connections (wss://) rather than the
standard HTTP-based /json/version CDP discovery flow used by Browserless.
This change teaches the browser tool to accept ws:// and wss:// URLs as
cdpUrl values: when a WebSocket URL is detected, OpenClaw connects
directly instead of attempting HTTP discovery.

Changes:
- config.ts: accept ws:// and wss:// in cdpUrl validation
- cdp.helpers.ts: add isWebSocketUrl() helper
- cdp.ts: skip /json/version when cdpUrl is already a WebSocket URL
- chrome.ts: probe WSS endpoints via WebSocket handshake instead of HTTP
- cdp.test.ts: add test for direct WebSocket target creation
- docs/tools/browser.md: update Browserbase section with correct URL
  format and notes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shrey150
2026-03-02 00:49:55 -08:00
committed by Peter Steinberger
parent 3cf75f760c
commit 75602014db
6 changed files with 106 additions and 17 deletions

View File

@@ -200,9 +200,10 @@ Notes:
[Browserbase](https://www.browserbase.com) is a cloud platform for running
headless browsers. It provides remote CDP endpoints with built-in CAPTCHA
solving, stealth mode, and residential proxies. You can point an
OpenClaw browser profile at Browserbase's connect endpoint and authenticate
with your API key.
solving, stealth mode, and residential proxies. Unlike Browserless (which
exposes a standard HTTP-based CDP discovery endpoint), Browserbase uses a
direct WebSocket connection — OpenClaw connects to `wss://connect.browserbase.com`
and authenticates via your API key in the query string.
Example:
@@ -228,6 +229,8 @@ Notes:
- [Sign up](https://www.browserbase.com/sign-up) and copy your **API Key**
from the [Overview dashboard](https://www.browserbase.com/overview).
- Replace `<BROWSERBASE_API_KEY>` with your real Browserbase API key.
- Browserbase auto-creates a browser session on WebSocket connect, so no
manual session creation step is needed.
- The free tier allows one concurrent session and one browser hour per month.
See [pricing](https://www.browserbase.com/pricing) for paid plan limits.
- See the [Browserbase docs](https://docs.browserbase.com) for full API

View File

@@ -7,6 +7,20 @@ import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js";
export { isLoopbackHost };
/**
* Returns true when the URL uses a WebSocket protocol (ws: or wss:).
* Used to distinguish direct-WebSocket CDP endpoints (e.g. Browserbase)
* from HTTP(S) endpoints that require /json/version discovery.
*/
export function isWebSocketUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "ws:" || parsed.protocol === "wss:";
} catch {
return false;
}
}
type CdpResponse = {
id: number;
result?: unknown;

View File

@@ -95,6 +95,34 @@ describe("cdp", () => {
expect(created.targetId).toBe("TARGET_123");
});
it("creates a target via direct WebSocket URL (skips /json/version)", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method !== "Target.createTarget") {
return;
}
socket.send(
JSON.stringify({
id: msg.id,
result: { targetId: "TARGET_WS_DIRECT" },
}),
);
});
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
const created = await createTargetViaCdp({
cdpUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`,
url: "https://example.com",
});
expect(created.targetId).toBe("TARGET_WS_DIRECT");
// /json/version should NOT have been called — direct WS skips HTTP discovery
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it("blocks private navigation targets by default", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {

View File

@@ -1,8 +1,20 @@
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { appendCdpPath, fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";
import {
appendCdpPath,
fetchJson,
isLoopbackHost,
isWebSocketUrl,
withCdpSocket,
} from "./cdp.helpers.js";
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
export { appendCdpPath, fetchJson, fetchOk, getHeadersWithAuth } from "./cdp.helpers.js";
export {
appendCdpPath,
fetchJson,
fetchOk,
getHeadersWithAuth,
isWebSocketUrl,
} from "./cdp.helpers.js";
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
const ws = new URL(wsUrl);
@@ -94,14 +106,21 @@ export async function createTargetViaCdp(opts: {
...withBrowserNavigationPolicy(opts.ssrfPolicy),
});
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
appendCdpPath(opts.cdpUrl, "/json/version"),
1500,
);
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
const wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
if (!wsUrl) {
throw new Error("CDP /json/version missing webSocketDebuggerUrl");
let wsUrl: string;
if (isWebSocketUrl(opts.cdpUrl)) {
// Direct WebSocket URL (e.g. Browserbase) — skip /json/version discovery.
wsUrl = opts.cdpUrl;
} else {
// Standard HTTP(S) CDP endpoint — discover WebSocket URL via /json/version.
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
appendCdpPath(opts.cdpUrl, "/json/version"),
1500,
);
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
if (!wsUrl) {
throw new Error("CDP /json/version missing webSocketDebuggerUrl");
}
}
return await withCdpSocket(wsUrl, async (send) => {

View File

@@ -17,7 +17,7 @@ import {
CHROME_STOP_TIMEOUT_MS,
CHROME_WS_READY_TIMEOUT_MS,
} from "./cdp-timeouts.js";
import { appendCdpPath, fetchCdpChecked, openCdpWebSocket } from "./cdp.helpers.js";
import { appendCdpPath, fetchCdpChecked, isWebSocketUrl, openCdpWebSocket } from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import {
type BrowserExecutable,
@@ -78,10 +78,29 @@ function cdpUrlForPort(cdpPort: number) {
return `http://127.0.0.1:${cdpPort}`;
}
async function canOpenWebSocket(url: string, timeoutMs: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs });
ws.once("open", () => {
try {
ws.close();
} catch {
// ignore
}
resolve(true);
});
ws.once("error", () => resolve(false));
});
}
export async function isChromeReachable(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
): Promise<boolean> {
if (isWebSocketUrl(cdpUrl)) {
// Direct WebSocket endpoint (e.g. Browserbase) — probe via WS handshake.
return await canOpenWebSocket(cdpUrl, timeoutMs);
}
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
return Boolean(version);
}
@@ -117,6 +136,10 @@ export async function getChromeWebSocketUrl(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
): Promise<string | null> {
if (isWebSocketUrl(cdpUrl)) {
// Direct WebSocket endpoint — the cdpUrl is already the WebSocket URL.
return cdpUrl;
}
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
if (!wsUrl) {

View File

@@ -129,14 +129,16 @@ function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy |
export function parseHttpUrl(raw: string, label: string) {
const trimmed = raw.trim();
const parsed = new URL(trimmed);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`${label} must be http(s), got: ${parsed.protocol.replace(":", "")}`);
const allowed = ["http:", "https:", "ws:", "wss:"];
if (!allowed.includes(parsed.protocol)) {
throw new Error(`${label} must be http(s) or ws(s), got: ${parsed.protocol.replace(":", "")}`);
}
const isSecure = parsed.protocol === "https:" || parsed.protocol === "wss:";
const port =
parsed.port && Number.parseInt(parsed.port, 10) > 0
? Number.parseInt(parsed.port, 10)
: parsed.protocol === "https:"
: isSecure
? 443
: 80;