feat: add remote CDP browser support

This commit is contained in:
Peter Steinberger
2026-01-01 22:44:52 +01:00
parent 73d0e2cb81
commit bd8a0a9f8f
21 changed files with 400 additions and 157 deletions

View File

@@ -7,6 +7,7 @@ import WebSocket from "ws";
import { ensurePortAvailable } from "../infra/ports.js";
import { createSubsystemLogger } from "../logging.js";
import { CONFIG_DIR } from "../utils.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import type { ResolvedBrowserConfig } from "./config.js";
import {
DEFAULT_CLAWD_BROWSER_COLOR,
@@ -16,7 +17,7 @@ import {
const log = createSubsystemLogger("browser").child("chrome");
export type BrowserExecutable = {
kind: "canary" | "chromium" | "chrome";
kind: "canary" | "chromium" | "chrome" | "custom";
path: string;
};
@@ -81,6 +82,40 @@ export function findChromeExecutableMac(): BrowserExecutable | null {
return null;
}
export function findChromeExecutableLinux(): BrowserExecutable | null {
const candidates: Array<BrowserExecutable> = [
{ kind: "chrome", path: "/usr/bin/google-chrome" },
{ kind: "chrome", path: "/usr/bin/google-chrome-stable" },
{ kind: "chromium", path: "/usr/bin/chromium" },
{ kind: "chromium", path: "/usr/bin/chromium-browser" },
{ kind: "chromium", path: "/snap/bin/chromium" },
{ kind: "chrome", path: "/usr/bin/chrome" },
];
for (const candidate of candidates) {
if (exists(candidate.path)) return candidate;
}
return null;
}
function resolveBrowserExecutable(
resolved: ResolvedBrowserConfig,
): BrowserExecutable | null {
if (resolved.executablePath) {
if (!exists(resolved.executablePath)) {
throw new Error(
`browser.executablePath not found: ${resolved.executablePath}`,
);
}
return { kind: "custom", path: resolved.executablePath };
}
if (process.platform === "darwin") return findChromeExecutableMac();
if (process.platform === "linux") return findChromeExecutableLinux();
return null;
}
export function resolveClawdUserDataDir() {
return path.join(
CONFIG_DIR,
@@ -112,6 +147,10 @@ function safeWriteJson(filePath: string, data: Record<string, unknown>) {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}
function cdpUrlForPort(cdpPort: number) {
return `http://127.0.0.1:${cdpPort}`;
}
function setDeep(obj: Record<string, unknown>, keys: string[], value: unknown) {
let node: Record<string, unknown> = obj;
for (const key of keys.slice(0, -1)) {
@@ -304,10 +343,10 @@ export function decorateClawdProfile(
}
export async function isChromeReachable(
cdpPort: number,
cdpUrl: string,
timeoutMs = 500,
): Promise<boolean> {
const version = await fetchChromeVersion(cdpPort, timeoutMs);
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
return Boolean(version);
}
@@ -318,13 +357,14 @@ type ChromeVersion = {
};
async function fetchChromeVersion(
cdpPort: number,
cdpUrl: string,
timeoutMs = 500,
): Promise<ChromeVersion | null> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
const base = cdpUrl.replace(/\/$/, "");
const res = await fetch(`${base}/json/version`, {
signal: ctrl.signal,
});
if (!res.ok) return null;
@@ -339,12 +379,13 @@ async function fetchChromeVersion(
}
export async function getChromeWebSocketUrl(
cdpPort: number,
cdpUrl: string,
timeoutMs = 500,
): Promise<string | null> {
const version = await fetchChromeVersion(cdpPort, timeoutMs);
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
return wsUrl ? wsUrl : null;
if (!wsUrl) return null;
return normalizeCdpWsUrl(wsUrl, cdpUrl);
}
async function canOpenWebSocket(
@@ -381,11 +422,11 @@ async function canOpenWebSocket(
}
export async function isChromeCdpReady(
cdpPort: number,
cdpUrl: string,
timeoutMs = 500,
handshakeTimeoutMs = 800,
): Promise<boolean> {
const wsUrl = await getChromeWebSocketUrl(cdpPort, timeoutMs);
const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs);
if (!wsUrl) return false;
return await canOpenWebSocket(wsUrl, handshakeTimeoutMs);
}
@@ -395,10 +436,10 @@ export async function launchClawdChrome(
): Promise<RunningChrome> {
await ensurePortAvailable(resolved.cdpPort);
const exe = process.platform === "darwin" ? findChromeExecutableMac() : null;
const exe = resolveBrowserExecutable(resolved);
if (!exe) {
throw new Error(
"No supported browser found (Chrome Canary/Chromium/Chrome on macOS).",
"No supported browser found (Chrome/Chromium on macOS or Linux).",
);
}
@@ -430,6 +471,13 @@ export async function launchClawdChrome(
args.push("--headless=new");
args.push("--disable-gpu");
}
if (resolved.noSandbox) {
args.push("--no-sandbox");
args.push("--disable-setuid-sandbox");
}
if (process.platform === "linux") {
args.push("--disable-dev-shm-usage");
}
// Always open a blank tab to ensure a target exists.
args.push("about:blank");
@@ -484,11 +532,11 @@ export async function launchClawdChrome(
// Wait for CDP to come up.
const readyDeadline = Date.now() + 15_000;
while (Date.now() < readyDeadline) {
if (await isChromeReachable(resolved.cdpPort, 500)) break;
if (await isChromeReachable(resolved.cdpUrl, 500)) break;
await new Promise((r) => setTimeout(r, 200));
}
if (!(await isChromeReachable(resolved.cdpPort, 500))) {
if (!(await isChromeReachable(resolved.cdpUrl, 500))) {
try {
proc.kill("SIGKILL");
} catch {
@@ -527,7 +575,7 @@ export async function stopClawdChrome(
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (!proc.exitCode && proc.killed) break;
if (!(await isChromeReachable(running.cdpPort, 200))) return;
if (!(await isChromeReachable(cdpUrlForPort(running.cdpPort), 200))) return;
await new Promise((r) => setTimeout(r, 100));
}