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

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

View File

@@ -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
}
}
```

View File

@@ -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<typeof createServer> | 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");
});
});

View File

@@ -18,6 +18,31 @@ type CdpSendFn = (
params?: Record<string, 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) {
let nextId = 1;
const pending = new Map<number, Pending>();
@@ -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) => {

View File

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

View File

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

View File

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

View File

@@ -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;
};

View File

@@ -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", () => {

View File

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

View File

@@ -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",
});

View File

@@ -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<ConnectedBrowser> | 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<ConnectedBrowser> {
if (cached?.endpoint === endpoint) return cached;
async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
const normalized = normalizeCdpUrl(cdpUrl);
if (cached?.cdpUrl === normalized) return cached;
if (connecting) return await connecting;
const connectWithRetry = async (): Promise<ConnectedBrowser> => {
@@ -106,8 +108,12 @@ async function connectBrowser(endpoint: string): Promise<ConnectedBrowser> {
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<Page> {
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.");

View File

@@ -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();

View File

@@ -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<void> {
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<void> {
@@ -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<void> {
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<BrowserConsoleMessage[]> {

View File

@@ -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<typeof pw.clickViaPlaywright>[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<typeof pw.typeViaPlaywright>[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({

View File

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

View File

@@ -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<BrowserTab[]> => {
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<BrowserTab> => {
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<CdpTarget>(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<void> => {
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<void> => {
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<void> => {
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);

View File

@@ -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",
});

View File

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

View File

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