fix(browser): keep guarded CDP sockets on validated endpoints [AI] (#114506)

* fix(browser): keep guarded CDP sockets on validated endpoints

* fix(browser): avoid retrying rate-limited CDP discovery

* fix(browser): close Chrome MCP CDP policy gaps

* test(browser): match Chrome MCP policy error

* test(browser): satisfy transport type and lint checks

* fix(browser): narrow guarded CDP transport changes

* fix(browser): separate upload filesystem locality

* fix(browser): keep strict remote CDP hosts untrusted
This commit is contained in:
Pavan Kumar Gondhi
2026-08-12 16:16:12 +05:30
committed by GitHub
parent 786c646d88
commit 92f5f07da1
52 changed files with 2584 additions and 529 deletions
+15
View File
@@ -316,6 +316,21 @@ main model can read the screenshot directly.
- Browser navigation and open-tab requests are preflight checked. During the action and bounded post-action grace, guarded Playwright interactions (click, coordinate click, hover, drag, scroll, select, press, type, form fill, and evaluate) intercept policy-denied top-level and subframe document loads before HTTP request bytes, then best-effort re-check the final `http(s)` URL.
- Before each fresh OpenClaw-managed Chrome launch, OpenClaw best-effort disables network prediction, suppressing Chromium's observed speculative preconnect for those denied loads. This is defense in depth, not a policy boundary: a browser reused across a control-service restart and other browser backends may not share the hardening. Playwright routing is still not a network firewall and does not intercept redirect hops, a popup's first request, Service Worker traffic, page code that runs after the bounded guard window, or every background/subresource path. Complete egress isolation requires owner-side isolation or a policy-enforcing proxy.
- In strict SSRF mode, remote CDP endpoint discovery and `/json/version` probes (`cdpUrl`) are checked too.
- Guarded remote CDP connections now fail closed when the selected driver cannot
keep the approved endpoint bound to the actual socket. Use the regular
`openclaw` driver for Browserless, Browserbase, Notte, or other guarded
remote CDP providers. `existing-session`/Chrome MCP profiles with an explicit
`cdpUrl` or `--browserUrl`/`--wsEndpoint` MCP argument are rejected under the
default strict Browser policy because Chrome MCP cannot carry OpenClaw's
pinned DNS lookup or guarded discovery result across its subprocess boundary.
They remain supported only when private-network Browser access is explicitly
trusted. Otherwise, omit the explicit endpoint and attach Chrome MCP to a
host-local Chrome profile, or switch the profile to the regular driver for
guarded CDP.
- Redirecting CDP discovery to a different authority remains unsupported unless
the active policy explicitly allows that authority change. Revalidating a
returned hostname is not enough; the WebSocket transport must use the endpoint
that passed policy validation.
- Gateway/provider `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` environment variables do not automatically proxy the OpenClaw-managed browser. Managed Chrome launches direct by default so provider proxy settings do not weaken browser SSRF checks.
- OpenClaw-managed local CDP readiness probes and DevTools WebSocket connections bypass the managed network proxy for the exact launched loopback endpoint, so `openclaw browser start` still works when an operator proxy blocks loopback egress.
- To proxy the managed browser itself, pass explicit Chrome proxy flags through `browser.extraArgs`, such as `--proxy-server=...` or `--proxy-pac-url=...`. Strict SSRF mode blocks explicit browser proxy routing unless private-network browser access is intentionally enabled.
@@ -0,0 +1,45 @@
function decodeUrlUserInfo(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
/** Merge URL basic-auth credentials into headers without overriding explicit auth. */
export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
const mergedHeaders = { ...headers };
try {
const parsed = new URL(url);
const hasAuthHeader = Object.keys(mergedHeaders).some(
(key) => key.trim().toLowerCase() === "authorization",
);
if (hasAuthHeader) {
return mergedHeaders;
}
if (parsed.username || parsed.password) {
const username = decodeUrlUserInfo(parsed.username);
const password = decodeUrlUserInfo(parsed.password);
const auth = Buffer.from(`${username}:${password}`).toString("base64");
return { ...mergedHeaders, Authorization: `Basic ${auth}` };
}
} catch {
// ignore
}
return mergedHeaders;
}
/** Remove URL userinfo after callers have converted it to an Authorization header. */
export function stripCdpUrlCredentials(url: string): string {
try {
const parsed = new URL(url);
if (!parsed.username && !parsed.password) {
return url;
}
parsed.username = "";
parsed.password = "";
return parsed.toString();
} catch {
return url;
}
}
@@ -143,7 +143,7 @@ export async function waitForCdpCommittedNavigationUrl(opts: {
signal?: AbortSignal;
timeouts?: CdpActionTimeouts;
}): Promise<string | undefined> {
await assertCdpEndpointAllowed(opts.wsUrl, opts.cdpPolicy, {
const pinned = await assertCdpEndpointAllowed(opts.wsUrl, opts.cdpPolicy, {
source: "discovered",
configuredUrl: opts.configuredCdpUrl,
});
@@ -160,6 +160,7 @@ export async function waitForCdpCommittedNavigationUrl(opts: {
commandTimeoutMs: opts.timeouts?.httpTimeoutMs ?? CDP_TARGET_NAVIGATION_RESULT_TIMEOUT_MS,
handshakeTimeoutMs: opts.timeouts?.handshakeTimeoutMs,
handshakeRetries: 0,
lookup: pinned?.lookup,
},
);
} catch {
@@ -5,10 +5,17 @@
* is stricter, so this module scopes the exception to browser control only.
*/
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { matchesHostnameAllowlist, normalizeHostname } from "../sdk-security-runtime.js";
import { normalizeHostname } from "../sdk-security-runtime.js";
import { CHROME_MCP_ENDPOINT_FLAGS } from "./chrome-mcp-contracts.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserProfileUnavailableError } from "./errors.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import { withExactHostnamePolicy } from "./ssrf-policy-helpers.js";
import { isCdpHostnameTrustedByPolicy, withExactHostnamePolicy } from "./ssrf-policy-helpers.js";
// Synthetic exact-host CDP policies must retain the operator's original intent;
// otherwise Chrome MCP cannot distinguish default control-plane scoping from a
// user-authored restriction that genuinely requires pinned transport.
const cdpControlSourcePolicyByScopedPolicy = new WeakMap<SsrFPolicy, SsrFPolicy>();
function withCdpControlHostname(
profile: ResolvedBrowserProfile,
@@ -19,17 +26,41 @@ function withCdpControlHostname(
if (!ssrfPolicy || !cdpHost) {
return ssrfPolicy;
}
const allowedHostnames = (ssrfPolicy.allowedHostnames ?? [])
.map((pattern) => normalizeHostname(pattern))
.filter((pattern) => pattern && pattern !== "*" && pattern !== "*.");
if (
requireAllowlistMatch &&
allowedHostnames.length > 0 &&
!matchesHostnameAllowlist(cdpHost, allowedHostnames)
) {
if (requireAllowlistMatch && !isCdpHostnameTrustedByPolicy(ssrfPolicy, cdpHost)) {
return ssrfPolicy;
}
return withExactHostnamePolicy(ssrfPolicy, cdpHost);
const scopedPolicy = withExactHostnamePolicy(ssrfPolicy, cdpHost);
cdpControlSourcePolicyByScopedPolicy.set(scopedPolicy, ssrfPolicy);
return scopedPolicy;
}
function hasPolicyEntries(values?: string[]): boolean {
return (values ?? []).some((value) => value.trim().length > 0);
}
function requiresPinnedChromeMcpCdpTransport(cdpPolicy?: SsrFPolicy): boolean {
if (!cdpPolicy) {
return false;
}
const policyIntent = cdpControlSourcePolicyByScopedPolicy.get(cdpPolicy) ?? cdpPolicy;
const hasScopedPolicy =
policyIntent.allowRfc2544BenchmarkRange === true ||
policyIntent.allowIpv6UniqueLocalRange === true ||
hasPolicyEntries(policyIntent.allowedHostnames) ||
hasPolicyEntries(policyIntent.hostnameAllowlist) ||
hasPolicyEntries(policyIntent.allowedOrigins);
return !(
!hasScopedPolicy &&
(policyIntent.dangerouslyAllowPrivateNetwork === true ||
policyIntent.allowPrivateNetwork === true)
);
}
function hasChromeMcpEndpointArg(args?: string[]): boolean {
return (args ?? []).some((arg) => {
const [name] = arg.split("=", 1);
return CHROME_MCP_ENDPOINT_FLAGS.has(name ?? arg);
});
}
export function resolveCdpReachabilityPolicy(
@@ -51,3 +82,19 @@ export function resolveCdpReachabilityPolicy(
/** Alias used by callers that treat reachability and control as one CDP policy. */
export const resolveCdpControlPolicy = resolveCdpReachabilityPolicy;
export function assertChromeMcpCdpTransportAllowed(
profile: ResolvedBrowserProfile,
cdpPolicy?: SsrFPolicy,
): void {
const hasExplicitEndpoint = Boolean(profile.cdpUrl) || hasChromeMcpEndpointArg(profile.mcpArgs);
if (profile.driver !== "existing-session" || !hasExplicitEndpoint) {
return;
}
if (!requiresPinnedChromeMcpCdpTransport(cdpPolicy)) {
return;
}
throw new BrowserProfileUnavailableError(
`Browser profile "${profile.name}" uses Chrome MCP with an explicit CDP endpoint, but the active Browser CDP policy requires OpenClaw to pin the approved endpoint. Chrome MCP cannot carry that pinned transport across its subprocess boundary. Use driver "openclaw" for guarded CDP endpoints, or remove cdpUrl and browserUrl/wsEndpoint mcpArgs from this existing-session profile so Chrome MCP attaches to a host-local Chrome profile.`,
);
}
@@ -0,0 +1,419 @@
import type { lookup as dnsLookupCb } from "node:dns";
import type { ClientRequest } from "node:http";
import http from "node:http";
import https from "node:https";
import net from "node:net";
import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime";
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
import WebSocket from "ws";
import { getHeadersWithAuth, stripCdpUrlCredentials } from "./cdp-auth.js";
import { getDirectAgentForCdp, withManagedProxyForCdpUrl } from "./cdp-proxy-bypass.js";
import { CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
import { getPlaywrightUserAgent } from "./playwright-core.runtime.js";
import { normalizeBrowserTimerDelayMs } from "./timer-delay.js";
const PLAYWRIGHT_CDP_MAX_PAYLOAD_BYTES = 256 * 1024 * 1024;
const PLAYWRIGHT_CDP_PER_MESSAGE_DEFLATE = {
clientNoContextTakeover: true,
zlibDeflateOptions: { level: 3 },
zlibInflateOptions: { chunkSize: 10 * 1024 },
threshold: 10 * 1024,
} as const;
const PLAYWRIGHT_CDP_MAX_REDIRECTS = 10;
type CdpSocketLookup = typeof dnsLookupCb;
type CdpResponse = {
id: number;
result?: unknown;
error?: { message?: string };
};
type Pending = {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
timer?: ReturnType<typeof setTimeout>;
};
export type CdpSendFn = (
method: string,
params?: Record<string, unknown>,
sessionId?: string,
) => Promise<unknown>;
function withDefaultPlaywrightUserAgent(headers: Record<string, string>): Record<string, string> {
if (Object.keys(headers).some((key) => key.trim().toLowerCase() === "user-agent")) {
return headers;
}
return { ...headers, "User-Agent": getPlaywrightUserAgent() };
}
function cdpWebSocketAuthority(url: string): string {
const parsed = new URL(url);
return `${parsed.protocol}//${parsed.host}`;
}
function assertSameAuthorityWebSocketRedirect(
originalUrl: string,
redirectedUrl: string,
request: ClientRequest,
): void {
if (cdpWebSocketAuthority(originalUrl) === cdpWebSocketAuthority(redirectedUrl)) {
return;
}
request.destroy(new Error("CDP WebSocket redirect changed authority"));
}
function defaultPortForWebSocketProtocol(protocol: string): string {
return protocol === "wss:" || protocol === "https:" ? "443" : "80";
}
function normalizeAuthorityHostname(hostname: string): string {
return hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase();
}
function hostnameFromAgentOptions(options: unknown): string | undefined {
if (options instanceof URL) {
return options.hostname;
}
if (!options || typeof options !== "object") {
return undefined;
}
if ("hostname" in options && typeof options.hostname === "string") {
return options.hostname;
}
const rawHost = "host" in options && typeof options.host === "string" ? options.host : undefined;
if (!rawHost) {
return undefined;
}
if (rawHost.startsWith("[")) {
const end = rawHost.indexOf("]");
return end > 0 ? rawHost.slice(1, end) : rawHost;
}
if ((rawHost.match(/:/g) ?? []).length > 1) {
return rawHost;
}
return rawHost.includes(":") ? rawHost.split(":")[0] : rawHost;
}
function portFromAgentOptions(options: unknown, fallbackProtocol: string): string {
if (options instanceof URL) {
return options.port || defaultPortForWebSocketProtocol(options.protocol);
}
if (!options || typeof options !== "object") {
return defaultPortForWebSocketProtocol(fallbackProtocol);
}
if ("port" in options) {
const rawPort = options.port;
if (typeof rawPort === "string" || typeof rawPort === "number") {
return String(rawPort);
}
}
return defaultPortForWebSocketProtocol(fallbackProtocol);
}
function assertPinnedAgentAuthority(originalUrl: string, options: unknown): void {
const parsed = new URL(originalUrl);
const expectedHostname = normalizeAuthorityHostname(parsed.hostname);
const expectedPort = parsed.port || defaultPortForWebSocketProtocol(parsed.protocol);
const requestedHostname = hostnameFromAgentOptions(options);
const requestedPort = portFromAgentOptions(options, parsed.protocol);
if (
!requestedHostname ||
normalizeAuthorityHostname(requestedHostname) !== expectedHostname ||
requestedPort !== expectedPort
) {
throw new Error("CDP WebSocket redirect changed authority");
}
}
function createPinnedAgentForCdpUrl(
url: string,
lookup: CdpSocketLookup,
): http.Agent | https.Agent {
const parsed = new URL(url);
const options = { keepAlive: false, lookup };
const agent =
parsed.protocol === "https:" || parsed.protocol === "wss:"
? new https.Agent(options)
: new http.Agent(options);
const createConnection = agent.createConnection.bind(agent);
agent.createConnection = ((connectionOptions, callback) => {
try {
assertPinnedAgentAuthority(url, connectionOptions);
} catch (err) {
const socket = new net.Socket();
const error = err instanceof Error ? err : new Error(String(err));
process.nextTick(() => {
callback?.(error, socket);
socket.destroy(error);
});
return socket;
}
return createConnection(connectionOptions, callback);
}) as typeof agent.createConnection;
return agent;
}
function createCdpSender(ws: WebSocket, opts?: { commandTimeoutMs?: number }) {
let nextId = 1;
const pending = new Map<number, Pending>();
const commandTimeoutMs =
typeof opts?.commandTimeoutMs === "number" && Number.isFinite(opts.commandTimeoutMs)
? normalizeBrowserTimerDelayMs(opts.commandTimeoutMs)
: undefined;
const clearPendingTimer = (p: Pending) => {
if (p.timer !== undefined) {
clearTimeout(p.timer);
}
};
const send: CdpSendFn = (
method: string,
params?: Record<string, unknown>,
sessionId?: string,
) => {
const id = nextId++;
const msg = { id, method, params, sessionId };
return new Promise<unknown>((resolve, reject) => {
if (ws.readyState !== WebSocket.OPEN) {
reject(new Error("CDP socket closed"));
return;
}
const entry: Pending = { resolve, reject };
if (commandTimeoutMs !== undefined) {
// A timed-out command closes the whole socket so pending calls do not
// hang on a connection whose CDP command stream is no longer reliable.
entry.timer = setTimeout(() => {
closeWithError(new Error(`CDP command ${method} timed out after ${commandTimeoutMs}ms`));
}, commandTimeoutMs);
}
pending.set(id, entry);
try {
ws.send(JSON.stringify(msg));
} catch (err) {
pending.delete(id);
clearPendingTimer(entry);
reject(toStringifiedError(err));
}
});
};
const closeWithError = (err: Error) => {
for (const [, p] of pending) {
clearPendingTimer(p);
p.reject(err);
}
pending.clear();
ws.close();
};
ws.on("error", (err) => {
// The `err instanceof Error` guard is defensive: Node's `ws` library
// always emits Error instances on the 'error' event. Triggering the
// non-Error branch would require synthetically emitting on the socket,
// which the library treats as an unhandled error and hangs the test.
/* c8 ignore next */
closeWithError(toStringifiedError(err));
});
ws.on("message", (data) => {
try {
const parsed = JSON.parse(rawDataToString(data)) as CdpResponse;
if (typeof parsed.id !== "number") {
return;
}
const p = pending.get(parsed.id);
if (!p) {
return;
}
pending.delete(parsed.id);
clearPendingTimer(p);
if (parsed.error?.message) {
p.reject(new Error(parsed.error.message));
return;
}
p.resolve(parsed.result);
} catch {
// ignore
}
});
ws.on("close", () => {
closeWithError(new Error("CDP socket closed"));
});
return { send, closeWithError };
}
/** Open a CDP WebSocket with URL basic-auth and proxy bypass handling. */
export function openCdpWebSocket(
wsUrl: string,
opts?: {
headers?: Record<string, string>;
handshakeTimeoutMs?: number;
lookup?: CdpSocketLookup;
playwrightTransportDefaults?: boolean;
},
): WebSocket {
const headersWithAuth = getHeadersWithAuth(wsUrl, opts?.headers ?? {});
const headers = opts?.playwrightTransportDefaults
? withDefaultPlaywrightUserAgent(headersWithAuth)
: headersWithAuth;
const handshakeTimeoutMs =
typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs)
? Math.max(1, Math.floor(opts.handshakeTimeoutMs))
: CDP_WS_HANDSHAKE_TIMEOUT_MS;
const connectionUrl = stripCdpUrlCredentials(wsUrl);
const agent = opts?.lookup
? createPinnedAgentForCdpUrl(connectionUrl, opts.lookup)
: getDirectAgentForCdp(connectionUrl);
return withManagedProxyForCdpUrl(connectionUrl, () => {
const ws = new WebSocket(connectionUrl, {
handshakeTimeout: handshakeTimeoutMs,
...(opts?.playwrightTransportDefaults
? {
followRedirects: true,
maxRedirects: PLAYWRIGHT_CDP_MAX_REDIRECTS,
maxPayload: PLAYWRIGHT_CDP_MAX_PAYLOAD_BYTES,
perMessageDeflate: PLAYWRIGHT_CDP_PER_MESSAGE_DEFLATE,
}
: {}),
...(Object.keys(headers).length ? { headers } : {}),
...(agent ? { agent } : {}),
});
if (opts?.playwrightTransportDefaults) {
ws.on("redirect", (redirectedUrl, request) => {
assertSameAuthorityWebSocketRedirect(connectionUrl, redirectedUrl, request);
});
}
return ws;
});
}
type CdpSocketOptions = {
headers?: Record<string, string>;
handshakeTimeoutMs?: number;
commandTimeoutMs?: number;
handshakeRetries?: number;
handshakeRetryDelayMs?: number;
handshakeMaxRetryDelayMs?: number;
lookup?: CdpSocketLookup;
signal?: AbortSignal;
};
function normalizeRetryCount(value: number | undefined, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return Math.max(0, Math.floor(value));
}
function computeHandshakeRetryDelayMs(attempt: number, opts?: CdpSocketOptions): number {
const baseDelayMs =
typeof opts?.handshakeRetryDelayMs === "number" && Number.isFinite(opts.handshakeRetryDelayMs)
? Math.max(1, Math.floor(opts.handshakeRetryDelayMs))
: 200;
const maxDelayMs =
typeof opts?.handshakeMaxRetryDelayMs === "number" &&
Number.isFinite(opts.handshakeMaxRetryDelayMs)
? Math.max(baseDelayMs, Math.floor(opts.handshakeMaxRetryDelayMs))
: 3000;
const raw = Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, attempt - 1));
// Jitter keeps several browser sessions from retrying handshakes in lockstep
// after a shared Chrome or network hiccup.
const jitterScale = 0.8 + Math.random() * 0.4;
return Math.max(1, Math.floor(raw * jitterScale));
}
function shouldRetryCdpHandshakeError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false;
}
const msg = err.message.toLowerCase();
if (!msg) {
return false;
}
if (msg.includes("rate limit")) {
return false;
}
const statusMatch = msg.match(/(?:unexpected server response|response):\s*(\d{3})/);
if (statusMatch?.[1]) {
return Number(statusMatch[1]) >= 500;
}
return (
msg.includes("cdp socket closed") ||
msg.includes("econnreset") ||
msg.includes("econnrefused") ||
msg.includes("econnaborted") ||
msg.includes("ehostunreach") ||
msg.includes("enetunreach") ||
msg.includes("etimedout") ||
msg.includes("socket hang up") ||
msg.includes("websocket error") ||
msg.includes("closed before")
);
}
export async function withCdpSocket<T>(
wsUrl: string,
fn: (send: CdpSendFn) => Promise<T>,
opts?: CdpSocketOptions,
): Promise<T> {
const maxHandshakeRetries = normalizeRetryCount(opts?.handshakeRetries, 2);
for (let attempt = 0; ; attempt += 1) {
opts?.signal?.throwIfAborted();
const ws = openCdpWebSocket(wsUrl, opts);
const { send, closeWithError } = createCdpSender(ws, opts);
const openPromise = new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", (err) => reject(err));
ws.once("close", () => reject(new Error("CDP socket closed")));
});
// A stalled HTTP upgrade must release its TCP socket on cancellation.
const abortHandshake = () => ws.terminate();
opts?.signal?.addEventListener("abort", abortHandshake, { once: true });
if (opts?.signal?.aborted) {
abortHandshake();
}
try {
await openPromise;
} catch (err) {
// openPromise is only rejected via `ws.once('error', err => reject(err))`
// or the close event's `new Error(...)`; the former always carries an
// Error from Node's `ws` library, the latter is already an Error. The
// non-Error wrap is defensive and structurally unreachable.
/* c8 ignore next */
closeWithError(toStringifiedError(err));
// Cancellation on the final attempt must not become a handshake error.
opts?.signal?.throwIfAborted();
if (attempt >= maxHandshakeRetries || !shouldRetryCdpHandshakeError(err)) {
throw err;
}
// Retry only handshake failures. Once CDP commands are flowing, callers
// own retry semantics because commands may already have side effects.
// Cancelled route requests must not keep retrying Chrome handshakes.
await sleepWithAbort(computeHandshakeRetryDelayMs(attempt + 1, opts), opts?.signal).catch(
(error: unknown) => {
opts?.signal?.throwIfAborted();
throw error;
},
);
continue;
} finally {
opts?.signal?.removeEventListener("abort", abortHandshake);
}
try {
return await fn(send);
} catch (err) {
closeWithError(toStringifiedError(err));
throw err;
} finally {
ws.close();
}
}
}
@@ -1,5 +1,5 @@
// Browser tests cover cdp.helpers.internal plugin behavior.
import { createServer } from "node:http";
import http, { createServer } from "node:http";
import type { Socket } from "node:net";
import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -115,7 +115,10 @@ describe("cdp.helpers internal", () => {
assertCdpEndpointAllowed("http://93.184.216.34:443/cdp", {
allowPrivateNetwork: true,
}),
).resolves.toBeUndefined();
).resolves.toMatchObject({
addresses: ["93.184.216.34"],
hostname: "93.184.216.34",
});
});
});
@@ -256,6 +259,176 @@ describe("cdp.helpers internal", () => {
});
describe("createCdpSender (via withCdpSocket)", () => {
function pinnedLookupMock() {
return vi.fn((hostname: string, options: unknown, callback?: unknown) => {
const cb = typeof options === "function" ? options : callback;
if (typeof cb === "function") {
if (typeof options === "object" && options !== null && "all" in options) {
cb(null, [{ address: "127.0.0.1", family: 4 }]);
return undefined as never;
}
cb(null, "127.0.0.1", 4);
}
return undefined as never;
});
}
it("uses a per-connection agent for pinned WebSocket handshakes", async () => {
const server = await startWsServer();
wss = server.wss;
const lookup = pinnedLookupMock();
const globalCreateConnection = vi
.spyOn(http.globalAgent, "createConnection")
.mockImplementation(() => {
throw new Error("global agent must not be used for pinned CDP sockets");
});
server.wss.on("connection", (socket) => {
socket.close();
});
try {
const ws = openCdpWebSocket(`ws://cdp-pinned.test:${server.port}/devtools/browser/TEST`, {
lookup: lookup as never,
});
await new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", reject);
});
expect(lookup).toHaveBeenCalled();
expect(globalCreateConnection).not.toHaveBeenCalled();
ws.close();
} finally {
globalCreateConnection.mockRestore();
}
});
it.each([
{ playwrightTransportDefaults: false, expectedMaxPayload: 100 * 1024 * 1024 },
{ playwrightTransportDefaults: true, expectedMaxPayload: 256 * 1024 * 1024 },
])(
"uses the expected payload limit when Playwright transport defaults are $playwrightTransportDefaults",
async ({ playwrightTransportDefaults, expectedMaxPayload }) => {
const server = await startWsServer();
wss = server.wss;
const ws = openCdpWebSocket(server.url, { playwrightTransportDefaults });
try {
await new Promise<void>((resolve, reject) => {
ws.once("open", resolve);
ws.once("error", reject);
});
const receiver = Reflect.get(ws, "_receiver") as object | undefined;
const maxPayload = receiver ? Reflect.get(receiver, "_maxPayload") : undefined;
expect(maxPayload).toBe(expectedMaxPayload);
} finally {
ws.close();
}
},
);
it("preserves IPv6 hostnames in pinned WebSocket agent checks", async () => {
const server = new WebSocketServer({ port: 0, host: "::1" });
try {
await new Promise<void>((resolve, reject) => {
server.once("listening", () => resolve());
server.once("error", reject);
});
} catch {
return;
}
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("IPv6 test server did not expose a TCP port");
}
server.on("connection", (socket) => {
socket.close();
});
const lookup = vi.fn((_hostname: string, options: unknown, callback?: unknown) => {
const cb = typeof options === "function" ? options : callback;
if (typeof cb === "function") {
if (typeof options === "object" && options !== null && "all" in options) {
cb(null, [{ address: "::1", family: 6 }]);
return undefined as never;
}
cb(null, "::1", 6);
}
return undefined as never;
});
try {
const ws = openCdpWebSocket(`ws://[::1]:${address.port}/devtools/browser/TEST`, {
lookup: lookup as never,
});
await new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", reject);
});
ws.close();
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("blocks pinned WebSocket redirects before connecting to a new authority", async () => {
const redirectServer = http.createServer();
const targetServer = http.createServer();
let targetConnections = 0;
targetServer.on("connection", () => {
targetConnections += 1;
});
await new Promise<void>((resolve) => {
targetServer.listen(0, "127.0.0.1", () => resolve());
});
const targetAddress = targetServer.address();
if (!targetAddress || typeof targetAddress === "string") {
throw new Error("target server did not expose a TCP port");
}
redirectServer.on("upgrade", (_request, socket) => {
socket.write(
`HTTP/1.1 302 Found\r\nLocation: ws://127.0.0.1:${targetAddress.port}/devtools/browser/redirected\r\nConnection: close\r\n\r\n`,
);
socket.destroy();
});
await new Promise<void>((resolve) => {
redirectServer.listen(0, "127.0.0.1", () => resolve());
});
const redirectAddress = redirectServer.address();
if (!redirectAddress || typeof redirectAddress === "string") {
throw new Error("redirect server did not expose a TCP port");
}
const ws = openCdpWebSocket(
`ws://cdp-pinned.test:${redirectAddress.port}/devtools/browser/start`,
{
lookup: pinnedLookupMock() as never,
playwrightTransportDefaults: true,
},
);
try {
const error = await new Promise<Error>((resolve, reject) => {
ws.once("open", () => reject(new Error("redirect unexpectedly opened")));
ws.once("error", (err) => resolve(err instanceof Error ? err : new Error(String(err))));
});
expect(error.message).toContain("CDP WebSocket redirect changed authority");
await new Promise<void>((resolve) => {
setTimeout(resolve, 25);
});
expect(targetConnections).toBe(0);
} finally {
ws.close();
await new Promise<void>((resolve) => {
redirectServer.close(() => {
targetServer.close(() => resolve());
});
});
}
});
it("ignores messages with a non-numeric id", async () => {
const server = await startWsServer();
wss = server.wss;
@@ -732,4 +905,80 @@ describe("openCdpWebSocket option handling", () => {
ws.once("error", () => {});
ws.close();
});
it("uses a pinned lookup for websocket connections", async () => {
const server = await startWsServer();
try {
const url = server.url.replace("127.0.0.1", "cdp.test.local");
const lookup = vi.fn((hostname: string, options: unknown, callback?: unknown) => {
const cb = typeof options === "function" ? options : callback;
expect(hostname).toBe("cdp.test.local");
if (typeof cb === "function") {
const wantsAll =
typeof options === "object" && options !== null && (options as { all?: boolean }).all;
if (wantsAll) {
cb(null, [{ address: "127.0.0.1", family: 4 }]);
return;
}
cb(null, "127.0.0.1", 4);
}
});
const ws = openCdpWebSocket(url, {
handshakeTimeoutMs: 500,
lookup: lookup as never,
});
await new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", reject);
});
expect(lookup).toHaveBeenCalled();
ws.close();
} finally {
await new Promise<void>((resolve) => {
server.wss.close(() => resolve());
});
}
});
it("forwards pinned lookup options through withCdpSocket", async () => {
const server = await startWsServer();
server.wss.on("connection", (socket) => {
socket.on("message", (data) => {
const msg = JSON.parse(rawDataToString(data)) as { id?: number };
socket.send(JSON.stringify({ id: msg.id, result: { ok: true } }));
});
});
try {
const url = server.url.replace("127.0.0.1", "cdp.test.local");
const lookup = vi.fn((hostname: string, options: unknown, callback?: unknown) => {
const cb = typeof options === "function" ? options : callback;
expect(hostname).toBe("cdp.test.local");
if (typeof cb === "function") {
const wantsAll =
typeof options === "object" && options !== null && (options as { all?: boolean }).all;
if (wantsAll) {
cb(null, [{ address: "127.0.0.1", family: 4 }]);
return;
}
cb(null, "127.0.0.1", 4);
}
});
const result = await withCdpSocket(url, async (send) => await send("Browser.getVersion"), {
handshakeTimeoutMs: 500,
handshakeRetries: 0,
lookup: lookup as never,
});
expect(result).toStrictEqual({ ok: true });
expect(lookup).toHaveBeenCalled();
} finally {
await new Promise<void>((resolve) => {
server.wss.close(() => resolve());
});
}
});
});
@@ -1,7 +1,10 @@
// Browser tests cover cdp.helpers plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js";
import {
assertChromeMcpCdpTransportAllowed,
resolveCdpReachabilityPolicy,
} from "./cdp-reachability-policy.js";
import { resolveCdpReachabilityTimeouts } from "./cdp-timeouts.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { assertBrowserNavigationAllowed } from "./navigation-guard.js";
@@ -87,7 +90,9 @@ describe("cdp helpers", () => {
assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", {
dangerouslyAllowPrivateNetwork: false,
}),
).resolves.toBeUndefined();
).resolves.toEqual(
expect.objectContaining({ hostname: "127.0.0.1", lookup: expect.any(Function) }),
);
});
it("adds exact loopback hosts to the CDP hostname allowlist", async () => {
@@ -96,7 +101,9 @@ describe("cdp helpers", () => {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["*.corp.example"],
}),
).resolves.toBeUndefined();
).resolves.toEqual(
expect.objectContaining({ hostname: "127.0.0.1", lookup: expect.any(Function) }),
);
});
it("still enforces hostname allowlist for non-loopback CDP endpoints", async () => {
@@ -131,7 +138,9 @@ describe("cdp helpers", () => {
source: "discovered",
configuredUrl: "http://127.0.0.1:9222",
}),
).resolves.toBeUndefined();
).resolves.toEqual(
expect.objectContaining({ hostname: "127.0.0.1", lookup: expect.any(Function) }),
);
});
it("preserves broad private authority permission through exact-host scoping", async () => {
@@ -143,7 +152,45 @@ describe("cdp helpers", () => {
source: "discovered",
configuredUrl: "http://127.0.0.1:9222",
}),
).resolves.toBeUndefined();
).resolves.toEqual(
expect.objectContaining({ hostname: "127.0.0.1", lookup: expect.any(Function) }),
);
});
it("does not turn a strict remote CDP hostname into a private-network grant", async () => {
const policy = { dangerouslyAllowPrivateNetwork: false };
const scoped = scopeCdpPolicyToConfiguredEndpoint("https://browser.example:9222", policy);
const { resolvePinnedHostnameWithPolicy } =
await vi.importActual<typeof import("../infra/net/ssrf.js")>("../infra/net/ssrf.js");
expect(scoped).toBe(policy);
await expect(
resolvePinnedHostnameWithPolicy("browser.example", {
policy: scoped,
lookupFn: async () => [{ address: "10.0.0.8", family: 4 }],
}),
).rejects.toThrow(/private\/internal\/special-use ip address/i);
});
it("keeps explicit remote CDP hostname grants available", async () => {
const policy = {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["browser.example"],
};
const scoped = scopeCdpPolicyToConfiguredEndpoint("https://browser.example:9222", policy);
const { resolvePinnedHostnameWithPolicy } =
await vi.importActual<typeof import("../infra/net/ssrf.js")>("../infra/net/ssrf.js");
expect(scoped).toEqual({
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["browser.example"],
});
await expect(
resolvePinnedHostnameWithPolicy("browser.example", {
policy: scoped,
lookupFn: async () => [{ address: "10.0.0.8", family: 4 }],
}),
).resolves.toEqual(expect.objectContaining({ addresses: ["10.0.0.8"] }));
});
it("blocks a discovered endpoint on another port in strict SSRF mode", async () => {
@@ -161,7 +208,9 @@ describe("cdp helpers", () => {
assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", {
allowedHostnames: ["api.example.com"],
}),
).resolves.toBeUndefined();
).resolves.toEqual(
expect.objectContaining({ hostname: "127.0.0.1", lookup: expect.any(Function) }),
);
});
it("releases guarded CDP fetches for bodyless requests", async () => {
@@ -344,6 +393,27 @@ describe("cdp helpers", () => {
expect(release).toHaveBeenCalledTimes(1);
});
it("passes the default remote CDP policy object into guarded discovery fetches", async () => {
const release = vi.fn(async () => {});
const policy = {};
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: {
ok: true,
status: 200,
},
release,
});
await expect(
fetchOk("https://browserless.example:9222/json/version", 250, undefined, policy),
).resolves.toBeUndefined();
const request = requireGuardedFetchRequest();
expect(request?.url).toBe("https://browserless.example:9222/json/version");
expect(request?.policy).toBe(policy);
expect(release).toHaveBeenCalledOnce();
});
it("replaces navigation grants with the exact loopback CDP host", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
@@ -463,13 +533,11 @@ describe("resolveCdpReachabilityTimeouts", () => {
});
describe("CDP reachability policy", () => {
it("allows the selected remote profile CDP host without widening browser navigation policy", async () => {
it("keeps the default remote CDP policy strict without widening browser navigation policy", async () => {
const browserPolicy = {};
const profile = createProfile({});
expect(resolveCdpReachabilityPolicy(profile, browserPolicy)).toEqual({
allowedHostnames: ["172.29.128.1"],
});
expect(resolveCdpReachabilityPolicy(profile, browserPolicy)).toBe(browserPolicy);
expect(browserPolicy).toStrictEqual({});
await expect(
assertBrowserNavigationAllowed({
@@ -583,4 +651,115 @@ describe("CDP reachability policy", () => {
allowedHostnames: ["127.0.0.1"],
});
});
it.each([
["cdpUrl", { cdpUrl: "http://127.0.0.1:9222" }],
["--browserUrl", { cdpUrl: "", mcpArgs: ["--browserUrl", "http://127.0.0.1:9222"] }],
["-u", { cdpUrl: "", mcpArgs: ["-u", "http://127.0.0.1:9222"] }],
["--u", { cdpUrl: "", mcpArgs: ["--u", "http://127.0.0.1:9222"] }],
["--wsEndpoint", { cdpUrl: "", mcpArgs: ["--wsEndpoint=ws://127.0.0.1:9222"] }],
["-w", { cdpUrl: "", mcpArgs: ["-w", "ws://127.0.0.1:9222"] }],
["--w", { cdpUrl: "", mcpArgs: ["--w=ws://127.0.0.1:9222"] }],
])("rejects Chrome MCP explicit %s endpoints under the default policy", (_source, endpoint) => {
const profile = createProfile({
driver: "existing-session",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
...endpoint,
});
expect(() => assertChromeMcpCdpTransportAllowed(profile, {})).toThrow(
/cannot carry that pinned transport/i,
);
});
it("rejects Chrome MCP explicit CDP URL profiles after default CDP scoping", () => {
const profile = createProfile({
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
});
const cdpPolicy = resolveCdpReachabilityPolicy(profile, {});
expect(cdpPolicy).toEqual({ allowedHostnames: ["127.0.0.1"] });
expect(() => assertChromeMcpCdpTransportAllowed(profile, cdpPolicy)).toThrow(
/cannot carry that pinned transport/i,
);
});
it("preserves Chrome MCP explicit CDP URL profiles when private CDP endpoints are trusted", () => {
const profile = createProfile({
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
});
expect(() =>
assertChromeMcpCdpTransportAllowed(profile, { dangerouslyAllowPrivateNetwork: true }),
).not.toThrow();
});
it("rejects Chrome MCP explicit CDP URL profiles under explicit strict policy", () => {
const profile = createProfile({
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
});
expect(() =>
assertChromeMcpCdpTransportAllowed(profile, { dangerouslyAllowPrivateNetwork: false }),
).toThrow(/cannot carry that pinned transport/i);
});
it("rejects Chrome MCP explicit CDP URL profiles after explicit strict CDP scoping", () => {
const profile = createProfile({
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
});
const cdpPolicy = resolveCdpReachabilityPolicy(profile, {
dangerouslyAllowPrivateNetwork: false,
});
expect(cdpPolicy).toEqual({
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
});
expect(() => assertChromeMcpCdpTransportAllowed(profile, cdpPolicy)).toThrow(
/cannot carry that pinned transport/i,
);
});
it("rejects Chrome MCP explicit CDP URL profiles under endpoint allowlists", () => {
const profile = createProfile({
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
});
expect(() =>
assertChromeMcpCdpTransportAllowed(profile, { allowedHostnames: ["127.0.0.1"] }),
).toThrow(/cannot carry that pinned transport/i);
});
it("does not let trusted private CDP policy override endpoint allowlists for Chrome MCP", () => {
const profile = createProfile({
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
});
expect(() =>
assertChromeMcpCdpTransportAllowed(profile, {
dangerouslyAllowPrivateNetwork: true,
allowedHostnames: ["127.0.0.1"],
}),
).toThrow(/cannot carry that pinned transport/i);
});
});
+30 -324
View File
@@ -1,16 +1,13 @@
/**
* Chrome DevTools Protocol URL, fetch, and socket helpers.
*
* Handles CDP URL normalization, SSRF-guarded HTTP discovery, credential
* redaction/headers, and request/response correlation over WebSocket.
*/
import { createHash } from "node:crypto";
import { parseBrowserHttpUrl, redactCdpUrl } from "openclaw/plugin-sdk/browser-config";
import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
import WebSocket from "ws";
import { isLoopbackHost } from "../gateway/net.js";
import {
SsrFBlockedError,
@@ -18,24 +15,27 @@ import {
resolvePinnedHostnameWithPolicy,
} from "../infra/net/ssrf.js";
import { redactToolPayloadText } from "../logging/redact.js";
import {
getDirectAgentForCdp,
withManagedProxyForCdpUrl,
withNoProxyForCdpUrl,
} from "./cdp-proxy-bypass.js";
import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
import { getHeadersWithAuth, stripCdpUrlCredentials } from "./cdp-auth.js";
import { withManagedProxyForCdpUrl, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
import { CDP_HTTP_REQUEST_TIMEOUT_MS } from "./cdp-timeouts.js";
import { withCdpSocket } from "./cdp-websocket.js";
import type { BrowserTabOwnership } from "./client.types.js";
import { BrowserCdpEndpointBlockedError } from "./errors.js";
import { resolveBrowserRateLimitMessage } from "./rate-limit-message.js";
import {
allowsDiscoveredCdpAuthorityChange,
isCdpHostnameTrustedByPolicy,
withExactHostnamePolicy,
} from "./ssrf-policy-helpers.js";
import { normalizeBrowserTimerDelayMs } from "./timer-delay.js";
const CDP_URL_IN_TEXT_RE = /\b(?:https?|wss?):\/\/[^\s"'<>`]+/gi;
export { isLoopbackHost, parseBrowserHttpUrl, redactCdpUrl };
export { isLoopbackHost };
export { getHeadersWithAuth, stripCdpUrlCredentials } from "./cdp-auth.js";
export { openCdpWebSocket, withCdpSocket } from "./cdp-websocket.js";
export type { CdpSendFn } from "./cdp-websocket.js";
export { parseBrowserHttpUrl, redactCdpUrl };
/**
* Returns true when the URL uses a WebSocket protocol (ws: or wss:).
@@ -82,7 +82,7 @@ export function isDirectCdpWebSocketEndpoint(url: string): boolean {
/* c8 ignore stop */
}
/** Restricts discovered CDP endpoints to the configured control-plane host. */
/** Restrict a trusted CDP endpoint to its configured control-plane host. */
export function scopeCdpPolicyToConfiguredEndpoint(
cdpUrl: string,
ssrfPolicy?: SsrFPolicy,
@@ -90,12 +90,18 @@ export function scopeCdpPolicyToConfiguredEndpoint(
if (!ssrfPolicy) {
return undefined;
}
return withExactHostnamePolicy(ssrfPolicy, new URL(cdpUrl).hostname);
const hostname = new URL(cdpUrl).hostname;
// Never turn an otherwise strict remote hostname into a private-network grant.
if (!isLoopbackHost(hostname) && !isCdpHostnameTrustedByPolicy(ssrfPolicy, hostname)) {
return ssrfPolicy;
}
return withExactHostnamePolicy(ssrfPolicy, hostname);
}
type CdpEndpointSource =
| { source?: "configured" }
| { source: "discovered"; configuredUrl: string };
type CdpEndpointPin = Awaited<ReturnType<typeof resolvePinnedHostnameWithPolicy>>;
function cdpEndpointAuthority(url: string): string {
const parsed = new URL(url);
@@ -124,12 +130,12 @@ export async function assertCdpEndpointAllowed(
cdpUrl: string,
ssrfPolicy?: SsrFPolicy,
options?: CdpEndpointSource,
): Promise<void> {
): Promise<CdpEndpointPin | undefined> {
if (options?.source === "discovered") {
assertDiscoveredCdpEndpointMatchesConfigured(cdpUrl, options.configuredUrl, ssrfPolicy);
}
if (!ssrfPolicy) {
return;
return undefined;
}
const parsed = new URL(cdpUrl);
if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
@@ -143,7 +149,7 @@ export async function assertCdpEndpointAllowed(
isLoopbackHost(parsed.hostname) && options?.source !== "discovered"
? withExactHostnamePolicy(ssrfPolicy, parsed.hostname)
: ssrfPolicy;
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
return await resolvePinnedHostnameWithPolicy(parsed.hostname, {
policy,
});
} catch (error) {
@@ -151,70 +157,6 @@ export async function assertCdpEndpointAllowed(
}
}
type CdpResponse = {
id: number;
result?: unknown;
error?: { message?: string };
};
type Pending = {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
timer?: ReturnType<typeof setTimeout>;
};
export type CdpSendFn = (
method: string,
params?: Record<string, unknown>,
sessionId?: string,
) => Promise<unknown>;
function decodeUrlUserInfo(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
/** Merge URL basic-auth credentials into headers without overriding explicit auth. */
export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
const mergedHeaders = { ...headers };
try {
const parsed = new URL(url);
const hasAuthHeader = Object.keys(mergedHeaders).some(
(key) => key.trim().toLowerCase() === "authorization",
);
if (hasAuthHeader) {
return mergedHeaders;
}
if (parsed.username || parsed.password) {
const username = decodeUrlUserInfo(parsed.username);
const password = decodeUrlUserInfo(parsed.password);
const auth = Buffer.from(`${username}:${password}`).toString("base64");
return { ...mergedHeaders, Authorization: `Basic ${auth}` };
}
} catch {
// ignore
}
return mergedHeaders;
}
/** Remove URL userinfo after callers have converted it to an Authorization header. */
export function stripCdpUrlCredentials(url: string): string {
try {
const parsed = new URL(url);
if (!parsed.username && !parsed.password) {
return url;
}
parsed.username = "";
parsed.password = "";
return parsed.toString();
} catch {
return url;
}
}
/** Redact CDP URLs and credential-shaped text before dependency errors leave Browser. */
export function redactCdpErrorText(text: string): string {
const redactedUrls = text.replace(CDP_URL_IN_TEXT_RE, (match) => redactCdpUrl(match) ?? match);
@@ -321,9 +263,11 @@ type CdpTabOwnershipParams = {
ssrfPolicy?: SsrFPolicy;
};
async function resolveCdpTabOwnershipContext(
params: CdpTabOwnershipParams,
): Promise<{ ownership: BrowserTabOwnership; browserWebSocketUrl?: string }> {
async function resolveCdpTabOwnershipContext(params: CdpTabOwnershipParams): Promise<{
ownership: BrowserTabOwnership;
browserWebSocketUrl?: string;
browserWebSocketLookup?: CdpEndpointPin["lookup"];
}> {
params.signal?.throwIfAborted();
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(params.cdpUrl);
let version: { webSocketDebuggerUrl?: unknown };
@@ -352,7 +296,7 @@ async function resolveCdpTabOwnershipContext(
return { ownership: { status: "non-durable", reason: "browser-identity-unavailable" } };
}
try {
await assertCdpEndpointAllowed(browserWebSocketUrl, params.ssrfPolicy, {
const pinned = await assertCdpEndpointAllowed(browserWebSocketUrl, params.ssrfPolicy, {
source: "discovered",
configuredUrl: params.cdpUrl,
});
@@ -367,6 +311,7 @@ async function resolveCdpTabOwnershipContext(
}),
},
browserWebSocketUrl,
browserWebSocketLookup: pinned?.lookup,
};
} catch (error) {
if (error instanceof BrowserCdpEndpointBlockedError) {
@@ -470,6 +415,7 @@ export async function closeTrackedCdpTarget(
commandTimeoutMs: params.timeoutMs,
handshakeTimeoutMs: params.timeoutMs,
handshakeRetries: 0,
lookup: resolved.browserWebSocketLookup,
},
);
} catch (error) {
@@ -488,98 +434,6 @@ type CdpFetchResult = {
release: () => Promise<void>;
};
function createCdpSender(ws: WebSocket, opts?: { commandTimeoutMs?: number }) {
let nextId = 1;
const pending = new Map<number, Pending>();
const commandTimeoutMs =
typeof opts?.commandTimeoutMs === "number" && Number.isFinite(opts.commandTimeoutMs)
? normalizeBrowserTimerDelayMs(opts.commandTimeoutMs)
: undefined;
const clearPendingTimer = (p: Pending) => {
if (p.timer !== undefined) {
clearTimeout(p.timer);
}
};
const send: CdpSendFn = (
method: string,
params?: Record<string, unknown>,
sessionId?: string,
) => {
const id = nextId++;
const msg = { id, method, params, sessionId };
return new Promise<unknown>((resolve, reject) => {
if (ws.readyState !== WebSocket.OPEN) {
reject(new Error("CDP socket closed"));
return;
}
const entry: Pending = { resolve, reject };
if (commandTimeoutMs !== undefined) {
// A timed-out command closes the whole socket so pending calls do not
// hang on a connection whose CDP command stream is no longer reliable.
entry.timer = setTimeout(() => {
closeWithError(new Error(`CDP command ${method} timed out after ${commandTimeoutMs}ms`));
}, commandTimeoutMs);
}
pending.set(id, entry);
try {
ws.send(JSON.stringify(msg));
} catch (err) {
pending.delete(id);
clearPendingTimer(entry);
reject(toStringifiedError(err));
}
});
};
const closeWithError = (err: Error) => {
for (const [, p] of pending) {
clearPendingTimer(p);
p.reject(err);
}
pending.clear();
ws.close();
};
ws.on("error", (err) => {
// The `err instanceof Error` guard is defensive: Node's `ws` library
// always emits Error instances on the 'error' event. Triggering the
// non-Error branch would require synthetically emitting on the socket,
// which the library treats as an unhandled error and hangs the test.
/* c8 ignore next */
closeWithError(toStringifiedError(err));
});
ws.on("message", (data) => {
try {
const parsed = JSON.parse(rawDataToString(data)) as CdpResponse;
if (typeof parsed.id !== "number") {
return;
}
const p = pending.get(parsed.id);
if (!p) {
return;
}
pending.delete(parsed.id);
clearPendingTimer(p);
if (parsed.error?.message) {
p.reject(new Error(parsed.error.message));
return;
}
p.resolve(parsed.result);
} catch {
// ignore
}
});
ws.on("close", () => {
closeWithError(new Error("CDP socket closed"));
});
return { send, closeWithError };
}
/** Fetch and parse a CDP JSON endpoint through the configured SSRF guard. */
export async function fetchJson<T>(
url: string,
@@ -679,151 +533,3 @@ export async function fetchOk(
const { release } = await fetchCdpChecked(url, timeoutMs, init, ssrfPolicy);
await release();
}
/** Open a CDP WebSocket with URL basic-auth and proxy bypass handling. */
export function openCdpWebSocket(
wsUrl: string,
opts?: { headers?: Record<string, string>; handshakeTimeoutMs?: number },
): WebSocket {
const headers = getHeadersWithAuth(wsUrl, opts?.headers ?? {});
const handshakeTimeoutMs =
typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs)
? Math.max(1, Math.floor(opts.handshakeTimeoutMs))
: CDP_WS_HANDSHAKE_TIMEOUT_MS;
const connectionUrl = stripCdpUrlCredentials(wsUrl);
const agent = getDirectAgentForCdp(connectionUrl);
return withManagedProxyForCdpUrl(
connectionUrl,
() =>
new WebSocket(connectionUrl, {
handshakeTimeout: handshakeTimeoutMs,
...(Object.keys(headers).length ? { headers } : {}),
...(agent ? { agent } : {}),
}),
);
}
type CdpSocketOptions = {
headers?: Record<string, string>;
handshakeTimeoutMs?: number;
commandTimeoutMs?: number;
handshakeRetries?: number;
handshakeRetryDelayMs?: number;
handshakeMaxRetryDelayMs?: number;
signal?: AbortSignal;
};
function normalizeRetryCount(value: number | undefined, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return Math.max(0, Math.floor(value));
}
function computeHandshakeRetryDelayMs(attempt: number, opts?: CdpSocketOptions): number {
const baseDelayMs =
typeof opts?.handshakeRetryDelayMs === "number" && Number.isFinite(opts.handshakeRetryDelayMs)
? Math.max(1, Math.floor(opts.handshakeRetryDelayMs))
: 200;
const maxDelayMs =
typeof opts?.handshakeMaxRetryDelayMs === "number" &&
Number.isFinite(opts.handshakeMaxRetryDelayMs)
? Math.max(baseDelayMs, Math.floor(opts.handshakeMaxRetryDelayMs))
: 3000;
const raw = Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, attempt - 1));
// Jitter keeps several browser sessions from retrying handshakes in lockstep
// after a shared Chrome or network hiccup.
const jitterScale = 0.8 + Math.random() * 0.4;
return Math.max(1, Math.floor(raw * jitterScale));
}
function shouldRetryCdpHandshakeError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false;
}
const msg = err.message.toLowerCase();
if (!msg) {
return false;
}
if (msg.includes("rate limit")) {
return false;
}
const statusMatch = msg.match(/(?:unexpected server response|response):\s*(\d{3})/);
if (statusMatch?.[1]) {
return Number(statusMatch[1]) >= 500;
}
return (
msg.includes("cdp socket closed") ||
msg.includes("econnreset") ||
msg.includes("econnrefused") ||
msg.includes("econnaborted") ||
msg.includes("ehostunreach") ||
msg.includes("enetunreach") ||
msg.includes("etimedout") ||
msg.includes("socket hang up") ||
msg.includes("websocket error") ||
msg.includes("closed before")
);
}
export async function withCdpSocket<T>(
wsUrl: string,
fn: (send: CdpSendFn) => Promise<T>,
opts?: CdpSocketOptions,
): Promise<T> {
const maxHandshakeRetries = normalizeRetryCount(opts?.handshakeRetries, 2);
for (let attempt = 0; ; attempt += 1) {
opts?.signal?.throwIfAborted();
const ws = openCdpWebSocket(wsUrl, opts);
const { send, closeWithError } = createCdpSender(ws, opts);
const openPromise = new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", (err) => reject(err));
ws.once("close", () => reject(new Error("CDP socket closed")));
});
// A stalled HTTP upgrade must release its TCP socket on cancellation.
const abortHandshake = () => ws.terminate();
opts?.signal?.addEventListener("abort", abortHandshake, { once: true });
if (opts?.signal?.aborted) {
abortHandshake();
}
try {
await openPromise;
} catch (err) {
// openPromise is only rejected via `ws.once('error', err => reject(err))`
// or the close event's `new Error(...)`; the former always carries an
// Error from Node's `ws` library, the latter is already an Error. The
// non-Error wrap is defensive and structurally unreachable.
/* c8 ignore next */
closeWithError(toStringifiedError(err));
// Cancellation on the final attempt must not become a handshake error.
opts?.signal?.throwIfAborted();
if (attempt >= maxHandshakeRetries || !shouldRetryCdpHandshakeError(err)) {
throw err;
}
// Retry only handshake failures. Once CDP commands are flowing, callers
// own retry semantics because commands may already have side effects.
// Cancelled route requests must not keep retrying Chrome handshakes.
await sleepWithAbort(computeHandshakeRetryDelayMs(attempt + 1, opts), opts?.signal).catch(
(error: unknown) => {
opts?.signal?.throwIfAborted();
throw error;
},
);
continue;
} finally {
opts?.signal?.removeEventListener("abort", abortHandshake);
}
try {
return await fn(send);
} catch (err) {
closeWithError(toStringifiedError(err));
throw err;
} finally {
ws.close();
}
}
}
+15 -6
View File
@@ -1,3 +1,4 @@
import type { lookup as dnsLookupCb } from "node:dns";
/**
* Chrome DevTools Protocol browser operations.
*
@@ -40,12 +41,13 @@ export { type CdpActionTimeouts, waitForCdpCommittedNavigationUrl } from "./cdp-
/** Read the current main-frame loader identity from a page-level CDP target. */
export async function getMainFrameDocumentIdentityViaCdp(opts: {
wsUrl: string;
lookup?: typeof dnsLookupCb;
timeoutMs?: number;
}): Promise<string | undefined> {
return await withCdpSocket(
opts.wsUrl,
async (send) => await readCdpMainFrameDocumentIdentity(send),
{ commandTimeoutMs: opts.timeoutMs ?? 5000 },
{ commandTimeoutMs: opts.timeoutMs ?? 5000, ...(opts.lookup ? { lookup: opts.lookup } : {}) },
);
}
@@ -92,6 +94,7 @@ export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
/** Capture a PNG or JPEG screenshot through CDP, optionally full-page. */
export async function captureScreenshot(opts: {
wsUrl: string;
lookup?: typeof dnsLookupCb;
fullPage?: boolean;
format?: "png" | "jpeg";
quality?: number; // jpeg only (0..100)
@@ -202,7 +205,7 @@ export async function captureScreenshot(opts: {
}
}
},
{ commandTimeoutMs: opts.timeoutMs },
{ commandTimeoutMs: opts.timeoutMs, lookup: opts.lookup },
);
}
@@ -221,7 +224,7 @@ export async function createTargetViaCdp(opts: {
url: opts.url,
...withBrowserNavigationPolicy(opts.ssrfPolicy),
});
await assertCdpEndpointAllowed(opts.cdpUrl, opts.ssrfPolicy);
const configuredCdpPin = await assertCdpEndpointAllowed(opts.cdpUrl, opts.ssrfPolicy);
const cdpControlPolicy = scopeCdpPolicyToConfiguredEndpoint(opts.cdpUrl, opts.ssrfPolicy);
let wsUrl: string;
@@ -274,7 +277,10 @@ export async function createTargetViaCdp(opts: {
candidateWsUrl === opts.cdpUrl
? ({ source: "configured" } as const)
: ({ source: "discovered", configuredUrl: opts.cdpUrl } as const);
await assertCdpEndpointAllowed(candidateWsUrl, cdpControlPolicy, endpointSource);
const candidateCdpPin =
candidateWsUrl === opts.cdpUrl
? configuredCdpPin
: await assertCdpEndpointAllowed(candidateWsUrl, cdpControlPolicy, endpointSource);
opts.signal?.throwIfAborted();
return await withCdpSocket(
candidateWsUrl,
@@ -299,6 +305,7 @@ export async function createTargetViaCdp(opts: {
{
commandTimeoutMs: opts.timeouts?.httpTimeoutMs ?? 5000,
handshakeTimeoutMs: opts.timeouts?.handshakeTimeoutMs,
lookup: candidateCdpPin?.lookup,
},
);
} catch (err) {
@@ -424,6 +431,7 @@ export function formatAriaSnapshot(nodes: RawAXNode[], limit: number): AriaSnaps
/** Capture an accessibility-tree snapshot through CDP. */
export async function snapshotAria(opts: {
wsUrl: string;
lookup?: typeof dnsLookupCb;
limit?: number;
timeoutMs?: number;
}): Promise<{ nodes: AriaSnapshotNode[] }> {
@@ -438,7 +446,7 @@ export async function snapshotAria(opts: {
const nodes = Array.isArray(res?.nodes) ? res.nodes : [];
return { nodes: formatAriaSnapshot(nodes, limit) };
},
{ commandTimeoutMs: opts.timeoutMs ?? 5000 },
{ commandTimeoutMs: opts.timeoutMs ?? 5000, lookup: opts.lookup },
);
}
@@ -917,6 +925,7 @@ async function buildCdpRoleSnapshot(params: {
/** Build a role/name text snapshot with stable refs from CDP DOM and AX data. */
export async function snapshotRoleViaCdp(opts: {
wsUrl: string;
lookup?: typeof dnsLookupCb;
options?: CdpRoleSnapshotOptions;
urls?: boolean;
timeoutMs?: number;
@@ -955,7 +964,7 @@ export async function snapshotRoleViaCdp(opts: {
? { ...finalized, truncated: true }
: finalized;
},
{ commandTimeoutMs: opts.timeoutMs ?? 5000 },
{ commandTimeoutMs: opts.timeoutMs ?? 5000, lookup: opts.lookup },
);
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
@@ -169,14 +169,20 @@ export const DEFAULT_CHROME_MCP_FEATURE_ARGS = [
"--experimental-page-id-routing",
];
export const CHROME_MCP_USAGE_STATISTICS_FLAG_RE = /^--(?:no-)?usage-?statistics(?:=.*)?$/i;
export const CHROME_MCP_CONNECTION_FLAGS = new Set([
"--autoConnect",
"--auto-connect",
export const CHROME_MCP_ENDPOINT_FLAGS = new Set([
"--browserUrl",
"--browser-url",
"-u",
"--u",
"--wsEndpoint",
"--ws-endpoint",
"-w",
"--w",
]);
export const CHROME_MCP_CONNECTION_FLAGS = new Set([
"--autoConnect",
"--auto-connect",
...CHROME_MCP_ENDPOINT_FLAGS,
]);
export const CHROME_MCP_USER_DATA_DIR_FLAGS = new Set(["--userDataDir", "--user-data-dir"]);
export const CHROME_MCP_NEW_PAGE_TIMEOUT_MS = 5_000;
@@ -6,6 +6,7 @@ import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { createOpenClawTestState } from "openclaw/plugin-sdk/test-state";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildChromeMcpArgsFromOptions, normalizeChromeMcpOptions } from "./chrome-mcp-options.js";
import {
ChromeMcpDocumentUnavailableError,
clickChromeMcpCoords,
@@ -243,6 +244,26 @@ describe("chrome MCP page parsing", () => {
vi.unstubAllEnvs();
});
it("passes HTTP CDP endpoints to Chrome MCP as browserUrl discovery endpoints", () => {
const args = buildChromeMcpArgsFromOptions(
normalizeChromeMcpOptions({ cdpUrl: "http://127.0.0.1:9222" }),
);
expect(args).toContain("--browserUrl");
expect(args).toContain("http://127.0.0.1:9222");
expect(args).not.toContain("--wsEndpoint");
});
it("passes direct WebSocket CDP endpoints to Chrome MCP as wsEndpoint attachments", () => {
const args = buildChromeMcpArgsFromOptions(
normalizeChromeMcpOptions({ cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc" }),
);
expect(args).toContain("--wsEndpoint");
expect(args).toContain("ws://127.0.0.1:9222/devtools/browser/abc");
expect(args).not.toContain("--browserUrl");
});
it("keeps document-bound evaluations on one pinned target and raw snapshot uid", async () => {
const session = createPageSession({
pid: 139,
@@ -20,11 +20,12 @@ import {
openCdpWebSocket,
redactCdpUrl,
scopeCdpPolicyToConfiguredEndpoint,
stripCdpUrlCredentials,
} from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import { BrowserCdpEndpointBlockedError } from "./errors.js";
type ChromeCdpEndpointPin = NonNullable<Awaited<ReturnType<typeof assertCdpEndpointAllowed>>>;
/** Machine-readable failure codes for Chrome CDP diagnostics. */
type ChromeCdpDiagnosticCode =
| "ssrf_blocked"
@@ -127,7 +128,7 @@ async function readChromeVersion(
}
}
/** Preserve authenticated providers that expose only Playwright's trailing-slash route. */
/** Preserve providers that expose only Playwright's trailing-slash route. */
export async function readChromeVersionWithCredentialFallback(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
@@ -135,10 +136,7 @@ export async function readChromeVersionWithCredentialFallback(
): Promise<ChromeVersion> {
try {
const primaryVersion = await readChromeVersion(cdpUrl, timeoutMs, ssrfPolicy);
if (
normalizeOptionalString(primaryVersion.webSocketDebuggerUrl) ||
stripCdpUrlCredentials(cdpUrl) === cdpUrl
) {
if (normalizeOptionalString(primaryVersion.webSocketDebuggerUrl)) {
return primaryVersion;
}
try {
@@ -147,9 +145,6 @@ export async function readChromeVersionWithCredentialFallback(
return primaryVersion;
}
} catch (primaryError) {
if (stripCdpUrlCredentials(cdpUrl) === cdpUrl) {
throw primaryError;
}
try {
return await readChromeVersion(cdpUrl, timeoutMs, ssrfPolicy, "/json/version/");
} catch {
@@ -191,10 +186,12 @@ function chromeVersionFromCdpResult(result: unknown): ChromeVersion | undefined
async function diagnoseCdpHealthCommand(
wsUrl: string,
timeoutMs = CHROME_WS_READY_TIMEOUT_MS,
lookup?: ChromeCdpEndpointPin["lookup"],
): Promise<CdpHealthDiagnostic> {
return await new Promise<CdpHealthDiagnostic>((resolve) => {
const ws = openCdpWebSocket(wsUrl, {
handshakeTimeoutMs: timeoutMs,
lookup,
});
let settled = false;
let opened = false;
@@ -343,9 +340,14 @@ async function diagnoseCdpWebSocketEndpoint(params: {
wsUrl: string;
startedAt: number;
handshakeTimeoutMs: number;
lookup?: ChromeCdpEndpointPin["lookup"];
version?: ChromeVersion;
}): Promise<ChromeCdpDiagnostic> {
const health = await diagnoseCdpHealthCommand(params.wsUrl, params.handshakeTimeoutMs);
const health = await diagnoseCdpHealthCommand(
params.wsUrl,
params.handshakeTimeoutMs,
params.lookup,
);
if (!health.ok) {
return failureDiagnostic({
cdpUrl: params.cdpUrl,
@@ -373,8 +375,9 @@ export async function diagnoseChromeCdp(
ssrfPolicy?: SsrFPolicy,
): Promise<ChromeCdpDiagnostic> {
const startedAt = Date.now();
let configuredPin: ChromeCdpEndpointPin | undefined;
try {
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
configuredPin = await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
} catch (err) {
return failureDiagnostic({
cdpUrl,
@@ -391,6 +394,7 @@ export async function diagnoseChromeCdp(
wsUrl: cdpUrl,
startedAt,
handshakeTimeoutMs,
lookup: configuredPin?.lookup,
});
}
@@ -411,6 +415,7 @@ export async function diagnoseChromeCdp(
wsUrl: cdpUrl,
startedAt,
handshakeTimeoutMs,
lookup: configuredPin?.lookup,
});
}
const classified = classifyChromeVersionError(err);
@@ -430,6 +435,7 @@ export async function diagnoseChromeCdp(
wsUrl: cdpUrl,
startedAt,
handshakeTimeoutMs,
lookup: configuredPin?.lookup,
version,
});
}
@@ -441,8 +447,9 @@ export async function diagnoseChromeCdp(
});
}
const wsUrl = normalizeCdpWsUrl(wsUrlRaw, discoveryUrl);
let discoveredPin: ChromeCdpEndpointPin | undefined;
try {
await assertCdpEndpointAllowed(wsUrl, cdpControlPolicy, {
discoveredPin = await assertCdpEndpointAllowed(wsUrl, cdpControlPolicy, {
source: "discovered",
configuredUrl: cdpUrl,
});
@@ -456,10 +463,14 @@ export async function diagnoseChromeCdp(
});
}
const health = await diagnoseCdpHealthCommand(wsUrl, handshakeTimeoutMs);
const health = await diagnoseCdpHealthCommand(wsUrl, handshakeTimeoutMs, discoveredPin?.lookup);
if (!health.ok) {
if (isWebSocketUrl(cdpUrl) && wsUrl !== cdpUrl) {
const directHealth = await diagnoseCdpHealthCommand(cdpUrl, handshakeTimeoutMs);
const directHealth = await diagnoseCdpHealthCommand(
cdpUrl,
handshakeTimeoutMs,
configuredPin?.lookup,
);
if (directHealth.ok) {
return {
ok: true,
@@ -13,7 +13,7 @@ import {
*/
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { redactCdpErrorText, withCdpSocket } from "./cdp.helpers.js";
import { getChromeWebSocketUrl, type RunningChrome } from "./chrome.js";
import { getChromeWebSocketEndpoint, type RunningChrome } from "./chrome.js";
import type {
BrowserGraphicsAcceleration,
BrowserGraphicsDevice,
@@ -174,19 +174,28 @@ export async function inspectChromeGraphicsDiagnostics(
): Promise<BrowserGraphicsDiagnostics> {
const observedAt = Date.now();
try {
const wsUrl = await getChromeWebSocketUrl(cdpUrl, options.httpTimeoutMs, options.ssrfPolicy);
if (!wsUrl) {
const endpoint = await getChromeWebSocketEndpoint(
cdpUrl,
options.httpTimeoutMs,
options.ssrfPolicy,
);
if (!endpoint) {
return {
status: "unavailable",
observedAt,
reason: "browser-level CDP WebSocket was not advertised",
};
}
const result = await withCdpSocket(wsUrl, async (send) => await send("SystemInfo.getInfo"), {
handshakeTimeoutMs: options.handshakeTimeoutMs,
commandTimeoutMs: options.commandTimeoutMs,
handshakeRetries: 0,
});
const result = await withCdpSocket(
endpoint.url,
async (send) => await send("SystemInfo.getInfo"),
{
handshakeTimeoutMs: options.handshakeTimeoutMs,
commandTimeoutMs: options.commandTimeoutMs,
handshakeRetries: 0,
lookup: endpoint.lookup,
},
);
return normalizeChromeGraphicsInfo(result, observedAt);
} catch (error) {
return {
@@ -60,7 +60,7 @@ vi.mock("./cdp-timeouts.js", async () => {
import { CHROME_STDERR_HINT_MAX_CHARS } from "./cdp-timeouts.js";
import {
getChromeWebSocketUrl,
getChromeWebSocketEndpoint,
isChromeCdpReady,
isChromeReachable,
launchOpenClawChrome,
@@ -72,6 +72,12 @@ import { BROWSER_ERROR_REASONS, BrowserProfileUnavailableError } from "./errors.
const CHROME_TEST_WS_MAX_PAYLOAD_BYTES = 1024 * 1024;
async function getChromeWebSocketUrl(
...args: Parameters<typeof getChromeWebSocketEndpoint>
): Promise<string | null> {
return (await getChromeWebSocketEndpoint(...args))?.url ?? null;
}
/**
* Covers the parts of chrome.ts that the mainline chrome.test.ts does
* not exercise: launchOpenClawChrome (with child_process.spawn mocked),
@@ -2,7 +2,7 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import { getChromeWebSocketUrl, isChromeReachable } from "./chrome.js";
import { getChromeWebSocketEndpoint, isChromeReachable } from "./chrome.js";
type RunningServer = {
server: Server;
@@ -62,8 +62,8 @@ describe("chrome loopback SSRF integration", () => {
it("returns the loopback websocket URL under strict default SSRF policy", async () => {
const { baseUrl } = await startLoopbackCdpServer();
await expect(getChromeWebSocketUrl(baseUrl, 500, {})).resolves.toMatch(
/\/devtools\/browser\/TEST$/,
);
await expect(
getChromeWebSocketEndpoint(baseUrl, 500, {}).then((endpoint) => endpoint?.url ?? null),
).resolves.toMatch(/\/devtools\/browser\/TEST$/);
});
});
+46 -1
View File
@@ -13,7 +13,7 @@ import {
resolveGoogleChromeExecutableForPlatform,
} from "./chrome.executables.js";
import {
getChromeWebSocketUrl,
getChromeWebSocketEndpoint,
isChromeCdpOwnedByPid,
isChromeCdpReady,
isChromeReachable,
@@ -52,6 +52,12 @@ function jsonResponse(payload: unknown, status = 200): Response {
});
}
async function getChromeWebSocketUrl(
...args: Parameters<typeof getChromeWebSocketEndpoint>
): Promise<string | null> {
return (await getChromeWebSocketEndpoint(...args))?.url ?? null;
}
async function withMockChromeCdpServer(params: {
wsPath: string;
onConnection?: (wss: WebSocketServer) => void;
@@ -289,6 +295,45 @@ describe("browser chrome helpers", () => {
}
});
it("keeps trailing-slash discovery inside the guarded fetch path for HTTP endpoints", async () => {
const requests: string[] = [];
const server = createServer((req, res) => {
requests.push(req.url ?? "");
if (req.url === "/json/version/") {
const addr = server.address() as AddressInfo;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
webSocketDebuggerUrl: `ws://127.0.0.1:${addr.port}/devtools/browser/trailing`,
}),
);
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve, reject) => {
server.listen(0, "127.0.0.1", () => resolve());
server.once("error", reject);
});
try {
const addr = server.address() as AddressInfo;
await expect(
getChromeWebSocketUrl(`http://127.0.0.1:${addr.port}`, 1000, {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
}),
).resolves.toBe(`ws://127.0.0.1:${addr.port}/devtools/browser/trailing`);
expect(requests).toEqual(["/json/version", "/json/version/"]);
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("reports cdpReady only when Browser.getVersion command succeeds", async () => {
await withMockChromeCdpServer({
wsPath: "/devtools/browser/health",
+32 -19
View File
@@ -849,9 +849,20 @@ function buildOpenClawChromeLaunchArgs(params: {
return args;
}
async function canOpenWebSocket(url: string, timeoutMs: number): Promise<boolean> {
type ChromeCdpEndpointPin = NonNullable<Awaited<ReturnType<typeof assertCdpEndpointAllowed>>>;
export type ChromeWebSocketEndpoint = {
url: string;
lookup?: ChromeCdpEndpointPin["lookup"];
};
async function canOpenWebSocket(
url: string,
timeoutMs: number,
lookup?: ChromeCdpEndpointPin["lookup"],
): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs });
const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs, lookup });
ws.once("open", () => {
ws.close();
resolve(true);
@@ -868,10 +879,10 @@ export async function isChromeReachable(
ssrfPolicy?: SsrFPolicy,
): Promise<boolean> {
try {
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
const configuredPin = await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
if (isDirectCdpWebSocketEndpoint(cdpUrl)) {
// Handshake-ready direct WS endpoint — probe via WS handshake.
return await canOpenWebSocket(cdpUrl, timeoutMs);
return await canOpenWebSocket(cdpUrl, timeoutMs, configuredPin?.lookup);
}
// Either an http(s) discovery URL or a bare ws/wss root. Try
// /json/version discovery first. For bare ws/wss URLs, fall back to a
@@ -886,7 +897,7 @@ export async function isChromeReachable(
return true;
}
if (isWebSocketUrl(cdpUrl)) {
return await canOpenWebSocket(cdpUrl, timeoutMs);
return await canOpenWebSocket(cdpUrl, timeoutMs, configuredPin?.lookup);
}
return false;
} catch {
@@ -906,18 +917,18 @@ async function fetchChromeVersion(
}
}
/** Resolve a usable Chrome DevTools WebSocket URL from a CDP endpoint. */
export async function getChromeWebSocketUrl(
/** Resolve a usable Chrome DevTools WebSocket endpoint from a CDP endpoint. */
export async function getChromeWebSocketEndpoint(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
ssrfPolicy?: SsrFPolicy,
): Promise<string | null> {
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
): Promise<ChromeWebSocketEndpoint | null> {
const configuredPin = await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
const cdpControlPolicy = scopeCdpPolicyToConfiguredEndpoint(cdpUrl, ssrfPolicy);
if (isDirectCdpWebSocketEndpoint(cdpUrl)) {
// Handshake-ready direct WebSocket endpoint — the cdpUrl is already
// the WebSocket URL.
return cdpUrl;
return { url: cdpUrl, lookup: configuredPin?.lookup };
}
// Either an http(s) endpoint or a bare ws/wss root; discover the
// actual WebSocket URL via /json/version. Normalise the scheme so
@@ -934,16 +945,16 @@ export async function getChromeWebSocketUrl(
// The SSRF check on cdpUrl was already performed at the start of this
// function, so we can return it directly.
if (isWebSocketUrl(cdpUrl)) {
return cdpUrl;
return { url: cdpUrl, lookup: configuredPin?.lookup };
}
return null;
}
const normalizedWsUrl = normalizeCdpWsUrl(wsUrl, discoveryUrl);
await assertCdpEndpointAllowed(normalizedWsUrl, cdpControlPolicy, {
const discoveredPin = await assertCdpEndpointAllowed(normalizedWsUrl, cdpControlPolicy, {
source: "discovered",
configuredUrl: cdpUrl,
});
return normalizedWsUrl;
return { url: normalizedWsUrl, lookup: discoveredPin?.lookup };
}
/** Return true when a Chrome CDP endpoint has a healthy WebSocket command path. */
@@ -1355,13 +1366,13 @@ export async function isChromeCdpOwnedByPid(
ssrfPolicy?: SsrFPolicy,
): Promise<boolean> {
try {
const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs, ssrfPolicy);
if (!wsUrl) {
const endpoint = await getChromeWebSocketEndpoint(cdpUrl, timeoutMs, ssrfPolicy);
if (!endpoint) {
return false;
}
let owned = false;
await withCdpSocket(
wsUrl,
endpoint.url,
async (send) => {
owned = cdpProcessListOwnsBrowser(await send("SystemInfo.getProcessInfo"), pid);
},
@@ -1369,6 +1380,7 @@ export async function isChromeCdpOwnedByPid(
commandTimeoutMs: timeoutMs,
handshakeRetries: 0,
handshakeTimeoutMs: timeoutMs,
lookup: endpoint.lookup,
},
);
return owned;
@@ -1387,15 +1399,15 @@ async function requestGracefulChromeClose(
);
let commandSent = false;
try {
const wsUrl = await getChromeWebSocketUrl(
const endpoint = await getChromeWebSocketEndpoint(
cdpUrlForPort(running.cdpPort),
Math.min(commandTimeoutMs, CHROME_STOP_PROBE_TIMEOUT_MS),
);
if (!wsUrl) {
if (!endpoint) {
return false;
}
await withCdpSocket(
wsUrl,
endpoint.url,
async (send) => {
// The fixed port can be rebound while this handle remains retained.
// Never ask a replacement browser to close on behalf of the old child.
@@ -1410,6 +1422,7 @@ async function requestGracefulChromeClose(
commandTimeoutMs,
handshakeTimeoutMs: commandTimeoutMs,
handshakeRetries: 0,
lookup: endpoint.lookup,
},
);
return commandSent;
@@ -3,6 +3,10 @@
*
* Shared by the browser control client, CLI, and Browser agent tool.
*/
import type { lookup as dnsLookupCb } from "node:dns";
type BrowserCdpLookup = typeof dnsLookupCb;
/** Browser transport backing the selected profile. */
export type BrowserTransport = "cdp" | "chrome-mcp" | "extension";
type BrowserHeadlessSource =
@@ -126,6 +130,8 @@ export type BrowserTab = {
title: string;
url: string;
wsUrl?: string;
/** Internal CDP lookup pin paired with wsUrl; omitted from model-facing summaries. */
wsLookup?: BrowserCdpLookup;
type?: string;
};
@@ -8,6 +8,12 @@ import { createRequire } from "node:module";
import type * as PlaywrightCore from "playwright-core";
const require = createRequire(import.meta.url);
const playwrightCoreBundle = require("playwright-core/lib/coreBundle") as {
getUserAgent: () => string;
};
/** Runtime playwright-core module instance. */
export const playwrightCore = require("playwright-core") as typeof PlaywrightCore;
/** Dependency-owned User-Agent used by Playwright's native CDP WebSocket transport. */
export const getPlaywrightUserAgent = playwrightCoreBundle.getUserAgent;
@@ -15,6 +15,8 @@ type BrowserProfileMode =
type BrowserProfileCapabilities = {
mode: BrowserProfileMode;
isRemote: boolean;
/** Browser process reads paths from the same filesystem as OpenClaw. */
browserFilesystemLocal: boolean;
/** Profile uses the Chrome DevTools MCP server (existing-session driver). */
usesChromeMcp: boolean;
usesPersistentPlaywright: boolean;
@@ -32,6 +34,7 @@ export function getBrowserProfileCapabilities(
return {
mode: "local-existing-session",
isRemote: false,
browserFilesystemLocal: false,
usesChromeMcp: true,
usesPersistentPlaywright: false,
supportsPerTabWs: false,
@@ -48,6 +51,7 @@ export function getBrowserProfileCapabilities(
return {
mode: "local-extension",
isRemote: false,
browserFilesystemLocal: true,
usesChromeMcp: false,
usesPersistentPlaywright: true,
supportsPerTabWs: false,
@@ -61,6 +65,7 @@ export function getBrowserProfileCapabilities(
return {
mode: "remote-cdp",
isRemote: true,
browserFilesystemLocal: false,
usesChromeMcp: false,
usesPersistentPlaywright: true,
supportsPerTabWs: false,
@@ -73,6 +78,9 @@ export function getBrowserProfileCapabilities(
return {
mode: "local-managed",
isRemote: false,
// A loopback attach-only endpoint can terminate in Docker or a tunnel.
// Only an OpenClaw-owned browser is known to share this filesystem.
browserFilesystemLocal: !profile.attachOnly,
usesChromeMcp: false,
usesPersistentPlaywright: false,
supportsPerTabWs: true,
@@ -1,6 +1,6 @@
// Browser tests cover pw ai plugin behavior.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { connectOverCdpMock, getChromeWebSocketUrlMock } from "./pw-session.mock-setup.js";
import { connectOverCdpMock, getChromeWebSocketEndpointMock } from "./pw-session.mock-setup.js";
type FakeSession = {
send: ReturnType<typeof vi.fn>;
@@ -57,7 +57,7 @@ let clickViaPlaywright: typeof import("./pw-tools-core.interactions.js").clickVi
let closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection;
beforeAll(async () => {
getChromeWebSocketUrlMock.mockResolvedValue(null);
getChromeWebSocketEndpointMock.mockResolvedValue(null);
({ snapshotAiViaPlaywright } = await import("./pw-tools-core.snapshot.js"));
({ clickViaPlaywright } = await import("./pw-tools-core.interactions.js"));
({ closePlaywrightBrowserConnection } = await import("./pw-session.js"));
@@ -201,7 +201,7 @@ async function tryTerminateExecutionViaCdp(opts: {
return;
}
const wsUrl = normalizeCdpWsUrl(wsUrlRaw, cdpHttpBase);
await assertCdpEndpointAllowed(wsUrl, cdpControlPolicy, {
const wsPin = await assertCdpEndpointAllowed(wsUrl, cdpControlPolicy, {
source: "discovered",
configuredUrl: opts.cdpUrl,
});
@@ -245,7 +245,7 @@ async function tryTerminateExecutionViaCdp(opts: {
// Best-effort; ignore
}
},
{ handshakeTimeoutMs: 2000 },
{ handshakeTimeoutMs: 2000, ...(wsPin?.lookup ? { lookup: wsPin.lookup } : {}) },
).catch(() => {});
}
@@ -0,0 +1,138 @@
import type { lookup as dnsLookupCb } from "node:dns";
import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
import type { Browser, ConnectOverCDPTransport } from "playwright-core";
import WebSocket from "ws";
import { formatErrorMessage } from "../infra/errors.js";
import { openCdpWebSocket } from "./cdp.helpers.js";
import { playwrightCore } from "./playwright-core.runtime.js";
const { chromium } = playwrightCore;
type CdpSocketLookup = typeof dnsLookupCb;
export async function connectOverCdpPinnedTransport(
connectionUrl: string,
opts: {
timeout: number;
headers: Record<string, string>;
lookup: CdpSocketLookup;
},
): Promise<Browser> {
const ws = openCdpWebSocket(connectionUrl, {
headers: opts.headers,
handshakeTimeoutMs: opts.timeout,
lookup: opts.lookup,
playwrightTransportDefaults: true,
});
try {
await new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", reject);
ws.once("close", () => reject(new Error("CDP socket closed")));
});
let onMessage: ((message: object) => void) | undefined;
let onClose: ((reason?: string) => void) | undefined;
const pendingMessages: object[] = [];
let pendingCloseReason: string | undefined;
let transportClosed = false;
let transportCloseScheduled = false;
const notifyTransportClosed = (reason: string) => {
if (transportClosed) {
return;
}
transportClosed = true;
if (onClose) {
onClose(reason);
return;
}
pendingCloseReason = reason;
};
const scheduleTransportClosed = (reason: string) => {
if (transportClosed || transportCloseScheduled) {
return;
}
transportCloseScheduled = true;
setImmediate(() => {
transportCloseScheduled = false;
notifyTransportClosed(reason);
});
};
const closeTransportSocket = (reason = "CDP socket closed") => {
notifyTransportClosed(reason);
ws.close();
const terminateTimer = setTimeout(() => {
if (ws.readyState !== WebSocket.CLOSED) {
ws.terminate();
}
}, 100);
terminateTimer.unref?.();
};
const scheduleMessage = (message: object) => {
setImmediate(() => {
if (transportClosed) {
return;
}
if (!onMessage) {
pendingMessages.push(message);
return;
}
try {
onMessage(message);
} catch (error) {
closeTransportSocket(formatErrorMessage(error));
}
});
};
const transport: ConnectOverCDPTransport = {
send: (message) => {
ws.send(JSON.stringify(message));
},
close: () => {
closeTransportSocket();
},
get onmessage() {
return onMessage;
},
set onmessage(handler) {
onMessage = handler;
if (!handler) {
return;
}
while (pendingMessages.length > 0) {
const pending = pendingMessages.shift();
if (pending) {
scheduleMessage(pending);
}
}
},
get onclose() {
return onClose;
},
set onclose(handler) {
onClose = handler;
if (handler && pendingCloseReason !== undefined) {
const reason = pendingCloseReason;
pendingCloseReason = undefined;
handler(reason);
}
},
};
ws.on("message", (raw) => {
try {
const parsed = JSON.parse(rawDataToString(raw)) as object;
scheduleMessage(parsed);
} catch {
closeTransportSocket();
}
});
ws.on("close", () => {
scheduleTransportClosed("CDP socket closed");
});
ws.on("error", (error) => {
scheduleTransportClosed(formatErrorMessage(error));
});
return await chromium.connectOverCDP(transport, { timeout: opts.timeout });
} catch (error) {
ws.close();
throw error;
}
}
@@ -8,13 +8,15 @@ import { PLAYWRIGHT_TARGET_INFO_TIMEOUT_MS } from "./cdp-timeouts.js";
import {
assertCdpEndpointAllowed,
getHeadersWithAuth,
isLoopbackHost,
isWebSocketUrl,
redactCdpErrorText,
stripCdpUrlCredentials,
} from "./cdp.helpers.js";
import { getChromeWebSocketUrl } from "./chrome.js";
import { getChromeWebSocketEndpoint } from "./chrome.js";
import { BrowserTabNotFoundError } from "./errors.js";
import { playwrightCore } from "./playwright-core.runtime.js";
import { connectOverCdpPinnedTransport } from "./pw-session-cdp-transport.js";
import {
blockedPageRefsByCdpUrl,
blockedTargetsByCdpUrl,
@@ -39,6 +41,7 @@ import {
} from "./pw-session-state.js";
const { chromium } = playwrightCore;
type CdpEndpointPin = NonNullable<Awaited<ReturnType<typeof assertCdpEndpointAllowed>>>;
function resolveCdpConnectRetryDelayMs(attempt: number): number {
return 250 + attempt * 250;
@@ -393,7 +396,7 @@ export async function connectBrowser(
}
// Run SSRF policy check only on cache miss so transient DNS failures
// do not break active sessions that already hold a live CDP connection.
await assertCdpEndpointAllowed(normalized, ssrfPolicy);
const configuredPin = await assertCdpEndpointAllowed(normalized, ssrfPolicy);
const connecting = connectingByCdpUrl.get(normalized);
if (connecting) {
return await connecting.promise;
@@ -408,34 +411,59 @@ export async function connectBrowser(
}
try {
const timeout = 5000 + attempt * 2000;
const wsUrl = await getChromeWebSocketUrl(normalized, timeout, ssrfPolicy).catch(
() => null,
);
let endpointDiscoveryError: unknown;
const resolvedEndpoint = await getChromeWebSocketEndpoint(
normalized,
timeout,
ssrfPolicy,
).catch((err: unknown) => {
endpointDiscoveryError = err;
return null;
});
const hasUrlCredentials = stripCdpUrlCredentials(normalized) !== normalized;
if (!wsUrl && hasUrlCredentials && !isWebSocketUrl(normalized)) {
if (!resolvedEndpoint && hasUrlCredentials && !isWebSocketUrl(normalized)) {
// Playwright preserves explicit headers across HTTP discovery redirects.
// Keep credentialed discovery in OpenClaw's guarded fetch path instead.
throw new Error("Authenticated CDP HTTP endpoint did not expose a usable WebSocket URL.");
}
const endpoint = wsUrl ?? normalized;
const connectEndpoint = async (target: string) => {
if (!resolvedEndpoint && ssrfPolicy && !isWebSocketUrl(normalized)) {
const detail = endpointDiscoveryError
? ` Reason: ${redactCdpErrorText(formatErrorMessage(endpointDiscoveryError))}`
: "";
throw new Error(`Guarded CDP endpoint did not expose a usable WebSocket URL.${detail}`);
}
const normalizedCdpHostname = new URL(normalized).hostname;
const needsPinnedDependencyConnect =
Boolean(configuredPin?.lookup) && !isLoopbackHost(normalizedCdpHostname);
const endpointUrl = resolvedEndpoint?.url ?? normalized;
const endpointLookup =
resolvedEndpoint?.lookup ??
(needsPinnedDependencyConnect ? configuredPin?.lookup : undefined);
const connectEndpoint = async (target: string, lookup?: CdpEndpointPin["lookup"]) => {
const headers = getHeadersWithAuth(target);
const connectionUrl = stripCdpUrlCredentials(target);
// Keep both loopback bypasses active until the Playwright handshake settles.
return await withManagedProxyForCdpUrl(connectionUrl, () =>
withNoProxyForCdpUrl(connectionUrl, () =>
chromium.connectOverCDP(connectionUrl, { timeout, headers }),
),
withNoProxyForCdpUrl(connectionUrl, async () => {
if (lookup) {
return await connectOverCdpPinnedTransport(connectionUrl, {
timeout,
headers,
lookup,
});
}
return await chromium.connectOverCDP(connectionUrl, { timeout, headers });
}),
);
};
let browser: Browser;
try {
browser = await connectEndpoint(endpoint);
browser = await connectEndpoint(endpointUrl, endpointLookup);
} catch (err) {
if (!isWebSocketUrl(normalized) || endpoint === normalized) {
if (!isWebSocketUrl(normalized) || endpointUrl === normalized) {
throw err;
}
browser = await connectEndpoint(normalized);
browser = await connectEndpoint(normalized, configuredPin?.lookup);
}
if (connectionAttempt.cancelled) {
connectionAttempt.retired = { browser, cdpUrl: normalized };
@@ -24,7 +24,8 @@ const {
} = pwAi;
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
const getChromeWebSocketEndpointSpy = vi.spyOn(chromeModule, "getChromeWebSocketEndpoint");
const getChromeWebSocketUrlSpy = getChromeWebSocketEndpointSpy;
type BrowserMockBundle = {
browser: import("playwright-core").Browser;
@@ -244,7 +245,7 @@ describe("pw-session connection scoping", () => {
const wsUrl = "ws://127.0.0.1:9222/devtools/browser/discovered";
const release = vi.fn();
registerManagedProxyBrowserCdpBypassMock.mockReturnValue(release);
getChromeWebSocketUrlSpy.mockResolvedValue(wsUrl);
getChromeWebSocketUrlSpy.mockResolvedValue({ url: wsUrl });
connectOverCdpSpy.mockImplementationOnce(async () => {
expect(registerManagedProxyBrowserCdpBypassMock).toHaveBeenCalledWith(wsUrl);
expect(release).not.toHaveBeenCalled();
@@ -303,7 +304,7 @@ describe("pw-session connection scoping", () => {
releases.push(release);
return release;
});
getChromeWebSocketUrlSpy.mockResolvedValue(discoveredUrl);
getChromeWebSocketUrlSpy.mockResolvedValue({ url: discoveredUrl });
connectOverCdpSpy
.mockRejectedValueOnce(new Error("stale discovered endpoint"))
.mockResolvedValueOnce(browser.browser);
@@ -362,10 +363,42 @@ describe("pw-session connection scoping", () => {
expect(connectOverCdpSpy).not.toHaveBeenCalled();
});
it("does not fall back to Playwright discovery for guarded non-loopback CDP hosts", async () => {
getChromeWebSocketEndpointSpy.mockRejectedValue(new Error("discovery unavailable"));
const connection = listPagesViaPlaywright({
cdpUrl: "http://93.184.216.34:9222",
ssrfPolicy: { allowPrivateNetwork: true },
});
await expect(connection).rejects.toThrow(
"Guarded CDP endpoint did not expose a usable WebSocket URL.",
);
await expect(connection).rejects.toThrow("discovery unavailable");
expect(connectOverCdpSpy).not.toHaveBeenCalled();
});
it("does not fall back to Playwright discovery for guarded loopback HTTP CDP hosts", async () => {
getChromeWebSocketEndpointSpy.mockRejectedValue(new Error("loopback discovery blocked"));
const connection = listPagesViaPlaywright({
cdpUrl: "http://127.0.0.1:9222",
ssrfPolicy: {},
});
await expect(connection).rejects.toThrow(
"Guarded CDP endpoint did not expose a usable WebSocket URL.",
);
await expect(connection).rejects.toThrow("loopback discovery blocked");
expect(connectOverCdpSpy).not.toHaveBeenCalled();
});
it("allows loopback CDP control without widening the navigation allowlist", async () => {
const browser = makeBrowser("A", "https://example.com");
connectOverCdpSpy.mockResolvedValue(browser.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketUrlSpy.mockResolvedValue({
url: "ws://127.0.0.1:9222/devtools/browser/local",
});
const ssrfPolicy = {
dangerouslyAllowPrivateNetwork: true,
allowedHostnames: ["example.com"],
@@ -23,7 +23,7 @@ const {
} = pwAi;
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
const getChromeWebSocketEndpointSpy = vi.spyOn(chromeModule, "getChromeWebSocketEndpoint");
const PROXY_ENV_KEYS = [
"ALL_PROXY",
@@ -115,7 +115,7 @@ function installBrowserMocks() {
} as unknown as import("playwright-core").Browser;
connectOverCdpSpy.mockResolvedValue(browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketEndpointSpy.mockResolvedValue(null);
const getBrowserDisconnectedHandler = () =>
browserOn.mock.calls.find((call) => call[0] === "disconnected")?.[1] as
@@ -214,7 +214,7 @@ beforeEach(() => {
afterEach(async () => {
vi.unstubAllEnvs();
connectOverCdpSpy.mockClear();
getChromeWebSocketUrlSpy.mockClear();
getChromeWebSocketEndpointSpy.mockClear();
await closePlaywrightBrowserConnection().catch(() => {});
});
@@ -246,6 +246,9 @@ describe("pw-session createPageViaPlaywright navigation guard", () => {
it("blocks hostname navigation when strict SSRF policy is configured", async () => {
const { pageGoto } = installBrowserMocks();
getChromeWebSocketEndpointSpy.mockResolvedValue({
url: "ws://127.0.0.1:18792/devtools/browser/ROOT",
});
await expect(
createPageViaPlaywright({
@@ -14,7 +14,7 @@ const {
} = pwAi;
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
const getChromeWebSocketEndpointSpy = vi.spyOn(chromeModule, "getChromeWebSocketEndpoint");
type MockPageSpec = {
targetId?: string;
@@ -107,13 +107,13 @@ function makeBrowser(pages: MockPageSpec[]): BrowserMockBundle {
function installBrowser(pages: MockPageSpec[]): BrowserMockBundle {
const bundle = makeBrowser(pages);
connectOverCdpSpy.mockResolvedValue(bundle.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketEndpointSpy.mockResolvedValue(null);
return bundle;
}
afterEach(async () => {
connectOverCdpSpy.mockReset();
getChromeWebSocketUrlSpy.mockReset();
getChromeWebSocketEndpointSpy.mockReset();
await closePlaywrightBrowserConnection().catch(() => {});
});
@@ -227,7 +227,7 @@ describe("pw-session getPageForTargetId", () => {
const fresh = makeBrowser([{ targetId: "TARGET_OK", url: "https://fresh.example" }]);
connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketEndpointSpy.mockResolvedValue(null);
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
@@ -249,7 +249,7 @@ describe("pw-session getPageForTargetId", () => {
]);
connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketEndpointSpy.mockResolvedValue(null);
await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9333" });
@@ -270,7 +270,7 @@ describe("pw-session getPageForTargetId", () => {
connectOverCdpSpy
.mockResolvedValueOnce(stale.browser)
.mockResolvedValueOnce(stillBroken.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketEndpointSpy.mockResolvedValue(null);
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9444" });
@@ -283,7 +283,7 @@ describe("pw-session getPageForTargetId", () => {
it("does not add an extra top-level retry for non-recoverable connect failures", async () => {
connectOverCdpSpy.mockRejectedValue(new Error("connectOverCDP exploded"));
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketEndpointSpy.mockResolvedValue(null);
await expect(getPageForTargetId({ cdpUrl: "http://127.0.0.1:9555" })).rejects.toThrow(
"connectOverCDP exploded",
@@ -10,9 +10,10 @@ import type { MockFn } from "../test-utils/vitest-mock-fn.js";
/** Mock for playwright.chromium.connectOverCDP. */
export const connectOverCdpMock: MockFn = vi.fn();
/** Mock for Chrome CDP WebSocket URL discovery. */
export const getChromeWebSocketUrlMock: MockFn = vi.fn();
export const getChromeWebSocketEndpointMock: MockFn = vi.fn();
vi.mock("./playwright-core.runtime.js", () => ({
getPlaywrightUserAgent: () => "Playwright/test",
playwrightCore: {
chromium: {
connectOverCDP: (...args: unknown[]) => connectOverCdpMock(...args),
@@ -22,5 +23,5 @@ vi.mock("./playwright-core.runtime.js", () => ({
}));
vi.mock("./chrome.js", () => ({
getChromeWebSocketUrl: (...args: unknown[]) => getChromeWebSocketUrlMock(...args),
getChromeWebSocketEndpoint: (...args: unknown[]) => getChromeWebSocketEndpointMock(...args),
}));
@@ -0,0 +1,373 @@
// Browser tests cover pinned Playwright CDP transport behavior.
import { createServer } from "node:http";
import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
import { chromium } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WebSocketServer } from "ws";
import * as chromeModule from "./chrome.js";
import { pwAi } from "./pw-ai.js";
const { registerManagedProxyBrowserCdpBypassMock } = vi.hoisted(() => ({
registerManagedProxyBrowserCdpBypassMock: vi.fn<(url: string) => (() => void) | undefined>(
() => undefined,
),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime-internal", () => ({
registerManagedProxyBrowserCdpBypass: registerManagedProxyBrowserCdpBypassMock,
}));
const { closePlaywrightBrowserConnection, listPagesViaPlaywright } = pwAi;
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketEndpointSpy = vi.spyOn(chromeModule, "getChromeWebSocketEndpoint");
const TEST_CDP_WS_MAX_PAYLOAD_BYTES = 1024 * 1024;
function webSocketMessageToString(data: import("ws").Data): string {
return typeof data === "string" ? data : rawDataToString(data);
}
function makeBrowser(
targetId: string,
url: string,
): { browser: import("playwright-core").Browser } {
const page = {
on: vi.fn(),
context: () => context,
title: vi.fn(async () => `title:${targetId}`),
url: vi.fn(() => url),
} as unknown as import("playwright-core").Page;
const context: import("playwright-core").BrowserContext = {
pages: () => [page],
on: vi.fn(),
newCDPSession: vi.fn(async () => ({
send: vi.fn(async (method: string) =>
method === "Target.getTargetInfo"
? { targetInfo: { targetId, title: `title:${targetId}` } }
: {},
),
detach: vi.fn(async () => {}),
})),
} as unknown as import("playwright-core").BrowserContext;
const browser = {
contexts: () => [context],
on: vi.fn(),
off: vi.fn(),
close: vi.fn(async () => {}),
} as unknown as import("playwright-core").Browser;
return { browser };
}
function pinnedLoopbackLookup() {
return ((_hostname: string, options: unknown, callback?: unknown) => {
const cb = typeof options === "function" ? options : callback;
if (typeof cb === "function") {
cb(null, "127.0.0.1", 4);
}
}) as never;
}
afterEach(async () => {
connectOverCdpSpy.mockReset();
getChromeWebSocketEndpointSpy.mockReset();
registerManagedProxyBrowserCdpBypassMock.mockReset();
registerManagedProxyBrowserCdpBypassMock.mockImplementation(() => undefined);
await closePlaywrightBrowserConnection().catch(() => {});
});
describe("pw-session pinned Playwright transport", () => {
it("connects guarded Playwright CDP through the pinned WebSocket transport", async () => {
const server = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
server.once("listening", () => resolve());
});
const port = (server.address() as { port: number }).port;
const cdpUrl = `ws://127.0.0.1:${port}/devtools/browser/test`;
const requestHeaders: Array<Record<string, string | string[] | undefined>> = [];
server.on("connection", (socket, request) => {
requestHeaders.push(request.headers);
socket.addEventListener("message", (event) => {
const msg = JSON.parse(webSocketMessageToString(event.data)) as { id?: number };
socket.send(JSON.stringify({ id: msg.id, result: { ok: true } }));
});
});
getChromeWebSocketEndpointSpy.mockResolvedValue({
url: cdpUrl,
lookup: pinnedLoopbackLookup(),
});
const browser = makeBrowser("A", "https://example.com");
connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => {
expect(typeof transportArg).not.toBe("string");
const transport = transportArg as import("playwright-core").ConnectOverCDPTransport;
let delivered = false;
const message = new Promise<object>((resolve) => {
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onmessage property.
transport.onmessage = (value) => {
delivered = true;
resolve(value);
};
});
transport.send({ id: 7, method: "Browser.getVersion" });
expect(delivered).toBe(false);
await expect(message).resolves.toStrictEqual({ id: 7, result: { ok: true } });
transport.close();
return browser.browser;
}) as never);
try {
const pages = await listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} });
expect(pages.map((page) => page.targetId)).toStrictEqual(["A"]);
expect(connectOverCdpSpy).toHaveBeenCalledTimes(1);
expect(requestHeaders[0]?.["user-agent"]).toContain("Playwright/");
expect(requestHeaders[0]?.["sec-websocket-extensions"]).toContain("permessage-deflate");
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("follows same-authority redirects in the pinned Playwright CDP transport", async () => {
const server = createServer();
const wss = new WebSocketServer({
noServer: true,
maxPayload: TEST_CDP_WS_MAX_PAYLOAD_BYTES,
});
const redirectedUpgradePaths: string[] = [];
wss.on("connection", (socket) => {
socket.addEventListener("message", (event) => {
const msg = JSON.parse(webSocketMessageToString(event.data)) as { id?: number };
socket.send(JSON.stringify({ id: msg.id, result: { ok: true } }));
});
});
server.on("upgrade", (request, socket, head) => {
if (request.url === "/start") {
socket.write(
"HTTP/1.1 302 Found\r\nLocation: /devtools/browser/redirected\r\nConnection: close\r\n\r\n",
);
socket.destroy();
return;
}
redirectedUpgradePaths.push(request.url ?? "");
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request);
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("test server did not expose a TCP port");
}
const cdpUrl = `ws://127.0.0.1:${address.port}/start`;
getChromeWebSocketEndpointSpy.mockResolvedValue({
url: cdpUrl,
lookup: pinnedLoopbackLookup(),
});
const browser = makeBrowser("A", "https://example.com");
connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => {
const transport = transportArg as import("playwright-core").ConnectOverCDPTransport;
const message = new Promise<object>((resolve) => {
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onmessage property.
transport.onmessage = (value) => resolve(value);
});
transport.send({ id: 8, method: "Browser.getVersion" });
await expect(message).resolves.toStrictEqual({ id: 8, result: { ok: true } });
transport.close();
return browser.browser;
}) as never);
try {
await expect(listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} })).resolves.toEqual([
expect.objectContaining({ targetId: "A" }),
]);
expect(redirectedUpgradePaths).toStrictEqual(["/devtools/browser/redirected"]);
} finally {
await new Promise<void>((resolve) => {
wss.close(() => {
server.close(() => resolve());
});
});
}
});
it("closes the pinned Playwright transport on malformed CDP JSON", async () => {
const server = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
server.once("listening", () => resolve());
});
const port = (server.address() as { port: number }).port;
const cdpUrl = `ws://127.0.0.1:${port}/devtools/browser/test`;
const serverSocket = new Promise<import("ws").WebSocket>((resolve) => {
server.on("connection", (socket) => resolve(socket));
});
getChromeWebSocketEndpointSpy.mockResolvedValue({
url: cdpUrl,
lookup: pinnedLoopbackLookup(),
});
const browser = makeBrowser("A", "https://example.com");
connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => {
const transport = transportArg as import("playwright-core").ConnectOverCDPTransport;
const closed = new Promise<string | undefined>((resolve) => {
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onclose property.
transport.onclose = (reason) => resolve(reason);
});
(await serverSocket).send("{not-json");
await expect(closed).resolves.toBe("CDP socket closed");
return browser.browser;
}) as never);
try {
await expect(listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} })).resolves.toEqual([
expect.objectContaining({ targetId: "A" }),
]);
expect(connectOverCdpSpy).toHaveBeenCalledOnce();
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("delivers queued CDP messages before reporting pinned transport closure", async () => {
const server = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
server.once("listening", () => resolve());
});
const port = (server.address() as { port: number }).port;
const cdpUrl = `ws://127.0.0.1:${port}/devtools/browser/test`;
const serverSocket = new Promise<import("ws").WebSocket>((resolve) => {
server.on("connection", (socket) => resolve(socket));
});
getChromeWebSocketEndpointSpy.mockResolvedValue({
url: cdpUrl,
lookup: pinnedLoopbackLookup(),
});
const browser = makeBrowser("A", "https://example.com");
connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => {
const transport = transportArg as import("playwright-core").ConnectOverCDPTransport;
const events: string[] = [];
const message = new Promise<void>((resolve) => {
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onmessage property.
transport.onmessage = () => {
events.push("message");
resolve();
};
});
const closed = new Promise<void>((resolve) => {
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onclose property.
transport.onclose = () => {
events.push("close");
resolve();
};
});
const socket = await serverSocket;
socket.send(JSON.stringify({ id: 1, result: { ok: true } }));
socket.close();
await message;
await closed;
expect(events).toStrictEqual(["message", "close"]);
return browser.browser;
}) as never);
try {
await expect(listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} })).resolves.toEqual([
expect.objectContaining({ targetId: "A" }),
]);
expect(connectOverCdpSpy).toHaveBeenCalledOnce();
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("closes the pinned Playwright transport when message delivery fails", async () => {
const server = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
server.once("listening", () => resolve());
});
const port = (server.address() as { port: number }).port;
const cdpUrl = `ws://127.0.0.1:${port}/devtools/browser/test`;
const serverSocket = new Promise<import("ws").WebSocket>((resolve) => {
server.on("connection", (socket) => resolve(socket));
});
getChromeWebSocketEndpointSpy.mockResolvedValue({
url: cdpUrl,
lookup: pinnedLoopbackLookup(),
});
const browser = makeBrowser("A", "https://example.com");
connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => {
const transport = transportArg as import("playwright-core").ConnectOverCDPTransport;
const closed = new Promise<string | undefined>((resolve) => {
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onclose property.
transport.onclose = (reason) => resolve(reason);
});
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onmessage property.
transport.onmessage = () => {
throw new Error("handler failed");
};
(await serverSocket).send(JSON.stringify({ id: 1, result: {} }));
await expect(closed).resolves.toContain("handler failed");
return browser.browser;
}) as never);
try {
await expect(listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} })).resolves.toEqual([
expect.objectContaining({ targetId: "A" }),
]);
expect(connectOverCdpSpy).toHaveBeenCalledOnce();
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("propagates pinned WebSocket protocol errors through transport closure", async () => {
const server = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
server.once("listening", () => resolve());
});
const port = (server.address() as { port: number }).port;
const cdpUrl = `ws://127.0.0.1:${port}/devtools/browser/test`;
const serverSocket = new Promise<import("ws").WebSocket>((resolve) => {
server.on("connection", (socket) => resolve(socket));
});
getChromeWebSocketEndpointSpy.mockResolvedValue({
url: cdpUrl,
lookup: pinnedLoopbackLookup(),
});
const browser = makeBrowser("A", "https://example.com");
connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => {
const transport = transportArg as import("playwright-core").ConnectOverCDPTransport;
const closed = new Promise<string | undefined>((resolve) => {
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onclose property.
transport.onclose = (reason) => resolve(reason);
});
const socket = await serverSocket;
const rawSocket = Reflect.get(socket, "_socket") as { write(data: Buffer): void };
// Send an invalid reserved opcode so the real ws client emits an error.
rawSocket.write(Buffer.from([0x83, 0x00]));
await expect(closed).resolves.toContain("Invalid WebSocket frame");
return browser.browser;
}) as never);
try {
await expect(listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} })).resolves.toEqual([
expect.objectContaining({ targetId: "A" }),
]);
expect(connectOverCdpSpy).toHaveBeenCalledOnce();
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
});
@@ -12,6 +12,7 @@ const {
const wsMockState = vi.hoisted(() => ({
constructorUrls: [] as string[],
constructorOptions: [] as Array<{ agent?: unknown } | undefined>,
}));
vi.mock("ws", () => {
@@ -21,8 +22,9 @@ vi.mock("ws", () => {
readyState = 0;
private readonly handlers = new Map<string, (error?: Error) => void>();
constructor(url: string) {
constructor(url: string, options?: { agent?: unknown }) {
wsMockState.constructorUrls.push(url);
wsMockState.constructorOptions.push(options);
setTimeout(() => {
this.handlers.get("error")?.(new Error("test socket should not open"));
}, 0);
@@ -34,6 +36,9 @@ vi.mock("ws", () => {
}
close() {
if (this.readyState === 3) {
return;
}
this.readyState = 3;
this.handlers.get("close")?.();
}
@@ -45,7 +50,7 @@ vi.mock("ws", () => {
});
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
const getChromeWebSocketEndpointSpy = vi.spyOn(chromeModule, "getChromeWebSocketEndpoint");
function installBrowserMock() {
const sessionSend = vi.fn(async (method: string) => {
@@ -78,14 +83,17 @@ function installBrowserMock() {
} as unknown as import("playwright-core").Browser;
connectOverCdpSpy.mockResolvedValue(browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketEndpointSpy.mockResolvedValue({
url: "ws://127.0.0.1:18792/devtools/browser/ROOT",
});
return { browserClose };
}
afterEach(async () => {
connectOverCdpSpy.mockReset();
getChromeWebSocketUrlSpy.mockReset();
getChromeWebSocketEndpointSpy.mockReset();
wsMockState.constructorUrls = [];
wsMockState.constructorOptions = [];
await closePlaywrightBrowserConnection().catch(() => {});
});
@@ -116,12 +124,64 @@ describe("pw-session termination CDP SSRF guard", () => {
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0]?.[0]).toBe("http://127.0.0.1:18792/json/list");
const fetchUrls = fetchSpy.mock.calls.map((call) => call[0]);
expect(fetchUrls).toContain("http://127.0.0.1:18792/json/list");
expect(fetchUrls).not.toContain("http://169.254.169.254/json/list");
expect(wsMockState.constructorUrls).toEqual([]);
expect(browserClose).toHaveBeenCalledTimes(1);
} finally {
fetchSpy.mockRestore();
}
});
it("uses the discovered target lookup pin for best-effort termination sockets", async () => {
installBrowserMock();
const lookup = vi.fn((_hostname: string, options: unknown, callback?: unknown) => {
const cb = typeof options === "function" ? options : callback;
if (typeof cb === "function") {
cb(null, "127.0.0.1", 4);
}
});
const assertAllowedSpy = vi
.spyOn(await import("./cdp.helpers.js"), "assertCdpEndpointAllowed")
.mockImplementation(async (url: string) =>
url.includes("/devtools/page/")
? {
hostname: "cdp-pinned.test",
addresses: ["127.0.0.1"],
lookup: lookup as never,
}
: undefined,
);
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify([
{
id: "TARGET_1",
webSocketDebuggerUrl: "ws://cdp-pinned.test/devtools/page/TARGET_1",
},
]),
{ status: 200 },
),
);
try {
await listPagesViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
ssrfPolicy: {},
});
await forceDisconnectPlaywrightForTarget({
cdpUrl: "http://127.0.0.1:18792",
targetId: "TARGET_1",
ssrfPolicy: {},
});
expect(wsMockState.constructorUrls).toEqual(["ws://cdp-pinned.test/devtools/page/TARGET_1"]);
expect(wsMockState.constructorOptions[0]?.agent).toBeDefined();
} finally {
assertAllowedSpy.mockRestore();
fetchSpy.mockRestore();
}
});
});
@@ -69,12 +69,15 @@ function resolveImplicitDownloadRoot(): string {
}
/** Arms the next page file chooser and fills it with strict existing paths. */
export async function armFileUploadViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
paths?: string[];
timeoutMs?: number;
}): Promise<void> {
export async function armFileUploadViaPlaywright(
opts: {
cdpUrl: string;
browserFilesystemLocal?: boolean;
targetId?: string;
paths?: string[];
timeoutMs?: number;
} & BrowserNavigationPolicyOptions,
): Promise<void> {
const key = opts.cdpUrl;
const armId = bumpUploadArmId();
pendingUploadClaims.set(key, armId);
@@ -115,7 +118,17 @@ export async function armFileUploadViaPlaywright(opts: {
await dismissFileChooser(page);
return;
}
await fileChooser.setFiles(uploadPathsResult.paths);
await setFileChooserFilesViaPlaywright({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
page,
fileChooser,
paths: uploadPathsResult.paths,
timeoutMs: timeout,
browserFilesystemLocal: opts.browserFilesystemLocal,
ssrfPolicy: opts.ssrfPolicy,
browserProxyMode: opts.browserProxyMode,
});
})
.catch(() => {
// Ignore timeouts; the chooser may never appear.
@@ -131,6 +144,7 @@ export async function armFileUploadViaPlaywright(opts: {
export async function uploadViaPlaywright(
opts: {
cdpUrl: string;
browserFilesystemLocal?: boolean;
targetId?: string;
ref: string;
paths: string[];
@@ -274,6 +288,7 @@ export async function uploadViaPlaywright(
fileChooser: chooser,
paths: uploadPathsResult.paths,
timeoutMs: Math.max(1, deadline - Date.now()),
browserFilesystemLocal: opts.browserFilesystemLocal,
ssrfPolicy: opts.ssrfPolicy,
browserProxyMode: opts.browserProxyMode,
});
@@ -1,3 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { detectMime } from "openclaw/plugin-sdk/media-mime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { FileChooser, Page } from "playwright-core";
import { ACT_MAX_WAIT_TIME_MS, resolveActWaitTimeoutMs } from "./act-policy.js";
@@ -32,6 +35,43 @@ import {
type RawAnnotationInput,
} from "./screenshot-annotate.js";
const DEFAULT_UPLOAD_MIME_TYPE = "application/octet-stream";
const PLAYWRIGHT_FILE_PAYLOAD_SIZE_LIMIT_BYTES = 50 * 1024 * 1024;
type PlaywrightFilePayload = {
name: string;
mimeType: string;
buffer: Buffer;
lastModifiedMs?: number;
};
async function toPlaywrightFilePayloads(paths: string[]): Promise<PlaywrightFilePayload[]> {
const stats = await Promise.all(paths.map(async (filePath) => await fs.stat(filePath)));
const totalSize = stats.reduce((size, stat) => size + stat.size, 0);
if (totalSize >= PLAYWRIGHT_FILE_PAYLOAD_SIZE_LIMIT_BYTES) {
throw new Error(
"Cannot set buffer larger than 50Mb, please write it to a file and pass its path instead.",
);
}
return await Promise.all(
paths.map(async (filePath, index) => {
const buffer = await fs.readFile(filePath);
return {
name: path.basename(filePath),
mimeType: (await detectMime({ buffer, filePath })) ?? DEFAULT_UPLOAD_MIME_TYPE,
buffer,
lastModifiedMs: stats[index]?.mtimeMs,
};
}),
);
}
function shouldUsePlaywrightFilePayloads(
opts: Pick<NavigationTargetOptions, "browserFilesystemLocal" | "ssrfPolicy">,
): boolean {
return Boolean(opts.ssrfPolicy) && opts.browserFilesystemLocal !== true;
}
type BrowserWaitPredicateState = {
document: unknown;
pending?: boolean;
@@ -402,9 +442,18 @@ export async function setFileChooserFilesViaPlaywright(
timeoutMs: number;
},
): Promise<void> {
const resolvedResult = await resolveStrictExistingUploadPaths({ requestedPaths: opts.paths });
if (!resolvedResult.ok) {
throw new Error(resolvedResult.error);
}
const resolvedPaths = resolvedResult.paths;
const resolvedFiles = shouldUsePlaywrightFilePayloads(opts)
? await toPlaywrightFilePayloads(resolvedPaths)
: resolvedPaths;
await awaitNavigationGuardedInteraction({
action: async () => {
await opts.fileChooser.setFiles(opts.paths, { timeout: opts.timeoutMs });
await opts.fileChooser.setFiles(resolvedFiles, { timeout: opts.timeoutMs });
},
cdpUrl: opts.cdpUrl,
page: opts.page,
@@ -441,11 +490,14 @@ export async function setInputFilesViaPlaywright(
throw new Error(resolvedResult.error);
}
const resolvedPaths = resolvedResult.paths;
const resolvedFiles = shouldUsePlaywrightFilePayloads(opts)
? await toPlaywrightFilePayloads(resolvedPaths)
: resolvedPaths;
try {
await awaitNavigationGuardedInteraction({
action: async () => {
await locator.setInputFiles(resolvedPaths);
await locator.setInputFiles(resolvedFiles);
},
cdpUrl: opts.cdpUrl,
page,
@@ -22,6 +22,7 @@ import { toAIFriendlyError } from "./pw-tools-core.shared.js";
export type InteractionTargetOptions = {
cdpUrl: string;
browserFilesystemLocal?: boolean;
targetId?: string;
};
@@ -1,6 +1,10 @@
// Browser tests cover pw tools core.interactions.set input files plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
const readFile = vi.fn();
const stat = vi.fn();
const detectMime = vi.fn();
let page: Record<string, unknown> | null = null;
let locator: Record<string, unknown> | null = null;
@@ -58,7 +62,19 @@ vi.mock("./paths.js", () => {
};
});
const { setInputFilesViaPlaywright } = await import("./pw-tools-core.interactions.js");
vi.mock("node:fs/promises", () => ({
default: {
readFile,
stat,
},
}));
vi.mock("openclaw/plugin-sdk/media-mime", () => ({
detectMime,
}));
const { setFileChooserFilesViaPlaywright, setInputFilesViaPlaywright } =
await import("./pw-tools-core.interactions.js");
function seedSingleLocatorPage(): {
setInputFiles: ReturnType<typeof vi.fn>;
@@ -79,11 +95,81 @@ function seedSingleLocatorPage(): {
return { setInputFiles, elementHandle };
}
describe("setFileChooserFilesViaPlaywright", () => {
beforeEach(() => {
vi.clearAllMocks();
page = {
url: vi.fn(() => "https://allowed.example/form"),
};
locator = null;
readFile.mockResolvedValue(Buffer.from("upload contents"));
stat.mockResolvedValue({ size: Buffer.byteLength("upload contents"), mtimeMs: 1700000000000 });
detectMime.mockResolvedValue("text/plain");
resolveStrictExistingUploadPaths.mockResolvedValue({
ok: true,
paths: ["/private/tmp/openclaw/uploads/ok.txt"],
});
});
it("keeps chooser path handoff for unguarded local sessions", async () => {
const fileChooser = { setFiles: vi.fn(async () => {}) };
await setFileChooserFilesViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
page: page as never,
fileChooser: fileChooser as never,
paths: ["/tmp/openclaw/uploads/ok.txt"],
timeoutMs: 250,
});
expect(resolveStrictExistingUploadPaths).toHaveBeenCalledWith({
requestedPaths: ["/tmp/openclaw/uploads/ok.txt"],
});
expect(stat).not.toHaveBeenCalled();
expect(readFile).not.toHaveBeenCalled();
expect(fileChooser.setFiles).toHaveBeenCalledWith(["/private/tmp/openclaw/uploads/ok.txt"], {
timeout: 250,
});
});
it("converts guarded chooser uploads to payloads before Playwright path handoff", async () => {
const fileChooser = { setFiles: vi.fn(async () => {}) };
await setFileChooserFilesViaPlaywright({
cdpUrl: "https://browser.example/cdp",
targetId: "T1",
page: page as never,
fileChooser: fileChooser as never,
paths: ["/tmp/openclaw/uploads/ok.txt"],
timeoutMs: 250,
ssrfPolicy: {},
});
expect(stat).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt");
expect(readFile).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt");
expect(fileChooser.setFiles).toHaveBeenCalledWith(
[
{
name: "ok.txt",
mimeType: "text/plain",
buffer: Buffer.from("upload contents"),
lastModifiedMs: 1700000000000,
},
],
{ timeout: 250 },
);
});
});
describe("setInputFilesViaPlaywright", () => {
beforeEach(() => {
vi.clearAllMocks();
page = null;
locator = null;
readFile.mockResolvedValue(Buffer.from("upload contents"));
stat.mockResolvedValue({ size: Buffer.byteLength("upload contents"), mtimeMs: 1700000000000 });
detectMime.mockResolvedValue("text/plain");
resolveStrictExistingUploadPaths.mockResolvedValue({
ok: true,
paths: ["/private/tmp/openclaw/uploads/ok.txt"],
@@ -104,27 +190,182 @@ describe("setInputFilesViaPlaywright", () => {
requestedPaths: ["/tmp/openclaw/uploads/ok.txt"],
});
expect(refLocator).toHaveBeenCalledWith(page, "e7");
expect(stat).not.toHaveBeenCalled();
expect(readFile).not.toHaveBeenCalled();
expect(detectMime).not.toHaveBeenCalled();
expect(setInputFiles).toHaveBeenCalledWith(["/private/tmp/openclaw/uploads/ok.txt"]);
expect(setInputFiles).toHaveBeenCalledTimes(1);
expect(elementHandle).not.toHaveBeenCalled();
});
it("keeps assignment-triggered navigation inside the browser policy guard", async () => {
it("converts guarded remote uploads to payloads before Playwright path handoff", async () => {
const { setInputFiles, elementHandle } = seedSingleLocatorPage();
await setInputFilesViaPlaywright({
cdpUrl: "https://browser.example/cdp",
targetId: "T1",
inputRef: "e7",
paths: ["/tmp/openclaw/uploads/ok.txt"],
ssrfPolicy: {},
});
expect(stat).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt");
expect(readFile).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt");
expect(detectMime).toHaveBeenCalledWith({
buffer: Buffer.from("upload contents"),
filePath: "/private/tmp/openclaw/uploads/ok.txt",
});
expect(setInputFiles).toHaveBeenCalledWith([
{
name: "ok.txt",
mimeType: "text/plain",
buffer: Buffer.from("upload contents"),
lastModifiedMs: 1700000000000,
},
]);
expect(setInputFiles).toHaveBeenCalledTimes(1);
expect(elementHandle).not.toHaveBeenCalled();
});
it("falls back to an octet-stream payload when mime detection has no answer", async () => {
detectMime.mockResolvedValueOnce(undefined);
const { setInputFiles } = seedSingleLocatorPage();
await setInputFilesViaPlaywright({
cdpUrl: "https://browser.example/cdp",
targetId: "T1",
inputRef: "e7",
paths: ["/tmp/openclaw/uploads/ok.txt"],
ssrfPolicy: {},
});
expect(setInputFiles).toHaveBeenCalledWith([
{
name: "ok.txt",
mimeType: "application/octet-stream",
buffer: Buffer.from("upload contents"),
lastModifiedMs: 1700000000000,
},
]);
});
it("checks the Playwright aggregate payload size cap before reading guarded remote upload files", async () => {
stat.mockResolvedValueOnce({ size: 50 * 1024 * 1024 });
const { setInputFiles } = seedSingleLocatorPage();
await expect(
setInputFilesViaPlaywright({
cdpUrl: "https://browser.example/cdp",
targetId: "T1",
inputRef: "e7",
paths: ["/tmp/openclaw/uploads/too-large.bin"],
ssrfPolicy: {},
}),
).rejects.toThrow("Cannot set buffer larger than 50Mb");
expect(readFile).not.toHaveBeenCalled();
expect(setInputFiles).not.toHaveBeenCalled();
});
it("allows a guarded remote upload below the aggregate payload cap", async () => {
stat.mockResolvedValueOnce({ size: 50 * 1024 * 1024 - 1, mtimeMs: 1700000000000 });
const { setInputFiles } = seedSingleLocatorPage();
await setInputFilesViaPlaywright({
cdpUrl: "https://browser.example/cdp",
targetId: "T1",
inputRef: "e7",
paths: ["/tmp/openclaw/uploads/limit.bin"],
ssrfPolicy: {},
});
expect(readFile).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt");
expect(setInputFiles).toHaveBeenCalledWith([
{
name: "ok.txt",
mimeType: "text/plain",
buffer: Buffer.from("upload contents"),
lastModifiedMs: 1700000000000,
},
]);
});
it("checks the aggregate cap across multiple guarded remote upload payloads", async () => {
stat
.mockResolvedValueOnce({ size: 30 * 1024 * 1024, mtimeMs: 1700000000000 })
.mockResolvedValueOnce({ size: 30 * 1024 * 1024, mtimeMs: 1700000001000 });
resolveStrictExistingUploadPaths.mockResolvedValueOnce({
ok: true,
paths: ["/private/tmp/openclaw/uploads/one.txt", "/private/tmp/openclaw/uploads/two.txt"],
});
const { setInputFiles } = seedSingleLocatorPage();
await expect(
setInputFilesViaPlaywright({
cdpUrl: "https://browser.example/cdp",
targetId: "T1",
inputRef: "e7",
paths: ["/tmp/openclaw/uploads/one.txt", "/tmp/openclaw/uploads/two.txt"],
ssrfPolicy: {},
}),
).rejects.toThrow("Cannot set buffer larger than 50Mb");
expect(readFile).not.toHaveBeenCalled();
expect(setInputFiles).not.toHaveBeenCalled();
});
it("keeps guarded loopback uploads as path handoffs inside the browser policy guard", async () => {
const { setInputFiles } = seedSingleLocatorPage();
await setInputFilesViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
browserFilesystemLocal: true,
targetId: "T1",
inputRef: "e7",
paths: ["/tmp/openclaw/uploads/ok.txt"],
ssrfPolicy: { dangerouslyAllowPrivateNetwork: true },
});
expect(stat).not.toHaveBeenCalled();
expect(readFile).not.toHaveBeenCalled();
expect(detectMime).not.toHaveBeenCalled();
expect(setInputFiles).toHaveBeenCalledWith(["/private/tmp/openclaw/uploads/ok.txt"]);
expect(withPageNavigationRequestGuard).toHaveBeenCalledTimes(1);
expect(setInputFiles).toHaveBeenCalledTimes(1);
expect(assertPageNavigationCompletedSafely).toHaveBeenCalledTimes(1);
});
it("converts guarded loopback uploads to payloads when the browser filesystem is remote", async () => {
const { setInputFiles, elementHandle } = seedSingleLocatorPage();
await setInputFilesViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
browserFilesystemLocal: false,
targetId: "T1",
inputRef: "e7",
paths: ["/tmp/openclaw/uploads/ok.txt"],
ssrfPolicy: { dangerouslyAllowPrivateNetwork: true },
});
expect(stat).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt");
expect(readFile).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt");
expect(detectMime).toHaveBeenCalledWith({
buffer: Buffer.from("upload contents"),
filePath: "/private/tmp/openclaw/uploads/ok.txt",
});
expect(setInputFiles).toHaveBeenCalledWith([
{
name: "ok.txt",
mimeType: "text/plain",
buffer: Buffer.from("upload contents"),
lastModifiedMs: 1700000000000,
},
]);
expect(withPageNavigationRequestGuard).toHaveBeenCalledTimes(1);
expect(setInputFiles).toHaveBeenCalledTimes(1);
expect(elementHandle).not.toHaveBeenCalled();
});
it("throws and skips setInputFiles when use-time validation fails", async () => {
resolveStrictExistingUploadPaths.mockResolvedValueOnce({
ok: false,
@@ -108,9 +108,10 @@ describe("armFileUploadViaPlaywright upload path validation", () => {
await Promise.resolve();
await vi.waitFor(() => {
expect(fileChooser.setFiles).toHaveBeenCalledWith([
"/home/user/.openclaw/media/inbound/report.pdf",
]);
expect(fileChooser.setFiles).toHaveBeenCalledWith(
["/home/user/.openclaw/media/inbound/report.pdf"],
{ timeout: expect.any(Number) },
);
});
expect(fileChooser.setFiles).toHaveBeenCalledTimes(1);
expect(fileChooser.element).not.toHaveBeenCalled();
@@ -14,7 +14,9 @@ const tmpDirMocks = vi.hoisted(() => ({
resolvePreferredOpenClawTmpDir: vi.fn(() => "/tmp/openclaw"),
}));
const chromeMocks = vi.hoisted(() => ({
getChromeWebSocketUrl: vi.fn(async () => "ws://127.0.0.1/devtools/browser/mock"),
getChromeWebSocketEndpoint: vi.fn(async () => ({
url: "ws://127.0.0.1/devtools/browser/mock",
})),
}));
const clientFetchMocks = vi.hoisted(() => ({
resolveBrowserRateLimitMessage: vi.fn(() => undefined),
@@ -36,25 +36,33 @@ vi.mock("../pw-ai-module.js", () => ({
const { registerBrowserAgentActHookRoutes } = await import("./agent.act.hooks.js");
function createProfileContext() {
function createProfileContext(options?: {
attachOnly?: boolean;
driver?: "openclaw" | "extension";
tabUrl?: string;
}) {
return {
profile: {
attachOnly: options?.attachOnly ?? false,
cdpIsLoopback: true,
cdpUrl: "http://127.0.0.1:9222",
driver: "openclaw" as const,
driver: options?.driver ?? ("openclaw" as const),
name: "default",
},
ensureTabAvailable: vi.fn(async () => ({
targetId: "tab-1",
title: "Internal Admin",
url: "http://127.0.0.1:8080/admin",
url: options?.tabUrl ?? "http://127.0.0.1:8080/admin",
type: "page",
})),
listTabs: vi.fn(async () => []),
};
}
function createRouteContext(profileCtx: ReturnType<typeof createProfileContext>) {
function createRouteContext(
profileCtx: ReturnType<typeof createProfileContext>,
options?: { allowPrivateNetwork?: boolean },
) {
return {
forProfile: () => profileCtx,
mapTabError: vi.fn(toBrowserErrorResponse),
@@ -62,7 +70,9 @@ function createRouteContext(profileCtx: ReturnType<typeof createProfileContext>)
resolved: {
actionTimeoutMs: 60_000,
extraArgs: [],
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: options?.allowPrivateNetwork === true,
},
},
}),
};
@@ -72,9 +82,15 @@ async function callHook(params: {
path: "/hooks/file-chooser" | "/hooks/dialog";
body: Record<string, unknown>;
profileCtx: ReturnType<typeof createProfileContext>;
allowPrivateNetwork?: boolean;
}) {
const { app, postHandlers } = createBrowserRouteApp();
registerBrowserAgentActHookRoutes(app, createRouteContext(params.profileCtx) as never);
registerBrowserAgentActHookRoutes(
app,
createRouteContext(params.profileCtx, {
allowPrivateNetwork: params.allowPrivateNetwork,
}) as never,
);
const handler = postHandlers.get(params.path);
expect(handler).toBeTypeOf("function");
@@ -144,4 +160,52 @@ describe("agent act hook current URL guard", () => {
}
},
);
it("keeps file chooser path handoff local for extension-backed profiles", async () => {
const profileCtx = createProfileContext({
driver: "extension",
tabUrl: "http://127.0.0.1:8080/upload",
});
const response = await callHook({
path: "/hooks/file-chooser",
body: { paths: ["/tmp/upload.txt"], ref: "upload-button" },
profileCtx,
allowPrivateNetwork: true,
});
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ ok: true });
expect(pwMocks.uploadViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
browserFilesystemLocal: true,
ref: "upload-button",
paths: ["/tmp/upload.txt"],
}),
);
});
it("sends loopback attach-only uploads as payloads for a separate browser filesystem", async () => {
const profileCtx = createProfileContext({
attachOnly: true,
tabUrl: "http://127.0.0.1:8080/upload",
});
const response = await callHook({
path: "/hooks/file-chooser",
body: { paths: ["/tmp/upload.txt"], ref: "upload-button" },
profileCtx,
allowPrivateNetwork: true,
});
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ ok: true });
expect(pwMocks.uploadViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
browserFilesystemLocal: false,
ref: "upload-button",
paths: ["/tmp/upload.txt"],
}),
);
});
});
@@ -55,8 +55,9 @@ export function registerBrowserAgentActHookRoutes(
return;
}
const resolvedPaths = resolvedResult.paths;
const capabilities = getBrowserProfileCapabilities(profileCtx.profile);
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (capabilities.usesChromeMcp) {
if (element) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadElement);
}
@@ -84,12 +85,14 @@ export function registerBrowserAgentActHookRoutes(
return;
}
const browserFilesystemLocal = capabilities.browserFilesystemLocal;
if (inputRef || element) {
if (ref) {
return jsonError(res, 400, "ref cannot be combined with inputRef/element");
}
await pw.setInputFilesViaPlaywright({
cdpUrl,
browserFilesystemLocal,
targetId: tab.targetId,
inputRef,
element,
@@ -99,6 +102,7 @@ export function registerBrowserAgentActHookRoutes(
} else if (ref) {
await pw.uploadViaPlaywright({
cdpUrl,
browserFilesystemLocal,
targetId: tab.targetId,
paths: resolvedPaths,
timeoutMs: timeoutMs ?? undefined,
@@ -109,9 +113,11 @@ export function registerBrowserAgentActHookRoutes(
} else {
await pw.armFileUploadViaPlaywright({
cdpUrl,
browserFilesystemLocal,
targetId: tab.targetId,
paths: resolvedPaths,
timeoutMs: timeoutMs ?? undefined,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
}
res.json({ ok: true });
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
import type { BrowserRequest } from "./types.js";
const tabLookup = vi.hoisted(() => vi.fn());
const routeState = vi.hoisted(() => ({
profileCtx: {
profile: {
@@ -15,12 +17,13 @@ const routeState = vi.hoisted(() => ({
targetId: "7",
url: "http://127.0.0.1:8080/admin",
wsUrl: "ws://127.0.0.1/devtools/page/7",
wsLookup: tabLookup,
})),
},
}));
const cdpMocks = vi.hoisted(() => ({
getMainFrameDocumentIdentityViaCdp: vi.fn<() => Promise<string | undefined>>(
getMainFrameDocumentIdentityViaCdp: vi.fn<(_opts?: unknown) => Promise<string | undefined>>(
async () => "cdp:test-document",
),
snapshotAria: vi.fn(async () => ({
@@ -122,6 +125,7 @@ describe("local-managed browser snapshot routes", () => {
cdpMocks.getMainFrameDocumentIdentityViaCdp.mockReset().mockResolvedValue("cdp:test-document");
cdpMocks.snapshotAria.mockClear();
cdpMocks.snapshotRoleViaCdp.mockClear();
tabLookup.mockClear();
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockClear();
navigationGuardMocks.withBrowserNavigationPolicy.mockClear();
});
@@ -193,6 +197,22 @@ describe("local-managed browser snapshot routes", () => {
});
});
it("uses the tab lookup pin when reading delta document identity via CDP", async () => {
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockResolvedValue(undefined);
const handler = getSnapshotGetHandler();
const response = createBrowserRouteResponse();
await handler?.({ params: {}, query: { format: "ai", interactive: "true" } }, response.res);
expect(response.statusCode).toBe(200);
expect(cdpMocks.getMainFrameDocumentIdentityViaCdp).toHaveBeenCalledWith(
expect.objectContaining({
wsUrl: "ws://127.0.0.1/devtools/page/7",
lookup: tabLookup,
}),
);
});
it("disables deltas when no stable document identity is available", async () => {
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockResolvedValue(undefined);
cdpMocks.getMainFrameDocumentIdentityViaCdp.mockResolvedValue(undefined);
@@ -12,6 +12,7 @@ const cdpMocks = vi.hoisted(() => ({
stats: { lines: 1, chars: 15, refs: 0, interactive: 0 },
})),
}));
const tabLookup = vi.hoisted(() => vi.fn());
const profileContext = vi.hoisted(() => ({
profile: {
@@ -29,6 +30,7 @@ const profileContext = vi.hoisted(() => ({
targetId: "tab-1",
url: "https://example.com",
wsUrl: "ws://127.0.0.1:18800/devtools/page/tab-1",
wsLookup: tabLookup,
})),
}));
@@ -80,7 +82,7 @@ vi.mock("./agent.shared.js", () => ({
async (params: {
run: (ctx: {
profileCtx: typeof profileContext;
tab: { targetId: string; url: string; wsUrl: string };
tab: { targetId: string; url: string; wsUrl: string; wsLookup: typeof tabLookup };
cdpUrl: string;
}) => Promise<void>;
}) =>
@@ -90,6 +92,7 @@ vi.mock("./agent.shared.js", () => ({
targetId: "tab-1",
url: "https://example.com",
wsUrl: "ws://127.0.0.1:18800/devtools/page/tab-1",
wsLookup: tabLookup,
},
cdpUrl: "http://127.0.0.1:18800",
}),
@@ -136,6 +139,7 @@ describe("browser agent snapshot timeout routing", () => {
expect(cdpMocks.snapshotAria).toHaveBeenCalledWith(
expect.objectContaining({
wsUrl: "ws://127.0.0.1:18800/devtools/page/tab-1",
lookup: tabLookup,
timeoutMs: 4321,
}),
);
@@ -151,6 +155,7 @@ describe("browser agent snapshot timeout routing", () => {
expect(cdpMocks.snapshotRoleViaCdp).toHaveBeenCalledWith(
expect.objectContaining({
wsUrl: "ws://127.0.0.1:18800/devtools/page/tab-1",
lookup: tabLookup,
timeoutMs: 9876,
}),
);
@@ -169,6 +174,7 @@ describe("browser agent snapshot timeout routing", () => {
expect(response.statusCode).toBe(200);
expect(cdpMocks.captureScreenshot).toHaveBeenCalledWith(
expect.objectContaining({
lookup: tabLookup,
timeoutMs: 2_147_483_647,
}),
);
@@ -569,6 +569,7 @@ export function registerBrowserAgentSnapshotRoutes(
} else {
buffer = await captureScreenshot({
wsUrl: tab.wsUrl ?? "",
...(tab.wsLookup ? { lookup: tab.wsLookup } : {}),
fullPage,
format: type,
quality: type === "jpeg" ? 85 : undefined,
@@ -807,6 +808,7 @@ export function registerBrowserAgentSnapshotRoutes(
}
return await getMainFrameDocumentIdentityViaCdp({
wsUrl: tab.wsUrl,
...(tab.wsLookup ? { lookup: tab.wsLookup } : {}),
timeoutMs: plan.timeoutMs,
}).catch(() => undefined);
};
@@ -851,6 +853,7 @@ export function registerBrowserAgentSnapshotRoutes(
}
return await snapshotRoleViaCdp({
wsUrl: tab.wsUrl,
...(tab.wsLookup ? { lookup: tab.wsLookup } : {}),
urls: plan.urls,
timeoutMs: plan.timeoutMs,
maxChars: plan.resolvedMaxChars,
@@ -979,6 +982,7 @@ export function registerBrowserAgentSnapshotRoutes(
})()
: snapshotAria({
wsUrl: tab.wsUrl ?? "",
...(tab.wsLookup ? { lookup: tab.wsLookup } : {}),
limit: plan.limit,
timeoutMs: plan.timeoutMs,
});
@@ -288,7 +288,11 @@ async function runBrowserLiveProbe(profileCtx: ProfileContext, signal: AbortSign
summary: "No per-tab CDP WebSocket available for the lightweight live snapshot probe",
};
}
const snap = await snapshotAria({ wsUrl: tab.wsUrl, limit: 25 });
const snap = await snapshotAria({
wsUrl: tab.wsUrl,
...(tab.wsLookup ? { lookup: tab.wsLookup } : {}),
limit: 25,
});
return {
id: "live-snapshot",
label: "Live snapshot",
@@ -4,7 +4,9 @@ import { BROWSER_ERROR_REASONS, BrowserProfileUnavailableError } from "../errors
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
const cdpMocks = vi.hoisted(() => ({
getChromeWebSocketUrl: vi.fn(async () => "ws://127.0.0.1:18800/devtools/browser/test"),
getChromeWebSocketEndpoint: vi.fn(async () => ({
url: "ws://127.0.0.1:18800/devtools/browser/test",
})),
send: vi.fn(
async (
_method: string,
@@ -32,7 +34,7 @@ const pwMocks = vi.hoisted(() => ({
}));
vi.mock("../chrome.js", () => ({
getChromeWebSocketUrl: cdpMocks.getChromeWebSocketUrl,
getChromeWebSocketEndpoint: cdpMocks.getChromeWebSocketEndpoint,
}));
vi.mock("../cdp.helpers.js", () => ({
@@ -107,7 +109,7 @@ async function callGrant(
describe("browser permission routes", () => {
beforeEach(() => {
cdpMocks.getChromeWebSocketUrl.mockClear();
cdpMocks.getChromeWebSocketEndpoint.mockClear();
cdpMocks.send.mockReset().mockResolvedValue({});
cdpMocks.withCdpSocket.mockClear();
pwMocks.getPwAiModule.mockReset().mockResolvedValue(null);
@@ -163,7 +165,7 @@ describe("browser permission routes", () => {
grantMethod: "cdp",
});
expect(profileCtx.ensureBrowserAvailable).toHaveBeenCalled();
expect(cdpMocks.getChromeWebSocketUrl).toHaveBeenCalledWith(
expect(cdpMocks.getChromeWebSocketEndpoint).toHaveBeenCalledWith(
"http://127.0.0.1:18800",
1234,
undefined,
@@ -171,7 +173,7 @@ describe("browser permission routes", () => {
expect(cdpMocks.withCdpSocket).toHaveBeenCalledWith(
"ws://127.0.0.1:18800/devtools/browser/test",
expect.any(Function),
{ commandTimeoutMs: 1234, signal: expect.any(AbortSignal) },
{ commandTimeoutMs: 1234, lookup: undefined, signal: expect.any(AbortSignal) },
);
expect(cdpMocks.send).toHaveBeenCalledWith("Browser.grantPermissions", {
origin: "https://meet.google.com",
@@ -217,7 +219,7 @@ describe("browser permission routes", () => {
displayPresent: false,
},
});
expect(cdpMocks.getChromeWebSocketUrl).not.toHaveBeenCalled();
expect(cdpMocks.getChromeWebSocketEndpoint).not.toHaveBeenCalled();
});
it("rejects loose timeoutMs values before granting permissions", async () => {
@@ -230,7 +232,7 @@ describe("browser permission routes", () => {
expect(response.statusCode).toBe(400);
expect(response.body).toStrictEqual({ error: "timeoutMs must be a positive integer." });
expect(profileCtx.ensureBrowserAvailable).not.toHaveBeenCalled();
expect(cdpMocks.getChromeWebSocketUrl).not.toHaveBeenCalled();
expect(cdpMocks.getChromeWebSocketEndpoint).not.toHaveBeenCalled();
expect(cdpMocks.send).not.toHaveBeenCalled();
});
@@ -242,7 +244,7 @@ describe("browser permission routes", () => {
});
expect(response.statusCode).toBe(200);
expect(cdpMocks.getChromeWebSocketUrl).toHaveBeenCalledWith(
expect(cdpMocks.getChromeWebSocketEndpoint).toHaveBeenCalledWith(
"http://127.0.0.1:18800",
1000,
undefined,
@@ -270,7 +272,7 @@ describe("browser permission routes", () => {
);
expect(response.statusCode).toBe(200);
expect(cdpMocks.getChromeWebSocketUrl).toHaveBeenCalledWith(
expect(cdpMocks.getChromeWebSocketEndpoint).toHaveBeenCalledWith(
"https://browser.example:9222",
5000,
{
@@ -9,7 +9,7 @@ import { formatErrorMessage } from "../../infra/errors.js";
import type { SsrFPolicy } from "../../infra/net/ssrf.js";
import { resolveCdpControlPolicy } from "../cdp-reachability-policy.js";
import { withCdpSocket } from "../cdp.helpers.js";
import { getChromeWebSocketUrl } from "../chrome.js";
import { getChromeWebSocketEndpoint, type ChromeWebSocketEndpoint } from "../chrome.js";
import { BrowserProfileUnavailableError, toBrowserErrorResponse } from "../errors.js";
import { getPwAiModule } from "../pw-ai-module.js";
import type { BrowserRouteContext } from "../server-context.js";
@@ -55,6 +55,7 @@ async function grantPermissions(params: {
requiredPermissions: string[];
optionalPermissions: string[];
timeoutMs: number;
wsLookup?: ChromeWebSocketEndpoint["lookup"];
ssrfPolicy?: SsrFPolicy;
signal: AbortSignal;
}) {
@@ -112,7 +113,7 @@ async function grantPermissions(params: {
});
unsupportedPermissions = params.optionalPermissions;
},
{ commandTimeoutMs: params.timeoutMs, signal: params.signal },
{ commandTimeoutMs: params.timeoutMs, lookup: params.wsLookup, signal: params.signal },
);
params.signal.throwIfAborted();
return {
@@ -172,19 +173,20 @@ export function registerBrowserPermissionRoutes(
profileCtx.profile,
ctx.state().resolved.ssrfPolicy,
);
const wsUrl = await getChromeWebSocketUrl(
const endpoint = await getChromeWebSocketEndpoint(
profileCtx.profile.cdpUrl,
timeoutMs,
cdpPolicy,
);
signal.throwIfAborted();
if (!wsUrl) {
if (!endpoint) {
throw new BrowserProfileUnavailableError("browser CDP WebSocket unavailable");
}
return await grantPermissions({
profileCtx,
targetId,
wsUrl,
wsUrl: endpoint.url,
wsLookup: endpoint.lookup,
origin,
requiredPermissions,
optionalPermissions,
@@ -3,7 +3,10 @@
* launch/restart, Chrome MCP attach, and profile stop handling.
*/
import fs from "node:fs";
import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js";
import {
assertChromeMcpCdpTransportAllowed,
resolveCdpReachabilityPolicy,
} from "./cdp-reachability-policy.js";
import {
CHROME_MCP_ATTACH_READY_POLL_MS,
CHROME_MCP_ATTACH_READY_WINDOW_MS,
@@ -190,6 +193,7 @@ export function createProfileAvailability({
// countChromeMcpTabs creates the session if needed — no separate availability call required.
// Status probes opt into ephemeral so they reuse a cached attach session if one exists,
// but do not seed a new persistent session as a side effect of read-only status calls.
assertChromeMcpCdpTransportAllowed(profile, getCdpReachabilityPolicy());
const { countChromeMcpTabs } = await getChromeMcpModule();
const callOptions: { timeoutMs?: number; ephemeral?: boolean; signal?: AbortSignal } = {};
if (timeoutMs != null) {
@@ -215,6 +219,7 @@ export function createProfileAvailability({
const isTransportAvailable = async (timeoutMs?: number, signal?: AbortSignal) => {
if (capabilities.usesChromeMcp) {
assertChromeMcpCdpTransportAllowed(profile, getCdpReachabilityPolicy());
const { ensureChromeMcpAvailable } = await getChromeMcpModule();
await ensureChromeMcpAvailable(profile.name, profile, {
ephemeral: true,
@@ -437,6 +442,7 @@ export function createProfileAvailability({
`Browser user data directory not found for profile "${profile.name}": ${profile.userDataDir}`,
);
}
assertChromeMcpCdpTransportAllowed(profile, getCdpReachabilityPolicy());
const { ensureChromeMcpAvailable } = await getChromeMcpModule();
await ensureChromeMcpAvailable(profile.name, profile, { signal });
await waitForChromeMcpReadyAfterAttach(signal);
@@ -37,6 +37,7 @@ type ChromeLiveProfile = {
name?: string;
cdpUrl?: string;
userDataDir?: string;
mcpArgs?: string[];
};
function deferred<T>() {
@@ -128,6 +129,50 @@ afterEach(() => {
});
describe("browser server-context existing-session profile", () => {
it("fails closed for Chrome MCP endpoint mcpArgs under the default CDP policy", async () => {
fs.mkdirSync("/tmp/brave-profile", { recursive: true });
const state = makeState();
state.resolved.ssrfPolicy = {};
state.resolved.profiles["chrome-live"] = {
...state.resolved.profiles["chrome-live"],
mcpArgs: ["--browserUrl", "http://127.0.0.1:9222"],
};
const live = createBrowserRouteContext({ getState: () => state }).forProfile("chrome-live");
await expect(live.listTabs()).rejects.toThrow(/Chrome MCP cannot carry that pinned transport/);
await expect(live.openTab("https://example.com")).rejects.toThrow(
/remove cdpUrl and browserUrl\/wsEndpoint mcpArgs/,
);
await expect(live.ensureBrowserAvailable()).rejects.toThrow(/host-local Chrome profile/);
expect(chromeMcp.listChromeMcpTabs).not.toHaveBeenCalled();
expect(chromeMcp.openChromeMcpTab).not.toHaveBeenCalled();
expect(chromeMcp.ensureChromeMcpAvailable).not.toHaveBeenCalled();
});
it("fails closed for explicit Chrome MCP cdpUrl under explicit restrictive CDP policy", async () => {
fs.mkdirSync("/tmp/brave-profile", { recursive: true });
const state = makeState();
state.resolved.ssrfPolicy = { dangerouslyAllowPrivateNetwork: false };
state.resolved.profiles["chrome-live"] = {
...state.resolved.profiles["chrome-live"],
cdpUrl: "http://127.0.0.1:9222",
};
const live = createBrowserRouteContext({ getState: () => state }).forProfile("chrome-live");
await expect(live.listTabs()).rejects.toThrow(/Chrome MCP cannot carry that pinned transport/);
await expect(live.openTab("https://93.184.216.34")).rejects.toThrow(
/Use driver "openclaw" for guarded CDP endpoints/,
);
await expect(live.ensureBrowserAvailable()).rejects.toThrow(
/remove cdpUrl and browserUrl\/wsEndpoint mcpArgs/,
);
expect(chromeMcp.listChromeMcpTabs).not.toHaveBeenCalled();
expect(chromeMcp.openChromeMcpTab).not.toHaveBeenCalled();
expect(chromeMcp.ensureChromeMcpAvailable).not.toHaveBeenCalled();
});
it("reports attach-only profiles as running when the MCP session is available but no page is selected", async () => {
fs.mkdirSync("/tmp/brave-profile", { recursive: true });
const state = makeState();
@@ -176,6 +221,7 @@ describe("browser server-context existing-session profile", () => {
state.resolved.profiles["chrome-live"],
"chrome-live browser profile",
);
state.resolved.ssrfPolicy = undefined;
state.resolved.profiles["chrome-live"] = {
...chromeLiveProfile,
cdpUrl: "http://openclaw:relay-token@127.0.0.1:9222",
@@ -78,6 +78,8 @@ describe("browser remote profile fallback and attachOnly behavior", () => {
const tabs = await remote.listTabs();
expect(tabs.map((t) => t.targetId)).toEqual(["T1"]);
expect(tabs[0]?.wsLookup).toBeTypeOf("function");
expect(JSON.stringify(tabs[0])).not.toContain("wsLookup");
});
it("filters browser-internal and non-page targets from raw CDP tab listing", async () => {
@@ -5,6 +5,7 @@ import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { formatErrorMessage } from "../infra/errors.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { assertChromeMcpCdpTransportAllowed } from "./cdp-reachability-policy.js";
import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
import { appendCdpPath } from "./cdp.js";
import { getChromeMcpModule } from "./chrome-mcp.runtime.js";
@@ -61,7 +62,11 @@ function mergeOpenedTabSnapshot(
return tabs;
}
const merged = tabs.slice();
merged[index] = { ...listedTab, wsUrl: openedTab.wsUrl };
merged[index] = {
...listedTab,
wsUrl: openedTab.wsUrl,
...(openedTab.wsLookup ? { wsLookup: openedTab.wsLookup } : {}),
};
return merged;
}
@@ -249,6 +254,7 @@ export function createProfileSelectionOps({
const resolvedTargetId = await resolveTargetIdOrThrow(targetId, options);
if (capabilities.usesChromeMcp) {
assertChromeMcpCdpTransportAllowed(profile, getCdpControlPolicy());
const { focusChromeMcpTab } = await getChromeMcpModule();
await focusChromeMcpTab(profile.name, resolvedTargetId, profile, options);
runtime.lastTargetId = resolvedTargetId;
@@ -283,6 +289,7 @@ export function createProfileSelectionOps({
const resolvedTargetId = await resolveTargetIdOrThrow(targetId, options);
if (capabilities.usesChromeMcp) {
assertChromeMcpCdpTransportAllowed(profile, getCdpControlPolicy());
const { closeChromeMcpTab } = await getChromeMcpModule();
await closeChromeMcpTab(profile.name, resolvedTargetId, profile, options);
} else {
@@ -3,7 +3,10 @@
*/
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
import { resolveBrowserNavigationProxyMode } from "./browser-proxy-mode.js";
import { resolveCdpControlPolicy } from "./cdp-reachability-policy.js";
import {
assertChromeMcpCdpTransportAllowed,
resolveCdpControlPolicy,
} from "./cdp-reachability-policy.js";
import { isSelectableCdpBrowserTarget } from "./cdp-target-filter.js";
import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js";
import {
@@ -116,6 +119,7 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
const readTabs = async (options?: BrowserOperationOptions): Promise<BrowserTab[]> => {
if (capabilities.usesChromeMcp) {
assertChromeMcpCdpTransportAllowed(profile, getCdpControlPolicy());
const { listChromeMcpTabs } = await getChromeMcpModule();
return await listChromeMcpTabs(profile.name, profile, options);
}
@@ -168,10 +172,13 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
continue;
}
if (tab.wsUrl) {
await assertCdpEndpointAllowed(tab.wsUrl, cdpControlPolicy, {
const wsPin = await assertCdpEndpointAllowed(tab.wsUrl, cdpControlPolicy, {
source: "discovered",
configuredUrl: profile.cdpUrl,
});
if (wsPin?.lookup) {
tab.wsLookup = wsPin.lookup;
}
}
tabs.push(tab);
}
@@ -288,12 +295,14 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
if (capabilities.usesChromeMcp) {
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const cdpPolicy = getCdpControlPolicy();
assertChromeMcpCdpTransportAllowed(profile, cdpPolicy);
const { openChromeMcpTab } = await getChromeMcpModule();
const cdpTimeouts = getRemoteCdpActionTimeouts();
const page = await openChromeMcpTab(profile.name, url, profile, {
signal: opts?.signal,
timeoutMs: opts?.timeoutMs,
cdpPolicy: getCdpControlPolicy(),
cdpPolicy,
...(cdpTimeouts ? { cdpTimeouts } : {}),
});
await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts });
@@ -434,6 +443,12 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
}
await assertBrowserNavigationResultAllowed({ url: resolvedUrl, ...ssrfPolicyOpts });
const wsUrl = normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl);
const wsPin = wsUrl
? await assertCdpEndpointAllowed(wsUrl, getCdpControlPolicy(), {
source: "discovered",
configuredUrl: profile.cdpUrl,
})
: undefined;
const committedUrl = wsUrl
? await waitForCdpCommittedNavigationUrl({
wsUrl,
@@ -452,6 +467,7 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
title: created.title ?? "",
url: resolvedUrl,
wsUrl,
...(wsPin?.lookup ? { wsLookup: wsPin.lookup } : {}),
type: created.type,
},
opts,
@@ -465,6 +481,7 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
title: created.title ?? "",
url: committedUrl,
wsUrl,
...(wsPin?.lookup ? { wsLookup: wsPin.lookup } : {}),
type: created.type,
},
opts,
@@ -0,0 +1,139 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { withBrowserFetchPreconnect } from "../../test-fetch.js";
import "../test-support/browser-security.mock.js";
import "./server-context.chrome-test-harness.js";
import * as cdpHelpersModule from "./cdp.helpers.js";
import * as cdpModule from "./cdp.js";
import {
createTestBrowserRouteContext,
makeState,
originalFetch,
} from "./server-context.remote-tab-ops.harness.js";
afterEach(async () => {
const { closePlaywrightBrowserConnection } = await import("./pw-session.js");
await closePlaywrightBrowserConnection().catch(() => {});
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
function seedRunningProfileState(
state: ReturnType<typeof makeState>,
profileName = "openclaw",
): void {
(state.profiles as Map<string, unknown>).set(profileName, {
profile: { name: profileName },
running: { pid: 1234, proc: { on: vi.fn() } },
lastTargetId: null,
});
}
function fetchCallUrls(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls.map(([url]) => String(url));
}
describe("browser server-context tab selection lookup state", () => {
it("preserves the opened tab lookup when a same-target listing lacks a WebSocket URL", async () => {
vi.spyOn(cdpModule, "createTargetViaCdp").mockRejectedValue(new Error("raw create failed"));
vi.spyOn(cdpModule, "waitForCdpCommittedNavigationUrl").mockResolvedValue(undefined);
let listCalls = 0;
const lookupHosts: string[] = [];
const fetchJson = vi.spyOn(cdpHelpersModule, "fetchJson").mockImplementation(async (url) => {
if (url.includes("/json/list")) {
listCalls += 1;
return listCalls === 1
? []
: [
{
id: "NEW",
title: "Listed",
url: "about:blank",
type: "page",
},
];
}
if (url.includes("/json/new")) {
return {
id: "NEW",
title: "Opened",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1:18800/devtools/page/NEW",
type: "page",
};
}
throw new Error(`unexpected fetchJson: ${url}`);
});
vi.spyOn(cdpHelpersModule, "assertCdpEndpointAllowed").mockImplementation(async () => ({
hostname: "browser.example",
addresses: ["127.0.0.1"],
lookup: ((hostname: string, _options: unknown, callback?: unknown) => {
lookupHosts.push(hostname);
if (typeof callback === "function") {
callback(null, "127.0.0.1", 4);
}
}) as never,
}));
const state = makeState("openclaw");
state.resolved.ssrfPolicy = {};
seedRunningProfileState(state);
const openclaw = createTestBrowserRouteContext({ getState: () => state }).forProfile(
"openclaw",
);
const selected = await openclaw.ensureTabAvailable();
expect(selected).toEqual(
expect.objectContaining({
targetId: "NEW",
title: "Listed",
url: "about:blank",
wsUrl: "ws://127.0.0.1:18800/devtools/page/NEW",
}),
);
expect(selected.wsLookup).toBeTypeOf("function");
selected.wsLookup?.("browser.example", {}, () => {});
expect(lookupHosts).toEqual(["browser.example"]);
expect(fetchJson.mock.calls.some(([url]) => url.includes("/json/new"))).toBe(true);
});
it("resolves friendly tab references before backend focus and close calls", async () => {
const fetchMock = vi.fn(async (url: unknown) => {
const value = String(url);
if (value.includes("/json/list")) {
return {
ok: true,
json: async () => [
{
id: "DOCS_RAW",
title: "Docs",
url: "https://docs.example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/DOCS_RAW",
type: "page",
},
],
} as unknown as Response;
}
if (value.includes("/json/activate/DOCS_RAW") || value.includes("/json/close/DOCS_RAW")) {
return { ok: true } as unknown as Response;
}
throw new Error(`unexpected fetch: ${value}`);
});
global.fetch = withBrowserFetchPreconnect(fetchMock);
const state = makeState("openclaw");
const ctx = createTestBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
await openclaw.labelTab("DOCS_RAW", "docs");
await expect(openclaw.ensureTabAvailable("t1")).resolves.toEqual(
expect.objectContaining({ targetId: "DOCS_RAW" }),
);
await openclaw.focusTab("docs");
await openclaw.closeTab("t1");
expect(fetchCallUrls(fetchMock).some((url) => url.includes("/json/activate/DOCS_RAW"))).toBe(
true,
);
expect(fetchCallUrls(fetchMock).some((url) => url.includes("/json/close/DOCS_RAW"))).toBe(true);
});
});
@@ -984,45 +984,4 @@ describe("browser server-context tab selection state", () => {
}),
]);
});
it("resolves friendly tab references before backend focus and close calls", async () => {
const fetchMock = vi.fn(async (url: unknown) => {
const value = String(url);
if (value.includes("/json/list")) {
return {
ok: true,
json: async () => [
{
id: "DOCS_RAW",
title: "Docs",
url: "https://docs.example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/DOCS_RAW",
type: "page",
},
],
} as unknown as Response;
}
if (value.includes("/json/activate/DOCS_RAW") || value.includes("/json/close/DOCS_RAW")) {
return { ok: true } as unknown as Response;
}
throw new Error(`unexpected fetch: ${value}`);
});
global.fetch = withBrowserFetchPreconnect(fetchMock);
const state = makeState("openclaw");
const ctx = createTestBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
await openclaw.labelTab("DOCS_RAW", "docs");
await expect(openclaw.ensureTabAvailable("t1")).resolves.toEqual(
expect.objectContaining({ targetId: "DOCS_RAW" }),
);
await openclaw.focusTab("docs");
await openclaw.closeTab("t1");
expect(fetchCallUrls(fetchMock).some((url) => url.includes("/json/activate/DOCS_RAW"))).toBe(
true,
);
expect(fetchCallUrls(fetchMock).some((url) => url.includes("/json/close/DOCS_RAW"))).toBe(true);
});
});
@@ -2,6 +2,7 @@
* SSRF policy helpers for Browser routes that need one-off hostname grants.
*/
import { isPrivateNetworkAllowedByPolicy, type SsrFPolicy } from "../infra/net/ssrf.js";
import { matchesHostnameAllowlist, normalizeHostname } from "../sdk-security-runtime.js";
// Exact-host CDP scoping replaces allowedHostnames. Preserve whether the source
// policy allowed authority changes before that synthetic allowlist was added.
@@ -20,6 +21,27 @@ export function allowsDiscoveredCdpAuthorityChange(ssrfPolicy?: SsrFPolicy): boo
);
}
/** Return true when policy already trusts this hostname as a private-network destination. */
export function isCdpHostnameTrustedByPolicy(
ssrfPolicy: SsrFPolicy | undefined,
hostname: string,
): boolean {
const normalizedHostname = normalizeHostname(hostname);
if (!normalizedHostname) {
return false;
}
const allowedHostnames = (ssrfPolicy?.allowedHostnames ?? [])
.map((pattern) => normalizeHostname(pattern))
.filter(Boolean);
if (allowedHostnames.length === 0) {
return isPrivateNetworkAllowedByPolicy(ssrfPolicy);
}
if (allowedHostnames.some((pattern) => pattern === "*" || pattern === "*.")) {
return true;
}
return matchesHostnameAllowlist(normalizedHostname, allowedHostnames);
}
/** Returns an SSRF policy restricted to one exact control-plane hostname. */
export function withExactHostnamePolicy(
ssrfPolicy: SsrFPolicy | undefined,