From 92f5f07da175a97090b82192d71effeab2c9753e Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Wed, 12 Aug 2026 16:16:12 +0530 Subject: [PATCH] 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 --- docs/tools/browser.md | 15 + extensions/browser/src/browser/cdp-auth.ts | 45 ++ .../browser/src/browser/cdp-page-session.ts | 3 +- .../src/browser/cdp-reachability-policy.ts | 69 ++- .../browser/src/browser/cdp-websocket.ts | 419 ++++++++++++++++++ .../src/browser/cdp.helpers.internal.test.ts | 253 ++++++++++- .../browser/src/browser/cdp.helpers.test.ts | 199 ++++++++- extensions/browser/src/browser/cdp.helpers.ts | 354 ++------------- extensions/browser/src/browser/cdp.ts | 21 +- .../src/browser/chrome-mcp-contracts.ts | 12 +- .../browser/src/browser/chrome-mcp.test.ts | 21 + .../browser/src/browser/chrome.diagnostics.ts | 39 +- .../browser/src/browser/chrome.graphics.ts | 25 +- .../src/browser/chrome.internal.test.ts | 8 +- .../chrome.loopback-ssrf.integration.test.ts | 8 +- extensions/browser/src/browser/chrome.test.ts | 47 +- extensions/browser/src/browser/chrome.ts | 51 ++- .../browser/src/browser/client.types.ts | 6 + .../src/browser/playwright-core.runtime.ts | 6 + .../src/browser/profile-capabilities.ts | 8 + .../browser/src/browser/pw-ai.e2e.test.ts | 4 +- .../browser/src/browser/pw-session-actions.ts | 4 +- .../src/browser/pw-session-cdp-transport.ts | 138 ++++++ .../src/browser/pw-session-connection.ts | 56 ++- .../browser/pw-session.connections.test.ts | 41 +- ...ssion.create-page.navigation-guard.test.ts | 9 +- .../pw-session.get-page-for-targetid.test.ts | 14 +- .../src/browser/pw-session.mock-setup.ts | 5 +- .../pw-session.pinned-transport.test.ts | 373 ++++++++++++++++ .../pw-session.termination-cdp-ssrf.test.ts | 72 ++- .../src/browser/pw-tools-core.downloads.ts | 29 +- .../pw-tools-core.interactions.content.ts | 56 ++- .../pw-tools-core.interactions.navigation.ts | 1 + ...-core.interactions.set-input-files.test.ts | 245 +++++++++- .../pw-tools-core.upload-paths.test.ts | 7 +- ...-core.waits-next-download-saves-it.test.ts | 4 +- .../agent.act.hooks.current-url-guard.test.ts | 76 +++- .../src/browser/routes/agent.act.hooks.ts | 8 +- .../agent.snapshot.local-managed.test.ts | 22 +- .../routes/agent.snapshot.timeout.test.ts | 8 +- .../src/browser/routes/agent.snapshot.ts | 4 + .../browser/src/browser/routes/basic.ts | 6 +- .../src/browser/routes/permissions.test.ts | 20 +- .../browser/src/browser/routes/permissions.ts | 12 +- .../browser/server-context.availability.ts | 8 +- .../server-context.existing-session.test.ts | 46 ++ ...xt.remote-profile-tab-ops.fallback.test.ts | 2 + .../src/browser/server-context.selection.ts | 9 +- .../src/browser/server-context.tab-ops.ts | 23 +- ...erver-context.tab-selection-lookup.test.ts | 139 ++++++ ...server-context.tab-selection-state.test.ts | 41 -- .../src/browser/ssrf-policy-helpers.ts | 22 + 52 files changed, 2584 insertions(+), 529 deletions(-) create mode 100644 extensions/browser/src/browser/cdp-auth.ts create mode 100644 extensions/browser/src/browser/cdp-websocket.ts create mode 100644 extensions/browser/src/browser/pw-session-cdp-transport.ts create mode 100644 extensions/browser/src/browser/pw-session.pinned-transport.test.ts create mode 100644 extensions/browser/src/browser/server-context.tab-selection-lookup.test.ts diff --git a/docs/tools/browser.md b/docs/tools/browser.md index af74b2a704a7..a3d6b4141d53 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -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. diff --git a/extensions/browser/src/browser/cdp-auth.ts b/extensions/browser/src/browser/cdp-auth.ts new file mode 100644 index 000000000000..6296e2fe912a --- /dev/null +++ b/extensions/browser/src/browser/cdp-auth.ts @@ -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 = {}) { + 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; + } +} diff --git a/extensions/browser/src/browser/cdp-page-session.ts b/extensions/browser/src/browser/cdp-page-session.ts index 0d2348e53350..8760a8fa0124 100644 --- a/extensions/browser/src/browser/cdp-page-session.ts +++ b/extensions/browser/src/browser/cdp-page-session.ts @@ -143,7 +143,7 @@ export async function waitForCdpCommittedNavigationUrl(opts: { signal?: AbortSignal; timeouts?: CdpActionTimeouts; }): Promise { - 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 { diff --git a/extensions/browser/src/browser/cdp-reachability-policy.ts b/extensions/browser/src/browser/cdp-reachability-policy.ts index 38266d17bf33..54ea8c2a3408 100644 --- a/extensions/browser/src/browser/cdp-reachability-policy.ts +++ b/extensions/browser/src/browser/cdp-reachability-policy.ts @@ -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(); 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.`, + ); +} diff --git a/extensions/browser/src/browser/cdp-websocket.ts b/extensions/browser/src/browser/cdp-websocket.ts new file mode 100644 index 000000000000..a9f382a1368b --- /dev/null +++ b/extensions/browser/src/browser/cdp-websocket.ts @@ -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; +}; + +export type CdpSendFn = ( + method: string, + params?: Record, + sessionId?: string, +) => Promise; + +function withDefaultPlaywrightUserAgent(headers: Record): Record { + 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(); + 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, + sessionId?: string, + ) => { + const id = nextId++; + const msg = { id, method, params, sessionId }; + return new Promise((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; + 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; + 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( + wsUrl: string, + fn: (send: CdpSendFn) => Promise, + opts?: CdpSocketOptions, +): Promise { + 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((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(); + } + } +} diff --git a/extensions/browser/src/browser/cdp.helpers.internal.test.ts b/extensions/browser/src/browser/cdp.helpers.internal.test.ts index 0596a9a6cb2d..b3398faa17c0 100644 --- a/extensions/browser/src/browser/cdp.helpers.internal.test.ts +++ b/extensions/browser/src/browser/cdp.helpers.internal.test.ts @@ -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((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((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((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((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + }); + + ws.close(); + } finally { + await new Promise((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((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((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((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((resolve) => { + setTimeout(resolve, 25); + }); + expect(targetConnections).toBe(0); + } finally { + ws.close(); + await new Promise((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((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + }); + + expect(lookup).toHaveBeenCalled(); + ws.close(); + } finally { + await new Promise((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((resolve) => { + server.wss.close(() => resolve()); + }); + } + }); }); diff --git a/extensions/browser/src/browser/cdp.helpers.test.ts b/extensions/browser/src/browser/cdp.helpers.test.ts index 447a9e028db0..ebd2401951ec 100644 --- a/extensions/browser/src/browser/cdp.helpers.test.ts +++ b/extensions/browser/src/browser/cdp.helpers.test.ts @@ -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("../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("../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); + }); }); diff --git a/extensions/browser/src/browser/cdp.helpers.ts b/extensions/browser/src/browser/cdp.helpers.ts index 5b624822f100..f992ba81a908 100644 --- a/extensions/browser/src/browser/cdp.helpers.ts +++ b/extensions/browser/src/browser/cdp.helpers.ts @@ -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>; 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 { +): Promise { 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; -}; - -export type CdpSendFn = ( - method: string, - params?: Record, - sessionId?: string, -) => Promise; - -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 = {}) { - 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; }; -function createCdpSender(ws: WebSocket, opts?: { commandTimeoutMs?: number }) { - let nextId = 1; - const pending = new Map(); - 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, - sessionId?: string, - ) => { - const id = nextId++; - const msg = { id, method, params, sessionId }; - return new Promise((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( 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; 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; - 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( - wsUrl: string, - fn: (send: CdpSendFn) => Promise, - opts?: CdpSocketOptions, -): Promise { - 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((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(); - } - } -} diff --git a/extensions/browser/src/browser/cdp.ts b/extensions/browser/src/browser/cdp.ts index bdef8c5be6be..1f245e303139 100644 --- a/extensions/browser/src/browser/cdp.ts +++ b/extensions/browser/src/browser/cdp.ts @@ -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 { 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. */ diff --git a/extensions/browser/src/browser/chrome-mcp-contracts.ts b/extensions/browser/src/browser/chrome-mcp-contracts.ts index 21860a0634bc..b1c9ae519886 100644 --- a/extensions/browser/src/browser/chrome-mcp-contracts.ts +++ b/extensions/browser/src/browser/chrome-mcp-contracts.ts @@ -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; diff --git a/extensions/browser/src/browser/chrome-mcp.test.ts b/extensions/browser/src/browser/chrome-mcp.test.ts index ea9fba037185..c0debc1139c0 100644 --- a/extensions/browser/src/browser/chrome-mcp.test.ts +++ b/extensions/browser/src/browser/chrome-mcp.test.ts @@ -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, diff --git a/extensions/browser/src/browser/chrome.diagnostics.ts b/extensions/browser/src/browser/chrome.diagnostics.ts index 654fa25fd533..56d77a3025ec 100644 --- a/extensions/browser/src/browser/chrome.diagnostics.ts +++ b/extensions/browser/src/browser/chrome.diagnostics.ts @@ -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>>; + /** 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 { 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 { return await new Promise((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 { - 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 { 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, diff --git a/extensions/browser/src/browser/chrome.graphics.ts b/extensions/browser/src/browser/chrome.graphics.ts index 0814b6d86abc..e0bbd7a93390 100644 --- a/extensions/browser/src/browser/chrome.graphics.ts +++ b/extensions/browser/src/browser/chrome.graphics.ts @@ -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 { 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 { diff --git a/extensions/browser/src/browser/chrome.internal.test.ts b/extensions/browser/src/browser/chrome.internal.test.ts index 8bc028deeeb5..4c8070677a7c 100644 --- a/extensions/browser/src/browser/chrome.internal.test.ts +++ b/extensions/browser/src/browser/chrome.internal.test.ts @@ -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 +): Promise { + 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), diff --git a/extensions/browser/src/browser/chrome.loopback-ssrf.integration.test.ts b/extensions/browser/src/browser/chrome.loopback-ssrf.integration.test.ts index 3ace79918e7e..91a088694d05 100644 --- a/extensions/browser/src/browser/chrome.loopback-ssrf.integration.test.ts +++ b/extensions/browser/src/browser/chrome.loopback-ssrf.integration.test.ts @@ -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$/); }); }); diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index 26f57cbb6329..dbb3a561c5a8 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -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 +): Promise { + 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((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((resolve) => { + server.close(() => resolve()); + }); + } + }); + it("reports cdpReady only when Browser.getVersion command succeeds", async () => { await withMockChromeCdpServer({ wsPath: "/devtools/browser/health", diff --git a/extensions/browser/src/browser/chrome.ts b/extensions/browser/src/browser/chrome.ts index b762871a210c..2931fe91c363 100644 --- a/extensions/browser/src/browser/chrome.ts +++ b/extensions/browser/src/browser/chrome.ts @@ -849,9 +849,20 @@ function buildOpenClawChromeLaunchArgs(params: { return args; } -async function canOpenWebSocket(url: string, timeoutMs: number): Promise { +type ChromeCdpEndpointPin = NonNullable>>; + +export type ChromeWebSocketEndpoint = { + url: string; + lookup?: ChromeCdpEndpointPin["lookup"]; +}; + +async function canOpenWebSocket( + url: string, + timeoutMs: number, + lookup?: ChromeCdpEndpointPin["lookup"], +): Promise { return new Promise((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 { 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 { - await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); +): Promise { + 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 { 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; diff --git a/extensions/browser/src/browser/client.types.ts b/extensions/browser/src/browser/client.types.ts index 659fee3001a8..24c448860668 100644 --- a/extensions/browser/src/browser/client.types.ts +++ b/extensions/browser/src/browser/client.types.ts @@ -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; }; diff --git a/extensions/browser/src/browser/playwright-core.runtime.ts b/extensions/browser/src/browser/playwright-core.runtime.ts index 4b932c9b46fe..c01c29fa3cdb 100644 --- a/extensions/browser/src/browser/playwright-core.runtime.ts +++ b/extensions/browser/src/browser/playwright-core.runtime.ts @@ -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; diff --git a/extensions/browser/src/browser/profile-capabilities.ts b/extensions/browser/src/browser/profile-capabilities.ts index 428518bc6ed2..b93c4dc2630f 100644 --- a/extensions/browser/src/browser/profile-capabilities.ts +++ b/extensions/browser/src/browser/profile-capabilities.ts @@ -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, diff --git a/extensions/browser/src/browser/pw-ai.e2e.test.ts b/extensions/browser/src/browser/pw-ai.e2e.test.ts index 1627d76e4009..711cfc381250 100644 --- a/extensions/browser/src/browser/pw-ai.e2e.test.ts +++ b/extensions/browser/src/browser/pw-ai.e2e.test.ts @@ -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; @@ -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")); diff --git a/extensions/browser/src/browser/pw-session-actions.ts b/extensions/browser/src/browser/pw-session-actions.ts index 58e89b5e10e1..ce4aa490644b 100644 --- a/extensions/browser/src/browser/pw-session-actions.ts +++ b/extensions/browser/src/browser/pw-session-actions.ts @@ -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(() => {}); } diff --git a/extensions/browser/src/browser/pw-session-cdp-transport.ts b/extensions/browser/src/browser/pw-session-cdp-transport.ts new file mode 100644 index 000000000000..f5e9f1828049 --- /dev/null +++ b/extensions/browser/src/browser/pw-session-cdp-transport.ts @@ -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; + lookup: CdpSocketLookup; + }, +): Promise { + const ws = openCdpWebSocket(connectionUrl, { + headers: opts.headers, + handshakeTimeoutMs: opts.timeout, + lookup: opts.lookup, + playwrightTransportDefaults: true, + }); + try { + await new Promise((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; + } +} diff --git a/extensions/browser/src/browser/pw-session-connection.ts b/extensions/browser/src/browser/pw-session-connection.ts index 078213f6370f..ab62a5384100 100644 --- a/extensions/browser/src/browser/pw-session-connection.ts +++ b/extensions/browser/src/browser/pw-session-connection.ts @@ -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>>; 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 }; diff --git a/extensions/browser/src/browser/pw-session.connections.test.ts b/extensions/browser/src/browser/pw-session.connections.test.ts index ff2486e5de55..137aabd19ba5 100644 --- a/extensions/browser/src/browser/pw-session.connections.test.ts +++ b/extensions/browser/src/browser/pw-session.connections.test.ts @@ -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"], diff --git a/extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts b/extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts index 3c6762dba30f..5a193526f409 100644 --- a/extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts +++ b/extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts @@ -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({ diff --git a/extensions/browser/src/browser/pw-session.get-page-for-targetid.test.ts b/extensions/browser/src/browser/pw-session.get-page-for-targetid.test.ts index 7ad1e0e53504..68f3200246c5 100644 --- a/extensions/browser/src/browser/pw-session.get-page-for-targetid.test.ts +++ b/extensions/browser/src/browser/pw-session.get-page-for-targetid.test.ts @@ -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", diff --git a/extensions/browser/src/browser/pw-session.mock-setup.ts b/extensions/browser/src/browser/pw-session.mock-setup.ts index da596ed85722..72b389b86127 100644 --- a/extensions/browser/src/browser/pw-session.mock-setup.ts +++ b/extensions/browser/src/browser/pw-session.mock-setup.ts @@ -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), })); diff --git a/extensions/browser/src/browser/pw-session.pinned-transport.test.ts b/extensions/browser/src/browser/pw-session.pinned-transport.test.ts new file mode 100644 index 000000000000..ae8eae991884 --- /dev/null +++ b/extensions/browser/src/browser/pw-session.pinned-transport.test.ts @@ -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((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> = []; + 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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((resolve) => { + server.close(() => resolve()); + }); + } + }); +}); diff --git a/extensions/browser/src/browser/pw-session.termination-cdp-ssrf.test.ts b/extensions/browser/src/browser/pw-session.termination-cdp-ssrf.test.ts index 4dde25386ce0..9d43173ff118 100644 --- a/extensions/browser/src/browser/pw-session.termination-cdp-ssrf.test.ts +++ b/extensions/browser/src/browser/pw-session.termination-cdp-ssrf.test.ts @@ -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 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(); + } + }); }); diff --git a/extensions/browser/src/browser/pw-tools-core.downloads.ts b/extensions/browser/src/browser/pw-tools-core.downloads.ts index 4f82fd1f47b7..36fd86d30cb0 100644 --- a/extensions/browser/src/browser/pw-tools-core.downloads.ts +++ b/extensions/browser/src/browser/pw-tools-core.downloads.ts @@ -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 { +export async function armFileUploadViaPlaywright( + opts: { + cdpUrl: string; + browserFilesystemLocal?: boolean; + targetId?: string; + paths?: string[]; + timeoutMs?: number; + } & BrowserNavigationPolicyOptions, +): Promise { 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, }); diff --git a/extensions/browser/src/browser/pw-tools-core.interactions.content.ts b/extensions/browser/src/browser/pw-tools-core.interactions.content.ts index 7c596ae76a57..f3557cbebc66 100644 --- a/extensions/browser/src/browser/pw-tools-core.interactions.content.ts +++ b/extensions/browser/src/browser/pw-tools-core.interactions.content.ts @@ -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 { + 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, +): 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 { + 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, diff --git a/extensions/browser/src/browser/pw-tools-core.interactions.navigation.ts b/extensions/browser/src/browser/pw-tools-core.interactions.navigation.ts index 5fe4ae15101e..e5f638d6083e 100644 --- a/extensions/browser/src/browser/pw-tools-core.interactions.navigation.ts +++ b/extensions/browser/src/browser/pw-tools-core.interactions.navigation.ts @@ -22,6 +22,7 @@ import { toAIFriendlyError } from "./pw-tools-core.shared.js"; export type InteractionTargetOptions = { cdpUrl: string; + browserFilesystemLocal?: boolean; targetId?: string; }; diff --git a/extensions/browser/src/browser/pw-tools-core.interactions.set-input-files.test.ts b/extensions/browser/src/browser/pw-tools-core.interactions.set-input-files.test.ts index 5fc56b8e38b7..3270ccd73f5b 100644 --- a/extensions/browser/src/browser/pw-tools-core.interactions.set-input-files.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.interactions.set-input-files.test.ts @@ -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 | null = null; let locator: Record | 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; @@ -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, diff --git a/extensions/browser/src/browser/pw-tools-core.upload-paths.test.ts b/extensions/browser/src/browser/pw-tools-core.upload-paths.test.ts index 5f86902f3ea9..9334202f52fa 100644 --- a/extensions/browser/src/browser/pw-tools-core.upload-paths.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.upload-paths.test.ts @@ -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(); diff --git a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts index 9f71d57b3baa..2f15315ce808 100644 --- a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts @@ -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), diff --git a/extensions/browser/src/browser/routes/agent.act.hooks.current-url-guard.test.ts b/extensions/browser/src/browser/routes/agent.act.hooks.current-url-guard.test.ts index c245326f07a5..83ff572141eb 100644 --- a/extensions/browser/src/browser/routes/agent.act.hooks.current-url-guard.test.ts +++ b/extensions/browser/src/browser/routes/agent.act.hooks.current-url-guard.test.ts @@ -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) { +function createRouteContext( + profileCtx: ReturnType, + options?: { allowPrivateNetwork?: boolean }, +) { return { forProfile: () => profileCtx, mapTabError: vi.fn(toBrowserErrorResponse), @@ -62,7 +70,9 @@ function createRouteContext(profileCtx: ReturnType) 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; profileCtx: ReturnType; + 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"], + }), + ); + }); }); diff --git a/extensions/browser/src/browser/routes/agent.act.hooks.ts b/extensions/browser/src/browser/routes/agent.act.hooks.ts index 83779eb11a5a..fcb2550b66c2 100644 --- a/extensions/browser/src/browser/routes/agent.act.hooks.ts +++ b/extensions/browser/src/browser/routes/agent.act.hooks.ts @@ -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 }); diff --git a/extensions/browser/src/browser/routes/agent.snapshot.local-managed.test.ts b/extensions/browser/src/browser/routes/agent.snapshot.local-managed.test.ts index 6e47a49c6af6..060d16514bec 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.local-managed.test.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.local-managed.test.ts @@ -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>( + getMainFrameDocumentIdentityViaCdp: vi.fn<(_opts?: unknown) => Promise>( 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); diff --git a/extensions/browser/src/browser/routes/agent.snapshot.timeout.test.ts b/extensions/browser/src/browser/routes/agent.snapshot.timeout.test.ts index 29dbd41c16c0..35de1f1679fd 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.timeout.test.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.timeout.test.ts @@ -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; }) => @@ -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, }), ); diff --git a/extensions/browser/src/browser/routes/agent.snapshot.ts b/extensions/browser/src/browser/routes/agent.snapshot.ts index df53ce925192..1c3d90a02e2e 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.ts @@ -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, }); diff --git a/extensions/browser/src/browser/routes/basic.ts b/extensions/browser/src/browser/routes/basic.ts index 7fa63b89f905..61a954851eed 100644 --- a/extensions/browser/src/browser/routes/basic.ts +++ b/extensions/browser/src/browser/routes/basic.ts @@ -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", diff --git a/extensions/browser/src/browser/routes/permissions.test.ts b/extensions/browser/src/browser/routes/permissions.test.ts index d269cabb9382..d11cf23999d7 100644 --- a/extensions/browser/src/browser/routes/permissions.test.ts +++ b/extensions/browser/src/browser/routes/permissions.test.ts @@ -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, { diff --git a/extensions/browser/src/browser/routes/permissions.ts b/extensions/browser/src/browser/routes/permissions.ts index 050b52326e0e..175aca9b4fb6 100644 --- a/extensions/browser/src/browser/routes/permissions.ts +++ b/extensions/browser/src/browser/routes/permissions.ts @@ -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, diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index 355ccf8f7a85..bfcf75c168a2 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -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); diff --git a/extensions/browser/src/browser/server-context.existing-session.test.ts b/extensions/browser/src/browser/server-context.existing-session.test.ts index b247f7ffe79f..2a9a729beb8b 100644 --- a/extensions/browser/src/browser/server-context.existing-session.test.ts +++ b/extensions/browser/src/browser/server-context.existing-session.test.ts @@ -37,6 +37,7 @@ type ChromeLiveProfile = { name?: string; cdpUrl?: string; userDataDir?: string; + mcpArgs?: string[]; }; function deferred() { @@ -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", diff --git a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.fallback.test.ts b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.fallback.test.ts index bc334839a611..d9225da2a65b 100644 --- a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.fallback.test.ts +++ b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.fallback.test.ts @@ -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 () => { diff --git a/extensions/browser/src/browser/server-context.selection.ts b/extensions/browser/src/browser/server-context.selection.ts index f7f905db85f3..05af9588f2a5 100644 --- a/extensions/browser/src/browser/server-context.selection.ts +++ b/extensions/browser/src/browser/server-context.selection.ts @@ -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 { diff --git a/extensions/browser/src/browser/server-context.tab-ops.ts b/extensions/browser/src/browser/server-context.tab-ops.ts index c4ef4873d34a..596dc102851a 100644 --- a/extensions/browser/src/browser/server-context.tab-ops.ts +++ b/extensions/browser/src/browser/server-context.tab-ops.ts @@ -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 => { 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, diff --git a/extensions/browser/src/browser/server-context.tab-selection-lookup.test.ts b/extensions/browser/src/browser/server-context.tab-selection-lookup.test.ts new file mode 100644 index 000000000000..a030ed02c6ea --- /dev/null +++ b/extensions/browser/src/browser/server-context.tab-selection-lookup.test.ts @@ -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, + profileName = "openclaw", +): void { + (state.profiles as Map).set(profileName, { + profile: { name: profileName }, + running: { pid: 1234, proc: { on: vi.fn() } }, + lastTargetId: null, + }); +} + +function fetchCallUrls(fetchMock: ReturnType): 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); + }); +}); diff --git a/extensions/browser/src/browser/server-context.tab-selection-state.test.ts b/extensions/browser/src/browser/server-context.tab-selection-state.test.ts index 08bc2e0926b6..8608034829f3 100644 --- a/extensions/browser/src/browser/server-context.tab-selection-state.test.ts +++ b/extensions/browser/src/browser/server-context.tab-selection-state.test.ts @@ -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); - }); }); diff --git a/extensions/browser/src/browser/ssrf-policy-helpers.ts b/extensions/browser/src/browser/ssrf-policy-helpers.ts index bf8464f07b12..40f36291a838 100644 --- a/extensions/browser/src/browser/ssrf-policy-helpers.ts +++ b/extensions/browser/src/browser/ssrf-policy-helpers.ts @@ -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,