mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
committed by
Peter Steinberger
parent
3cf75f760c
commit
75602014db
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user