Compare commits
15 Commits
main
...
docs/add-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55300ea850 | ||
|
|
8e5f702adf | ||
|
|
56d2662f9d | ||
|
|
e2ecd0a321 | ||
|
|
7fce53976e | ||
|
|
1cc021251e | ||
|
|
a8ad7e42af | ||
|
|
42320281c6 | ||
|
|
f60168b735 | ||
|
|
0a5701f468 | ||
|
|
07f65838ed | ||
|
|
4d326271f0 | ||
|
|
eb4ff4464e | ||
|
|
cbcf9d0811 | ||
|
|
83a854bfa0 |
@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.
|
||||
- Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.
|
||||
- ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.
|
||||
- Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150.
|
||||
|
||||
## 2026.3.7
|
||||
|
||||
|
||||
@ -196,6 +196,53 @@ Notes:
|
||||
- Replace `<BROWSERLESS_API_KEY>` with your real Browserless token.
|
||||
- Choose the region endpoint that matches your Browserless account (see their docs).
|
||||
|
||||
## Direct WebSocket CDP providers
|
||||
|
||||
Some hosted browser services expose a **direct WebSocket** endpoint rather than
|
||||
the standard HTTP-based CDP discovery (`/json/version`). OpenClaw supports both:
|
||||
|
||||
- **HTTP(S) endpoints** (e.g. Browserless) — OpenClaw calls `/json/version` to
|
||||
discover the WebSocket debugger URL, then connects.
|
||||
- **WebSocket endpoints** (`ws://` / `wss://`) — OpenClaw connects directly,
|
||||
skipping `/json/version`. Use this for services like
|
||||
[Browserbase](https://www.browserbase.com) or any provider that hands you a
|
||||
WebSocket URL.
|
||||
|
||||
### Browserbase
|
||||
|
||||
[Browserbase](https://www.browserbase.com) is a cloud platform for running
|
||||
headless browsers with built-in CAPTCHA solving, stealth mode, and residential
|
||||
proxies.
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
defaultProfile: "browserbase",
|
||||
remoteCdpTimeoutMs: 3000,
|
||||
remoteCdpHandshakeTimeoutMs: 5000,
|
||||
profiles: {
|
||||
browserbase: {
|
||||
cdpUrl: "wss://connect.browserbase.com?apiKey=<BROWSERBASE_API_KEY>",
|
||||
color: "#F97316",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
reference, SDK guides, and integration examples.
|
||||
|
||||
## Security
|
||||
|
||||
Key ideas:
|
||||
@ -207,7 +254,7 @@ Key ideas:
|
||||
|
||||
Remote CDP tips:
|
||||
|
||||
- Prefer HTTPS endpoints and short-lived tokens where possible.
|
||||
- Prefer encrypted endpoints (HTTPS or WSS) and short-lived tokens where possible.
|
||||
- Avoid embedding long-lived tokens directly in config files.
|
||||
|
||||
## Profiles (multi-browser)
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { appendCdpPath, getHeadersWithAuth } from "./cdp.helpers.js";
|
||||
import {
|
||||
appendCdpPath,
|
||||
getHeadersWithAuth,
|
||||
normalizeCdpHttpBaseForJsonEndpoints,
|
||||
} from "./cdp.helpers.js";
|
||||
import { __test } from "./client-fetch.js";
|
||||
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
||||
import { shouldRejectBrowserMutation } from "./csrf.js";
|
||||
@ -155,6 +159,18 @@ describe("cdp.helpers", () => {
|
||||
expect(url).toBe("https://example.com/chrome/json/list?token=abc");
|
||||
});
|
||||
|
||||
it("normalizes direct WebSocket CDP URLs to an HTTP base for /json endpoints", () => {
|
||||
const url = normalizeCdpHttpBaseForJsonEndpoints(
|
||||
"wss://connect.example.com/devtools/browser/ABC?token=abc",
|
||||
);
|
||||
expect(url).toBe("https://connect.example.com/?token=abc");
|
||||
});
|
||||
|
||||
it("strips a trailing /cdp suffix when normalizing HTTP bases", () => {
|
||||
const url = normalizeCdpHttpBaseForJsonEndpoints("ws://127.0.0.1:9222/cdp?token=abc");
|
||||
expect(url).toBe("http://127.0.0.1:9222/?token=abc");
|
||||
});
|
||||
|
||||
it("adds basic auth headers when credentials are present", () => {
|
||||
const headers = getHeadersWithAuth("https://user:pass@example.com");
|
||||
expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`);
|
||||
|
||||
@ -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
|
||||
* 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;
|
||||
@ -53,6 +67,28 @@ export function appendCdpPath(cdpUrl: string, path: string): string {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string {
|
||||
try {
|
||||
const url = new URL(cdpUrl);
|
||||
if (url.protocol === "ws:") {
|
||||
url.protocol = "http:";
|
||||
} else if (url.protocol === "wss:") {
|
||||
url.protocol = "https:";
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, "");
|
||||
url.pathname = url.pathname.replace(/\/cdp$/, "");
|
||||
return url.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
// Best-effort fallback for non-URL-ish inputs.
|
||||
return cdpUrl
|
||||
.replace(/^ws:/, "http:")
|
||||
.replace(/^wss:/, "https:")
|
||||
.replace(/\/devtools\/browser\/.*$/, "")
|
||||
.replace(/\/cdp$/, "")
|
||||
.replace(/\/$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
function createCdpSender(ws: WebSocket) {
|
||||
let nextId = 1;
|
||||
const pending = new Map<number, Pending>();
|
||||
|
||||
@ -3,7 +3,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { type WebSocket, WebSocketServer } from "ws";
|
||||
import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { isWebSocketUrl } from "./cdp.helpers.js";
|
||||
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
|
||||
import { parseHttpUrl } from "./config.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
|
||||
describe("cdp", () => {
|
||||
@ -95,6 +97,79 @@ 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("preserves query params when connecting via direct WebSocket URL", async () => {
|
||||
let receivedHeaders: Record<string, string> = {};
|
||||
const wsPort = await startWsServer();
|
||||
if (!wsServer) {
|
||||
throw new Error("ws server not initialized");
|
||||
}
|
||||
wsServer.on("headers", (headers, req) => {
|
||||
receivedHeaders = Object.fromEntries(
|
||||
Object.entries(req.headers).map(([k, v]) => [k, String(v)]),
|
||||
);
|
||||
});
|
||||
wsServer.on("connection", (socket) => {
|
||||
socket.on("message", (data) => {
|
||||
const msg = JSON.parse(rawDataToString(data)) as { id?: number; method?: string };
|
||||
if (msg.method === "Target.createTarget") {
|
||||
socket.send(JSON.stringify({ id: msg.id, result: { targetId: "T_QP" } }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const created = await createTargetViaCdp({
|
||||
cdpUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST?apiKey=secret123`,
|
||||
url: "https://example.com",
|
||||
});
|
||||
expect(created.targetId).toBe("T_QP");
|
||||
// The WebSocket upgrade request should have been made to the URL with the query param
|
||||
expect(receivedHeaders.host).toBe(`127.0.0.1:${wsPort}`);
|
||||
});
|
||||
|
||||
it("still enforces SSRF policy for direct WebSocket URLs", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
try {
|
||||
await expect(
|
||||
createTargetViaCdp({
|
||||
cdpUrl: "ws://127.0.0.1:9222",
|
||||
url: "http://127.0.0.1:8080",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||
// SSRF check happens before any connection attempt
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks private navigation targets by default", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
try {
|
||||
@ -253,3 +328,58 @@ describe("cdp", () => {
|
||||
expect(normalized).toBe("wss://production-sfo.browserless.io/?token=abc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWebSocketUrl", () => {
|
||||
it("returns true for ws:// URLs", () => {
|
||||
expect(isWebSocketUrl("ws://127.0.0.1:9222")).toBe(true);
|
||||
expect(isWebSocketUrl("ws://example.com/devtools/browser/ABC")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for wss:// URLs", () => {
|
||||
expect(isWebSocketUrl("wss://connect.example.com")).toBe(true);
|
||||
expect(isWebSocketUrl("wss://connect.example.com?apiKey=abc")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for http:// and https:// URLs", () => {
|
||||
expect(isWebSocketUrl("http://127.0.0.1:9222")).toBe(false);
|
||||
expect(isWebSocketUrl("https://production-sfo.browserless.io?token=abc")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for invalid or non-URL strings", () => {
|
||||
expect(isWebSocketUrl("not-a-url")).toBe(false);
|
||||
expect(isWebSocketUrl("")).toBe(false);
|
||||
expect(isWebSocketUrl("ftp://example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHttpUrl with WebSocket protocols", () => {
|
||||
it("accepts wss:// URLs and defaults to port 443", () => {
|
||||
const result = parseHttpUrl("wss://connect.example.com?apiKey=abc", "test");
|
||||
expect(result.parsed.protocol).toBe("wss:");
|
||||
expect(result.port).toBe(443);
|
||||
expect(result.normalized).toContain("wss://connect.example.com");
|
||||
});
|
||||
|
||||
it("accepts ws:// URLs and defaults to port 80", () => {
|
||||
const result = parseHttpUrl("ws://127.0.0.1/devtools", "test");
|
||||
expect(result.parsed.protocol).toBe("ws:");
|
||||
expect(result.port).toBe(80);
|
||||
});
|
||||
|
||||
it("preserves explicit ports in wss:// URLs", () => {
|
||||
const result = parseHttpUrl("wss://connect.example.com:8443/path", "test");
|
||||
expect(result.port).toBe(8443);
|
||||
});
|
||||
|
||||
it("still accepts http:// and https:// URLs", () => {
|
||||
const http = parseHttpUrl("http://127.0.0.1:9222", "test");
|
||||
expect(http.port).toBe(9222);
|
||||
const https = parseHttpUrl("https://browserless.example?token=abc", "test");
|
||||
expect(https.port).toBe(443);
|
||||
});
|
||||
|
||||
it("rejects unsupported protocols", () => {
|
||||
expect(() => parseHttpUrl("ftp://example.com", "test")).toThrow("must be http(s) or ws(s)");
|
||||
expect(() => parseHttpUrl("file:///etc/passwd", "test")).toThrow("must be http(s) or ws(s)");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 — 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) => {
|
||||
|
||||
@ -350,6 +350,16 @@ describe("browser chrome helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("probes WebSocket URLs via handshake instead of HTTP", async () => {
|
||||
// For ws:// URLs, isChromeReachable should NOT call fetch at all —
|
||||
// it should attempt a WebSocket handshake instead.
|
||||
const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called"));
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
// No WS server listening → handshake fails → not reachable
|
||||
await expect(isChromeReachable("ws://127.0.0.1:19999", 50)).resolves.toBe(false);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stopOpenClawChrome no-ops when process is already killed", async () => {
|
||||
const proc = makeChromeTestProc({ killed: true });
|
||||
await stopChromeWithProc(proc, 10);
|
||||
|
||||
@ -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 — 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) {
|
||||
|
||||
@ -165,8 +165,21 @@ describe("browser config", () => {
|
||||
expect(work?.cdpUrl).toBe("https://example.com:18801");
|
||||
});
|
||||
|
||||
it("preserves wss:// cdpUrl with query params for the default profile", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
cdpUrl: "wss://connect.browserbase.com?apiKey=test-key",
|
||||
});
|
||||
const profile = resolveProfile(resolved, "openclaw");
|
||||
expect(profile?.cdpUrl).toBe("wss://connect.browserbase.com/?apiKey=test-key");
|
||||
expect(profile?.cdpHost).toBe("connect.browserbase.com");
|
||||
expect(profile?.cdpPort).toBe(443);
|
||||
expect(profile?.cdpIsLoopback).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unsupported protocols", () => {
|
||||
expect(() => resolveBrowserConfig({ cdpUrl: "ws://127.0.0.1:18791" })).toThrow(/must be http/i);
|
||||
expect(() => resolveBrowserConfig({ cdpUrl: "ftp://127.0.0.1:18791" })).toThrow(
|
||||
"must be http(s) or ws(s)",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add the built-in chrome extension profile if the derived relay port is already used", () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -160,12 +162,17 @@ function ensureDefaultProfile(
|
||||
defaultColor: string,
|
||||
legacyCdpPort?: number,
|
||||
derivedDefaultCdpPort?: number,
|
||||
legacyCdpUrl?: string,
|
||||
): Record<string, BrowserProfileConfig> {
|
||||
const result = { ...profiles };
|
||||
if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) {
|
||||
result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = {
|
||||
cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? CDP_PORT_RANGE_START,
|
||||
color: defaultColor,
|
||||
// Preserve the full cdpUrl for ws/wss endpoints so resolveProfile()
|
||||
// doesn't reconstruct from cdpProtocol/cdpHost/cdpPort (which drops
|
||||
// the WebSocket protocol and query params like API keys).
|
||||
...(legacyCdpUrl ? { cdpUrl: legacyCdpUrl } : {}),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
@ -258,8 +265,16 @@ export function resolveBrowserConfig(
|
||||
const defaultProfileFromConfig = cfg?.defaultProfile?.trim() || undefined;
|
||||
// Use legacy cdpUrl port for backward compatibility when no profiles configured
|
||||
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
|
||||
const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:";
|
||||
const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined;
|
||||
const profiles = ensureDefaultChromeExtensionProfile(
|
||||
ensureDefaultProfile(cfg?.profiles, defaultColor, legacyCdpPort, cdpPortRangeStart),
|
||||
ensureDefaultProfile(
|
||||
cfg?.profiles,
|
||||
defaultColor,
|
||||
legacyCdpPort,
|
||||
cdpPortRangeStart,
|
||||
legacyCdpUrl,
|
||||
),
|
||||
controlPort,
|
||||
);
|
||||
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
|
||||
|
||||
@ -10,7 +10,13 @@ import { chromium } from "playwright-core";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
|
||||
import { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js";
|
||||
import {
|
||||
appendCdpPath,
|
||||
fetchJson,
|
||||
getHeadersWithAuth,
|
||||
normalizeCdpHttpBaseForJsonEndpoints,
|
||||
withCdpSocket,
|
||||
} from "./cdp.helpers.js";
|
||||
import { normalizeCdpWsUrl } from "./cdp.js";
|
||||
import { getChromeWebSocketUrl } from "./chrome.js";
|
||||
import {
|
||||
@ -546,28 +552,6 @@ export async function closePlaywrightBrowserConnection(): Promise<void> {
|
||||
await cur.browser.close().catch(() => {});
|
||||
}
|
||||
|
||||
function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string {
|
||||
try {
|
||||
const url = new URL(cdpUrl);
|
||||
if (url.protocol === "ws:") {
|
||||
url.protocol = "http:";
|
||||
} else if (url.protocol === "wss:") {
|
||||
url.protocol = "https:";
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, "");
|
||||
url.pathname = url.pathname.replace(/\/cdp$/, "");
|
||||
return url.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
// Best-effort fallback for non-URL-ish inputs.
|
||||
return cdpUrl
|
||||
.replace(/^ws:/, "http:")
|
||||
.replace(/^wss:/, "https:")
|
||||
.replace(/\/devtools\/browser\/.*$/, "")
|
||||
.replace(/\/cdp$/, "")
|
||||
.replace(/\/$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
function cdpSocketNeedsAttach(wsUrl: string): boolean {
|
||||
try {
|
||||
const pathname = new URL(wsUrl).pathname;
|
||||
|
||||
100
src/browser/server-context.loopback-direct-ws.test.ts
Normal file
100
src/browser/server-context.loopback-direct-ws.test.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
|
||||
import * as cdpModule from "./cdp.js";
|
||||
import { createBrowserRouteContext } from "./server-context.js";
|
||||
import { makeState, originalFetch } from "./server-context.remote-tab-ops.harness.js";
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("browser server-context loopback direct WebSocket profiles", () => {
|
||||
it("uses an HTTP /json/list base when opening tabs", async () => {
|
||||
const createTargetViaCdp = vi
|
||||
.spyOn(cdpModule, "createTargetViaCdp")
|
||||
.mockResolvedValue({ targetId: "CREATED" });
|
||||
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const u = String(url);
|
||||
expect(u).toBe("http://127.0.0.1:18800/json/list?token=abc");
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
id: "CREATED",
|
||||
title: "New Tab",
|
||||
url: "http://127.0.0.1:8080",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
|
||||
type: "page",
|
||||
},
|
||||
],
|
||||
} as unknown as Response;
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.profiles.openclaw = {
|
||||
cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc",
|
||||
color: "#FF4500",
|
||||
};
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:8080");
|
||||
expect(opened.targetId).toBe("CREATED");
|
||||
expect(createTargetViaCdp).toHaveBeenCalledWith({
|
||||
cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc",
|
||||
url: "http://127.0.0.1:8080",
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an HTTP /json base for focus and close", async () => {
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u === "http://127.0.0.1:18800/json/list?token=abc") {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
id: "T1",
|
||||
title: "Tab 1",
|
||||
url: "https://example.com",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/T1",
|
||||
type: "page",
|
||||
},
|
||||
],
|
||||
} as unknown as Response;
|
||||
}
|
||||
if (u === "http://127.0.0.1:18800/json/activate/T1?token=abc") {
|
||||
return { ok: true, json: async () => ({}) } as unknown as Response;
|
||||
}
|
||||
if (u === "http://127.0.0.1:18800/json/close/T1?token=abc") {
|
||||
return { ok: true, json: async () => ({}) } as unknown as Response;
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${u}`);
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.profiles.openclaw = {
|
||||
cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc",
|
||||
color: "#FF4500",
|
||||
};
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
await openclaw.focusTab("T1");
|
||||
await openclaw.closeTab("T1");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18800/json/activate/T1?token=abc",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18800/json/close/T1?token=abc",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -1,4 +1,4 @@
|
||||
import { fetchOk } from "./cdp.helpers.js";
|
||||
import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
|
||||
import { appendCdpPath } from "./cdp.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import type { PwAiModule } from "./pw-ai-module.js";
|
||||
@ -27,6 +27,8 @@ export function createProfileSelectionOps({
|
||||
listTabs,
|
||||
openTab,
|
||||
}: SelectionDeps): SelectionOps {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
||||
|
||||
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
|
||||
await ensureBrowserAvailable();
|
||||
const profileState = getProfileState();
|
||||
@ -122,7 +124,7 @@ export function createProfileSelectionOps({
|
||||
}
|
||||
}
|
||||
|
||||
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolvedTargetId}`));
|
||||
await fetchOk(appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`));
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = resolvedTargetId;
|
||||
};
|
||||
@ -144,7 +146,7 @@ export function createProfileSelectionOps({
|
||||
}
|
||||
}
|
||||
|
||||
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolvedTargetId}`));
|
||||
await fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`));
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js";
|
||||
import { fetchJson, fetchOk } from "./cdp.helpers.js";
|
||||
import { fetchJson, fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
|
||||
import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import {
|
||||
@ -58,6 +58,8 @@ export function createProfileTabOps({
|
||||
state,
|
||||
getProfileState,
|
||||
}: TabOpsDeps): ProfileTabOps {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
||||
|
||||
const listTabs = async (): Promise<BrowserTab[]> => {
|
||||
// For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions
|
||||
if (!profile.cdpIsLoopback) {
|
||||
@ -82,7 +84,7 @@ export function createProfileTabOps({
|
||||
webSocketDebuggerUrl?: string;
|
||||
type?: string;
|
||||
}>
|
||||
>(appendCdpPath(profile.cdpUrl, "/json/list"));
|
||||
>(appendCdpPath(cdpHttpBase, "/json/list"));
|
||||
return raw
|
||||
.map((t) => ({
|
||||
targetId: t.id ?? "",
|
||||
@ -115,7 +117,7 @@ export function createProfileTabOps({
|
||||
const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId);
|
||||
const excessCount = pageTabs.length - MANAGED_BROWSER_PAGE_TAB_LIMIT;
|
||||
for (const tab of candidates.slice(0, excessCount)) {
|
||||
void fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${tab.targetId}`)).catch(() => {
|
||||
void fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${tab.targetId}`)).catch(() => {
|
||||
// best-effort cleanup only
|
||||
});
|
||||
}
|
||||
@ -180,7 +182,7 @@ export function createProfileTabOps({
|
||||
}
|
||||
|
||||
const encoded = encodeURIComponent(url);
|
||||
const endpointUrl = new URL(appendCdpPath(profile.cdpUrl, "/json/new"));
|
||||
const endpointUrl = new URL(appendCdpPath(cdpHttpBase, "/json/new"));
|
||||
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
|
||||
const endpoint = endpointUrl.search
|
||||
? (() => {
|
||||
|
||||
@ -110,7 +110,7 @@ describe("profile CRUD endpoints", () => {
|
||||
const createBadRemote = await realFetch(`${base}/profiles/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "badremote", cdpUrl: "ws://bad" }),
|
||||
body: JSON.stringify({ name: "badremote", cdpUrl: "ftp://bad" }),
|
||||
});
|
||||
expect(createBadRemote.status).toBe(400);
|
||||
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user