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 [Browserbase](https://www.browserbase.com) is a cloud platform for running
headless browsers. It provides remote CDP endpoints with built-in CAPTCHA headless browsers. It provides remote CDP endpoints with built-in CAPTCHA
solving, stealth mode, and residential proxies. You can point an solving, stealth mode, and residential proxies. Unlike Browserless (which
OpenClaw browser profile at Browserbase's connect endpoint and authenticate exposes a standard HTTP-based CDP discovery endpoint), Browserbase uses a
with your API key. direct WebSocket connection — OpenClaw connects to `wss://connect.browserbase.com`
and authenticates via your API key in the query string.
Example: Example:
@@ -228,6 +229,8 @@ Notes:
- [Sign up](https://www.browserbase.com/sign-up) and copy your **API Key** - [Sign up](https://www.browserbase.com/sign-up) and copy your **API Key**
from the [Overview dashboard](https://www.browserbase.com/overview). from the [Overview dashboard](https://www.browserbase.com/overview).
- Replace `<BROWSERBASE_API_KEY>` with your real Browserbase API key. - 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. - 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 [pricing](https://www.browserbase.com/pricing) for paid plan limits.
- See the [Browserbase docs](https://docs.browserbase.com) for full API - 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 }; 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 = { type CdpResponse = {
id: number; id: number;
result?: unknown; result?: unknown;

View File

@@ -95,6 +95,34 @@ describe("cdp", () => {
expect(created.targetId).toBe("TARGET_123"); 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 () => { it("blocks private navigation targets by default", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch"); const fetchSpy = vi.spyOn(globalThis, "fetch");
try { try {

View File

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

View File

@@ -17,7 +17,7 @@ import {
CHROME_STOP_TIMEOUT_MS, CHROME_STOP_TIMEOUT_MS,
CHROME_WS_READY_TIMEOUT_MS, CHROME_WS_READY_TIMEOUT_MS,
} from "./cdp-timeouts.js"; } 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 { normalizeCdpWsUrl } from "./cdp.js";
import { import {
type BrowserExecutable, type BrowserExecutable,
@@ -78,10 +78,29 @@ function cdpUrlForPort(cdpPort: number) {
return `http://127.0.0.1:${cdpPort}`; 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( export async function isChromeReachable(
cdpUrl: string, cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
): Promise<boolean> { ): 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); const version = await fetchChromeVersion(cdpUrl, timeoutMs);
return Boolean(version); return Boolean(version);
} }
@@ -117,6 +136,10 @@ export async function getChromeWebSocketUrl(
cdpUrl: string, cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
): Promise<string | null> { ): 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 version = await fetchChromeVersion(cdpUrl, timeoutMs);
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim(); const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
if (!wsUrl) { if (!wsUrl) {

View File

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