diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e90ffb1090..4b4c4940022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 520535c2807..ec916e481b2 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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. diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 365bd0586aa..f8ec17d5df2 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -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. diff --git a/extensions/browser/src/browser/cdp.test.ts b/extensions/browser/src/browser/cdp.test.ts index 74780de512c..d99dad6dd89 100644 --- a/extensions/browser/src/browser/cdp.test.ts +++ b/extensions/browser/src/browser/cdp.test.ts @@ -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((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((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 = {}; const wsPort = await startWsServer(); diff --git a/extensions/browser/src/browser/cdp.ts b/extensions/browser/src/browser/cdp.ts index 14784253e5b..750fdd7a190 100644 --- a/extensions/browser/src/browser/cdp.ts +++ b/extensions/browser/src/browser/cdp.ts @@ -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; } diff --git a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.fallback.test.ts b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.fallback.test.ts index 9de51a1a660..d8252b2dc0a 100644 --- a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.fallback.test.ts +++ b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.fallback.test.ts @@ -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((_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), + }), + ); + }); }); diff --git a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.test-helpers.ts b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.test-helpers.ts index 69038e5cf40..b78741faeb8 100644 --- a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.test-helpers.ts +++ b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.test-helpers.ts @@ -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 | undefined; export async function loadRemoteProfileTestDeps(): Promise { 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 { + if (profile.cdpIsLoopback && !profile.attachOnly) { + return undefined; + } + const resolved = state().resolved; + return { + httpTimeoutMs: resolved.remoteCdpTimeoutMs, + handshakeTimeoutMs: resolved.remoteCdpHandshakeTimeoutMs, + }; + }; const readTabs = async (): Promise => { if (capabilities.usesChromeMcp) { @@ -270,11 +281,16 @@ export function createProfileTabOps({ } await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); - const createdViaCdp = await createTargetViaCdp({ + const cdpActionTimeouts = getRemoteCdpActionTimeouts(); + const createTargetOpts: Parameters[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( 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( endpoint, - CDP_JSON_NEW_TIMEOUT_MS, + cdpActionTimeouts?.httpTimeoutMs ?? CDP_JSON_NEW_TIMEOUT_MS, undefined, getCdpControlPolicy(), );