diff --git a/CHANGELOG.md b/CHANGELOG.md index 526b34cbc69..0ab4f298933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Telegram/callbacks: treat permanent callback edit errors as completed updates so stale command pagination buttons no longer wedge the update watermark and block newer Telegram updates. (#68588) Thanks @Lucenx9. - 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. ## 2026.4.18 diff --git a/extensions/browser/src/browser/cdp.test.ts b/extensions/browser/src/browser/cdp.test.ts index 64b291db72a..a50daca9153 100644 --- a/extensions/browser/src/browser/cdp.test.ts +++ b/extensions/browser/src/browser/cdp.test.ts @@ -404,6 +404,14 @@ describe("cdp", () => { expect(normalized).toBe("wss://user:pass@example.com/devtools/browser/ABC?token=abc"); }); + it("normalizes loopback websocket aliases to the configured CDP loopback host", () => { + const normalized = normalizeCdpWsUrl( + "ws://localhost.:18800/devtools/browser/ABC", + "http://127.0.0.1:18800", + ); + expect(normalized).toBe("ws://127.0.0.1:18800/devtools/browser/ABC"); + }); + it("rewrites 0.0.0.0 wildcard bind address to remote CDP host", () => { const normalized = normalizeCdpWsUrl( "ws://0.0.0.0:3000/devtools/browser/ABC", diff --git a/extensions/browser/src/browser/cdp.ts b/extensions/browser/src/browser/cdp.ts index 898ac120bed..32866c8a280 100644 --- a/extensions/browser/src/browser/cdp.ts +++ b/extensions/browser/src/browser/cdp.ts @@ -31,6 +31,8 @@ export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string { ws.port = cdpPort; } ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:"; + } else if (isLoopbackHost(ws.hostname) && isLoopbackHost(cdp.hostname)) { + ws.hostname = cdp.hostname; } if (cdp.protocol === "https:" && ws.protocol === "ws:") { ws.protocol = "wss:"; diff --git a/extensions/browser/src/browser/chrome.diagnostics.ts b/extensions/browser/src/browser/chrome.diagnostics.ts new file mode 100644 index 00000000000..ca9d8e6bd54 --- /dev/null +++ b/extensions/browser/src/browser/chrome.diagnostics.ts @@ -0,0 +1,342 @@ +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { rawDataToString } from "../infra/ws.js"; +import { redactSensitiveText } from "../logging/redact.js"; +import { CHROME_REACHABILITY_TIMEOUT_MS, CHROME_WS_READY_TIMEOUT_MS } from "./cdp-timeouts.js"; +import { + appendCdpPath, + assertCdpEndpointAllowed, + fetchCdpChecked, + isWebSocketUrl, + openCdpWebSocket, + redactCdpUrl, +} from "./cdp.helpers.js"; +import { normalizeCdpWsUrl } from "./cdp.js"; +import { BrowserCdpEndpointBlockedError } from "./errors.js"; + +export type ChromeCdpDiagnosticCode = + | "ssrf_blocked" + | "http_unreachable" + | "http_status_failed" + | "invalid_json" + | "missing_websocket_debugger_url" + | "websocket_ssrf_blocked" + | "websocket_handshake_failed" + | "websocket_health_command_failed" + | "websocket_health_command_timeout"; + +export type ChromeCdpDiagnostic = + | { + ok: true; + cdpUrl: string; + wsUrl: string; + browser?: string; + userAgent?: string; + elapsedMs: number; + } + | { + ok: false; + code: ChromeCdpDiagnosticCode; + cdpUrl: string; + wsUrl?: string; + message: string; + elapsedMs: number; + }; + +export type ChromeVersion = { + webSocketDebuggerUrl?: string; + Browser?: string; + "User-Agent"?: string; +}; + +function elapsedSince(startedAt: number): number { + return Math.max(0, Date.now() - startedAt); +} + +export function safeChromeCdpErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return redactSensitiveText(message || "unknown error"); +} + +function failureDiagnostic(params: { + cdpUrl: string; + code: ChromeCdpDiagnosticCode; + message: string; + startedAt: number; + wsUrl?: string; +}): ChromeCdpDiagnostic { + return { + ok: false, + cdpUrl: params.cdpUrl, + wsUrl: params.wsUrl, + code: params.code, + message: redactSensitiveText(params.message), + elapsedMs: elapsedSince(params.startedAt), + }; +} + +export async function readChromeVersion( + cdpUrl: string, + timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, + ssrfPolicy?: SsrFPolicy, +): Promise { + const ctrl = new AbortController(); + const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs); + try { + const versionUrl = appendCdpPath(cdpUrl, "/json/version"); + const { response, release } = await fetchCdpChecked( + versionUrl, + timeoutMs, + { signal: ctrl.signal }, + ssrfPolicy, + ); + try { + const data = (await response.json()) as ChromeVersion; + if (!data || typeof data !== "object") { + throw new Error("CDP /json/version returned non-object JSON"); + } + return data; + } finally { + await release(); + } + } finally { + clearTimeout(t); + } +} + +type CdpHealthDiagnostic = + | { ok: true } + | { + ok: false; + code: + | "websocket_handshake_failed" + | "websocket_health_command_failed" + | "websocket_health_command_timeout"; + message: string; + }; + +async function diagnoseCdpHealthCommand( + wsUrl: string, + timeoutMs = CHROME_WS_READY_TIMEOUT_MS, +): Promise { + return await new Promise((resolve) => { + const ws = openCdpWebSocket(wsUrl, { + handshakeTimeoutMs: timeoutMs, + }); + let settled = false; + let opened = false; + const onMessage = (raw: Parameters[0]) => { + if (settled) { + return; + } + let parsed: { id?: unknown; result?: unknown } | null = null; + try { + parsed = JSON.parse(rawDataToString(raw)) as { id?: unknown; result?: unknown }; + } catch { + return; + } + if (parsed?.id !== 1) { + return; + } + if (parsed.result && typeof parsed.result === "object") { + finish({ ok: true }); + return; + } + finish({ + ok: false, + code: "websocket_health_command_failed", + message: "Browser.getVersion returned no result object", + }); + }; + + const finish = (value: CdpHealthDiagnostic) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + ws.off("message", onMessage); + try { + ws.close(); + } catch { + // ignore + } + resolve(value); + }; + const timer = setTimeout( + () => { + try { + ws.terminate(); + } catch { + // ignore + } + finish({ + ok: false, + code: opened ? "websocket_health_command_timeout" : "websocket_handshake_failed", + message: opened + ? `Browser.getVersion did not respond within ${timeoutMs}ms` + : `WebSocket handshake did not complete within ${timeoutMs}ms`, + }); + }, + Math.max(50, timeoutMs + 25), + ); + + ws.once("open", () => { + opened = true; + try { + ws.send( + JSON.stringify({ + id: 1, + method: "Browser.getVersion", + }), + ); + } catch (err) { + finish({ + ok: false, + code: "websocket_health_command_failed", + message: safeChromeCdpErrorMessage(err), + }); + } + }); + + ws.on("message", onMessage); + + ws.once("error", (err) => { + finish({ + ok: false, + code: opened ? "websocket_health_command_failed" : "websocket_handshake_failed", + message: safeChromeCdpErrorMessage(err), + }); + }); + ws.once("close", () => { + finish({ + ok: false, + code: opened ? "websocket_health_command_failed" : "websocket_handshake_failed", + message: opened + ? "WebSocket closed before Browser.getVersion completed" + : "WebSocket closed before handshake completed", + }); + }); + }); +} + +function classifyChromeVersionError(error: unknown): { + code: ChromeCdpDiagnosticCode; + message: string; +} { + const message = safeChromeCdpErrorMessage(error); + if (error instanceof BrowserCdpEndpointBlockedError) { + return { code: "ssrf_blocked", message }; + } + if (/^HTTP \d+/.test(message)) { + return { code: "http_status_failed", message }; + } + if (error instanceof SyntaxError || message.includes("non-object JSON")) { + return { code: "invalid_json", message }; + } + return { code: "http_unreachable", message }; +} + +export function formatChromeCdpDiagnostic(diagnostic: ChromeCdpDiagnostic): string { + const redactedCdpUrl = redactCdpUrl(diagnostic.cdpUrl) ?? diagnostic.cdpUrl; + const redactedWsUrl = redactCdpUrl(diagnostic.wsUrl) ?? diagnostic.wsUrl; + if (diagnostic.ok) { + const browser = diagnostic.browser ? ` browser=${diagnostic.browser}` : ""; + return `CDP diagnostic: ready after ${diagnostic.elapsedMs}ms; cdp=${redactedCdpUrl}; websocket=${redactedWsUrl}.${browser}`; + } + const websocket = redactedWsUrl ? `; websocket=${redactedWsUrl}` : ""; + return `CDP diagnostic: ${diagnostic.code} after ${diagnostic.elapsedMs}ms; cdp=${redactedCdpUrl}${websocket}; ${diagnostic.message}.`; +} + +export async function diagnoseChromeCdp( + cdpUrl: string, + timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, + handshakeTimeoutMs = CHROME_WS_READY_TIMEOUT_MS, + ssrfPolicy?: SsrFPolicy, +): Promise { + const startedAt = Date.now(); + try { + await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); + } catch (err) { + return failureDiagnostic({ + cdpUrl, + code: "ssrf_blocked", + message: safeChromeCdpErrorMessage(err), + startedAt, + }); + } + + 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), + }; + } + + let version: ChromeVersion; + try { + version = await readChromeVersion(cdpUrl, timeoutMs, ssrfPolicy); + } catch (err) { + const classified = classifyChromeVersionError(err); + return failureDiagnostic({ + cdpUrl, + code: classified.code, + message: classified.message, + startedAt, + }); + } + + const wsUrlRaw = normalizeOptionalString(version.webSocketDebuggerUrl) ?? ""; + if (!wsUrlRaw) { + return failureDiagnostic({ + cdpUrl, + code: "missing_websocket_debugger_url", + message: "CDP /json/version did not include webSocketDebuggerUrl", + startedAt, + }); + } + const wsUrl = normalizeCdpWsUrl(wsUrlRaw, cdpUrl); + try { + await assertCdpEndpointAllowed(wsUrl, ssrfPolicy); + } catch (err) { + return failureDiagnostic({ + cdpUrl, + wsUrl, + code: "websocket_ssrf_blocked", + message: safeChromeCdpErrorMessage(err), + startedAt, + }); + } + + const health = await diagnoseCdpHealthCommand(wsUrl, handshakeTimeoutMs); + if (!health.ok) { + return failureDiagnostic({ + cdpUrl, + wsUrl, + code: health.code, + message: health.message, + startedAt, + }); + } + + return { + ok: true, + cdpUrl, + wsUrl, + browser: version.Browser, + userAgent: version["User-Agent"], + elapsedMs: elapsedSince(startedAt), + }; +} diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index fb7b137d503..200f19e4354 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -8,9 +8,11 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { WebSocketServer } from "ws"; import { decorateOpenClawProfile, + diagnoseChromeCdp, ensureProfileCleanExit, findChromeExecutableMac, findChromeExecutableWindows, + formatChromeCdpDiagnostic, getChromeWebSocketUrl, isChromeCdpReady, isChromeReachable, @@ -312,6 +314,22 @@ describe("browser chrome helpers", () => { await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false); }); + it("diagnoses /json/version responses that omit the websocket URL", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ Browser: "Chrome/Mock" }), + } as unknown as Response), + ); + + await expect(diagnoseChromeCdp("http://127.0.0.1:12345", 50, 50)).resolves.toMatchObject({ + ok: false, + code: "missing_websocket_debugger_url", + cdpUrl: "http://127.0.0.1:12345", + }); + }); + it("allows loopback CDP probes while still blocking non-loopback private targets in strict SSRF mode", async () => { const fetchSpy = vi .fn() @@ -417,6 +435,39 @@ describe("browser chrome helpers", () => { }); }); + it("diagnoses stale websocket command channels with the discovered websocket URL", async () => { + await withMockChromeCdpServer({ + wsPath: "/devtools/browser/stale-diagnostic", + onConnection: (wss) => wss.on("connection", (_ws) => {}), + run: async (baseUrl) => { + const diagnostic = await diagnoseChromeCdp(baseUrl, 300, 150); + expect(diagnostic).toMatchObject({ + ok: false, + code: "websocket_health_command_timeout", + }); + expect(diagnostic.wsUrl).toMatch(/\/devtools\/browser\/stale-diagnostic$/); + }, + }); + }); + + it("formats diagnostics with redacted CDP credentials", () => { + const formatted = formatChromeCdpDiagnostic({ + ok: false, + code: "websocket_handshake_failed", + cdpUrl: "https://user:pass@browserless.example.com?token=supersecret123", + wsUrl: "wss://user:pass@browserless.example.com/devtools/browser/1?token=supersecret123", + message: "connect ECONNREFUSED browserless.example.com", + elapsedMs: 12, + }); + + expect(formatted).toContain("websocket_handshake_failed"); + expect(formatted).toContain("https://browserless.example.com/?token=***"); + expect(formatted).toContain("wss://browserless.example.com/devtools/browser/1?token=***"); + expect(formatted).not.toContain("user"); + expect(formatted).not.toContain("pass"); + 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. diff --git a/extensions/browser/src/browser/chrome.ts b/extensions/browser/src/browser/chrome.ts index 0ece64410b3..5641e5bff38 100644 --- a/extensions/browser/src/browser/chrome.ts +++ b/extensions/browser/src/browser/chrome.ts @@ -5,7 +5,6 @@ import path from "node:path"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { ensurePortAvailable } from "../infra/ports.js"; -import { rawDataToString } from "../infra/ws.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { CONFIG_DIR } from "../utils.js"; import { @@ -19,14 +18,15 @@ import { CHROME_STOP_TIMEOUT_MS, CHROME_WS_READY_TIMEOUT_MS, } from "./cdp-timeouts.js"; -import { - appendCdpPath, - assertCdpEndpointAllowed, - fetchCdpChecked, - isWebSocketUrl, - openCdpWebSocket, -} from "./cdp.helpers.js"; +import { assertCdpEndpointAllowed, isWebSocketUrl, openCdpWebSocket } from "./cdp.helpers.js"; import { normalizeCdpWsUrl } from "./cdp.js"; +import { + diagnoseChromeCdp, + formatChromeCdpDiagnostic, + type ChromeVersion, + readChromeVersion, + safeChromeCdpErrorMessage, +} from "./chrome.diagnostics.js"; import { type BrowserExecutable, resolveBrowserExecutableForPlatform, @@ -45,6 +45,12 @@ import { const log = createSubsystemLogger("browser").child("chrome"); export type { BrowserExecutable } from "./chrome.executables.js"; +export { + diagnoseChromeCdp, + formatChromeCdpDiagnostic, + type ChromeCdpDiagnostic, + type ChromeCdpDiagnosticCode, +} from "./chrome.diagnostics.js"; export { findChromeExecutableLinux, findChromeExecutableMac, @@ -127,15 +133,24 @@ export function buildOpenClawChromeLaunchArgs(params: { async function canOpenWebSocket(url: string, timeoutMs: number): Promise { return new Promise((resolve) => { const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs }); + let settled = false; + const finish = (value: boolean) => { + if (settled) { + return; + } + settled = true; + resolve(value); + }; ws.once("open", () => { try { ws.close(); } catch { // ignore } - resolve(true); + finish(true); }); - ws.once("error", () => resolve(false)); + ws.once("error", () => finish(false)); + ws.once("close", () => finish(false)); }); } @@ -157,40 +172,15 @@ export async function isChromeReachable( } } -type ChromeVersion = { - webSocketDebuggerUrl?: string; - Browser?: string; - "User-Agent"?: string; -}; - async function fetchChromeVersion( cdpUrl: string, timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, ssrfPolicy?: SsrFPolicy, ): Promise { - const ctrl = new AbortController(); - const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs); try { - const versionUrl = appendCdpPath(cdpUrl, "/json/version"); - const { response, release } = await fetchCdpChecked( - versionUrl, - timeoutMs, - { signal: ctrl.signal }, - ssrfPolicy, - ); - try { - const data = (await response.json()) as ChromeVersion; - if (!data || typeof data !== "object") { - return null; - } - return data; - } finally { - await release(); - } + return await readChromeVersion(cdpUrl, timeoutMs, ssrfPolicy); } catch { return null; - } finally { - clearTimeout(t); } } @@ -214,92 +204,17 @@ export async function getChromeWebSocketUrl( return normalizedWsUrl; } -async function canRunCdpHealthCommand( - wsUrl: string, - timeoutMs = CHROME_WS_READY_TIMEOUT_MS, -): Promise { - return await new Promise((resolve) => { - const ws = openCdpWebSocket(wsUrl, { - handshakeTimeoutMs: timeoutMs, - }); - let settled = false; - const onMessage = (raw: Parameters[0]) => { - if (settled) { - return; - } - let parsed: { id?: unknown; result?: unknown } | null = null; - try { - parsed = JSON.parse(rawDataToString(raw)) as { id?: unknown; result?: unknown }; - } catch { - return; - } - if (parsed?.id !== 1) { - return; - } - finish(Boolean(parsed.result && typeof parsed.result === "object")); - }; - - const finish = (value: boolean) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - ws.off("message", onMessage); - try { - ws.close(); - } catch { - // ignore - } - resolve(value); - }; - const timer = setTimeout( - () => { - try { - ws.terminate(); - } catch { - // ignore - } - finish(false); - }, - Math.max(50, timeoutMs + 25), - ); - - ws.once("open", () => { - try { - ws.send( - JSON.stringify({ - id: 1, - method: "Browser.getVersion", - }), - ); - } catch { - finish(false); - } - }); - - ws.on("message", onMessage); - - ws.once("error", () => { - finish(false); - }); - ws.once("close", () => { - finish(false); - }); - }); -} - export async function isChromeCdpReady( cdpUrl: string, timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, handshakeTimeoutMs = CHROME_WS_READY_TIMEOUT_MS, ssrfPolicy?: SsrFPolicy, ): Promise { - const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs, ssrfPolicy).catch(() => null); - if (!wsUrl) { - return false; + const diagnostic = await diagnoseChromeCdp(cdpUrl, timeoutMs, handshakeTimeoutMs, ssrfPolicy); + if (!diagnostic.ok) { + log.debug(formatChromeCdpDiagnostic(diagnostic)); } - return await canRunCdpHealthCommand(wsUrl, handshakeTimeoutMs); + return diagnostic.ok; } export async function launchOpenClawChrome( @@ -418,6 +333,9 @@ export async function launchOpenClawChrome( } if (!(await isChromeReachable(profile.cdpUrl))) { + const diagnosticText = await diagnoseChromeCdp(profile.cdpUrl) + .then(formatChromeCdpDiagnostic) + .catch((err) => `CDP diagnostic failed: ${safeChromeCdpErrorMessage(err)}.`); const stderrOutput = normalizeOptionalString(Buffer.concat(stderrChunks).toString("utf8")) ?? ""; const stderrHint = stderrOutput @@ -433,7 +351,7 @@ export async function launchOpenClawChrome( // ignore } throw new Error( - `Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".${sandboxHint}${stderrHint}`, + `Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}". ${diagnosticText}${sandboxHint}${stderrHint}`, ); } diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index 146f9561b15..f4daa7e6eb3 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -14,6 +14,8 @@ import { listChromeMcpTabs, } from "./chrome-mcp.js"; import { + diagnoseChromeCdp, + formatChromeCdpDiagnostic, isChromeCdpReady, isChromeReachable, launchOpenClawChrome, @@ -96,6 +98,17 @@ export function createProfileAvailability({ return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, getCdpReachabilityPolicy()); }; + const describeCdpFailure = async (timeoutMs?: number): Promise => { + const { httpTimeoutMs, wsTimeoutMs } = resolveTimeouts(timeoutMs); + const diagnostic = await diagnoseChromeCdp( + profile.cdpUrl, + httpTimeoutMs, + wsTimeoutMs, + getCdpReachabilityPolicy(), + ); + return formatChromeCdpDiagnostic(diagnostic); + }; + const attachRunning = (running: NonNullable) => { setProfileRunning(running); running.proc.on("exit", () => { @@ -150,7 +163,9 @@ export function createProfileAvailability({ await new Promise((r) => setTimeout(r, CDP_READY_AFTER_LAUNCH_POLL_MS)); } throw new Error( - `Chrome CDP websocket for profile "${profile.name}" is not reachable after start.`, + `Chrome CDP websocket for profile "${profile.name}" is not reachable after start. ${await describeCdpFailure( + CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS, + )}`, ); }; @@ -245,18 +260,20 @@ export function createProfileAvailability({ if (remoteCdp && (await isReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))) { return; } + const detail = await describeCdpFailure(PROFILE_ATTACH_RETRY_TIMEOUT_MS); throw new BrowserProfileUnavailableError( remoteCdp - ? `Remote CDP websocket for profile "${profile.name}" is not reachable.` - : `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`, + ? `Remote CDP websocket for profile "${profile.name}" is not reachable. ${detail}` + : `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable. ${detail}`, ); } // HTTP responds but WebSocket fails - port in use by something else. if (!profileState.running) { + const detail = await describeCdpFailure(PROFILE_ATTACH_RETRY_TIMEOUT_MS); throw new BrowserProfileUnavailableError( `Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. ` + - `Run action=reset-profile profile=${profile.name} to kill the process.`, + `Run action=reset-profile profile=${profile.name} to kill the process. ${detail}`, ); } @@ -268,7 +285,9 @@ export function createProfileAvailability({ if (!(await isReachable(PROFILE_POST_RESTART_WS_TIMEOUT_MS))) { throw new Error( - `Chrome CDP websocket for profile "${profile.name}" is not reachable after restart.`, + `Chrome CDP websocket for profile "${profile.name}" is not reachable after restart. ${await describeCdpFailure( + PROFILE_POST_RESTART_WS_TIMEOUT_MS, + )}`, ); } }; diff --git a/extensions/browser/src/browser/server-context.chrome-test-harness.ts b/extensions/browser/src/browser/server-context.chrome-test-harness.ts index 95ebe8097e6..64cb1ce3710 100644 --- a/extensions/browser/src/browser/server-context.chrome-test-harness.ts +++ b/extensions/browser/src/browser/server-context.chrome-test-harness.ts @@ -5,6 +5,18 @@ const chromeUserDataDir = { dir: "/tmp/openclaw" }; installChromeUserDataDirHooks(chromeUserDataDir); vi.mock("./chrome.js", () => ({ + diagnoseChromeCdp: vi.fn(async () => ({ + ok: false, + code: "websocket_health_command_timeout", + cdpUrl: "http://127.0.0.1:18800", + message: "mock CDP diagnostic", + elapsedMs: 1, + })), + formatChromeCdpDiagnostic: vi.fn((diagnostic: { ok: boolean; code?: string; message?: string }) => + diagnostic.ok + ? "CDP diagnostic: ready." + : `CDP diagnostic: ${diagnostic.code}; ${diagnostic.message}.`, + ), isChromeCdpReady: vi.fn(async () => true), isChromeReachable: vi.fn(async () => true), launchOpenClawChrome: vi.fn(async () => { 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 9ad87c4a403..b93fe0ea1c1 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 @@ -55,8 +55,12 @@ describe("browser server-context ensureBrowserAvailable", () => { const promise = profile.ensureBrowserAvailable(); const rejected = expect(promise).rejects.toThrow("not reachable after start"); + const diagnosticRejected = expect(promise).rejects.toThrow( + "CDP diagnostic: websocket_health_command_timeout; mock CDP diagnostic.", + ); await vi.advanceTimersByTimeAsync(8100); await rejected; + await diagnosticRejected; expect(launchOpenClawChrome).toHaveBeenCalledTimes(1); expect(stopOpenClawChrome).toHaveBeenCalledTimes(1);