diff --git a/docs/tools/browser.md b/docs/tools/browser.md index efd8874c993..d02feaf3b55 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -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 `` 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 diff --git a/src/browser/cdp.helpers.ts b/src/browser/cdp.helpers.ts index 0ae9d22d80b..1072853abfb 100644 --- a/src/browser/cdp.helpers.ts +++ b/src/browser/cdp.helpers.ts @@ -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; diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index e8e2b9f6d6a..8b988c69860 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -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 { diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 20686b76fed..b40b142bf3b 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -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) => { diff --git a/src/browser/chrome.ts b/src/browser/chrome.ts index f610b74caaa..86ebc70576b 100644 --- a/src/browser/chrome.ts +++ b/src/browser/chrome.ts @@ -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 { + return new Promise((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 { + 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 { + 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) { diff --git a/src/browser/config.ts b/src/browser/config.ts index 336049e8c69..b2b953aa31a 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -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;