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
|
[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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user