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

@@ -26,6 +26,7 @@
- Skills: add `things-mac` (Things 3 CLI) for read/search plus add/update via URL scheme. - 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: add a Docker-based onboarding E2E harness.
- Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs. - 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 ### Fixes
- Skills: switch imsg installer to brew tap formula. - Skills: switch imsg installer to brew tap formula.

View File

@@ -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. - 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 - If the URL host is not loopback, Clawdis must **not** attempt to launch a local
browser; it only connects. 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") - **Accent color** (`default: #FF4500`, "lobster-orange")
- Used to theme the clawd browser profile (best-effort) and to tint UI indicators - Used to theme the clawd browser profile (best-effort) and to tint UI indicators
in Clawdis. in Clawdis.
@@ -36,6 +43,8 @@ Optional (advanced, can be hidden behind Debug initially):
- **Use headless browser** (`default: off`) - **Use headless browser** (`default: off`)
- **Attach to existing only** (`default: off`) — if on, never launch; only connect if - **Attach to existing only** (`default: off`) — if on, never launch; only connect if
already running. already running.
- **Browser executable path** (override, optional)
- **No sandbox** (`default: off`) — adds `--no-sandbox` + `--disable-setuid-sandbox`
### Port convention ### Port convention
@@ -68,7 +77,7 @@ internal detail.
- The agent must be able to enumerate and target tabs deterministically (by - The agent must be able to enumerate and target tabs deterministically (by
stable `targetId` or equivalent), not "last tab". 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 On startup (when enabled + local URL), Clawdis chooses the browser executable
in this order: in this order:
@@ -76,9 +85,14 @@ in this order:
2) **Chromium** (if installed) 2) **Chromium** (if installed)
3) **Google Chrome** (fallback) 3) **Google Chrome** (fallback)
Implementation detail: detection is by existence of the `.app` bundle under Linux:
`/Applications` (and optionally `~/Applications`), then using the resolved - Looks for `google-chrome` / `chromium` in common system paths.
executable path. - 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: Rationale:
- Canary/Chromium are easy to visually distinguish from the user's daily driver. - 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 - The control server must bind to loopback only by default (`127.0.0.1`) unless the
user explicitly configures a non-loopback URL. user explicitly configures a non-loopback URL.
- Never reuse or copy the user's default Chrome profile. - 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) ## Non-goals (for the first cut)

View File

@@ -462,6 +462,7 @@ Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd a
Defaults: Defaults:
- enabled: `true` - enabled: `true`
- control URL: `http://127.0.0.1:18791` (CDP uses `18792`) - 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) - profile color: `#FF4500` (lobster-orange)
- Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`). - Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`).
@@ -470,10 +471,13 @@ Defaults:
browser: { browser: {
enabled: true, enabled: true,
controlUrl: "http://127.0.0.1:18791", controlUrl: "http://127.0.0.1:18791",
// cdpUrl: "http://127.0.0.1:18792", // override for remote CDP
color: "#FF4500", color: "#FF4500",
// Advanced: // Advanced:
// headless: false, // headless: false,
// attachOnly: false, // noSandbox: false,
// executablePath: "/usr/bin/chromium",
// attachOnly: false, // set true when tunneling a remote CDP to localhost
} }
} }
``` ```

View File

