mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
fix(browser): honor remote CDP open timeouts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user