From e25b3c60565f1797f3889f95ec72be6ec69e0926 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 12:59:28 +0100 Subject: [PATCH] fix(browser): align bare ws cdp readiness --- CHANGELOG.md | 1 + .../browser/src/browser/chrome.diagnostics.ts | 49 +++++++++++++- extensions/browser/src/browser/chrome.test.ts | 66 ++++++++++++++++++- 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f31f1686431..15c47e1cf44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Browser/CDP: make readiness diagnostics use the same discovery-first fallback as reachability for bare `ws://` Browserless and Browserbase CDP URLs. Fixes #69532. - Memory-host SDK: use trusted env-proxy mode for remote embedding and batch HTTP calls only when Undici will proxy that target, preserving SSRF DNS pinning for `ALL_PROXY`-only and `NO_PROXY` bypass cases. Fixes #52162. (#71506) Thanks @DhtIsCoding. - Gateway/dashboard: render Control UI and WebSocket links with `https://`/`wss://` when `gateway.tls.enabled=true`, including `openclaw gateway status`. Fixes #71494. (#71499) Thanks @deepkilo. - Agents/OpenAI-compatible: default proxy/local completions tool requests to `tool_choice: "auto"` when tools are present, so providers enter native tool-calling mode instead of replying with plain-text tool directives. (#71472) Thanks @Speed-maker. diff --git a/extensions/browser/src/browser/chrome.diagnostics.ts b/extensions/browser/src/browser/chrome.diagnostics.ts index ee59875461f..eea5b7ebb1c 100644 --- a/extensions/browser/src/browser/chrome.diagnostics.ts +++ b/extensions/browser/src/browser/chrome.diagnostics.ts @@ -7,7 +7,9 @@ import { appendCdpPath, assertCdpEndpointAllowed, fetchCdpChecked, + isDirectCdpWebSocketEndpoint, isWebSocketUrl, + normalizeCdpHttpBaseForJsonEndpoints, openCdpWebSocket, redactCdpUrl, } from "./cdp.helpers.js"; @@ -266,7 +268,7 @@ export async function diagnoseChromeCdp( }); } - if (isWebSocketUrl(cdpUrl)) { + if (isDirectCdpWebSocketEndpoint(cdpUrl)) { const health = await diagnoseCdpHealthCommand(cdpUrl, handshakeTimeoutMs); if (!health.ok) { return failureDiagnostic({ @@ -285,10 +287,31 @@ export async function diagnoseChromeCdp( }; } + const discoveryUrl = isWebSocketUrl(cdpUrl) + ? normalizeCdpHttpBaseForJsonEndpoints(cdpUrl) + : cdpUrl; let version: ChromeVersion; try { - version = await readChromeVersion(cdpUrl, timeoutMs, ssrfPolicy); + version = await readChromeVersion(discoveryUrl, timeoutMs, ssrfPolicy); } catch (err) { + if (isWebSocketUrl(cdpUrl)) { + const health = await diagnoseCdpHealthCommand(cdpUrl, handshakeTimeoutMs); + if (!health.ok) { + return failureDiagnostic({ + cdpUrl, + wsUrl: cdpUrl, + code: health.code, + message: health.message, + startedAt, + }); + } + return { + ok: true, + cdpUrl, + wsUrl: cdpUrl, + elapsedMs: elapsedSince(startedAt), + }; + } const classified = classifyChromeVersionError(err); return failureDiagnostic({ cdpUrl, @@ -300,6 +323,26 @@ export async function diagnoseChromeCdp( const wsUrlRaw = normalizeOptionalString(version.webSocketDebuggerUrl) ?? ""; if (!wsUrlRaw) { + if (isWebSocketUrl(cdpUrl)) { + const health = await diagnoseCdpHealthCommand(cdpUrl, handshakeTimeoutMs); + if (!health.ok) { + return failureDiagnostic({ + cdpUrl, + wsUrl: cdpUrl, + code: health.code, + message: health.message, + startedAt, + }); + } + return { + ok: true, + cdpUrl, + wsUrl: cdpUrl, + browser: version.Browser, + userAgent: version["User-Agent"], + elapsedMs: elapsedSince(startedAt), + }; + } return failureDiagnostic({ cdpUrl, code: "missing_websocket_debugger_url", @@ -307,7 +350,7 @@ export async function diagnoseChromeCdp( startedAt, }); } - const wsUrl = normalizeCdpWsUrl(wsUrlRaw, cdpUrl); + const wsUrl = normalizeCdpWsUrl(wsUrlRaw, discoveryUrl); try { await assertCdpEndpointAllowed(wsUrl, ssrfPolicy); } catch (err) { diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index 96c78316e94..f2b05165783 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { WebSocketServer } from "ws"; +import { rawDataToString } from "../infra/ws.js"; import { parseBrowserMajorVersion, resolveGoogleChromeExecutableForPlatform, @@ -56,7 +57,7 @@ async function withMockChromeCdpServer(params: { run: (baseUrl: string) => Promise; }) { const server = createServer((req, res) => { - if (req.url === "/json/version") { + if (req.url?.startsWith("/json/version")) { const addr = server.address() as AddressInfo; res.writeHead(200, { "Content-Type": "application/json" }); res.end( @@ -71,7 +72,7 @@ async function withMockChromeCdpServer(params: { }); const wss = new WebSocketServer({ noServer: true }); server.on("upgrade", (req, socket, head) => { - if (req.url !== params.wsPath) { + if (!req.url?.startsWith(params.wsPath)) { socket.destroy(); return; } @@ -630,6 +631,37 @@ describe("browser chrome helpers", () => { }); }); + it("uses HTTP discovery before readiness checks for a bare ws:// CDP URL", async () => { + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/READY", + onConnection: (wss) => { + wss.on("connection", (ws) => { + ws.on("message", (raw) => { + const message = JSON.parse(rawDataToString(raw)) as { id?: number; method?: string }; + if (message.method === "Browser.getVersion" && message.id === 1) { + ws.send( + JSON.stringify({ + id: 1, + result: { product: "Chrome/Mock" }, + }), + ); + } + }); + }); + }, + run: async (baseUrl) => { + const url = new URL(baseUrl); + const wsOnlyBase = `ws://${url.host}?token=abc`; + await expect(isChromeCdpReady(wsOnlyBase, 300, 400)).resolves.toBe(true); + const diagnostic = await diagnoseChromeCdp(wsOnlyBase, 300, 400); + expect(diagnostic).toMatchObject({ + ok: true, + wsUrl: `ws://${url.host}/devtools/browser/READY?token=abc`, + }); + }, + }); + }); + 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 @@ -664,6 +696,36 @@ describe("browser chrome helpers", () => { } }); + it("falls back to a direct WS readiness check when /json/version has no debugger URL", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + } as unknown as Response), + ); + const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" }); + wss.on("connection", (ws) => { + ws.on("message", (raw) => { + const message = JSON.parse(rawDataToString(raw)) as { id?: number; method?: string }; + if (message.method === "Browser.getVersion" && message.id === 1) { + ws.send(JSON.stringify({ id: 1, result: { product: "Browserless/Mock" } })); + } + }); + }); + await new Promise((resolve) => wss.once("listening", () => resolve())); + const port = (wss.address() as AddressInfo).port; + try { + await expect(isChromeCdpReady(`ws://127.0.0.1:${port}`, 500, 500)).resolves.toBe(true); + await expect(diagnoseChromeCdp(`ws://127.0.0.1:${port}`, 500, 500)).resolves.toMatchObject({ + ok: true, + wsUrl: `ws://127.0.0.1:${port}`, + }); + } 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.