From 4cfc8cd5beb218b2e47cd823a9d4ddc50045bc57 Mon Sep 17 00:00:00 2001 From: Viz Date: Sun, 19 Apr 2026 05:43:39 -0400 Subject: [PATCH] fix(browser): discover CDP websocket from bare ws:// URL before attach (#68715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(browser): discover CDP websocket from bare ws:// URL before attach When browser.cdpUrl is set to a bare ws://host:port (no /devtools/ path), ensureBrowserAvailable would call isChromeReachable -> canOpenWebSocket against the URL verbatim. Chrome only accepts WebSocket upgrades at the specific path returned by /json/version, so the handshake failed immediately with HTTP 400. With attachOnly: true, that surfaced as: Browser attachOnly is enabled and profile "openclaw" is not running. even though the CDP endpoint was reachable and the profile was healthy. Reproduced by the new tests in chrome.test.ts and cdp.test.ts (#68027). Fix: introduce isDirectCdpWebSocketEndpoint(url) — true only when a ws/wss URL has a /devtools// handshake path. Route any other ws/wss cdpUrl (including the bare ws://host:port shape) through HTTP /json/version discovery by normalising the scheme via the existing normalizeCdpHttpBaseForJsonEndpoints helper. Apply this in isChromeReachable, getChromeWebSocketUrl, and createTargetViaCdp. Direct WS endpoints with a /devtools/ path are still opened without an extra discovery round-trip. Fixes #68027 * test(browser): add seeded fuzz coverage for CDP URL helpers Adds property-based / seeded-fuzz tests for the URL helpers the attachOnly CDP fix depends on (#68027): - isWebSocketUrl - isDirectCdpWebSocketEndpoint - normalizeCdpHttpBaseForJsonEndpoints - parseBrowserHttpUrl - redactCdpUrl - appendCdpPath - getHeadersWithAuth Follows the existing repo convention (see src/gateway/http-common.fuzz.test.ts): no fast-check dep, small mulberry32 PRNG + hand-rolled generators, deterministic per-describe seeds so failures are reproducible. Lifts cdp.helpers.ts coverage from 77.77% -> 89.54% statements, 67.9% -> 80.24% branches, 78% -> 90% lines. Remaining uncovered lines are inside the WS sender internals (createCdpSender, withCdpSocket, fetchCdpChecked rate-limit branch), which require integration-style mocks and are unrelated to the attachOnly fix. * test(browser): drive cdp.helpers/cdp/chrome to 100% coverage Lifts the three files touched by the #68027 attachOnly fix to 100% statements/branches/functions/lines across the extensions test suite. Adds cdp.helpers.internal.test.ts, cdp.internal.test.ts, and chrome.internal.test.ts covering error paths, branch matrices, CDP session helpers, Chrome spawn/launch/stop flows, and canRunCdpHealthCommand. Defensively unreachable guards are annotated with c8 ignore + inline justifications. * fix(browser): restore WS fallback for non-/devtools ws:// CDP URLs When /json/version discovery is unavailable (or returns no webSocketDebuggerUrl), fall back to treating the original bare ws/wss URL as a direct WebSocket endpoint. This preserves the #68027 fix for Chrome's debug port while restoring compatibility with Browserless/ Browserbase-style providers that expose a direct WebSocket root without a /json/version endpoint. Priority order for bare ws/wss cdpUrl inputs: 1. /devtools// URL \u2192 direct handshake, no discovery (unchanged) 2. bare ws/wss root \u2192 try HTTP discovery first; if discovery returns a webSocketDebuggerUrl use it; otherwise fall back to the original URL as a direct WS endpoint 3. HTTP/HTTPS URL \u2192 HTTP discovery only, no fallback (unchanged) Affected call sites: isChromeReachable, getChromeWebSocketUrl, createTargetViaCdp. Also renames a misleading test ('still enforces SSRF policy for direct WebSocket URLs') to accurately describe what it tests: SSRF enforcement on the navigation target URL, not on the CDP endpoint. New tests added for all three fallback paths. Coverage remains 100% on all three touched files (238 tests). * fix: browser attachOnly bare ws CDP follow-ups (#68715) (thanks @visionik) --- CHANGELOG.md | 1 + docs/tools/browser.md | 30 +- .../src/browser/cdp.helpers.fuzz.test.ts | 441 +++++++ .../src/browser/cdp.helpers.internal.test.ts | 394 +++++++ extensions/browser/src/browser/cdp.helpers.ts | 46 + .../browser/src/browser/cdp.internal.test.ts | 955 ++++++++++++++++ extensions/browser/src/browser/cdp.test.ts | 58 +- extensions/browser/src/browser/cdp.ts | 59 +- .../src/browser/chrome.internal.test.ts | 1012 +++++++++++++++++ extensions/browser/src/browser/chrome.test.ts | 78 +- extensions/browser/src/browser/chrome.ts | 53 +- ...wser-available.waits-for-cdp-ready.test.ts | 55 + 12 files changed, 3147 insertions(+), 35 deletions(-) create mode 100644 extensions/browser/src/browser/cdp.helpers.fuzz.test.ts create mode 100644 extensions/browser/src/browser/cdp.helpers.internal.test.ts create mode 100644 extensions/browser/src/browser/cdp.internal.test.ts create mode 100644 extensions/browser/src/browser/chrome.internal.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 372b83dc661..6c9c3b20e54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Browser/CDP: allow the selected remote CDP profile host for CDP health and control checks without widening browser navigation SSRF policy, so WSL-to-Windows Chrome endpoints no longer appear offline under strict defaults. Fixes #68108. (#68207) Thanks @Mlightsnow. - Codex: stop cumulative app-server token totals from being treated as fresh context usage, so session status no longer reports inflated context percentages after long Codex threads. (#64669) Thanks @cyrusaf. - Browser/CDP: add phase-specific CDP readiness diagnostics and normalize loopback WebSocket host aliases, so Windows browser startup failures surface whether HTTP discovery, WebSocket discovery, SSRF validation, or the `Browser.getVersion` health check failed. +- Browser/CDP: discover Chrome’s real DevTools websocket from bare `ws://host:port` attach-only roots before declaring the profile down, while still falling back to direct websocket providers that do not expose `/json/version`. Fixes #68027. (#68715) Thanks @visionik. ## 2026.4.18 diff --git a/docs/tools/browser.md b/docs/tools/browser.md index c604e96b5d8..f64991a595f 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -316,15 +316,29 @@ Notes: ## Direct WebSocket CDP providers Some hosted browser services expose a **direct WebSocket** endpoint rather than -the standard HTTP-based CDP discovery (`/json/version`). OpenClaw supports both: +the standard HTTP-based CDP discovery (`/json/version`). OpenClaw accepts three +CDP URL shapes and picks the right connection strategy automatically: -- **HTTP(S) endpoints** — OpenClaw calls `/json/version` to discover the - WebSocket debugger URL, then connects. -- **WebSocket endpoints** (`ws://` / `wss://`) — OpenClaw connects directly, - skipping `/json/version`. Use this for services like - [Browserless](https://browserless.io), - [Browserbase](https://www.browserbase.com), or any provider that hands you a - WebSocket URL. +- **HTTP(S) discovery** — `http://host[:port]` or `https://host[:port]`. + OpenClaw calls `/json/version` to discover the WebSocket debugger URL, then + connects. No WebSocket fallback. +- **Direct WebSocket endpoints** — `ws://host[:port]/devtools//` or + `wss://...` with a `/devtools/browser|page|worker|shared_worker|service_worker/` + path. OpenClaw connects directly via a WebSocket handshake and skips + `/json/version` entirely. +- **Bare WebSocket roots** — `ws://host[:port]` or `wss://host[:port]` with no + `/devtools/...` path (e.g. [Browserless](https://browserless.io), + [Browserbase](https://www.browserbase.com)). OpenClaw tries HTTP + `/json/version` discovery first (normalising the scheme to `http`/`https`); + if discovery returns a `webSocketDebuggerUrl` it is used, otherwise OpenClaw + falls back to a direct WebSocket handshake at the bare root. This covers + both Chrome-style remote debug ports and WebSocket-only providers. + +Plain `ws://host:port` / `wss://host:port` without a `/devtools/...` path +pointed at a local Chrome instance is supported via the discovery-first +fallback — Chrome only accepts WebSocket upgrades on the specific per-browser +or per-target path returned by `/json/version`, so a bare-root handshake alone +would fail. ### Browserbase diff --git a/extensions/browser/src/browser/cdp.helpers.fuzz.test.ts b/extensions/browser/src/browser/cdp.helpers.fuzz.test.ts new file mode 100644 index 00000000000..94236a67756 --- /dev/null +++ b/extensions/browser/src/browser/cdp.helpers.fuzz.test.ts @@ -0,0 +1,441 @@ +import { describe, expect, it } from "vitest"; +import { + appendCdpPath, + getHeadersWithAuth, + isDirectCdpWebSocketEndpoint, + isWebSocketUrl, + normalizeCdpHttpBaseForJsonEndpoints, + parseBrowserHttpUrl, + redactCdpUrl, +} from "./cdp.helpers.js"; + +/** + * Seeded property-based / fuzz coverage for the URL helpers in cdp.helpers. + * + * The repo intentionally does not pull in `fast-check` (see + * src/gateway/http-common.fuzz.test.ts); this file follows the same + * pattern: a small deterministic PRNG (mulberry32) + hand-rolled + * generators, with every property running N iterations. Failures are + * deterministic because each describe block seeds its own rng. + * + * Focus is on the URL parsing / normalisation primitives that the + * #68027 attachOnly fix depends on: distinguishing direct-WS CDP + * endpoints from bare ws roots, and normalising bare ws URLs to http + * for `/json/version` discovery. + */ + +/** Deterministic 32-bit PRNG. */ +function makeRng(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function randInt(rng: () => number, loInclusive: number, hiInclusive: number): number { + return Math.floor(rng() * (hiInclusive - loInclusive + 1)) + loInclusive; +} + +function pick(rng: () => number, arr: readonly T[]): T { + return arr[randInt(rng, 0, arr.length - 1)]; +} + +function randHost(rng: () => number): string { + return pick(rng, [ + "127.0.0.1", + "localhost", + "[::1]", + "0.0.0.0", + "[::]", + "example.com", + "connect.example.com", + "browserless.example", + "host-1.example.internal", + "user.example.com", + "192.168.1.202", + "10.0.0.5", + ]); +} + +function randPort(rng: () => number): string { + const kind = randInt(rng, 0, 4); + if (kind === 0) { + return ""; + } + if (kind === 1) { + return ":9222"; + } + if (kind === 2) { + return `:${randInt(rng, 1, 65535)}`; + } + if (kind === 3) { + return ":3000"; + } + return ":443"; +} + +function randWsScheme(rng: () => number): "ws://" | "wss://" { + return rng() < 0.5 ? "ws://" : "wss://"; +} + +function randHttpScheme(rng: () => number): "http://" | "https://" { + return rng() < 0.5 ? "http://" : "https://"; +} + +function randDirectDevtoolsPath(rng: () => number): string { + const kind = pick(rng, ["browser", "page", "worker", "shared_worker", "service_worker"] as const); + const id = `${randInt(rng, 0, 0xffffffff).toString(16)}-${randInt(rng, 0, 9999)}`; + return `/devtools/${kind}/${id}`; +} + +function randNonDevtoolsPath(rng: () => number): string { + return pick(rng, [ + "", + "/", + "/json/version", + "/devtools", + "/devtools/", + "/devtools/browser/", // trailing slash, no id + "/devtools/unknown/abc", + "/other/path", + "/cdp", + "/json/list", + ]); +} + +function randQuery(rng: () => number): string { + if (rng() < 0.5) { + return ""; + } + return pick(rng, ["?token=abc", "?apiKey=xyz&other=1", "?session=1&token=ws-token", "?t="]); +} + +function randUserInfo(rng: () => number): string { + if (rng() < 0.6) { + return ""; + } + return pick(rng, ["user:pass@", "u:p@", "alice:s3cr3t@", "only-user@", ":only-pass@"]); +} + +const ITERATIONS = 200; + +describe("fuzz: isWebSocketUrl", () => { + it("returns true for any syntactically valid ws/wss URL", () => { + const rng = makeRng(0x1001); + for (let i = 0; i < ITERATIONS; i += 1) { + const url = `${randWsScheme(rng)}${randUserInfo(rng)}${randHost(rng)}${randPort(rng)}${ + rng() < 0.5 ? randDirectDevtoolsPath(rng) : randNonDevtoolsPath(rng) + }${randQuery(rng)}`; + try { + // Only assert the property when the URL itself parses; assign + // the result to satisfy eslint's no-new rule. + const _parsed = new URL(url); + void _parsed; + } catch { + continue; + } + expect(isWebSocketUrl(url)).toBe(true); + } + }); + + it("returns false for http/https URLs and random non-URL garbage", () => { + const rng = makeRng(0x1002); + for (let i = 0; i < ITERATIONS; i += 1) { + const kind = randInt(rng, 0, 2); + if (kind === 0) { + const url = `${randHttpScheme(rng)}${randHost(rng)}${randPort(rng)}${randNonDevtoolsPath( + rng, + )}${randQuery(rng)}`; + expect(isWebSocketUrl(url)).toBe(false); + } else if (kind === 1) { + expect(isWebSocketUrl("")).toBe(false); + } else { + // Deliberately malformed: no scheme, or unsupported scheme. + const junk = pick(rng, [ + "not-a-url", + "ftp://example.com", + "file:///etc/passwd", + "://foo", + "ws:", + "ws:/", + "ws//", + ]); + expect(isWebSocketUrl(junk)).toBe(false); + } + } + }); +}); + +describe("fuzz: isDirectCdpWebSocketEndpoint", () => { + it("returns true iff the URL is ws/wss AND path is /devtools//", () => { + const rng = makeRng(0x2001); + for (let i = 0; i < ITERATIONS; i += 1) { + const scheme = randWsScheme(rng); + const path = randDirectDevtoolsPath(rng); + const url = `${scheme}${randHost(rng)}${randPort(rng)}${path}${randQuery(rng)}`; + expect(isDirectCdpWebSocketEndpoint(url)).toBe(true); + } + }); + + it("returns false for bare ws roots and non-devtools ws paths (needs HTTP discovery)", () => { + const rng = makeRng(0x2002); + for (let i = 0; i < ITERATIONS; i += 1) { + const url = `${randWsScheme(rng)}${randHost(rng)}${randPort(rng)}${randNonDevtoolsPath( + rng, + )}${randQuery(rng)}`; + expect(isDirectCdpWebSocketEndpoint(url)).toBe(false); + } + }); + + it("returns false for any http/https URL regardless of path", () => { + const rng = makeRng(0x2003); + for (let i = 0; i < ITERATIONS; i += 1) { + const path = rng() < 0.5 ? randDirectDevtoolsPath(rng) : randNonDevtoolsPath(rng); + const url = `${randHttpScheme(rng)}${randHost(rng)}${randPort(rng)}${path}${randQuery(rng)}`; + expect(isDirectCdpWebSocketEndpoint(url)).toBe(false); + } + }); + + it("never throws on random input (including invalid URLs)", () => { + const rng = makeRng(0x2004); + const junkPool = [ + "", + " ", + "not-a-url", + "http://", + "ws://", + "ws:///devtools/browser/abc", + "://x", + "\u0000", + "ws://[not-an-ip]/devtools/browser/abc", + ]; + for (let i = 0; i < ITERATIONS; i += 1) { + const input = rng() < 0.5 ? pick(rng, junkPool) : String.fromCharCode(randInt(rng, 0, 0x7f)); + expect(() => isDirectCdpWebSocketEndpoint(input)).not.toThrow(); + expect(typeof isDirectCdpWebSocketEndpoint(input)).toBe("boolean"); + } + }); +}); + +describe("fuzz: normalizeCdpHttpBaseForJsonEndpoints", () => { + it("ws -> http and wss -> https, drops trailing /devtools/browser/... and /cdp", () => { + const rng = makeRng(0x3001); + for (let i = 0; i < ITERATIONS; i += 1) { + const scheme = randWsScheme(rng); + const host = randHost(rng); + const port = randPort(rng); + const suffix = pick(rng, [ + "", + "/", + "/cdp", + "/devtools/browser/abc", + "/devtools/browser/abc/path-fragment", + ]); + const input = `${scheme}${host}${port}${suffix}`; + const out = normalizeCdpHttpBaseForJsonEndpoints(input); + // Scheme mapping + if (scheme === "ws://") { + expect(out.startsWith("http://")).toBe(true); + expect(out.startsWith("ws://")).toBe(false); + } else { + expect(out.startsWith("https://")).toBe(true); + expect(out.startsWith("wss://")).toBe(false); + } + // /devtools/browser/... and /cdp are stripped + expect(out.includes("/devtools/browser/")).toBe(false); + expect(out.endsWith("/cdp")).toBe(false); + // No trailing slash + expect(out.endsWith("/")).toBe(false); + } + }); + + it("preserves http/https inputs and strips a trailing /cdp when present", () => { + const rng = makeRng(0x3002); + for (let i = 0; i < ITERATIONS; i += 1) { + const scheme = randHttpScheme(rng); + const hasCdp = rng() < 0.5; + const hasTrailingSlash = rng() < 0.3; + // Only exercise the trailing-/cdp branch here (the regex only + // strips /cdp when it's the final path segment, not /cdp/ etc.). + const input = `${scheme}${randHost(rng)}${randPort(rng)}${hasCdp ? "/cdp" : ""}${ + hasTrailingSlash && !hasCdp ? "/" : "" + }`; + const out = normalizeCdpHttpBaseForJsonEndpoints(input); + expect(out.startsWith(scheme)).toBe(true); + expect(out.endsWith("/cdp")).toBe(false); + expect(out.endsWith("/")).toBe(false); + } + }); + + it("falls back safely for non-URL-ish inputs (never throws)", () => { + const rng = makeRng(0x3003); + // These inputs either trigger the catch branch (empty / "garbage" / + // bare "ws://" / "wss://") or are accepted by WHATWG URL as + // special-scheme absolute URLs (e.g. "ws:host/path" becomes + // "ws://host/path"). Either way the helper must never throw. + const junk = [ + "ws:/devtools/browser/abc", + "wss:/devtools/browser/abc", + "ws:no-host/cdp", + "wss:no-host/", + "garbage", + "", + "ws://", + "wss://", + ]; + for (let i = 0; i < ITERATIONS; i += 1) { + const input = pick(rng, junk); + expect(() => normalizeCdpHttpBaseForJsonEndpoints(input)).not.toThrow(); + const out = normalizeCdpHttpBaseForJsonEndpoints(input); + expect(typeof out).toBe("string"); + // Scheme swap invariant: whatever branch ran, ws:/wss: never + // appear as a scheme prefix in the normalized output. + expect(out.startsWith("ws:")).toBe(false); + expect(out.startsWith("wss:")).toBe(false); + } + }); + + it("fallback explicitly handles malformed ws:/wss: scheme-only strings", () => { + // Hand-crafted inputs that parse as URLs via WHATWG but the pattern + // still exercises the scheme swap + suffix strip in both branches. + expect(normalizeCdpHttpBaseForJsonEndpoints("ws://host:9222/cdp")).toBe("http://host:9222"); + expect(normalizeCdpHttpBaseForJsonEndpoints("wss://host:9222/")).toBe("https://host:9222"); + expect(normalizeCdpHttpBaseForJsonEndpoints("ws://host/devtools/browser/abc")).toBe( + "http://host", + ); + // WHATWG URL preserves the root "/" on the path after stripping the + // /devtools/browser/... suffix, so the trailing-slash removal only + // trims the final character of the serialized form (which is "1", + // not "/"). + expect(normalizeCdpHttpBaseForJsonEndpoints("wss://host/devtools/browser/abc?t=1")).toBe( + "https://host/?t=1", + ); + // Fallback branch: inputs `new URL` genuinely rejects. The fallback + // performs a naive scheme swap and suffix strip on the raw string. + expect(normalizeCdpHttpBaseForJsonEndpoints("")).toBe(""); + expect(normalizeCdpHttpBaseForJsonEndpoints("garbage")).toBe("garbage"); + expect(normalizeCdpHttpBaseForJsonEndpoints("ws://").startsWith("http:")).toBe(true); + expect(normalizeCdpHttpBaseForJsonEndpoints("wss://").startsWith("https:")).toBe(true); + }); +}); + +describe("fuzz: parseBrowserHttpUrl", () => { + it("accepts http/https/ws/wss and assigns sensible default ports", () => { + const rng = makeRng(0x4001); + for (let i = 0; i < ITERATIONS; i += 1) { + const scheme = pick(rng, ["http://", "https://", "ws://", "wss://"] as const); + const explicitPort = rng() < 0.5; + const portNum = randInt(rng, 1, 65535); + const url = `${scheme}${randHost(rng)}${explicitPort ? `:${portNum}` : ""}/path`; + const result = parseBrowserHttpUrl(url, "test"); + expect(result.parsed.protocol).toBe(scheme.replace("//", "")); + if (explicitPort) { + expect(result.port).toBe(portNum); + } else { + const isSecure = scheme === "https://" || scheme === "wss://"; + expect(result.port).toBe(isSecure ? 443 : 80); + } + expect(result.normalized.endsWith("/")).toBe(false); + } + }); + + it("rejects unsupported protocols", () => { + const rng = makeRng(0x4002); + for (let i = 0; i < ITERATIONS; i += 1) { + const scheme = pick(rng, ["ftp://", "file://", "gopher://", "data:"] as const); + const url = scheme === "data:" ? "data:text/plain,hello" : `${scheme}${randHost(rng)}`; + expect(() => parseBrowserHttpUrl(url, "test")).toThrow(/must be http\(s\) or ws\(s\)/); + } + }); +}); + +describe("fuzz: redactCdpUrl", () => { + it("strips username/password from valid URLs and preserves host/path", () => { + const rng = makeRng(0x5001); + for (let i = 0; i < ITERATIONS; i += 1) { + const scheme = pick(rng, ["http://", "https://", "ws://", "wss://"] as const); + const host = randHost(rng); + const port = randPort(rng); + const path = rng() < 0.5 ? randDirectDevtoolsPath(rng) : randNonDevtoolsPath(rng); + const url = `${scheme}user:pass@${host}${port}${path}`; + const out = redactCdpUrl(url); + expect(typeof out).toBe("string"); + expect(String(out)).not.toContain("user:pass@"); + } + }); + + it("returns non-string inputs unchanged and short-circuits empty/whitespace strings", () => { + expect(redactCdpUrl(undefined)).toBeUndefined(); + expect(redactCdpUrl(null)).toBeNull(); + // Empty and whitespace-only inputs both short-circuit to the + // trimmed empty string before any URL parsing / redaction. + expect(redactCdpUrl("")).toBe(""); + expect(redactCdpUrl(" ")).toBe(""); + }); + + it("falls back to redactSensitiveText for non-URL-ish inputs (never throws)", () => { + const rng = makeRng(0x5002); + for (let i = 0; i < ITERATIONS; i += 1) { + const junk = pick(rng, ["not-a-url", "http://", "ws://", "::::", "Bearer ey.SECRET.xyz"]); + expect(() => redactCdpUrl(junk)).not.toThrow(); + const out = redactCdpUrl(junk); + expect(typeof out).toBe("string"); + } + }); +}); + +describe("fuzz: appendCdpPath", () => { + it("produces a URL that ends with the appended path exactly once", () => { + const rng = makeRng(0x6001); + for (let i = 0; i < ITERATIONS; i += 1) { + const scheme = pick(rng, ["http://", "https://", "ws://", "wss://"] as const); + const base = `${scheme}${randHost(rng)}${randPort(rng)}${rng() < 0.5 ? "/" : ""}`; + const path = pick(rng, ["/json/version", "json/version", "/json/close/TARGET_1"]); + const out = appendCdpPath(base, path); + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + // Path segment should appear in output and not be doubled. + expect(out.endsWith(normalizedPath)).toBe(true); + expect(out.split(normalizedPath).length - 1).toBeGreaterThanOrEqual(1); + } + }); +}); + +describe("fuzz: getHeadersWithAuth", () => { + it("never throws and always returns a mergedHeaders object", () => { + const rng = makeRng(0x7001); + for (let i = 0; i < ITERATIONS; i += 1) { + const withAuth = rng() < 0.3; + const url = + rng() < 0.5 + ? `${randHttpScheme(rng)}${withAuth ? "alice:s3cr3t@" : ""}${randHost(rng)}${randPort(rng)}` + : pick(rng, ["not-a-url", "", "ws://"]); + const headers: Record = {}; + if (rng() < 0.3) { + headers.Authorization = "Bearer preset"; + } + const out = getHeadersWithAuth(url, headers); + expect(typeof out).toBe("object"); + // Preset auth header must always be preserved verbatim. + if (headers.Authorization) { + expect(out.Authorization).toBe("Bearer preset"); + } + } + }); + + it("injects Basic auth from URL userinfo when no Authorization header is present", () => { + const out = getHeadersWithAuth("https://alice:s3cr3t@example.com/path"); + expect(out.Authorization).toBe(`Basic ${Buffer.from("alice:s3cr3t").toString("base64")}`); + }); + + it("preserves an existing Authorization header (case-insensitive) over URL userinfo", () => { + const out = getHeadersWithAuth("https://alice:s3cr3t@example.com/path", { + authorization: "Bearer preset", + }); + expect(out.authorization).toBe("Bearer preset"); + expect(out.Authorization).toBeUndefined(); + }); +}); diff --git a/extensions/browser/src/browser/cdp.helpers.internal.test.ts b/extensions/browser/src/browser/cdp.helpers.internal.test.ts new file mode 100644 index 00000000000..b41cb0c5d40 --- /dev/null +++ b/extensions/browser/src/browser/cdp.helpers.internal.test.ts @@ -0,0 +1,394 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WebSocketServer } from "ws"; +import { rawDataToString } from "../infra/ws.js"; + +const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args), + }; +}); + +import { SsrFBlockedError } from "../infra/net/ssrf.js"; +import { + assertCdpEndpointAllowed, + fetchCdpChecked, + fetchJson, + openCdpWebSocket, + withCdpSocket, +} from "./cdp.helpers.js"; +import { BrowserCdpEndpointBlockedError } from "./errors.js"; + +/** + * Targets the non-URL-helper code paths in cdp.helpers.ts: + * - assertCdpEndpointAllowed invalid-protocol throw + * - fetchCdpChecked 429 rate-limit + double-release guard + * - createCdpSender message routing (non-number id, unknown id, error body) + * - createCdpSender 'error' event + pending rejection + * - withCdpSocket open-error / fn-throw / close error-close paths + */ + +async function startWsServer() { + const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" }); + await new Promise((resolve) => wss.once("listening", () => resolve())); + const port = (wss.address() as { port: number }).port; + return { wss, port, url: `ws://127.0.0.1:${port}/devtools/browser/TEST` }; +} + +describe("cdp.helpers internal", () => { + let wss: WebSocketServer | null = null; + + afterEach(async () => { + fetchWithSsrFGuardMock.mockReset(); + if (wss) { + await new Promise((resolve) => wss?.close(() => resolve())); + wss = null; + } + }); + + describe("assertCdpEndpointAllowed", () => { + it("throws on non-http/https/ws/wss protocols under any SSRF policy", async () => { + await expect( + assertCdpEndpointAllowed("ftp://example.com/cdp", { + dangerouslyAllowPrivateNetwork: false, + }), + ).rejects.toThrow(/Invalid CDP URL protocol: ftp/); + }); + + it("no-ops when no policy is supplied, regardless of protocol", async () => { + await expect(assertCdpEndpointAllowed("ftp://example.com/cdp")).resolves.toBeUndefined(); + }); + + it("uses the raw ssrfPolicy path for non-loopback hosts", async () => { + // Non-loopback public host: hits the else branch of the loopback + // ternary in assertCdpEndpointAllowed. Using a well-known public IP + // under a permissive policy so the SSRF pin resolves without a DNS + // mock. + await expect( + assertCdpEndpointAllowed("http://93.184.216.34:443/cdp", { + allowPrivateNetwork: true, + }), + ).resolves.toBeUndefined(); + }); + }); + + describe("fetchCdpChecked", () => { + it("maps HTTP 429 responses into the browser rate-limit error", async () => { + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: { ok: false, status: 429 } as unknown as Response, + release: vi.fn(async () => {}), + }); + await expect( + fetchCdpChecked("http://127.0.0.1:9222/json/version", 250, undefined, { + dangerouslyAllowPrivateNetwork: false, + allowedHostnames: ["127.0.0.1"], + }), + ).rejects.toThrow(/rate[ -]?limit/i); + }); + + it("is idempotent when release() is awaited more than once", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: { ok: true, status: 200 } as unknown as Response, + release, + }); + const { release: guardedRelease } = await fetchCdpChecked( + "http://127.0.0.1:9222/json/version", + 250, + undefined, + { dangerouslyAllowPrivateNetwork: false, allowedHostnames: ["127.0.0.1"] }, + ); + await guardedRelease(); + await guardedRelease(); + // The underlying release must be invoked exactly once. + expect(release).toHaveBeenCalledTimes(1); + }); + + it("converts SSRF-blocked errors from the underlying fetch into a browser-scoped error", async () => { + fetchWithSsrFGuardMock.mockRejectedValueOnce(new SsrFBlockedError("blocked by policy")); + await expect( + fetchCdpChecked("http://127.0.0.1:9222/json/version", 250, undefined, { + dangerouslyAllowPrivateNetwork: false, + allowedHostnames: ["127.0.0.1"], + }), + ).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError); + }); + + it("maps non-429 HTTP failures into a generic HTTP error", async () => { + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: { ok: false, status: 503 } as unknown as Response, + release: vi.fn(async () => {}), + }); + await expect( + fetchJson("http://127.0.0.1:9222/json/version", 250, undefined, { + dangerouslyAllowPrivateNetwork: false, + allowedHostnames: ["127.0.0.1"], + }), + ).rejects.toThrow(/HTTP 503/); + }); + + it("uses the caller-supplied policy for non-loopback hosts", async () => { + // Hits the else branch of the isLoopbackHost ternary inside + // withNoProxyForCdpUrl plus the left-hand side of the + // `ssrfPolicy ?? { allowPrivateNetwork: true }` coalescing. + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: { ok: true, status: 200 } as unknown as Response, + release, + }); + await fetchCdpChecked("http://93.184.216.34:9222/json/version", 250, undefined, { + allowPrivateNetwork: true, + }); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + policy: expect.objectContaining({ allowPrivateNetwork: true }), + }), + ); + }); + + it("falls back to a permissive private-network policy when none is supplied on a non-loopback host", async () => { + // Hits the right-hand side of the `ssrfPolicy ?? { allowPrivateNetwork: true }` default. + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: { ok: true, status: 200 } as unknown as Response, + release, + }); + await fetchCdpChecked("http://93.184.216.34:9222/json/version", 250); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + policy: { allowPrivateNetwork: true }, + }), + ); + }); + }); + + describe("createCdpSender (via withCdpSocket)", () => { + it("ignores messages with a non-numeric id", async () => { + const server = await startWsServer(); + wss = server.wss; + let received = 0; + server.wss.on("connection", (socket) => { + socket.on("message", (raw) => { + received += 1; + const text = rawDataToString(raw); + const msg = JSON.parse(text) as { id?: number; method?: string }; + // First emit a noise message with a non-number id (should be ignored), + // then a garbage-json payload (hits the outer catch), then the real + // response so the caller resolves. + socket.send(JSON.stringify({ id: "oops", method: "unrelated" })); + socket.send("not-json"); + socket.send(JSON.stringify({ id: msg.id, result: { echoed: msg.method } })); + }); + }); + + const result = await withCdpSocket<{ echoed: string | undefined }>( + server.url, + async (send) => (await send("Test.ping")) as { echoed: string | undefined }, + ); + expect(result.echoed).toBe("Test.ping"); + expect(received).toBe(1); + }); + + it("ignores responses whose id does not match any pending call", async () => { + const server = await startWsServer(); + wss = server.wss; + server.wss.on("connection", (socket) => { + socket.on("message", (raw) => { + const msg = JSON.parse(rawDataToString(raw)) as { id?: number; method?: string }; + // Stranger id with no pending entry — must be silently dropped. + socket.send(JSON.stringify({ id: 99999, result: {} })); + socket.send(JSON.stringify({ id: msg.id, result: { ok: true } })); + }); + }); + const result = await withCdpSocket<{ ok: boolean }>( + server.url, + async (send) => (await send("Test.ping")) as { ok: boolean }, + ); + expect(result.ok).toBe(true); + }); + + it("propagates CDP error-body messages as rejections to the caller", async () => { + const server = await startWsServer(); + wss = server.wss; + server.wss.on("connection", (socket) => { + socket.on("message", (raw) => { + const msg = JSON.parse(rawDataToString(raw)) as { id?: number }; + socket.send( + JSON.stringify({ + id: msg.id, + error: { message: "boom from cdp" }, + }), + ); + }); + }); + await expect( + withCdpSocket(server.url, async (send) => { + await send("Test.failing"); + }), + ).rejects.toThrow(/boom from cdp/); + }); + + it("rejects in-flight pending calls when the socket closes mid-call", async () => { + const server = await startWsServer(); + wss = server.wss; + server.wss.on("connection", (socket) => { + socket.on("message", () => { + // Defer close so the pending entry is definitely registered. + setTimeout(() => socket.close(), 10); + }); + }); + await expect( + withCdpSocket(server.url, async (send) => { + await send("Test.willClose"); + }), + ).rejects.toThrow(/CDP socket closed/); + }); + }); + + describe("withCdpSocket", () => { + it("rejects and rethrows when the WebSocket fails to open", async () => { + // Port 1 on 127.0.0.1 is reserved and will reliably refuse connections, + // triggering the open-error branch synchronously. + await expect( + withCdpSocket("ws://127.0.0.1:1/devtools/browser/NO", async () => { + return "unreachable"; + }), + ).rejects.toThrow(); + }); + + it("wraps a non-Error callback throw before closing the socket", async () => { + // `fn` is user-supplied and may throw a non-Error. Exercise the + // `err instanceof Error ? err : new Error(String(err))` wrap in the + // fn-throw catch branch. + const server = await startWsServer(); + wss = server.wss; + server.wss.on("connection", (socket) => { + socket.on("message", (raw) => { + const msg = JSON.parse(rawDataToString(raw)) as { id?: number }; + socket.send(JSON.stringify({ id: msg.id, result: {} })); + }); + }); + await expect( + withCdpSocket(server.url, async (send) => { + await send("Test.ok"); + // biome-ignore lint/style/useThrowOnlyError: exercising the non-Error guard on purpose. + throw "raw-string-from-callback"; + }), + ).rejects.toThrow(/raw-string-from-callback/); + }); + + it("rethrows callback errors and still closes the socket cleanly", async () => { + const server = await startWsServer(); + wss = server.wss; + server.wss.on("connection", (socket) => { + socket.on("message", (raw) => { + const msg = JSON.parse(rawDataToString(raw)) as { id?: number }; + socket.send(JSON.stringify({ id: msg.id, result: {} })); + }); + }); + await expect( + withCdpSocket(server.url, async (send) => { + await send("Test.ok"); + throw new Error("callback boom"); + }), + ).rejects.toThrow(/callback boom/); + }); + + it("tolerates a ws.close() that throws in the cleanup finally", async () => { + // Force ws.close() to throw by wrapping withCdpSocket against a live + // server but monkey-patching the ws prototype momentarily. We do this + // via a callback that pre-empts close by calling terminate() first. + const server = await startWsServer(); + wss = server.wss; + server.wss.on("connection", (socket) => { + socket.on("message", (raw) => { + const msg = JSON.parse(rawDataToString(raw)) as { id?: number }; + socket.send(JSON.stringify({ id: msg.id, result: {} })); + }); + }); + // The fn throws AFTER sending so both the catch (closeWithError) and + // the finally ws.close() run. ws.close() on an already-closed socket + // is a no-op but exercises the try/catch in the finally. + await expect( + withCdpSocket(server.url, async (send) => { + await send("Test.ok"); + throw new Error("fn post-send boom"); + }), + ).rejects.toThrow(/fn post-send boom/); + }); + }); + + describe("createCdpSender error/close event forwarding", () => { + beforeEach(() => { + // Ensure a fresh mock registry each scenario. + }); + + it("rejects pending calls when the ws emits an error event", async () => { + const server = await startWsServer(); + wss = server.wss; + server.wss.on("connection", (socket) => { + socket.on("message", () => { + // Emit a synthetic error event on the server-side socket. The + // client-side ws will see the abrupt close and surface an error. + socket.terminate(); + }); + }); + await expect( + withCdpSocket(server.url, async (send) => { + await send("Test.boom"); + }), + ).rejects.toThrow(); + }); + + // The non-Error branch of the `err instanceof Error ? ... : new Error(String(err))` + // guard is defensive: node's `ws` library always emits Error instances + // on the 'error' event. Triggering the non-Error branch in a test + // requires synthetically emitting on the client socket, which the + // library then treats as an unhandled error event and hangs the + // suite. The branch is c8-ignored in the source file with an + // accompanying justification. + }); +}); + +describe("openCdpWebSocket option handling", () => { + it("clamps a non-finite handshakeTimeoutMs to the default", () => { + // Exercises the Number.isFinite false side of the handshake-timeout + // ternary in openCdpWebSocket. + const ws = openCdpWebSocket("ws://127.0.0.1:1/devtools/browser/X", { + handshakeTimeoutMs: Number.NaN, + }); + // Ensure we don't leak the socket even though we never await it. + ws.once("error", () => {}); + ws.close(); + }); + + it("honours an explicit, finite handshakeTimeoutMs", () => { + // Exercises the truthy side of the handshake-timeout ternary: both + // typeof === "number" AND Number.isFinite must be true. + const ws = openCdpWebSocket("ws://127.0.0.1:1/devtools/browser/X", { + handshakeTimeoutMs: 500, + }); + ws.once("error", () => {}); + ws.close(); + }); + + it("omits the direct-loopback agent for non-loopback targets", () => { + // Exercises the falsy side of `agent ? { agent } : {}` — the loopback + // agent helper returns undefined for non-loopback hosts. + const ws = openCdpWebSocket("ws://93.184.216.34:9222/devtools/browser/X"); + ws.once("error", () => {}); + ws.close(); + }); + + it("injects custom headers when opts.headers is a non-empty object", () => { + // Exercises the truthy side of `Object.keys(headers).length ? ... : {}`. + const ws = openCdpWebSocket("ws://127.0.0.1:1/devtools/browser/X", { + headers: { "X-Custom": "abc" }, + }); + ws.once("error", () => {}); + ws.close(); + }); +}); diff --git a/extensions/browser/src/browser/cdp.helpers.ts b/extensions/browser/src/browser/cdp.helpers.ts index fa9b1afcaaf..487bad42ce8 100644 --- a/extensions/browser/src/browser/cdp.helpers.ts +++ b/extensions/browser/src/browser/cdp.helpers.ts @@ -33,6 +33,11 @@ export function parseBrowserHttpUrl(raw: string, label: string) { ? 443 : 80; + // WHATWG URL rejects invalid ports (non-numeric, negative, >65535), and + // the ternary above falls back to 80/443 for empty or zero parsed.port, + // so this defensive guard is unreachable at runtime. Kept as a + // belt-and-braces check against parser drift. + /* c8 ignore next 3 */ if (Number.isNaN(port) || port <= 0 || port > 65535) { throw new Error(`${label} has invalid port: ${parsed.port}`); } @@ -58,6 +63,37 @@ export function isWebSocketUrl(url: string): boolean { } } +/** + * Returns true when `url` is a ws/wss URL with a `/devtools//` + * path segment — i.e. a handshake-ready per-browser or per-target CDP + * endpoint that can be opened directly without HTTP discovery. + * + * Bare ws roots (`ws://host:port`, `ws://host:port/`) and any other + * non-`/devtools/...` paths are NOT direct endpoints: Chrome's debug + * port only accepts WebSocket upgrades on the specific path returned + * by `GET /json/version`. Callers with a bare ws root must normalise + * it to http for discovery instead of attempting a root handshake that + * Chrome will reject with HTTP 400. + */ +export function isDirectCdpWebSocketEndpoint(url: string): boolean { + if (!isWebSocketUrl(url)) { + return false; + } + try { + const parsed = new URL(url); + return /\/devtools\/(?:browser|page|worker|shared_worker|service_worker)\/[^/]/i.test( + parsed.pathname, + ); + // isWebSocketUrl above already parsed the same URL successfully, so + // new URL(url) cannot throw here. Kept for structural symmetry with + // the other try/catch URL helpers. + /* c8 ignore start */ + } catch { + return false; + } + /* c8 ignore stop */ +} + export async function assertCdpEndpointAllowed( cdpUrl: string, ssrfPolicy?: SsrFPolicy, @@ -201,6 +237,11 @@ function createCdpSender(ws: WebSocket) { }; 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(err instanceof Error ? err : new Error(String(err))); }); @@ -342,6 +383,11 @@ export async function withCdpSocket( 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(err instanceof Error ? err : new Error(String(err))); throw err; } diff --git a/extensions/browser/src/browser/cdp.internal.test.ts b/extensions/browser/src/browser/cdp.internal.test.ts new file mode 100644 index 00000000000..8bff372dac7 --- /dev/null +++ b/extensions/browser/src/browser/cdp.internal.test.ts @@ -0,0 +1,955 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { type WebSocket, WebSocketServer } from "ws"; +import { rawDataToString } from "../infra/ws.js"; +import { + type AriaSnapshotNode, + captureScreenshot, + captureScreenshotPng, + createTargetViaCdp, + type DomSnapshotNode, + evaluateJavaScript, + formatAriaSnapshot, + getDomText, + normalizeCdpWsUrl, + type QueryMatch, + querySelector, + type RawAXNode, + snapshotAria, + snapshotDom, +} from "./cdp.js"; + +/** + * Exercises the CDP session-oriented exports of cdp.ts against a local + * `ws` server. A single `createCdpMockServer` helper echoes replies + * keyed on method, keeping individual tests short. + */ + +type CdpReplyHandler = ( + msg: { id?: number; method?: string; params?: Record }, + socket: WebSocket, +) => void; + +async function startMockWsServer(handle: CdpReplyHandler) { + const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" }); + await new Promise((resolve) => wss.once("listening", () => resolve())); + const port = (wss.address() as { port: number }).port; + wss.on("connection", (socket) => { + socket.on("message", (raw) => { + const msg = JSON.parse(rawDataToString(raw)) as { + id?: number; + method?: string; + params?: Record; + }; + handle(msg, socket); + }); + }); + return { + wss, + port, + wsUrl: `ws://127.0.0.1:${port}/devtools/browser/TEST`, + }; +} + +describe("cdp internal", () => { + let wss: WebSocketServer | null = null; + + afterEach(async () => { + if (wss) { + await new Promise((resolve) => wss?.close(() => resolve())); + wss = null; + } + }); + + describe("captureScreenshot", () => { + it("captures a PNG without fullPage", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Page.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Page.captureScreenshot") { + expect(msg.params).toMatchObject({ format: "png", captureBeyondViewport: true }); + socket.send( + JSON.stringify({ + id: msg.id, + result: { data: Buffer.from("PNGDATA").toString("base64") }, + }), + ); + } + }); + wss = server.wss; + const buf = await captureScreenshot({ wsUrl: server.wsUrl }); + expect(buf.toString("utf8")).toBe("PNGDATA"); + }); + + it("captureScreenshotPng forwards to the png captureScreenshot flow", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Page.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Page.captureScreenshot") { + expect(msg.params?.format).toBe("png"); + socket.send( + JSON.stringify({ + id: msg.id, + result: { data: Buffer.from("WRAPPED").toString("base64") }, + }), + ); + } + }); + wss = server.wss; + const buf = await captureScreenshotPng({ wsUrl: server.wsUrl }); + expect(buf.toString("utf8")).toBe("WRAPPED"); + }); + + it("clamps out-of-range JPEG quality values into [0, 100]", async () => { + const observed: Array> = []; + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Page.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Page.captureScreenshot") { + observed.push(msg.params ?? {}); + socket.send( + JSON.stringify({ + id: msg.id, + result: { data: Buffer.from("JPG").toString("base64") }, + }), + ); + } + }); + wss = server.wss; + await captureScreenshot({ wsUrl: server.wsUrl, format: "jpeg", quality: 250 }); + expect(observed[0]?.format).toBe("jpeg"); + expect(observed[0]?.quality).toBe(100); + }); + + it("captures fullPage and restores viewport overrides", async () => { + const events: string[] = []; + const server = await startMockWsServer((msg, socket) => { + events.push(msg.method ?? ""); + if (msg.method === "Page.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Page.getLayoutMetrics") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { cssContentSize: { width: 2000, height: 3000 } }, + }), + ); + return; + } + if (msg.method === "Runtime.evaluate") { + // Pre-capture viewport probe + post-capture probe. + const isPre = events.filter((m) => m === "Runtime.evaluate").length === 1; + socket.send( + JSON.stringify({ + id: msg.id, + result: { + result: { + value: isPre + ? { w: 800, h: 600, dpr: 2, sw: 1600, sh: 1200 } + : { w: 2000, h: 3000, dpr: 2 }, + }, + }, + }), + ); + return; + } + if (msg.method === "Emulation.setDeviceMetricsOverride") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Emulation.clearDeviceMetricsOverride") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Page.captureScreenshot") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { data: Buffer.from("FULL").toString("base64") }, + }), + ); + } + }); + wss = server.wss; + const buf = await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true }); + expect(buf.toString("utf8")).toBe("FULL"); + expect(events).toContain("Emulation.setDeviceMetricsOverride"); + expect(events).toContain("Emulation.clearDeviceMetricsOverride"); + }); + + it("restores viewport even when the post-capture probe mismatches", async () => { + // Post probe returns a different dpr than saved → helper reapplies. + const calls: Array> = []; + let evalCount = 0; + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Page.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Page.getLayoutMetrics") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { contentSize: { width: 1200, height: 800 } }, + }), + ); + return; + } + if (msg.method === "Runtime.evaluate") { + evalCount += 1; + socket.send( + JSON.stringify({ + id: msg.id, + result: { + result: { + value: + evalCount === 1 + ? { w: 400, h: 300, dpr: 1, sw: 800, sh: 600 } + : { w: 9999, h: 9999, dpr: 9 }, + }, + }, + }), + ); + return; + } + if (msg.method === "Emulation.setDeviceMetricsOverride") { + calls.push(msg.params ?? {}); + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Emulation.clearDeviceMetricsOverride") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Page.captureScreenshot") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { data: Buffer.from("PIC").toString("base64") }, + }), + ); + } + }); + wss = server.wss; + await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true }); + // Two setDeviceMetricsOverride calls: expand then restore. + expect(calls.length).toBeGreaterThanOrEqual(2); + }); + + it("skips viewport expansion when content size is zero", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Page.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Page.getLayoutMetrics") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { cssContentSize: { width: 0, height: 0 } }, + }), + ); + return; + } + if (msg.method === "Page.captureScreenshot") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { data: Buffer.from("Z").toString("base64") }, + }), + ); + } + }); + wss = server.wss; + const buf = await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true }); + expect(buf.toString("utf8")).toBe("Z"); + }); + + it("throws when Page.captureScreenshot returns no data", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Page.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Page.captureScreenshot") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + } + }); + wss = server.wss; + await expect(captureScreenshot({ wsUrl: server.wsUrl })).rejects.toThrow( + /Screenshot failed: missing data/, + ); + }); + }); + + describe("createTargetViaCdp", () => { + it("throws when Target.createTarget returns no targetId", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Target.createTarget") { + socket.send(JSON.stringify({ id: msg.id, result: { targetId: "" } })); + } + }); + wss = server.wss; + await expect( + createTargetViaCdp({ cdpUrl: server.wsUrl, url: "https://example.com" }), + ).rejects.toThrow(/Target\.createTarget returned no targetId/); + }); + }); + + describe("evaluateJavaScript", () => { + it("throws when Runtime.evaluate returns no result", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Runtime.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Runtime.evaluate") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + } + }); + wss = server.wss; + await expect(evaluateJavaScript({ wsUrl: server.wsUrl, expression: "1" })).rejects.toThrow( + /Runtime\.evaluate returned no result/, + ); + }); + + it("surfaces CDP exceptionDetails alongside result", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Runtime.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Runtime.evaluate") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { + result: { type: "undefined" }, + exceptionDetails: { text: "ReferenceError", lineNumber: 1 }, + }, + }), + ); + } + }); + wss = server.wss; + const res = await evaluateJavaScript({ wsUrl: server.wsUrl, expression: "boom" }); + expect(res.exceptionDetails?.text).toBe("ReferenceError"); + }); + }); + + describe("formatAriaSnapshot", () => { + it("returns an empty array when the AX tree is empty", () => { + expect(formatAriaSnapshot([], 100)).toEqual([]); + }); + + it("returns an empty array when no node has an id", () => { + const nodes = [{ role: { value: "Role" }, name: { value: "" } }] as unknown as RawAXNode[]; + expect(formatAriaSnapshot(nodes, 100)).toEqual([]); + }); + + it("skips child references that are absent from the node map", () => { + const nodes: RawAXNode[] = [ + { + nodeId: "1", + role: { value: "Root" }, + name: { value: "" }, + childIds: ["2", "missing"], + }, + { + nodeId: "2", + role: { value: "Leaf" }, + name: { value: "ok" }, + childIds: [], + }, + ]; + const out: AriaSnapshotNode[] = formatAriaSnapshot(nodes, 100); + // Only the root + the resolvable child — missing is dropped. + expect(out).toHaveLength(2); + expect(out[1]?.name).toBe("ok"); + }); + + it("coerces AX values from strings, numbers, and booleans (with fallback to empty)", () => { + const nodes: RawAXNode[] = [ + { + nodeId: "1", + role: { value: "Root" } as unknown as RawAXNode["role"], + name: { value: 42 } as unknown as RawAXNode["name"], + value: { value: true } as unknown as RawAXNode["value"], + description: { value: {} } as unknown as RawAXNode["description"], + childIds: [], + }, + ]; + const out = formatAriaSnapshot(nodes, 100); + expect(out[0]?.role).toBe("Root"); + expect(out[0]?.name).toBe("42"); + expect(out[0]?.value).toBe("true"); + // Unknown/object-shaped AX value → falls back to empty → omitted. + expect(out[0]?.description).toBeUndefined(); + }); + + it("respects the limit argument", () => { + const nodes: RawAXNode[] = Array.from({ length: 10 }, (_, i) => ({ + nodeId: String(i + 1), + role: { value: `Role${i + 1}` }, + name: { value: "" }, + childIds: i === 0 ? ["2", "3", "4", "5", "6", "7", "8", "9", "10"] : [], + })); + const out = formatAriaSnapshot(nodes, 3); + expect(out).toHaveLength(3); + }); + }); + + describe("snapshotAria", () => { + it("forwards the happy-path tree to formatAriaSnapshot", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Accessibility.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Accessibility.getFullAXTree") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { + nodes: [ + { nodeId: "1", role: { value: "Root" }, name: { value: "" }, childIds: [] }, + ], + }, + }), + ); + } + }); + wss = server.wss; + const snap = await snapshotAria({ wsUrl: server.wsUrl, limit: 50 }); + expect(snap.nodes[0]?.role).toBe("Root"); + }); + + it("returns an empty list when the server omits nodes", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Accessibility.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Accessibility.getFullAXTree") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + } + }); + wss = server.wss; + const snap = await snapshotAria({ wsUrl: server.wsUrl }); + expect(snap.nodes).toEqual([]); + }); + }); + + describe("snapshotDom", () => { + it("returns the nodes array from the evaluated expression", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Runtime.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Runtime.evaluate") { + const fake: DomSnapshotNode[] = [{ ref: "n1", parentRef: null, depth: 0, tag: "html" }]; + socket.send( + JSON.stringify({ + id: msg.id, + result: { result: { value: { nodes: fake } } }, + }), + ); + } + }); + wss = server.wss; + const snap = await snapshotDom({ wsUrl: server.wsUrl, limit: 10, maxTextChars: 200 }); + expect(snap.nodes[0]?.tag).toBe("html"); + }); + + it("returns an empty nodes array when the value is not an object", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Runtime.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Runtime.evaluate") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { result: { value: null } }, + }), + ); + } + }); + wss = server.wss; + const snap = await snapshotDom({ wsUrl: server.wsUrl }); + expect(snap.nodes).toEqual([]); + }); + + it("returns an empty nodes array when nodes is not an array", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Runtime.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Runtime.evaluate") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { result: { value: { nodes: "not-an-array" } } }, + }), + ); + } + }); + wss = server.wss; + const snap = await snapshotDom({ wsUrl: server.wsUrl }); + expect(snap.nodes).toEqual([]); + }); + }); + + describe("getDomText", () => { + it("returns the evaluated string for text format", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Runtime.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Runtime.evaluate") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { result: { value: "plain body text" } }, + }), + ); + } + }); + wss = server.wss; + const res = await getDomText({ wsUrl: server.wsUrl, format: "text", maxChars: 100 }); + expect(res.text).toBe("plain body text"); + }); + + it("returns the html outerHTML for html format with a selector", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Runtime.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Runtime.evaluate") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { result: { value: "
html
" } }, + }), + ); + } + }); + wss = server.wss; + const res = await getDomText({ + wsUrl: server.wsUrl, + format: "html", + selector: "#foo", + }); + expect(res.text).toBe("
html
"); + }); + + it("coerces numeric/boolean values to strings and falls back to empty for objects", async () => { + const responses: unknown[] = [42, true, { shape: "object" }]; + let i = 0; + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Runtime.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Runtime.evaluate") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { result: { value: responses[i++] } }, + }), + ); + } + }); + wss = server.wss; + const num = await getDomText({ wsUrl: server.wsUrl, format: "text" }); + expect(num.text).toBe("42"); + const bool = await getDomText({ wsUrl: server.wsUrl, format: "text" }); + expect(bool.text).toBe("true"); + const obj = await getDomText({ wsUrl: server.wsUrl, format: "text" }); + expect(obj.text).toBe(""); + }); + }); + + describe("querySelector", () => { + it("returns the matches array from the evaluated expression", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Runtime.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Runtime.evaluate") { + const matches: QueryMatch[] = [{ index: 1, tag: "button", text: "OK" }]; + socket.send(JSON.stringify({ id: msg.id, result: { result: { value: matches } } })); + } + }); + wss = server.wss; + const out = await querySelector({ + wsUrl: server.wsUrl, + selector: "button", + limit: 5, + maxTextChars: 100, + maxHtmlChars: 500, + }); + expect(out.matches[0]?.tag).toBe("button"); + }); + + it("returns an empty array when the value is not an array", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Runtime.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Runtime.evaluate") { + socket.send(JSON.stringify({ id: msg.id, result: { result: { value: "not-array" } } })); + } + }); + wss = server.wss; + const out = await querySelector({ wsUrl: server.wsUrl, selector: "button" }); + expect(out.matches).toEqual([]); + }); + }); + + describe("normalizeCdpWsUrl fill-in", () => { + it("respects an already-non-loopback ws hostname (no-rewrite branch)", () => { + // Covers the else side of the loopback/wildcard-guard in normalizeCdpWsUrl. + const out = normalizeCdpWsUrl( + "ws://non-loopback.example:9222/devtools/browser/ABC", + "http://non-loopback.example:9222", + ); + expect(out).toContain("non-loopback.example:9222"); + }); + + it("falls back to protocol-default ports when the cdp URL omits a port", () => { + // Covers the right-hand side of `cdp.port || (cdp.protocol === 'https:' ? '443' : '80')`. + // WHATWG URL elides default ports (443 for wss, 80 for ws) in the + // serialized form, so we assert the scheme + host rather than port. + const secure = normalizeCdpWsUrl( + "ws://127.0.0.1:9222/devtools/browser/ABC", + "https://example.com/", + ); + expect(secure).toBe("wss://example.com/devtools/browser/ABC"); + const plain = normalizeCdpWsUrl( + "ws://127.0.0.1:9222/devtools/browser/ABC", + "http://example.com/", + ); + expect(plain).toBe("ws://example.com/devtools/browser/ABC"); + }); + }); + + describe("captureScreenshot branch coverage", () => { + it("uses the default jpeg quality when opts.quality is omitted", async () => { + const observed: Array> = []; + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Page.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Page.captureScreenshot") { + observed.push(msg.params ?? {}); + socket.send( + JSON.stringify({ + id: msg.id, + result: { data: Buffer.from("J").toString("base64") }, + }), + ); + } + }); + wss = server.wss; + await captureScreenshot({ wsUrl: server.wsUrl, format: "jpeg" }); + expect(observed[0]?.quality).toBe(85); + }); + + it("defaults fullPage content/viewport fields to 0 when the page reports nothing", async () => { + // Covers the right-hand sides of `size?.width ?? 0`, `size?.height ?? 0`, + // `v?.w ?? 0`, `v?.h ?? 0`, `v?.dpr ?? 1`, `v?.sw ?? currentW`, `v?.sh ?? currentH`. + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Page.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Page.getLayoutMetrics") { + // Both cssContentSize and contentSize absent — forces the + // `?? 0` default on width/height. + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Page.captureScreenshot") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { data: Buffer.from("N").toString("base64") }, + }), + ); + } + }); + wss = server.wss; + const buf = await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true }); + expect(buf.toString("utf8")).toBe("N"); + }); + + it("falls back to the non-css contentSize when cssContentSize is absent", async () => { + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Page.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Page.getLayoutMetrics") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { contentSize: { width: 100, height: 200 } }, + }), + ); + return; + } + if (msg.method === "Runtime.evaluate") { + // viewport probe with a completely empty value to exercise all + // `v?.X ?? default` branches. + socket.send(JSON.stringify({ id: msg.id, result: { result: { value: {} } } })); + return; + } + if (msg.method === "Emulation.setDeviceMetricsOverride") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Emulation.clearDeviceMetricsOverride") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Page.captureScreenshot") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { data: Buffer.from("C").toString("base64") }, + }), + ); + } + }); + wss = server.wss; + const buf = await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true }); + expect(buf.toString("utf8")).toBe("C"); + }); + }); + + describe("createTargetViaCdp branch coverage", () => { + it("normalises a bare ws:// CDP URL to http for /json/version discovery", async () => { + // Covers the truthy side of `isWebSocketUrl(opts.cdpUrl) ? normalize... : opts.cdpUrl` + // in createTargetViaCdp — the bare-ws root triggers discovery. + const http = await import("node:http"); + const wsServer = new WebSocketServer({ port: 0, host: "127.0.0.1" }); + await new Promise((resolve) => wsServer.once("listening", () => resolve())); + const wsPort = (wsServer.address() as { port: number }).port; + wsServer.on("connection", (socket) => { + socket.on("message", (raw) => { + const msg = JSON.parse(rawDataToString(raw)) as { id?: number; method?: string }; + if (msg.method === "Target.createTarget") { + socket.send(JSON.stringify({ id: msg.id, result: { targetId: "T_BARE_WS" } })); + } + }); + }); + const httpServer = http.createServer((req, res) => { + if (req.url === "/json/version") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/BARE_WS`, + }), + ); + return; + } + res.writeHead(404).end(); + }); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", () => resolve())); + const httpPort = (httpServer.address() as { port: number }).port; + try { + const out = await createTargetViaCdp({ + cdpUrl: `ws://127.0.0.1:${httpPort}`, // bare ws root → forces discovery + url: "https://example.com", + }); + expect(out.targetId).toBe("T_BARE_WS"); + } finally { + await new Promise((resolve) => wsServer.close(() => resolve())); + await new Promise((resolve) => httpServer.close(() => resolve())); + } + }); + + it("throws when Target.createTarget returns a missing (undefined) targetId", async () => { + // Covers the right-hand side of `created?.targetId?.trim() ?? ""` (?? ""). + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Target.createTarget") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + } + }); + wss = server.wss; + await expect( + createTargetViaCdp({ cdpUrl: server.wsUrl, url: "https://example.com" }), + ).rejects.toThrow(/Target\.createTarget returned no targetId/); + }); + }); + + describe("formatAriaSnapshot branch coverage", () => { + it("falls back to 'unknown' role and omits empty value/description", () => { + // role "" triggers `role || "unknown"`; value/description empty + // triggers the falsy side of `value ? { value } : {}`. + const nodes: RawAXNode[] = [ + { + nodeId: "1", + role: { value: "" }, + name: { value: "n" }, + value: { value: "" }, + description: { value: "" }, + childIds: [], + }, + ]; + const out = formatAriaSnapshot(nodes, 100); + expect(out[0]?.role).toBe("unknown"); + expect(out[0]?.value).toBeUndefined(); + expect(out[0]?.description).toBeUndefined(); + }); + + it("includes the description field when the AX node provides a truthy description", () => { + // Covers the truthy side of `description ? { description } : {}`. + const nodes: RawAXNode[] = [ + { + nodeId: "1", + role: { value: "Button" }, + name: { value: "n" }, + description: { value: "explanatory" }, + childIds: [], + }, + ]; + const out = formatAriaSnapshot(nodes, 100); + expect(out[0]?.description).toBe("explanatory"); + }); + + it("defaults childIds to an empty array when the AX node omits the field", () => { + // Covers the right-hand side of `(n.childIds ?? [])`. + const nodes: RawAXNode[] = [ + { + nodeId: "solo", + role: { value: "Leaf" }, + name: { value: "" }, + }, + ]; + const out = formatAriaSnapshot(nodes, 100); + expect(out).toHaveLength(1); + }); + }); + + describe(".catch(() => {}) swallow arrows", () => { + it("swallows a failing Accessibility.enable in snapshotAria", async () => { + // Exercises the `.catch(() => {})` arrow on `Accessibility.enable`. + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Accessibility.enable") { + socket.send(JSON.stringify({ id: msg.id, error: { message: "denied" } })); + return; + } + if (msg.method === "Accessibility.getFullAXTree") { + socket.send(JSON.stringify({ id: msg.id, result: { nodes: [] } })); + } + }); + wss = server.wss; + const snap = await snapshotAria({ wsUrl: server.wsUrl }); + expect(snap.nodes).toEqual([]); + }); + + it("swallows a failing Runtime.enable in evaluateJavaScript", async () => { + // Exercises the `.catch(() => {})` arrow on `Runtime.enable`. + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Runtime.enable") { + socket.send(JSON.stringify({ id: msg.id, error: { message: "denied" } })); + return; + } + if (msg.method === "Runtime.evaluate") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { result: { type: "number", value: 1 } }, + }), + ); + } + }); + wss = server.wss; + const res = await evaluateJavaScript({ wsUrl: server.wsUrl, expression: "1" }); + expect(res.result.value).toBe(1); + }); + + it("swallows a failing Emulation.clearDeviceMetricsOverride in the screenshot finally", async () => { + // Exercises the `.catch(() => {})` on clearDeviceMetricsOverride inside + // the fullPage finally block. + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Page.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Page.getLayoutMetrics") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { cssContentSize: { width: 800, height: 600 } }, + }), + ); + return; + } + if (msg.method === "Runtime.evaluate") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { result: { value: { w: 400, h: 300, dpr: 1, sw: 800, sh: 600 } } }, + }), + ); + return; + } + if (msg.method === "Emulation.setDeviceMetricsOverride") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Emulation.clearDeviceMetricsOverride") { + socket.send(JSON.stringify({ id: msg.id, error: { message: "denied" } })); + return; + } + if (msg.method === "Page.captureScreenshot") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { data: Buffer.from("S").toString("base64") }, + }), + ); + } + }); + wss = server.wss; + const buf = await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true }); + expect(buf.toString("utf8")).toBe("S"); + }); + }); + + describe("getDomText branch coverage", () => { + it("coerces a missing evaluated value to an empty string", async () => { + // Covers the right-hand side of `evaluated.result?.value ?? ""`. + const server = await startMockWsServer((msg, socket) => { + if (msg.method === "Runtime.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Runtime.evaluate") { + socket.send(JSON.stringify({ id: msg.id, result: { result: {} } })); + } + }); + wss = server.wss; + const res = await getDomText({ wsUrl: server.wsUrl, format: "text" }); + expect(res.text).toBe(""); + }); + }); +}); diff --git a/extensions/browser/src/browser/cdp.test.ts b/extensions/browser/src/browser/cdp.test.ts index a50daca9153..d44d4bf075a 100644 --- a/extensions/browser/src/browser/cdp.test.ts +++ b/extensions/browser/src/browser/cdp.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type WebSocket, WebSocketServer } from "ws"; import { SsrFBlockedError } from "../infra/net/ssrf.js"; import { rawDataToString } from "../infra/ws.js"; -import { isWebSocketUrl } from "./cdp.helpers.js"; +import { isDirectCdpWebSocketEndpoint, isWebSocketUrl } from "./cdp.helpers.js"; import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js"; import { parseHttpUrl } from "./config.js"; import { BrowserCdpEndpointBlockedError } from "./errors.js"; @@ -171,7 +171,7 @@ describe("cdp", () => { expect(receivedHeaders.host).toBe(`127.0.0.1:${wsPort}`); }); - it("still enforces SSRF policy for direct WebSocket URLs", async () => { + it("enforces SSRF policy on the navigation target URL before any CDP connection attempt", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); try { await expect( @@ -329,7 +329,7 @@ describe("cdp", () => { expect(res.result.value).toBe(2); }); - it("fails when /json/version omits webSocketDebuggerUrl", async () => { + it("fails when /json/version omits webSocketDebuggerUrl for an HTTP cdpUrl", async () => { const httpPort = await startVersionHttpServer({}); await expect( createTargetViaCdp({ @@ -339,6 +339,23 @@ describe("cdp", () => { ).rejects.toThrow("CDP /json/version missing webSocketDebuggerUrl"); }); + it("falls back to direct WS connection when /json/version is unavailable for a bare ws:// cdpUrl", async () => { + // Simulates a Browserless/Browserbase-style provider: the cdpUrl IS a + // WebSocket root (no /devtools/ path) but there is no HTTP /json/version + // endpoint. The WS server accepts Target.createTarget directly. + const wsPort = await startWsServerWithMessages((msg, socket) => { + if (msg.method === "Target.createTarget") { + socket.send(JSON.stringify({ id: msg.id, result: { targetId: "WS_FALLBACK" } })); + } + }); + // No HTTP server on this port — discovery will fail, triggering the fallback. + const created = await createTargetViaCdp({ + cdpUrl: `ws://127.0.0.1:${wsPort}`, + url: "https://example.com", + }); + expect(created.targetId).toBe("WS_FALLBACK"); + }); + it("captures an aria snapshot via CDP", async () => { const wsPort = await startWsServerWithMessages((msg, socket) => { if (msg.method === "Accessibility.enable") { @@ -480,6 +497,41 @@ describe("isWebSocketUrl", () => { }); }); +describe("isDirectCdpWebSocketEndpoint", () => { + it("returns true for ws/wss URLs with a /devtools// path", () => { + expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools/browser/ABC")).toBe(true); + expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools/page/42")).toBe(true); + expect(isDirectCdpWebSocketEndpoint("wss://connect.example.com/devtools/browser/xyz")).toBe( + true, + ); + expect( + isDirectCdpWebSocketEndpoint("wss://connect.example.com/devtools/browser/xyz?token=secret"), + ).toBe(true); + }); + + it("returns false for bare ws/wss URLs without a /devtools/ path (needs discovery)", () => { + // Reproduces the configuration shape reported in #68027. + expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222")).toBe(false); + expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/")).toBe(false); + expect(isDirectCdpWebSocketEndpoint("wss://browserless.example")).toBe(false); + expect(isDirectCdpWebSocketEndpoint("wss://browserless.example/?token=abc")).toBe(false); + }); + + it("returns false for ws URLs whose path is not /devtools/*", () => { + expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/json/version")).toBe(false); + expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools")).toBe(false); + expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools/")).toBe(false); + expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/other/path")).toBe(false); + }); + + it("returns false for http/https URLs, invalid URLs, and empty strings", () => { + expect(isDirectCdpWebSocketEndpoint("http://127.0.0.1:9222/devtools/browser/ABC")).toBe(false); + expect(isDirectCdpWebSocketEndpoint("https://host/devtools/browser/ABC")).toBe(false); + expect(isDirectCdpWebSocketEndpoint("not-a-url")).toBe(false); + expect(isDirectCdpWebSocketEndpoint("")).toBe(false); + }); +}); + describe("parseHttpUrl with WebSocket protocols", () => { it("accepts wss:// URLs and defaults to port 443", () => { const result = parseHttpUrl("wss://connect.example.com?apiKey=abc", "test"); diff --git a/extensions/browser/src/browser/cdp.ts b/extensions/browser/src/browser/cdp.ts index 32866c8a280..d7079719c1a 100644 --- a/extensions/browser/src/browser/cdp.ts +++ b/extensions/browser/src/browser/cdp.ts @@ -3,8 +3,10 @@ import { appendCdpPath, assertCdpEndpointAllowed, fetchJson, + isDirectCdpWebSocketEndpoint, isLoopbackHost, isWebSocketUrl, + normalizeCdpHttpBaseForJsonEndpoints, withCdpSocket, } from "./cdp.helpers.js"; import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js"; @@ -27,6 +29,10 @@ export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string { if ((isLoopbackHost(ws.hostname) || isWildcardBind) && !isLoopbackHost(cdp.hostname)) { ws.hostname = cdp.hostname; const cdpPort = cdp.port || (cdp.protocol === "https:" ? "443" : "80"); + // `cdpPort` is always truthy: either the explicit cdp.port (truthy + // string), or the "443"/"80" default from the ternary. The guard is + // defensive against future parser edge cases. + /* c8 ignore next 3 */ if (cdpPort) { ws.port = cdpPort; } @@ -179,21 +185,43 @@ export async function createTargetViaCdp(opts: { }); let wsUrl: string; - if (isWebSocketUrl(opts.cdpUrl)) { - // Direct WebSocket URL — skip /json/version discovery. + if (isDirectCdpWebSocketEndpoint(opts.cdpUrl)) { + // Handshake-ready direct WebSocket URL — skip /json/version discovery. await assertCdpEndpointAllowed(opts.cdpUrl, opts.ssrfPolicy); wsUrl = opts.cdpUrl; } else { - // Standard HTTP(S) CDP endpoint — discover WebSocket URL via /json/version. - const version = await fetchJson<{ webSocketDebuggerUrl?: string }>( - appendCdpPath(opts.cdpUrl, "/json/version"), - 1500, - undefined, - opts.ssrfPolicy, - ); + // Either an HTTP(S) CDP endpoint or a bare ws/wss root. Try + // /json/version discovery first. For bare ws/wss URLs, fall back to + // using the URL itself as a direct WS endpoint when discovery is + // unavailable — some providers (e.g. Browserless/Browserbase) expose + // a direct WebSocket root without a /json/version route. + const discoveryUrl = isWebSocketUrl(opts.cdpUrl) + ? normalizeCdpHttpBaseForJsonEndpoints(opts.cdpUrl) + : opts.cdpUrl; + let version: { webSocketDebuggerUrl?: string } | null = null; + try { + version = await fetchJson<{ webSocketDebuggerUrl?: string }>( + appendCdpPath(discoveryUrl, "/json/version"), + 1500, + undefined, + opts.ssrfPolicy, + ); + } catch (err) { + // Discovery failed for an HTTP/HTTPS URL — propagate immediately. + if (!isWebSocketUrl(opts.cdpUrl)) { + throw err; + } + // For bare ws/wss URLs, fall through: /json/version is unavailable + // so we attempt to use opts.cdpUrl as a direct WS endpoint below. + } const wsUrlRaw = version?.webSocketDebuggerUrl?.trim() ?? ""; - wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : ""; - if (!wsUrl) { + if (wsUrlRaw) { + wsUrl = normalizeCdpWsUrl(wsUrlRaw, discoveryUrl); + } else if (isWebSocketUrl(opts.cdpUrl)) { + // /json/version unavailable or returned no WebSocket URL. Treat the + // original URL as a direct WebSocket endpoint. + wsUrl = opts.cdpUrl; + } else { throw new Error("CDP /json/version missing webSocketDebuggerUrl"); } await assertCdpEndpointAllowed(wsUrl, opts.ssrfPolicy); @@ -316,11 +344,17 @@ export function formatAriaSnapshot(nodes: RawAXNode[], limit: number): AriaSnaps const stack: Array<{ id: string; depth: number }> = [{ id: root.nodeId, depth: 0 }]; while (stack.length && out.length < limit) { const popped = stack.pop(); + // `stack.pop()` only returns undefined on an empty stack, but the + // while guard already asserts `stack.length > 0`. Dead defensive guard. + /* c8 ignore next 3 */ if (!popped) { break; } const { id, depth } = popped; const n = byId.get(id); + // Every id pushed onto the stack came from `children.filter(c => byId.has(c))`, + // so byId.get(id) is always defined here. Dead defensive guard. + /* c8 ignore next 3 */ if (!n) { continue; } @@ -342,6 +376,9 @@ export function formatAriaSnapshot(nodes: RawAXNode[], limit: number): AriaSnaps const children = (n.childIds ?? []).filter((c) => byId.has(c)); for (let i = children.length - 1; i >= 0; i--) { const child = children[i]; + // `children` is a string[] from an array filter over RawAXNode.childIds, + // so `child` is always a defined string here. Dead defensive guard. + /* c8 ignore next 3 */ if (child) { stack.push({ id: child, depth: depth + 1 }); } diff --git a/extensions/browser/src/browser/chrome.internal.test.ts b/extensions/browser/src/browser/chrome.internal.test.ts new file mode 100644 index 00000000000..da7c54b66c7 --- /dev/null +++ b/extensions/browser/src/browser/chrome.internal.test.ts @@ -0,0 +1,1012 @@ +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WebSocketServer } from "ws"; +import { rawDataToString } from "../infra/ws.js"; + +const spawnMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + spawn: (...args: unknown[]) => spawnMock(...args), + }; +}); + +const ensurePortAvailableMock = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("../infra/ports.js", () => ({ + ensurePortAvailable: ensurePortAvailableMock, +})); + +// Shrink long launch/bootstrap timeouts so tests don't wait 15s for +// the CHROME_LAUNCH_READY_WINDOW_MS elapse-on-failure path. +vi.mock("./cdp-timeouts.js", async () => { + const actual = await vi.importActual("./cdp-timeouts.js"); + return { + ...actual, + CHROME_LAUNCH_READY_WINDOW_MS: 300, + CHROME_LAUNCH_READY_POLL_MS: 25, + CHROME_BOOTSTRAP_PREFS_TIMEOUT_MS: 200, + CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS: 100, + }; +}); + +import { + buildOpenClawChromeLaunchArgs, + getChromeWebSocketUrl, + isChromeCdpReady, + isChromeReachable, + launchOpenClawChrome, + resolveOpenClawUserDataDir, + stopOpenClawChrome, +} from "./chrome.js"; +import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js"; + +/** + * Covers the parts of chrome.ts that the mainline chrome.test.ts does + * not exercise: launchOpenClawChrome (with child_process.spawn mocked), + * canRunCdpHealthCommand all branches, canOpenWebSocket failure, + * stopOpenClawChrome SIGKILL fallback, fs.exists() catch, default + * profile name, buildOpenClawChromeLaunchArgs branches, and friends. + */ + +type FakeProc = EventEmitter & { + pid?: number; + killed: boolean; + exitCode: number | null; + kill: (sig?: string) => boolean; + stderr: EventEmitter; +}; + +function makeFakeProc(overrides: Partial = {}): FakeProc { + const stderr = new EventEmitter(); + const proc = Object.assign(new EventEmitter(), { + pid: 4242, + killed: false, + exitCode: null, + kill: vi.fn((_sig?: string) => { + proc.killed = true; + return true; + }), + stderr, + }) as unknown as FakeProc; + return Object.assign(proc, overrides); +} + +async function withMockChromeCdpServer(params: { + wsPath: string; + onConnection?: (wss: WebSocketServer) => void; + run: (baseUrl: string) => Promise; +}) { + const server = createServer((req, res) => { + 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}${params.wsPath}`, + }), + ); + return; + } + res.writeHead(404); + res.end(); + }); + const wss = new WebSocketServer({ noServer: true }); + server.on("upgrade", (req, socket, head) => { + if (req.url !== params.wsPath) { + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + }); + params.onConnection?.(wss); + 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 params.run(`http://127.0.0.1:${addr.port}`); + } finally { + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + } +} + +describe("chrome.ts internal", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + spawnMock.mockReset(); + ensurePortAvailableMock.mockReset(); + ensurePortAvailableMock.mockImplementation(async () => {}); + }); + + describe("resolveOpenClawUserDataDir", () => { + it("falls back to the default profile name when none is supplied", () => { + const dir = resolveOpenClawUserDataDir(); + expect(dir.endsWith(path.join("openclaw", "user-data"))).toBe(true); + }); + + it("respects an explicit profile name", () => { + const dir = resolveOpenClawUserDataDir("my-profile"); + expect(dir.endsWith(path.join("my-profile", "user-data"))).toBe(true); + }); + }); + + describe("buildOpenClawChromeLaunchArgs branches", () => { + const baseResolved = (overrides: Partial = {}): ResolvedBrowserConfig => + ({ + headless: false, + noSandbox: false, + extraArgs: [], + ...overrides, + }) as unknown as ResolvedBrowserConfig; + + const baseProfile: ResolvedBrowserProfile = { + name: "openclaw", + color: "#FF4500", + cdpPort: 19222, + cdpUrl: "http://127.0.0.1:19222", + cdpIsLoopback: true, + } as unknown as ResolvedBrowserProfile; + + it("toggles headless args", () => { + const args = buildOpenClawChromeLaunchArgs({ + resolved: baseResolved({ headless: true }), + profile: baseProfile, + userDataDir: "/tmp/foo", + }); + expect(args).toContain("--headless=new"); + expect(args).toContain("--disable-gpu"); + }); + + it("toggles no-sandbox args", () => { + const args = buildOpenClawChromeLaunchArgs({ + resolved: baseResolved({ noSandbox: true }), + profile: baseProfile, + userDataDir: "/tmp/foo", + }); + expect(args).toContain("--no-sandbox"); + expect(args).toContain("--disable-setuid-sandbox"); + }); + + it("adds --disable-dev-shm-usage on linux", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "linux" }); + try { + const args = buildOpenClawChromeLaunchArgs({ + resolved: baseResolved(), + profile: baseProfile, + userDataDir: "/tmp/foo", + }); + expect(args).toContain("--disable-dev-shm-usage"); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + } + }); + + it("propagates extraArgs", () => { + const args = buildOpenClawChromeLaunchArgs({ + resolved: baseResolved({ + extraArgs: ["--proxy-server=http://localhost:3128", "--mute-audio"], + }), + profile: baseProfile, + userDataDir: "/tmp/foo", + }); + expect(args).toContain("--proxy-server=http://localhost:3128"); + expect(args).toContain("--mute-audio"); + }); + }); + + describe("fs.exists() catch branch", () => { + it("treats a throwing fs.existsSync (for prefs files) as non-existent to force bootstrap", async () => { + // Make existsSync throw ONLY for Local State / Preferences checks + // — other candidate-executable probes still return true so + // resolveBrowserExecutable succeeds and we actually reach the + // exists() invocation inside launchOpenClawChrome. + const existsSpy = vi.spyOn(fs, "existsSync").mockImplementation((p) => { + const s = String(p); + if (s.endsWith("Local State") || s.endsWith("Preferences")) { + throw new Error("EACCES"); + } + if (s.includes("Google Chrome")) { + return true; + } + return false; + }); + spawnMock.mockImplementation(() => makeFakeProc()); + + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/CATCH_EXISTS", + run: async (baseUrl) => { + const port = Number(new URL(baseUrl).port); + const profile = { + name: "openclaw", + color: "#FF4500", + cdpPort: port, + cdpUrl: baseUrl, + cdpIsLoopback: true, + } as unknown as ResolvedBrowserProfile; + const resolved = { + headless: true, + noSandbox: true, + extraArgs: [], + } as unknown as ResolvedBrowserConfig; + const running = await launchOpenClawChrome(resolved, profile); + running.proc.kill?.("SIGTERM"); + }, + }); + existsSpy.mockRestore(); + }); + }); + + describe("launchOpenClawChrome", () => { + let tmpDir = ""; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-launch-")); + }); + + afterEach(async () => { + if (tmpDir) { + await fsp.rm(tmpDir, { recursive: true, force: true }); + } + }); + + const makeProfile = (cdpPort: number): ResolvedBrowserProfile => + ({ + name: path.basename(tmpDir), + color: "#FF4500", + cdpPort, + cdpUrl: `http://127.0.0.1:${cdpPort}`, + cdpIsLoopback: true, + }) as unknown as ResolvedBrowserProfile; + + const makeResolved = (): ResolvedBrowserConfig => + ({ + headless: true, + noSandbox: true, + extraArgs: [], + }) as unknown as ResolvedBrowserConfig; + + it("rejects a remote profile before attempting to spawn", async () => { + const profile = { + name: "openclaw", + color: "#FF4500", + cdpPort: 19222, + cdpUrl: "http://example.com:19222", + cdpIsLoopback: false, + } as unknown as ResolvedBrowserProfile; + await expect(launchOpenClawChrome(makeResolved(), profile)).rejects.toThrow( + /is remote; cannot launch local Chrome/, + ); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it("throws when no supported browser executable is found", async () => { + // Strip all candidate executables — override config so no explicit + // path is set, then mock existsSync to return false for everything. + vi.spyOn(fs, "existsSync").mockReturnValue(false); + const profile = makeProfile(51111); + await expect(launchOpenClawChrome(makeResolved(), profile)).rejects.toThrow( + /No supported browser found/, + ); + }); + + it("completes successfully when Chrome reports /json/version and CDP is reachable", async () => { + // Mock executable discovery to a truthy path. + vi.spyOn(fs, "existsSync").mockImplementation((p) => { + const s = String(p); + // Pretend the mac Chrome binary exists and the preference files exist. + if (s.includes("Google Chrome")) { + return true; + } + if (s.endsWith("Local State") || s.endsWith("Preferences")) { + return true; + } + return false; + }); + + let spawnCalls = 0; + spawnMock.mockImplementation(() => { + spawnCalls += 1; + return makeFakeProc(); + }); + + // Set up a real HTTP server impersonating Chrome's /json/version. + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/LAUNCHED", + run: async (baseUrl) => { + const port = new URL(baseUrl).port; + const profile = makeProfile(Number(port)); + const running = await launchOpenClawChrome(makeResolved(), profile); + expect(running.pid).toBe(4242); + expect(spawnCalls).toBeGreaterThanOrEqual(1); + // Cleanup. + running.proc.kill?.("SIGTERM"); + }, + }); + }); + + it("throws with stderr hint + sandbox hint when CDP never becomes reachable", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "linux" }); + try { + vi.spyOn(fs, "existsSync").mockImplementation((p) => { + const s = String(p); + if (s.includes("google-chrome")) { + return true; + } + return false; + }); + const fakeProc = makeFakeProc(); + spawnMock.mockReturnValue(fakeProc); + // Leak some stderr into the buffer so the hint renders. + setTimeout(() => fakeProc.stderr.emit("data", Buffer.from("crash dump\n")), 10); + + // fetch always fails → isChromeReachable returns false every poll. + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED"))); + + const resolved = { + headless: false, + noSandbox: false, // sandbox hint will render on linux + extraArgs: [], + } as unknown as ResolvedBrowserConfig; + const profile = makeProfile(55555); + await expect(launchOpenClawChrome(resolved, profile)).rejects.toThrow( + /Failed to start Chrome CDP/, + ); + expect(fakeProc.kill).toHaveBeenCalledWith("SIGKILL"); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + } + }); + }); + + describe("stopOpenClawChrome SIGKILL fallback", () => { + it("escalates to SIGKILL when CDP keeps reporting reachable past the deadline", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }), + } as unknown as Response), + ); + const proc = makeFakeProc(); + await stopOpenClawChrome( + { proc, cdpPort: 12345 } as unknown as Parameters[0], + 1, + ); + expect(proc.kill).toHaveBeenNthCalledWith(1, "SIGTERM"); + expect(proc.kill).toHaveBeenNthCalledWith(2, "SIGKILL"); + }); + }); + + describe("fetchChromeVersion non-object branch", () => { + it("returns null when the /json/version response JSON is not an object", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => null, + } as unknown as Response), + ); + // isChromeReachable invokes fetchChromeVersion; when it returns null, + // Boolean(null) === false → reachability is false. + await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false); + }); + }); + + describe("getChromeWebSocketUrl missing-debugger-url", () => { + it("returns null when /json/version omits webSocketDebuggerUrl", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ Browser: "Chrome/Mock" }), + } as unknown as Response), + ); + await expect(getChromeWebSocketUrl("http://127.0.0.1:12345", 50)).resolves.toBeNull(); + }); + }); + + describe("isChromeCdpReady no-ws-url branch", () => { + it("returns false when getChromeWebSocketUrl resolves to null", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + } as unknown as Response), + ); + await expect(isChromeCdpReady("http://127.0.0.1:12345", 50, 50)).resolves.toBe(false); + }); + }); + + describe("canRunCdpHealthCommand branches", () => { + it("returns false when the ws upgrade is refused", async () => { + // isChromeCdpReady -> getChromeWebSocketUrl -> canRunCdpHealthCommand. + // Point at a port that doesn't accept ws upgrades at the /devtools path + // to trigger the error-event branch. + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/MISMATCH", + onConnection: (wss) => { + wss.on("connection", (_ws) => { + // Accept but never respond → timeout-based failure. + }); + }, + run: async (baseUrl) => { + await expect(isChromeCdpReady(baseUrl, 300, 100)).resolves.toBe(false); + }, + }); + }); + + it("returns false when the health command response is malformed JSON", async () => { + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/BAD_JSON", + onConnection: (wss) => { + wss.on("connection", (ws) => { + ws.on("message", () => { + ws.send("not-json-at-all"); + setTimeout(() => ws.close(), 50); + }); + }); + }, + run: async (baseUrl) => { + await expect(isChromeCdpReady(baseUrl, 300, 200)).resolves.toBe(false); + }, + }); + }); + + it("ignores messages whose id does not match the health probe id", async () => { + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/WRONG_ID", + onConnection: (wss) => { + wss.on("connection", (ws) => { + ws.on("message", () => { + ws.send(JSON.stringify({ id: 42, result: { product: "Chrome" } })); + setTimeout(() => ws.close(), 50); + }); + }); + }, + run: async (baseUrl) => { + await expect(isChromeCdpReady(baseUrl, 300, 200)).resolves.toBe(false); + }, + }); + }); + + it("returns true when Browser.getVersion responds with an object", async () => { + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/OK", + onConnection: (wss) => { + wss.on("connection", (ws) => { + ws.on("message", (raw) => { + const text = rawDataToString(raw); + const msg = JSON.parse(text) as { id?: number }; + if (msg.id === 1) { + ws.send(JSON.stringify({ id: 1, result: { product: "Chrome/Mock" } })); + } + }); + }); + }, + run: async (baseUrl) => { + await expect(isChromeCdpReady(baseUrl, 300, 400)).resolves.toBe(true); + }, + }); + }); + }); + + describe("canOpenWebSocket", () => { + it("resolves false when the direct-ws probe cannot connect", async () => { + // Bind a ws server and then close it, so connecting to it fails. + const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" }); + await new Promise((resolve) => wss.once("listening", () => resolve())); + const port = (wss.address() as { port: number }).port; + await new Promise((resolve) => wss.close(() => resolve())); + await expect( + isChromeReachable(`ws://127.0.0.1:${port}/devtools/browser/GONE`, 50), + ).resolves.toBe(false); + }); + + it("resolves true when the direct-ws handshake succeeds", async () => { + const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" }); + await new Promise((resolve) => wss.once("listening", () => resolve())); + const port = (wss.address() as { port: number }).port; + try { + // Direct /devtools/ WS URL — isChromeReachable goes through + // canOpenWebSocket. The server accepts the upgrade; the probe + // resolves true as soon as 'open' fires. + await expect( + isChromeReachable(`ws://127.0.0.1:${port}/devtools/browser/OK`, 500), + ).resolves.toBe(true); + } finally { + await new Promise((resolve) => wss.close(() => resolve())); + } + }); + }); + + describe("getChromeWebSocketUrl direct-ws short-circuit", () => { + it("returns the input URL as-is for handshake-ready direct ws endpoints", async () => { + // Covers the `return cdpUrl;` early-return on a direct ws endpoint. + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const out = await getChromeWebSocketUrl("ws://127.0.0.1:19222/devtools/browser/DIRECT", 50); + expect(out).toBe("ws://127.0.0.1:19222/devtools/browser/DIRECT"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); + + describe("canRunCdpHealthCommand error/close/throw-on-send branches", () => { + it("resolves false when the ws client cannot connect to the discovered ws URL", async () => { + // Serve /json/version pointing at a port that's not actually + // accepting ws upgrades — the canRunCdpHealthCommand probe will + // fire its 'error' handler during handshake. + const dead = new WebSocketServer({ port: 0, host: "127.0.0.1" }); + await new Promise((resolve) => dead.once("listening", () => resolve())); + const deadPort = (dead.address() as { port: number }).port; + await new Promise((resolve) => dead.close(() => resolve())); + const server = createServer((req, res) => { + if (req.url === "/json/version") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + webSocketDebuggerUrl: `ws://127.0.0.1:${deadPort}/devtools/browser/DEAD`, + }), + ); + return; + } + res.writeHead(404).end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + try { + const addr = server.address() as AddressInfo; + await expect(isChromeCdpReady(`http://127.0.0.1:${addr.port}`, 300, 200)).resolves.toBe( + false, + ); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("resolves false when the ws 'close' event fires before a response arrives", async () => { + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/CLOSE", + onConnection: (wss) => { + wss.on("connection", (ws) => { + // Immediately close with no response, triggering the 'close' branch. + setTimeout(() => ws.close(), 10); + }); + }, + run: async (baseUrl) => { + await expect(isChromeCdpReady(baseUrl, 300, 200)).resolves.toBe(false); + }, + }); + }); + + it("guards against post-settled messages by dropping them", async () => { + // Emit two valid id=1 responses — the second must be dropped via the + // `if (settled) return;` guard at the top of onMessage. + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/SETTLED", + onConnection: (wss) => { + wss.on("connection", (ws) => { + ws.on("message", (raw) => { + const text = rawDataToString(raw); + const msg = JSON.parse(text) as { id?: number }; + if (msg.id === 1) { + ws.send(JSON.stringify({ id: 1, result: { product: "Chrome" } })); + // Second message after settled — the onMessage guard + // should return early. + setTimeout( + () => ws.send(JSON.stringify({ id: 1, result: { product: "after" } })), + 20, + ); + } + }); + }); + }, + run: async (baseUrl) => { + await expect(isChromeCdpReady(baseUrl, 300, 400)).resolves.toBe(true); + }, + }); + }); + }); + + describe("isChromeCdpReady swallowed errors", () => { + it("returns false when getChromeWebSocketUrl rejects (SSRF-blocked)", async () => { + // Covers the `.catch(() => null)` arrow on getChromeWebSocketUrl in + // isChromeCdpReady by pointing at a private-IP cdp url under strict SSRF. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools/browser/x" }), + } as unknown as Response), + ); + await expect( + isChromeCdpReady("http://169.254.169.254:9222", 50, 50, { + dangerouslyAllowPrivateNetwork: false, + allowedHostnames: ["127.0.0.1"], + }), + ).resolves.toBe(false); + }); + }); + + describe("launchOpenClawChrome remaining branches", () => { + it("skips decoration entirely when the profile is already decorated", async () => { + // Covers the `needsDecorate` false branch by writing a real, + // properly-shaped Local State + Preferences pair that matches + // the desired name and color seed so isProfileDecorated returns + // true on the first check. + const stageDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-decorated-")); + try { + const profileName = path.basename(stageDir); + const colorHex = "#FF4500"; + const colorInt = ((0xff << 24) | 0xff4500) >> 0; + const userDataDir = path.join(resolveOpenClawUserDataDir(profileName)); + await fsp.mkdir(path.join(userDataDir, "Default"), { recursive: true }); + await fsp.writeFile( + path.join(userDataDir, "Local State"), + JSON.stringify({ + profile: { + info_cache: { + Default: { + name: profileName, + profile_color_seed: colorInt, + }, + }, + }, + }), + ); + await fsp.writeFile( + path.join(userDataDir, "Default", "Preferences"), + JSON.stringify({ + browser: { theme: { user_color2: colorInt } }, + autogenerated: { theme: { color: colorInt } }, + }), + ); + vi.spyOn(fs, "existsSync").mockImplementation((p) => { + const s = String(p); + if (s.includes("Google Chrome")) { + return true; + } + // Fall through to real fs for the user-data-dir files. + return fs.statSync(s, { throwIfNoEntry: false }) !== undefined; + }); + spawnMock.mockImplementation(() => makeFakeProc()); + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/DECORATED", + run: async (baseUrl) => { + const port = Number(new URL(baseUrl).port); + const profile = { + name: profileName, + color: colorHex, + cdpPort: port, + cdpUrl: baseUrl, + cdpIsLoopback: true, + } as unknown as ResolvedBrowserProfile; + const resolved = { + headless: true, + noSandbox: true, + extraArgs: [], + } as unknown as ResolvedBrowserConfig; + const running = await launchOpenClawChrome(resolved, profile); + running.proc.kill?.("SIGTERM"); + }, + }); + } finally { + await fsp.rm(stageDir, { recursive: true, force: true }); + const staged = resolveOpenClawUserDataDir(path.basename(stageDir)); + await fsp.rm(staged, { recursive: true, force: true }).catch(() => {}); + } + }); + + it("falls back to the default color when profile.color is undefined", async () => { + // Covers the `profile.color ?? DEFAULT_OPENCLAW_BROWSER_COLOR` coalescing. + vi.spyOn(fs, "existsSync").mockImplementation((p) => { + const s = String(p); + if (s.includes("Google Chrome")) { + return true; + } + if (s.endsWith("Local State") || s.endsWith("Preferences")) { + return true; + } + return false; + }); + spawnMock.mockImplementation(() => makeFakeProc()); + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/NO_COLOR", + run: async (baseUrl) => { + const port = Number(new URL(baseUrl).port); + const profile = { + name: "openclaw", + color: undefined, + cdpPort: port, + cdpUrl: baseUrl, + cdpIsLoopback: true, + } as unknown as ResolvedBrowserProfile; + const resolved = { + headless: true, + noSandbox: true, + extraArgs: [], + } as unknown as ResolvedBrowserConfig; + const running = await launchOpenClawChrome(resolved, profile); + running.proc.kill?.("SIGTERM"); + }, + }); + }); + + it("buffers stderr chunks when Chrome emits diagnostics while CDP comes up", async () => { + // Covers onStderr (pushing chunks to stderrChunks) plus the + // stderrHint truthy branch on failure. + vi.spyOn(fs, "existsSync").mockImplementation((p) => { + const s = String(p); + if (s.includes("Google Chrome")) { + return true; + } + if (s.endsWith("Local State") || s.endsWith("Preferences")) { + return true; + } + return false; + }); + const fakeProc = makeFakeProc(); + spawnMock.mockImplementation(() => { + // Synthesize stderr data shortly after spawn. + setTimeout(() => fakeProc.stderr.emit("data", Buffer.from("chrome crash log\n")), 5); + return fakeProc; + }); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED"))); + const profile = { + name: "openclaw-stderr", + color: "#FF4500", + cdpPort: 54321, + cdpUrl: "http://127.0.0.1:54321", + cdpIsLoopback: true, + } as unknown as ResolvedBrowserProfile; + const resolved = { + headless: true, + noSandbox: true, + extraArgs: [], + } as unknown as ResolvedBrowserConfig; + await expect(launchOpenClawChrome(resolved, profile)).rejects.toThrow(/Chrome stderr:/); + }); + + it("omits the sandbox hint on non-linux platforms", async () => { + // Covers the else side of `process.platform === 'linux' && !resolved.noSandbox ? ... : ''`. + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "darwin" }); + try { + vi.spyOn(fs, "existsSync").mockImplementation((p) => { + const s = String(p); + if (s.includes("Google Chrome")) { + return true; + } + if (s.endsWith("Local State") || s.endsWith("Preferences")) { + return true; + } + return false; + }); + spawnMock.mockImplementation(() => makeFakeProc()); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED"))); + const profile = { + name: "openclaw-mac", + color: "#FF4500", + cdpPort: 54322, + cdpUrl: "http://127.0.0.1:54322", + cdpIsLoopback: true, + } as unknown as ResolvedBrowserProfile; + const resolved = { + headless: true, + noSandbox: false, + extraArgs: [], + } as unknown as ResolvedBrowserConfig; + let caught: unknown; + try { + await launchOpenClawChrome(resolved, profile); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).not.toContain("Hint: If running in a container"); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + } + }); + + it("breaks out of the bootstrap prefs-wait loop as soon as both files exist", async () => { + // Covers the `if (exists(localStatePath) && exists(preferencesPath)) break;` branch. + // Use a wallclock flag that the mock checks each call so the loop + // iterates (awaiting its 100ms setTimeout) once with prefs-absent, + // then the flag flips and the next iteration hits the break. + let prefsVisible = false; + setTimeout(() => { + prefsVisible = true; + }, 50); + vi.spyOn(fs, "existsSync").mockImplementation((p) => { + const s = String(p); + if (s.includes("Google Chrome")) { + return true; + } + if (s.endsWith("Local State") || s.endsWith("Preferences")) { + return prefsVisible; + } + return false; + }); + const fakeProc = makeFakeProc(); + spawnMock.mockImplementation(() => fakeProc); + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/BOOTSTRAP_BREAK", + run: async (baseUrl) => { + const port = Number(new URL(baseUrl).port); + const profile = { + name: "openclaw", + color: "#FF4500", + cdpPort: port, + cdpUrl: baseUrl, + cdpIsLoopback: true, + } as unknown as ResolvedBrowserProfile; + const resolved = { + headless: true, + noSandbox: true, + extraArgs: [], + } as unknown as ResolvedBrowserConfig; + const running = await launchOpenClawChrome(resolved, profile); + running.proc.kill?.("SIGTERM"); + }, + }); + }); + + it("breaks out of the bootstrap exit-wait loop once the child reports an exit code", async () => { + // Covers the `if (bootstrap.exitCode != null) break;` branch. + vi.spyOn(fs, "existsSync").mockImplementation((p) => { + const s = String(p); + if (s.includes("Google Chrome")) { + return true; + } + // Force bootstrap by reporting prefs absent. + return false; + }); + const bootstrapProc = makeFakeProc(); + const runtimeProc = makeFakeProc(); + let callCount = 0; + spawnMock.mockImplementation(() => { + callCount += 1; + if (callCount === 1) { + // Set exitCode shortly after spawn so the exit-wait loop breaks. + setTimeout(() => { + bootstrapProc.exitCode = 0; + }, 25); + return bootstrapProc; + } + return runtimeProc; + }); + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/EXIT_BREAK", + run: async (baseUrl) => { + const port = Number(new URL(baseUrl).port); + const profile = { + name: "openclaw", + color: "#FF4500", + cdpPort: port, + cdpUrl: baseUrl, + cdpIsLoopback: true, + } as unknown as ResolvedBrowserProfile; + const resolved = { + headless: true, + noSandbox: true, + extraArgs: [], + } as unknown as ResolvedBrowserConfig; + const running = await launchOpenClawChrome(resolved, profile); + running.proc.kill?.("SIGTERM"); + }, + }); + }); + + it("logs a warning when decorateOpenClawProfile throws and still returns a running Chrome", async () => { + // Covers the decoration catch branch (log.warn). + const { decorateOpenClawProfile } = await import("./chrome.profile-decoration.js"); + vi.spyOn(fs, "existsSync").mockImplementation((p) => { + const s = String(p); + if (s.includes("Google Chrome")) { + return true; + } + if (s.endsWith("Local State") || s.endsWith("Preferences")) { + return true; + } + return false; + }); + const decorationSpy = vi + .spyOn({ decorateOpenClawProfile }, "decorateOpenClawProfile") + .mockImplementation(() => { + throw new Error("decoration blew up"); + }); + // The real decoration throws via our writes — fake by spying on + // fs.writeFileSync to throw for the marker file. + const writeSpy = vi.spyOn(fs, "writeFileSync").mockImplementation((p) => { + const s = String(p); + if (s.endsWith(".openclaw-profile-decorated") || s.endsWith("Preferences")) { + throw new Error("write blew up"); + } + }); + spawnMock.mockImplementation(() => makeFakeProc()); + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/DECO_WARN", + run: async (baseUrl) => { + const port = Number(new URL(baseUrl).port); + const profile = { + name: "openclaw-warn", + color: "#FF4500", + cdpPort: port, + cdpUrl: baseUrl, + cdpIsLoopback: true, + } as unknown as ResolvedBrowserProfile; + const resolved = { + headless: true, + noSandbox: true, + extraArgs: [], + } as unknown as ResolvedBrowserConfig; + const running = await launchOpenClawChrome(resolved, profile); + running.proc.kill?.("SIGTERM"); + }, + }); + decorationSpy.mockRestore(); + writeSpy.mockRestore(); + }); + + it("logs pid as -1 when the spawned proc reports no pid", async () => { + // Covers the `proc.pid ?? -1` falsy side. + vi.spyOn(fs, "existsSync").mockImplementation((p) => { + const s = String(p); + if (s.includes("Google Chrome")) { + return true; + } + if (s.endsWith("Local State") || s.endsWith("Preferences")) { + return true; + } + return false; + }); + spawnMock.mockImplementation(() => { + const fp = makeFakeProc(); + fp.pid = undefined; + return fp; + }); + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/NO_PID", + run: async (baseUrl) => { + const port = Number(new URL(baseUrl).port); + const profile = { + name: "openclaw-nopid", + color: "#FF4500", + cdpPort: port, + cdpUrl: baseUrl, + cdpIsLoopback: true, + } as unknown as ResolvedBrowserProfile; + const resolved = { + headless: true, + noSandbox: true, + extraArgs: [], + } as unknown as ResolvedBrowserConfig; + const running = await launchOpenClawChrome(resolved, profile); + expect(running.pid).toBe(-1); + running.proc.kill?.("SIGTERM"); + }, + }); + }); + }); +}); diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index 200f19e4354..94749598024 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -468,16 +468,86 @@ describe("browser chrome helpers", () => { expect(formatted).not.toContain("supersecret123"); }); - it("probes WebSocket URLs via handshake instead of HTTP", async () => { - // For ws:// URLs, isChromeReachable should NOT call fetch at all — - // it should attempt a WebSocket handshake instead. + it("probes direct ws:// CDP URLs (with /devtools/ path) via handshake instead of HTTP", async () => { + // A direct WS endpoint like ws://host/devtools/browser/ is already + // the handshake target — isChromeReachable must NOT hit /json/version. const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called")); vi.stubGlobal("fetch", fetchSpy); // No WS server listening → handshake fails → not reachable - await expect(isChromeReachable("ws://127.0.0.1:19999", 50)).resolves.toBe(false); + await expect(isChromeReachable("ws://127.0.0.1:19999/devtools/browser/ABC", 50)).resolves.toBe( + false, + ); expect(fetchSpy).not.toHaveBeenCalled(); }); + it("falls back to HTTP /json/version discovery for a bare ws:// CDP URL (issue #68027)", async () => { + // A user-supplied cdpUrl of `ws://host:port` without a /devtools/ path + // points at Chrome's debug root; Chrome only accepts WS upgrades on the + // specific path returned by `GET /json/version`. The reachability probe + // must normalise the ws scheme to http for discovery, not attempt a + // handshake at the bare root. + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/DISCOVERED", + run: async (baseUrl) => { + const url = new URL(baseUrl); + const wsOnlyBase = `ws://${url.host}`; + await expect(isChromeReachable(wsOnlyBase, 300)).resolves.toBe(true); + await expect(getChromeWebSocketUrl(wsOnlyBase, 300)).resolves.toBe( + `ws://${url.host}/devtools/browser/DISCOVERED`, + ); + }, + }); + }); + + it("reports unreachable when a bare ws:// CDP URL points at a server with no /json/version and refuses WS", async () => { + // Negative counterpart to the #68027 happy path — a bare ws URL + // pointed at a port that neither serves /json/version nor accepts + // WS upgrades must resolve false without hanging. + const fetchSpy = vi.fn().mockRejectedValue(new Error("connection refused")); + vi.stubGlobal("fetch", fetchSpy); + // Port 19998 is not listening; the WS fallback probe will also fail. + await expect(isChromeReachable("ws://127.0.0.1:19998", 50)).resolves.toBe(false); + // fetch() must have been invoked — HTTP discovery is always tried first. + expect(fetchSpy).toHaveBeenCalled(); + }); + + it("falls back to a direct WS probe when /json/version is unavailable for a bare ws:// URL", async () => { + // Covers the WS-fallback path in isChromeReachable: /json/version returns + // nothing (simulated by empty response) but the WS socket IS accepting + // connections (Browserless/Browserbase-style provider). + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), // empty — no webSocketDebuggerUrl + } as unknown as Response), + ); + // A real WS server accepts the handshake. + const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" }); + await new Promise((resolve) => wss.once("listening", () => resolve())); + const port = (wss.address() as AddressInfo).port; + try { + await expect(isChromeReachable(`ws://127.0.0.1:${port}`, 500)).resolves.toBe(true); + } finally { + await new Promise((resolve) => wss.close(() => resolve())); + } + }); + + it("returns the original ws:// URL from getChromeWebSocketUrl when /json/version provides no debugger URL", async () => { + // Covers the getChromeWebSocketUrl WS-fallback: discovery succeeds but + // webSocketDebuggerUrl is absent — the original URL is returned as-is. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + } as unknown as Response), + ); + await expect(getChromeWebSocketUrl("ws://127.0.0.1:12345", 50)).resolves.toBe( + "ws://127.0.0.1:12345", + ); + }); + it("stopOpenClawChrome no-ops when process is already killed", async () => { const proc = makeChromeTestProc({ killed: true }); await stopChromeWithProc(proc, 10); diff --git a/extensions/browser/src/browser/chrome.ts b/extensions/browser/src/browser/chrome.ts index 5641e5bff38..7992cb53247 100644 --- a/extensions/browser/src/browser/chrome.ts +++ b/extensions/browser/src/browser/chrome.ts @@ -18,7 +18,13 @@ import { CHROME_STOP_TIMEOUT_MS, CHROME_WS_READY_TIMEOUT_MS, } from "./cdp-timeouts.js"; -import { assertCdpEndpointAllowed, isWebSocketUrl, openCdpWebSocket } from "./cdp.helpers.js"; +import { + assertCdpEndpointAllowed, + isDirectCdpWebSocketEndpoint, + isWebSocketUrl, + normalizeCdpHttpBaseForJsonEndpoints, + openCdpWebSocket, +} from "./cdp.helpers.js"; import { normalizeCdpWsUrl } from "./cdp.js"; import { diagnoseChromeCdp, @@ -161,12 +167,26 @@ export async function isChromeReachable( ): Promise { try { await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); - if (isWebSocketUrl(cdpUrl)) { - // Direct WebSocket endpoint — probe via WS handshake. + if (isDirectCdpWebSocketEndpoint(cdpUrl)) { + // Handshake-ready direct WS endpoint — probe via WS handshake. return await canOpenWebSocket(cdpUrl, timeoutMs); } - const version = await fetchChromeVersion(cdpUrl, timeoutMs, ssrfPolicy); - return Boolean(version); + // 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 + // direct WS handshake when discovery is unavailable — some providers + // (e.g. Browserless/Browserbase) expose a direct WebSocket root without + // a /json/version endpoint. + const discoveryUrl = isWebSocketUrl(cdpUrl) + ? normalizeCdpHttpBaseForJsonEndpoints(cdpUrl) + : cdpUrl; + const version = await fetchChromeVersion(discoveryUrl, timeoutMs, ssrfPolicy); + if (version) { + return true; + } + if (isWebSocketUrl(cdpUrl)) { + return await canOpenWebSocket(cdpUrl, timeoutMs); + } + return false; } catch { return false; } @@ -190,16 +210,31 @@ export async function getChromeWebSocketUrl( ssrfPolicy?: SsrFPolicy, ): Promise { await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); - if (isWebSocketUrl(cdpUrl)) { - // Direct WebSocket endpoint — the cdpUrl is already the WebSocket URL. + if (isDirectCdpWebSocketEndpoint(cdpUrl)) { + // Handshake-ready direct WebSocket endpoint — the cdpUrl is already + // the WebSocket URL. return cdpUrl; } - const version = await fetchChromeVersion(cdpUrl, timeoutMs, ssrfPolicy); + // Either an http(s) endpoint or a bare ws/wss root; discover the + // actual WebSocket URL via /json/version. Normalise the scheme so + // fetch() can reach the endpoint. + const discoveryUrl = isWebSocketUrl(cdpUrl) + ? normalizeCdpHttpBaseForJsonEndpoints(cdpUrl) + : cdpUrl; + const version = await fetchChromeVersion(discoveryUrl, timeoutMs, ssrfPolicy); const wsUrl = normalizeOptionalString(version?.webSocketDebuggerUrl) ?? ""; if (!wsUrl) { + // /json/version unavailable or returned no WebSocket URL. For bare + // ws/wss inputs, the URL itself may be a direct WebSocket endpoint + // (e.g. Browserless/Browserbase-style providers without /json/version). + // 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 null; } - const normalizedWsUrl = normalizeCdpWsUrl(wsUrl, cdpUrl); + const normalizedWsUrl = normalizeCdpWsUrl(wsUrl, discoveryUrl); await assertCdpEndpointAllowed(normalizedWsUrl, ssrfPolicy); return normalizedWsUrl; } diff --git a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts index b93fe0ea1c1..8d084d55bd5 100644 --- a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts +++ b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts @@ -181,6 +181,61 @@ describe("browser server-context ensureBrowserAvailable", () => { expect(stopOpenClawChrome).not.toHaveBeenCalled(); }); + it("resolves for attachOnly loopback profile with a bare ws:// cdpUrl when CDP is reachable (#68027)", async () => { + // Regression for #68027: a bare `ws://host:port` cdpUrl on a loopback + // attachOnly profile must not surface as + // `Browser attachOnly is enabled and profile "" is not running.` + // when the underlying CDP endpoint is actually healthy. The low-level + // fix lives in chrome.ts/cdp.ts (see chrome.test.ts #68027 tests); this + // higher-level test locks the user-facing symptom at + // ensureBrowserAvailable() so future refactors of the availability flow + // cannot silently reintroduce the bug by munging/short-circuiting bare + // ws:// URLs before they reach the helpers. + const { launchOpenClawChrome, stopOpenClawChrome } = setupEnsureBrowserAvailableHarness(); + const isChromeReachable = vi.mocked(chromeModule.isChromeReachable); + const isChromeCdpReady = vi.mocked(chromeModule.isChromeCdpReady); + + const state = makeBrowserServerState({ + profile: { + name: "manual-cdp", + cdpUrl: "ws://127.0.0.1:9222", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + cdpPort: 9222, + color: "#00AA00", + driver: "openclaw", + attachOnly: true, + }, + resolvedOverrides: { + defaultProfile: "manual-cdp", + ssrfPolicy: {}, + }, + }); + const ctx = createBrowserRouteContext({ getState: () => state }); + const profile = ctx.forProfile("manual-cdp"); + + isChromeReachable.mockResolvedValueOnce(true); + isChromeCdpReady.mockResolvedValueOnce(true); + + await expect(profile.ensureBrowserAvailable()).resolves.toBeUndefined(); + + // The bare ws:// URL must pass through unchanged — the helpers own the + // discovery-first-then-fallback strategy for bare ws roots. + expect(isChromeReachable).toHaveBeenCalledWith( + "ws://127.0.0.1:9222", + state.resolved.remoteCdpTimeoutMs, + undefined, + ); + expect(isChromeCdpReady).toHaveBeenCalledWith( + "ws://127.0.0.1:9222", + state.resolved.remoteCdpTimeoutMs, + state.resolved.remoteCdpHandshakeTimeoutMs, + undefined, + ); + expect(launchOpenClawChrome).not.toHaveBeenCalled(); + expect(stopOpenClawChrome).not.toHaveBeenCalled(); + }); + it("redacts credentials in remote CDP availability errors", async () => { const { launchOpenClawChrome, stopOpenClawChrome } = setupEnsureBrowserAvailableHarness(); const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);