mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:10:44 +00:00
fix(browser): improve CDP startup diagnostics
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:";
|
||||
|
||||
342
extensions/browser/src/browser/chrome.diagnostics.ts
Normal file
342
extensions/browser/src/browser/chrome.diagnostics.ts
Normal file
@@ -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<ChromeVersion> {
|
||||
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<CdpHealthDiagnostic> {
|
||||
return await new Promise<CdpHealthDiagnostic>((resolve) => {
|
||||
const ws = openCdpWebSocket(wsUrl, {
|
||||
handshakeTimeoutMs: timeoutMs,
|
||||
});
|
||||
let settled = false;
|
||||
let opened = false;
|
||||
const onMessage = (raw: Parameters<typeof rawDataToString>[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<ChromeCdpDiagnostic> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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<boolean> {
|
||||
return new Promise<boolean>((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<ChromeVersion | null> {
|
||||
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<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const ws = openCdpWebSocket(wsUrl, {
|
||||
handshakeTimeoutMs: timeoutMs,
|
||||
});
|
||||
let settled = false;
|
||||
const onMessage = (raw: Parameters<typeof rawDataToString>[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<boolean> {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string> => {
|
||||
const { httpTimeoutMs, wsTimeoutMs } = resolveTimeouts(timeoutMs);
|
||||
const diagnostic = await diagnoseChromeCdp(
|
||||
profile.cdpUrl,
|
||||
httpTimeoutMs,
|
||||
wsTimeoutMs,
|
||||
getCdpReachabilityPolicy(),
|
||||
);
|
||||
return formatChromeCdpDiagnostic(diagnostic);
|
||||
};
|
||||
|
||||
const attachRunning = (running: NonNullable<ProfileRuntimeState["running"]>) => {
|
||||
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,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user