diff --git a/CHANGELOG.md b/CHANGELOG.md index d7b12106e18..62f1f6774e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Subagents/browser: show an actionable `/tools` notice when browser automation is configured but filtered out by the active tool profile, and document that coding-profile agents should use `tools.alsoAllow: ["browser"]` rather than subagent allowlists alone. - Control UI/Quick Settings: persist the assistant avatar override to browser local storage (mirroring the user avatar) so uploaded image data URLs no longer fail config validation with "Too big: expected string to have <=200 characters". Also lift the gateway-side `ui.assistant.avatar` length cap to match the user avatar size budget for non-UI clients writing the field directly. Thanks @BunsDev. - Browser/CDP: make readiness diagnostics use the same discovery-first fallback as reachability for bare `ws://` Browserless and Browserbase CDP URLs. Fixes #69532. +- Browser/CDP: explain that loopback Browserless or other externally managed CDP services need `attachOnly: true` and matching Browserless `EXTERNAL` endpoint when reporting local port ownership conflicts, and fall back to the configured bare WebSocket root when a discovered Browserless endpoint rejects CDP. Fixes #49815. - ACP/OpenCode: update the bundled acpx runtime to 0.6.0 and cover the OpenCode ACP bind path in Docker live tests. - Providers/OpenCode Go: add DeepSeek V4 Pro and DeepSeek V4 Flash to the Go catalog while the bundled Pi registry catches up. Fixes #71587. - Browser/existing-session: support per-profile Chrome MCP command/args, map `cdpUrl` to `--browserUrl` or `--wsEndpoint`, and avoid combining endpoint flags with `--userDataDir`. Fixes #47879, #48037, and #62706. Thanks @puneet1409, @zhehao, and @madkow1001. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b3a84b621b6..520535c2807 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. +- 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. - `existing-session` profiles use Chrome MCP instead of CDP and can attach on the selected host or through a connected browser node. - `existing-session` profiles can set `userDataDir` to target a specific diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 19f8c12081b..365bd0586aa 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -297,6 +297,9 @@ instead, and remote CDP profiles use the browser behind `cdpUrl`. - **Remote control (node host):** run a node host on the machine that has the browser; the Gateway proxies browser actions to it. - **Remote CDP:** set `browser.profiles..cdpUrl` (or `browser.cdpUrl`) to attach to a remote Chromium-based browser. In this case, OpenClaw will not launch a local browser. +- For externally managed CDP services on loopback (for example Browserless in + Docker published to `127.0.0.1`), also set `attachOnly: true`. Loopback CDP + without `attachOnly` is treated as a local OpenClaw-managed browser profile. - `headless` only affects local managed profiles that OpenClaw launches. It does not restart or change existing-session or remote CDP browsers. - `executablePath` follows the same local managed profile rule. Changing it on a running local managed profile marks that profile for restart/reconcile so the @@ -370,6 +373,39 @@ Notes: `wss://` for a direct CDP connection or keep the HTTPS URL and let OpenClaw discover `/json/version`. +### Browserless Docker on the same host + +When Browserless is self-hosted in Docker and OpenClaw runs on the host, treat +Browserless as an externally managed CDP service: + +```json5 +{ + browser: { + enabled: true, + defaultProfile: "browserless", + profiles: { + browserless: { + cdpUrl: "ws://127.0.0.1:3000", + attachOnly: true, + color: "#00AA00", + }, + }, + }, +} +``` + +The address in `browser.profiles.browserless.cdpUrl` must be reachable from the +OpenClaw process. Browserless must also advertise a matching reachable endpoint; +set Browserless `EXTERNAL` to that same public-to-OpenClaw WebSocket base, such +as `ws://127.0.0.1:3000`, `ws://browserless:3000`, or a stable private Docker +network address. If `/json/version` returns `webSocketDebuggerUrl` pointing at +an address OpenClaw cannot reach, CDP HTTP can look healthy while the WebSocket +attach still fails. + +Do not leave `attachOnly` unset for a loopback Browserless profile. Without +`attachOnly`, OpenClaw treats the loopback port as a local managed browser +profile and may report that the port is in use but not owned by OpenClaw. + ## Direct WebSocket CDP providers Some hosted browser services expose a **direct WebSocket** endpoint rather than @@ -388,10 +424,13 @@ CDP URL shapes and picks the right connection strategy automatically: [Browserbase](https://www.browserbase.com)). OpenClaw tries HTTP `/json/version` discovery first (normalising the scheme to `http`/`https`); if discovery returns a `webSocketDebuggerUrl` it is used, otherwise OpenClaw - falls back to a direct WebSocket handshake at the bare root. This lets a - bare `ws://` pointed at a local Chrome still connect, since Chrome only - accepts WebSocket upgrades on the specific per-target path from - `/json/version`. + falls back to a direct WebSocket handshake at the bare root. If the advertised + WebSocket endpoint rejects the CDP handshake but the configured bare root + accepts it, OpenClaw falls back to that root as well. This lets a bare `ws://` + pointed at a local Chrome still connect, since Chrome only accepts WebSocket + upgrades on the specific per-target path from `/json/version`, while hosted + providers can still use their root WebSocket endpoint when their discovery + endpoint advertises a short-lived URL that is not suitable for Playwright CDP. ### Browserbase @@ -654,6 +693,8 @@ Common examples: - CDP startup or readiness failure: - `Chrome CDP websocket for profile "openclaw" is not reachable after start` - `Remote CDP for profile "" is not reachable at ` + - `Port is in use for profile "" but not by openclaw` when a + loopback external CDP service is configured without `attachOnly: true` - Navigation SSRF block: - `open`, `navigate`, snapshot, or tab-opening flows fail with a browser/network policy error while `start` and `tabs` still work diff --git a/extensions/browser/src/browser/cdp.test.ts b/extensions/browser/src/browser/cdp.test.ts index a3e23623ff9..74780de512c 100644 --- a/extensions/browser/src/browser/cdp.test.ts +++ b/extensions/browser/src/browser/cdp.test.ts @@ -1,4 +1,5 @@ import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type WebSocket, WebSocketServer } from "ws"; import { SsrFBlockedError } from "../infra/net/ssrf.js"; @@ -351,6 +352,56 @@ describe("cdp", () => { expect(created.targetId).toBe("WS_FALLBACK"); }); + it("falls back to direct WS connection when discovered Browserless endpoint rejects commands", async () => { + const server = createServer((req, res) => { + if (req.url?.startsWith("/json/version")) { + const addr = server.address() as AddressInfo; + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + webSocketDebuggerUrl: `ws://127.0.0.1:${addr.port}/e/bad`, + }), + ); + return; + } + res.statusCode = 404; + res.end("not found"); + }); + const wss = new WebSocketServer({ noServer: true }); + server.on("upgrade", (req, socket, head) => { + if (req.url?.startsWith("/e/bad")) { + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + }); + wss.on("connection", (socket) => { + socket.on("message", (data) => { + const msg = JSON.parse(rawDataToString(data)) as { + id?: number; + method?: string; + }; + if (msg.method === "Target.createTarget") { + socket.send(JSON.stringify({ id: msg.id, result: { targetId: "ROOT_FALLBACK" } })); + } + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const addr = server.address() as AddressInfo; + const created = await createTargetViaCdp({ + cdpUrl: `ws://127.0.0.1:${addr.port}?token=abc`, + url: "https://example.com", + }); + expect(created.targetId).toBe("ROOT_FALLBACK"); + } finally { + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it("captures an aria snapshot via CDP", async () => { const wsPort = await startWsServerWithMessages((msg, socket) => { if (msg.method === "Accessibility.enable") { diff --git a/extensions/browser/src/browser/cdp.ts b/extensions/browser/src/browser/cdp.ts index aef31361bfe..14784253e5b 100644 --- a/extensions/browser/src/browser/cdp.ts +++ b/extensions/browser/src/browser/cdp.ts @@ -230,19 +230,32 @@ export async function createTargetViaCdp(opts: { } else { throw new Error("CDP /json/version missing webSocketDebuggerUrl"); } - await assertCdpEndpointAllowed(wsUrl, opts.ssrfPolicy); } - return await withCdpSocket(wsUrl, 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"); + const candidateWsUrls = + isWebSocketUrl(opts.cdpUrl) && wsUrl !== opts.cdpUrl ? [wsUrl, opts.cdpUrl] : [wsUrl]; + let lastError: unknown; + 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 }; + }); + } catch (err) { + lastError = err; } - return { targetId }; - }); + } + if (lastError instanceof Error) { + throw lastError; + } + throw new Error("CDP Target.createTarget failed"); } export type CdpRemoteObject = { diff --git a/extensions/browser/src/browser/chrome.diagnostics.ts b/extensions/browser/src/browser/chrome.diagnostics.ts index eea5b7ebb1c..1420dcae5c8 100644 --- a/extensions/browser/src/browser/chrome.diagnostics.ts +++ b/extensions/browser/src/browser/chrome.diagnostics.ts @@ -365,6 +365,19 @@ export async function diagnoseChromeCdp( const health = await diagnoseCdpHealthCommand(wsUrl, handshakeTimeoutMs); if (!health.ok) { + if (isWebSocketUrl(cdpUrl) && wsUrl !== cdpUrl) { + const directHealth = await diagnoseCdpHealthCommand(cdpUrl, handshakeTimeoutMs); + if (directHealth.ok) { + return { + ok: true, + cdpUrl, + wsUrl: cdpUrl, + browser: version.Browser, + userAgent: version["User-Agent"], + elapsedMs: elapsedSince(startedAt), + }; + } + } return failureDiagnostic({ cdpUrl, wsUrl, diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index f2b05165783..fc4eed4f6a8 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -662,6 +662,59 @@ describe("browser chrome helpers", () => { }); }); + it("falls back to the bare WebSocket root when discovered Browserless endpoint rejects readiness", async () => { + const server = createServer((req, res) => { + if (req.url?.startsWith("/json/version")) { + const addr = server.address() as AddressInfo; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + Browser: "Browserless/Mock", + webSocketDebuggerUrl: `ws://127.0.0.1:${addr.port}/e/bad`, + }), + ); + return; + } + res.writeHead(404); + res.end(); + }); + const wss = new WebSocketServer({ noServer: true }); + server.on("upgrade", (req, socket, head) => { + if (req.url?.startsWith("/e/bad")) { + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + }); + wss.on("connection", (ws) => { + ws.on("message", (raw) => { + const message = JSON.parse(rawDataToString(raw)) as { id?: number; method?: string }; + if (message.method === "Browser.getVersion" && message.id === 1) { + ws.send(JSON.stringify({ id: 1, result: { product: "Browserless/Mock" } })); + } + }); + }); + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", () => resolve()); + server.once("error", reject); + }); + try { + const addr = server.address() as AddressInfo; + const wsOnlyBase = `ws://127.0.0.1:${addr.port}?token=abc`; + await expect(isChromeCdpReady(wsOnlyBase, 300, 400)).resolves.toBe(true); + await expect(diagnoseChromeCdp(wsOnlyBase, 300, 400)).resolves.toMatchObject({ + ok: true, + wsUrl: wsOnlyBase, + browser: "Browserless/Mock", + }); + } finally { + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it("reports unreachable when a bare ws:// CDP URL points at a server with no /json/version and refuses WS", async () => { // Negative counterpart to the #68027 happy path — a bare ws URL // pointed at a port that neither serves /json/version nor accepts diff --git a/extensions/browser/src/browser/pw-session.ts b/extensions/browser/src/browser/pw-session.ts index bfb002f9999..81d81817f97 100644 --- a/extensions/browser/src/browser/pw-session.ts +++ b/extensions/browser/src/browser/pw-session.ts @@ -20,6 +20,7 @@ import { assertCdpEndpointAllowed, fetchJson, getHeadersWithAuth, + isWebSocketUrl, normalizeCdpHttpBaseForJsonEndpoints, withCdpSocket, } from "./cdp.helpers.js"; @@ -500,11 +501,22 @@ async function connectBrowser(cdpUrl: string, ssrfPolicy?: SsrFPolicy): Promise< () => null, ); const endpoint = wsUrl ?? normalized; - const headers = getHeadersWithAuth(endpoint); - // Bypass proxy for loopback CDP connections (#31219) - const browser = await withNoProxyForCdpUrl(endpoint, () => - chromium.connectOverCDP(endpoint, { timeout, headers }), - ); + const connectEndpoint = async (target: string) => { + const headers = getHeadersWithAuth(target); + // Bypass proxy for loopback CDP connections (#31219) + return await withNoProxyForCdpUrl(target, () => + chromium.connectOverCDP(target, { timeout, headers }), + ); + }; + let browser: Browser; + try { + browser = await connectEndpoint(endpoint); + } catch (err) { + if (!isWebSocketUrl(normalized) || endpoint === normalized) { + throw err; + } + browser = await connectEndpoint(normalized); + } const onDisconnected = () => { const current = cachedByCdpUrl.get(normalized); if (current?.browser === browser) { diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index 135b07d8bcf..08d754539fd 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -66,6 +66,21 @@ function ensureOptionsKey(options?: BrowserEnsureOptions): string { return typeof options?.headless === "boolean" ? `headless:${options.headless}` : "default"; } +function formatLocalPortOwnershipHint(profile: ResolvedBrowserProfile): string { + const resetHint = + `If OpenClaw should own this local profile, run action=reset-profile profile=${profile.name} ` + + "to stop the conflicting process."; + if (!profile.cdpIsLoopback) { + return resetHint; + } + return ( + `${resetHint} If this port is an externally managed CDP service such as Browserless, ` + + `set browser.profiles.${profile.name}.attachOnly=true so OpenClaw attaches without trying ` + + "to manage the local process. For Browserless Docker, set EXTERNAL to the same WebSocket " + + "endpoint OpenClaw can reach via browser.profiles..cdpUrl." + ); +} + export function createProfileAvailability({ opts, profile, @@ -317,7 +332,7 @@ export function createProfileAvailability({ 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. ${detail}`, + `${formatLocalPortOwnershipHint(profile)} ${detail}`, ); } 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 fb073302005..3a944d080ce 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 @@ -230,6 +230,29 @@ describe("browser server-context ensureBrowserAvailable", () => { expect(stopOpenClawChrome).not.toHaveBeenCalled(); }); + it("explains attachOnly for externally managed loopback CDP services", async () => { + const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } = + setupEnsureBrowserAvailableHarness(); + const isChromeReachable = vi.mocked(chromeModule.isChromeReachable); + + isChromeReachable.mockResolvedValue(true); + isChromeCdpReady.mockResolvedValue(false); + + const promise = profile.ensureBrowserAvailable(); + await expect(promise).rejects.toThrow( + 'Port 18800 is in use for profile "openclaw" but not by openclaw.', + ); + await expect(promise).rejects.toThrow( + "set browser.profiles.openclaw.attachOnly=true so OpenClaw attaches without trying to manage the local process", + ); + await expect(promise).rejects.toThrow( + "For Browserless Docker, set EXTERNAL to the same WebSocket endpoint OpenClaw can reach via browser.profiles..cdpUrl.", + ); + + expect(launchOpenClawChrome).not.toHaveBeenCalled(); + expect(stopOpenClawChrome).not.toHaveBeenCalled(); + }); + it("retries remote CDP websocket reachability once before failing", async () => { const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady } = setupEnsureBrowserAvailableHarness();