fix(browser): clarify Browserless CDP attach handling

This commit is contained in:
Peter Steinberger
2026-04-25 18:26:47 +01:00
parent 0bbb0eb735
commit 88df8fe09d
10 changed files with 245 additions and 20 deletions

View File

@@ -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.

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.
- 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

View File

@@ -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.<name>.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 "<name>" is not reachable at <cdpUrl>`
- `Port <port> is in use for profile "<name>" 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

View File

@@ -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<void>((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<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("captures an aria snapshot via CDP", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method === "Accessibility.enable") {

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -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<void>((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<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((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

View File

@@ -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) {

View File

@@ -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.<name>.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}`,
);
}

View File

@@ -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.<name>.cdpUrl.",
);
expect(launchOpenClawChrome).not.toHaveBeenCalled();
expect(stopOpenClawChrome).not.toHaveBeenCalled();
});
it("retries remote CDP websocket reachability once before failing", async () => {
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady } =
setupEnsureBrowserAvailableHarness();