Skip to content

Commit 7560201

Browse files
shrey150claude
authored andcommitted
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 <[email protected]>
1 parent 3cf75f7 commit 7560201

File tree

6 files changed

+106
-17
lines changed

6 files changed

+106
-17
lines changed

docs/tools/browser.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,10 @@ Notes:
200200

201201
[Browserbase](https://www.browserbase.com) is a cloud platform for running
202202
headless browsers. It provides remote CDP endpoints with built-in CAPTCHA
203-
solving, stealth mode, and residential proxies. You can point an
204-
OpenClaw browser profile at Browserbase's connect endpoint and authenticate
205-
with your API key.
203+
solving, stealth mode, and residential proxies. Unlike Browserless (which
204+
exposes a standard HTTP-based CDP discovery endpoint), Browserbase uses a
205+
direct WebSocket connection — OpenClaw connects to `wss://connect.browserbase.com`
206+
and authenticates via your API key in the query string.
206207

207208
Example:
208209

@@ -228,6 +229,8 @@ Notes:
228229
- [Sign up](https://www.browserbase.com/sign-up) and copy your **API Key**
229230
from the [Overview dashboard](https://www.browserbase.com/overview).
230231
- Replace `<BROWSERBASE_API_KEY>` with your real Browserbase API key.
232+
- Browserbase auto-creates a browser session on WebSocket connect, so no
233+
manual session creation step is needed.
231234
- The free tier allows one concurrent session and one browser hour per month.
232235
See [pricing](https://www.browserbase.com/pricing) for paid plan limits.
233236
- See the [Browserbase docs](https://docs.browserbase.com) for full API

src/browser/cdp.helpers.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js";
77

88
export { isLoopbackHost };
99

10+
/**
11+
* Returns true when the URL uses a WebSocket protocol (ws: or wss:).
12+
* Used to distinguish direct-WebSocket CDP endpoints (e.g. Browserbase)
13+
* from HTTP(S) endpoints that require /json/version discovery.
14+
*/
15+
export function isWebSocketUrl(url: string): boolean {
16+
try {
17+
const parsed = new URL(url);
18+
return parsed.protocol === "ws:" || parsed.protocol === "wss:";
19+
} catch {
20+
return false;
21+
}
22+
}
23+
1024
type CdpResponse = {
1125
id: number;
1226
result?: unknown;

src/browser/cdp.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,34 @@ describe("cdp", () => {
9595
expect(created.targetId).toBe("TARGET_123");
9696
});
9797

98+
it("creates a target via direct WebSocket URL (skips /json/version)", async () => {
99+
const wsPort = await startWsServerWithMessages((msg, socket) => {
100+
if (msg.method !== "Target.createTarget") {
101+
return;
102+
}
103+
socket.send(
104+
JSON.stringify({
105+
id: msg.id,
106+
result: { targetId: "TARGET_WS_DIRECT" },
107+
}),
108+
);
109+
});
110+
111+
const fetchSpy = vi.spyOn(globalThis, "fetch");
112+
try {
113+
const created = await createTargetViaCdp({
114+
cdpUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`,
115+
url: "https://example.com",
116+
});
117+
118+
expect(created.targetId).toBe("TARGET_WS_DIRECT");
119+
// /json/version should NOT have been called — direct WS skips HTTP discovery
120+
expect(fetchSpy).not.toHaveBeenCalled();
121+
} finally {
122+
fetchSpy.mockRestore();
123+
}
124+
});
125+
98126
it("blocks private navigation targets by default", async () => {
99127
const fetchSpy = vi.spyOn(globalThis, "fetch");
100128
try {

src/browser/cdp.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
import type { SsrFPolicy } from "../infra/net/ssrf.js";
2-
import { appendCdpPath, fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";
2+
import {
3+
appendCdpPath,
4+
fetchJson,
5+
isLoopbackHost,
6+
isWebSocketUrl,
7+
withCdpSocket,
8+
} from "./cdp.helpers.js";
39
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
410

5-
export { appendCdpPath, fetchJson, fetchOk, getHeadersWithAuth } from "./cdp.helpers.js";
11+
export {
12+
appendCdpPath,
13+
fetchJson,
14+
fetchOk,
15+
getHeadersWithAuth,
16+
isWebSocketUrl,
17+
} from "./cdp.helpers.js";
618

719
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
820
const ws = new URL(wsUrl);
@@ -94,14 +106,21 @@ export async function createTargetViaCdp(opts: {
94106
...withBrowserNavigationPolicy(opts.ssrfPolicy),
95107
});
96108

97-
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
98-
appendCdpPath(opts.cdpUrl, "/json/version"),
99-
1500,
100-
);
101-
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
102-
const wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
103-
if (!wsUrl) {
104-
throw new Error("CDP /json/version missing webSocketDebuggerUrl");
109+
let wsUrl: string;
110+
if (isWebSocketUrl(opts.cdpUrl)) {
111+
// Direct WebSocket URL (e.g. Browserbase) — skip /json/version discovery.
112+
wsUrl = opts.cdpUrl;
113+
} else {
114+
// Standard HTTP(S) CDP endpoint — discover WebSocket URL via /json/version.
115+
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
116+
appendCdpPath(opts.cdpUrl, "/json/version"),
117+
1500,
118+
);
119+
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
120+
wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
121+
if (!wsUrl) {
122+
throw new Error("CDP /json/version missing webSocketDebuggerUrl");
123+
}
105124
}
106125

107126
return await withCdpSocket(wsUrl, async (send) => {

src/browser/chrome.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
CHROME_STOP_TIMEOUT_MS,
1818
CHROME_WS_READY_TIMEOUT_MS,
1919
} from "./cdp-timeouts.js";
20-
import { appendCdpPath, fetchCdpChecked, openCdpWebSocket } from "./cdp.helpers.js";
20+
import { appendCdpPath, fetchCdpChecked, isWebSocketUrl, openCdpWebSocket } from "./cdp.helpers.js";
2121
import { normalizeCdpWsUrl } from "./cdp.js";
2222
import {
2323
type BrowserExecutable,
@@ -78,10 +78,29 @@ function cdpUrlForPort(cdpPort: number) {
7878
return `http://127.0.0.1:${cdpPort}`;
7979
}
8080

81+
async function canOpenWebSocket(url: string, timeoutMs: number): Promise<boolean> {
82+
return new Promise<boolean>((resolve) => {
83+
const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs });
84+
ws.once("open", () => {
85+
try {
86+
ws.close();
87+
} catch {
88+
// ignore
89+
}
90+
resolve(true);
91+
});
92+
ws.once("error", () => resolve(false));
93+
});
94+
}
95+
8196
export async function isChromeReachable(
8297
cdpUrl: string,
8398
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
8499
): Promise<boolean> {
100+
if (isWebSocketUrl(cdpUrl)) {
101+
// Direct WebSocket endpoint (e.g. Browserbase) — probe via WS handshake.
102+
return await canOpenWebSocket(cdpUrl, timeoutMs);
103+
}
85104
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
86105
return Boolean(version);
87106
}
@@ -117,6 +136,10 @@ export async function getChromeWebSocketUrl(
117136
cdpUrl: string,
118137
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
119138
): Promise<string | null> {
139+
if (isWebSocketUrl(cdpUrl)) {
140+
// Direct WebSocket endpoint — the cdpUrl is already the WebSocket URL.
141+
return cdpUrl;
142+
}
120143
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
121144
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
122145
if (!wsUrl) {

src/browser/config.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,16 @@ function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy |
129129
export function parseHttpUrl(raw: string, label: string) {
130130
const trimmed = raw.trim();
131131
const parsed = new URL(trimmed);
132-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
133-
throw new Error(`${label} must be http(s), got: ${parsed.protocol.replace(":", "")}`);
132+
const allowed = ["http:", "https:", "ws:", "wss:"];
133+
if (!allowed.includes(parsed.protocol)) {
134+
throw new Error(`${label} must be http(s) or ws(s), got: ${parsed.protocol.replace(":", "")}`);
134135
}
135136

137+
const isSecure = parsed.protocol === "https:" || parsed.protocol === "wss:";
136138
const port =
137139
parsed.port && Number.parseInt(parsed.port, 10) > 0
138140
? Number.parseInt(parsed.port, 10)
139-
: parsed.protocol === "https:"
141+
: isSecure
140142
? 443
141143
: 80;
142144

0 commit comments

Comments
 (0)