fix(browser): align bare ws cdp readiness

This commit is contained in:
Peter Steinberger
2026-04-25 12:59:28 +01:00
parent 2b822f6ed0
commit e25b3c6056
3 changed files with 111 additions and 5 deletions

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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<void>;
}) {
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<void>((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<void>((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.