fix(browser): honor remote CDP open timeouts

This commit is contained in:
Peter Steinberger
2026-04-25 18:52:22 +01:00
parent d623354a0e
commit 617e1dd6bf
8 changed files with 261 additions and 16 deletions

View File

@@ -38,6 +38,9 @@ Docs: https://docs.openclaw.ai
- Agents/replies: forward sanitized underlying agent failure details on external
channels instead of replacing unknown failures with a generic retry message.
Thanks @steipete.
- Browser/CDP: honor configured remote and `attachOnly` CDP HTTP/WebSocket
timeouts when opening tabs through raw CDP or `/json/new` fallback. (#54238)
Thanks @FuncWei.
- Agents/TTS: preserve `[[audio_as_voice]]` directives on trusted text
tool-result `MEDIA:` payloads so generated audio still delivers as a voice
note. (#46535) Thanks @azade-c.

View File

@@ -253,6 +253,9 @@ See [Plugins](/tools/plugin).
- `profiles.*.cdpUrl` accepts `http://`, `https://`, `ws://`, and `wss://`.
Use HTTP(S) when you want OpenClaw to discover `/json/version`; use WS(S)
when your provider gives you a direct DevTools WebSocket URL.
- `remoteCdpTimeoutMs` and `remoteCdpHandshakeTimeoutMs` apply to remote and
`attachOnly` CDP reachability plus tab-opening requests. Managed loopback
profiles keep local CDP defaults.
- If an externally managed CDP service is reachable through loopback, set that
profile's `attachOnly: true`; otherwise OpenClaw treats the loopback port as a
local managed browser profile and may report local port ownership errors.

View File

@@ -193,7 +193,9 @@ Browser settings live in `~/.openclaw/openclaw.json`.
- Control service binds to loopback on a port derived from `gateway.port` (default `18791` = gateway + 2). Overriding `gateway.port` or `OPENCLAW_GATEWAY_PORT` shifts the derived ports in the same family.
- Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl`; set those only for remote CDP. `cdpUrl` defaults to the managed local CDP port when unset.
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP HTTP reachability checks; `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket handshakes.
- `remoteCdpTimeoutMs` applies to remote and `attachOnly` CDP HTTP reachability
checks and tab-opening HTTP requests; `remoteCdpHandshakeTimeoutMs` applies to
their CDP WebSocket handshakes.
- `localLaunchTimeoutMs` is the budget for a locally launched managed Chrome
process to expose its CDP HTTP endpoint. `localCdpReadyTimeoutMs` is the
follow-up budget for CDP websocket readiness after the process is discovered.

View File

@@ -1,5 +1,6 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import type { Duplex } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type WebSocket, WebSocketServer } from "ws";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
@@ -138,6 +139,67 @@ describe("cdp", () => {
}
});
it("honors configured HTTP discovery timeouts when creating a target", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method !== "Target.createTarget") {
return;
}
socket.send(JSON.stringify({ id: msg.id, result: { targetId: "TARGET_SLOW" } }));
});
httpServer = createServer((req, res) => {
if (req.url === "/json/version") {
setTimeout(() => {
res.setHeader("content-type", "application/json");
res.end(
JSON.stringify({
webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/SLOW`,
}),
);
}, 120);
return;
}
res.statusCode = 404;
res.end("not found");
});
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
const httpPort = (httpServer.address() as AddressInfo).port;
await expect(
createTargetViaCdp({
cdpUrl: `http://127.0.0.1:${httpPort}`,
url: "https://example.com",
timeouts: { httpTimeoutMs: 20 },
}),
).rejects.toThrow();
});
it("honors configured WebSocket handshake timeouts when creating a target", async () => {
wsServer = new WebSocketServer({ noServer: true });
httpServer = createServer();
const heldSockets: Duplex[] = [];
httpServer.on("upgrade", (_req, socket) => {
heldSockets.push(socket);
// Hold the TCP connection open without completing the WebSocket handshake.
});
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
const port = (httpServer.address() as AddressInfo).port;
try {
await expect(
createTargetViaCdp({
cdpUrl: `ws://127.0.0.1:${port}/devtools/browser/SLOW`,
url: "https://example.com",
timeouts: { handshakeTimeoutMs: 20 },
}),
).rejects.toThrow();
} finally {
for (const socket of heldSockets) {
socket.destroy();
}
}
});
it("preserves query params when connecting via direct WebSocket URL", async () => {
let receivedHeaders: Record<string, string> = {};
const wsPort = await startWsServer();

View File

@@ -180,10 +180,16 @@ export async function captureScreenshot(opts: {
);
}
export type CdpActionTimeouts = {
httpTimeoutMs?: number;
handshakeTimeoutMs?: number;
};
export async function createTargetViaCdp(opts: {
cdpUrl: string;
url: string;
ssrfPolicy?: SsrFPolicy;
timeouts?: CdpActionTimeouts;
}): Promise<{ targetId: string }> {
await assertBrowserNavigationAllowed({
url: opts.url,
@@ -208,7 +214,7 @@ export async function createTargetViaCdp(opts: {
try {
version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
appendCdpPath(discoveryUrl, "/json/version"),
1500,
opts.timeouts?.httpTimeoutMs,
undefined,
opts.ssrfPolicy,
);
@@ -238,16 +244,20 @@ export async function createTargetViaCdp(opts: {
for (const candidateWsUrl of candidateWsUrls) {
try {
await assertCdpEndpointAllowed(candidateWsUrl, opts.ssrfPolicy);
return await withCdpSocket(candidateWsUrl, async (send) => {
const created = (await send("Target.createTarget", { url: opts.url })) as {
targetId?: string;
};
const targetId = created?.targetId?.trim() ?? "";
if (!targetId) {
throw new Error("CDP Target.createTarget returned no targetId");
}
return { targetId };
});
return await withCdpSocket(
candidateWsUrl,
async (send) => {
const created = (await send("Target.createTarget", { url: opts.url })) as {
targetId?: string;
};
const targetId = created?.targetId?.trim() ?? "";
if (!targetId) {
throw new Error("CDP Target.createTarget returned no targetId");
}
return { targetId };
},
{ handshakeTimeoutMs: opts.timeouts?.handshakeTimeoutMs },
);
} catch (err) {
lastError = err;
}

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { withBrowserFetchPreconnect } from "../../test-fetch.js";
import {
installRemoteProfileTestLifecycle,
loadRemoteProfileTestDeps,
@@ -127,4 +128,149 @@ describe("browser remote profile fallback and attachOnly behavior", () => {
expect(opened.targetId).toBe("T1");
expect(fetchMock).not.toHaveBeenCalled();
});
it("passes configured remote CDP timeouts when opening tabs through raw CDP", async () => {
vi.spyOn(deps.pwAiModule, "getPwAiModule").mockResolvedValue(null);
const createTargetViaCdp = vi
.spyOn(deps.cdpModule, "createTargetViaCdp")
.mockResolvedValue({ targetId: "T_REMOTE" });
const { state, remote } = deps.createRemoteRouteHarness(
vi.fn(
deps.createJsonListFetchMock([
{
id: "T_REMOTE",
title: "Remote Tab",
url: "https://example.com",
webSocketDebuggerUrl: "wss://browserless.example/devtools/page/T_REMOTE",
type: "page",
},
]),
),
);
state.resolved.remoteCdpTimeoutMs = 4321;
state.resolved.remoteCdpHandshakeTimeoutMs = 8765;
const opened = await remote.openTab("https://example.com");
expect(opened.targetId).toBe("T_REMOTE");
expect(createTargetViaCdp).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: "https://1.1.1.1:9222/chrome?token=abc",
url: "https://example.com",
timeouts: {
httpTimeoutMs: 4321,
handshakeTimeoutMs: 8765,
},
}),
);
});
it("uses remote-class tab-open timeouts for attachOnly loopback CDP profiles", async () => {
vi.spyOn(deps.pwAiModule, "getPwAiModule").mockResolvedValue(null);
const createTargetViaCdp = vi
.spyOn(deps.cdpModule, "createTargetViaCdp")
.mockResolvedValue({ targetId: "T_ATTACH" });
const state = deps.makeState("openclaw");
state.resolved.remoteCdpTimeoutMs = 2345;
state.resolved.remoteCdpHandshakeTimeoutMs = 6789;
state.resolved.profiles.openclaw = {
cdpPort: 18800,
attachOnly: true,
color: "#FF4500",
};
const fetchMock = vi.fn(
deps.createJsonListFetchMock([
{
id: "T_ATTACH",
title: "Attach Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1:18800/devtools/page/T_ATTACH",
type: "page",
},
]),
);
global.fetch = withBrowserFetchPreconnect(fetchMock);
const ctx = deps.createBrowserRouteContext({ getState: () => state });
const opened = await ctx.forProfile("openclaw").openTab("https://example.com");
expect(opened.targetId).toBe("T_ATTACH");
expect(createTargetViaCdp).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: "http://127.0.0.1:18800",
timeouts: {
httpTimeoutMs: 2345,
handshakeTimeoutMs: 6789,
},
}),
);
});
it("keeps managed loopback tab opens on local CDP defaults", async () => {
vi.spyOn(deps.pwAiModule, "getPwAiModule").mockResolvedValue(null);
const createTargetViaCdp = vi
.spyOn(deps.cdpModule, "createTargetViaCdp")
.mockResolvedValue({ targetId: "T_LOCAL" });
const state = deps.makeState("openclaw");
const fetchMock = vi.fn(
deps.createJsonListFetchMock([
{
id: "T_LOCAL",
title: "Local Tab",
url: "http://127.0.0.1:3000",
webSocketDebuggerUrl: "ws://127.0.0.1:18800/devtools/page/T_LOCAL",
type: "page",
},
]),
);
global.fetch = withBrowserFetchPreconnect(fetchMock);
const ctx = deps.createBrowserRouteContext({ getState: () => state });
await ctx.forProfile("openclaw").openTab("http://127.0.0.1:3000");
expect(createTargetViaCdp).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18800",
url: "http://127.0.0.1:3000",
ssrfPolicy: undefined,
});
});
it("uses the remote HTTP timeout for /json/new fallback tab opens", async () => {
vi.spyOn(deps.pwAiModule, "getPwAiModule").mockResolvedValue(null);
vi.spyOn(deps.cdpModule, "createTargetViaCdp").mockRejectedValue(
new Error("Target.createTarget unavailable"),
);
const fetchMock = vi.fn(async (...args: unknown[]) => {
const url = String(args[0]);
if (url.includes("/json/new")) {
const init = args[1] as RequestInit | undefined;
expect(init?.method).toBe("PUT");
expect(init?.signal).toBeInstanceOf(AbortSignal);
return await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
"abort",
() => reject(new Error("aborted after remote timeout")),
{ once: true },
);
});
}
throw new Error(`unexpected fetch: ${url}`);
});
const { state, remote } = deps.createRemoteRouteHarness(fetchMock);
state.resolved.remoteCdpTimeoutMs = 25;
const startedAt = Date.now();
await expect(remote.openTab("https://example.com")).rejects.toThrow(
/aborted after remote timeout/,
);
expect(Date.now() - startedAt).toBeLessThan(700);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/json/new"),
expect.objectContaining({
method: "PUT",
signal: expect.any(AbortSignal),
}),
);
});
});

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, vi } from "vitest";
export type RemoteProfileTestDeps = {
cdpModule: typeof import("./cdp.js");
chromeModule: typeof import("./chrome.js");
InvalidBrowserNavigationUrlError: typeof import("./navigation-guard.js").InvalidBrowserNavigationUrlError;
pwAiModule: typeof import("./pw-ai-module.js");
@@ -18,6 +19,7 @@ let remoteProfileTestDepsPromise: Promise<RemoteProfileTestDeps> | undefined;
export async function loadRemoteProfileTestDeps(): Promise<RemoteProfileTestDeps> {
remoteProfileTestDepsPromise ??= (async () => {
await import("./server-context.chrome-test-harness.js");
const cdpModule = await import("./cdp.js");
const chromeModule = await import("./chrome.js");
const { InvalidBrowserNavigationUrlError } = await import("./navigation-guard.js");
const pwAiModule = await import("./pw-ai-module.js");
@@ -31,6 +33,7 @@ export async function loadRemoteProfileTestDeps(): Promise<RemoteProfileTestDeps
originalFetch,
} = await import("./server-context.remote-tab-ops.harness.js");
return {
cdpModule,
chromeModule,
InvalidBrowserNavigationUrlError,
pwAiModule,

View File

@@ -8,6 +8,7 @@ import {
normalizeCdpHttpBaseForJsonEndpoints,
} from "./cdp.helpers.js";
import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
import type { CdpActionTimeouts } from "./cdp.js";
import { getChromeMcpModule } from "./chrome-mcp.runtime.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.js";
@@ -140,6 +141,16 @@ export function createProfileTabOps({
profile,
}),
});
const getRemoteCdpActionTimeouts = (): CdpActionTimeouts | undefined => {
if (profile.cdpIsLoopback && !profile.attachOnly) {
return undefined;
}
const resolved = state().resolved;
return {
httpTimeoutMs: resolved.remoteCdpTimeoutMs,
handshakeTimeoutMs: resolved.remoteCdpHandshakeTimeoutMs,
};
};
const readTabs = async (): Promise<BrowserTab[]> => {
if (capabilities.usesChromeMcp) {
@@ -270,11 +281,16 @@ export function createProfileTabOps({
}
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const createdViaCdp = await createTargetViaCdp({
const cdpActionTimeouts = getRemoteCdpActionTimeouts();
const createTargetOpts: Parameters<typeof createTargetViaCdp>[0] = {
cdpUrl: profile.cdpUrl,
url,
ssrfPolicy: getCdpControlPolicy(),
})
};
if (cdpActionTimeouts) {
createTargetOpts.timeouts = cdpActionTimeouts;
}
const createdViaCdp = await createTargetViaCdp(createTargetOpts)
.then((r) => r.targetId)
.catch(() => null);
@@ -310,7 +326,7 @@ export function createProfileTabOps({
: `${endpointUrl.toString()}?${encoded}`;
const created = await fetchJson<CdpTarget>(
endpoint,
CDP_JSON_NEW_TIMEOUT_MS,
cdpActionTimeouts?.httpTimeoutMs ?? CDP_JSON_NEW_TIMEOUT_MS,
{
method: "PUT",
},
@@ -319,7 +335,7 @@ export function createProfileTabOps({
if (String(err).includes("HTTP 405")) {
return await fetchJson<CdpTarget>(
endpoint,
CDP_JSON_NEW_TIMEOUT_MS,
cdpActionTimeouts?.httpTimeoutMs ?? CDP_JSON_NEW_TIMEOUT_MS,
undefined,
getCdpControlPolicy(),
);