From bd8a0a9f8f4663817965088770bee7b2bbea3c3a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 22:44:52 +0100 Subject: [PATCH] feat: add remote CDP browser support --- CHANGELOG.md | 1 + docs/browser.md | 23 ++++++-- docs/configuration.md | 6 +- src/browser/cdp.test.ts | 17 +++++- src/browser/cdp.ts | 33 ++++++++++- src/browser/chrome.test.ts | 12 +++- src/browser/chrome.ts | 78 ++++++++++++++++++++----- src/browser/client.test.ts | 3 + src/browser/client.ts | 3 + src/browser/config.test.ts | 12 ++++ src/browser/config.ts | 95 +++++++++++++++++++++++-------- src/browser/pw-ai.test.ts | 16 ++++-- src/browser/pw-session.ts | 23 +++++--- src/browser/pw-tools-core.test.ts | 29 ++++++---- src/browser/pw-tools-core.ts | 42 +++++++------- src/browser/routes/agent.ts | 42 +++++++------- src/browser/routes/basic.ts | 3 + src/browser/server-context.ts | 64 +++++++++++++++------ src/browser/server.test.ts | 45 ++++++++------- src/cli/browser-cli-manage.ts | 1 + src/config/config.ts | 9 +++ 21 files changed, 400 insertions(+), 157 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d4bb9ae9..d9eef2209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - Skills: add `things-mac` (Things 3 CLI) for read/search plus add/update via URL scheme. - Tests: add a Docker-based onboarding E2E harness. - Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs. +- Browser tools: add remote CDP URL support, Linux launcher options (`executablePath`, `noSandbox`), and surface `cdpUrl` in status. ### Fixes - Skills: switch imsg installer to brew tap formula. diff --git a/docs/browser.md b/docs/browser.md index 0c63f69e2..e0a8f422a 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -28,6 +28,13 @@ Add a dedicated settings section (preferably under **Skills** or its own "Browse - Interpreted as the base URL of the local/remote browser-control server. - If the URL host is not loopback, Clawdis must **not** attempt to launch a local browser; it only connects. +- **CDP URL** (`default: controlUrl + 1`) + - Base URL for Chrome DevTools Protocol (e.g. `http://127.0.0.1:18792`). + - Set this to a non-loopback host to attach the local control server to a remote + Chrome/Chromium CDP endpoint (SSH/Tailscale tunnel recommended). + - If the CDP URL host is non-loopback, clawd does **not** auto-launch a local browser. + - If you tunnel a remote CDP to `localhost`, set **Attach to existing only** to + avoid accidentally launching a local browser. - **Accent color** (`default: #FF4500`, "lobster-orange") - Used to theme the clawd browser profile (best-effort) and to tint UI indicators in Clawdis. @@ -36,6 +43,8 @@ Optional (advanced, can be hidden behind Debug initially): - **Use headless browser** (`default: off`) - **Attach to existing only** (`default: off`) — if on, never launch; only connect if already running. +- **Browser executable path** (override, optional) +- **No sandbox** (`default: off`) — adds `--no-sandbox` + `--disable-setuid-sandbox` ### Port convention @@ -68,7 +77,7 @@ internal detail. - The agent must be able to enumerate and target tabs deterministically (by stable `targetId` or equivalent), not "last tab". -## Browser selection (macOS) +## Browser selection (macOS + Linux) On startup (when enabled + local URL), Clawdis chooses the browser executable in this order: @@ -76,9 +85,14 @@ in this order: 2) **Chromium** (if installed) 3) **Google Chrome** (fallback) -Implementation detail: detection is by existence of the `.app` bundle under -`/Applications` (and optionally `~/Applications`), then using the resolved -executable path. +Linux: +- Looks for `google-chrome` / `chromium` in common system paths. +- Use **Browser executable path** to force a specific binary. + +Implementation detail: +- macOS: detection is by existence of the `.app` bundle under `/Applications` + (and optionally `~/Applications`), then using the resolved executable path. +- Linux: common `/usr/bin`/`/snap/bin` paths. Rationale: - Canary/Chromium are easy to visually distinguish from the user's daily driver. @@ -205,6 +219,7 @@ Notes: - The control server must bind to loopback only by default (`127.0.0.1`) unless the user explicitly configures a non-loopback URL. - Never reuse or copy the user's default Chrome profile. +- Remote CDP endpoints should be tunneled or protected; CDP is highly privileged. ## Non-goals (for the first cut) diff --git a/docs/configuration.md b/docs/configuration.md index de59536c3..c0dd79d59 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -462,6 +462,7 @@ Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd a Defaults: - enabled: `true` - control URL: `http://127.0.0.1:18791` (CDP uses `18792`) +- CDP URL: `http://127.0.0.1:18792` (control URL + 1) - profile color: `#FF4500` (lobster-orange) - Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`). @@ -470,10 +471,13 @@ Defaults: browser: { enabled: true, controlUrl: "http://127.0.0.1:18791", + // cdpUrl: "http://127.0.0.1:18792", // override for remote CDP color: "#FF4500", // Advanced: // headless: false, - // attachOnly: false, + // noSandbox: false, + // executablePath: "/usr/bin/chromium", + // attachOnly: false, // set true when tunneling a remote CDP to localhost } } ``` diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index da9a21ee9..450ce2b44 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -3,7 +3,12 @@ import { createServer } from "node:http"; import { afterEach, describe, expect, it } from "vitest"; import { WebSocketServer } from "ws"; import { rawDataToString } from "../infra/ws.js"; -import { createTargetViaCdp, evaluateJavaScript, snapshotAria } from "./cdp.js"; +import { + createTargetViaCdp, + evaluateJavaScript, + normalizeCdpWsUrl, + snapshotAria, +} from "./cdp.js"; describe("cdp", () => { let httpServer: ReturnType | null = null; @@ -64,7 +69,7 @@ describe("cdp", () => { const httpPort = (httpServer.address() as { port: number }).port; const created = await createTargetViaCdp({ - cdpPort: httpPort, + cdpUrl: `http://127.0.0.1:${httpPort}`, url: "https://example.com", }); @@ -159,4 +164,12 @@ describe("cdp", () => { expect(snap.nodes[1]?.backendDOMNodeId).toBe(42); expect(snap.nodes[1]?.depth).toBe(1); }); + + it("normalizes loopback websocket URLs for remote CDP hosts", () => { + const normalized = normalizeCdpWsUrl( + "ws://127.0.0.1:9222/devtools/browser/ABC", + "http://example.com:9222", + ); + expect(normalized).toBe("ws://example.com:9222/devtools/browser/ABC"); + }); }); diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 2d2c5523d..c58e37873 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -18,6 +18,31 @@ type CdpSendFn = ( params?: Record, ) => Promise; +function isLoopbackHost(host: string) { + const h = host.trim().toLowerCase(); + return ( + h === "localhost" || + h === "127.0.0.1" || + h === "0.0.0.0" || + h === "[::1]" || + h === "::1" || + h === "[::]" || + h === "::" + ); +} + +export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string { + const ws = new URL(wsUrl); + const cdp = new URL(cdpUrl); + if (isLoopbackHost(ws.hostname) && !isLoopbackHost(cdp.hostname)) { + ws.hostname = cdp.hostname; + const cdpPort = cdp.port || (cdp.protocol === "https:" ? "443" : "80"); + if (cdpPort) ws.port = cdpPort; + ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:"; + } + return ws.toString(); +} + function createCdpSender(ws: WebSocket) { let nextId = 1; const pending = new Map(); @@ -165,14 +190,16 @@ export async function captureScreenshot(opts: { } export async function createTargetViaCdp(opts: { - cdpPort: number; + cdpUrl: string; url: string; }): Promise<{ targetId: string }> { + const base = opts.cdpUrl.replace(/\/$/, ""); const version = await fetchJson<{ webSocketDebuggerUrl?: string }>( - `http://127.0.0.1:${opts.cdpPort}/json/version`, + `${base}/json/version`, 1500, ); - const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim(); + const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim(); + const wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : ""; if (!wsUrl) throw new Error("CDP /json/version missing webSocketDebuggerUrl"); return await withCdpSocket(wsUrl, async (send) => { diff --git a/src/browser/chrome.test.ts b/src/browser/chrome.test.ts index 1cc4aae2b..080f13bcd 100644 --- a/src/browser/chrome.test.ts +++ b/src/browser/chrome.test.ts @@ -163,7 +163,9 @@ describe("browser chrome helpers", () => { json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }), } as unknown as Response), ); - await expect(isChromeReachable(12345, 50)).resolves.toBe(true); + await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe( + true, + ); vi.stubGlobal( "fetch", @@ -172,10 +174,14 @@ describe("browser chrome helpers", () => { json: async () => ({}), } as unknown as Response), ); - await expect(isChromeReachable(12345, 50)).resolves.toBe(false); + await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe( + false, + ); vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom"))); - await expect(isChromeReachable(12345, 50)).resolves.toBe(false); + await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe( + false, + ); }); it("stopClawdChrome no-ops when process is already killed", async () => { diff --git a/src/browser/chrome.ts b/src/browser/chrome.ts index 8be2b7e80..801583978 100644 --- a/src/browser/chrome.ts +++ b/src/browser/chrome.ts @@ -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 = [ + { 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) { fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); } +function cdpUrlForPort(cdpPort: number) { + return `http://127.0.0.1:${cdpPort}`; +} + function setDeep(obj: Record, keys: string[], value: unknown) { let node: Record = 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 { - 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 { 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 { - 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 { - 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 { 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)); } diff --git a/src/browser/client.test.ts b/src/browser/client.test.ts index cb4c4bfdd..4b813fb46 100644 --- a/src/browser/client.test.ts +++ b/src/browser/client.test.ts @@ -169,10 +169,13 @@ describe("browser client", () => { running: true, pid: 1, cdpPort: 18792, + cdpUrl: "http://127.0.0.1:18792", chosenBrowser: "chrome", userDataDir: "/tmp", color: "#FF4500", headless: false, + noSandbox: false, + executablePath: null, attachOnly: false, }), } as unknown as Response; diff --git a/src/browser/client.ts b/src/browser/client.ts index 9ab851e5a..06f6bb2ea 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -10,10 +10,13 @@ export type BrowserStatus = { cdpHttp?: boolean; pid: number | null; cdpPort: number; + cdpUrl?: string; chosenBrowser: string | null; userDataDir: string | null; color: string; headless: boolean; + noSandbox?: boolean; + executablePath?: string | null; attachOnly: boolean; }; diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index a954f0b6c..60be5a9e2 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -10,6 +10,7 @@ describe("browser config", () => { expect(resolved.enabled).toBe(true); expect(resolved.controlPort).toBe(18791); expect(resolved.cdpPort).toBe(18792); + expect(resolved.cdpUrl).toBe("http://127.0.0.1:18792"); expect(resolved.controlHost).toBe("127.0.0.1"); expect(resolved.color).toBe("#FF4500"); expect(shouldStartLocalBrowserServer(resolved)).toBe(true); @@ -44,6 +45,17 @@ describe("browser config", () => { }); expect(resolved.controlPort).toBe(19000); expect(resolved.cdpPort).toBe(19001); + expect(resolved.cdpUrl).toBe("http://127.0.0.1:19001"); + }); + + it("supports explicit CDP URLs", () => { + const resolved = resolveBrowserConfig({ + controlUrl: "http://127.0.0.1:18791", + cdpUrl: "http://example.com:9222", + }); + expect(resolved.cdpPort).toBe(9222); + expect(resolved.cdpUrl).toBe("http://example.com:9222"); + expect(resolved.cdpIsLoopback).toBe(false); }); it("rejects unsupported protocols", () => { diff --git a/src/browser/config.ts b/src/browser/config.ts index f8182de45..6c3899eee 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -10,15 +10,28 @@ export type ResolvedBrowserConfig = { controlUrl: string; controlHost: string; controlPort: number; + cdpUrl: string; + cdpHost: string; cdpPort: number; + cdpIsLoopback: boolean; color: string; + executablePath?: string; headless: boolean; + noSandbox: boolean; attachOnly: boolean; }; function isLoopbackHost(host: string) { const h = host.trim().toLowerCase(); - return h === "localhost" || h === "127.0.0.1" || h === "[::1]" || h === "::1"; + return ( + h === "localhost" || + h === "127.0.0.1" || + h === "0.0.0.0" || + h === "[::1]" || + h === "::1" || + h === "[::]" || + h === "::" + ); } function normalizeHexColor(raw: string | undefined) { @@ -29,17 +42,12 @@ function normalizeHexColor(raw: string | undefined) { return normalized.toUpperCase(); } -export function resolveBrowserConfig( - cfg: BrowserConfig | undefined, -): ResolvedBrowserConfig { - const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED; - const controlUrl = ( - cfg?.controlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL - ).trim(); - const parsed = new URL(controlUrl); +function parseHttpUrl(raw: string, label: string) { + const trimmed = raw.trim(); + const parsed = new URL(trimmed); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error( - `browser.controlUrl must be http(s), got: ${parsed.protocol.replace(":", "")}`, + `${label} must be http(s), got: ${parsed.protocol.replace(":", "")}`, ); } @@ -51,32 +59,71 @@ export function resolveBrowserConfig( : 80; if (Number.isNaN(port) || port <= 0 || port > 65535) { - throw new Error(`browser.controlUrl has invalid port: ${parsed.port}`); + throw new Error(`${label} has invalid port: ${parsed.port}`); } - const cdpPort = port + 1; - if (cdpPort > 65535) { - throw new Error( - `browser.controlUrl port (${port}) is too high; cannot derive CDP port (${cdpPort})`, - ); - } - if (port === cdpPort) { - throw new Error( - `browser.controlUrl port (${port}) must not equal CDP port (${cdpPort})`, - ); + return { + parsed, + port, + normalized: parsed.toString().replace(/\/$/, ""), + }; +} + +export function resolveBrowserConfig( + cfg: BrowserConfig | undefined, +): ResolvedBrowserConfig { + const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED; + const controlInfo = parseHttpUrl( + cfg?.controlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL, + "browser.controlUrl", + ); + const controlPort = controlInfo.port; + + const rawCdpUrl = (cfg?.cdpUrl ?? "").trim(); + let cdpInfo: + | { + parsed: URL; + port: number; + normalized: string; + } + | undefined; + if (rawCdpUrl) { + cdpInfo = parseHttpUrl(rawCdpUrl, "browser.cdpUrl"); + } else { + const derivedPort = controlPort + 1; + if (derivedPort > 65535) { + throw new Error( + `browser.controlUrl port (${controlPort}) is too high; cannot derive CDP port (${derivedPort})`, + ); + } + const derived = new URL(controlInfo.normalized); + derived.port = String(derivedPort); + cdpInfo = { + parsed: derived, + port: derivedPort, + normalized: derived.toString().replace(/\/$/, ""), + }; } + const cdpPort = cdpInfo.port; const headless = cfg?.headless === true; + const noSandbox = cfg?.noSandbox === true; const attachOnly = cfg?.attachOnly === true; + const executablePath = cfg?.executablePath?.trim() || undefined; return { enabled, - controlUrl: parsed.toString().replace(/\/$/, ""), - controlHost: parsed.hostname, - controlPort: port, + controlUrl: controlInfo.normalized, + controlHost: controlInfo.parsed.hostname, + controlPort, + cdpUrl: cdpInfo.normalized, + cdpHost: cdpInfo.parsed.hostname, cdpPort, + cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname), color: normalizeHexColor(cfg?.color), + executablePath, headless, + noSandbox, attachOnly, }; } diff --git a/src/browser/pw-ai.test.ts b/src/browser/pw-ai.test.ts index 6c7beaf28..d991913ed 100644 --- a/src/browser/pw-ai.test.ts +++ b/src/browser/pw-ai.test.ts @@ -83,7 +83,7 @@ describe("pw-ai", () => { const mod = await importModule(); const res = await mod.snapshotAiViaPlaywright({ - cdpPort: 18792, + cdpUrl: "http://127.0.0.1:18792", targetId: "T2", }); @@ -102,7 +102,7 @@ describe("pw-ai", () => { const mod = await importModule(); await mod.clickViaPlaywright({ - cdpPort: 18792, + cdpUrl: "http://127.0.0.1:18792", targetId: "T1", ref: "76", }); @@ -121,7 +121,10 @@ describe("pw-ai", () => { const mod = await importModule(); await expect( - mod.snapshotAiViaPlaywright({ cdpPort: 18792, targetId: "T1" }), + mod.snapshotAiViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + }), ).rejects.toThrow(/_snapshotForAI/i); }); @@ -133,9 +136,12 @@ describe("pw-ai", () => { connect.mockResolvedValue(browser); const mod = await importModule(); - await mod.snapshotAiViaPlaywright({ cdpPort: 18792, targetId: "T1" }); + await mod.snapshotAiViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + }); await mod.clickViaPlaywright({ - cdpPort: 18792, + cdpUrl: "http://127.0.0.1:18792", targetId: "T1", ref: "1", }); diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index d9d7f3fb3..41c0e43d5 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -6,6 +6,7 @@ import type { } from "playwright-core"; import { chromium } from "playwright-core"; import { formatErrorMessage } from "../infra/errors.js"; +import { getChromeWebSocketUrl } from "./chrome.js"; export type BrowserConsoleMessage = { type: string; @@ -31,7 +32,7 @@ type TargetInfoResponse = { type ConnectedBrowser = { browser: Browser; - endpoint: string; + cdpUrl: string; }; type PageState = { @@ -49,8 +50,8 @@ const MAX_CONSOLE_MESSAGES = 500; let cached: ConnectedBrowser | null = null; let connecting: Promise | null = null; -function endpointForCdpPort(cdpPort: number) { - return `http://127.0.0.1:${cdpPort}`; +function normalizeCdpUrl(raw: string) { + return raw.replace(/\/$/, ""); } export function ensurePageState(page: Page): PageState { @@ -97,8 +98,9 @@ function observeBrowser(browser: Browser) { for (const context of browser.contexts()) observeContext(context); } -async function connectBrowser(endpoint: string): Promise { - if (cached?.endpoint === endpoint) return cached; +async function connectBrowser(cdpUrl: string): Promise { + const normalized = normalizeCdpUrl(cdpUrl); + if (cached?.cdpUrl === normalized) return cached; if (connecting) return await connecting; const connectWithRetry = async (): Promise => { @@ -106,8 +108,12 @@ async function connectBrowser(endpoint: string): Promise { for (let attempt = 0; attempt < 3; attempt += 1) { try { const timeout = 5000 + attempt * 2000; + const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch( + () => null, + ); + const endpoint = wsUrl ?? normalized; const browser = await chromium.connectOverCDP(endpoint, { timeout }); - const connected: ConnectedBrowser = { browser, endpoint }; + const connected: ConnectedBrowser = { browser, cdpUrl: normalized }; cached = connected; observeBrowser(browser); browser.on("disconnected", () => { @@ -168,11 +174,10 @@ async function findPageByTargetId( } export async function getPageForTargetId(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; }): Promise { - const endpoint = endpointForCdpPort(opts.cdpPort); - const { browser } = await connectBrowser(endpoint); + const { browser } = await connectBrowser(opts.cdpUrl); const pages = await getAllPages(browser); if (!pages.length) throw new Error("No pages available in the connected browser."); diff --git a/src/browser/pw-tools-core.test.ts b/src/browser/pw-tools-core.test.ts index 46a7d0268..0275a1fac 100644 --- a/src/browser/pw-tools-core.test.ts +++ b/src/browser/pw-tools-core.test.ts @@ -41,7 +41,7 @@ describe("pw-tools-core", () => { const mod = await importModule(); const res = await mod.takeScreenshotViaPlaywright({ - cdpPort: 18792, + cdpUrl: "http://127.0.0.1:18792", targetId: "T1", element: "#main", type: "png", @@ -65,7 +65,7 @@ describe("pw-tools-core", () => { const mod = await importModule(); const res = await mod.takeScreenshotViaPlaywright({ - cdpPort: 18792, + cdpUrl: "http://127.0.0.1:18792", targetId: "T1", ref: "76", type: "jpeg", @@ -89,7 +89,7 @@ describe("pw-tools-core", () => { await expect( mod.takeScreenshotViaPlaywright({ - cdpPort: 18792, + cdpUrl: "http://127.0.0.1:18792", targetId: "T1", element: "#x", fullPage: true, @@ -98,7 +98,7 @@ describe("pw-tools-core", () => { await expect( mod.takeScreenshotViaPlaywright({ - cdpPort: 18792, + cdpUrl: "http://127.0.0.1:18792", targetId: "T1", ref: "1", fullPage: true, @@ -118,7 +118,7 @@ describe("pw-tools-core", () => { const mod = await importModule(); await mod.armFileUploadViaPlaywright({ - cdpPort: 18792, + cdpUrl: "http://127.0.0.1:18792", targetId: "T1", paths: ["/tmp/a.txt"], }); @@ -142,7 +142,10 @@ describe("pw-tools-core", () => { }; const mod = await importModule(); - await mod.armFileUploadViaPlaywright({ cdpPort: 18792, paths: [] }); + await mod.armFileUploadViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + paths: [], + }); await Promise.resolve(); expect(fileChooser.setFiles).not.toHaveBeenCalled(); @@ -177,8 +180,14 @@ describe("pw-tools-core", () => { }; const mod = await importModule(); - await mod.armFileUploadViaPlaywright({ cdpPort: 18792, paths: ["/tmp/1"] }); - await mod.armFileUploadViaPlaywright({ cdpPort: 18792, paths: ["/tmp/2"] }); + await mod.armFileUploadViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + paths: ["/tmp/1"], + }); + await mod.armFileUploadViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + paths: ["/tmp/2"], + }); resolve1?.(fc1); resolve2?.(fc2); @@ -199,7 +208,7 @@ describe("pw-tools-core", () => { const mod = await importModule(); await mod.armDialogViaPlaywright({ - cdpPort: 18792, + cdpUrl: "http://127.0.0.1:18792", accept: true, promptText: "x", }); @@ -214,7 +223,7 @@ describe("pw-tools-core", () => { waitForEvent.mockClear(); await mod.armDialogViaPlaywright({ - cdpPort: 18792, + cdpUrl: "http://127.0.0.1:18792", accept: false, }); await Promise.resolve(); diff --git a/src/browser/pw-tools-core.ts b/src/browser/pw-tools-core.ts index d08c0805e..d9fc5e30e 100644 --- a/src/browser/pw-tools-core.ts +++ b/src/browser/pw-tools-core.ts @@ -17,12 +17,12 @@ function requireRef(value: unknown): string { } export async function snapshotAiViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; timeoutMs?: number; }): Promise<{ snapshot: string }> { const page = await getPageForTargetId({ - cdpPort: opts.cdpPort, + cdpUrl: opts.cdpUrl, targetId: opts.targetId, }); ensurePageState(page); @@ -45,7 +45,7 @@ export async function snapshotAiViaPlaywright(opts: { } export async function clickViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; ref: string; doubleClick?: boolean; @@ -54,7 +54,7 @@ export async function clickViaPlaywright(opts: { timeoutMs?: number; }): Promise { const page = await getPageForTargetId({ - cdpPort: opts.cdpPort, + cdpUrl: opts.cdpUrl, targetId: opts.targetId, }); ensurePageState(page); @@ -79,7 +79,7 @@ export async function clickViaPlaywright(opts: { } export async function hoverViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; ref: string; timeoutMs?: number; @@ -94,7 +94,7 @@ export async function hoverViaPlaywright(opts: { } export async function dragViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; startRef: string; endRef: string; @@ -111,7 +111,7 @@ export async function dragViaPlaywright(opts: { } export async function selectOptionViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; ref: string; values: string[]; @@ -128,7 +128,7 @@ export async function selectOptionViaPlaywright(opts: { } export async function pressKeyViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; key: string; delayMs?: number; @@ -143,7 +143,7 @@ export async function pressKeyViaPlaywright(opts: { } export async function typeViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; ref: string; text: string; @@ -168,7 +168,7 @@ export async function typeViaPlaywright(opts: { } export async function fillFormViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; fields: BrowserFormField[]; }): Promise { @@ -200,7 +200,7 @@ export async function fillFormViaPlaywright(opts: { } export async function evaluateViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; fn: string; ref?: string; @@ -257,7 +257,7 @@ export async function evaluateViaPlaywright(opts: { } export async function armFileUploadViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; paths?: string[]; timeoutMs?: number; @@ -304,7 +304,7 @@ export async function armFileUploadViaPlaywright(opts: { } export async function setInputFilesViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; inputRef?: string; element?: string; @@ -342,7 +342,7 @@ export async function setInputFilesViaPlaywright(opts: { } export async function armDialogViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; accept: boolean; promptText?: string; @@ -368,7 +368,7 @@ export async function armDialogViaPlaywright(opts: { } export async function navigateViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; url: string; timeoutMs?: number; @@ -384,7 +384,7 @@ export async function navigateViaPlaywright(opts: { } export async function waitForViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; timeMs?: number; text?: string; @@ -417,7 +417,7 @@ export async function waitForViaPlaywright(opts: { } export async function takeScreenshotViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; ref?: string; element?: string; @@ -449,7 +449,7 @@ export async function takeScreenshotViaPlaywright(opts: { } export async function resizeViewportViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; width: number; height: number; @@ -463,7 +463,7 @@ export async function resizeViewportViaPlaywright(opts: { } export async function closePageViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; }): Promise { const page = await getPageForTargetId(opts); @@ -472,7 +472,7 @@ export async function closePageViaPlaywright(opts: { } export async function pdfViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; }): Promise<{ buffer: Buffer }> { const page = await getPageForTargetId(opts); @@ -498,7 +498,7 @@ function consolePriority(level: string) { } export async function getConsoleMessagesViaPlaywright(opts: { - cdpPort: number; + cdpUrl: string; targetId?: string; level?: string; }): Promise { diff --git a/src/browser/routes/agent.ts b/src/browser/routes/agent.ts index 41d32ddc1..2719bfba6 100644 --- a/src/browser/routes/agent.ts +++ b/src/browser/routes/agent.ts @@ -109,7 +109,7 @@ export function registerBrowserAgentRoutes( const pw = await requirePwAi(res, "navigate"); if (!pw) return; const result = await pw.navigateViaPlaywright({ - cdpPort: ctx.state().cdpPort, + cdpUrl: ctx.state().resolved.cdpUrl, targetId: tab.targetId, url, }); @@ -145,7 +145,7 @@ export function registerBrowserAgentRoutes( try { const tab = await ctx.ensureTabAvailable(targetId); - const cdpPort = ctx.state().cdpPort; + const cdpUrl = ctx.state().resolved.cdpUrl; const pw = await requirePwAi(res, `act:${kind}`); if (!pw) return; @@ -180,7 +180,7 @@ export function registerBrowserAgentRoutes( ? (modifiersRaw as ClickModifier[]) : undefined; const clickRequest: Parameters[0] = { - cdpPort, + cdpUrl, targetId: tab.targetId, ref, doubleClick, @@ -199,7 +199,7 @@ export function registerBrowserAgentRoutes( const submit = toBoolean(body.submit) ?? false; const slowly = toBoolean(body.slowly) ?? false; const typeRequest: Parameters[0] = { - cdpPort, + cdpUrl, targetId: tab.targetId, ref, text, @@ -213,7 +213,7 @@ export function registerBrowserAgentRoutes( const key = toStringOrEmpty(body.key); if (!key) return jsonError(res, 400, "key is required"); await pw.pressKeyViaPlaywright({ - cdpPort, + cdpUrl, targetId: tab.targetId, key, }); @@ -222,7 +222,7 @@ export function registerBrowserAgentRoutes( case "hover": { const ref = toStringOrEmpty(body.ref); if (!ref) return jsonError(res, 400, "ref is required"); - await pw.hoverViaPlaywright({ cdpPort, targetId: tab.targetId, ref }); + await pw.hoverViaPlaywright({ cdpUrl, targetId: tab.targetId, ref }); return res.json({ ok: true, targetId: tab.targetId }); } case "drag": { @@ -231,7 +231,7 @@ export function registerBrowserAgentRoutes( if (!startRef || !endRef) return jsonError(res, 400, "startRef and endRef are required"); await pw.dragViaPlaywright({ - cdpPort, + cdpUrl, targetId: tab.targetId, startRef, endRef, @@ -244,7 +244,7 @@ export function registerBrowserAgentRoutes( if (!ref || !values?.length) return jsonError(res, 400, "ref and values are required"); await pw.selectOptionViaPlaywright({ - cdpPort, + cdpUrl, targetId: tab.targetId, ref, values, @@ -273,7 +273,7 @@ export function registerBrowserAgentRoutes( .filter((field): field is BrowserFormField => field !== null); if (!fields.length) return jsonError(res, 400, "fields are required"); await pw.fillFormViaPlaywright({ - cdpPort, + cdpUrl, targetId: tab.targetId, fields, }); @@ -285,7 +285,7 @@ export function registerBrowserAgentRoutes( if (!width || !height) return jsonError(res, 400, "width and height are required"); await pw.resizeViewportViaPlaywright({ - cdpPort, + cdpUrl, targetId: tab.targetId, width, height, @@ -297,7 +297,7 @@ export function registerBrowserAgentRoutes( const text = toStringOrEmpty(body.text) || undefined; const textGone = toStringOrEmpty(body.textGone) || undefined; await pw.waitForViaPlaywright({ - cdpPort, + cdpUrl, targetId: tab.targetId, timeMs, text, @@ -310,7 +310,7 @@ export function registerBrowserAgentRoutes( if (!fn) return jsonError(res, 400, "fn is required"); const ref = toStringOrEmpty(body.ref) || undefined; const result = await pw.evaluateViaPlaywright({ - cdpPort, + cdpUrl, targetId: tab.targetId, fn, ref, @@ -323,7 +323,7 @@ export function registerBrowserAgentRoutes( }); } case "close": { - await pw.closePageViaPlaywright({ cdpPort, targetId: tab.targetId }); + await pw.closePageViaPlaywright({ cdpUrl, targetId: tab.targetId }); return res.json({ ok: true, targetId: tab.targetId }); } default: { @@ -357,7 +357,7 @@ export function registerBrowserAgentRoutes( ); } await pw.setInputFilesViaPlaywright({ - cdpPort: ctx.state().cdpPort, + cdpUrl: ctx.state().resolved.cdpUrl, targetId: tab.targetId, inputRef, element, @@ -365,14 +365,14 @@ export function registerBrowserAgentRoutes( }); } else { await pw.armFileUploadViaPlaywright({ - cdpPort: ctx.state().cdpPort, + cdpUrl: ctx.state().resolved.cdpUrl, targetId: tab.targetId, paths, timeoutMs: timeoutMs ?? undefined, }); if (ref) { await pw.clickViaPlaywright({ - cdpPort: ctx.state().cdpPort, + cdpUrl: ctx.state().resolved.cdpUrl, targetId: tab.targetId, ref, }); @@ -396,7 +396,7 @@ export function registerBrowserAgentRoutes( const pw = await requirePwAi(res, "dialog hook"); if (!pw) return; await pw.armDialogViaPlaywright({ - cdpPort: ctx.state().cdpPort, + cdpUrl: ctx.state().resolved.cdpUrl, targetId: tab.targetId, accept, promptText, @@ -418,7 +418,7 @@ export function registerBrowserAgentRoutes( const pw = await requirePwAi(res, "console messages"); if (!pw) return; const messages = await pw.getConsoleMessagesViaPlaywright({ - cdpPort: ctx.state().cdpPort, + cdpUrl: ctx.state().resolved.cdpUrl, targetId: tab.targetId, level: level.trim() || undefined, }); @@ -436,7 +436,7 @@ export function registerBrowserAgentRoutes( const pw = await requirePwAi(res, "pdf"); if (!pw) return; const pdf = await pw.pdfViaPlaywright({ - cdpPort: ctx.state().cdpPort, + cdpUrl: ctx.state().resolved.cdpUrl, targetId: tab.targetId, }); await ensureMediaDir(); @@ -480,7 +480,7 @@ export function registerBrowserAgentRoutes( const pw = await requirePwAi(res, "element/ref screenshot"); if (!pw) return; const snap = await pw.takeScreenshotViaPlaywright({ - cdpPort: ctx.state().cdpPort, + cdpUrl: ctx.state().resolved.cdpUrl, targetId: tab.targetId, ref, element, @@ -539,7 +539,7 @@ export function registerBrowserAgentRoutes( const pw = await requirePwAi(res, "ai snapshot"); if (!pw) return; const snap = await pw.snapshotAiViaPlaywright({ - cdpPort: ctx.state().cdpPort, + cdpUrl: ctx.state().resolved.cdpUrl, targetId: tab.targetId, }); return res.json({ diff --git a/src/browser/routes/basic.ts b/src/browser/routes/basic.ts index dbf279cd3..184d5d753 100644 --- a/src/browser/routes/basic.ts +++ b/src/browser/routes/basic.ts @@ -27,10 +27,13 @@ export function registerBrowserBasicRoutes( cdpHttp, pid: current.running?.pid ?? null, cdpPort: current.cdpPort, + cdpUrl: current.resolved.cdpUrl, chosenBrowser: current.running?.exe.kind ?? null, userDataDir: current.running?.userDataDir ?? null, color: current.resolved.color, headless: current.resolved.headless, + noSandbox: current.resolved.noSandbox, + executablePath: current.resolved.executablePath ?? null, attachOnly: current.resolved.attachOnly, }); }); diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index 26a0f41c3..0519abd6b 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { runExec } from "../process/exec.js"; -import { createTargetViaCdp } from "./cdp.js"; +import { createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js"; import { isChromeCdpReady, isChromeReachable, @@ -98,6 +98,15 @@ export function createBrowserRouteContext( const listTabs = async (): Promise => { const current = state(); + const base = current.resolved.cdpUrl; + const normalizeWsUrl = (raw?: string) => { + if (!raw) return undefined; + try { + return normalizeCdpWsUrl(raw, base); + } catch { + return raw; + } + }; const raw = await fetchJson< Array<{ id?: string; @@ -106,13 +115,13 @@ export function createBrowserRouteContext( webSocketDebuggerUrl?: string; type?: string; }> - >(`http://127.0.0.1:${current.cdpPort}/json/list`); + >(`${base.replace(/\/$/, "")}/json/list`); return raw .map((t) => ({ targetId: t.id ?? "", title: t.title ?? "", url: t.url ?? "", - wsUrl: t.webSocketDebuggerUrl, + wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl), type: t.type, })) .filter((t) => Boolean(t.targetId)); @@ -121,7 +130,7 @@ export function createBrowserRouteContext( const openTab = async (url: string): Promise => { const current = state(); const createdViaCdp = await createTargetViaCdp({ - cdpPort: current.cdpPort, + cdpUrl: current.resolved.cdpUrl, url, }) .then((r) => r.targetId) @@ -148,7 +157,16 @@ export function createBrowserRouteContext( type?: string; }; - const endpoint = `http://127.0.0.1:${current.cdpPort}/json/new?${encoded}`; + const base = current.resolved.cdpUrl.replace(/\/$/, ""); + const normalizeWsUrl = (raw?: string) => { + if (!raw) return undefined; + try { + return normalizeCdpWsUrl(raw, base); + } catch { + return raw; + } + }; + const endpoint = `${base}/json/new?${encoded}`; const created = await fetchJson(endpoint, 1500, { method: "PUT", }).catch(async (err) => { @@ -163,7 +181,7 @@ export function createBrowserRouteContext( targetId: created.id, title: created.title ?? "", url: created.url ?? url, - wsUrl: created.webSocketDebuggerUrl, + wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl), type: created.type, }; }; @@ -171,12 +189,16 @@ export function createBrowserRouteContext( const isReachable = async (timeoutMs = 300) => { const current = state(); const wsTimeout = Math.max(200, Math.min(2000, timeoutMs * 2)); - return await isChromeCdpReady(current.cdpPort, timeoutMs, wsTimeout); + return await isChromeCdpReady( + current.resolved.cdpUrl, + timeoutMs, + wsTimeout, + ); }; const isHttpReachable = async (timeoutMs = 300) => { const current = state(); - return await isChromeReachable(current.cdpPort, timeoutMs); + return await isChromeReachable(current.resolved.cdpUrl, timeoutMs); }; const attachRunning = (running: RunningChrome) => { @@ -191,11 +213,14 @@ export function createBrowserRouteContext( const ensureBrowserAvailable = async (): Promise => { const current = state(); + const remoteCdp = !current.resolved.cdpIsLoopback; const httpReachable = await isHttpReachable(); if (!httpReachable) { - if (current.resolved.attachOnly) { + if (current.resolved.attachOnly || remoteCdp) { throw new Error( - "Browser attachOnly is enabled and no browser is running.", + remoteCdp + ? "Remote CDP is not reachable. Check browser.cdpUrl." + : "Browser attachOnly is enabled and no browser is running.", ); } const launched = await launchClawdChrome(current.resolved); @@ -204,9 +229,11 @@ export function createBrowserRouteContext( if (await isReachable()) return; - if (current.resolved.attachOnly) { + if (current.resolved.attachOnly || remoteCdp) { throw new Error( - "Browser attachOnly is enabled and CDP websocket is not reachable.", + remoteCdp + ? "Remote CDP websocket is not reachable. Check browser.cdpUrl." + : "Browser attachOnly is enabled and CDP websocket is not reachable.", ); } @@ -255,6 +282,7 @@ export function createBrowserRouteContext( const focusTab = async (targetId: string): Promise => { const current = state(); + const base = current.resolved.cdpUrl.replace(/\/$/, ""); const tabs = await listTabs(); const resolved = resolveTargetIdFromTabs(targetId, tabs); if (!resolved.ok) { @@ -263,13 +291,12 @@ export function createBrowserRouteContext( } throw new Error("tab not found"); } - await fetchOk( - `http://127.0.0.1:${current.cdpPort}/json/activate/${resolved.targetId}`, - ); + await fetchOk(`${base}/json/activate/${resolved.targetId}`); }; const closeTab = async (targetId: string): Promise => { const current = state(); + const base = current.resolved.cdpUrl.replace(/\/$/, ""); const tabs = await listTabs(); const resolved = resolveTargetIdFromTabs(targetId, tabs); if (!resolved.ok) { @@ -278,9 +305,7 @@ export function createBrowserRouteContext( } throw new Error("tab not found"); } - await fetchOk( - `http://127.0.0.1:${current.cdpPort}/json/close/${resolved.targetId}`, - ); + await fetchOk(`${base}/json/close/${resolved.targetId}`); }; const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => { @@ -293,6 +318,9 @@ export function createBrowserRouteContext( const resetProfile = async () => { const current = state(); + if (!current.resolved.cdpIsLoopback) { + throw new Error("reset-profile is only supported for local browsers."); + } const userDataDir = resolveClawdUserDataDir(); const httpReachable = await isHttpReachable(300); diff --git a/src/browser/server.test.ts b/src/browser/server.test.ts index d7206728a..723bda985 100644 --- a/src/browser/server.test.ts +++ b/src/browser/server.test.ts @@ -4,6 +4,7 @@ import { fetch as realFetch } from "undici"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; let testPort = 0; +let cdpBaseUrl = ""; let reachable = false; let cfgAttachOnly = false; let createTargetId: string | null = null; @@ -99,6 +100,7 @@ vi.mock("./chrome.js", () => ({ vi.mock("./cdp.js", () => ({ createTargetViaCdp: cdpMocks.createTargetViaCdp, + normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl), snapshotAria: cdpMocks.snapshotAria, })); @@ -159,6 +161,7 @@ describe("browser control server", () => { for (const fn of Object.values(cdpMocks)) fn.mockClear(); testPort = await getFreePort(); + cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`; // Minimal CDP JSON endpoints used by the server. let putNewCalls = 0; @@ -293,7 +296,7 @@ describe("browser control server", () => { expect(snapAi.ok).toBe(true); expect(snapAi.format).toBe("ai"); expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", }); @@ -305,7 +308,7 @@ describe("browser control server", () => { expect(nav.ok).toBe(true); expect(typeof nav.targetId).toBe("string"); expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", url: "https://example.com", }); @@ -322,7 +325,7 @@ describe("browser control server", () => { }).then((r) => r.json())) as { ok: boolean }; expect(click.ok).toBe(true); expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(1, { - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", ref: "1", doubleClick: false, @@ -351,7 +354,7 @@ describe("browser control server", () => { }).then((r) => r.json())) as { ok: boolean }; expect(type.ok).toBe(true); expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(1, { - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", ref: "1", text: "", @@ -366,7 +369,7 @@ describe("browser control server", () => { }).then((r) => r.json())) as { ok: boolean }; expect(press.ok).toBe(true); expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", key: "Enter", }); @@ -378,7 +381,7 @@ describe("browser control server", () => { }).then((r) => r.json())) as { ok: boolean }; expect(hover.ok).toBe(true); expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", ref: "2", }); @@ -390,7 +393,7 @@ describe("browser control server", () => { }).then((r) => r.json())) as { ok: boolean }; expect(drag.ok).toBe(true); expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", startRef: "3", endRef: "4", @@ -403,7 +406,7 @@ describe("browser control server", () => { }).then((r) => r.json())) as { ok: boolean }; expect(select.ok).toBe(true); expect(pwMocks.selectOptionViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", ref: "5", values: ["a", "b"], @@ -419,7 +422,7 @@ describe("browser control server", () => { }).then((r) => r.json())) as { ok: boolean }; expect(fill.ok).toBe(true); expect(pwMocks.fillFormViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", fields: [{ ref: "6", type: "textbox", value: "hello" }], }); @@ -431,7 +434,7 @@ describe("browser control server", () => { }).then((r) => r.json())) as { ok: boolean }; expect(resize.ok).toBe(true); expect(pwMocks.resizeViewportViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", width: 800, height: 600, @@ -444,7 +447,7 @@ describe("browser control server", () => { }).then((r) => r.json())) as { ok: boolean }; expect(wait.ok).toBe(true); expect(pwMocks.waitForViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", timeMs: 5, text: undefined, @@ -459,7 +462,7 @@ describe("browser control server", () => { expect(evalRes.ok).toBe(true); expect(evalRes.result).toBe("ok"); expect(pwMocks.evaluateViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", fn: "() => 1", ref: undefined, @@ -472,7 +475,7 @@ describe("browser control server", () => { }).then((r) => r.json()); expect(upload).toMatchObject({ ok: true }); expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", paths: ["/tmp/a.txt"], timeoutMs: 1234, @@ -485,13 +488,13 @@ describe("browser control server", () => { }).then((r) => r.json()); expect(uploadWithRef).toMatchObject({ ok: true }); expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", paths: ["/tmp/b.txt"], timeoutMs: undefined, }); expect(pwMocks.clickViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", ref: "e12", }); @@ -503,7 +506,7 @@ describe("browser control server", () => { }).then((r) => r.json()); expect(uploadWithInputRef).toMatchObject({ ok: true }); expect(pwMocks.setInputFilesViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", inputRef: "e99", element: undefined, @@ -520,7 +523,7 @@ describe("browser control server", () => { }).then((r) => r.json()); expect(uploadWithElement).toMatchObject({ ok: true }); expect(pwMocks.setInputFilesViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", inputRef: undefined, element: "input[type=file]", @@ -534,7 +537,7 @@ describe("browser control server", () => { }).then((r) => r.json()); expect(dialog).toMatchObject({ ok: true }); expect(pwMocks.armDialogViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", accept: true, promptText: undefined, @@ -547,7 +550,7 @@ describe("browser control server", () => { expect(consoleRes.ok).toBe(true); expect(Array.isArray(consoleRes.messages)).toBe(true); expect(pwMocks.getConsoleMessagesViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", level: "error", }); @@ -568,7 +571,7 @@ describe("browser control server", () => { expect(shot.ok).toBe(true); expect(typeof shot.path).toBe("string"); expect(pwMocks.takeScreenshotViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", ref: undefined, element: "body", @@ -583,7 +586,7 @@ describe("browser control server", () => { }).then((r) => r.json())) as { ok: boolean }; expect(close.ok).toBe(true); expect(pwMocks.closePageViaPlaywright).toHaveBeenCalledWith({ - cdpPort: testPort + 1, + cdpUrl: cdpBaseUrl, targetId: "abcd1234", }); diff --git a/src/cli/browser-cli-manage.ts b/src/cli/browser-cli-manage.ts index 53b4b795b..156e20da7 100644 --- a/src/cli/browser-cli-manage.ts +++ b/src/cli/browser-cli-manage.ts @@ -38,6 +38,7 @@ export function registerBrowserManageCommands( `running: ${status.running}`, `controlUrl: ${status.controlUrl}`, `cdpPort: ${status.cdpPort}`, + `cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`, `browser: ${status.chosenBrowser ?? "unknown"}`, `profileColor: ${status.color}`, ].join("\n"), diff --git a/src/config/config.ts b/src/config/config.ts index 4d3c9ccc0..a1beabbac 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -62,10 +62,16 @@ export type BrowserConfig = { enabled?: boolean; /** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */ controlUrl?: string; + /** Base URL of the CDP endpoint. Default: controlUrl with port + 1. */ + cdpUrl?: string; /** Accent color for the clawd browser profile (hex). Default: #FF4500 */ color?: string; + /** Override the browser executable path (macOS/Linux). */ + executablePath?: string; /** Start Chrome headless (best-effort). Default: false */ headless?: boolean; + /** Pass --no-sandbox to Chrome (Linux containers). Default: false */ + noSandbox?: boolean; /** If true: never launch; only attach to an existing browser. Default: false */ attachOnly?: boolean; }; @@ -759,8 +765,11 @@ const ClawdisSchema = z.object({ .object({ enabled: z.boolean().optional(), controlUrl: z.string().optional(), + cdpUrl: z.string().optional(), color: z.string().optional(), + executablePath: z.string().optional(), headless: z.boolean().optional(), + noSandbox: z.boolean().optional(), attachOnly: z.boolean().optional(), }) .optional(),