mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:00:42 +00:00
fix(browser): clarify Browserless CDP attach handling
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user