@@ -3,7 +3,12 @@ import { createServer } from "node:http";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { rawDataToString } from "../infra/ws.js"; import { rawDataToString } from "../infra/ws.js";
import { createTargetViaCdp, evaluateJavaScript, snapshotAria } from "./cdp.js"; import {
createTargetViaCdp,
evaluateJavaScript,
normalizeCdpWsUrl,
snapshotAria,
} from "./cdp.js";
describe("cdp", () => { describe("cdp", () => {
let httpServer: ReturnType<typeof createServer> | null = null; let httpServer: ReturnType<typeof createServer> | null = null;
@@ -64,7 +69,7 @@ describe("cdp", () => {
const httpPort = (httpServer.address() as { port: number }).port; const httpPort = (httpServer.address() as { port: number }).port;
const created = await createTargetViaCdp({ const created = await createTargetViaCdp({
cdpPort: httpPort, cdpUrl: `http://127.0.0.1:${httpPort}`,
url: "https://example.com", url: "https://example.com",
}); });
@@ -159,4 +164,12 @@ describe("cdp", () => {
expect(snap.nodes[1]?.backendDOMNodeId).toBe(42); expect(snap.nodes[1]?.backendDOMNodeId).toBe(42);
expect(snap.nodes[1]?.depth).toBe(1); 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");
});
}); });

View File

@@ -18,6 +18,31 @@ type CdpSendFn = (
params?: Record<string, unknown>, params?: Record<string, unknown>,
) => Promise<unknown>; ) => Promise<unknown>;
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) { function createCdpSender(ws: WebSocket) {
let nextId = 1; let nextId = 1;
const pending = new Map<number, Pending>(); const pending = new Map<number, Pending>();
@@ -165,14 +190,16 @@ export async function captureScreenshot(opts: {
} }
export async function createTargetViaCdp(opts: { export async function createTargetViaCdp(opts: {
cdpPort: number; cdpUrl: string;
url: string; url: string;
}): Promise<{ targetId: string }> { }): Promise<{ targetId: string }> {
const base = opts.cdpUrl.replace(/\/$/, "");
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>( const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
`http://127.0.0.1:${opts.cdpPort}/json/version`, `${base}/json/version`,
1500, 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"); if (!wsUrl) throw new Error("CDP /json/version missing webSocketDebuggerUrl");
return await withCdpSocket(wsUrl, async (send) => { return await withCdpSocket(wsUrl, async (send) => {

View File

@@ -163,7 +163,9 @@ describe("browser chrome helpers", () => {
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }), json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
} as unknown as Response), } 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( vi.stubGlobal(
"fetch", "fetch",
@@ -172,10 +174,14 @@ describe("browser chrome helpers", () => {
json: async () => ({}), json: async () => ({}),
} as unknown as Response), } 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"))); 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 () => { it("stopClawdChrome no-ops when process is already killed", async () => {

View File

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

View File

@@ -169,10 +169,13 @@ describe("browser client", () => {
running: true, running: true,
pid: 1, pid: 1,
cdpPort: 18792, cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
chosenBrowser: "chrome", chosenBrowser: "chrome",
userDataDir: "/tmp", userDataDir: "/tmp",
color: "#FF4500", color: "#FF4500",
headless: false, headless: false,
noSandbox: false,
executablePath: null,
attachOnly: false, attachOnly: false,
}), }),
} as unknown as Response; } as unknown as Response;

View File

@@ -10,10 +10,13 @@ export type BrowserStatus = {
cdpHttp?: boolean; cdpHttp?: boolean;
pid: number | null; pid: number | null;
cdpPort: number; cdpPort: number;
cdpUrl?: string;
chosenBrowser: string | null; chosenBrowser: string | null;
userDataDir: string | null; userDataDir: string | null;
color: string; color: string;
headless: boolean; headless: boolean;
noSandbox?: boolean;
executablePath?: string | null;
attachOnly: boolean; attachOnly: boolean;
}; };

View File

@@ -10,6 +10,7 @@ describe("browser config", () => {
expect(resolved.enabled).toBe(true); expect(resolved.enabled).toBe(true);
expect(resolved.controlPort).toBe(18791); expect(resolved.controlPort).toBe(18791);
expect(resolved.cdpPort).toBe(18792); 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.controlHost).toBe("127.0.0.1");
expect(resolved.color).toBe("#FF4500"); expect(resolved.color).toBe("#FF4500");
expect(shouldStartLocalBrowserServer(resolved)).toBe(true); expect(shouldStartLocalBrowserServer(resolved)).toBe(true);
@@ -44,6 +45,17 @@ describe("browser config", () => {
}); });
expect(resolved.controlPort).toBe(19000); expect(resolved.controlPort).toBe(19000);
expect(resolved.cdpPort).toBe(19001); 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", () => { it("rejects unsupported protocols", () => {

View File

@@ -10,15 +10,28 @@ export type ResolvedBrowserConfig = {
controlUrl: string; controlUrl: string;
controlHost: string; controlHost: string;
controlPort: number; controlPort: number;
cdpUrl: string;
cdpHost: string;
cdpPort: number; cdpPort: number;
cdpIsLoopback: boolean;
color: string; color: string;
executablePath?: string;
headless: boolean; headless: boolean;
noSandbox: boolean;
attachOnly: boolean; attachOnly: boolean;
}; };
function isLoopbackHost(host: string) { function isLoopbackHost(host: string) {
const h = host.trim().toLowerCase(); 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) { function normalizeHexColor(raw: string | undefined) {
@@ -29,17 +42,12 @@ function normalizeHexColor(raw: string | undefined) {
return normalized.toUpperCase(); return normalized.toUpperCase();
} }
export function resolveBrowserConfig( function parseHttpUrl(raw: string, label: string) {
cfg: BrowserConfig | undefined, const trimmed = raw.trim();
): ResolvedBrowserConfig { const parsed = new URL(trimmed);
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
const controlUrl = (
cfg?.controlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL
).trim();
const parsed = new URL(controlUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error( 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; : 80;
if (Number.isNaN(port) || port <= 0 || port > 65535) { 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; return {
if (cdpPort > 65535) { parsed,
throw new Error( port,
`browser.controlUrl port (${port}) is too high; cannot derive CDP port (${cdpPort})`, normalized: parsed.toString().replace(/\/$/, ""),
); };
} }
if (port === cdpPort) {
throw new Error( export function resolveBrowserConfig(
`browser.controlUrl port (${port}) must not equal CDP port (${cdpPort})`, 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 headless = cfg?.headless === true;
const noSandbox = cfg?.noSandbox === true;
const attachOnly = cfg?.attachOnly === true; const attachOnly = cfg?.attachOnly === true;
const executablePath = cfg?.executablePath?.trim() || undefined;
return { return {
enabled, enabled,
controlUrl: parsed.toString().replace(/\/$/, ""), controlUrl: controlInfo.normalized,
controlHost: parsed.hostname, controlHost: controlInfo.parsed.hostname,
controlPort: port, controlPort,
cdpUrl: cdpInfo.normalized,
cdpHost: cdpInfo.parsed.hostname,
cdpPort, cdpPort,
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
color: normalizeHexColor(cfg?.color), color: normalizeHexColor(cfg?.color),
executablePath,
headless, headless,
noSandbox,
attachOnly, attachOnly,
}; };
} }

View File

@@ -83,7 +83,7 @@ describe("pw-ai", () => {
const mod = await importModule(); const mod = await importModule();
const res = await mod.snapshotAiViaPlaywright({ const res = await mod.snapshotAiViaPlaywright({
cdpPort: 18792, cdpUrl: "http://127.0.0.1:18792",
targetId: "T2", targetId: "T2",
}); });
@@ -102,7 +102,7 @@ describe("pw-ai", () => {
const mod = await importModule(); const mod = await importModule();
await mod.clickViaPlaywright({ await mod.clickViaPlaywright({
cdpPort: 18792, cdpUrl: "http://127.0.0.1:18792",
targetId: "T1", targetId: "T1",
ref: "76", ref: "76",
}); });
@@ -121,7 +121,10 @@ describe("pw-ai", () => {
const mod = await importModule(); const mod = await importModule();
await expect( await expect(
mod.snapshotAiViaPlaywright({ cdpPort: 18792, targetId: "T1" }), mod.snapshotAiViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
}),
).rejects.toThrow(/_snapshotForAI/i); ).rejects.toThrow(/_snapshotForAI/i);
}); });
@@ -133,9 +136,12 @@ describe("pw-ai", () => {
connect.mockResolvedValue(browser); connect.mockResolvedValue(browser);
const mod = await importModule(); 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({ await mod.clickViaPlaywright({
cdpPort: 18792, cdpUrl: "http://127.0.0.1:18792",
targetId: "T1", targetId: "T1",
ref: "1", ref: "1",
}); });

View File

@@ -6,6 +6,7 @@ import type {
} from "playwright-core"; } from "playwright-core";
import { chromium } from "playwright-core"; import { chromium } from "playwright-core";
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
import { getChromeWebSocketUrl } from "./chrome.js";
export type BrowserConsoleMessage = { export type BrowserConsoleMessage = {
type: string; type: string;
@@ -31,7 +32,7 @@ type TargetInfoResponse = {
type ConnectedBrowser = { type ConnectedBrowser = {
browser: Browser; browser: Browser;
endpoint: string; cdpUrl: string;
}; };
type PageState = { type PageState = {
@@ -49,8 +50,8 @@ const MAX_CONSOLE_MESSAGES = 500;
let cached: ConnectedBrowser | null = null; let cached: ConnectedBrowser | null = null;
let connecting: Promise<ConnectedBrowser> | null = null; let connecting: Promise<ConnectedBrowser> | null = null;
function endpointForCdpPort(cdpPort: number) { function normalizeCdpUrl(raw: string) {
return `http://127.0.0.1:${cdpPort}`; return raw.replace(/\/$/, "");
} }
export function ensurePageState(page: Page): PageState { export function ensurePageState(page: Page): PageState {
@@ -97,8 +98,9 @@ function observeBrowser(browser: Browser) {
for (const context of browser.contexts()) observeContext(context); for (const context of browser.contexts()) observeContext(context);
} }
async function connectBrowser(endpoint: string): Promise<ConnectedBrowser> { async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
if (cached?.endpoint === endpoint) return cached; const normalized = normalizeCdpUrl(cdpUrl);
if (cached?.cdpUrl === normalized) return cached;
if (connecting) return await connecting; if (connecting) return await connecting;
const connectWithRetry = async (): Promise<ConnectedBrowser> => { const connectWithRetry = async (): Promise<ConnectedBrowser> => {
@@ -106,8 +108,12 @@ async function connectBrowser(endpoint: string): Promise<ConnectedBrowser> {
for (let attempt = 0; attempt < 3; attempt += 1) { for (let attempt = 0; attempt < 3; attempt += 1) {
try { try {
const timeout = 5000 + attempt * 2000; 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 browser = await chromium.connectOverCDP(endpoint, { timeout });
const connected: ConnectedBrowser = { browser, endpoint }; const connected: ConnectedBrowser = { browser, cdpUrl: normalized };
cached = connected; cached = connected;
observeBrowser(browser); observeBrowser(browser);
browser.on("disconnected", () => { browser.on("disconnected", () => {
@@ -168,11 +174,10 @@ async function findPageByTargetId(
} }
export async function getPageForTargetId(opts: { export async function getPageForTargetId(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
}): Promise<Page> { }): Promise<Page> {
const endpoint = endpointForCdpPort(opts.cdpPort); const { browser } = await connectBrowser(opts.cdpUrl);
const { browser } = await connectBrowser(endpoint);
const pages = await getAllPages(browser); const pages = await getAllPages(browser);
if (!pages.length) if (!pages.length)
throw new Error("No pages available in the connected browser."); throw new Error("No pages available in the connected browser.");

View File

@@ -41,7 +41,7 @@ describe("pw-tools-core", () => {
const mod = await importModule(); const mod = await importModule();
const res = await mod.takeScreenshotViaPlaywright({ const res = await mod.takeScreenshotViaPlaywright({
cdpPort: 18792, cdpUrl: "http://127.0.0.1:18792",
targetId: "T1", targetId: "T1",
element: "#main", element: "#main",
type: "png", type: "png",
@@ -65,7 +65,7 @@ describe("pw-tools-core", () => {
const mod = await importModule(); const mod = await importModule();
const res = await mod.takeScreenshotViaPlaywright({ const res = await mod.takeScreenshotViaPlaywright({
cdpPort: 18792, cdpUrl: "http://127.0.0.1:18792",
targetId: "T1", targetId: "T1",
ref: "76", ref: "76",
type: "jpeg", type: "jpeg",
@@ -89,7 +89,7 @@ describe("pw-tools-core", () => {
await expect( await expect(
mod.takeScreenshotViaPlaywright({ mod.takeScreenshotViaPlaywright({
cdpPort: 18792, cdpUrl: "http://127.0.0.1:18792",
targetId: "T1", targetId: "T1",
element: "#x", element: "#x",
fullPage: true, fullPage: true,
@@ -98,7 +98,7 @@ describe("pw-tools-core", () => {
await expect( await expect(
mod.takeScreenshotViaPlaywright({ mod.takeScreenshotViaPlaywright({
cdpPort: 18792, cdpUrl: "http://127.0.0.1:18792",
targetId: "T1", targetId: "T1",
ref: "1", ref: "1",
fullPage: true, fullPage: true,
@@ -118,7 +118,7 @@ describe("pw-tools-core", () => {
const mod = await importModule(); const mod = await importModule();
await mod.armFileUploadViaPlaywright({ await mod.armFileUploadViaPlaywright({
cdpPort: 18792, cdpUrl: "http://127.0.0.1:18792",
targetId: "T1", targetId: "T1",
paths: ["/tmp/a.txt"], paths: ["/tmp/a.txt"],
}); });
@@ -142,7 +142,10 @@ describe("pw-tools-core", () => {
}; };
const mod = await importModule(); const mod = await importModule();
await mod.armFileUploadViaPlaywright({ cdpPort: 18792, paths: [] }); await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
paths: [],
});
await Promise.resolve(); await Promise.resolve();
expect(fileChooser.setFiles).not.toHaveBeenCalled(); expect(fileChooser.setFiles).not.toHaveBeenCalled();
@@ -177,8 +180,14 @@ describe("pw-tools-core", () => {
}; };
const mod = await importModule(); const mod = await importModule();
await mod.armFileUploadViaPlaywright({ cdpPort: 18792, paths: ["/tmp/1"] }); await mod.armFileUploadViaPlaywright({
await mod.armFileUploadViaPlaywright({ cdpPort: 18792, paths: ["/tmp/2"] }); 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); resolve1?.(fc1);
resolve2?.(fc2); resolve2?.(fc2);
@@ -199,7 +208,7 @@ describe("pw-tools-core", () => {
const mod = await importModule(); const mod = await importModule();
await mod.armDialogViaPlaywright({ await mod.armDialogViaPlaywright({
cdpPort: 18792, cdpUrl: "http://127.0.0.1:18792",
accept: true, accept: true,
promptText: "x", promptText: "x",
}); });
@@ -214,7 +223,7 @@ describe("pw-tools-core", () => {
waitForEvent.mockClear(); waitForEvent.mockClear();
await mod.armDialogViaPlaywright({ await mod.armDialogViaPlaywright({
cdpPort: 18792, cdpUrl: "http://127.0.0.1:18792",
accept: false, accept: false,
}); });
await Promise.resolve(); await Promise.resolve();

View File

@@ -17,12 +17,12 @@ function requireRef(value: unknown): string {
} }
export async function snapshotAiViaPlaywright(opts: { export async function snapshotAiViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
timeoutMs?: number; timeoutMs?: number;
}): Promise<{ snapshot: string }> { }): Promise<{ snapshot: string }> {
const page = await getPageForTargetId({ const page = await getPageForTargetId({
cdpPort: opts.cdpPort, cdpUrl: opts.cdpUrl,
targetId: opts.targetId, targetId: opts.targetId,
}); });
ensurePageState(page); ensurePageState(page);
@@ -45,7 +45,7 @@ export async function snapshotAiViaPlaywright(opts: {
} }
export async function clickViaPlaywright(opts: { export async function clickViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
ref: string; ref: string;
doubleClick?: boolean; doubleClick?: boolean;
@@ -54,7 +54,7 @@ export async function clickViaPlaywright(opts: {
timeoutMs?: number; timeoutMs?: number;
}): Promise<void> { }): Promise<void> {
const page = await getPageForTargetId({ const page = await getPageForTargetId({
cdpPort: opts.cdpPort, cdpUrl: opts.cdpUrl,
targetId: opts.targetId, targetId: opts.targetId,
}); });
ensurePageState(page); ensurePageState(page);
@@ -79,7 +79,7 @@ export async function clickViaPlaywright(opts: {
} }
export async function hoverViaPlaywright(opts: { export async function hoverViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
ref: string; ref: string;
timeoutMs?: number; timeoutMs?: number;
@@ -94,7 +94,7 @@ export async function hoverViaPlaywright(opts: {
} }
export async function dragViaPlaywright(opts: { export async function dragViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
startRef: string; startRef: string;
endRef: string; endRef: string;
@@ -111,7 +111,7 @@ export async function dragViaPlaywright(opts: {
} }
export async function selectOptionViaPlaywright(opts: { export async function selectOptionViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
ref: string; ref: string;
values: string[]; values: string[];
@@ -128,7 +128,7 @@ export async function selectOptionViaPlaywright(opts: {
} }
export async function pressKeyViaPlaywright(opts: { export async function pressKeyViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
key: string; key: string;
delayMs?: number; delayMs?: number;
@@ -143,7 +143,7 @@ export async function pressKeyViaPlaywright(opts: {
} }
export async function typeViaPlaywright(opts: { export async function typeViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
ref: string; ref: string;
text: string; text: string;
@@ -168,7 +168,7 @@ export async function typeViaPlaywright(opts: {
} }
export async function fillFormViaPlaywright(opts: { export async function fillFormViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
fields: BrowserFormField[]; fields: BrowserFormField[];
}): Promise<void> { }): Promise<void> {
@@ -200,7 +200,7 @@ export async function fillFormViaPlaywright(opts: {
} }
export async function evaluateViaPlaywright(opts: { export async function evaluateViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
fn: string; fn: string;
ref?: string; ref?: string;
@@ -257,7 +257,7 @@ export async function evaluateViaPlaywright(opts: {
} }
export async function armFileUploadViaPlaywright(opts: { export async function armFileUploadViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
paths?: string[]; paths?: string[];
timeoutMs?: number; timeoutMs?: number;
@@ -304,7 +304,7 @@ export async function armFileUploadViaPlaywright(opts: {
} }
export async function setInputFilesViaPlaywright(opts: { export async function setInputFilesViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
inputRef?: string; inputRef?: string;
element?: string; element?: string;
@@ -342,7 +342,7 @@ export async function setInputFilesViaPlaywright(opts: {
} }
export async function armDialogViaPlaywright(opts: { export async function armDialogViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
accept: boolean; accept: boolean;
promptText?: string; promptText?: string;
@@ -368,7 +368,7 @@ export async function armDialogViaPlaywright(opts: {
} }
export async function navigateViaPlaywright(opts: { export async function navigateViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
url: string; url: string;
timeoutMs?: number; timeoutMs?: number;
@@ -384,7 +384,7 @@ export async function navigateViaPlaywright(opts: {
} }
export async function waitForViaPlaywright(opts: { export async function waitForViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
timeMs?: number; timeMs?: number;
text?: string; text?: string;
@@ -417,7 +417,7 @@ export async function waitForViaPlaywright(opts: {
} }
export async function takeScreenshotViaPlaywright(opts: { export async function takeScreenshotViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
ref?: string; ref?: string;
element?: string; element?: string;
@@ -449,7 +449,7 @@ export async function takeScreenshotViaPlaywright(opts: {
} }
export async function resizeViewportViaPlaywright(opts: { export async function resizeViewportViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
width: number; width: number;
height: number; height: number;
@@ -463,7 +463,7 @@ export async function resizeViewportViaPlaywright(opts: {
} }
export async function closePageViaPlaywright(opts: { export async function closePageViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
}): Promise<void> { }): Promise<void> {
const page = await getPageForTargetId(opts); const page = await getPageForTargetId(opts);
@@ -472,7 +472,7 @@ export async function closePageViaPlaywright(opts: {
} }
export async function pdfViaPlaywright(opts: { export async function pdfViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
}): Promise<{ buffer: Buffer }> { }): Promise<{ buffer: Buffer }> {
const page = await getPageForTargetId(opts); const page = await getPageForTargetId(opts);
@@ -498,7 +498,7 @@ function consolePriority(level: string) {
} }
export async function getConsoleMessagesViaPlaywright(opts: { export async function getConsoleMessagesViaPlaywright(opts: {
cdpPort: number; cdpUrl: string;
targetId?: string; targetId?: string;
level?: string; level?: string;
}): Promise<BrowserConsoleMessage[]> { }): Promise<BrowserConsoleMessage[]> {

View File

@@ -109,7 +109,7 @@ export function registerBrowserAgentRoutes(
const pw = await requirePwAi(res, "navigate"); const pw = await requirePwAi(res, "navigate");
if (!pw) return; if (!pw) return;
const result = await pw.navigateViaPlaywright({ const result = await pw.navigateViaPlaywright({
cdpPort: ctx.state().cdpPort, cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
url, url,
}); });
@@ -145,7 +145,7 @@ export function registerBrowserAgentRoutes(
try { try {
const tab = await ctx.ensureTabAvailable(targetId); const tab = await ctx.ensureTabAvailable(targetId);
const cdpPort = ctx.state().cdpPort; const cdpUrl = ctx.state().resolved.cdpUrl;
const pw = await requirePwAi(res, `act:${kind}`); const pw = await requirePwAi(res, `act:${kind}`);
if (!pw) return; if (!pw) return;
@@ -180,7 +180,7 @@ export function registerBrowserAgentRoutes(
? (modifiersRaw as ClickModifier[]) ? (modifiersRaw as ClickModifier[])
: undefined; : undefined;
const clickRequest: Parameters<typeof pw.clickViaPlaywright>[0] = { const clickRequest: Parameters<typeof pw.clickViaPlaywright>[0] = {
cdpPort, cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
ref, ref,
doubleClick, doubleClick,
@@ -199,7 +199,7 @@ export function registerBrowserAgentRoutes(
const submit = toBoolean(body.submit) ?? false; const submit = toBoolean(body.submit) ?? false;
const slowly = toBoolean(body.slowly) ?? false; const slowly = toBoolean(body.slowly) ?? false;
const typeRequest: Parameters<typeof pw.typeViaPlaywright>[0] = { const typeRequest: Parameters<typeof pw.typeViaPlaywright>[0] = {
cdpPort, cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
ref, ref,
text, text,
@@ -213,7 +213,7 @@ export function registerBrowserAgentRoutes(
const key = toStringOrEmpty(body.key); const key = toStringOrEmpty(body.key);
if (!key) return jsonError(res, 400, "key is required"); if (!key) return jsonError(res, 400, "key is required");
await pw.pressKeyViaPlaywright({ await pw.pressKeyViaPlaywright({
cdpPort, cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
key, key,
}); });
@@ -222,7 +222,7 @@ export function registerBrowserAgentRoutes(
case "hover": { case "hover": {
const ref = toStringOrEmpty(body.ref); const ref = toStringOrEmpty(body.ref);
if (!ref) return jsonError(res, 400, "ref is required"); 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 }); return res.json({ ok: true, targetId: tab.targetId });
} }
case "drag": { case "drag": {
@@ -231,7 +231,7 @@ export function registerBrowserAgentRoutes(
if (!startRef || !endRef) if (!startRef || !endRef)
return jsonError(res, 400, "startRef and endRef are required"); return jsonError(res, 400, "startRef and endRef are required");
await pw.dragViaPlaywright({ await pw.dragViaPlaywright({
cdpPort, cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
startRef, startRef,
endRef, endRef,
@@ -244,7 +244,7 @@ export function registerBrowserAgentRoutes(
if (!ref || !values?.length) if (!ref || !values?.length)
return jsonError(res, 400, "ref and values are required"); return jsonError(res, 400, "ref and values are required");
await pw.selectOptionViaPlaywright({ await pw.selectOptionViaPlaywright({
cdpPort, cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
ref, ref,
values, values,
@@ -273,7 +273,7 @@ export function registerBrowserAgentRoutes(
.filter((field): field is BrowserFormField => field !== null); .filter((field): field is BrowserFormField => field !== null);
if (!fields.length) return jsonError(res, 400, "fields are required"); if (!fields.length) return jsonError(res, 400, "fields are required");
await pw.fillFormViaPlaywright({ await pw.fillFormViaPlaywright({
cdpPort, cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
fields, fields,
}); });
@@ -285,7 +285,7 @@ export function registerBrowserAgentRoutes(
if (!width || !height) if (!width || !height)
return jsonError(res, 400, "width and height are required"); return jsonError(res, 400, "width and height are required");
await pw.resizeViewportViaPlaywright({ await pw.resizeViewportViaPlaywright({
cdpPort, cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
width, width,
height, height,
@@ -297,7 +297,7 @@ export function registerBrowserAgentRoutes(
const text = toStringOrEmpty(body.text) || undefined; const text = toStringOrEmpty(body.text) || undefined;
const textGone = toStringOrEmpty(body.textGone) || undefined; const textGone = toStringOrEmpty(body.textGone) || undefined;
await pw.waitForViaPlaywright({ await pw.waitForViaPlaywright({
cdpPort, cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
timeMs, timeMs,
text, text,
@@ -310,7 +310,7 @@ export function registerBrowserAgentRoutes(
if (!fn) return jsonError(res, 400, "fn is required"); if (!fn) return jsonError(res, 400, "fn is required");
const ref = toStringOrEmpty(body.ref) || undefined; const ref = toStringOrEmpty(body.ref) || undefined;
const result = await pw.evaluateViaPlaywright({ const result = await pw.evaluateViaPlaywright({
cdpPort, cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
fn, fn,
ref, ref,
@@ -323,7 +323,7 @@ export function registerBrowserAgentRoutes(
}); });
} }
case "close": { case "close": {
await pw.closePageViaPlaywright({ cdpPort, targetId: tab.targetId }); await pw.closePageViaPlaywright({ cdpUrl, targetId: tab.targetId });
return res.json({ ok: true, targetId: tab.targetId }); return res.json({ ok: true, targetId: tab.targetId });
} }
default: { default: {
@@ -357,7 +357,7 @@ export function registerBrowserAgentRoutes(
); );
} }
await pw.setInputFilesViaPlaywright({ await pw.setInputFilesViaPlaywright({
cdpPort: ctx.state().cdpPort, cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
inputRef, inputRef,
element, element,
@@ -365,14 +365,14 @@ export function registerBrowserAgentRoutes(
}); });
} else { } else {
await pw.armFileUploadViaPlaywright({ await pw.armFileUploadViaPlaywright({
cdpPort: ctx.state().cdpPort, cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
paths, paths,
timeoutMs: timeoutMs ?? undefined, timeoutMs: timeoutMs ?? undefined,
}); });
if (ref) { if (ref) {
await pw.clickViaPlaywright({ await pw.clickViaPlaywright({
cdpPort: ctx.state().cdpPort, cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
ref, ref,
}); });
@@ -396,7 +396,7 @@ export function registerBrowserAgentRoutes(
const pw = await requirePwAi(res, "dialog hook"); const pw = await requirePwAi(res, "dialog hook");
if (!pw) return; if (!pw) return;
await pw.armDialogViaPlaywright({ await pw.armDialogViaPlaywright({
cdpPort: ctx.state().cdpPort, cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
accept, accept,
promptText, promptText,
@@ -418,7 +418,7 @@ export function registerBrowserAgentRoutes(
const pw = await requirePwAi(res, "console messages"); const pw = await requirePwAi(res, "console messages");
if (!pw) return; if (!pw) return;
const messages = await pw.getConsoleMessagesViaPlaywright({ const messages = await pw.getConsoleMessagesViaPlaywright({
cdpPort: ctx.state().cdpPort, cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
level: level.trim() || undefined, level: level.trim() || undefined,
}); });
@@ -436,7 +436,7 @@ export function registerBrowserAgentRoutes(
const pw = await requirePwAi(res, "pdf"); const pw = await requirePwAi(res, "pdf");
if (!pw) return; if (!pw) return;
const pdf = await pw.pdfViaPlaywright({ const pdf = await pw.pdfViaPlaywright({
cdpPort: ctx.state().cdpPort, cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
}); });
await ensureMediaDir(); await ensureMediaDir();
@@ -480,7 +480,7 @@ export function registerBrowserAgentRoutes(
const pw = await requirePwAi(res, "element/ref screenshot"); const pw = await requirePwAi(res, "element/ref screenshot");
if (!pw) return; if (!pw) return;
const snap = await pw.takeScreenshotViaPlaywright({ const snap = await pw.takeScreenshotViaPlaywright({
cdpPort: ctx.state().cdpPort, cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
ref, ref,
element, element,
@@ -539,7 +539,7 @@ export function registerBrowserAgentRoutes(
const pw = await requirePwAi(res, "ai snapshot"); const pw = await requirePwAi(res, "ai snapshot");
if (!pw) return; if (!pw) return;
const snap = await pw.snapshotAiViaPlaywright({ const snap = await pw.snapshotAiViaPlaywright({
cdpPort: ctx.state().cdpPort, cdpUrl: ctx.state().resolved.cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
}); });
return res.json({ return res.json({

View File

@@ -27,10 +27,13 @@ export function registerBrowserBasicRoutes(
cdpHttp, cdpHttp,
pid: current.running?.pid ?? null, pid: current.running?.pid ?? null,
cdpPort: current.cdpPort, cdpPort: current.cdpPort,
cdpUrl: current.resolved.cdpUrl,
chosenBrowser: current.running?.exe.kind ?? null, chosenBrowser: current.running?.exe.kind ?? null,
userDataDir: current.running?.userDataDir ?? null, userDataDir: current.running?.userDataDir ?? null,
color: current.resolved.color, color: current.resolved.color,
headless: current.resolved.headless, headless: current.resolved.headless,
noSandbox: current.resolved.noSandbox,
executablePath: current.resolved.executablePath ?? null,
attachOnly: current.resolved.attachOnly, attachOnly: current.resolved.attachOnly,
}); });
}); });

View File

@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { runExec } from "../process/exec.js"; import { runExec } from "../process/exec.js";
import { createTargetViaCdp } from "./cdp.js"; import { createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
import { import {
isChromeCdpReady, isChromeCdpReady,
isChromeReachable, isChromeReachable,
@@ -98,6 +98,15 @@ export function createBrowserRouteContext(
const listTabs = async (): Promise<BrowserTab[]> => { const listTabs = async (): Promise<BrowserTab[]> => {
const current = state(); 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< const raw = await fetchJson<
Array<{ Array<{
id?: string; id?: string;
@@ -106,13 +115,13 @@ export function createBrowserRouteContext(
webSocketDebuggerUrl?: string; webSocketDebuggerUrl?: string;
type?: string; type?: string;
}> }>
>(`http://127.0.0.1:${current.cdpPort}/json/list`); >(`${base.replace(/\/$/, "")}/json/list`);
return raw return raw
.map((t) => ({ .map((t) => ({
targetId: t.id ?? "", targetId: t.id ?? "",
title: t.title ?? "", title: t.title ?? "",
url: t.url ?? "", url: t.url ?? "",
wsUrl: t.webSocketDebuggerUrl, wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl),
type: t.type, type: t.type,
})) }))
.filter((t) => Boolean(t.targetId)); .filter((t) => Boolean(t.targetId));
@@ -121,7 +130,7 @@ export function createBrowserRouteContext(
const openTab = async (url: string): Promise<BrowserTab> => { const openTab = async (url: string): Promise<BrowserTab> => {
const current = state(); const current = state();
const createdViaCdp = await createTargetViaCdp({ const createdViaCdp = await createTargetViaCdp({
cdpPort: current.cdpPort, cdpUrl: current.resolved.cdpUrl,
url, url,
}) })
.then((r) => r.targetId) .then((r) => r.targetId)
@@ -148,7 +157,16 @@ export function createBrowserRouteContext(
type?: string; 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<CdpTarget>(endpoint, 1500, { const created = await fetchJson<CdpTarget>(endpoint, 1500, {
method: "PUT", method: "PUT",
}).catch(async (err) => { }).catch(async (err) => {
@@ -163,7 +181,7 @@ export function createBrowserRouteContext(
targetId: created.id, targetId: created.id,
title: created.title ?? "", title: created.title ?? "",
url: created.url ?? url, url: created.url ?? url,
wsUrl: created.webSocketDebuggerUrl, wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl),
type: created.type, type: created.type,
}; };
}; };
@@ -171,12 +189,16 @@ export function createBrowserRouteContext(
const isReachable = async (timeoutMs = 300) => { const isReachable = async (timeoutMs = 300) => {
const current = state(); const current = state();
const wsTimeout = Math.max(200, Math.min(2000, timeoutMs * 2)); 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 isHttpReachable = async (timeoutMs = 300) => {
const current = state(); const current = state();
return await isChromeReachable(current.cdpPort, timeoutMs); return await isChromeReachable(current.resolved.cdpUrl, timeoutMs);
}; };
const attachRunning = (running: RunningChrome) => { const attachRunning = (running: RunningChrome) => {
@@ -191,11 +213,14 @@ export function createBrowserRouteContext(
const ensureBrowserAvailable = async (): Promise<void> => { const ensureBrowserAvailable = async (): Promise<void> => {
const current = state(); const current = state();
const remoteCdp = !current.resolved.cdpIsLoopback;
const httpReachable = await isHttpReachable(); const httpReachable = await isHttpReachable();
if (!httpReachable) { if (!httpReachable) {
if (current.resolved.attachOnly) { if (current.resolved.attachOnly || remoteCdp) {
throw new Error( 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); const launched = await launchClawdChrome(current.resolved);
@@ -204,9 +229,11 @@ export function createBrowserRouteContext(
if (await isReachable()) return; if (await isReachable()) return;
if (current.resolved.attachOnly) { if (current.resolved.attachOnly || remoteCdp) {
throw new Error( 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<void> => { const focusTab = async (targetId: string): Promise<void> => {
const current = state(); const current = state();
const base = current.resolved.cdpUrl.replace(/\/$/, "");
const tabs = await listTabs(); const tabs = await listTabs();
const resolved = resolveTargetIdFromTabs(targetId, tabs); const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) { if (!resolved.ok) {
@@ -263,13 +291,12 @@ export function createBrowserRouteContext(
} }
throw new Error("tab not found"); throw new Error("tab not found");
} }
await fetchOk( await fetchOk(`${base}/json/activate/${resolved.targetId}`);
`http://127.0.0.1:${current.cdpPort}/json/activate/${resolved.targetId}`,
);
}; };
const closeTab = async (targetId: string): Promise<void> => { const closeTab = async (targetId: string): Promise<void> => {
const current = state(); const current = state();
const base = current.resolved.cdpUrl.replace(/\/$/, "");
const tabs = await listTabs(); const tabs = await listTabs();
const resolved = resolveTargetIdFromTabs(targetId, tabs); const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) { if (!resolved.ok) {
@@ -278,9 +305,7 @@ export function createBrowserRouteContext(
} }
throw new Error("tab not found"); throw new Error("tab not found");
} }
await fetchOk( await fetchOk(`${base}/json/close/${resolved.targetId}`);
`http://127.0.0.1:${current.cdpPort}/json/close/${resolved.targetId}`,
);
}; };
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => { const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
@@ -293,6 +318,9 @@ export function createBrowserRouteContext(
const resetProfile = async () => { const resetProfile = async () => {
const current = state(); const current = state();
if (!current.resolved.cdpIsLoopback) {
throw new Error("reset-profile is only supported for local browsers.");
}
const userDataDir = resolveClawdUserDataDir(); const userDataDir = resolveClawdUserDataDir();
const httpReachable = await isHttpReachable(300); const httpReachable = await isHttpReachable(300);

View File

@@ -4,6 +4,7 @@ import { fetch as realFetch } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let testPort = 0; let testPort = 0;
let cdpBaseUrl = "";
let reachable = false; let reachable = false;
let cfgAttachOnly = false; let cfgAttachOnly = false;
let createTargetId: string | null = null; let createTargetId: string | null = null;
@@ -99,6 +100,7 @@ vi.mock("./chrome.js", () => ({
vi.mock("./cdp.js", () => ({ vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp, createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria, snapshotAria: cdpMocks.snapshotAria,
})); }));
@@ -159,6 +161,7 @@ describe("browser control server", () => {
for (const fn of Object.values(cdpMocks)) fn.mockClear(); for (const fn of Object.values(cdpMocks)) fn.mockClear();
testPort = await getFreePort(); testPort = await getFreePort();
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
// Minimal CDP JSON endpoints used by the server. // Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0; let putNewCalls = 0;
@@ -293,7 +296,7 @@ describe("browser control server", () => {
expect(snapAi.ok).toBe(true); expect(snapAi.ok).toBe(true);
expect(snapAi.format).toBe("ai"); expect(snapAi.format).toBe("ai");
expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
}); });
@@ -305,7 +308,7 @@ describe("browser control server", () => {
expect(nav.ok).toBe(true); expect(nav.ok).toBe(true);
expect(typeof nav.targetId).toBe("string"); expect(typeof nav.targetId).toBe("string");
expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
url: "https://example.com", url: "https://example.com",
}); });
@@ -322,7 +325,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean }; }).then((r) => r.json())) as { ok: boolean };
expect(click.ok).toBe(true); expect(click.ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(1, { expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(1, {
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
ref: "1", ref: "1",
doubleClick: false, doubleClick: false,
@@ -351,7 +354,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean }; }).then((r) => r.json())) as { ok: boolean };
expect(type.ok).toBe(true); expect(type.ok).toBe(true);
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(1, { expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(1, {
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
ref: "1", ref: "1",
text: "", text: "",
@@ -366,7 +369,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean }; }).then((r) => r.json())) as { ok: boolean };
expect(press.ok).toBe(true); expect(press.ok).toBe(true);
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
key: "Enter", key: "Enter",
}); });
@@ -378,7 +381,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean }; }).then((r) => r.json())) as { ok: boolean };
expect(hover.ok).toBe(true); expect(hover.ok).toBe(true);
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
ref: "2", ref: "2",
}); });
@@ -390,7 +393,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean }; }).then((r) => r.json())) as { ok: boolean };
expect(drag.ok).toBe(true); expect(drag.ok).toBe(true);
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
startRef: "3", startRef: "3",
endRef: "4", endRef: "4",
@@ -403,7 +406,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean }; }).then((r) => r.json())) as { ok: boolean };
expect(select.ok).toBe(true); expect(select.ok).toBe(true);
expect(pwMocks.selectOptionViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.selectOptionViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
ref: "5", ref: "5",
values: ["a", "b"], values: ["a", "b"],
@@ -419,7 +422,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean }; }).then((r) => r.json())) as { ok: boolean };
expect(fill.ok).toBe(true); expect(fill.ok).toBe(true);
expect(pwMocks.fillFormViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.fillFormViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
fields: [{ ref: "6", type: "textbox", value: "hello" }], fields: [{ ref: "6", type: "textbox", value: "hello" }],
}); });
@@ -431,7 +434,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean }; }).then((r) => r.json())) as { ok: boolean };
expect(resize.ok).toBe(true); expect(resize.ok).toBe(true);
expect(pwMocks.resizeViewportViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.resizeViewportViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
width: 800, width: 800,
height: 600, height: 600,
@@ -444,7 +447,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean }; }).then((r) => r.json())) as { ok: boolean };
expect(wait.ok).toBe(true); expect(wait.ok).toBe(true);
expect(pwMocks.waitForViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.waitForViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
timeMs: 5, timeMs: 5,
text: undefined, text: undefined,
@@ -459,7 +462,7 @@ describe("browser control server", () => {
expect(evalRes.ok).toBe(true); expect(evalRes.ok).toBe(true);
expect(evalRes.result).toBe("ok"); expect(evalRes.result).toBe("ok");
expect(pwMocks.evaluateViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.evaluateViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
fn: "() => 1", fn: "() => 1",
ref: undefined, ref: undefined,
@@ -472,7 +475,7 @@ describe("browser control server", () => {
}).then((r) => r.json()); }).then((r) => r.json());
expect(upload).toMatchObject({ ok: true }); expect(upload).toMatchObject({ ok: true });
expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
paths: ["/tmp/a.txt"], paths: ["/tmp/a.txt"],
timeoutMs: 1234, timeoutMs: 1234,
@@ -485,13 +488,13 @@ describe("browser control server", () => {
}).then((r) => r.json()); }).then((r) => r.json());
expect(uploadWithRef).toMatchObject({ ok: true }); expect(uploadWithRef).toMatchObject({ ok: true });
expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
paths: ["/tmp/b.txt"], paths: ["/tmp/b.txt"],
timeoutMs: undefined, timeoutMs: undefined,
}); });
expect(pwMocks.clickViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.clickViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
ref: "e12", ref: "e12",
}); });
@@ -503,7 +506,7 @@ describe("browser control server", () => {
}).then((r) => r.json()); }).then((r) => r.json());
expect(uploadWithInputRef).toMatchObject({ ok: true }); expect(uploadWithInputRef).toMatchObject({ ok: true });
expect(pwMocks.setInputFilesViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.setInputFilesViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
inputRef: "e99", inputRef: "e99",
element: undefined, element: undefined,
@@ -520,7 +523,7 @@ describe("browser control server", () => {
}).then((r) => r.json()); }).then((r) => r.json());
expect(uploadWithElement).toMatchObject({ ok: true }); expect(uploadWithElement).toMatchObject({ ok: true });
expect(pwMocks.setInputFilesViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.setInputFilesViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
inputRef: undefined, inputRef: undefined,
element: "input[type=file]", element: "input[type=file]",
@@ -534,7 +537,7 @@ describe("browser control server", () => {
}).then((r) => r.json()); }).then((r) => r.json());
expect(dialog).toMatchObject({ ok: true }); expect(dialog).toMatchObject({ ok: true });
expect(pwMocks.armDialogViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.armDialogViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
accept: true, accept: true,
promptText: undefined, promptText: undefined,
@@ -547,7 +550,7 @@ describe("browser control server", () => {
expect(consoleRes.ok).toBe(true); expect(consoleRes.ok).toBe(true);
expect(Array.isArray(consoleRes.messages)).toBe(true); expect(Array.isArray(consoleRes.messages)).toBe(true);
expect(pwMocks.getConsoleMessagesViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.getConsoleMessagesViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
level: "error", level: "error",
}); });
@@ -568,7 +571,7 @@ describe("browser control server", () => {
expect(shot.ok).toBe(true); expect(shot.ok).toBe(true);
expect(typeof shot.path).toBe("string"); expect(typeof shot.path).toBe("string");
expect(pwMocks.takeScreenshotViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.takeScreenshotViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
ref: undefined, ref: undefined,
element: "body", element: "body",
@@ -583,7 +586,7 @@ describe("browser control server", () => {
}).then((r) => r.json())) as { ok: boolean }; }).then((r) => r.json())) as { ok: boolean };
expect(close.ok).toBe(true); expect(close.ok).toBe(true);
expect(pwMocks.closePageViaPlaywright).toHaveBeenCalledWith({ expect(pwMocks.closePageViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1, cdpUrl: cdpBaseUrl,
targetId: "abcd1234", targetId: "abcd1234",
}); });

View File

@@ -38,6 +38,7 @@ export function registerBrowserManageCommands(
`running: ${status.running}`, `running: ${status.running}`,
`controlUrl: ${status.controlUrl}`, `controlUrl: ${status.controlUrl}`,
`cdpPort: ${status.cdpPort}`, `cdpPort: ${status.cdpPort}`,
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
`browser: ${status.chosenBrowser ?? "unknown"}`, `browser: ${status.chosenBrowser ?? "unknown"}`,
`profileColor: ${status.color}`, `profileColor: ${status.color}`,
].join("\n"), ].join("\n"),

View File

@@ -62,10 +62,16 @@ export type BrowserConfig = {
enabled?: boolean; enabled?: boolean;
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */ /** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
controlUrl?: string; 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 */ /** Accent color for the clawd browser profile (hex). Default: #FF4500 */
color?: string; color?: string;
/** Override the browser executable path (macOS/Linux). */
executablePath?: string;
/** Start Chrome headless (best-effort). Default: false */ /** Start Chrome headless (best-effort). Default: false */
headless?: boolean; 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 */ /** If true: never launch; only attach to an existing browser. Default: false */
attachOnly?: boolean; attachOnly?: boolean;
}; };
@@ -759,8 +765,11 @@ const ClawdisSchema = z.object({
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
controlUrl: z.string().optional(), controlUrl: z.string().optional(),
cdpUrl: z.string().optional(),
color: z.string().optional(), color: z.string().optional(),
executablePath: z.string().optional(),
headless: z.boolean().optional(), headless: z.boolean().optional(),
noSandbox: z.boolean().optional(),
attachOnly: z.boolean().optional(), attachOnly: z.boolean().optional(),
}) })
.optional(), .optional(),