feat: add remote CDP browser support
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user