refactor: switch browser ownership to bundled plugin

This commit is contained in:
Peter Steinberger
2026-03-26 22:18:41 +00:00
parent 197510f693
commit 8eeb7f0829
255 changed files with 16981 additions and 21074 deletions

View File

@@ -42,6 +42,44 @@ openclaw browser --browser-profile openclaw snapshot
If you get “Browser disabled”, enable it in config (see below) and restart the
Gateway.
## Plugin control
The default `browser` tool is now a bundled plugin that ships enabled by
default. That means you can disable or replace it without removing the rest of
OpenClaw's plugin system:
```json5
{
plugins: {
entries: {
browser: {
enabled: false,
},
},
},
}
```
Disable the bundled plugin before installing another plugin that provides the
same `browser` tool name. The default browser experience needs both:
- `plugins.entries.browser.enabled` not disabled
- `browser.enabled=true`
If you turn off only the plugin, the bundled browser CLI (`openclaw browser`),
gateway method (`browser.request`), agent tool, and default browser control
service all disappear together. Your `browser.*` config stays intact for a
replacement plugin to reuse.
The bundled browser plugin also owns the browser runtime implementation now.
Core keeps only shared Plugin SDK helpers plus compatibility re-exports for
older internal import paths. In practice, removing or replacing
`extensions/browser` removes the browser feature set instead of leaving a
second core-owned runtime behind.
Browser config changes still require a Gateway restart so the bundled plugin
can re-register its browser service with the new settings.
## Profiles: `openclaw` vs `user`
- `openclaw`: managed, isolated browser (no extension required).

View File

@@ -104,6 +104,7 @@ and the [Plugin SDK Overview](/plugins/sdk-overview).
</Accordion>
<Accordion title="Other">
- `browser` — bundled browser plugin for the browser tool, `openclaw browser` CLI, `browser.request` gateway method, browser runtime, and default browser control service (enabled by default; disable before replacing it)
- `copilot-proxy` — VS Code Copilot Proxy bridge (disabled by default)
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,34 @@
type BridgeAuth = {
token?: string;
password?: string;
};
// In-process registry for loopback-only bridge servers that require auth, but
// are addressed via dynamic ephemeral ports (e.g. sandbox browser bridge).
const authByPort = new Map<number, BridgeAuth>();
export function setBridgeAuthForPort(port: number, auth: BridgeAuth): void {
if (!Number.isFinite(port) || port <= 0) {
return;
}
const token = typeof auth.token === "string" ? auth.token.trim() : "";
const password = typeof auth.password === "string" ? auth.password.trim() : "";
authByPort.set(port, {
token: token || undefined,
password: password || undefined,
});
}
export function getBridgeAuthForPort(port: number): BridgeAuth | undefined {
if (!Number.isFinite(port) || port <= 0) {
return undefined;
}
return authByPort.get(port);
}
export function deleteBridgeAuthForPort(port: number): void {
if (!Number.isFinite(port) || port <= 0) {
return;
}
authByPort.delete(port);
}

View File

@@ -0,0 +1,146 @@
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { isLoopbackHost } from "../gateway/net.js";
import { deleteBridgeAuthForPort, setBridgeAuthForPort } from "./bridge-auth-registry.js";
import type { ResolvedBrowserConfig } from "./config.js";
import { registerBrowserRoutes } from "./routes/index.js";
import type { BrowserRouteRegistrar } from "./routes/types.js";
import {
type BrowserServerState,
createBrowserRouteContext,
type ProfileContext,
} from "./server-context.js";
import {
installBrowserAuthMiddleware,
installBrowserCommonMiddleware,
} from "./server-middleware.js";
export type BrowserBridge = {
server: Server;
port: number;
baseUrl: string;
state: BrowserServerState;
};
type ResolvedNoVncObserver = {
noVncPort: number;
password?: string;
};
function buildNoVncBootstrapHtml(params: ResolvedNoVncObserver): string {
const hash = new URLSearchParams({
autoconnect: "1",
resize: "remote",
});
if (params.password?.trim()) {
hash.set("password", params.password);
}
const targetUrl = `http://127.0.0.1:${params.noVncPort}/vnc.html#${hash.toString()}`;
const encodedTarget = JSON.stringify(targetUrl);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<title>OpenClaw noVNC Observer</title>
</head>
<body>
<p>Opening sandbox observer...</p>
<script>
const target = ${encodedTarget};
window.location.replace(target);
</script>
</body>
</html>`;
}
export async function startBrowserBridgeServer(params: {
resolved: ResolvedBrowserConfig;
host?: string;
port?: number;
authToken?: string;
authPassword?: string;
onEnsureAttachTarget?: (profile: ProfileContext["profile"]) => Promise<void>;
resolveSandboxNoVncToken?: (token: string) => ResolvedNoVncObserver | null;
}): Promise<BrowserBridge> {
const host = params.host ?? "127.0.0.1";
if (!isLoopbackHost(host)) {
throw new Error(`bridge server must bind to loopback host (got ${host})`);
}
const port = params.port ?? 0;
const app = express();
installBrowserCommonMiddleware(app);
if (params.resolveSandboxNoVncToken) {
app.get("/sandbox/novnc", (req, res) => {
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
res.setHeader("Referrer-Policy", "no-referrer");
const rawToken = typeof req.query?.token === "string" ? req.query.token.trim() : "";
if (!rawToken) {
res.status(400).send("Missing token");
return;
}
const resolved = params.resolveSandboxNoVncToken?.(rawToken);
if (!resolved) {
res.status(404).send("Invalid or expired token");
return;
}
res.type("html").status(200).send(buildNoVncBootstrapHtml(resolved));
});
}
const authToken = params.authToken?.trim() || undefined;
const authPassword = params.authPassword?.trim() || undefined;
if (!authToken && !authPassword) {
throw new Error("bridge server requires auth (authToken/authPassword missing)");
}
installBrowserAuthMiddleware(app, { token: authToken, password: authPassword });
const state: BrowserServerState = {
server: null as unknown as Server,
port,
resolved: params.resolved,
profiles: new Map(),
};
const ctx = createBrowserRouteContext({
getState: () => state,
onEnsureAttachTarget: params.onEnsureAttachTarget,
});
registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
const server = await new Promise<Server>((resolve, reject) => {
const s = app.listen(port, host, () => resolve(s));
s.once("error", reject);
});
const address = server.address() as AddressInfo | null;
const resolvedPort = address?.port ?? port;
state.server = server;
state.port = resolvedPort;
state.resolved.controlPort = resolvedPort;
setBridgeAuthForPort(resolvedPort, { token: authToken, password: authPassword });
const baseUrl = `http://${host}:${resolvedPort}`;
return { server, port: resolvedPort, baseUrl, state };
}
export async function stopBrowserBridgeServer(server: Server): Promise<void> {
try {
const address = server.address() as AddressInfo | null;
if (address?.port) {
deleteBridgeAuthForPort(address.port);
}
} catch {
// ignore
}
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}

View File

@@ -0,0 +1,151 @@
/**
* Proxy bypass for CDP (Chrome DevTools Protocol) localhost connections.
*
* When HTTP_PROXY / HTTPS_PROXY / ALL_PROXY environment variables are set,
* CDP connections to localhost/127.0.0.1 can be incorrectly routed through
* the proxy, causing browser control to fail.
*
* @see https://github.com/nicepkg/openclaw/issues/31219
*/
import http from "node:http";
import https from "node:https";
import { isLoopbackHost } from "../gateway/net.js";
import { hasProxyEnvConfigured } from "../infra/net/proxy-env.js";
/** HTTP agent that never uses a proxy — for localhost CDP connections. */
const directHttpAgent = new http.Agent();
const directHttpsAgent = new https.Agent();
/**
* Returns a plain (non-proxy) agent for WebSocket or HTTP connections
* when the target is a loopback address. Returns `undefined` otherwise
* so callers fall through to their default behaviour.
*/
export function getDirectAgentForCdp(url: string): http.Agent | https.Agent | undefined {
try {
const parsed = new URL(url);
if (isLoopbackHost(parsed.hostname)) {
return parsed.protocol === "https:" || parsed.protocol === "wss:"
? directHttpsAgent
: directHttpAgent;
}
} catch {
// not a valid URL — let caller handle it
}
return undefined;
}
/**
* Returns `true` when any proxy-related env var is set that could
* interfere with loopback connections.
*/
export function hasProxyEnv(): boolean {
return hasProxyEnvConfigured();
}
const LOOPBACK_ENTRIES = "localhost,127.0.0.1,[::1]";
function noProxyAlreadyCoversLocalhost(): boolean {
const current = process.env.NO_PROXY || process.env.no_proxy || "";
return (
current.includes("localhost") && current.includes("127.0.0.1") && current.includes("[::1]")
);
}
export async function withNoProxyForLocalhost<T>(fn: () => Promise<T>): Promise<T> {
return await withNoProxyForCdpUrl("http://127.0.0.1", fn);
}
function isLoopbackCdpUrl(url: string): boolean {
try {
return isLoopbackHost(new URL(url).hostname);
} catch {
return false;
}
}
type NoProxySnapshot = {
noProxy: string | undefined;
noProxyLower: string | undefined;
applied: string;
};
class NoProxyLeaseManager {
private leaseCount = 0;
private snapshot: NoProxySnapshot | null = null;
acquire(url: string): (() => void) | null {
if (!isLoopbackCdpUrl(url) || !hasProxyEnv()) {
return null;
}
if (this.leaseCount === 0 && !noProxyAlreadyCoversLocalhost()) {
const noProxy = process.env.NO_PROXY;
const noProxyLower = process.env.no_proxy;
const current = noProxy || noProxyLower || "";
const applied = current ? `${current},${LOOPBACK_ENTRIES}` : LOOPBACK_ENTRIES;
process.env.NO_PROXY = applied;
process.env.no_proxy = applied;
this.snapshot = { noProxy, noProxyLower, applied };
}
this.leaseCount += 1;
let released = false;
return () => {
if (released) {
return;
}
released = true;
this.release();
};
}
private release() {
if (this.leaseCount <= 0) {
return;
}
this.leaseCount -= 1;
if (this.leaseCount > 0 || !this.snapshot) {
return;
}
const { noProxy, noProxyLower, applied } = this.snapshot;
const currentNoProxy = process.env.NO_PROXY;
const currentNoProxyLower = process.env.no_proxy;
const untouched =
currentNoProxy === applied &&
(currentNoProxyLower === applied || currentNoProxyLower === undefined);
if (untouched) {
if (noProxy !== undefined) {
process.env.NO_PROXY = noProxy;
} else {
delete process.env.NO_PROXY;
}
if (noProxyLower !== undefined) {
process.env.no_proxy = noProxyLower;
} else {
delete process.env.no_proxy;
}
}
this.snapshot = null;
}
}
const noProxyLeaseManager = new NoProxyLeaseManager();
/**
* Scoped NO_PROXY bypass for loopback CDP URLs.
*
* This wrapper only mutates env vars for loopback destinations. On restore,
* it avoids clobbering external NO_PROXY changes that happened while calls
* were in-flight.
*/
export async function withNoProxyForCdpUrl<T>(url: string, fn: () => Promise<T>): Promise<T> {
const release = noProxyLeaseManager.acquire(url);
try {
return await fn();
} finally {
release?.();
}
}

View File

@@ -0,0 +1,56 @@
export const CDP_HTTP_REQUEST_TIMEOUT_MS = 1500;
export const CDP_WS_HANDSHAKE_TIMEOUT_MS = 5000;
export const CDP_JSON_NEW_TIMEOUT_MS = 1500;
export const CHROME_REACHABILITY_TIMEOUT_MS = 500;
export const CHROME_WS_READY_TIMEOUT_MS = 800;
export const CHROME_BOOTSTRAP_PREFS_TIMEOUT_MS = 10_000;
export const CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS = 5000;
export const CHROME_LAUNCH_READY_WINDOW_MS = 15_000;
export const CHROME_LAUNCH_READY_POLL_MS = 200;
export const CHROME_STOP_TIMEOUT_MS = 2500;
export const CHROME_STOP_PROBE_TIMEOUT_MS = 200;
export const CHROME_STDERR_HINT_MAX_CHARS = 2000;
export const PROFILE_HTTP_REACHABILITY_TIMEOUT_MS = 300;
export const PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS = 200;
export const PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS = 2000;
export const PROFILE_ATTACH_RETRY_TIMEOUT_MS = 1200;
export const PROFILE_POST_RESTART_WS_TIMEOUT_MS = 600;
export const CHROME_MCP_ATTACH_READY_WINDOW_MS = 8000;
export const CHROME_MCP_ATTACH_READY_POLL_MS = 200;
function normalizeTimeoutMs(value: number | undefined): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
}
return Math.max(1, Math.floor(value));
}
export function resolveCdpReachabilityTimeouts(params: {
profileIsLoopback: boolean;
timeoutMs?: number;
remoteHttpTimeoutMs: number;
remoteHandshakeTimeoutMs: number;
}): { httpTimeoutMs: number; wsTimeoutMs: number } {
const normalized = normalizeTimeoutMs(params.timeoutMs);
if (params.profileIsLoopback) {
const httpTimeoutMs = normalized ?? PROFILE_HTTP_REACHABILITY_TIMEOUT_MS;
const wsTimeoutMs = Math.max(
PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS,
Math.min(PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS, httpTimeoutMs * 2),
);
return { httpTimeoutMs, wsTimeoutMs };
}
if (normalized !== undefined) {
return {
httpTimeoutMs: Math.max(normalized, params.remoteHttpTimeoutMs),
wsTimeoutMs: Math.max(normalized * 2, params.remoteHandshakeTimeoutMs),
};
}
return {
httpTimeoutMs: params.remoteHttpTimeoutMs,
wsTimeoutMs: params.remoteHandshakeTimeoutMs,
};
}

View File

@@ -0,0 +1,280 @@
import WebSocket from "ws";
import { isLoopbackHost } from "../gateway/net.js";
import { type SsrFPolicy, resolvePinnedHostnameWithPolicy } from "../infra/net/ssrf.js";
import { rawDataToString } from "../infra/ws.js";
import { redactSensitiveText } from "../logging/redact.js";
import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
import { resolveBrowserRateLimitMessage } from "./client-fetch.js";
export { isLoopbackHost };
/**
* Returns true when the URL uses a WebSocket protocol (ws: or wss:).
* Used to distinguish direct-WebSocket CDP endpoints
* from HTTP(S) endpoints that require /json/version discovery.
*/
export function isWebSocketUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "ws:" || parsed.protocol === "wss:";
} catch {
return false;
}
}
export async function assertCdpEndpointAllowed(
cdpUrl: string,
ssrfPolicy?: SsrFPolicy,
): Promise<void> {
if (!ssrfPolicy) {
return;
}
const parsed = new URL(cdpUrl);
if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`);
}
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
policy: ssrfPolicy,
});
}
export function redactCdpUrl(cdpUrl: string | null | undefined): string | null | undefined {
if (typeof cdpUrl !== "string") {
return cdpUrl;
}
const trimmed = cdpUrl.trim();
if (!trimmed) {
return trimmed;
}
try {
const parsed = new URL(trimmed);
parsed.username = "";
parsed.password = "";
return redactSensitiveText(parsed.toString().replace(/\/$/, ""));
} catch {
return redactSensitiveText(trimmed);
}
}
type CdpResponse = {
id: number;
result?: unknown;
error?: { message?: string };
};
type Pending = {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
};
export type CdpSendFn = (
method: string,
params?: Record<string, unknown>,
sessionId?: string,
) => Promise<unknown>;
export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
const mergedHeaders = { ...headers };
try {
const parsed = new URL(url);
const hasAuthHeader = Object.keys(mergedHeaders).some(
(key) => key.toLowerCase() === "authorization",
);
if (hasAuthHeader) {
return mergedHeaders;
}
if (parsed.username || parsed.password) {
const auth = Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64");
return { ...mergedHeaders, Authorization: `Basic ${auth}` };
}
} catch {
// ignore
}
return mergedHeaders;
}
export function appendCdpPath(cdpUrl: string, path: string): string {
const url = new URL(cdpUrl);
const basePath = url.pathname.replace(/\/$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
url.pathname = `${basePath}${suffix}`;
return url.toString();
}
export function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string {
try {
const url = new URL(cdpUrl);
if (url.protocol === "ws:") {
url.protocol = "http:";
} else if (url.protocol === "wss:") {
url.protocol = "https:";
}
url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, "");
url.pathname = url.pathname.replace(/\/cdp$/, "");
return url.toString().replace(/\/$/, "");
} catch {
// Best-effort fallback for non-URL-ish inputs.
return cdpUrl
.replace(/^ws:/, "http:")
.replace(/^wss:/, "https:")
.replace(/\/devtools\/browser\/.*$/, "")
.replace(/\/cdp$/, "")
.replace(/\/$/, "");
}
}
function createCdpSender(ws: WebSocket) {
let nextId = 1;
const pending = new Map<number, Pending>();
const send: CdpSendFn = (
method: string,
params?: Record<string, unknown>,
sessionId?: string,
) => {
const id = nextId++;
const msg = { id, method, params, sessionId };
ws.send(JSON.stringify(msg));
return new Promise<unknown>((resolve, reject) => {
pending.set(id, { resolve, reject });
});
};
const closeWithError = (err: Error) => {
for (const [, p] of pending) {
p.reject(err);
}
pending.clear();
try {
ws.close();
} catch {
// ignore
}
};
ws.on("error", (err) => {
closeWithError(err instanceof Error ? err : new Error(String(err)));
});
ws.on("message", (data) => {
try {
const parsed = JSON.parse(rawDataToString(data)) as CdpResponse;
if (typeof parsed.id !== "number") {
return;
}
const p = pending.get(parsed.id);
if (!p) {
return;
}
pending.delete(parsed.id);
if (parsed.error?.message) {
p.reject(new Error(parsed.error.message));
return;
}
p.resolve(parsed.result);
} catch {
// ignore
}
});
ws.on("close", () => {
closeWithError(new Error("CDP socket closed"));
});
return { send, closeWithError };
}
export async function fetchJson<T>(
url: string,
timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS,
init?: RequestInit,
): Promise<T> {
const res = await fetchCdpChecked(url, timeoutMs, init);
return (await res.json()) as T;
}
export async function fetchCdpChecked(
url: string,
timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS,
init?: RequestInit,
): Promise<Response> {
const ctrl = new AbortController();
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
try {
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
const res = await withNoProxyForCdpUrl(url, () =>
fetch(url, { ...init, headers, signal: ctrl.signal }),
);
if (!res.ok) {
if (res.status === 429) {
// Do not reflect upstream response text into the error surface (log/agent injection risk)
throw new Error(`${resolveBrowserRateLimitMessage(url)} Do NOT retry the browser tool.`);
}
throw new Error(`HTTP ${res.status}`);
}
return res;
} finally {
clearTimeout(t);
}
}
export async function fetchOk(
url: string,
timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS,
init?: RequestInit,
): Promise<void> {
await fetchCdpChecked(url, timeoutMs, init);
}
export function openCdpWebSocket(
wsUrl: string,
opts?: { headers?: Record<string, string>; handshakeTimeoutMs?: number },
): WebSocket {
const headers = getHeadersWithAuth(wsUrl, opts?.headers ?? {});
const handshakeTimeoutMs =
typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs)
? Math.max(1, Math.floor(opts.handshakeTimeoutMs))
: CDP_WS_HANDSHAKE_TIMEOUT_MS;
const agent = getDirectAgentForCdp(wsUrl);
return new WebSocket(wsUrl, {
handshakeTimeout: handshakeTimeoutMs,
...(Object.keys(headers).length ? { headers } : {}),
...(agent ? { agent } : {}),
});
}
export async function withCdpSocket<T>(
wsUrl: string,
fn: (send: CdpSendFn) => Promise<T>,
opts?: { headers?: Record<string, string>; handshakeTimeoutMs?: number },
): Promise<T> {
const ws = openCdpWebSocket(wsUrl, opts);
const { send, closeWithError } = createCdpSender(ws);
const openPromise = new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", (err) => reject(err));
ws.once("close", () => reject(new Error("CDP socket closed")));
});
try {
await openPromise;
} catch (err) {
closeWithError(err instanceof Error ? err : new Error(String(err)));
throw err;
}
try {
return await fn(send);
} catch (err) {
closeWithError(err instanceof Error ? err : new Error(String(err)));
throw err;
} finally {
try {
ws.close();
} catch {
// ignore
}
}
}

View File

@@ -0,0 +1,485 @@
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import {
appendCdpPath,
fetchJson,
isLoopbackHost,
isWebSocketUrl,
withCdpSocket,
} from "./cdp.helpers.js";
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
export {
appendCdpPath,
fetchJson,
fetchOk,
getHeadersWithAuth,
isWebSocketUrl,
} from "./cdp.helpers.js";
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
const ws = new URL(wsUrl);
const cdp = new URL(cdpUrl);
// Treat 0.0.0.0 and :: as wildcard bind addresses that need rewriting.
// Containerized browsers (e.g. browserless) report ws://0.0.0.0:<internal-port>
// in /json/version — these must be rewritten to the external cdpUrl host:port.
const isWildcardBind = ws.hostname === "0.0.0.0" || ws.hostname === "[::]";
if ((isLoopbackHost(ws.hostname) || isWildcardBind) && !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:";
}
if (cdp.protocol === "https:" && ws.protocol === "ws:") {
ws.protocol = "wss:";
}
if (!ws.username && !ws.password && (cdp.username || cdp.password)) {
ws.username = cdp.username;
ws.password = cdp.password;
}
for (const [key, value] of cdp.searchParams.entries()) {
if (!ws.searchParams.has(key)) {
ws.searchParams.append(key, value);
}
}
return ws.toString();
}
export async function captureScreenshotPng(opts: {
wsUrl: string;
fullPage?: boolean;
}): Promise<Buffer> {
return await captureScreenshot({
wsUrl: opts.wsUrl,
fullPage: opts.fullPage,
format: "png",
});
}
export async function captureScreenshot(opts: {
wsUrl: string;
fullPage?: boolean;
format?: "png" | "jpeg";
quality?: number; // jpeg only (0..100)
}): Promise<Buffer> {
return await withCdpSocket(opts.wsUrl, async (send) => {
await send("Page.enable");
let clip: { x: number; y: number; width: number; height: number; scale: number } | undefined;
if (opts.fullPage) {
const metrics = (await send("Page.getLayoutMetrics")) as {
cssContentSize?: { width?: number; height?: number };
contentSize?: { width?: number; height?: number };
};
const size = metrics?.cssContentSize ?? metrics?.contentSize;
const width = Number(size?.width ?? 0);
const height = Number(size?.height ?? 0);
if (width > 0 && height > 0) {
clip = { x: 0, y: 0, width, height, scale: 1 };
}
}
const format = opts.format ?? "png";
const quality =
format === "jpeg" ? Math.max(0, Math.min(100, Math.round(opts.quality ?? 85))) : undefined;
const result = (await send("Page.captureScreenshot", {
format,
...(quality !== undefined ? { quality } : {}),
fromSurface: true,
captureBeyondViewport: true,
...(clip ? { clip } : {}),
})) as { data?: string };
const base64 = result?.data;
if (!base64) {
throw new Error("Screenshot failed: missing data");
}
return Buffer.from(base64, "base64");
});
}
export async function createTargetViaCdp(opts: {
cdpUrl: string;
url: string;
ssrfPolicy?: SsrFPolicy;
}): Promise<{ targetId: string }> {
await assertBrowserNavigationAllowed({
url: opts.url,
...withBrowserNavigationPolicy(opts.ssrfPolicy),
});
let wsUrl: string;
if (isWebSocketUrl(opts.cdpUrl)) {
// Direct WebSocket URL — skip /json/version discovery.
wsUrl = opts.cdpUrl;
} else {
// Standard HTTP(S) CDP endpoint — discover WebSocket URL via /json/version.
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
appendCdpPath(opts.cdpUrl, "/json/version"),
1500,
);
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
if (!wsUrl) {
throw new Error("CDP /json/version missing webSocketDebuggerUrl");
}
}
return await withCdpSocket(wsUrl, async (send) => {
const created = (await send("Target.createTarget", { url: opts.url })) as {
targetId?: string;
};
const targetId = String(created?.targetId ?? "").trim();
if (!targetId) {
throw new Error("CDP Target.createTarget returned no targetId");
}
return { targetId };
});
}
export type CdpRemoteObject = {
type: string;
subtype?: string;
value?: unknown;
description?: string;
unserializableValue?: string;
preview?: unknown;
};
export type CdpExceptionDetails = {
text?: string;
lineNumber?: number;
columnNumber?: number;
exception?: CdpRemoteObject;
stackTrace?: unknown;
};
export async function evaluateJavaScript(opts: {
wsUrl: string;
expression: string;
awaitPromise?: boolean;
returnByValue?: boolean;
}): Promise<{
result: CdpRemoteObject;
exceptionDetails?: CdpExceptionDetails;
}> {
return await withCdpSocket(opts.wsUrl, async (send) => {
await send("Runtime.enable").catch(() => {});
const evaluated = (await send("Runtime.evaluate", {
expression: opts.expression,
awaitPromise: Boolean(opts.awaitPromise),
returnByValue: opts.returnByValue ?? true,
userGesture: true,
includeCommandLineAPI: true,
})) as {
result?: CdpRemoteObject;
exceptionDetails?: CdpExceptionDetails;
};
const result = evaluated?.result;
if (!result) {
throw new Error("CDP Runtime.evaluate returned no result");
}
return { result, exceptionDetails: evaluated.exceptionDetails };
});
}
export type AriaSnapshotNode = {
ref: string;
role: string;
name: string;
value?: string;
description?: string;
backendDOMNodeId?: number;
depth: number;
};
export type RawAXNode = {
nodeId?: string;
role?: { value?: string };
name?: { value?: string };
value?: { value?: string };
description?: { value?: string };
childIds?: string[];
backendDOMNodeId?: number;
};
function axValue(v: unknown): string {
if (!v || typeof v !== "object") {
return "";
}
const value = (v as { value?: unknown }).value;
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return "";
}
export function formatAriaSnapshot(nodes: RawAXNode[], limit: number): AriaSnapshotNode[] {
const byId = new Map<string, RawAXNode>();
for (const n of nodes) {
if (n.nodeId) {
byId.set(n.nodeId, n);
}
}
// Heuristic: pick a root-ish node (one that is not referenced as a child), else first.
const referenced = new Set<string>();
for (const n of nodes) {
for (const c of n.childIds ?? []) {
referenced.add(c);
}
}
const root = nodes.find((n) => n.nodeId && !referenced.has(n.nodeId)) ?? nodes[0];
if (!root?.nodeId) {
return [];
}
const out: AriaSnapshotNode[] = [];
const stack: Array<{ id: string; depth: number }> = [{ id: root.nodeId, depth: 0 }];
while (stack.length && out.length < limit) {
const popped = stack.pop();
if (!popped) {
break;
}
const { id, depth } = popped;
const n = byId.get(id);
if (!n) {
continue;
}
const role = axValue(n.role);
const name = axValue(n.name);
const value = axValue(n.value);
const description = axValue(n.description);
const ref = `ax${out.length + 1}`;
out.push({
ref,
role: role || "unknown",
name: name || "",
...(value ? { value } : {}),
...(description ? { description } : {}),
...(typeof n.backendDOMNodeId === "number" ? { backendDOMNodeId: n.backendDOMNodeId } : {}),
depth,
});
const children = (n.childIds ?? []).filter((c) => byId.has(c));
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
if (child) {
stack.push({ id: child, depth: depth + 1 });
}
}
}
return out;
}
export async function snapshotAria(opts: {
wsUrl: string;
limit?: number;
}): Promise<{ nodes: AriaSnapshotNode[] }> {
const limit = Math.max(1, Math.min(2000, Math.floor(opts.limit ?? 500)));
return await withCdpSocket(opts.wsUrl, async (send) => {
await send("Accessibility.enable").catch(() => {});
const res = (await send("Accessibility.getFullAXTree")) as {
nodes?: RawAXNode[];
};
const nodes = Array.isArray(res?.nodes) ? res.nodes : [];
return { nodes: formatAriaSnapshot(nodes, limit) };
});
}
export async function snapshotDom(opts: {
wsUrl: string;
limit?: number;
maxTextChars?: number;
}): Promise<{
nodes: DomSnapshotNode[];
}> {
const limit = Math.max(1, Math.min(5000, Math.floor(opts.limit ?? 800)));
const maxTextChars = Math.max(0, Math.min(5000, Math.floor(opts.maxTextChars ?? 220)));
const expression = `(() => {
const maxNodes = ${JSON.stringify(limit)};
const maxText = ${JSON.stringify(maxTextChars)};
const nodes = [];
const root = document.documentElement;
if (!root) return { nodes };
const stack = [{ el: root, depth: 0, parentRef: null }];
while (stack.length && nodes.length < maxNodes) {
const cur = stack.pop();
const el = cur.el;
if (!el || el.nodeType !== 1) continue;
const ref = "n" + String(nodes.length + 1);
const tag = (el.tagName || "").toLowerCase();
const id = el.id ? String(el.id) : undefined;
const className = el.className ? String(el.className).slice(0, 300) : undefined;
const role = el.getAttribute && el.getAttribute("role") ? String(el.getAttribute("role")) : undefined;
const name = el.getAttribute && el.getAttribute("aria-label") ? String(el.getAttribute("aria-label")) : undefined;
let text = "";
try { text = String(el.innerText || "").trim(); } catch {}
if (maxText && text.length > maxText) text = text.slice(0, maxText) + "…";
const href = (el.href !== undefined && el.href !== null) ? String(el.href) : undefined;
const type = (el.type !== undefined && el.type !== null) ? String(el.type) : undefined;
const value = (el.value !== undefined && el.value !== null) ? String(el.value).slice(0, 500) : undefined;
nodes.push({
ref,
parentRef: cur.parentRef,
depth: cur.depth,
tag,
...(id ? { id } : {}),
...(className ? { className } : {}),
...(role ? { role } : {}),
...(name ? { name } : {}),
...(text ? { text } : {}),
...(href ? { href } : {}),
...(type ? { type } : {}),
...(value ? { value } : {}),
});
const children = el.children ? Array.from(el.children) : [];
for (let i = children.length - 1; i >= 0; i--) {
stack.push({ el: children[i], depth: cur.depth + 1, parentRef: ref });
}
}
return { nodes };
})()`;
const evaluated = await evaluateJavaScript({
wsUrl: opts.wsUrl,
expression,
awaitPromise: true,
returnByValue: true,
});
const value = evaluated.result?.value;
if (!value || typeof value !== "object") {
return { nodes: [] };
}
const nodes = (value as { nodes?: unknown }).nodes;
return { nodes: Array.isArray(nodes) ? (nodes as DomSnapshotNode[]) : [] };
}
export type DomSnapshotNode = {
ref: string;
parentRef: string | null;
depth: number;
tag: string;
id?: string;
className?: string;
role?: string;
name?: string;
text?: string;
href?: string;
type?: string;
value?: string;
};
export async function getDomText(opts: {
wsUrl: string;
format: "html" | "text";
maxChars?: number;
selector?: string;
}): Promise<{ text: string }> {
const maxChars = Math.max(0, Math.min(5_000_000, Math.floor(opts.maxChars ?? 200_000)));
const selectorExpr = opts.selector ? JSON.stringify(opts.selector) : "null";
const expression = `(() => {
const fmt = ${JSON.stringify(opts.format)};
const max = ${JSON.stringify(maxChars)};
const sel = ${selectorExpr};
const pick = sel ? document.querySelector(sel) : null;
let out = "";
if (fmt === "text") {
const el = pick || document.body || document.documentElement;
try { out = String(el && el.innerText ? el.innerText : ""); } catch { out = ""; }
} else {
const el = pick || document.documentElement;
try { out = String(el && el.outerHTML ? el.outerHTML : ""); } catch { out = ""; }
}
if (max && out.length > max) out = out.slice(0, max) + "\\n<!-- …truncated… -->";
return out;
})()`;
const evaluated = await evaluateJavaScript({
wsUrl: opts.wsUrl,
expression,
awaitPromise: true,
returnByValue: true,
});
const textValue = (evaluated.result?.value ?? "") as unknown;
const text =
typeof textValue === "string"
? textValue
: typeof textValue === "number" || typeof textValue === "boolean"
? String(textValue)
: "";
return { text };
}
export async function querySelector(opts: {
wsUrl: string;
selector: string;
limit?: number;
maxTextChars?: number;
maxHtmlChars?: number;
}): Promise<{
matches: QueryMatch[];
}> {
const limit = Math.max(1, Math.min(200, Math.floor(opts.limit ?? 20)));
const maxText = Math.max(0, Math.min(5000, Math.floor(opts.maxTextChars ?? 500)));
const maxHtml = Math.max(0, Math.min(20000, Math.floor(opts.maxHtmlChars ?? 1500)));
const expression = `(() => {
const sel = ${JSON.stringify(opts.selector)};
const lim = ${JSON.stringify(limit)};
const maxText = ${JSON.stringify(maxText)};
const maxHtml = ${JSON.stringify(maxHtml)};
const els = Array.from(document.querySelectorAll(sel)).slice(0, lim);
return els.map((el, i) => {
const tag = (el.tagName || "").toLowerCase();
const id = el.id ? String(el.id) : undefined;
const className = el.className ? String(el.className).slice(0, 300) : undefined;
let text = "";
try { text = String(el.innerText || "").trim(); } catch {}
if (maxText && text.length > maxText) text = text.slice(0, maxText) + "…";
const value = (el.value !== undefined && el.value !== null) ? String(el.value).slice(0, 500) : undefined;
const href = (el.href !== undefined && el.href !== null) ? String(el.href) : undefined;
let outerHTML = "";
try { outerHTML = String(el.outerHTML || ""); } catch {}
if (maxHtml && outerHTML.length > maxHtml) outerHTML = outerHTML.slice(0, maxHtml) + "…";
return {
index: i + 1,
tag,
...(id ? { id } : {}),
...(className ? { className } : {}),
...(text ? { text } : {}),
...(value ? { value } : {}),
...(href ? { href } : {}),
...(outerHTML ? { outerHTML } : {}),
};
});
})()`;
const evaluated = await evaluateJavaScript({
wsUrl: opts.wsUrl,
expression,
awaitPromise: true,
returnByValue: true,
});
const matches = evaluated.result?.value;
return { matches: Array.isArray(matches) ? (matches as QueryMatch[]) : [] };
}
export type QueryMatch = {
index: number;
tag: string;
id?: string;
className?: string;
text?: string;
value?: string;
href?: string;
outerHTML?: string;
};

View File

@@ -0,0 +1,193 @@
import type { SnapshotAriaNode } from "./client.js";
import {
getRoleSnapshotStats,
type RoleRefMap,
type RoleSnapshotOptions,
} from "./pw-role-snapshot.js";
import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js";
export type ChromeMcpSnapshotNode = {
id?: string;
role?: string;
name?: string;
value?: string | number | boolean;
description?: string;
children?: ChromeMcpSnapshotNode[];
};
function normalizeRole(node: ChromeMcpSnapshotNode): string {
const role = typeof node.role === "string" ? node.role.trim().toLowerCase() : "";
return role || "generic";
}
function normalizeString(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed || undefined;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return undefined;
}
function escapeQuoted(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
function shouldIncludeNode(params: {
role: string;
name?: string;
options?: RoleSnapshotOptions;
}): boolean {
if (params.options?.interactive && !INTERACTIVE_ROLES.has(params.role)) {
return false;
}
if (params.options?.compact && STRUCTURAL_ROLES.has(params.role) && !params.name) {
return false;
}
return true;
}
function shouldCreateRef(role: string, name?: string): boolean {
return INTERACTIVE_ROLES.has(role) || (CONTENT_ROLES.has(role) && Boolean(name));
}
type DuplicateTracker = {
counts: Map<string, number>;
keysByRef: Map<string, string>;
duplicates: Set<string>;
};
function createDuplicateTracker(): DuplicateTracker {
return {
counts: new Map(),
keysByRef: new Map(),
duplicates: new Set(),
};
}
function registerRef(
tracker: DuplicateTracker,
ref: string,
role: string,
name?: string,
): number | undefined {
const key = `${role}:${name ?? ""}`;
const count = tracker.counts.get(key) ?? 0;
tracker.counts.set(key, count + 1);
tracker.keysByRef.set(ref, key);
if (count > 0) {
tracker.duplicates.add(key);
return count;
}
return undefined;
}
export function flattenChromeMcpSnapshotToAriaNodes(
root: ChromeMcpSnapshotNode,
limit = 500,
): SnapshotAriaNode[] {
const boundedLimit = Math.max(1, Math.min(2000, Math.floor(limit)));
const out: SnapshotAriaNode[] = [];
const visit = (node: ChromeMcpSnapshotNode, depth: number) => {
if (out.length >= boundedLimit) {
return;
}
const ref = normalizeString(node.id);
if (ref) {
out.push({
ref,
role: normalizeRole(node),
name: normalizeString(node.name) ?? "",
value: normalizeString(node.value),
description: normalizeString(node.description),
depth,
});
}
for (const child of node.children ?? []) {
visit(child, depth + 1);
if (out.length >= boundedLimit) {
return;
}
}
};
visit(root, 0);
return out;
}
export function buildAiSnapshotFromChromeMcpSnapshot(params: {
root: ChromeMcpSnapshotNode;
options?: RoleSnapshotOptions;
maxChars?: number;
}): {
snapshot: string;
truncated?: boolean;
refs: RoleRefMap;
stats: { lines: number; chars: number; refs: number; interactive: number };
} {
const refs: RoleRefMap = {};
const tracker = createDuplicateTracker();
const lines: string[] = [];
const visit = (node: ChromeMcpSnapshotNode, depth: number) => {
const role = normalizeRole(node);
const name = normalizeString(node.name);
const value = normalizeString(node.value);
const description = normalizeString(node.description);
const maxDepth = params.options?.maxDepth;
if (maxDepth !== undefined && depth > maxDepth) {
return;
}
const includeNode = shouldIncludeNode({ role, name, options: params.options });
if (includeNode) {
let line = `${" ".repeat(depth)}- ${role}`;
if (name) {
line += ` "${escapeQuoted(name)}"`;
}
const ref = normalizeString(node.id);
if (ref && shouldCreateRef(role, name)) {
const nth = registerRef(tracker, ref, role, name);
refs[ref] = nth === undefined ? { role, name } : { role, name, nth };
line += ` [ref=${ref}]`;
}
if (value) {
line += ` value="${escapeQuoted(value)}"`;
}
if (description) {
line += ` description="${escapeQuoted(description)}"`;
}
lines.push(line);
}
for (const child of node.children ?? []) {
visit(child, depth + 1);
}
};
visit(params.root, 0);
for (const [ref, data] of Object.entries(refs)) {
const key = tracker.keysByRef.get(ref);
if (key && !tracker.duplicates.has(key)) {
delete data.nth;
}
}
let snapshot = lines.join("\n");
let truncated = false;
const maxChars =
typeof params.maxChars === "number" && Number.isFinite(params.maxChars) && params.maxChars > 0
? Math.floor(params.maxChars)
: undefined;
if (maxChars && snapshot.length > maxChars) {
snapshot = `${snapshot.slice(0, maxChars)}\n\n[...TRUNCATED - page too large]`;
truncated = true;
}
const stats = getRoleSnapshotStats(snapshot, refs);
return truncated ? { snapshot, truncated, refs, stats } : { snapshot, refs, stats };
}

View File

@@ -0,0 +1,650 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js";
import type { BrowserTab } from "./client.js";
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js";
type ChromeMcpStructuredPage = {
id: number;
url?: string;
selected?: boolean;
};
type ChromeMcpToolResult = {
structuredContent?: Record<string, unknown>;
content?: Array<Record<string, unknown>>;
isError?: boolean;
};
type ChromeMcpSession = {
client: Client;
transport: StdioClientTransport;
ready: Promise<void>;
};
type ChromeMcpSessionFactory = (
profileName: string,
userDataDir?: string,
) => Promise<ChromeMcpSession>;
const DEFAULT_CHROME_MCP_COMMAND = "npx";
const DEFAULT_CHROME_MCP_ARGS = [
"-y",
"chrome-devtools-mcp@latest",
"--autoConnect",
// Direct chrome-devtools-mcp launches do not enable structuredContent by default.
"--experimentalStructuredContent",
"--experimental-page-id-routing",
];
const sessions = new Map<string, ChromeMcpSession>();
const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
let sessionFactory: ChromeMcpSessionFactory | null = null;
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asPages(value: unknown): ChromeMcpStructuredPage[] {
if (!Array.isArray(value)) {
return [];
}
const out: ChromeMcpStructuredPage[] = [];
for (const entry of value) {
const record = asRecord(entry);
if (!record || typeof record.id !== "number") {
continue;
}
out.push({
id: record.id,
url: typeof record.url === "string" ? record.url : undefined,
selected: record.selected === true,
});
}
return out;
}
function parsePageId(targetId: string): number {
const parsed = Number.parseInt(targetId.trim(), 10);
if (!Number.isFinite(parsed)) {
throw new BrowserTabNotFoundError();
}
return parsed;
}
function toBrowserTabs(pages: ChromeMcpStructuredPage[]): BrowserTab[] {
return pages.map((page) => ({
targetId: String(page.id),
title: "",
url: page.url ?? "",
type: "page",
}));
}
function extractStructuredContent(result: ChromeMcpToolResult): Record<string, unknown> {
return asRecord(result.structuredContent) ?? {};
}
function extractTextContent(result: ChromeMcpToolResult): string[] {
const content = Array.isArray(result.content) ? result.content : [];
return content
.map((entry) => {
const record = asRecord(entry);
return record && typeof record.text === "string" ? record.text : "";
})
.filter(Boolean);
}
function extractTextPages(result: ChromeMcpToolResult): ChromeMcpStructuredPage[] {
const pages: ChromeMcpStructuredPage[] = [];
for (const block of extractTextContent(result)) {
for (const line of block.split(/\r?\n/)) {
const match = line.match(/^\s*(\d+):\s+(.+?)(?:\s+\[(selected)\])?\s*$/i);
if (!match) {
continue;
}
pages.push({
id: Number.parseInt(match[1] ?? "", 10),
url: match[2]?.trim() || undefined,
selected: Boolean(match[3]),
});
}
}
return pages;
}
function extractStructuredPages(result: ChromeMcpToolResult): ChromeMcpStructuredPage[] {
const structured = asPages(extractStructuredContent(result).pages);
return structured.length > 0 ? structured : extractTextPages(result);
}
function extractSnapshot(result: ChromeMcpToolResult): ChromeMcpSnapshotNode {
const structured = extractStructuredContent(result);
const snapshot = asRecord(structured.snapshot);
if (!snapshot) {
throw new Error("Chrome MCP snapshot response was missing structured snapshot data.");
}
return snapshot as unknown as ChromeMcpSnapshotNode;
}
function extractJsonBlock(text: string): unknown {
const match = text.match(/```json\s*([\s\S]*?)\s*```/i);
const raw = match?.[1]?.trim() || text.trim();
return raw ? JSON.parse(raw) : null;
}
function extractMessageText(result: ChromeMcpToolResult): string {
const message = extractStructuredContent(result).message;
if (typeof message === "string" && message.trim()) {
return message;
}
const blocks = extractTextContent(result);
return blocks.find((block) => block.trim()) ?? "";
}
function extractToolErrorMessage(result: ChromeMcpToolResult, name: string): string {
const message = extractMessageText(result).trim();
return message || `Chrome MCP tool "${name}" failed.`;
}
function extractJsonMessage(result: ChromeMcpToolResult): unknown {
const candidates = [extractMessageText(result), ...extractTextContent(result)].filter((text) =>
text.trim(),
);
let lastError: unknown;
for (const candidate of candidates) {
try {
return extractJsonBlock(candidate);
} catch (err) {
lastError = err;
}
}
if (lastError) {
throw lastError;
}
return null;
}
function normalizeChromeMcpUserDataDir(userDataDir?: string): string | undefined {
const trimmed = userDataDir?.trim();
return trimmed ? trimmed : undefined;
}
function buildChromeMcpSessionCacheKey(profileName: string, userDataDir?: string): string {
return JSON.stringify([profileName, normalizeChromeMcpUserDataDir(userDataDir) ?? ""]);
}
function cacheKeyMatchesProfileName(cacheKey: string, profileName: string): boolean {
try {
const parsed = JSON.parse(cacheKey);
return Array.isArray(parsed) && parsed[0] === profileName;
} catch {
return false;
}
}
async function closeChromeMcpSessionsForProfile(
profileName: string,
keepKey?: string,
): Promise<boolean> {
let closed = false;
for (const key of Array.from(pendingSessions.keys())) {
if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) {
pendingSessions.delete(key);
closed = true;
}
}
for (const [key, session] of Array.from(sessions.entries())) {
if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) {
sessions.delete(key);
closed = true;
await session.client.close().catch(() => {});
}
}
return closed;
}
export function buildChromeMcpArgs(userDataDir?: string): string[] {
const normalizedUserDataDir = normalizeChromeMcpUserDataDir(userDataDir);
return normalizedUserDataDir
? [...DEFAULT_CHROME_MCP_ARGS, "--userDataDir", normalizedUserDataDir]
: [...DEFAULT_CHROME_MCP_ARGS];
}
async function createRealSession(
profileName: string,
userDataDir?: string,
): Promise<ChromeMcpSession> {
const transport = new StdioClientTransport({
command: DEFAULT_CHROME_MCP_COMMAND,
args: buildChromeMcpArgs(userDataDir),
stderr: "pipe",
});
const client = new Client(
{
name: "openclaw-browser",
version: "0.0.0",
},
{},
);
const ready = (async () => {
try {
await client.connect(transport);
const tools = await client.listTools();
if (!tools.tools.some((tool) => tool.name === "list_pages")) {
throw new Error("Chrome MCP server did not expose the expected navigation tools.");
}
} catch (err) {
await client.close().catch(() => {});
const targetLabel = userDataDir
? `the configured Chromium user data dir (${userDataDir})`
: "Google Chrome's default profile";
throw new BrowserProfileUnavailableError(
`Chrome MCP existing-session attach failed for profile "${profileName}". ` +
`Make sure ${targetLabel} is running locally with remote debugging enabled. ` +
`Details: ${String(err)}`,
);
}
})();
return {
client,
transport,
ready,
};
}
async function getSession(profileName: string, userDataDir?: string): Promise<ChromeMcpSession> {
const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir);
await closeChromeMcpSessionsForProfile(profileName, cacheKey);
let session = sessions.get(cacheKey);
if (session && session.transport.pid === null) {
sessions.delete(cacheKey);
session = undefined;
}
if (!session) {
let pending = pendingSessions.get(cacheKey);
if (!pending) {
pending = (async () => {
const created = await (sessionFactory ?? createRealSession)(profileName, userDataDir);
if (pendingSessions.get(cacheKey) === pending) {
sessions.set(cacheKey, created);
} else {
await created.client.close().catch(() => {});
}
return created;
})();
pendingSessions.set(cacheKey, pending);
}
try {
session = await pending;
} finally {
if (pendingSessions.get(cacheKey) === pending) {
pendingSessions.delete(cacheKey);
}
}
}
try {
await session.ready;
return session;
} catch (err) {
const current = sessions.get(cacheKey);
if (current?.transport === session.transport) {
sessions.delete(cacheKey);
}
throw err;
}
}
async function callTool(
profileName: string,
userDataDir: string | undefined,
name: string,
args: Record<string, unknown> = {},
): Promise<ChromeMcpToolResult> {
const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir);
const session = await getSession(profileName, userDataDir);
let result: ChromeMcpToolResult;
try {
result = (await session.client.callTool({
name,
arguments: args,
})) as ChromeMcpToolResult;
} catch (err) {
// Transport/connection error — tear down session so it reconnects on next call
sessions.delete(cacheKey);
await session.client.close().catch(() => {});
throw err;
}
// Tool-level errors (element not found, script error, etc.) don't indicate a
// broken connection — don't tear down the session for these.
if (result.isError) {
throw new Error(extractToolErrorMessage(result, name));
}
return result;
}
async function withTempFile<T>(fn: (filePath: string) => Promise<T>): Promise<T> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-mcp-"));
const filePath = path.join(dir, randomUUID());
try {
return await fn(filePath);
} finally {
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
}
}
async function findPageById(
profileName: string,
pageId: number,
userDataDir?: string,
): Promise<ChromeMcpStructuredPage> {
const pages = await listChromeMcpPages(profileName, userDataDir);
const page = pages.find((entry) => entry.id === pageId);
if (!page) {
throw new BrowserTabNotFoundError();
}
return page;
}
export async function ensureChromeMcpAvailable(
profileName: string,
userDataDir?: string,
): Promise<void> {
await getSession(profileName, userDataDir);
}
export function getChromeMcpPid(profileName: string): number | null {
for (const [key, session] of sessions.entries()) {
if (cacheKeyMatchesProfileName(key, profileName)) {
return session.transport.pid ?? null;
}
}
return null;
}
export async function closeChromeMcpSession(profileName: string): Promise<boolean> {
return await closeChromeMcpSessionsForProfile(profileName);
}
export async function stopAllChromeMcpSessions(): Promise<void> {
const names = [...new Set([...sessions.keys()].map((key) => JSON.parse(key)[0] as string))];
for (const name of names) {
await closeChromeMcpSession(name).catch(() => {});
}
}
export async function listChromeMcpPages(
profileName: string,
userDataDir?: string,
): Promise<ChromeMcpStructuredPage[]> {
const result = await callTool(profileName, userDataDir, "list_pages");
return extractStructuredPages(result);
}
export async function listChromeMcpTabs(
profileName: string,
userDataDir?: string,
): Promise<BrowserTab[]> {
return toBrowserTabs(await listChromeMcpPages(profileName, userDataDir));
}
export async function openChromeMcpTab(
profileName: string,
url: string,
userDataDir?: string,
): Promise<BrowserTab> {
const result = await callTool(profileName, userDataDir, "new_page", { url });
const pages = extractStructuredPages(result);
const chosen = pages.find((page) => page.selected) ?? pages.at(-1);
if (!chosen) {
throw new Error("Chrome MCP did not return the created page.");
}
return {
targetId: String(chosen.id),
title: "",
url: chosen.url ?? url,
type: "page",
};
}
export async function focusChromeMcpTab(
profileName: string,
targetId: string,
userDataDir?: string,
): Promise<void> {
await callTool(profileName, userDataDir, "select_page", {
pageId: parsePageId(targetId),
bringToFront: true,
});
}
export async function closeChromeMcpTab(
profileName: string,
targetId: string,
userDataDir?: string,
): Promise<void> {
await callTool(profileName, userDataDir, "close_page", { pageId: parsePageId(targetId) });
}
export async function navigateChromeMcpPage(params: {
profileName: string;
userDataDir?: string;
targetId: string;
url: string;
timeoutMs?: number;
}): Promise<{ url: string }> {
await callTool(params.profileName, params.userDataDir, "navigate_page", {
pageId: parsePageId(params.targetId),
type: "url",
url: params.url,
...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}),
});
const page = await findPageById(
params.profileName,
parsePageId(params.targetId),
params.userDataDir,
);
return { url: page.url ?? params.url };
}
export async function takeChromeMcpSnapshot(params: {
profileName: string;
userDataDir?: string;
targetId: string;
}): Promise<ChromeMcpSnapshotNode> {
const result = await callTool(params.profileName, params.userDataDir, "take_snapshot", {
pageId: parsePageId(params.targetId),
});
return extractSnapshot(result);
}
export async function takeChromeMcpScreenshot(params: {
profileName: string;
userDataDir?: string;
targetId: string;
uid?: string;
fullPage?: boolean;
format?: "png" | "jpeg";
}): Promise<Buffer> {
return await withTempFile(async (filePath) => {
await callTool(params.profileName, params.userDataDir, "take_screenshot", {
pageId: parsePageId(params.targetId),
filePath,
format: params.format ?? "png",
...(params.uid ? { uid: params.uid } : {}),
...(params.fullPage ? { fullPage: true } : {}),
});
return await fs.readFile(filePath);
});
}
export async function clickChromeMcpElement(params: {
profileName: string;
userDataDir?: string;
targetId: string;
uid: string;
doubleClick?: boolean;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "click", {
pageId: parsePageId(params.targetId),
uid: params.uid,
...(params.doubleClick ? { dblClick: true } : {}),
});
}
export async function fillChromeMcpElement(params: {
profileName: string;
userDataDir?: string;
targetId: string;
uid: string;
value: string;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "fill", {
pageId: parsePageId(params.targetId),
uid: params.uid,
value: params.value,
});
}
export async function fillChromeMcpForm(params: {
profileName: string;
userDataDir?: string;
targetId: string;
elements: Array<{ uid: string; value: string }>;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "fill_form", {
pageId: parsePageId(params.targetId),
elements: params.elements,
});
}
export async function hoverChromeMcpElement(params: {
profileName: string;
userDataDir?: string;
targetId: string;
uid: string;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "hover", {
pageId: parsePageId(params.targetId),
uid: params.uid,
});
}
export async function dragChromeMcpElement(params: {
profileName: string;
userDataDir?: string;
targetId: string;
fromUid: string;
toUid: string;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "drag", {
pageId: parsePageId(params.targetId),
from_uid: params.fromUid,
to_uid: params.toUid,
});
}
export async function uploadChromeMcpFile(params: {
profileName: string;
userDataDir?: string;
targetId: string;
uid: string;
filePath: string;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "upload_file", {
pageId: parsePageId(params.targetId),
uid: params.uid,
filePath: params.filePath,
});
}
export async function pressChromeMcpKey(params: {
profileName: string;
userDataDir?: string;
targetId: string;
key: string;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "press_key", {
pageId: parsePageId(params.targetId),
key: params.key,
});
}
export async function resizeChromeMcpPage(params: {
profileName: string;
userDataDir?: string;
targetId: string;
width: number;
height: number;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "resize_page", {
pageId: parsePageId(params.targetId),
width: params.width,
height: params.height,
});
}
export async function handleChromeMcpDialog(params: {
profileName: string;
userDataDir?: string;
targetId: string;
action: "accept" | "dismiss";
promptText?: string;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "handle_dialog", {
pageId: parsePageId(params.targetId),
action: params.action,
...(params.promptText ? { promptText: params.promptText } : {}),
});
}
export async function evaluateChromeMcpScript(params: {
profileName: string;
userDataDir?: string;
targetId: string;
fn: string;
args?: string[];
}): Promise<unknown> {
const result = await callTool(params.profileName, params.userDataDir, "evaluate_script", {
pageId: parsePageId(params.targetId),
function: params.fn,
...(params.args?.length ? { args: params.args } : {}),
});
return extractJsonMessage(result);
}
export async function waitForChromeMcpText(params: {
profileName: string;
userDataDir?: string;
targetId: string;
text: string[];
timeoutMs?: number;
}): Promise<void> {
await callTool(params.profileName, params.userDataDir, "wait_for", {
pageId: parsePageId(params.targetId),
text: params.text,
...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}),
});
}
export function setChromeMcpSessionFactoryForTest(factory: ChromeMcpSessionFactory | null): void {
sessionFactory = factory;
}
export async function resetChromeMcpSessionsForTest(): Promise<void> {
sessionFactory = null;
pendingSessions.clear();
await stopAllChromeMcpSessions();
}

View File

@@ -0,0 +1,18 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll } from "vitest";
type ChromeUserDataDirRef = {
dir: string;
};
export function installChromeUserDataDirHooks(chromeUserDataDir: ChromeUserDataDirRef): void {
beforeAll(async () => {
chromeUserDataDir.dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-user-data-"));
});
afterAll(async () => {
await fs.rm(chromeUserDataDir.dir, { recursive: true, force: true });
});
}

View File

@@ -0,0 +1,721 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ResolvedBrowserConfig } from "./config.js";
export type BrowserExecutable = {
kind: "brave" | "canary" | "chromium" | "chrome" | "custom" | "edge";
path: string;
};
const CHROME_VERSION_RE = /(\d+)(?:\.\d+){0,3}/;
const CHROMIUM_BUNDLE_IDS = new Set([
"com.google.Chrome",
"com.google.Chrome.beta",
"com.google.Chrome.canary",
"com.google.Chrome.dev",
"com.brave.Browser",
"com.brave.Browser.beta",
"com.brave.Browser.nightly",
"com.microsoft.Edge",
"com.microsoft.EdgeBeta",
"com.microsoft.EdgeDev",
"com.microsoft.EdgeCanary",
// Edge LaunchServices IDs (used in macOS default browser registration —
// these differ from CFBundleIdentifier and are what plutil returns)
"com.microsoft.edgemac",
"com.microsoft.edgemac.beta",
"com.microsoft.edgemac.dev",
"com.microsoft.edgemac.canary",
"org.chromium.Chromium",
"com.vivaldi.Vivaldi",
"com.operasoftware.Opera",
"com.operasoftware.OperaGX",
"com.yandex.desktop.yandex-browser",
"company.thebrowser.Browser", // Arc
]);
const CHROMIUM_DESKTOP_IDS = new Set([
"google-chrome.desktop",
"google-chrome-beta.desktop",
"google-chrome-unstable.desktop",
"brave-browser.desktop",
"microsoft-edge.desktop",
"microsoft-edge-beta.desktop",
"microsoft-edge-dev.desktop",
"microsoft-edge-canary.desktop",
"chromium.desktop",
"chromium-browser.desktop",
"vivaldi.desktop",
"vivaldi-stable.desktop",
"opera.desktop",
"opera-gx.desktop",
"yandex-browser.desktop",
"org.chromium.Chromium.desktop",
]);
const CHROMIUM_EXE_NAMES = new Set([
"chrome.exe",
"msedge.exe",
"brave.exe",
"brave-browser.exe",
"chromium.exe",
"vivaldi.exe",
"opera.exe",
"launcher.exe",
"yandex.exe",
"yandexbrowser.exe",
// mac/linux names
"google chrome",
"google chrome canary",
"brave browser",
"microsoft edge",
"chromium",
"chrome",
"brave",
"msedge",
"brave-browser",
"google-chrome",
"google-chrome-stable",
"google-chrome-beta",
"google-chrome-unstable",
"microsoft-edge",
"microsoft-edge-beta",
"microsoft-edge-dev",
"microsoft-edge-canary",
"chromium-browser",
"vivaldi",
"vivaldi-stable",
"opera",
"opera-stable",
"opera-gx",
"yandex-browser",
]);
function exists(filePath: string) {
try {
return fs.existsSync(filePath);
} catch {
return false;
}
}
function execText(
command: string,
args: string[],
timeoutMs = 1200,
maxBuffer = 1024 * 1024,
): string | null {
try {
const output = execFileSync(command, args, {
timeout: timeoutMs,
encoding: "utf8",
maxBuffer,
});
return String(output ?? "").trim() || null;
} catch {
return null;
}
}
function inferKindFromIdentifier(identifier: string): BrowserExecutable["kind"] {
const id = identifier.toLowerCase();
if (id.includes("brave")) {
return "brave";
}
if (id.includes("edge")) {
return "edge";
}
if (id.includes("chromium")) {
return "chromium";
}
if (id.includes("canary")) {
return "canary";
}
if (
id.includes("opera") ||
id.includes("vivaldi") ||
id.includes("yandex") ||
id.includes("thebrowser")
) {
return "chromium";
}
return "chrome";
}
function inferKindFromExecutableName(name: string): BrowserExecutable["kind"] {
const lower = name.toLowerCase();
if (lower.includes("brave")) {
return "brave";
}
if (lower.includes("edge") || lower.includes("msedge")) {
return "edge";
}
if (lower.includes("chromium")) {
return "chromium";
}
if (lower.includes("canary") || lower.includes("sxs")) {
return "canary";
}
if (lower.includes("opera") || lower.includes("vivaldi") || lower.includes("yandex")) {
return "chromium";
}
return "chrome";
}
function detectDefaultChromiumExecutable(platform: NodeJS.Platform): BrowserExecutable | null {
if (platform === "darwin") {
return detectDefaultChromiumExecutableMac();
}
if (platform === "linux") {
return detectDefaultChromiumExecutableLinux();
}
if (platform === "win32") {
return detectDefaultChromiumExecutableWindows();
}
return null;
}
function detectDefaultChromiumExecutableMac(): BrowserExecutable | null {
const bundleId = detectDefaultBrowserBundleIdMac();
if (!bundleId || !CHROMIUM_BUNDLE_IDS.has(bundleId)) {
return null;
}
const appPathRaw = execText("/usr/bin/osascript", [
"-e",
`POSIX path of (path to application id "${bundleId}")`,
]);
if (!appPathRaw) {
return null;
}
const appPath = appPathRaw.trim().replace(/\/$/, "");
const exeName = execText("/usr/bin/defaults", [
"read",
path.join(appPath, "Contents", "Info"),
"CFBundleExecutable",
]);
if (!exeName) {
return null;
}
const exePath = path.join(appPath, "Contents", "MacOS", exeName.trim());
if (!exists(exePath)) {
return null;
}
return { kind: inferKindFromIdentifier(bundleId), path: exePath };
}
function detectDefaultBrowserBundleIdMac(): string | null {
const plistPath = path.join(
os.homedir(),
"Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist",
);
if (!exists(plistPath)) {
return null;
}
const handlersRaw = execText(
"/usr/bin/plutil",
["-extract", "LSHandlers", "json", "-o", "-", "--", plistPath],
2000,
5 * 1024 * 1024,
);
if (!handlersRaw) {
return null;
}
let handlers: unknown;
try {
handlers = JSON.parse(handlersRaw);
} catch {
return null;
}
if (!Array.isArray(handlers)) {
return null;
}
const resolveScheme = (scheme: string) => {
let candidate: string | null = null;
for (const entry of handlers) {
if (!entry || typeof entry !== "object") {
continue;
}
const record = entry as Record<string, unknown>;
if (record.LSHandlerURLScheme !== scheme) {
continue;
}
const role =
(typeof record.LSHandlerRoleAll === "string" && record.LSHandlerRoleAll) ||
(typeof record.LSHandlerRoleViewer === "string" && record.LSHandlerRoleViewer) ||
null;
if (role) {
candidate = role;
}
}
return candidate;
};
return resolveScheme("http") ?? resolveScheme("https");
}
function detectDefaultChromiumExecutableLinux(): BrowserExecutable | null {
const desktopId =
execText("xdg-settings", ["get", "default-web-browser"]) ||
execText("xdg-mime", ["query", "default", "x-scheme-handler/http"]);
if (!desktopId) {
return null;
}
const trimmed = desktopId.trim();
if (!CHROMIUM_DESKTOP_IDS.has(trimmed)) {
return null;
}
const desktopPath = findDesktopFilePath(trimmed);
if (!desktopPath) {
return null;
}
const execLine = readDesktopExecLine(desktopPath);
if (!execLine) {
return null;
}
const command = extractExecutableFromExecLine(execLine);
if (!command) {
return null;
}
const resolved = resolveLinuxExecutablePath(command);
if (!resolved) {
return null;
}
const exeName = path.posix.basename(resolved).toLowerCase();
if (!CHROMIUM_EXE_NAMES.has(exeName)) {
return null;
}
return { kind: inferKindFromExecutableName(exeName), path: resolved };
}
function detectDefaultChromiumExecutableWindows(): BrowserExecutable | null {
const progId = readWindowsProgId();
const command =
(progId ? readWindowsCommandForProgId(progId) : null) || readWindowsCommandForProgId("http");
if (!command) {
return null;
}
const expanded = expandWindowsEnvVars(command);
const exePath = extractWindowsExecutablePath(expanded);
if (!exePath) {
return null;
}
if (!exists(exePath)) {
return null;
}
const exeName = path.win32.basename(exePath).toLowerCase();
if (!CHROMIUM_EXE_NAMES.has(exeName)) {
return null;
}
return { kind: inferKindFromExecutableName(exeName), path: exePath };
}
function findDesktopFilePath(desktopId: string): string | null {
const candidates = [
path.join(os.homedir(), ".local", "share", "applications", desktopId),
path.join("/usr/local/share/applications", desktopId),
path.join("/usr/share/applications", desktopId),
path.join("/var/lib/snapd/desktop/applications", desktopId),
];
for (const candidate of candidates) {
if (exists(candidate)) {
return candidate;
}
}
return null;
}
function readDesktopExecLine(desktopPath: string): string | null {
try {
const raw = fs.readFileSync(desktopPath, "utf8");
const lines = raw.split(/\r?\n/);
for (const line of lines) {
if (line.startsWith("Exec=")) {
return line.slice("Exec=".length).trim();
}
}
} catch {
// ignore
}
return null;
}
function extractExecutableFromExecLine(execLine: string): string | null {
const tokens = splitExecLine(execLine);
for (const token of tokens) {
if (!token) {
continue;
}
if (token === "env") {
continue;
}
if (token.includes("=") && !token.startsWith("/") && !token.includes("\\")) {
continue;
}
return token.replace(/^["']|["']$/g, "");
}
return null;
}
function splitExecLine(line: string): string[] {
const tokens: string[] = [];
let current = "";
let inQuotes = false;
let quoteChar = "";
for (let i = 0; i < line.length; i += 1) {
const ch = line[i];
if ((ch === '"' || ch === "'") && (!inQuotes || ch === quoteChar)) {
if (inQuotes) {
inQuotes = false;
quoteChar = "";
} else {
inQuotes = true;
quoteChar = ch;
}
continue;
}
if (!inQuotes && /\s/.test(ch)) {
if (current) {
tokens.push(current);
current = "";
}
continue;
}
current += ch;
}
if (current) {
tokens.push(current);
}
return tokens;
}
function resolveLinuxExecutablePath(command: string): string | null {
const cleaned = command.trim().replace(/%[a-zA-Z]/g, "");
if (!cleaned) {
return null;
}
if (cleaned.startsWith("/")) {
return cleaned;
}
const resolved = execText("which", [cleaned], 800);
return resolved ? resolved.trim() : null;
}
function readWindowsProgId(): string | null {
const output = execText("reg", [
"query",
"HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice",
"/v",
"ProgId",
]);
if (!output) {
return null;
}
const match = output.match(/ProgId\s+REG_\w+\s+(.+)$/im);
return match?.[1]?.trim() || null;
}
function readWindowsCommandForProgId(progId: string): string | null {
const key =
progId === "http"
? "HKCR\\http\\shell\\open\\command"
: `HKCR\\${progId}\\shell\\open\\command`;
const output = execText("reg", ["query", key, "/ve"]);
if (!output) {
return null;
}
const match = output.match(/REG_\w+\s+(.+)$/im);
return match?.[1]?.trim() || null;
}
function expandWindowsEnvVars(value: string): string {
return value.replace(/%([^%]+)%/g, (_match, name) => {
const key = String(name ?? "").trim();
return key ? (process.env[key] ?? `%${key}%`) : _match;
});
}
function extractWindowsExecutablePath(command: string): string | null {
const quoted = command.match(/"([^"]+\\.exe)"/i);
if (quoted?.[1]) {
return quoted[1];
}
const unquoted = command.match(/([^\\s]+\\.exe)/i);
if (unquoted?.[1]) {
return unquoted[1];
}
return null;
}
function findFirstExecutable(candidates: Array<BrowserExecutable>): BrowserExecutable | null {
for (const candidate of candidates) {
if (exists(candidate.path)) {
return candidate;
}
}
return null;
}
function findFirstChromeExecutable(candidates: string[]): BrowserExecutable | null {
for (const candidate of candidates) {
if (exists(candidate)) {
return {
kind:
candidate.toLowerCase().includes("sxs") || candidate.toLowerCase().includes("canary")
? "canary"
: "chrome",
path: candidate,
};
}
}
return null;
}
export function findChromeExecutableMac(): BrowserExecutable | null {
const candidates: Array<BrowserExecutable> = [
{
kind: "chrome",
path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
},
{
kind: "chrome",
path: path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
},
{
kind: "brave",
path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
},
{
kind: "brave",
path: path.join(os.homedir(), "Applications/Brave Browser.app/Contents/MacOS/Brave Browser"),
},
{
kind: "edge",
path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
},
{
kind: "edge",
path: path.join(
os.homedir(),
"Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
),
},
{
kind: "chromium",
path: "/Applications/Chromium.app/Contents/MacOS/Chromium",
},
{
kind: "chromium",
path: path.join(os.homedir(), "Applications/Chromium.app/Contents/MacOS/Chromium"),
},
{
kind: "canary",
path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
},
{
kind: "canary",
path: path.join(
os.homedir(),
"Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
),
},
];
return findFirstExecutable(candidates);
}
export function findGoogleChromeExecutableMac(): BrowserExecutable | null {
return findFirstChromeExecutable([
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
path.join(
os.homedir(),
"Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
),
]);
}
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: "chrome", path: "/usr/bin/chrome" },
{ kind: "brave", path: "/usr/bin/brave-browser" },
{ kind: "brave", path: "/usr/bin/brave-browser-stable" },
{ kind: "brave", path: "/usr/bin/brave" },
{ kind: "brave", path: "/snap/bin/brave" },
{ kind: "edge", path: "/usr/bin/microsoft-edge" },
{ kind: "edge", path: "/usr/bin/microsoft-edge-stable" },
{ kind: "chromium", path: "/usr/bin/chromium" },
{ kind: "chromium", path: "/usr/bin/chromium-browser" },
{ kind: "chromium", path: "/snap/bin/chromium" },
];
return findFirstExecutable(candidates);
}
export function findGoogleChromeExecutableLinux(): BrowserExecutable | null {
return findFirstChromeExecutable([
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome-beta",
"/usr/bin/google-chrome-unstable",
"/snap/bin/google-chrome",
]);
}
export function findChromeExecutableWindows(): BrowserExecutable | null {
const localAppData = process.env.LOCALAPPDATA ?? "";
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
// Must use bracket notation: variable name contains parentheses
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
const joinWin = path.win32.join;
const candidates: Array<BrowserExecutable> = [];
if (localAppData) {
// Chrome (user install)
candidates.push({
kind: "chrome",
path: joinWin(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
});
// Brave (user install)
candidates.push({
kind: "brave",
path: joinWin(localAppData, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
});
// Edge (user install)
candidates.push({
kind: "edge",
path: joinWin(localAppData, "Microsoft", "Edge", "Application", "msedge.exe"),
});
// Chromium (user install)
candidates.push({
kind: "chromium",
path: joinWin(localAppData, "Chromium", "Application", "chrome.exe"),
});
// Chrome Canary (user install)
candidates.push({
kind: "canary",
path: joinWin(localAppData, "Google", "Chrome SxS", "Application", "chrome.exe"),
});
}
// Chrome (system install, 64-bit)
candidates.push({
kind: "chrome",
path: joinWin(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
});
// Chrome (system install, 32-bit on 64-bit Windows)
candidates.push({
kind: "chrome",
path: joinWin(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
});
// Brave (system install, 64-bit)
candidates.push({
kind: "brave",
path: joinWin(programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
});
// Brave (system install, 32-bit on 64-bit Windows)
candidates.push({
kind: "brave",
path: joinWin(programFilesX86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
});
// Edge (system install, 64-bit)
candidates.push({
kind: "edge",
path: joinWin(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
});
// Edge (system install, 32-bit on 64-bit Windows)
candidates.push({
kind: "edge",
path: joinWin(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
});
return findFirstExecutable(candidates);
}
export function findGoogleChromeExecutableWindows(): BrowserExecutable | null {
const localAppData = process.env.LOCALAPPDATA ?? "";
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
const joinWin = path.win32.join;
const candidates: string[] = [];
if (localAppData) {
candidates.push(joinWin(localAppData, "Google", "Chrome", "Application", "chrome.exe"));
candidates.push(joinWin(localAppData, "Google", "Chrome SxS", "Application", "chrome.exe"));
}
candidates.push(joinWin(programFiles, "Google", "Chrome", "Application", "chrome.exe"));
candidates.push(joinWin(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"));
return findFirstChromeExecutable(candidates);
}
export function resolveGoogleChromeExecutableForPlatform(
platform: NodeJS.Platform,
): BrowserExecutable | null {
if (platform === "darwin") {
return findGoogleChromeExecutableMac();
}
if (platform === "linux") {
return findGoogleChromeExecutableLinux();
}
if (platform === "win32") {
return findGoogleChromeExecutableWindows();
}
return null;
}
export function readBrowserVersion(executablePath: string): string | null {
const output = execText(executablePath, ["--version"], 2000);
if (!output) {
return null;
}
return output.replace(/\s+/g, " ").trim();
}
export function parseBrowserMajorVersion(rawVersion: string | null | undefined): number | null {
const match = String(rawVersion ?? "").match(CHROME_VERSION_RE);
if (!match?.[1]) {
return null;
}
const major = Number.parseInt(match[1], 10);
return Number.isFinite(major) ? major : null;
}
export function resolveBrowserExecutableForPlatform(
resolved: ResolvedBrowserConfig,
platform: NodeJS.Platform,
): BrowserExecutable | null {
if (resolved.executablePath) {
if (!exists(resolved.executablePath)) {
throw new Error(`browser.executablePath not found: ${resolved.executablePath}`);
}
return { kind: "custom", path: resolved.executablePath };
}
const detected = detectDefaultChromiumExecutable(platform);
if (detected) {
return detected;
}
if (platform === "darwin") {
return findChromeExecutableMac();
}
if (platform === "linux") {
return findChromeExecutableLinux();
}
if (platform === "win32") {
return findChromeExecutableWindows();
}
return null;
}

View File

@@ -0,0 +1,198 @@
import fs from "node:fs";
import path from "node:path";
import {
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";
function decoratedMarkerPath(userDataDir: string) {
return path.join(userDataDir, ".openclaw-profile-decorated");
}
function safeReadJson(filePath: string): Record<string, unknown> | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return null;
}
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
function safeWriteJson(filePath: string, data: Record<string, unknown>) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}
function setDeep(obj: Record<string, unknown>, keys: string[], value: unknown) {
let node: Record<string, unknown> = obj;
for (const key of keys.slice(0, -1)) {
const next = node[key];
if (typeof next !== "object" || next === null || Array.isArray(next)) {
node[key] = {};
}
node = node[key] as Record<string, unknown>;
}
node[keys[keys.length - 1] ?? ""] = value;
}
function parseHexRgbToSignedArgbInt(hex: string): number | null {
const cleaned = hex.trim().replace(/^#/, "");
if (!/^[0-9a-fA-F]{6}$/.test(cleaned)) {
return null;
}
const rgb = Number.parseInt(cleaned, 16);
const argbUnsigned = (0xff << 24) | rgb;
// Chrome stores colors as signed 32-bit ints (SkColor).
return argbUnsigned > 0x7fffffff ? argbUnsigned - 0x1_0000_0000 : argbUnsigned;
}
export function isProfileDecorated(
userDataDir: string,
desiredName: string,
desiredColorHex: string,
): boolean {
const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColorHex);
const localStatePath = path.join(userDataDir, "Local State");
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
const localState = safeReadJson(localStatePath);
const profile = localState?.profile;
const infoCache =
typeof profile === "object" && profile !== null && !Array.isArray(profile)
? (profile as Record<string, unknown>).info_cache
: null;
const info =
typeof infoCache === "object" &&
infoCache !== null &&
!Array.isArray(infoCache) &&
typeof (infoCache as Record<string, unknown>).Default === "object" &&
(infoCache as Record<string, unknown>).Default !== null &&
!Array.isArray((infoCache as Record<string, unknown>).Default)
? ((infoCache as Record<string, unknown>).Default as Record<string, unknown>)
: null;
const prefs = safeReadJson(preferencesPath);
const browserTheme = (() => {
const browser = prefs?.browser;
const theme =
typeof browser === "object" && browser !== null && !Array.isArray(browser)
? (browser as Record<string, unknown>).theme
: null;
return typeof theme === "object" && theme !== null && !Array.isArray(theme)
? (theme as Record<string, unknown>)
: null;
})();
const autogeneratedTheme = (() => {
const autogenerated = prefs?.autogenerated;
const theme =
typeof autogenerated === "object" && autogenerated !== null && !Array.isArray(autogenerated)
? (autogenerated as Record<string, unknown>).theme
: null;
return typeof theme === "object" && theme !== null && !Array.isArray(theme)
? (theme as Record<string, unknown>)
: null;
})();
const nameOk = typeof info?.name === "string" ? info.name === desiredName : true;
if (desiredColorInt == null) {
// If the user provided a non-#RRGGBB value, we can only do best-effort.
return nameOk;
}
const localSeedOk =
typeof info?.profile_color_seed === "number"
? info.profile_color_seed === desiredColorInt
: false;
const prefOk =
(typeof browserTheme?.user_color2 === "number" &&
browserTheme.user_color2 === desiredColorInt) ||
(typeof autogeneratedTheme?.color === "number" && autogeneratedTheme.color === desiredColorInt);
return nameOk && localSeedOk && prefOk;
}
/**
* Best-effort profile decoration (name + lobster-orange). Chrome preference keys
* vary by version; we keep this conservative and idempotent.
*/
export function decorateOpenClawProfile(
userDataDir: string,
opts?: { name?: string; color?: string },
) {
const desiredName = opts?.name ?? DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME;
const desiredColor = (opts?.color ?? DEFAULT_OPENCLAW_BROWSER_COLOR).toUpperCase();
const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColor);
const localStatePath = path.join(userDataDir, "Local State");
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
const localState = safeReadJson(localStatePath) ?? {};
// Common-ish shape: profile.info_cache.Default
setDeep(localState, ["profile", "info_cache", "Default", "name"], desiredName);
setDeep(localState, ["profile", "info_cache", "Default", "shortcut_name"], desiredName);
setDeep(localState, ["profile", "info_cache", "Default", "user_name"], desiredName);
// Color keys are best-effort (Chrome changes these frequently).
setDeep(localState, ["profile", "info_cache", "Default", "profile_color"], desiredColor);
setDeep(localState, ["profile", "info_cache", "Default", "user_color"], desiredColor);
if (desiredColorInt != null) {
// These are the fields Chrome actually uses for profile/avatar tinting.
setDeep(
localState,
["profile", "info_cache", "Default", "profile_color_seed"],
desiredColorInt,
);
setDeep(
localState,
["profile", "info_cache", "Default", "profile_highlight_color"],
desiredColorInt,
);
setDeep(
localState,
["profile", "info_cache", "Default", "default_avatar_fill_color"],
desiredColorInt,
);
setDeep(
localState,
["profile", "info_cache", "Default", "default_avatar_stroke_color"],
desiredColorInt,
);
}
safeWriteJson(localStatePath, localState);
const prefs = safeReadJson(preferencesPath) ?? {};
setDeep(prefs, ["profile", "name"], desiredName);
setDeep(prefs, ["profile", "profile_color"], desiredColor);
setDeep(prefs, ["profile", "user_color"], desiredColor);
if (desiredColorInt != null) {
// Chrome refresh stores the autogenerated theme in these prefs (SkColor ints).
setDeep(prefs, ["autogenerated", "theme", "color"], desiredColorInt);
// User-selected browser theme color (pref name: browser.theme.user_color2).
setDeep(prefs, ["browser", "theme", "user_color2"], desiredColorInt);
}
safeWriteJson(preferencesPath, prefs);
try {
fs.writeFileSync(decoratedMarkerPath(userDataDir), `${Date.now()}\n`, "utf-8");
} catch {
// ignore
}
}
export function ensureProfileCleanExit(userDataDir: string) {
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
const prefs = safeReadJson(preferencesPath) ?? {};
setDeep(prefs, ["exit_type"], "Normal");
setDeep(prefs, ["exited_cleanly"], true);
safeWriteJson(preferencesPath, prefs);
}

View File

@@ -0,0 +1,477 @@
import { type ChildProcess, type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { ensurePortAvailable } from "../infra/ports.js";
import { rawDataToString } from "../infra/ws.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { CONFIG_DIR } from "../utils.js";
import {
CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS,
CHROME_BOOTSTRAP_PREFS_TIMEOUT_MS,
CHROME_LAUNCH_READY_POLL_MS,
CHROME_LAUNCH_READY_WINDOW_MS,
CHROME_REACHABILITY_TIMEOUT_MS,
CHROME_STDERR_HINT_MAX_CHARS,
CHROME_STOP_PROBE_TIMEOUT_MS,
CHROME_STOP_TIMEOUT_MS,
CHROME_WS_READY_TIMEOUT_MS,
} from "./cdp-timeouts.js";
import {
appendCdpPath,
assertCdpEndpointAllowed,
fetchCdpChecked,
isWebSocketUrl,
openCdpWebSocket,
} from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import {
type BrowserExecutable,
resolveBrowserExecutableForPlatform,
} from "./chrome.executables.js";
import {
decorateOpenClawProfile,
ensureProfileCleanExit,
isProfileDecorated,
} from "./chrome.profile-decoration.js";
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
import {
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";
const log = createSubsystemLogger("browser").child("chrome");
export type { BrowserExecutable } from "./chrome.executables.js";
export {
findChromeExecutableLinux,
findChromeExecutableMac,
findChromeExecutableWindows,
resolveBrowserExecutableForPlatform,
} from "./chrome.executables.js";
export {
decorateOpenClawProfile,
ensureProfileCleanExit,
isProfileDecorated,
} from "./chrome.profile-decoration.js";
function exists(filePath: string) {
try {
return fs.existsSync(filePath);
} catch {
return false;
}
}
export type RunningChrome = {
pid: number;
exe: BrowserExecutable;
userDataDir: string;
cdpPort: number;
startedAt: number;
proc: ChildProcess;
};
function resolveBrowserExecutable(resolved: ResolvedBrowserConfig): BrowserExecutable | null {
return resolveBrowserExecutableForPlatform(resolved, process.platform);
}
export function resolveOpenClawUserDataDir(profileName = DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME) {
return path.join(CONFIG_DIR, "browser", profileName, "user-data");
}
function cdpUrlForPort(cdpPort: number) {
return `http://127.0.0.1:${cdpPort}`;
}
export function buildOpenClawChromeLaunchArgs(params: {
resolved: ResolvedBrowserConfig;
profile: ResolvedBrowserProfile;
userDataDir: string;
}): string[] {
const { resolved, profile, userDataDir } = params;
const args: string[] = [
`--remote-debugging-port=${profile.cdpPort}`,
`--user-data-dir=${userDataDir}`,
"--no-first-run",
"--no-default-browser-check",
"--disable-sync",
"--disable-background-networking",
"--disable-component-update",
"--disable-features=Translate,MediaRouter",
"--disable-session-crashed-bubble",
"--hide-crash-restore-bubble",
"--password-store=basic",
];
if (resolved.headless) {
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");
}
if (resolved.extraArgs.length > 0) {
args.push(...resolved.extraArgs);
}
return args;
}
async function canOpenWebSocket(url: string, timeoutMs: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs });
ws.once("open", () => {
try {
ws.close();
} catch {
// ignore
}
resolve(true);
});
ws.once("error", () => resolve(false));
});
}
export async function isChromeReachable(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
ssrfPolicy?: SsrFPolicy,
): Promise<boolean> {
try {
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
if (isWebSocketUrl(cdpUrl)) {
// Direct WebSocket endpoint — probe via WS handshake.
return await canOpenWebSocket(cdpUrl, timeoutMs);
}
const version = await fetchChromeVersion(cdpUrl, timeoutMs, ssrfPolicy);
return Boolean(version);
} catch {
return false;
}
}
type ChromeVersion = {
webSocketDebuggerUrl?: string;
Browser?: string;
"User-Agent"?: string;
};
async function fetchChromeVersion(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
ssrfPolicy?: SsrFPolicy,
): Promise<ChromeVersion | null> {
const ctrl = new AbortController();
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
try {
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
const res = await fetchCdpChecked(versionUrl, timeoutMs, { signal: ctrl.signal });
const data = (await res.json()) as ChromeVersion;
if (!data || typeof data !== "object") {
return null;
}
return data;
} catch {
return null;
} finally {
clearTimeout(t);
}
}
export async function getChromeWebSocketUrl(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
ssrfPolicy?: SsrFPolicy,
): Promise<string | null> {
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
if (isWebSocketUrl(cdpUrl)) {
// Direct WebSocket endpoint — the cdpUrl is already the WebSocket URL.
return cdpUrl;
}
const version = await fetchChromeVersion(cdpUrl, timeoutMs, ssrfPolicy);
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
if (!wsUrl) {
return null;
}
return normalizeCdpWsUrl(wsUrl, cdpUrl);
}
async function canRunCdpHealthCommand(
wsUrl: string,
timeoutMs = CHROME_WS_READY_TIMEOUT_MS,
): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const ws = openCdpWebSocket(wsUrl, {
handshakeTimeoutMs: timeoutMs,
});
let settled = false;
const onMessage = (raw: Parameters<typeof rawDataToString>[0]) => {
if (settled) {
return;
}
let parsed: { id?: unknown; result?: unknown } | null = null;
try {
parsed = JSON.parse(rawDataToString(raw)) as { id?: unknown; result?: unknown };
} catch {
return;
}
if (parsed?.id !== 1) {
return;
}
finish(Boolean(parsed.result && typeof parsed.result === "object"));
};
const finish = (value: boolean) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
ws.off("message", onMessage);
try {
ws.close();
} catch {
// ignore
}
resolve(value);
};
const timer = setTimeout(
() => {
try {
ws.terminate();
} catch {
// ignore
}
finish(false);
},
Math.max(50, timeoutMs + 25),
);
ws.once("open", () => {
try {
ws.send(
JSON.stringify({
id: 1,
method: "Browser.getVersion",
}),
);
} catch {
finish(false);
}
});
ws.on("message", onMessage);
ws.once("error", () => {
finish(false);
});
ws.once("close", () => {
finish(false);
});
});
}
export async function isChromeCdpReady(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
handshakeTimeoutMs = CHROME_WS_READY_TIMEOUT_MS,
ssrfPolicy?: SsrFPolicy,
): Promise<boolean> {
const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs, ssrfPolicy).catch(() => null);
if (!wsUrl) {
return false;
}
return await canRunCdpHealthCommand(wsUrl, handshakeTimeoutMs);
}
export async function launchOpenClawChrome(
resolved: ResolvedBrowserConfig,
profile: ResolvedBrowserProfile,
): Promise<RunningChrome> {
if (!profile.cdpIsLoopback) {
throw new Error(`Profile "${profile.name}" is remote; cannot launch local Chrome.`);
}
await ensurePortAvailable(profile.cdpPort);
const exe = resolveBrowserExecutable(resolved);
if (!exe) {
throw new Error(
"No supported browser found (Chrome/Brave/Edge/Chromium on macOS, Linux, or Windows).",
);
}
const userDataDir = resolveOpenClawUserDataDir(profile.name);
fs.mkdirSync(userDataDir, { recursive: true });
const needsDecorate = !isProfileDecorated(
userDataDir,
profile.name,
(profile.color ?? DEFAULT_OPENCLAW_BROWSER_COLOR).toUpperCase(),
);
// First launch to create preference files if missing, then decorate and relaunch.
const spawnOnce = () => {
const args = buildOpenClawChromeLaunchArgs({
resolved,
profile,
userDataDir,
});
// stdio tuple: discard stdout to prevent buffer saturation in constrained
// environments (e.g. Docker), while keeping stderr piped for diagnostics.
// Cast to ChildProcessWithoutNullStreams so callers can use .stderr safely;
// the tuple overload resolution varies across @types/node versions.
return spawn(exe.path, args, {
stdio: ["ignore", "ignore", "pipe"],
env: {
...process.env,
// Reduce accidental sharing with the user's env.
HOME: os.homedir(),
},
}) as unknown as ChildProcessWithoutNullStreams;
};
const startedAt = Date.now();
const localStatePath = path.join(userDataDir, "Local State");
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
const needsBootstrap = !exists(localStatePath) || !exists(preferencesPath);
// If the profile doesn't exist yet, bootstrap it once so Chrome creates defaults.
// Then decorate (if needed) before the "real" run.
if (needsBootstrap) {
const bootstrap = spawnOnce();
const deadline = Date.now() + CHROME_BOOTSTRAP_PREFS_TIMEOUT_MS;
while (Date.now() < deadline) {
if (exists(localStatePath) && exists(preferencesPath)) {
break;
}
await new Promise((r) => setTimeout(r, 100));
}
try {
bootstrap.kill("SIGTERM");
} catch {
// ignore
}
const exitDeadline = Date.now() + CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS;
while (Date.now() < exitDeadline) {
if (bootstrap.exitCode != null) {
break;
}
await new Promise((r) => setTimeout(r, 50));
}
}
if (needsDecorate) {
try {
decorateOpenClawProfile(userDataDir, {
name: profile.name,
color: profile.color,
});
log.info(`🦞 openclaw browser profile decorated (${profile.color})`);
} catch (err) {
log.warn(`openclaw browser profile decoration failed: ${String(err)}`);
}
}
try {
ensureProfileCleanExit(userDataDir);
} catch (err) {
log.warn(`openclaw browser clean-exit prefs failed: ${String(err)}`);
}
const proc = spawnOnce();
// Collect stderr for diagnostics in case Chrome fails to start.
// The listener is removed on success to avoid unbounded memory growth
// from a long-lived Chrome process that emits periodic warnings.
const stderrChunks: Buffer[] = [];
const onStderr = (chunk: Buffer) => {
stderrChunks.push(chunk);
};
proc.stderr?.on("data", onStderr);
// Wait for CDP to come up.
const readyDeadline = Date.now() + CHROME_LAUNCH_READY_WINDOW_MS;
while (Date.now() < readyDeadline) {
if (await isChromeReachable(profile.cdpUrl)) {
break;
}
await new Promise((r) => setTimeout(r, CHROME_LAUNCH_READY_POLL_MS));
}
if (!(await isChromeReachable(profile.cdpUrl))) {
const stderrOutput = Buffer.concat(stderrChunks).toString("utf8").trim();
const stderrHint = stderrOutput
? `\nChrome stderr:\n${stderrOutput.slice(0, CHROME_STDERR_HINT_MAX_CHARS)}`
: "";
const sandboxHint =
process.platform === "linux" && !resolved.noSandbox
? "\nHint: If running in a container or as root, try setting browser.noSandbox: true in config."
: "";
try {
proc.kill("SIGKILL");
} catch {
// ignore
}
throw new Error(
`Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".${sandboxHint}${stderrHint}`,
);
}
// Chrome started successfully — detach the stderr listener and release the buffer.
proc.stderr?.off("data", onStderr);
stderrChunks.length = 0;
const pid = proc.pid ?? -1;
log.info(
`🦞 openclaw browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`,
);
return {
pid,
exe,
userDataDir,
cdpPort: profile.cdpPort,
startedAt,
proc,
};
}
export async function stopOpenClawChrome(
running: RunningChrome,
timeoutMs = CHROME_STOP_TIMEOUT_MS,
) {
const proc = running.proc;
if (proc.killed) {
return;
}
try {
proc.kill("SIGTERM");
} catch {
// ignore
}
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (!proc.exitCode && proc.killed) {
break;
}
if (!(await isChromeReachable(cdpUrlForPort(running.cdpPort), CHROME_STOP_PROBE_TIMEOUT_MS))) {
return;
}
await new Promise((r) => setTimeout(r, 100));
}
try {
proc.kill("SIGKILL");
} catch {
// ignore
}
}

View File

@@ -0,0 +1,279 @@
import type {
BrowserActionOk,
BrowserActionPathResult,
BrowserActionTabResult,
} from "./client-actions-types.js";
import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js";
import { fetchBrowserJson } from "./client-fetch.js";
export type BrowserFormField = {
ref: string;
type: string;
value?: string | number | boolean;
};
export type BrowserActRequest =
| {
kind: "click";
ref?: string;
selector?: string;
targetId?: string;
doubleClick?: boolean;
button?: string;
modifiers?: string[];
delayMs?: number;
timeoutMs?: number;
}
| {
kind: "type";
ref?: string;
selector?: string;
text: string;
targetId?: string;
submit?: boolean;
slowly?: boolean;
timeoutMs?: number;
}
| { kind: "press"; key: string; targetId?: string; delayMs?: number }
| {
kind: "hover";
ref?: string;
selector?: string;
targetId?: string;
timeoutMs?: number;
}
| {
kind: "scrollIntoView";
ref?: string;
selector?: string;
targetId?: string;
timeoutMs?: number;
}
| {
kind: "drag";
startRef?: string;
startSelector?: string;
endRef?: string;
endSelector?: string;
targetId?: string;
timeoutMs?: number;
}
| {
kind: "select";
ref?: string;
selector?: string;
values: string[];
targetId?: string;
timeoutMs?: number;
}
| {
kind: "fill";
fields: BrowserFormField[];
targetId?: string;
timeoutMs?: number;
}
| { kind: "resize"; width: number; height: number; targetId?: string }
| {
kind: "wait";
timeMs?: number;
text?: string;
textGone?: string;
selector?: string;
url?: string;
loadState?: "load" | "domcontentloaded" | "networkidle";
fn?: string;
targetId?: string;
timeoutMs?: number;
}
| { kind: "evaluate"; fn: string; ref?: string; targetId?: string; timeoutMs?: number }
| { kind: "close"; targetId?: string }
| {
kind: "batch";
actions: BrowserActRequest[];
targetId?: string;
stopOnError?: boolean;
};
export type BrowserActResponse = {
ok: true;
targetId: string;
url?: string;
result?: unknown;
results?: Array<{ ok: boolean; error?: string }>;
};
export type BrowserDownloadPayload = {
url: string;
suggestedFilename: string;
path: string;
};
type BrowserDownloadResult = { ok: true; targetId: string; download: BrowserDownloadPayload };
async function postDownloadRequest(
baseUrl: string | undefined,
route: "/wait/download" | "/download",
body: Record<string, unknown>,
profile?: string,
): Promise<BrowserDownloadResult> {
const q = buildProfileQuery(profile);
return await fetchBrowserJson<BrowserDownloadResult>(withBaseUrl(baseUrl, `${route}${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
timeoutMs: 20000,
});
}
export async function browserNavigate(
baseUrl: string | undefined,
opts: {
url: string;
targetId?: string;
profile?: string;
},
): Promise<BrowserActionTabResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTabResult>(withBaseUrl(baseUrl, `/navigate${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
timeoutMs: 20000,
});
}
export async function browserArmDialog(
baseUrl: string | undefined,
opts: {
accept: boolean;
promptText?: string;
targetId?: string;
timeoutMs?: number;
profile?: string;
},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/hooks/dialog${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
accept: opts.accept,
promptText: opts.promptText,
targetId: opts.targetId,
timeoutMs: opts.timeoutMs,
}),
timeoutMs: 20000,
});
}
export async function browserArmFileChooser(
baseUrl: string | undefined,
opts: {
paths: string[];
ref?: string;
inputRef?: string;
element?: string;
targetId?: string;
timeoutMs?: number;
profile?: string;
},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/hooks/file-chooser${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
paths: opts.paths,
ref: opts.ref,
inputRef: opts.inputRef,
element: opts.element,
targetId: opts.targetId,
timeoutMs: opts.timeoutMs,
}),
timeoutMs: 20000,
});
}
export async function browserWaitForDownload(
baseUrl: string | undefined,
opts: {
path?: string;
targetId?: string;
timeoutMs?: number;
profile?: string;
},
): Promise<BrowserDownloadResult> {
return await postDownloadRequest(
baseUrl,
"/wait/download",
{
targetId: opts.targetId,
path: opts.path,
timeoutMs: opts.timeoutMs,
},
opts.profile,
);
}
export async function browserDownload(
baseUrl: string | undefined,
opts: {
ref: string;
path: string;
targetId?: string;
timeoutMs?: number;
profile?: string;
},
): Promise<BrowserDownloadResult> {
return await postDownloadRequest(
baseUrl,
"/download",
{
targetId: opts.targetId,
ref: opts.ref,
path: opts.path,
timeoutMs: opts.timeoutMs,
},
opts.profile,
);
}
export async function browserAct(
baseUrl: string | undefined,
req: BrowserActRequest,
opts?: { profile?: string },
): Promise<BrowserActResponse> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserActResponse>(withBaseUrl(baseUrl, `/act${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
timeoutMs: 20000,
});
}
export async function browserScreenshotAction(
baseUrl: string | undefined,
opts: {
targetId?: string;
fullPage?: boolean;
ref?: string;
element?: string;
type?: "png" | "jpeg";
profile?: string;
},
): Promise<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/screenshot${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
fullPage: opts.fullPage,
ref: opts.ref,
element: opts.element,
type: opts.type,
}),
timeoutMs: 20000,
});
}

View File

@@ -0,0 +1,184 @@
import type { BrowserActionPathResult, BrowserActionTargetOk } from "./client-actions-types.js";
import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js";
import { fetchBrowserJson } from "./client-fetch.js";
import type {
BrowserConsoleMessage,
BrowserNetworkRequest,
BrowserPageError,
} from "./pw-session.js";
function buildQuerySuffix(params: Array<[string, string | boolean | undefined]>): string {
const query = new URLSearchParams();
for (const [key, value] of params) {
if (typeof value === "boolean") {
query.set(key, String(value));
continue;
}
if (typeof value === "string" && value.length > 0) {
query.set(key, value);
}
}
const encoded = query.toString();
return encoded.length > 0 ? `?${encoded}` : "";
}
export async function browserConsoleMessages(
baseUrl: string | undefined,
opts: { level?: string; targetId?: string; profile?: string } = {},
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string }> {
const suffix = buildQuerySuffix([
["level", opts.level],
["targetId", opts.targetId],
["profile", opts.profile],
]);
return await fetchBrowserJson<{
ok: true;
messages: BrowserConsoleMessage[];
targetId: string;
}>(withBaseUrl(baseUrl, `/console${suffix}`), { timeoutMs: 20000 });
}
export async function browserPdfSave(
baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/pdf${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
timeoutMs: 20000,
});
}
export async function browserPageErrors(
baseUrl: string | undefined,
opts: { targetId?: string; clear?: boolean; profile?: string } = {},
): Promise<{ ok: true; targetId: string; errors: BrowserPageError[] }> {
const suffix = buildQuerySuffix([
["targetId", opts.targetId],
["clear", typeof opts.clear === "boolean" ? opts.clear : undefined],
["profile", opts.profile],
]);
return await fetchBrowserJson<{
ok: true;
targetId: string;
errors: BrowserPageError[];
}>(withBaseUrl(baseUrl, `/errors${suffix}`), { timeoutMs: 20000 });
}
export async function browserRequests(
baseUrl: string | undefined,
opts: {
targetId?: string;
filter?: string;
clear?: boolean;
profile?: string;
} = {},
): Promise<{ ok: true; targetId: string; requests: BrowserNetworkRequest[] }> {
const suffix = buildQuerySuffix([
["targetId", opts.targetId],
["filter", opts.filter],
["clear", typeof opts.clear === "boolean" ? opts.clear : undefined],
["profile", opts.profile],
]);
return await fetchBrowserJson<{
ok: true;
targetId: string;
requests: BrowserNetworkRequest[];
}>(withBaseUrl(baseUrl, `/requests${suffix}`), { timeoutMs: 20000 });
}
export async function browserTraceStart(
baseUrl: string | undefined,
opts: {
targetId?: string;
screenshots?: boolean;
snapshots?: boolean;
sources?: boolean;
profile?: string;
} = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/trace/start${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
screenshots: opts.screenshots,
snapshots: opts.snapshots,
sources: opts.sources,
}),
timeoutMs: 20000,
});
}
export async function browserTraceStop(
baseUrl: string | undefined,
opts: { targetId?: string; path?: string; profile?: string } = {},
): Promise<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/trace/stop${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, path: opts.path }),
timeoutMs: 20000,
});
}
export async function browserHighlight(
baseUrl: string | undefined,
opts: { ref: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/highlight${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, ref: opts.ref }),
timeoutMs: 20000,
});
}
export async function browserResponseBody(
baseUrl: string | undefined,
opts: {
url: string;
targetId?: string;
timeoutMs?: number;
maxChars?: number;
profile?: string;
},
): Promise<{
ok: true;
targetId: string;
response: {
url: string;
status?: number;
headers?: Record<string, string>;
body: string;
truncated?: boolean;
};
}> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<{
ok: true;
targetId: string;
response: {
url: string;
status?: number;
headers?: Record<string, string>;
body: string;
truncated?: boolean;
};
}>(withBaseUrl(baseUrl, `/response/body${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
url: opts.url,
timeoutMs: opts.timeoutMs,
maxChars: opts.maxChars,
}),
timeoutMs: 20000,
});
}

View File

@@ -0,0 +1,278 @@
import type { BrowserActionOk, BrowserActionTargetOk } from "./client-actions-types.js";
import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js";
import { fetchBrowserJson } from "./client-fetch.js";
type TargetedProfileOptions = {
targetId?: string;
profile?: string;
};
type HttpCredentialsOptions = TargetedProfileOptions & {
username?: string;
password?: string;
clear?: boolean;
};
type GeolocationOptions = TargetedProfileOptions & {
latitude?: number;
longitude?: number;
accuracy?: number;
origin?: string;
clear?: boolean;
};
function buildStateQuery(params: { targetId?: string; key?: string; profile?: string }): string {
const query = new URLSearchParams();
if (params.targetId) {
query.set("targetId", params.targetId);
}
if (params.key) {
query.set("key", params.key);
}
if (params.profile) {
query.set("profile", params.profile);
}
const suffix = query.toString();
return suffix ? `?${suffix}` : "";
}
async function postProfileJson<T>(
baseUrl: string | undefined,
params: { path: string; profile?: string; body: unknown },
): Promise<T> {
const query = buildProfileQuery(params.profile);
return await fetchBrowserJson<T>(withBaseUrl(baseUrl, `${params.path}${query}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params.body),
timeoutMs: 20000,
});
}
async function postTargetedProfileJson(
baseUrl: string | undefined,
params: {
path: string;
opts: { targetId?: string; profile?: string };
body: Record<string, unknown>;
},
): Promise<BrowserActionTargetOk> {
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: params.path,
profile: params.opts.profile,
body: {
targetId: params.opts.targetId,
...params.body,
},
});
}
export async function browserCookies(
baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise<{ ok: true; targetId: string; cookies: unknown[] }> {
const suffix = buildStateQuery({ targetId: opts.targetId, profile: opts.profile });
return await fetchBrowserJson<{
ok: true;
targetId: string;
cookies: unknown[];
}>(withBaseUrl(baseUrl, `/cookies${suffix}`), { timeoutMs: 20000 });
}
export async function browserCookiesSet(
baseUrl: string | undefined,
opts: {
cookie: Record<string, unknown>;
targetId?: string;
profile?: string;
},
): Promise<BrowserActionTargetOk> {
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/cookies/set",
profile: opts.profile,
body: { targetId: opts.targetId, cookie: opts.cookie },
});
}
export async function browserCookiesClear(
baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionTargetOk> {
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/cookies/clear",
profile: opts.profile,
body: { targetId: opts.targetId },
});
}
export async function browserStorageGet(
baseUrl: string | undefined,
opts: {
kind: "local" | "session";
key?: string;
targetId?: string;
profile?: string;
},
): Promise<{ ok: true; targetId: string; values: Record<string, string> }> {
const suffix = buildStateQuery({ targetId: opts.targetId, key: opts.key, profile: opts.profile });
return await fetchBrowserJson<{
ok: true;
targetId: string;
values: Record<string, string>;
}>(withBaseUrl(baseUrl, `/storage/${opts.kind}${suffix}`), { timeoutMs: 20000 });
}
export async function browserStorageSet(
baseUrl: string | undefined,
opts: {
kind: "local" | "session";
key: string;
value: string;
targetId?: string;
profile?: string;
},
): Promise<BrowserActionTargetOk> {
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: `/storage/${opts.kind}/set`,
profile: opts.profile,
body: {
targetId: opts.targetId,
key: opts.key,
value: opts.value,
},
});
}
export async function browserStorageClear(
baseUrl: string | undefined,
opts: { kind: "local" | "session"; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: `/storage/${opts.kind}/clear`,
profile: opts.profile,
body: { targetId: opts.targetId },
});
}
export async function browserSetOffline(
baseUrl: string | undefined,
opts: { offline: boolean; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/offline",
profile: opts.profile,
body: { targetId: opts.targetId, offline: opts.offline },
});
}
export async function browserSetHeaders(
baseUrl: string | undefined,
opts: {
headers: Record<string, string>;
targetId?: string;
profile?: string;
},
): Promise<BrowserActionTargetOk> {
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/headers",
profile: opts.profile,
body: { targetId: opts.targetId, headers: opts.headers },
});
}
export async function browserSetHttpCredentials(
baseUrl: string | undefined,
opts: HttpCredentialsOptions = {},
): Promise<BrowserActionTargetOk> {
return await postTargetedProfileJson(baseUrl, {
path: "/set/credentials",
opts,
body: {
username: opts.username,
password: opts.password,
clear: opts.clear,
},
});
}
export async function browserSetGeolocation(
baseUrl: string | undefined,
opts: GeolocationOptions = {},
): Promise<BrowserActionTargetOk> {
return await postTargetedProfileJson(baseUrl, {
path: "/set/geolocation",
opts,
body: {
latitude: opts.latitude,
longitude: opts.longitude,
accuracy: opts.accuracy,
origin: opts.origin,
clear: opts.clear,
},
});
}
export async function browserSetMedia(
baseUrl: string | undefined,
opts: {
colorScheme: "dark" | "light" | "no-preference" | "none";
targetId?: string;
profile?: string;
},
): Promise<BrowserActionTargetOk> {
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/media",
profile: opts.profile,
body: {
targetId: opts.targetId,
colorScheme: opts.colorScheme,
},
});
}
export async function browserSetTimezone(
baseUrl: string | undefined,
opts: { timezoneId: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/timezone",
profile: opts.profile,
body: {
targetId: opts.targetId,
timezoneId: opts.timezoneId,
},
});
}
export async function browserSetLocale(
baseUrl: string | undefined,
opts: { locale: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/locale",
profile: opts.profile,
body: { targetId: opts.targetId, locale: opts.locale },
});
}
export async function browserSetDevice(
baseUrl: string | undefined,
opts: { name: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/device",
profile: opts.profile,
body: { targetId: opts.targetId, name: opts.name },
});
}
export async function browserClearPermissions(
baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionOk> {
return await postProfileJson<BrowserActionOk>(baseUrl, {
path: "/set/geolocation",
profile: opts.profile,
body: { targetId: opts.targetId, clear: true },
});
}

View File

@@ -0,0 +1,16 @@
export type BrowserActionOk = { ok: true };
export type BrowserActionTabResult = {
ok: true;
targetId: string;
url?: string;
};
export type BrowserActionPathResult = {
ok: true;
path: string;
targetId: string;
url?: string;
};
export type BrowserActionTargetOk = { ok: true; targetId: string };

View File

@@ -0,0 +1,11 @@
export function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
export function withBaseUrl(baseUrl: string | undefined, path: string): string {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return path;
}
return `${trimmed.replace(/\/$/, "")}${path}`;
}

View File

@@ -0,0 +1,4 @@
export * from "./client-actions-core.js";
export * from "./client-actions-observe.js";
export * from "./client-actions-state.js";
export * from "./client-actions-types.js";

View File

@@ -0,0 +1,345 @@
import { formatCliCommand } from "../cli/command-format.js";
import { loadConfig } from "../config/config.js";
import { isLoopbackHost } from "../gateway/net.js";
import { getBridgeAuthForPort } from "./bridge-auth-registry.js";
import { resolveBrowserControlAuth } from "./control-auth.js";
import {
createBrowserControlContext,
startBrowserControlServiceFromConfig,
} from "./control-service.js";
import { createBrowserRouteDispatcher } from "./routes/dispatcher.js";
// Application-level error from the browser control service (service is reachable
// but returned an error response). Must NOT be wrapped with "Can't reach ..." messaging.
class BrowserServiceError extends Error {
constructor(message: string) {
super(message);
this.name = "BrowserServiceError";
}
}
type LoopbackBrowserAuthDeps = {
loadConfig: typeof loadConfig;
resolveBrowserControlAuth: typeof resolveBrowserControlAuth;
getBridgeAuthForPort: typeof getBridgeAuthForPort;
};
function isAbsoluteHttp(url: string): boolean {
return /^https?:\/\//i.test(url.trim());
}
function isLoopbackHttpUrl(url: string): boolean {
try {
return isLoopbackHost(new URL(url).hostname);
} catch {
return false;
}
}
function withLoopbackBrowserAuthImpl(
url: string,
init: (RequestInit & { timeoutMs?: number }) | undefined,
deps: LoopbackBrowserAuthDeps,
): RequestInit & { timeoutMs?: number } {
const headers = new Headers(init?.headers ?? {});
if (headers.has("authorization") || headers.has("x-openclaw-password")) {
return { ...init, headers };
}
if (!isLoopbackHttpUrl(url)) {
return { ...init, headers };
}
try {
const cfg = deps.loadConfig();
const auth = deps.resolveBrowserControlAuth(cfg);
if (auth.token) {
headers.set("Authorization", `Bearer ${auth.token}`);
return { ...init, headers };
}
if (auth.password) {
headers.set("x-openclaw-password", auth.password);
return { ...init, headers };
}
} catch {
// ignore config/auth lookup failures and continue without auth headers
}
// Sandbox bridge servers can run with per-process ephemeral auth on dynamic ports.
// Fall back to the in-memory registry if config auth is not available.
try {
const parsed = new URL(url);
const port =
parsed.port && Number.parseInt(parsed.port, 10) > 0
? Number.parseInt(parsed.port, 10)
: parsed.protocol === "https:"
? 443
: 80;
const bridgeAuth = deps.getBridgeAuthForPort(port);
if (bridgeAuth?.token) {
headers.set("Authorization", `Bearer ${bridgeAuth.token}`);
} else if (bridgeAuth?.password) {
headers.set("x-openclaw-password", bridgeAuth.password);
}
} catch {
// ignore
}
return { ...init, headers };
}
function withLoopbackBrowserAuth(
url: string,
init: (RequestInit & { timeoutMs?: number }) | undefined,
): RequestInit & { timeoutMs?: number } {
return withLoopbackBrowserAuthImpl(url, init, {
loadConfig,
resolveBrowserControlAuth,
getBridgeAuthForPort,
});
}
const BROWSER_TOOL_MODEL_HINT =
"Do NOT retry the browser tool — it will keep failing. " +
"Use an alternative approach or inform the user that the browser is currently unavailable.";
const BROWSER_SERVICE_RATE_LIMIT_MESSAGE =
"Browser service rate limit reached. " +
"Wait for the current session to complete, or retry later.";
const BROWSERBASE_RATE_LIMIT_MESSAGE =
"Browserbase rate limit reached (max concurrent sessions). " +
"Wait for the current session to complete, or upgrade your plan.";
function isRateLimitStatus(status: number): boolean {
return status === 429;
}
function isBrowserbaseUrl(url: string): boolean {
if (!isAbsoluteHttp(url)) {
return false;
}
try {
const host = new URL(url).hostname.toLowerCase();
return host === "browserbase.com" || host.endsWith(".browserbase.com");
} catch {
return false;
}
}
export function resolveBrowserRateLimitMessage(url: string): string {
return isBrowserbaseUrl(url)
? BROWSERBASE_RATE_LIMIT_MESSAGE
: BROWSER_SERVICE_RATE_LIMIT_MESSAGE;
}
function resolveBrowserFetchOperatorHint(url: string): string {
const isLocal = !isAbsoluteHttp(url);
return isLocal
? `Restart the OpenClaw gateway (OpenClaw.app menubar, or \`${formatCliCommand("openclaw gateway")}\`).`
: "If this is a sandboxed session, ensure the sandbox browser is running.";
}
function normalizeErrorMessage(err: unknown): string {
if (err instanceof Error && err.message.trim().length > 0) {
return err.message.trim();
}
return String(err);
}
function appendBrowserToolModelHint(message: string): string {
if (message.includes(BROWSER_TOOL_MODEL_HINT)) {
return message;
}
return `${message} ${BROWSER_TOOL_MODEL_HINT}`;
}
async function discardResponseBody(res: Response): Promise<void> {
try {
await res.body?.cancel();
} catch {
// Best effort only; we're already returning a stable error message.
}
}
function enhanceDispatcherPathError(url: string, err: unknown): Error {
const msg = normalizeErrorMessage(err);
const suffix = `${resolveBrowserFetchOperatorHint(url)} ${BROWSER_TOOL_MODEL_HINT}`;
const normalized = msg.endsWith(".") ? msg : `${msg}.`;
return new Error(`${normalized} ${suffix}`, err instanceof Error ? { cause: err } : undefined);
}
function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error {
const operatorHint = resolveBrowserFetchOperatorHint(url);
const msg = String(err);
const msgLower = msg.toLowerCase();
const looksLikeTimeout =
msgLower.includes("timed out") ||
msgLower.includes("timeout") ||
msgLower.includes("aborted") ||
msgLower.includes("abort") ||
msgLower.includes("aborterror");
if (looksLikeTimeout) {
return new Error(
appendBrowserToolModelHint(
`Can't reach the OpenClaw browser control service (timed out after ${timeoutMs}ms). ${operatorHint}`,
),
);
}
return new Error(
appendBrowserToolModelHint(
`Can't reach the OpenClaw browser control service. ${operatorHint} (${msg})`,
),
);
}
async function fetchHttpJson<T>(
url: string,
init: RequestInit & { timeoutMs?: number },
): Promise<T> {
const timeoutMs = init.timeoutMs ?? 5000;
const ctrl = new AbortController();
const upstreamSignal = init.signal;
let upstreamAbortListener: (() => void) | undefined;
if (upstreamSignal) {
if (upstreamSignal.aborted) {
ctrl.abort(upstreamSignal.reason);
} else {
upstreamAbortListener = () => ctrl.abort(upstreamSignal.reason);
upstreamSignal.addEventListener("abort", upstreamAbortListener, { once: true });
}
}
const t = setTimeout(() => ctrl.abort(new Error("timed out")), timeoutMs);
try {
const res = await fetch(url, { ...init, signal: ctrl.signal });
if (!res.ok) {
if (isRateLimitStatus(res.status)) {
// Do not reflect upstream response text into the error surface (log/agent injection risk)
await discardResponseBody(res);
throw new BrowserServiceError(
`${resolveBrowserRateLimitMessage(url)} ${BROWSER_TOOL_MODEL_HINT}`,
);
}
const text = await res.text().catch(() => "");
throw new BrowserServiceError(text || `HTTP ${res.status}`);
}
return (await res.json()) as T;
} finally {
clearTimeout(t);
if (upstreamSignal && upstreamAbortListener) {
upstreamSignal.removeEventListener("abort", upstreamAbortListener);
}
}
}
export async function fetchBrowserJson<T>(
url: string,
init?: RequestInit & { timeoutMs?: number },
): Promise<T> {
const timeoutMs = init?.timeoutMs ?? 5000;
let isDispatcherPath = false;
try {
if (isAbsoluteHttp(url)) {
const httpInit = withLoopbackBrowserAuth(url, init);
return await fetchHttpJson<T>(url, { ...httpInit, timeoutMs });
}
isDispatcherPath = true;
const started = await startBrowserControlServiceFromConfig();
if (!started) {
throw new Error("browser control disabled");
}
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
const parsed = new URL(url, "http://localhost");
const query: Record<string, unknown> = {};
for (const [key, value] of parsed.searchParams.entries()) {
query[key] = value;
}
let body = init?.body;
if (typeof body === "string") {
try {
body = JSON.parse(body);
} catch {
// keep as string
}
}
const abortCtrl = new AbortController();
const upstreamSignal = init?.signal;
let upstreamAbortListener: (() => void) | undefined;
if (upstreamSignal) {
if (upstreamSignal.aborted) {
abortCtrl.abort(upstreamSignal.reason);
} else {
upstreamAbortListener = () => abortCtrl.abort(upstreamSignal.reason);
upstreamSignal.addEventListener("abort", upstreamAbortListener, { once: true });
}
}
let abortListener: (() => void) | undefined;
const abortPromise: Promise<never> = abortCtrl.signal.aborted
? Promise.reject(abortCtrl.signal.reason ?? new Error("aborted"))
: new Promise((_, reject) => {
abortListener = () => reject(abortCtrl.signal.reason ?? new Error("aborted"));
abortCtrl.signal.addEventListener("abort", abortListener, { once: true });
});
let timer: ReturnType<typeof setTimeout> | undefined;
if (timeoutMs) {
timer = setTimeout(() => abortCtrl.abort(new Error("timed out")), timeoutMs);
}
const dispatchPromise = dispatcher.dispatch({
method:
init?.method?.toUpperCase() === "DELETE"
? "DELETE"
: init?.method?.toUpperCase() === "POST"
? "POST"
: "GET",
path: parsed.pathname,
query,
body,
signal: abortCtrl.signal,
});
const result = await Promise.race([dispatchPromise, abortPromise]).finally(() => {
if (timer) {
clearTimeout(timer);
}
if (abortListener) {
abortCtrl.signal.removeEventListener("abort", abortListener);
}
if (upstreamSignal && upstreamAbortListener) {
upstreamSignal.removeEventListener("abort", upstreamAbortListener);
}
});
if (result.status >= 400) {
if (isRateLimitStatus(result.status)) {
// Do not reflect upstream response text into the error surface (log/agent injection risk)
throw new BrowserServiceError(
`${resolveBrowserRateLimitMessage(url)} ${BROWSER_TOOL_MODEL_HINT}`,
);
}
const message =
result.body && typeof result.body === "object" && "error" in result.body
? String((result.body as { error?: unknown }).error)
: `HTTP ${result.status}`;
throw new BrowserServiceError(message);
}
return result.body as T;
} catch (err) {
if (err instanceof BrowserServiceError) {
throw err;
}
// Dispatcher-path failures are service-operation failures, not network
// reachability failures. Keep the original context, but retain anti-retry hints.
if (isDispatcherPath) {
throw enhanceDispatcherPathError(url, err);
}
throw enhanceBrowserFetchError(url, err, timeoutMs);
}
}
export const __test = {
withLoopbackBrowserAuth: withLoopbackBrowserAuthImpl,
};

View File

@@ -0,0 +1,351 @@
import { fetchBrowserJson } from "./client-fetch.js";
export type BrowserTransport = "cdp" | "chrome-mcp";
export type BrowserStatus = {
enabled: boolean;
profile?: string;
driver?: "openclaw" | "existing-session";
transport?: BrowserTransport;
running: boolean;
cdpReady?: boolean;
cdpHttp?: boolean;
pid: number | null;
cdpPort: number | null;
cdpUrl?: string | null;
chosenBrowser: string | null;
detectedBrowser?: string | null;
detectedExecutablePath?: string | null;
detectError?: string | null;
userDataDir: string | null;
color: string;
headless: boolean;
noSandbox?: boolean;
executablePath?: string | null;
attachOnly: boolean;
};
export type ProfileStatus = {
name: string;
transport?: BrowserTransport;
cdpPort: number | null;
cdpUrl: string | null;
color: string;
driver: "openclaw" | "existing-session";
running: boolean;
tabCount: number;
isDefault: boolean;
isRemote: boolean;
missingFromConfig?: boolean;
reconcileReason?: string | null;
};
export type BrowserResetProfileResult = {
ok: true;
moved: boolean;
from: string;
to?: string;
};
export type BrowserTab = {
targetId: string;
title: string;
url: string;
wsUrl?: string;
type?: string;
};
export type SnapshotAriaNode = {
ref: string;
role: string;
name: string;
value?: string;
description?: string;
backendDOMNodeId?: number;
depth: number;
};
export type SnapshotResult =
| {
ok: true;
format: "aria";
targetId: string;
url: string;
nodes: SnapshotAriaNode[];
}
| {
ok: true;
format: "ai";
targetId: string;
url: string;
snapshot: string;
truncated?: boolean;
refs?: Record<string, { role: string; name?: string; nth?: number }>;
stats?: {
lines: number;
chars: number;
refs: number;
interactive: number;
};
labels?: boolean;
labelsCount?: number;
labelsSkipped?: number;
imagePath?: string;
imageType?: "png" | "jpeg";
};
function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
function withBaseUrl(baseUrl: string | undefined, path: string): string {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return path;
}
return `${trimmed.replace(/\/$/, "")}${path}`;
}
export async function browserStatus(
baseUrl?: string,
opts?: { profile?: string },
): Promise<BrowserStatus> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserStatus>(withBaseUrl(baseUrl, `/${q}`), {
timeoutMs: 1500,
});
}
export async function browserProfiles(baseUrl?: string): Promise<ProfileStatus[]> {
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(
withBaseUrl(baseUrl, `/profiles`),
{
timeoutMs: 3000,
},
);
return res.profiles ?? [];
}
export async function browserStart(baseUrl?: string, opts?: { profile?: string }): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(withBaseUrl(baseUrl, `/start${q}`), {
method: "POST",
timeoutMs: 15000,
});
}
export async function browserStop(baseUrl?: string, opts?: { profile?: string }): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(withBaseUrl(baseUrl, `/stop${q}`), {
method: "POST",
timeoutMs: 15000,
});
}
export async function browserResetProfile(
baseUrl?: string,
opts?: { profile?: string },
): Promise<BrowserResetProfileResult> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserResetProfileResult>(
withBaseUrl(baseUrl, `/reset-profile${q}`),
{
method: "POST",
timeoutMs: 20000,
},
);
}
export type BrowserCreateProfileResult = {
ok: true;
profile: string;
transport?: BrowserTransport;
cdpPort: number | null;
cdpUrl: string | null;
userDataDir: string | null;
color: string;
isRemote: boolean;
};
export async function browserCreateProfile(
baseUrl: string | undefined,
opts: {
name: string;
color?: string;
cdpUrl?: string;
userDataDir?: string;
driver?: "openclaw" | "existing-session";
},
): Promise<BrowserCreateProfileResult> {
return await fetchBrowserJson<BrowserCreateProfileResult>(
withBaseUrl(baseUrl, `/profiles/create`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: opts.name,
color: opts.color,
cdpUrl: opts.cdpUrl,
userDataDir: opts.userDataDir,
driver: opts.driver,
}),
timeoutMs: 10000,
},
);
}
export type BrowserDeleteProfileResult = {
ok: true;
profile: string;
deleted: boolean;
};
export async function browserDeleteProfile(
baseUrl: string | undefined,
profile: string,
): Promise<BrowserDeleteProfileResult> {
return await fetchBrowserJson<BrowserDeleteProfileResult>(
withBaseUrl(baseUrl, `/profiles/${encodeURIComponent(profile)}`),
{
method: "DELETE",
timeoutMs: 20000,
},
);
}
export async function browserTabs(
baseUrl?: string,
opts?: { profile?: string },
): Promise<BrowserTab[]> {
const q = buildProfileQuery(opts?.profile);
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
withBaseUrl(baseUrl, `/tabs${q}`),
{ timeoutMs: 3000 },
);
return res.tabs ?? [];
}
export async function browserOpenTab(
baseUrl: string | undefined,
url: string,
opts?: { profile?: string },
): Promise<BrowserTab> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserTab>(withBaseUrl(baseUrl, `/tabs/open${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
timeoutMs: 15000,
});
}
export async function browserFocusTab(
baseUrl: string | undefined,
targetId: string,
opts?: { profile?: string },
): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/focus${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId }),
timeoutMs: 5000,
});
}
export async function browserCloseTab(
baseUrl: string | undefined,
targetId: string,
opts?: { profile?: string },
): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/${encodeURIComponent(targetId)}${q}`), {
method: "DELETE",
timeoutMs: 5000,
});
}
export async function browserTabAction(
baseUrl: string | undefined,
opts: {
action: "list" | "new" | "close" | "select";
index?: number;
profile?: string;
},
): Promise<unknown> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/action${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: opts.action,
index: opts.index,
}),
timeoutMs: 10_000,
});
}
export async function browserSnapshot(
baseUrl: string | undefined,
opts: {
format?: "aria" | "ai";
targetId?: string;
limit?: number;
maxChars?: number;
refs?: "role" | "aria";
interactive?: boolean;
compact?: boolean;
depth?: number;
selector?: string;
frame?: string;
labels?: boolean;
mode?: "efficient";
profile?: string;
},
): Promise<SnapshotResult> {
const q = new URLSearchParams();
if (opts.format) {
q.set("format", opts.format);
}
if (opts.targetId) {
q.set("targetId", opts.targetId);
}
if (typeof opts.limit === "number") {
q.set("limit", String(opts.limit));
}
if (typeof opts.maxChars === "number" && Number.isFinite(opts.maxChars)) {
q.set("maxChars", String(opts.maxChars));
}
if (opts.refs === "aria" || opts.refs === "role") {
q.set("refs", opts.refs);
}
if (typeof opts.interactive === "boolean") {
q.set("interactive", String(opts.interactive));
}
if (typeof opts.compact === "boolean") {
q.set("compact", String(opts.compact));
}
if (typeof opts.depth === "number" && Number.isFinite(opts.depth)) {
q.set("depth", String(opts.depth));
}
if (opts.selector?.trim()) {
q.set("selector", opts.selector.trim());
}
if (opts.frame?.trim()) {
q.set("frame", opts.frame.trim());
}
if (opts.labels === true) {
q.set("labels", "1");
}
if (opts.mode) {
q.set("mode", opts.mode);
}
if (opts.profile) {
q.set("profile", opts.profile);
}
return await fetchBrowserJson<SnapshotResult>(withBaseUrl(baseUrl, `/snapshot?${q.toString()}`), {
timeoutMs: 20000,
});
}
// Actions beyond the basic read-only commands live in client-actions.ts.

View File

@@ -0,0 +1,365 @@
import type { BrowserConfig, BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
import { resolveGatewayPort } from "../config/paths.js";
import {
deriveDefaultBrowserCdpPortRange,
deriveDefaultBrowserControlPort,
DEFAULT_BROWSER_CONTROL_PORT,
} from "../config/port-defaults.js";
import { isLoopbackHost } from "../gateway/net.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { resolveUserPath } from "../utils.js";
import {
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_ENABLED,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";
import { CDP_PORT_RANGE_START } from "./profiles.js";
export type ResolvedBrowserConfig = {
enabled: boolean;
evaluateEnabled: boolean;
controlPort: number;
cdpPortRangeStart: number;
cdpPortRangeEnd: number;
cdpProtocol: "http" | "https";
cdpHost: string;
cdpIsLoopback: boolean;
remoteCdpTimeoutMs: number;
remoteCdpHandshakeTimeoutMs: number;
color: string;
executablePath?: string;
headless: boolean;
noSandbox: boolean;
attachOnly: boolean;
defaultProfile: string;
profiles: Record<string, BrowserProfileConfig>;
ssrfPolicy?: SsrFPolicy;
extraArgs: string[];
};
export type ResolvedBrowserProfile = {
name: string;
cdpPort: number;
cdpUrl: string;
cdpHost: string;
cdpIsLoopback: boolean;
userDataDir?: string;
color: string;
driver: "openclaw" | "existing-session";
attachOnly: boolean;
};
function normalizeHexColor(raw: string | undefined) {
const value = (raw ?? "").trim();
if (!value) {
return DEFAULT_OPENCLAW_BROWSER_COLOR;
}
const normalized = value.startsWith("#") ? value : `#${value}`;
if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) {
return DEFAULT_OPENCLAW_BROWSER_COLOR;
}
return normalized.toUpperCase();
}
function normalizeTimeoutMs(raw: number | undefined, fallback: number) {
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
return value < 0 ? fallback : value;
}
function resolveCdpPortRangeStart(
rawStart: number | undefined,
fallbackStart: number,
rangeSpan: number,
) {
const start =
typeof rawStart === "number" && Number.isFinite(rawStart)
? Math.floor(rawStart)
: fallbackStart;
if (start < 1 || start > 65535) {
throw new Error(`browser.cdpPortRangeStart must be between 1 and 65535, got: ${start}`);
}
const maxStart = 65535 - rangeSpan;
if (start > maxStart) {
throw new Error(
`browser.cdpPortRangeStart (${start}) is too high for a ${rangeSpan + 1}-port range; max is ${maxStart}.`,
);
}
return start;
}
function normalizeStringList(raw: string[] | undefined): string[] | undefined {
if (!Array.isArray(raw) || raw.length === 0) {
return undefined;
}
const values = raw
.map((value) => value.trim())
.filter((value): value is string => value.length > 0);
return values.length > 0 ? values : undefined;
}
function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined {
const allowPrivateNetwork = cfg?.ssrfPolicy?.allowPrivateNetwork;
const dangerouslyAllowPrivateNetwork = cfg?.ssrfPolicy?.dangerouslyAllowPrivateNetwork;
const allowedHostnames = normalizeStringList(cfg?.ssrfPolicy?.allowedHostnames);
const hostnameAllowlist = normalizeStringList(cfg?.ssrfPolicy?.hostnameAllowlist);
const hasExplicitPrivateSetting =
allowPrivateNetwork !== undefined || dangerouslyAllowPrivateNetwork !== undefined;
// Browser defaults to trusted-network mode unless explicitly disabled by policy.
const resolvedAllowPrivateNetwork =
dangerouslyAllowPrivateNetwork === true ||
allowPrivateNetwork === true ||
!hasExplicitPrivateSetting;
if (
!resolvedAllowPrivateNetwork &&
!hasExplicitPrivateSetting &&
!allowedHostnames &&
!hostnameAllowlist
) {
return undefined;
}
return {
...(resolvedAllowPrivateNetwork ? { dangerouslyAllowPrivateNetwork: true } : {}),
...(allowedHostnames ? { allowedHostnames } : {}),
...(hostnameAllowlist ? { hostnameAllowlist } : {}),
};
}
export function parseHttpUrl(raw: string, label: string) {
const trimmed = raw.trim();
const parsed = new URL(trimmed);
const allowed = ["http:", "https:", "ws:", "wss:"];
if (!allowed.includes(parsed.protocol)) {
throw new Error(`${label} must be http(s) or ws(s), got: ${parsed.protocol.replace(":", "")}`);
}
const isSecure = parsed.protocol === "https:" || parsed.protocol === "wss:";
const port =
parsed.port && Number.parseInt(parsed.port, 10) > 0
? Number.parseInt(parsed.port, 10)
: isSecure
? 443
: 80;
if (Number.isNaN(port) || port <= 0 || port > 65535) {
throw new Error(`${label} has invalid port: ${parsed.port}`);
}
return {
parsed,
port,
normalized: parsed.toString().replace(/\/$/, ""),
};
}
/**
* Ensure the default "openclaw" profile exists in the profiles map.
* Auto-creates it with the legacy CDP port (from browser.cdpUrl) or first port if missing.
*/
function ensureDefaultProfile(
profiles: Record<string, BrowserProfileConfig> | undefined,
defaultColor: string,
legacyCdpPort?: number,
derivedDefaultCdpPort?: number,
legacyCdpUrl?: string,
): Record<string, BrowserProfileConfig> {
const result = { ...profiles };
if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) {
result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = {
cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? CDP_PORT_RANGE_START,
color: defaultColor,
// Preserve the full cdpUrl for ws/wss endpoints so resolveProfile()
// doesn't reconstruct from cdpProtocol/cdpHost/cdpPort (which drops
// the WebSocket protocol and query params like API keys).
...(legacyCdpUrl ? { cdpUrl: legacyCdpUrl } : {}),
};
}
return result;
}
/**
* Ensure a built-in "user" profile exists for Chrome's existing-session attach flow.
*/
function ensureDefaultUserBrowserProfile(
profiles: Record<string, BrowserProfileConfig>,
): Record<string, BrowserProfileConfig> {
const result = { ...profiles };
if (result.user) {
return result;
}
result.user = {
driver: "existing-session",
attachOnly: true,
color: "#00AA00",
};
return result;
}
export function resolveBrowserConfig(
cfg: BrowserConfig | undefined,
rootConfig?: OpenClawConfig,
): ResolvedBrowserConfig {
const enabled = cfg?.enabled ?? DEFAULT_OPENCLAW_BROWSER_ENABLED;
const evaluateEnabled = cfg?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED;
const gatewayPort = resolveGatewayPort(rootConfig);
const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT);
const defaultColor = normalizeHexColor(cfg?.color);
const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500);
const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(
cfg?.remoteCdpHandshakeTimeoutMs,
Math.max(2000, remoteCdpTimeoutMs * 2),
);
const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort);
const cdpRangeSpan = derivedCdpRange.end - derivedCdpRange.start;
const cdpPortRangeStart = resolveCdpPortRangeStart(
cfg?.cdpPortRangeStart,
derivedCdpRange.start,
cdpRangeSpan,
);
const cdpPortRangeEnd = cdpPortRangeStart + cdpRangeSpan;
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(
`Derived CDP port (${derivedPort}) is too high; check gateway port configuration.`,
);
}
const derived = new URL(`http://127.0.0.1:${derivedPort}`);
cdpInfo = {
parsed: derived,
port: derivedPort,
normalized: derived.toString().replace(/\/$/, ""),
};
}
const headless = cfg?.headless === true;
const noSandbox = cfg?.noSandbox === true;
const attachOnly = cfg?.attachOnly === true;
const executablePath = cfg?.executablePath?.trim() || undefined;
const defaultProfileFromConfig = cfg?.defaultProfile?.trim() || undefined;
// Use legacy cdpUrl port for backward compatibility when no profiles configured
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:";
const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined;
const profiles = ensureDefaultUserBrowserProfile(
ensureDefaultProfile(
cfg?.profiles,
defaultColor,
legacyCdpPort,
cdpPortRangeStart,
legacyCdpUrl,
),
);
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
const defaultProfile =
defaultProfileFromConfig ??
(profiles[DEFAULT_BROWSER_DEFAULT_PROFILE_NAME]
? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME
: profiles[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]
? DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME
: "user");
const extraArgs = Array.isArray(cfg?.extraArgs)
? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0)
: [];
const ssrfPolicy = resolveBrowserSsrFPolicy(cfg);
return {
enabled,
evaluateEnabled,
controlPort,
cdpPortRangeStart,
cdpPortRangeEnd,
cdpProtocol,
cdpHost: cdpInfo.parsed.hostname,
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
remoteCdpTimeoutMs,
remoteCdpHandshakeTimeoutMs,
color: defaultColor,
executablePath,
headless,
noSandbox,
attachOnly,
defaultProfile,
profiles,
ssrfPolicy,
extraArgs,
};
}
/**
* Resolve a profile by name from the config.
* Returns null if the profile doesn't exist.
*/
export function resolveProfile(
resolved: ResolvedBrowserConfig,
profileName: string,
): ResolvedBrowserProfile | null {
const profile = resolved.profiles[profileName];
if (!profile) {
return null;
}
const rawProfileUrl = profile.cdpUrl?.trim() ?? "";
let cdpHost = resolved.cdpHost;
let cdpPort = profile.cdpPort ?? 0;
let cdpUrl = "";
const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw";
if (driver === "existing-session") {
// existing-session uses Chrome MCP auto-connect; no CDP port/URL needed
return {
name: profileName,
cdpPort: 0,
cdpUrl: "",
cdpHost: "",
cdpIsLoopback: true,
userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined,
color: profile.color,
driver,
attachOnly: true,
};
}
if (rawProfileUrl) {
const parsed = parseHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`);
cdpHost = parsed.parsed.hostname;
cdpPort = parsed.port;
cdpUrl = parsed.normalized;
} else if (cdpPort) {
cdpUrl = `${resolved.cdpProtocol}://${resolved.cdpHost}:${cdpPort}`;
} else {
throw new Error(`Profile "${profileName}" must define cdpPort or cdpUrl.`);
}
return {
name: profileName,
cdpPort,
cdpUrl,
cdpHost,
cdpIsLoopback: isLoopbackHost(cdpHost),
color: profile.color,
driver,
attachOnly: profile.attachOnly ?? resolved.attachOnly,
};
}
export function shouldStartLocalBrowserServer(_resolved: ResolvedBrowserConfig) {
return true;
}

View File

@@ -0,0 +1,8 @@
export const DEFAULT_OPENCLAW_BROWSER_ENABLED = true;
export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500";
export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw";
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw";
export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 80_000;
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS = 10_000;
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH = 6;

View File

@@ -0,0 +1,98 @@
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { ensureGatewayStartupAuth } from "../gateway/startup-auth.js";
export type BrowserControlAuth = {
token?: string;
password?: string;
};
export function resolveBrowserControlAuth(
cfg: OpenClawConfig | undefined,
env: NodeJS.ProcessEnv = process.env,
): BrowserControlAuth {
const auth = resolveGatewayAuth({
authConfig: cfg?.gateway?.auth,
env,
tailscaleMode: cfg?.gateway?.tailscale?.mode,
});
const token = typeof auth.token === "string" ? auth.token.trim() : "";
const password = typeof auth.password === "string" ? auth.password.trim() : "";
return {
token: token || undefined,
password: password || undefined,
};
}
function shouldAutoGenerateBrowserAuth(env: NodeJS.ProcessEnv): boolean {
const nodeEnv = (env.NODE_ENV ?? "").trim().toLowerCase();
if (nodeEnv === "test") {
return false;
}
const vitest = (env.VITEST ?? "").trim().toLowerCase();
if (vitest && vitest !== "0" && vitest !== "false" && vitest !== "off") {
return false;
}
return true;
}
export async function ensureBrowserControlAuth(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): Promise<{
auth: BrowserControlAuth;
generatedToken?: string;
}> {
const env = params.env ?? process.env;
const auth = resolveBrowserControlAuth(params.cfg, env);
if (auth.token || auth.password) {
return { auth };
}
if (!shouldAutoGenerateBrowserAuth(env)) {
return { auth };
}
// Respect explicit password mode even if currently unset.
if (params.cfg.gateway?.auth?.mode === "password") {
return { auth };
}
if (params.cfg.gateway?.auth?.mode === "none") {
return { auth };
}
if (params.cfg.gateway?.auth?.mode === "trusted-proxy") {
return { auth };
}
// Re-read latest config to avoid racing with concurrent config writers.
const latestCfg = loadConfig();
const latestAuth = resolveBrowserControlAuth(latestCfg, env);
if (latestAuth.token || latestAuth.password) {
return { auth: latestAuth };
}
if (latestCfg.gateway?.auth?.mode === "password") {
return { auth: latestAuth };
}
if (latestCfg.gateway?.auth?.mode === "none") {
return { auth: latestAuth };
}
if (latestCfg.gateway?.auth?.mode === "trusted-proxy") {
return { auth: latestAuth };
}
const ensured = await ensureGatewayStartupAuth({
cfg: latestCfg,
env,
persist: true,
});
const ensuredAuth = {
token: ensured.auth.token,
password: ensured.auth.password,
};
return {
auth: ensuredAuth,
generatedToken: ensured.generatedToken,
};
}

View File

@@ -0,0 +1 @@
export * from "../control-service.js";

View File

@@ -0,0 +1,87 @@
import type { NextFunction, Request, Response } from "express";
import { isLoopbackHost } from "../gateway/net.js";
function firstHeader(value: string | string[] | undefined): string {
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
}
function isMutatingMethod(method: string): boolean {
const m = (method || "").trim().toUpperCase();
return m === "POST" || m === "PUT" || m === "PATCH" || m === "DELETE";
}
function isLoopbackUrl(value: string): boolean {
const v = value.trim();
if (!v || v === "null") {
return false;
}
try {
const parsed = new URL(v);
return isLoopbackHost(parsed.hostname);
} catch {
return false;
}
}
export function shouldRejectBrowserMutation(params: {
method: string;
origin?: string;
referer?: string;
secFetchSite?: string;
}): boolean {
if (!isMutatingMethod(params.method)) {
return false;
}
// Strong signal when present: browser says this is cross-site.
// Avoid being overly clever with "same-site" since localhost vs 127.0.0.1 may differ.
const secFetchSite = (params.secFetchSite ?? "").trim().toLowerCase();
if (secFetchSite === "cross-site") {
return true;
}
const origin = (params.origin ?? "").trim();
if (origin) {
return !isLoopbackUrl(origin);
}
const referer = (params.referer ?? "").trim();
if (referer) {
return !isLoopbackUrl(referer);
}
// Non-browser clients (curl/undici/Node) typically send no Origin/Referer.
return false;
}
export function browserMutationGuardMiddleware(): (
req: Request,
res: Response,
next: NextFunction,
) => void {
return (req: Request, res: Response, next: NextFunction) => {
// OPTIONS is used for CORS preflight. Even if cross-origin, the preflight isn't mutating.
const method = (req.method || "").trim().toUpperCase();
if (method === "OPTIONS") {
return next();
}
const origin = firstHeader(req.headers.origin);
const referer = firstHeader(req.headers.referer);
const secFetchSite = firstHeader(req.headers["sec-fetch-site"]);
if (
shouldRejectBrowserMutation({
method,
origin,
referer,
secFetchSite,
})
) {
res.status(403).send("Forbidden");
return;
}
next();
};
}

View File

@@ -0,0 +1,85 @@
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
export class BrowserError extends Error {
status: number;
constructor(message: string, status = 500, options?: ErrorOptions) {
super(message, options);
this.name = new.target.name;
this.status = status;
}
}
export class BrowserValidationError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 400, options);
}
}
export class BrowserConfigurationError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 400, options);
}
}
export class BrowserTargetAmbiguousError extends BrowserError {
constructor(message = "ambiguous target id prefix", options?: ErrorOptions) {
super(message, 409, options);
}
}
export class BrowserTabNotFoundError extends BrowserError {
constructor(message = "tab not found", options?: ErrorOptions) {
super(message, 404, options);
}
}
export class BrowserProfileNotFoundError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 404, options);
}
}
export class BrowserConflictError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 409, options);
}
}
export class BrowserResetUnsupportedError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 400, options);
}
}
export class BrowserProfileUnavailableError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 409, options);
}
}
export class BrowserResourceExhaustedError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 507, options);
}
}
export function toBrowserErrorResponse(err: unknown): {
status: number;
message: string;
} | null {
if (err instanceof BrowserError) {
return { status: err.status, message: err.message };
}
if (err instanceof SsrFBlockedError) {
return { status: 400, message: err.message };
}
if (
err instanceof InvalidBrowserNavigationUrlError ||
(err instanceof Error && err.name === "InvalidBrowserNavigationUrlError")
) {
return { status: 400, message: err.message };
}
return null;
}

View File

@@ -0,0 +1,32 @@
import type { BrowserFormField } from "./client-actions-core.js";
export const DEFAULT_FILL_FIELD_TYPE = "text";
type BrowserFormFieldValue = NonNullable<BrowserFormField["value"]>;
export function normalizeBrowserFormFieldRef(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
export function normalizeBrowserFormFieldType(value: unknown): string {
const type = typeof value === "string" ? value.trim() : "";
return type || DEFAULT_FILL_FIELD_TYPE;
}
export function normalizeBrowserFormFieldValue(value: unknown): BrowserFormFieldValue | undefined {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
? value
: undefined;
}
export function normalizeBrowserFormField(
record: Record<string, unknown>,
): BrowserFormField | null {
const ref = normalizeBrowserFormFieldRef(record.ref);
if (!ref) {
return null;
}
const type = normalizeBrowserFormFieldType(record.type);
const value = normalizeBrowserFormFieldValue(record.value);
return value === undefined ? { ref, type } : { ref, type, value };
}

View File

@@ -0,0 +1,63 @@
import type { IncomingMessage } from "node:http";
import { safeEqualSecret } from "../security/secret-equal.js";
function firstHeaderValue(value: string | string[] | undefined): string {
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
}
function parseBearerToken(authorization: string): string | undefined {
if (!authorization || !authorization.toLowerCase().startsWith("bearer ")) {
return undefined;
}
const token = authorization.slice(7).trim();
return token || undefined;
}
function parseBasicPassword(authorization: string): string | undefined {
if (!authorization || !authorization.toLowerCase().startsWith("basic ")) {
return undefined;
}
const encoded = authorization.slice(6).trim();
if (!encoded) {
return undefined;
}
try {
const decoded = Buffer.from(encoded, "base64").toString("utf8");
const sep = decoded.indexOf(":");
if (sep < 0) {
return undefined;
}
const password = decoded.slice(sep + 1).trim();
return password || undefined;
} catch {
return undefined;
}
}
export function isAuthorizedBrowserRequest(
req: IncomingMessage,
auth: { token?: string; password?: string },
): boolean {
const authorization = firstHeaderValue(req.headers.authorization).trim();
if (auth.token) {
const bearer = parseBearerToken(authorization);
if (bearer && safeEqualSecret(bearer, auth.token)) {
return true;
}
}
if (auth.password) {
const passwordHeader = firstHeaderValue(req.headers["x-openclaw-password"]).trim();
if (passwordHeader && safeEqualSecret(passwordHeader, auth.password)) {
return true;
}
const basicPassword = parseBasicPassword(authorization);
if (basicPassword && safeEqualSecret(basicPassword, auth.password)) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,134 @@
import { hasProxyEnvConfigured } from "../infra/net/proxy-env.js";
import {
isPrivateNetworkAllowedByPolicy,
resolvePinnedHostnameWithPolicy,
type LookupFn,
type SsrFPolicy,
} from "../infra/net/ssrf.js";
const NETWORK_NAVIGATION_PROTOCOLS = new Set(["http:", "https:"]);
const SAFE_NON_NETWORK_URLS = new Set(["about:blank"]);
function isAllowedNonNetworkNavigationUrl(parsed: URL): boolean {
// Keep non-network navigation explicit; about:blank is the only allowed bootstrap URL.
return SAFE_NON_NETWORK_URLS.has(parsed.href);
}
export class InvalidBrowserNavigationUrlError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidBrowserNavigationUrlError";
}
}
export type BrowserNavigationPolicyOptions = {
ssrfPolicy?: SsrFPolicy;
};
export type BrowserNavigationRequestLike = {
url(): string;
redirectedFrom(): BrowserNavigationRequestLike | null;
};
export function withBrowserNavigationPolicy(
ssrfPolicy?: SsrFPolicy,
): BrowserNavigationPolicyOptions {
return ssrfPolicy ? { ssrfPolicy } : {};
}
export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFPolicy): boolean {
return !isPrivateNetworkAllowedByPolicy(ssrfPolicy);
}
export async function assertBrowserNavigationAllowed(
opts: {
url: string;
lookupFn?: LookupFn;
} & BrowserNavigationPolicyOptions,
): Promise<void> {
const rawUrl = String(opts.url ?? "").trim();
if (!rawUrl) {
throw new InvalidBrowserNavigationUrlError("url is required");
}
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new InvalidBrowserNavigationUrlError(`Invalid URL: ${rawUrl}`);
}
if (!NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol)) {
if (isAllowedNonNetworkNavigationUrl(parsed)) {
return;
}
throw new InvalidBrowserNavigationUrlError(
`Navigation blocked: unsupported protocol "${parsed.protocol}"`,
);
}
// Browser network stacks may apply env proxy routing at connect-time, which
// can bypass strict destination-binding intent from pre-navigation DNS checks.
// In strict mode, fail closed unless private-network navigation is explicitly
// enabled by policy.
if (hasProxyEnvConfigured() && !isPrivateNetworkAllowedByPolicy(opts.ssrfPolicy)) {
throw new InvalidBrowserNavigationUrlError(
"Navigation blocked: strict browser SSRF policy cannot be enforced while env proxy variables are set",
);
}
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
lookupFn: opts.lookupFn,
policy: opts.ssrfPolicy,
});
}
/**
* Best-effort post-navigation guard for final page URLs.
* Only validates network URLs (http/https) and about:blank to avoid false
* positives on browser-internal error pages (e.g. chrome-error://).
*/
export async function assertBrowserNavigationResultAllowed(
opts: {
url: string;
lookupFn?: LookupFn;
} & BrowserNavigationPolicyOptions,
): Promise<void> {
const rawUrl = String(opts.url ?? "").trim();
if (!rawUrl) {
return;
}
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
return;
}
if (
NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol) ||
isAllowedNonNetworkNavigationUrl(parsed)
) {
await assertBrowserNavigationAllowed(opts);
}
}
export async function assertBrowserNavigationRedirectChainAllowed(
opts: {
request?: BrowserNavigationRequestLike | null;
lookupFn?: LookupFn;
} & BrowserNavigationPolicyOptions,
): Promise<void> {
const chain: string[] = [];
let current = opts.request ?? null;
while (current) {
chain.push(current.url());
current = current.redirectedFrom();
}
for (const url of chain.toReversed()) {
await assertBrowserNavigationAllowed({
url,
lookupFn: opts.lookupFn,
ssrfPolicy: opts.ssrfPolicy,
});
}
}

View File

@@ -0,0 +1,51 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { writeFileFromPathWithinRoot } from "../infra/fs-safe.js";
import { sanitizeUntrustedFileName } from "./safe-filename.js";
function buildSiblingTempPath(targetPath: string): string {
const id = crypto.randomUUID();
const safeTail = sanitizeUntrustedFileName(path.basename(targetPath), "output.bin");
return path.join(path.dirname(targetPath), `.openclaw-output-${id}-${safeTail}.part`);
}
export async function writeViaSiblingTempPath(params: {
rootDir: string;
targetPath: string;
writeTemp: (tempPath: string) => Promise<void>;
}): Promise<void> {
const rootDir = await fs
.realpath(path.resolve(params.rootDir))
.catch(() => path.resolve(params.rootDir));
const requestedTargetPath = path.resolve(params.targetPath);
const targetPath = await fs
.realpath(path.dirname(requestedTargetPath))
.then((realDir) => path.join(realDir, path.basename(requestedTargetPath)))
.catch(() => requestedTargetPath);
const relativeTargetPath = path.relative(rootDir, targetPath);
if (
!relativeTargetPath ||
relativeTargetPath === ".." ||
relativeTargetPath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativeTargetPath)
) {
throw new Error("Target path is outside the allowed root");
}
const tempPath = buildSiblingTempPath(targetPath);
let renameSucceeded = false;
try {
await params.writeTemp(tempPath);
await writeFileFromPathWithinRoot({
rootDir,
relativePath: relativeTargetPath,
sourcePath: tempPath,
mkdir: false,
});
renameSucceeded = true;
} finally {
if (!renameSucceeded) {
await fs.rm(tempPath, { force: true }).catch(() => {});
}
}
}

View File

@@ -0,0 +1,256 @@
import fs from "node:fs/promises";
import path from "node:path";
import { SafeOpenError, openFileWithinRoot } from "../infra/fs-safe.js";
import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
export const DEFAULT_BROWSER_TMP_DIR = resolvePreferredOpenClawTmpDir();
export const DEFAULT_TRACE_DIR = DEFAULT_BROWSER_TMP_DIR;
export const DEFAULT_DOWNLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "downloads");
export const DEFAULT_UPLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "uploads");
type InvalidPathResult = { ok: false; error: string };
function invalidPath(scopeLabel: string): InvalidPathResult {
return {
ok: false,
error: `Invalid path: must stay within ${scopeLabel}`,
};
}
async function resolveRealPathIfExists(targetPath: string): Promise<string | undefined> {
try {
return await fs.realpath(targetPath);
} catch {
return undefined;
}
}
async function resolveTrustedRootRealPath(rootDir: string): Promise<string | undefined> {
try {
const rootLstat = await fs.lstat(rootDir);
if (!rootLstat.isDirectory() || rootLstat.isSymbolicLink()) {
return undefined;
}
return await fs.realpath(rootDir);
} catch {
return undefined;
}
}
async function validateCanonicalPathWithinRoot(params: {
rootRealPath: string;
candidatePath: string;
expect: "directory" | "file";
}): Promise<"ok" | "not-found" | "invalid"> {
try {
const candidateLstat = await fs.lstat(params.candidatePath);
if (candidateLstat.isSymbolicLink()) {
return "invalid";
}
if (params.expect === "directory" && !candidateLstat.isDirectory()) {
return "invalid";
}
if (params.expect === "file" && !candidateLstat.isFile()) {
return "invalid";
}
if (params.expect === "file" && candidateLstat.nlink > 1) {
return "invalid";
}
const candidateRealPath = await fs.realpath(params.candidatePath);
return isPathInside(params.rootRealPath, candidateRealPath) ? "ok" : "invalid";
} catch (err) {
return isNotFoundPathError(err) ? "not-found" : "invalid";
}
}
export function resolvePathWithinRoot(params: {
rootDir: string;
requestedPath: string;
scopeLabel: string;
defaultFileName?: string;
}): { ok: true; path: string } | { ok: false; error: string } {
const root = path.resolve(params.rootDir);
const raw = params.requestedPath.trim();
if (!raw) {
if (!params.defaultFileName) {
return { ok: false, error: "path is required" };
}
return { ok: true, path: path.join(root, params.defaultFileName) };
}
const resolved = path.resolve(root, raw);
const rel = path.relative(root, resolved);
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel}` };
}
return { ok: true, path: resolved };
}
export async function resolveWritablePathWithinRoot(params: {
rootDir: string;
requestedPath: string;
scopeLabel: string;
defaultFileName?: string;
}): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
const lexical = resolvePathWithinRoot(params);
if (!lexical.ok) {
return lexical;
}
const rootDir = path.resolve(params.rootDir);
const rootRealPath = await resolveTrustedRootRealPath(rootDir);
if (!rootRealPath) {
return invalidPath(params.scopeLabel);
}
const requestedPath = lexical.path;
const parentDir = path.dirname(requestedPath);
const parentStatus = await validateCanonicalPathWithinRoot({
rootRealPath,
candidatePath: parentDir,
expect: "directory",
});
if (parentStatus !== "ok") {
return invalidPath(params.scopeLabel);
}
const targetStatus = await validateCanonicalPathWithinRoot({
rootRealPath,
candidatePath: requestedPath,
expect: "file",
});
if (targetStatus === "invalid") {
return invalidPath(params.scopeLabel);
}
return lexical;
}
export function resolvePathsWithinRoot(params: {
rootDir: string;
requestedPaths: string[];
scopeLabel: string;
}): { ok: true; paths: string[] } | { ok: false; error: string } {
const resolvedPaths: string[] = [];
for (const raw of params.requestedPaths) {
const pathResult = resolvePathWithinRoot({
rootDir: params.rootDir,
requestedPath: raw,
scopeLabel: params.scopeLabel,
});
if (!pathResult.ok) {
return { ok: false, error: pathResult.error };
}
resolvedPaths.push(pathResult.path);
}
return { ok: true, paths: resolvedPaths };
}
export async function resolveExistingPathsWithinRoot(params: {
rootDir: string;
requestedPaths: string[];
scopeLabel: string;
}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> {
return await resolveCheckedPathsWithinRoot({
...params,
allowMissingFallback: true,
});
}
export async function resolveStrictExistingPathsWithinRoot(params: {
rootDir: string;
requestedPaths: string[];
scopeLabel: string;
}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> {
return await resolveCheckedPathsWithinRoot({
...params,
allowMissingFallback: false,
});
}
async function resolveCheckedPathsWithinRoot(params: {
rootDir: string;
requestedPaths: string[];
scopeLabel: string;
allowMissingFallback: boolean;
}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> {
const rootDir = path.resolve(params.rootDir);
// Keep historical behavior for missing roots and rely on openFileWithinRoot for final checks.
const rootRealPath = await resolveRealPathIfExists(rootDir);
const isInRoot = (relativePath: string) =>
Boolean(relativePath) && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
const resolveExistingRelativePath = async (
requestedPath: string,
): Promise<
{ ok: true; relativePath: string; fallbackPath: string } | { ok: false; error: string }
> => {
const raw = requestedPath.trim();
const lexicalPathResult = resolvePathWithinRoot({
rootDir,
requestedPath,
scopeLabel: params.scopeLabel,
});
if (lexicalPathResult.ok) {
return {
ok: true,
relativePath: path.relative(rootDir, lexicalPathResult.path),
fallbackPath: lexicalPathResult.path,
};
}
if (!rootRealPath || !raw || !path.isAbsolute(raw)) {
return lexicalPathResult;
}
try {
const resolvedExistingPath = await fs.realpath(raw);
const relativePath = path.relative(rootRealPath, resolvedExistingPath);
if (!isInRoot(relativePath)) {
return lexicalPathResult;
}
return {
ok: true,
relativePath,
fallbackPath: resolvedExistingPath,
};
} catch {
return lexicalPathResult;
}
};
const resolvedPaths: string[] = [];
for (const raw of params.requestedPaths) {
const pathResult = await resolveExistingRelativePath(raw);
if (!pathResult.ok) {
return { ok: false, error: pathResult.error };
}
let opened: Awaited<ReturnType<typeof openFileWithinRoot>> | undefined;
try {
opened = await openFileWithinRoot({
rootDir,
relativePath: pathResult.relativePath,
});
resolvedPaths.push(opened.realPath);
} catch (err) {
if (params.allowMissingFallback && err instanceof SafeOpenError && err.code === "not-found") {
// Preserve historical behavior for paths that do not exist yet.
resolvedPaths.push(pathResult.fallbackPath);
continue;
}
if (err instanceof SafeOpenError && err.code === "outside-workspace") {
return {
ok: false,
error: `File is outside ${params.scopeLabel}`,
};
}
return {
ok: false,
error: `Invalid path: must stay within ${params.scopeLabel} and be a regular non-symlink file`,
};
} finally {
await opened?.handle.close().catch(() => {});
}
}
return { ok: true, paths: resolvedPaths };
}

View File

@@ -0,0 +1 @@
export * from "../plugin-enabled.js";

View File

@@ -0,0 +1 @@
export * from "../plugin-service.js";

View File

@@ -0,0 +1,93 @@
import type { ResolvedBrowserProfile } from "./config.js";
export type BrowserProfileMode = "local-managed" | "local-existing-session" | "remote-cdp";
export type BrowserProfileCapabilities = {
mode: BrowserProfileMode;
isRemote: boolean;
/** Profile uses the Chrome DevTools MCP server (existing-session driver). */
usesChromeMcp: boolean;
usesPersistentPlaywright: boolean;
supportsPerTabWs: boolean;
supportsJsonTabEndpoints: boolean;
supportsReset: boolean;
supportsManagedTabLimit: boolean;
};
export function getBrowserProfileCapabilities(
profile: ResolvedBrowserProfile,
): BrowserProfileCapabilities {
if (profile.driver === "existing-session") {
return {
mode: "local-existing-session",
isRemote: false,
usesChromeMcp: true,
usesPersistentPlaywright: false,
supportsPerTabWs: false,
supportsJsonTabEndpoints: false,
supportsReset: false,
supportsManagedTabLimit: false,
};
}
if (!profile.cdpIsLoopback) {
return {
mode: "remote-cdp",
isRemote: true,
usesChromeMcp: false,
usesPersistentPlaywright: true,
supportsPerTabWs: false,
supportsJsonTabEndpoints: false,
supportsReset: false,
supportsManagedTabLimit: false,
};
}
return {
mode: "local-managed",
isRemote: false,
usesChromeMcp: false,
usesPersistentPlaywright: false,
supportsPerTabWs: true,
supportsJsonTabEndpoints: true,
supportsReset: true,
supportsManagedTabLimit: true,
};
}
export function resolveDefaultSnapshotFormat(params: {
profile: ResolvedBrowserProfile;
hasPlaywright: boolean;
explicitFormat?: "ai" | "aria";
mode?: "efficient";
}): "ai" | "aria" {
if (params.explicitFormat) {
return params.explicitFormat;
}
if (params.mode === "efficient") {
return "ai";
}
const capabilities = getBrowserProfileCapabilities(params.profile);
if (capabilities.mode === "local-existing-session") {
return "ai";
}
return params.hasPlaywright ? "ai" : "aria";
}
export function shouldUsePlaywrightForScreenshot(params: {
profile: ResolvedBrowserProfile;
wsUrl?: string;
ref?: string;
element?: string;
}): boolean {
return !params.wsUrl || Boolean(params.ref) || Boolean(params.element);
}
export function shouldUsePlaywrightForAriaSnapshot(params: {
profile: ResolvedBrowserProfile;
wsUrl?: string;
}): boolean {
return !params.wsUrl;
}

View File

@@ -0,0 +1,258 @@
import fs from "node:fs";
import path from "node:path";
import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
import { resolveUserPath } from "../utils.js";
import { resolveOpenClawUserDataDir } from "./chrome.js";
import { parseHttpUrl, resolveProfile } from "./config.js";
import {
BrowserConflictError,
BrowserProfileNotFoundError,
BrowserResourceExhaustedError,
BrowserValidationError,
} from "./errors.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import {
allocateCdpPort,
allocateColor,
getUsedColors,
getUsedPorts,
isValidProfileName,
} from "./profiles.js";
import type { BrowserRouteContext, ProfileStatus } from "./server-context.js";
import { movePathToTrash } from "./trash.js";
export type CreateProfileParams = {
name: string;
color?: string;
cdpUrl?: string;
userDataDir?: string;
driver?: "openclaw" | "existing-session";
};
export type CreateProfileResult = {
ok: true;
profile: string;
transport: "cdp" | "chrome-mcp";
cdpPort: number | null;
cdpUrl: string | null;
userDataDir: string | null;
color: string;
isRemote: boolean;
};
export type DeleteProfileResult = {
ok: true;
profile: string;
deleted: boolean;
};
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
const cdpPortRange = (resolved: {
controlPort: number;
cdpPortRangeStart?: number;
cdpPortRangeEnd?: number;
}): { start: number; end: number } => {
const start = resolved.cdpPortRangeStart;
const end = resolved.cdpPortRangeEnd;
if (
typeof start === "number" &&
Number.isFinite(start) &&
Number.isInteger(start) &&
typeof end === "number" &&
Number.isFinite(end) &&
Number.isInteger(end) &&
start > 0 &&
end >= start &&
end <= 65535
) {
return { start, end };
}
return deriveDefaultBrowserCdpPortRange(resolved.controlPort);
};
export function createBrowserProfilesService(ctx: BrowserRouteContext) {
const listProfiles = async (): Promise<ProfileStatus[]> => {
return await ctx.listProfiles();
};
const createProfile = async (params: CreateProfileParams): Promise<CreateProfileResult> => {
const name = params.name.trim();
const rawCdpUrl = params.cdpUrl?.trim() || undefined;
const rawUserDataDir = params.userDataDir?.trim() || undefined;
const normalizedUserDataDir = rawUserDataDir ? resolveUserPath(rawUserDataDir) : undefined;
const driver = params.driver === "existing-session" ? "existing-session" : undefined;
if (!isValidProfileName(name)) {
throw new BrowserValidationError(
"invalid profile name: use lowercase letters, numbers, and hyphens only",
);
}
const state = ctx.state();
const resolvedProfiles = state.resolved.profiles;
if (name in resolvedProfiles) {
throw new BrowserConflictError(`profile "${name}" already exists`);
}
const cfg = loadConfig();
const rawProfiles = cfg.browser?.profiles ?? {};
if (name in rawProfiles) {
throw new BrowserConflictError(`profile "${name}" already exists`);
}
const usedColors = getUsedColors(resolvedProfiles);
const profileColor =
params.color && HEX_COLOR_RE.test(params.color) ? params.color : allocateColor(usedColors);
let profileConfig: BrowserProfileConfig;
if (normalizedUserDataDir && driver !== "existing-session") {
throw new BrowserValidationError(
"driver=existing-session is required when userDataDir is provided",
);
}
if (normalizedUserDataDir && !fs.existsSync(normalizedUserDataDir)) {
throw new BrowserValidationError(
`browser user data directory not found: ${normalizedUserDataDir}`,
);
}
if (rawCdpUrl) {
let parsed: ReturnType<typeof parseHttpUrl>;
try {
parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
} catch (err) {
throw new BrowserValidationError(String(err));
}
if (driver === "existing-session") {
throw new BrowserValidationError(
"driver=existing-session does not accept cdpUrl; it attaches via the Chrome MCP auto-connect flow",
);
}
profileConfig = {
cdpUrl: parsed.normalized,
...(driver ? { driver } : {}),
color: profileColor,
};
} else {
if (driver === "existing-session") {
// existing-session uses Chrome MCP auto-connect; no CDP port needed
profileConfig = {
driver,
attachOnly: true,
...(normalizedUserDataDir ? { userDataDir: normalizedUserDataDir } : {}),
color: profileColor,
};
} else {
const usedPorts = getUsedPorts(resolvedProfiles);
const range = cdpPortRange(state.resolved);
const cdpPort = allocateCdpPort(usedPorts, range);
if (cdpPort === null) {
throw new BrowserResourceExhaustedError("no available CDP ports in range");
}
profileConfig = {
cdpPort,
...(driver ? { driver } : {}),
color: profileColor,
};
}
}
const nextConfig: OpenClawConfig = {
...cfg,
browser: {
...cfg.browser,
profiles: {
...rawProfiles,
[name]: profileConfig,
},
},
};
await writeConfigFile(nextConfig);
state.resolved.profiles[name] = profileConfig;
const resolved = resolveProfile(state.resolved, name);
if (!resolved) {
throw new BrowserProfileNotFoundError(`profile "${name}" not found after creation`);
}
const capabilities = getBrowserProfileCapabilities(resolved);
return {
ok: true,
profile: name,
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
cdpPort: capabilities.usesChromeMcp ? null : resolved.cdpPort,
cdpUrl: capabilities.usesChromeMcp ? null : resolved.cdpUrl,
userDataDir: resolved.userDataDir ?? null,
color: resolved.color,
isRemote: !resolved.cdpIsLoopback,
};
};
const deleteProfile = async (nameRaw: string): Promise<DeleteProfileResult> => {
const name = nameRaw.trim();
if (!name) {
throw new BrowserValidationError("profile name is required");
}
if (!isValidProfileName(name)) {
throw new BrowserValidationError("invalid profile name");
}
const state = ctx.state();
const cfg = loadConfig();
const profiles = cfg.browser?.profiles ?? {};
const defaultProfile = cfg.browser?.defaultProfile ?? state.resolved.defaultProfile;
if (name === defaultProfile) {
throw new BrowserValidationError(
`cannot delete the default profile "${name}"; change browser.defaultProfile first`,
);
}
if (!(name in profiles)) {
throw new BrowserProfileNotFoundError(`profile "${name}" not found`);
}
let deleted = false;
const resolved = resolveProfile(state.resolved, name);
if (resolved?.cdpIsLoopback && resolved.driver === "openclaw") {
try {
await ctx.forProfile(name).stopRunningBrowser();
} catch {
// ignore
}
const userDataDir = resolveOpenClawUserDataDir(name);
const profileDir = path.dirname(userDataDir);
if (fs.existsSync(profileDir)) {
await movePathToTrash(profileDir);
deleted = true;
}
}
const { [name]: _removed, ...remainingProfiles } = profiles;
const nextConfig: OpenClawConfig = {
...cfg,
browser: {
...cfg.browser,
profiles: remainingProfiles,
},
};
await writeConfigFile(nextConfig);
delete state.resolved.profiles[name];
state.profiles.delete(name);
return { ok: true, profile: name, deleted };
};
return {
listProfiles,
createProfile,
deleteProfile,
};
}

View File

@@ -0,0 +1,113 @@
/**
* CDP port allocation for browser profiles.
*
* Default port range: 18800-18899 (100 profiles max)
* Ports are allocated once at profile creation and persisted in config.
* Multi-instance: callers may pass an explicit range to avoid collisions.
*
* Reserved ports (do not use for CDP):
* 18789 - Gateway WebSocket
* 18790 - Bridge
* 18791 - Browser control server
* 18792-18799 - Reserved for future one-off services (canvas at 18793)
*/
export const CDP_PORT_RANGE_START = 18800;
export const CDP_PORT_RANGE_END = 18899;
export const PROFILE_NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
export function isValidProfileName(name: string): boolean {
if (!name || name.length > 64) {
return false;
}
return PROFILE_NAME_REGEX.test(name);
}
export function allocateCdpPort(
usedPorts: Set<number>,
range?: { start: number; end: number },
): number | null {
const start = range?.start ?? CDP_PORT_RANGE_START;
const end = range?.end ?? CDP_PORT_RANGE_END;
if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= 0) {
return null;
}
if (start > end) {
return null;
}
for (let port = start; port <= end; port++) {
if (!usedPorts.has(port)) {
return port;
}
}
return null;
}
export function getUsedPorts(
profiles: Record<string, { cdpPort?: number; cdpUrl?: string }> | undefined,
): Set<number> {
if (!profiles) {
return new Set();
}
const used = new Set<number>();
for (const profile of Object.values(profiles)) {
if (typeof profile.cdpPort === "number") {
used.add(profile.cdpPort);
continue;
}
const rawUrl = profile.cdpUrl?.trim();
if (!rawUrl) {
continue;
}
try {
const parsed = new URL(rawUrl);
const port =
parsed.port && Number.parseInt(parsed.port, 10) > 0
? Number.parseInt(parsed.port, 10)
: parsed.protocol === "https:"
? 443
: 80;
if (!Number.isNaN(port) && port > 0 && port <= 65535) {
used.add(port);
}
} catch {
// ignore invalid URLs
}
}
return used;
}
export const PROFILE_COLORS = [
"#FF4500", // Orange-red (openclaw default)
"#0066CC", // Blue
"#00AA00", // Green
"#9933FF", // Purple
"#FF6699", // Pink
"#00CCCC", // Cyan
"#FF9900", // Orange
"#6666FF", // Indigo
"#CC3366", // Magenta
"#339966", // Teal
];
export function allocateColor(usedColors: Set<string>): string {
// Find first unused color from palette
for (const color of PROFILE_COLORS) {
if (!usedColors.has(color.toUpperCase())) {
return color;
}
}
// All colors used, cycle based on count
const index = usedColors.size % PROFILE_COLORS.length;
return PROFILE_COLORS[index] ?? PROFILE_COLORS[0];
}
export function getUsedColors(
profiles: Record<string, { color: string }> | undefined,
): Set<string> {
if (!profiles) {
return new Set();
}
return new Set(Object.values(profiles).map((p) => p.color.toUpperCase()));
}

View File

@@ -0,0 +1,40 @@
import { saveMediaBuffer } from "../media/store.js";
export type BrowserProxyFile = {
path: string;
base64: string;
mimeType?: string;
};
export async function persistBrowserProxyFiles(files: BrowserProxyFile[] | undefined) {
if (!files || files.length === 0) {
return new Map<string, string>();
}
const mapping = new Map<string, string>();
for (const file of files) {
const buffer = Buffer.from(file.base64, "base64");
const saved = await saveMediaBuffer(buffer, file.mimeType, "browser");
mapping.set(file.path, saved.path);
}
return mapping;
}
export function applyBrowserProxyPaths(result: unknown, mapping: Map<string, string>) {
if (!result || typeof result !== "object") {
return;
}
const obj = result as Record<string, unknown>;
if (typeof obj.path === "string" && mapping.has(obj.path)) {
obj.path = mapping.get(obj.path);
}
if (typeof obj.imagePath === "string" && mapping.has(obj.imagePath)) {
obj.imagePath = mapping.get(obj.imagePath);
}
const download = obj.download;
if (download && typeof download === "object") {
const d = download as Record<string, unknown>;
if (typeof d.path === "string" && mapping.has(d.path)) {
d.path = mapping.get(d.path);
}
}
}

View File

@@ -0,0 +1,51 @@
import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
export type PwAiModule = typeof import("./pw-ai.js");
type PwAiLoadMode = "soft" | "strict";
let pwAiModuleSoft: Promise<PwAiModule | null> | null = null;
let pwAiModuleStrict: Promise<PwAiModule | null> | null = null;
function isModuleNotFoundError(err: unknown): boolean {
const code = extractErrorCode(err);
if (code === "ERR_MODULE_NOT_FOUND") {
return true;
}
const msg = formatErrorMessage(err);
return (
msg.includes("Cannot find module") ||
msg.includes("Cannot find package") ||
msg.includes("Failed to resolve import") ||
msg.includes("Failed to resolve entry for package") ||
msg.includes("Failed to load url")
);
}
async function loadPwAiModule(mode: PwAiLoadMode): Promise<PwAiModule | null> {
try {
return await import("./pw-ai.js");
} catch (err) {
if (mode === "soft") {
return null;
}
if (isModuleNotFoundError(err)) {
return null;
}
throw err;
}
}
export async function getPwAiModule(opts?: { mode?: PwAiLoadMode }): Promise<PwAiModule | null> {
const mode: PwAiLoadMode = opts?.mode ?? "soft";
if (mode === "soft") {
if (!pwAiModuleSoft) {
pwAiModuleSoft = loadPwAiModule("soft");
}
return await pwAiModuleSoft;
}
if (!pwAiModuleStrict) {
pwAiModuleStrict = loadPwAiModule("strict");
}
return await pwAiModuleStrict;
}

View File

@@ -0,0 +1,9 @@
let pwAiLoaded = false;
export function markPwAiLoaded(): void {
pwAiLoaded = true;
}
export function isPwAiLoaded(): boolean {
return pwAiLoaded;
}

View File

@@ -0,0 +1,66 @@
import { markPwAiLoaded } from "./pw-ai-state.js";
markPwAiLoaded();
export {
type BrowserConsoleMessage,
closePageByTargetIdViaPlaywright,
closePlaywrightBrowserConnection,
createPageViaPlaywright,
ensurePageState,
forceDisconnectPlaywrightForTarget,
focusPageByTargetIdViaPlaywright,
getPageForTargetId,
listPagesViaPlaywright,
refLocator,
type WithSnapshotForAI,
} from "./pw-session.js";
export {
armDialogViaPlaywright,
armFileUploadViaPlaywright,
batchViaPlaywright,
clickViaPlaywright,
closePageViaPlaywright,
cookiesClearViaPlaywright,
cookiesGetViaPlaywright,
cookiesSetViaPlaywright,
downloadViaPlaywright,
dragViaPlaywright,
emulateMediaViaPlaywright,
evaluateViaPlaywright,
fillFormViaPlaywright,
getConsoleMessagesViaPlaywright,
getNetworkRequestsViaPlaywright,
getPageErrorsViaPlaywright,
highlightViaPlaywright,
hoverViaPlaywright,
navigateViaPlaywright,
pdfViaPlaywright,
pressKeyViaPlaywright,
resizeViewportViaPlaywright,
responseBodyViaPlaywright,
scrollIntoViewViaPlaywright,
selectOptionViaPlaywright,
setDeviceViaPlaywright,
setExtraHTTPHeadersViaPlaywright,
setGeolocationViaPlaywright,
setHttpCredentialsViaPlaywright,
setInputFilesViaPlaywright,
setLocaleViaPlaywright,
setOfflineViaPlaywright,
setTimezoneViaPlaywright,
snapshotAiViaPlaywright,
snapshotAriaViaPlaywright,
snapshotRoleViaPlaywright,
screenshotWithLabelsViaPlaywright,
storageClearViaPlaywright,
storageGetViaPlaywright,
storageSetViaPlaywright,
takeScreenshotViaPlaywright,
traceStartViaPlaywright,
traceStopViaPlaywright,
typeViaPlaywright,
waitForDownloadViaPlaywright,
waitForViaPlaywright,
} from "./pw-tools-core.js";

View File

@@ -0,0 +1,402 @@
import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js";
export type RoleRef = {
role: string;
name?: string;
/** Index used only when role+name duplicates exist. */
nth?: number;
};
export type RoleRefMap = Record<string, RoleRef>;
export type RoleSnapshotStats = {
lines: number;
chars: number;
refs: number;
interactive: number;
};
export type RoleSnapshotOptions = {
/** Only include interactive elements (buttons, links, inputs, etc.). */
interactive?: boolean;
/** Maximum depth to include (0 = root only). */
maxDepth?: number;
/** Remove unnamed structural elements and empty branches. */
compact?: boolean;
};
export function getRoleSnapshotStats(snapshot: string, refs: RoleRefMap): RoleSnapshotStats {
const interactive = Object.values(refs).filter((r) => INTERACTIVE_ROLES.has(r.role)).length;
return {
lines: snapshot.split("\n").length,
chars: snapshot.length,
refs: Object.keys(refs).length,
interactive,
};
}
function getIndentLevel(line: string): number {
const match = line.match(/^(\s*)/);
return match ? Math.floor(match[1].length / 2) : 0;
}
function matchInteractiveSnapshotLine(
line: string,
options: RoleSnapshotOptions,
): { roleRaw: string; role: string; name?: string; suffix: string } | null {
const depth = getIndentLevel(line);
if (options.maxDepth !== undefined && depth > options.maxDepth) {
return null;
}
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
if (!match) {
return null;
}
const [, , roleRaw, name, suffix] = match;
if (roleRaw.startsWith("/")) {
return null;
}
const role = roleRaw.toLowerCase();
return {
roleRaw,
role,
...(name ? { name } : {}),
suffix,
};
}
type RoleNameTracker = {
counts: Map<string, number>;
refsByKey: Map<string, string[]>;
getKey: (role: string, name?: string) => string;
getNextIndex: (role: string, name?: string) => number;
trackRef: (role: string, name: string | undefined, ref: string) => void;
getDuplicateKeys: () => Set<string>;
};
function createRoleNameTracker(): RoleNameTracker {
const counts = new Map<string, number>();
const refsByKey = new Map<string, string[]>();
return {
counts,
refsByKey,
getKey(role: string, name?: string) {
return `${role}:${name ?? ""}`;
},
getNextIndex(role: string, name?: string) {
const key = this.getKey(role, name);
const current = counts.get(key) ?? 0;
counts.set(key, current + 1);
return current;
},
trackRef(role: string, name: string | undefined, ref: string) {
const key = this.getKey(role, name);
const list = refsByKey.get(key) ?? [];
list.push(ref);
refsByKey.set(key, list);
},
getDuplicateKeys() {
const out = new Set<string>();
for (const [key, refs] of refsByKey) {
if (refs.length > 1) {
out.add(key);
}
}
return out;
},
};
}
function removeNthFromNonDuplicates(refs: RoleRefMap, tracker: RoleNameTracker) {
const duplicates = tracker.getDuplicateKeys();
for (const [ref, data] of Object.entries(refs)) {
const key = tracker.getKey(data.role, data.name);
if (!duplicates.has(key)) {
delete refs[ref]?.nth;
}
}
}
function compactTree(tree: string) {
const lines = tree.split("\n");
const result: string[] = [];
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (line.includes("[ref=")) {
result.push(line);
continue;
}
if (line.includes(":") && !line.trimEnd().endsWith(":")) {
result.push(line);
continue;
}
const currentIndent = getIndentLevel(line);
let hasRelevantChildren = false;
for (let j = i + 1; j < lines.length; j += 1) {
const childIndent = getIndentLevel(lines[j]);
if (childIndent <= currentIndent) {
break;
}
if (lines[j]?.includes("[ref=")) {
hasRelevantChildren = true;
break;
}
}
if (hasRelevantChildren) {
result.push(line);
}
}
return result.join("\n");
}
function processLine(
line: string,
refs: RoleRefMap,
options: RoleSnapshotOptions,
tracker: RoleNameTracker,
nextRef: () => string,
): string | null {
const depth = getIndentLevel(line);
if (options.maxDepth !== undefined && depth > options.maxDepth) {
return null;
}
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
if (!match) {
return options.interactive ? null : line;
}
const [, prefix, roleRaw, name, suffix] = match;
if (roleRaw.startsWith("/")) {
return options.interactive ? null : line;
}
const role = roleRaw.toLowerCase();
const isInteractive = INTERACTIVE_ROLES.has(role);
const isContent = CONTENT_ROLES.has(role);
const isStructural = STRUCTURAL_ROLES.has(role);
if (options.interactive && !isInteractive) {
return null;
}
if (options.compact && isStructural && !name) {
return null;
}
const shouldHaveRef = isInteractive || (isContent && name);
if (!shouldHaveRef) {
return line;
}
const ref = nextRef();
const nth = tracker.getNextIndex(role, name);
tracker.trackRef(role, name, ref);
refs[ref] = {
role,
name,
nth,
};
let enhanced = `${prefix}${roleRaw}`;
if (name) {
enhanced += ` "${name}"`;
}
enhanced += ` [ref=${ref}]`;
if (nth > 0) {
enhanced += ` [nth=${nth}]`;
}
if (suffix) {
enhanced += suffix;
}
return enhanced;
}
type InteractiveSnapshotLine = NonNullable<ReturnType<typeof matchInteractiveSnapshotLine>>;
function buildInteractiveSnapshotLines(params: {
lines: string[];
options: RoleSnapshotOptions;
resolveRef: (parsed: InteractiveSnapshotLine) => { ref: string; nth?: number } | null;
recordRef: (parsed: InteractiveSnapshotLine, ref: string, nth?: number) => void;
includeSuffix: (suffix: string) => boolean;
}): string[] {
const out: string[] = [];
for (const line of params.lines) {
const parsed = matchInteractiveSnapshotLine(line, params.options);
if (!parsed) {
continue;
}
if (!INTERACTIVE_ROLES.has(parsed.role)) {
continue;
}
const resolved = params.resolveRef(parsed);
if (!resolved?.ref) {
continue;
}
params.recordRef(parsed, resolved.ref, resolved.nth);
let enhanced = `- ${parsed.roleRaw}`;
if (parsed.name) {
enhanced += ` "${parsed.name}"`;
}
enhanced += ` [ref=${resolved.ref}]`;
if ((resolved.nth ?? 0) > 0) {
enhanced += ` [nth=${resolved.nth}]`;
}
if (params.includeSuffix(parsed.suffix)) {
enhanced += parsed.suffix;
}
out.push(enhanced);
}
return out;
}
export function parseRoleRef(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const normalized = trimmed.startsWith("@")
? trimmed.slice(1)
: trimmed.startsWith("ref=")
? trimmed.slice(4)
: trimmed;
return /^e\d+$/.test(normalized) ? normalized : null;
}
export function buildRoleSnapshotFromAriaSnapshot(
ariaSnapshot: string,
options: RoleSnapshotOptions = {},
): { snapshot: string; refs: RoleRefMap } {
const lines = ariaSnapshot.split("\n");
const refs: RoleRefMap = {};
const tracker = createRoleNameTracker();
let counter = 0;
const nextRef = () => {
counter += 1;
return `e${counter}`;
};
if (options.interactive) {
const result = buildInteractiveSnapshotLines({
lines,
options,
resolveRef: ({ role, name }) => {
const ref = nextRef();
const nth = tracker.getNextIndex(role, name);
tracker.trackRef(role, name, ref);
return { ref, nth };
},
recordRef: ({ role, name }, ref, nth) => {
refs[ref] = {
role,
name,
nth,
};
},
includeSuffix: (suffix) => suffix.includes("["),
});
removeNthFromNonDuplicates(refs, tracker);
return {
snapshot: result.join("\n") || "(no interactive elements)",
refs,
};
}
const result: string[] = [];
for (const line of lines) {
const processed = processLine(line, refs, options, tracker, nextRef);
if (processed !== null) {
result.push(processed);
}
}
removeNthFromNonDuplicates(refs, tracker);
const tree = result.join("\n") || "(empty)";
return {
snapshot: options.compact ? compactTree(tree) : tree,
refs,
};
}
function parseAiSnapshotRef(suffix: string): string | null {
const match = suffix.match(/\[ref=(e\d+)\]/i);
return match ? match[1] : null;
}
/**
* Build a role snapshot from Playwright's AI snapshot output while preserving Playwright's own
* aria-ref ids (e.g. ref=e13). This makes the refs self-resolving across calls.
*/
export function buildRoleSnapshotFromAiSnapshot(
aiSnapshot: string,
options: RoleSnapshotOptions = {},
): { snapshot: string; refs: RoleRefMap } {
const lines = String(aiSnapshot ?? "").split("\n");
const refs: RoleRefMap = {};
if (options.interactive) {
const out = buildInteractiveSnapshotLines({
lines,
options,
resolveRef: ({ suffix }) => {
const ref = parseAiSnapshotRef(suffix);
return ref ? { ref } : null;
},
recordRef: ({ role, name }, ref) => {
refs[ref] = { role, ...(name ? { name } : {}) };
},
includeSuffix: () => true,
});
return {
snapshot: out.join("\n") || "(no interactive elements)",
refs,
};
}
const out: string[] = [];
for (const line of lines) {
const depth = getIndentLevel(line);
if (options.maxDepth !== undefined && depth > options.maxDepth) {
continue;
}
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
if (!match) {
out.push(line);
continue;
}
const [, , roleRaw, name, suffix] = match;
if (roleRaw.startsWith("/")) {
out.push(line);
continue;
}
const role = roleRaw.toLowerCase();
const isStructural = STRUCTURAL_ROLES.has(role);
if (options.compact && isStructural && !name) {
continue;
}
const ref = parseAiSnapshotRef(suffix);
if (ref) {
refs[ref] = { role, ...(name ? { name } : {}) };
}
out.push(line);
}
const tree = out.join("\n") || "(empty)";
return {
snapshot: options.compact ? compactTree(tree) : tree,
refs,
};
}

View File

@@ -0,0 +1,15 @@
import { vi } from "vitest";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
export const connectOverCdpMock: MockFn = vi.fn();
export const getChromeWebSocketUrlMock: MockFn = vi.fn();
vi.mock("playwright-core", () => ({
chromium: {
connectOverCDP: (...args: unknown[]) => connectOverCdpMock(...args),
},
}));
vi.mock("./chrome.js", () => ({
getChromeWebSocketUrl: (...args: unknown[]) => getChromeWebSocketUrlMock(...args),
}));

View File

@@ -0,0 +1,33 @@
import type { CDPSession, Page } from "playwright-core";
type PageCdpSend = (method: string, params?: Record<string, unknown>) => Promise<unknown>;
async function withPlaywrightPageCdpSession<T>(
page: Page,
fn: (session: CDPSession) => Promise<T>,
): Promise<T> {
const session = await page.context().newCDPSession(page);
try {
return await fn(session);
} finally {
await session.detach().catch(() => {});
}
}
export async function withPageScopedCdpClient<T>(opts: {
cdpUrl: string;
page: Page;
targetId?: string;
fn: (send: PageCdpSend) => Promise<T>;
}): Promise<T> {
return await withPlaywrightPageCdpSession(opts.page, async (session) => {
return await opts.fn((method, params) =>
(
session.send as unknown as (
method: string,
params?: Record<string, unknown>,
) => Promise<unknown>
)(method, params),
);
});
}

View File

@@ -0,0 +1,845 @@
import type {
Browser,
BrowserContext,
ConsoleMessage,
Page,
Request,
Response,
} from "playwright-core";
import { chromium } from "playwright-core";
import { formatErrorMessage } from "../infra/errors.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
import {
appendCdpPath,
fetchJson,
getHeadersWithAuth,
normalizeCdpHttpBaseForJsonEndpoints,
withCdpSocket,
} from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import { getChromeWebSocketUrl } from "./chrome.js";
import { BrowserTabNotFoundError } from "./errors.js";
import {
assertBrowserNavigationAllowed,
assertBrowserNavigationRedirectChainAllowed,
assertBrowserNavigationResultAllowed,
withBrowserNavigationPolicy,
} from "./navigation-guard.js";
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
export type BrowserConsoleMessage = {
type: string;
text: string;
timestamp: string;
location?: { url?: string; lineNumber?: number; columnNumber?: number };
};
export type BrowserPageError = {
message: string;
name?: string;
stack?: string;
timestamp: string;
};
export type BrowserNetworkRequest = {
id: string;
timestamp: string;
method: string;
url: string;
resourceType?: string;
status?: number;
ok?: boolean;
failureText?: string;
};
type SnapshotForAIResult = { full: string; incremental?: string };
type SnapshotForAIOptions = { timeout?: number; track?: string };
export type WithSnapshotForAI = {
_snapshotForAI?: (options?: SnapshotForAIOptions) => Promise<SnapshotForAIResult>;
};
type TargetInfoResponse = {
targetInfo?: {
targetId?: string;
};
};
type ConnectedBrowser = {
browser: Browser;
cdpUrl: string;
onDisconnected?: () => void;
};
type PageState = {
console: BrowserConsoleMessage[];
errors: BrowserPageError[];
requests: BrowserNetworkRequest[];
requestIds: WeakMap<Request, string>;
nextRequestId: number;
armIdUpload: number;
armIdDialog: number;
armIdDownload: number;
/**
* Role-based refs from the last role snapshot (e.g. e1/e2).
* Mode "role" refs are generated from ariaSnapshot and resolved via getByRole.
* Mode "aria" refs are Playwright aria-ref ids and resolved via `aria-ref=...`.
*/
roleRefs?: Record<string, { role: string; name?: string; nth?: number }>;
roleRefsMode?: "role" | "aria";
roleRefsFrameSelector?: string;
};
type RoleRefs = NonNullable<PageState["roleRefs"]>;
type RoleRefsCacheEntry = {
refs: RoleRefs;
frameSelector?: string;
mode?: NonNullable<PageState["roleRefsMode"]>;
};
type ContextState = {
traceActive: boolean;
};
const pageStates = new WeakMap<Page, PageState>();
const contextStates = new WeakMap<BrowserContext, ContextState>();
const observedContexts = new WeakSet<BrowserContext>();
const observedPages = new WeakSet<Page>();
// Best-effort cache to make role refs stable even if Playwright returns a different Page object
// for the same CDP target across requests.
const roleRefsByTarget = new Map<string, RoleRefsCacheEntry>();
const MAX_ROLE_REFS_CACHE = 50;
const MAX_CONSOLE_MESSAGES = 500;
const MAX_PAGE_ERRORS = 200;
const MAX_NETWORK_REQUESTS = 500;
const cachedByCdpUrl = new Map<string, ConnectedBrowser>();
const connectingByCdpUrl = new Map<string, Promise<ConnectedBrowser>>();
function normalizeCdpUrl(raw: string) {
return raw.replace(/\/$/, "");
}
function findNetworkRequestById(state: PageState, id: string): BrowserNetworkRequest | undefined {
for (let i = state.requests.length - 1; i >= 0; i -= 1) {
const candidate = state.requests[i];
if (candidate && candidate.id === id) {
return candidate;
}
}
return undefined;
}
function roleRefsKey(cdpUrl: string, targetId: string) {
return `${normalizeCdpUrl(cdpUrl)}::${targetId}`;
}
export function rememberRoleRefsForTarget(opts: {
cdpUrl: string;
targetId: string;
refs: RoleRefs;
frameSelector?: string;
mode?: NonNullable<PageState["roleRefsMode"]>;
}): void {
const targetId = opts.targetId.trim();
if (!targetId) {
return;
}
roleRefsByTarget.set(roleRefsKey(opts.cdpUrl, targetId), {
refs: opts.refs,
...(opts.frameSelector ? { frameSelector: opts.frameSelector } : {}),
...(opts.mode ? { mode: opts.mode } : {}),
});
while (roleRefsByTarget.size > MAX_ROLE_REFS_CACHE) {
const first = roleRefsByTarget.keys().next();
if (first.done) {
break;
}
roleRefsByTarget.delete(first.value);
}
}
export function storeRoleRefsForTarget(opts: {
page: Page;
cdpUrl: string;
targetId?: string;
refs: RoleRefs;
frameSelector?: string;
mode: NonNullable<PageState["roleRefsMode"]>;
}): void {
const state = ensurePageState(opts.page);
state.roleRefs = opts.refs;
state.roleRefsFrameSelector = opts.frameSelector;
state.roleRefsMode = opts.mode;
if (!opts.targetId?.trim()) {
return;
}
rememberRoleRefsForTarget({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
refs: opts.refs,
frameSelector: opts.frameSelector,
mode: opts.mode,
});
}
export function restoreRoleRefsForTarget(opts: {
cdpUrl: string;
targetId?: string;
page: Page;
}): void {
const targetId = opts.targetId?.trim() || "";
if (!targetId) {
return;
}
const cached = roleRefsByTarget.get(roleRefsKey(opts.cdpUrl, targetId));
if (!cached) {
return;
}
const state = ensurePageState(opts.page);
if (state.roleRefs) {
return;
}
state.roleRefs = cached.refs;
state.roleRefsFrameSelector = cached.frameSelector;
state.roleRefsMode = cached.mode;
}
export function ensurePageState(page: Page): PageState {
const existing = pageStates.get(page);
if (existing) {
return existing;
}
const state: PageState = {
console: [],
errors: [],
requests: [],
requestIds: new WeakMap(),
nextRequestId: 0,
armIdUpload: 0,
armIdDialog: 0,
armIdDownload: 0,
};
pageStates.set(page, state);
if (!observedPages.has(page)) {
observedPages.add(page);
page.on("console", (msg: ConsoleMessage) => {
const entry: BrowserConsoleMessage = {
type: msg.type(),
text: msg.text(),
timestamp: new Date().toISOString(),
location: msg.location(),
};
state.console.push(entry);
if (state.console.length > MAX_CONSOLE_MESSAGES) {
state.console.shift();
}
});
page.on("pageerror", (err: Error) => {
state.errors.push({
message: err?.message ? String(err.message) : String(err),
name: err?.name ? String(err.name) : undefined,
stack: err?.stack ? String(err.stack) : undefined,
timestamp: new Date().toISOString(),
});
if (state.errors.length > MAX_PAGE_ERRORS) {
state.errors.shift();
}
});
page.on("request", (req: Request) => {
state.nextRequestId += 1;
const id = `r${state.nextRequestId}`;
state.requestIds.set(req, id);
state.requests.push({
id,
timestamp: new Date().toISOString(),
method: req.method(),
url: req.url(),
resourceType: req.resourceType(),
});
if (state.requests.length > MAX_NETWORK_REQUESTS) {
state.requests.shift();
}
});
page.on("response", (resp: Response) => {
const req = resp.request();
const id = state.requestIds.get(req);
if (!id) {
return;
}
const rec = findNetworkRequestById(state, id);
if (!rec) {
return;
}
rec.status = resp.status();
rec.ok = resp.ok();
});
page.on("requestfailed", (req: Request) => {
const id = state.requestIds.get(req);
if (!id) {
return;
}
const rec = findNetworkRequestById(state, id);
if (!rec) {
return;
}
rec.failureText = req.failure()?.errorText;
rec.ok = false;
});
page.on("close", () => {
pageStates.delete(page);
observedPages.delete(page);
});
}
return state;
}
function observeContext(context: BrowserContext) {
if (observedContexts.has(context)) {
return;
}
observedContexts.add(context);
ensureContextState(context);
for (const page of context.pages()) {
ensurePageState(page);
}
context.on("page", (page) => ensurePageState(page));
}
export function ensureContextState(context: BrowserContext): ContextState {
const existing = contextStates.get(context);
if (existing) {
return existing;
}
const state: ContextState = { traceActive: false };
contextStates.set(context, state);
return state;
}
function observeBrowser(browser: Browser) {
for (const context of browser.contexts()) {
observeContext(context);
}
}
async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
const normalized = normalizeCdpUrl(cdpUrl);
const cached = cachedByCdpUrl.get(normalized);
if (cached) {
return cached;
}
const connecting = connectingByCdpUrl.get(normalized);
if (connecting) {
return await connecting;
}
const connectWithRetry = async (): Promise<ConnectedBrowser> => {
let lastErr: unknown;
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 headers = getHeadersWithAuth(endpoint);
// Bypass proxy for loopback CDP connections (#31219)
const browser = await withNoProxyForCdpUrl(endpoint, () =>
chromium.connectOverCDP(endpoint, { timeout, headers }),
);
const onDisconnected = () => {
const current = cachedByCdpUrl.get(normalized);
if (current?.browser === browser) {
cachedByCdpUrl.delete(normalized);
}
};
const connected: ConnectedBrowser = { browser, cdpUrl: normalized, onDisconnected };
cachedByCdpUrl.set(normalized, connected);
browser.on("disconnected", onDisconnected);
observeBrowser(browser);
return connected;
} catch (err) {
lastErr = err;
// Don't retry rate-limit errors; retrying worsens the 429.
const errMsg = err instanceof Error ? err.message : String(err);
if (errMsg.includes("rate limit")) {
break;
}
const delay = 250 + attempt * 250;
await new Promise((r) => setTimeout(r, delay));
}
}
if (lastErr instanceof Error) {
throw lastErr;
}
const message = lastErr ? formatErrorMessage(lastErr) : "CDP connect failed";
throw new Error(message);
};
const pending = connectWithRetry().finally(() => {
connectingByCdpUrl.delete(normalized);
});
connectingByCdpUrl.set(normalized, pending);
return await pending;
}
async function getAllPages(browser: Browser): Promise<Page[]> {
const contexts = browser.contexts();
const pages = contexts.flatMap((c) => c.pages());
return pages;
}
async function pageTargetId(page: Page): Promise<string | null> {
const session = await page.context().newCDPSession(page);
try {
const info = (await session.send("Target.getTargetInfo")) as TargetInfoResponse;
const targetId = String(info?.targetInfo?.targetId ?? "").trim();
return targetId || null;
} finally {
await session.detach().catch(() => {});
}
}
function matchPageByTargetList(
pages: Page[],
targets: Array<{ id: string; url: string; title?: string }>,
targetId: string,
): Page | null {
const target = targets.find((entry) => entry.id === targetId);
if (!target) {
return null;
}
const urlMatch = pages.filter((page) => page.url() === target.url);
if (urlMatch.length === 1) {
return urlMatch[0] ?? null;
}
if (urlMatch.length > 1) {
const sameUrlTargets = targets.filter((entry) => entry.url === target.url);
if (sameUrlTargets.length === urlMatch.length) {
const idx = sameUrlTargets.findIndex((entry) => entry.id === targetId);
if (idx >= 0 && idx < urlMatch.length) {
return urlMatch[idx] ?? null;
}
}
}
return null;
}
async function findPageByTargetIdViaTargetList(
pages: Page[],
targetId: string,
cdpUrl: string,
): Promise<Page | null> {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl);
const targets = await fetchJson<
Array<{
id: string;
url: string;
title?: string;
}>
>(appendCdpPath(cdpHttpBase, "/json/list"), 2000);
return matchPageByTargetList(pages, targets, targetId);
}
async function findPageByTargetId(
browser: Browser,
targetId: string,
cdpUrl?: string,
): Promise<Page | null> {
const pages = await getAllPages(browser);
let resolvedViaCdp = false;
for (const page of pages) {
let tid: string | null = null;
try {
tid = await pageTargetId(page);
resolvedViaCdp = true;
} catch {
tid = null;
}
if (tid && tid === targetId) {
return page;
}
}
if (cdpUrl) {
try {
return await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl);
} catch {
// Ignore fetch errors and fall through to return null.
}
}
if (!resolvedViaCdp && pages.length === 1) {
return pages[0] ?? null;
}
return null;
}
async function resolvePageByTargetIdOrThrow(opts: {
cdpUrl: string;
targetId: string;
}): Promise<Page> {
const { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) {
throw new BrowserTabNotFoundError();
}
return page;
}
export async function getPageForTargetId(opts: {
cdpUrl: string;
targetId?: string;
}): Promise<Page> {
const { browser } = await connectBrowser(opts.cdpUrl);
const pages = await getAllPages(browser);
if (!pages.length) {
throw new Error("No pages available in the connected browser.");
}
const first = pages[0];
if (!opts.targetId) {
return first;
}
const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!found) {
// If Playwright only exposes a single Page, use it as a best-effort fallback.
if (pages.length === 1) {
return first;
}
throw new BrowserTabNotFoundError();
}
return found;
}
export function refLocator(page: Page, ref: string) {
const normalized = ref.startsWith("@")
? ref.slice(1)
: ref.startsWith("ref=")
? ref.slice(4)
: ref;
if (/^e\d+$/.test(normalized)) {
const state = pageStates.get(page);
if (state?.roleRefsMode === "aria") {
const scope = state.roleRefsFrameSelector
? page.frameLocator(state.roleRefsFrameSelector)
: page;
return scope.locator(`aria-ref=${normalized}`);
}
const info = state?.roleRefs?.[normalized];
if (!info) {
throw new Error(
`Unknown ref "${normalized}". Run a new snapshot and use a ref from that snapshot.`,
);
}
const scope = state?.roleRefsFrameSelector
? page.frameLocator(state.roleRefsFrameSelector)
: page;
const locAny = scope as unknown as {
getByRole: (
role: never,
opts?: { name?: string; exact?: boolean },
) => ReturnType<Page["getByRole"]>;
};
const locator = info.name
? locAny.getByRole(info.role as never, { name: info.name, exact: true })
: locAny.getByRole(info.role as never);
return info.nth !== undefined ? locator.nth(info.nth) : locator;
}
return page.locator(`aria-ref=${normalized}`);
}
export async function closePlaywrightBrowserConnection(opts?: { cdpUrl?: string }): Promise<void> {
const normalized = opts?.cdpUrl ? normalizeCdpUrl(opts.cdpUrl) : null;
if (normalized) {
const cur = cachedByCdpUrl.get(normalized);
cachedByCdpUrl.delete(normalized);
connectingByCdpUrl.delete(normalized);
if (!cur) {
return;
}
if (cur.onDisconnected && typeof cur.browser.off === "function") {
cur.browser.off("disconnected", cur.onDisconnected);
}
await cur.browser.close().catch(() => {});
return;
}
const connections = Array.from(cachedByCdpUrl.values());
cachedByCdpUrl.clear();
connectingByCdpUrl.clear();
for (const cur of connections) {
if (cur.onDisconnected && typeof cur.browser.off === "function") {
cur.browser.off("disconnected", cur.onDisconnected);
}
await cur.browser.close().catch(() => {});
}
}
function cdpSocketNeedsAttach(wsUrl: string): boolean {
try {
const pathname = new URL(wsUrl).pathname;
return (
pathname === "/cdp" || pathname.endsWith("/cdp") || pathname.includes("/devtools/browser/")
);
} catch {
return false;
}
}
async function tryTerminateExecutionViaCdp(opts: {
cdpUrl: string;
targetId: string;
}): Promise<void> {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(opts.cdpUrl);
const listUrl = appendCdpPath(cdpHttpBase, "/json/list");
const pages = await fetchJson<
Array<{
id?: string;
webSocketDebuggerUrl?: string;
}>
>(listUrl, 2000).catch(() => null);
if (!pages || pages.length === 0) {
return;
}
const target = pages.find((p) => String(p.id ?? "").trim() === opts.targetId);
const wsUrlRaw = String(target?.webSocketDebuggerUrl ?? "").trim();
if (!wsUrlRaw) {
return;
}
const wsUrl = normalizeCdpWsUrl(wsUrlRaw, cdpHttpBase);
const needsAttach = cdpSocketNeedsAttach(wsUrl);
const runWithTimeout = async <T>(work: Promise<T>, ms: number): Promise<T> => {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("CDP command timed out")), ms);
});
try {
return await Promise.race([work, timeoutPromise]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
};
await withCdpSocket(
wsUrl,
async (send) => {
let sessionId: string | undefined;
try {
if (needsAttach) {
const attached = (await runWithTimeout(
send("Target.attachToTarget", { targetId: opts.targetId, flatten: true }),
1500,
)) as { sessionId?: unknown };
if (typeof attached?.sessionId === "string" && attached.sessionId.trim()) {
sessionId = attached.sessionId;
}
}
await runWithTimeout(send("Runtime.terminateExecution", undefined, sessionId), 1500);
if (sessionId) {
// Best-effort cleanup; not required for termination to take effect.
void send("Target.detachFromTarget", { sessionId }).catch(() => {});
}
} catch {
// Best-effort; ignore
}
},
{ handshakeTimeoutMs: 2000 },
).catch(() => {});
}
/**
* Best-effort cancellation for stuck page operations.
*
* Playwright serializes CDP commands per page; a long-running or stuck operation (notably evaluate)
* can block all subsequent commands. We cannot safely "cancel" an individual command, and we do
* not want to close the actual Chromium tab. Instead, we disconnect Playwright's CDP connection
* so in-flight commands fail fast and the next request reconnects transparently.
*
* IMPORTANT: We CANNOT call Connection.close() because Playwright shares a single Connection
* across all objects (BrowserType, Browser, etc.). Closing it corrupts the entire Playwright
* instance, preventing reconnection.
*
* Instead we:
* 1. Null out `cached` so the next call triggers a fresh connectOverCDP
* 2. Fire-and-forget browser.close() — it may hang but won't block us
* 3. The next connectBrowser() creates a completely new CDP WebSocket connection
*
* The old browser.close() eventually resolves when the in-browser evaluate timeout fires,
* or the old connection gets GC'd. Either way, it doesn't affect the fresh connection.
*/
export async function forceDisconnectPlaywrightForTarget(opts: {
cdpUrl: string;
targetId?: string;
reason?: string;
}): Promise<void> {
const normalized = normalizeCdpUrl(opts.cdpUrl);
const cur = cachedByCdpUrl.get(normalized);
if (!cur) {
return;
}
cachedByCdpUrl.delete(normalized);
// Also clear the per-url in-flight connect so the next call does a fresh connectOverCDP
// rather than awaiting a stale promise.
connectingByCdpUrl.delete(normalized);
// Remove the "disconnected" listener to prevent the old browser's teardown
// from racing with a fresh connection and nulling the new cached entry.
if (cur.onDisconnected && typeof cur.browser.off === "function") {
cur.browser.off("disconnected", cur.onDisconnected);
}
// Best-effort: kill any stuck JS to unblock the target's execution context before we
// disconnect Playwright's CDP connection.
const targetId = opts.targetId?.trim() || "";
if (targetId) {
await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId }).catch(() => {});
}
// Fire-and-forget: don't await because browser.close() may hang on the stuck CDP pipe.
cur.browser.close().catch(() => {});
}
/**
* List all pages/tabs from the persistent Playwright connection.
* Used for remote profiles where HTTP-based /json/list is ephemeral.
*/
export async function listPagesViaPlaywright(opts: { cdpUrl: string }): Promise<
Array<{
targetId: string;
title: string;
url: string;
type: string;
}>
> {
const { browser } = await connectBrowser(opts.cdpUrl);
const pages = await getAllPages(browser);
const results: Array<{
targetId: string;
title: string;
url: string;
type: string;
}> = [];
for (const page of pages) {
const tid = await pageTargetId(page).catch(() => null);
if (tid) {
results.push({
targetId: tid,
title: await page.title().catch(() => ""),
url: page.url(),
type: "page",
});
}
}
return results;
}
/**
* Create a new page/tab using the persistent Playwright connection.
* Used for remote profiles where HTTP-based /json/new is ephemeral.
* Returns the new page's targetId and metadata.
*/
export async function createPageViaPlaywright(opts: {
cdpUrl: string;
url: string;
ssrfPolicy?: SsrFPolicy;
}): Promise<{
targetId: string;
title: string;
url: string;
type: string;
}> {
const { browser } = await connectBrowser(opts.cdpUrl);
const context = browser.contexts()[0] ?? (await browser.newContext());
ensureContextState(context);
const page = await context.newPage();
ensurePageState(page);
// Navigate to the URL
const targetUrl = opts.url.trim() || "about:blank";
if (targetUrl !== "about:blank") {
const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy);
await assertBrowserNavigationAllowed({
url: targetUrl,
...navigationPolicy,
});
const response = await page.goto(targetUrl, { timeout: 30_000 }).catch(() => {
// Navigation might fail for some URLs, but page is still created
return null;
});
await assertBrowserNavigationRedirectChainAllowed({
request: response?.request(),
...navigationPolicy,
});
await assertBrowserNavigationResultAllowed({
url: page.url(),
...navigationPolicy,
});
}
// Get the targetId for this page
const tid = await pageTargetId(page).catch(() => null);
if (!tid) {
throw new Error("Failed to get targetId for new page");
}
return {
targetId: tid,
title: await page.title().catch(() => ""),
url: page.url(),
type: "page",
};
}
/**
* Close a page/tab by targetId using the persistent Playwright connection.
* Used for remote profiles where HTTP-based /json/close is ephemeral.
*/
export async function closePageByTargetIdViaPlaywright(opts: {
cdpUrl: string;
targetId: string;
}): Promise<void> {
const page = await resolvePageByTargetIdOrThrow(opts);
await page.close();
}
/**
* Focus a page/tab by targetId using the persistent Playwright connection.
* Used for remote profiles where HTTP-based /json/activate can be ephemeral.
*/
export async function focusPageByTargetIdViaPlaywright(opts: {
cdpUrl: string;
targetId: string;
}): Promise<void> {
const page = await resolvePageByTargetIdOrThrow(opts);
try {
await page.bringToFront();
} catch (err) {
try {
await withPageScopedCdpClient({
cdpUrl: opts.cdpUrl,
page,
targetId: opts.targetId,
fn: async (send) => {
await send("Page.bringToFront");
},
});
return;
} catch {
throw err;
}
}
}

View File

@@ -0,0 +1,68 @@
import type {
BrowserConsoleMessage,
BrowserNetworkRequest,
BrowserPageError,
} from "./pw-session.js";
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
export async function getPageErrorsViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
clear?: boolean;
}): Promise<{ errors: BrowserPageError[] }> {
const page = await getPageForTargetId(opts);
const state = ensurePageState(page);
const errors = [...state.errors];
if (opts.clear) {
state.errors = [];
}
return { errors };
}
export async function getNetworkRequestsViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
filter?: string;
clear?: boolean;
}): Promise<{ requests: BrowserNetworkRequest[] }> {
const page = await getPageForTargetId(opts);
const state = ensurePageState(page);
const raw = [...state.requests];
const filter = typeof opts.filter === "string" ? opts.filter.trim() : "";
const requests = filter ? raw.filter((r) => r.url.includes(filter)) : raw;
if (opts.clear) {
state.requests = [];
state.requestIds = new WeakMap();
}
return { requests };
}
function consolePriority(level: string) {
switch (level) {
case "error":
return 3;
case "warning":
return 2;
case "info":
case "log":
return 1;
case "debug":
return 0;
default:
return 1;
}
}
export async function getConsoleMessagesViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
level?: string;
}): Promise<BrowserConsoleMessage[]> {
const page = await getPageForTargetId(opts);
const state = ensurePageState(page);
if (!opts.level) {
return [...state.console];
}
const min = consolePriority(opts.level);
return state.console.filter((msg) => consolePriority(msg.type) >= min);
}

View File

@@ -0,0 +1,280 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright-core";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { writeViaSiblingTempPath } from "./output-atomic.js";
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
import {
ensurePageState,
getPageForTargetId,
refLocator,
restoreRoleRefsForTarget,
} from "./pw-session.js";
import {
bumpDialogArmId,
bumpDownloadArmId,
bumpUploadArmId,
normalizeTimeoutMs,
requireRef,
toAIFriendlyError,
} from "./pw-tools-core.shared.js";
import { sanitizeUntrustedFileName } from "./safe-filename.js";
function buildTempDownloadPath(fileName: string): string {
const id = crypto.randomUUID();
const safeName = sanitizeUntrustedFileName(fileName, "download.bin");
return path.join(resolvePreferredOpenClawTmpDir(), "downloads", `${id}-${safeName}`);
}
function createPageDownloadWaiter(page: Page, timeoutMs: number) {
let done = false;
let timer: NodeJS.Timeout | undefined;
let handler: ((download: unknown) => void) | undefined;
const cleanup = () => {
if (timer) {
clearTimeout(timer);
}
timer = undefined;
if (handler) {
page.off("download", handler as never);
handler = undefined;
}
};
const promise = new Promise<unknown>((resolve, reject) => {
handler = (download: unknown) => {
if (done) {
return;
}
done = true;
cleanup();
resolve(download);
};
page.on("download", handler as never);
timer = setTimeout(() => {
if (done) {
return;
}
done = true;
cleanup();
reject(new Error("Timeout waiting for download"));
}, timeoutMs);
});
return {
promise,
cancel: () => {
if (done) {
return;
}
done = true;
cleanup();
},
};
}
type DownloadPayload = {
url?: () => string;
suggestedFilename?: () => string;
saveAs?: (outPath: string) => Promise<void>;
};
async function saveDownloadPayload(download: DownloadPayload, outPath: string) {
const suggested = download.suggestedFilename?.() || "download.bin";
const requestedPath = outPath?.trim();
const resolvedOutPath = path.resolve(requestedPath || buildTempDownloadPath(suggested));
await fs.mkdir(path.dirname(resolvedOutPath), { recursive: true });
if (!requestedPath) {
await download.saveAs?.(resolvedOutPath);
} else {
await writeViaSiblingTempPath({
rootDir: path.dirname(resolvedOutPath),
targetPath: resolvedOutPath,
writeTemp: async (tempPath) => {
await download.saveAs?.(tempPath);
},
});
}
return {
url: download.url?.() || "",
suggestedFilename: suggested,
path: resolvedOutPath,
};
}
async function awaitDownloadPayload(params: {
waiter: ReturnType<typeof createPageDownloadWaiter>;
state: ReturnType<typeof ensurePageState>;
armId: number;
outPath?: string;
}) {
try {
const download = (await params.waiter.promise) as DownloadPayload;
if (params.state.armIdDownload !== params.armId) {
throw new Error("Download was superseded by another waiter");
}
return await saveDownloadPayload(download, params.outPath ?? "");
} catch (err) {
params.waiter.cancel();
throw err;
}
}
export async function armFileUploadViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
paths?: string[];
timeoutMs?: number;
}): Promise<void> {
const page = await getPageForTargetId(opts);
const state = ensurePageState(page);
const timeout = Math.max(500, Math.min(120_000, opts.timeoutMs ?? 120_000));
state.armIdUpload = bumpUploadArmId();
const armId = state.armIdUpload;
void page
.waitForEvent("filechooser", { timeout })
.then(async (fileChooser) => {
if (state.armIdUpload !== armId) {
return;
}
if (!opts.paths?.length) {
// Playwright removed `FileChooser.cancel()`; best-effort close the chooser instead.
try {
await page.keyboard.press("Escape");
} catch {
// Best-effort.
}
return;
}
const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
rootDir: DEFAULT_UPLOAD_DIR,
requestedPaths: opts.paths,
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
});
if (!uploadPathsResult.ok) {
try {
await page.keyboard.press("Escape");
} catch {
// Best-effort.
}
return;
}
await fileChooser.setFiles(uploadPathsResult.paths);
try {
const input =
typeof fileChooser.element === "function"
? await Promise.resolve(fileChooser.element())
: null;
if (input) {
await input.evaluate((el) => {
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
});
}
} catch {
// Best-effort for sites that don't react to setFiles alone.
}
})
.catch(() => {
// Ignore timeouts; the chooser may never appear.
});
}
export async function armDialogViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
accept: boolean;
promptText?: string;
timeoutMs?: number;
}): Promise<void> {
const page = await getPageForTargetId(opts);
const state = ensurePageState(page);
const timeout = normalizeTimeoutMs(opts.timeoutMs, 120_000);
state.armIdDialog = bumpDialogArmId();
const armId = state.armIdDialog;
void page
.waitForEvent("dialog", { timeout })
.then(async (dialog) => {
if (state.armIdDialog !== armId) {
return;
}
if (opts.accept) {
await dialog.accept(opts.promptText);
} else {
await dialog.dismiss();
}
})
.catch(() => {
// Ignore timeouts; the dialog may never appear.
});
}
export async function waitForDownloadViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
path?: string;
timeoutMs?: number;
}): Promise<{
url: string;
suggestedFilename: string;
path: string;
}> {
const page = await getPageForTargetId(opts);
const state = ensurePageState(page);
const timeout = normalizeTimeoutMs(opts.timeoutMs, 120_000);
state.armIdDownload = bumpDownloadArmId();
const armId = state.armIdDownload;
const waiter = createPageDownloadWaiter(page, timeout);
return await awaitDownloadPayload({ waiter, state, armId, outPath: opts.path });
}
export async function downloadViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
ref: string;
path: string;
timeoutMs?: number;
}): Promise<{
url: string;
suggestedFilename: string;
path: string;
}> {
const page = await getPageForTargetId(opts);
const state = ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
const timeout = normalizeTimeoutMs(opts.timeoutMs, 120_000);
const ref = requireRef(opts.ref);
const outPath = String(opts.path ?? "").trim();
if (!outPath) {
throw new Error("path is required");
}
state.armIdDownload = bumpDownloadArmId();
const armId = state.armIdDownload;
const waiter = createPageDownloadWaiter(page, timeout);
try {
const locator = refLocator(page, ref);
try {
await locator.click({ timeout });
} catch (err) {
throw toAIFriendlyError(err, ref);
}
return await awaitDownloadPayload({ waiter, state, armId, outPath });
} catch (err) {
waiter.cancel();
throw err;
}
}

View File

@@ -0,0 +1,891 @@
import type { BrowserActRequest, BrowserFormField } from "./client-actions-core.js";
import { DEFAULT_FILL_FIELD_TYPE } from "./form-fields.js";
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
import {
ensurePageState,
forceDisconnectPlaywrightForTarget,
getPageForTargetId,
refLocator,
restoreRoleRefsForTarget,
} from "./pw-session.js";
import {
normalizeTimeoutMs,
requireRef,
requireRefOrSelector,
toAIFriendlyError,
} from "./pw-tools-core.shared.js";
import { closePageViaPlaywright, resizeViewportViaPlaywright } from "./pw-tools-core.snapshot.js";
type TargetOpts = {
cdpUrl: string;
targetId?: string;
};
const MAX_CLICK_DELAY_MS = 5_000;
const MAX_WAIT_TIME_MS = 30_000;
const MAX_BATCH_ACTIONS = 100;
function resolveBoundedDelayMs(value: number | undefined, label: string, maxMs: number): number {
const normalized = Math.floor(value ?? 0);
if (!Number.isFinite(normalized) || normalized < 0) {
throw new Error(`${label} must be >= 0`);
}
if (normalized > maxMs) {
throw new Error(`${label} exceeds maximum of ${maxMs}ms`);
}
return normalized;
}
async function getRestoredPageForTarget(opts: TargetOpts) {
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
return page;
}
function resolveInteractionTimeoutMs(timeoutMs?: number): number {
return Math.max(500, Math.min(60_000, Math.floor(timeoutMs ?? 8000)));
}
async function awaitEvalWithAbort<T>(
evalPromise: Promise<T>,
abortPromise?: Promise<never>,
): Promise<T> {
if (!abortPromise) {
return await evalPromise;
}
try {
return await Promise.race([evalPromise, abortPromise]);
} catch (err) {
// If abort wins the race, evaluate may reject later; avoid unhandled rejections.
void evalPromise.catch(() => {});
throw err;
}
}
export async function highlightViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
ref: string;
}): Promise<void> {
const page = await getRestoredPageForTarget(opts);
const ref = requireRef(opts.ref);
try {
await refLocator(page, ref).highlight();
} catch (err) {
throw toAIFriendlyError(err, ref);
}
}
export async function clickViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
ref?: string;
selector?: string;
doubleClick?: boolean;
button?: "left" | "right" | "middle";
modifiers?: Array<"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift">;
delayMs?: number;
timeoutMs?: number;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const page = await getRestoredPageForTarget(opts);
const label = resolved.ref ?? resolved.selector!;
const locator = resolved.ref
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
try {
const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS);
if (delayMs > 0) {
await locator.hover({ timeout });
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
if (opts.doubleClick) {
await locator.dblclick({
timeout,
button: opts.button,
modifiers: opts.modifiers,
});
} else {
await locator.click({
timeout,
button: opts.button,
modifiers: opts.modifiers,
});
}
} catch (err) {
throw toAIFriendlyError(err, label);
}
}
export async function hoverViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
ref?: string;
selector?: string;
timeoutMs?: number;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const page = await getRestoredPageForTarget(opts);
const label = resolved.ref ?? resolved.selector!;
const locator = resolved.ref
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
try {
await locator.hover({
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
});
} catch (err) {
throw toAIFriendlyError(err, label);
}
}
export async function dragViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
startRef?: string;
startSelector?: string;
endRef?: string;
endSelector?: string;
timeoutMs?: number;
}): Promise<void> {
const resolvedStart = requireRefOrSelector(opts.startRef, opts.startSelector);
const resolvedEnd = requireRefOrSelector(opts.endRef, opts.endSelector);
const page = await getRestoredPageForTarget(opts);
const startLocator = resolvedStart.ref
? refLocator(page, requireRef(resolvedStart.ref))
: page.locator(resolvedStart.selector!);
const endLocator = resolvedEnd.ref
? refLocator(page, requireRef(resolvedEnd.ref))
: page.locator(resolvedEnd.selector!);
const startLabel = resolvedStart.ref ?? resolvedStart.selector!;
const endLabel = resolvedEnd.ref ?? resolvedEnd.selector!;
try {
await startLocator.dragTo(endLocator, {
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
});
} catch (err) {
throw toAIFriendlyError(err, `${startLabel} -> ${endLabel}`);
}
}
export async function selectOptionViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
ref?: string;
selector?: string;
values: string[];
timeoutMs?: number;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
if (!opts.values?.length) {
throw new Error("values are required");
}
const page = await getRestoredPageForTarget(opts);
const label = resolved.ref ?? resolved.selector!;
const locator = resolved.ref
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
try {
await locator.selectOption(opts.values, {
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
});
} catch (err) {
throw toAIFriendlyError(err, label);
}
}
export async function pressKeyViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
key: string;
delayMs?: number;
}): Promise<void> {
const key = String(opts.key ?? "").trim();
if (!key) {
throw new Error("key is required");
}
const page = await getPageForTargetId(opts);
ensurePageState(page);
await page.keyboard.press(key, {
delay: Math.max(0, Math.floor(opts.delayMs ?? 0)),
});
}
export async function typeViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
ref?: string;
selector?: string;
text: string;
submit?: boolean;
slowly?: boolean;
timeoutMs?: number;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const text = String(opts.text ?? "");
const page = await getRestoredPageForTarget(opts);
const label = resolved.ref ?? resolved.selector!;
const locator = resolved.ref
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
try {
if (opts.slowly) {
await locator.click({ timeout });
await locator.type(text, { timeout, delay: 75 });
} else {
await locator.fill(text, { timeout });
}
if (opts.submit) {
await locator.press("Enter", { timeout });
}
} catch (err) {
throw toAIFriendlyError(err, label);
}
}
export async function fillFormViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
fields: BrowserFormField[];
timeoutMs?: number;
}): Promise<void> {
const page = await getRestoredPageForTarget(opts);
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
for (const field of opts.fields) {
const ref = field.ref.trim();
const type = (field.type || DEFAULT_FILL_FIELD_TYPE).trim() || DEFAULT_FILL_FIELD_TYPE;
const rawValue = field.value;
const value =
typeof rawValue === "string"
? rawValue
: typeof rawValue === "number" || typeof rawValue === "boolean"
? String(rawValue)
: "";
if (!ref) {
continue;
}
const locator = refLocator(page, ref);
if (type === "checkbox" || type === "radio") {
const checked =
rawValue === true || rawValue === 1 || rawValue === "1" || rawValue === "true";
try {
await locator.setChecked(checked, { timeout });
} catch (err) {
throw toAIFriendlyError(err, ref);
}
continue;
}
try {
await locator.fill(value, { timeout });
} catch (err) {
throw toAIFriendlyError(err, ref);
}
}
}
export async function evaluateViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
fn: string;
ref?: string;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<unknown> {
const fnText = String(opts.fn ?? "").trim();
if (!fnText) {
throw new Error("function is required");
}
const page = await getRestoredPageForTarget(opts);
// Clamp evaluate timeout to prevent permanently blocking Playwright's command queue.
// Without this, a long-running async evaluate blocks all subsequent page operations
// because Playwright serializes CDP commands per page.
//
// NOTE: Playwright's { timeout } on evaluate only applies to installing the function,
// NOT to its execution time. We must inject a Promise.race timeout into the browser
// context itself so async functions are bounded.
const outerTimeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
// Leave headroom for routing/serialization overhead so the outer request timeout
// doesn't fire first and strand a long-running evaluate.
let evaluateTimeout = Math.max(1000, Math.min(120_000, outerTimeout - 500));
evaluateTimeout = Math.min(evaluateTimeout, outerTimeout);
const signal = opts.signal;
let abortListener: (() => void) | undefined;
let abortReject: ((reason: unknown) => void) | undefined;
let abortPromise: Promise<never> | undefined;
if (signal) {
abortPromise = new Promise((_, reject) => {
abortReject = reject;
});
// Ensure the abort promise never becomes an unhandled rejection if we throw early.
void abortPromise.catch(() => {});
}
if (signal) {
const disconnect = () => {
void forceDisconnectPlaywrightForTarget({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
reason: "evaluate aborted",
}).catch(() => {});
};
if (signal.aborted) {
disconnect();
throw signal.reason ?? new Error("aborted");
}
abortListener = () => {
disconnect();
abortReject?.(signal.reason ?? new Error("aborted"));
};
signal.addEventListener("abort", abortListener, { once: true });
// If the signal aborted between the initial check and listener registration, handle it.
if (signal.aborted) {
abortListener();
throw signal.reason ?? new Error("aborted");
}
}
try {
if (opts.ref) {
const locator = refLocator(page, opts.ref);
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval
const elementEvaluator = new Function(
"el",
"args",
`
"use strict";
var fnBody = args.fnBody, timeoutMs = args.timeoutMs;
try {
var candidate = eval("(" + fnBody + ")");
var result = typeof candidate === "function" ? candidate(el) : candidate;
if (result && typeof result.then === "function") {
return Promise.race([
result,
new Promise(function(_, reject) {
setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);
})
]);
}
return result;
} catch (err) {
throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
}
`,
) as (el: Element, args: { fnBody: string; timeoutMs: number }) => unknown;
const evalPromise = locator.evaluate(elementEvaluator, {
fnBody: fnText,
timeoutMs: evaluateTimeout,
});
return await awaitEvalWithAbort(evalPromise, abortPromise);
}
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval
const browserEvaluator = new Function(
"args",
`
"use strict";
var fnBody = args.fnBody, timeoutMs = args.timeoutMs;
try {
var candidate = eval("(" + fnBody + ")");
var result = typeof candidate === "function" ? candidate() : candidate;
if (result && typeof result.then === "function") {
return Promise.race([
result,
new Promise(function(_, reject) {
setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);
})
]);
}
return result;
} catch (err) {
throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
}
`,
) as (args: { fnBody: string; timeoutMs: number }) => unknown;
const evalPromise = page.evaluate(browserEvaluator, {
fnBody: fnText,
timeoutMs: evaluateTimeout,
});
return await awaitEvalWithAbort(evalPromise, abortPromise);
} finally {
if (signal && abortListener) {
signal.removeEventListener("abort", abortListener);
}
}
}
export async function scrollIntoViewViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
ref?: string;
selector?: string;
timeoutMs?: number;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const page = await getRestoredPageForTarget(opts);
const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
const label = resolved.ref ?? resolved.selector!;
const locator = resolved.ref
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
try {
await locator.scrollIntoViewIfNeeded({ timeout });
} catch (err) {
throw toAIFriendlyError(err, label);
}
}
export async function waitForViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
timeMs?: number;
text?: string;
textGone?: string;
selector?: string;
url?: string;
loadState?: "load" | "domcontentloaded" | "networkidle";
fn?: string;
timeoutMs?: number;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) {
await page.waitForTimeout(resolveBoundedDelayMs(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS));
}
if (opts.text) {
await page.getByText(opts.text).first().waitFor({
state: "visible",
timeout,
});
}
if (opts.textGone) {
await page.getByText(opts.textGone).first().waitFor({
state: "hidden",
timeout,
});
}
if (opts.selector) {
const selector = String(opts.selector).trim();
if (selector) {
await page.locator(selector).first().waitFor({ state: "visible", timeout });
}
}
if (opts.url) {
const url = String(opts.url).trim();
if (url) {
await page.waitForURL(url, { timeout });
}
}
if (opts.loadState) {
await page.waitForLoadState(opts.loadState, { timeout });
}
if (opts.fn) {
const fn = String(opts.fn).trim();
if (fn) {
await page.waitForFunction(fn, { timeout });
}
}
}
export async function takeScreenshotViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
ref?: string;
element?: string;
fullPage?: boolean;
type?: "png" | "jpeg";
}): Promise<{ buffer: Buffer }> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
const type = opts.type ?? "png";
if (opts.ref) {
if (opts.fullPage) {
throw new Error("fullPage is not supported for element screenshots");
}
const locator = refLocator(page, opts.ref);
const buffer = await locator.screenshot({ type });
return { buffer };
}
if (opts.element) {
if (opts.fullPage) {
throw new Error("fullPage is not supported for element screenshots");
}
const locator = page.locator(opts.element).first();
const buffer = await locator.screenshot({ type });
return { buffer };
}
const buffer = await page.screenshot({
type,
fullPage: Boolean(opts.fullPage),
});
return { buffer };
}
export async function screenshotWithLabelsViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
refs: Record<string, { role: string; name?: string; nth?: number }>;
maxLabels?: number;
type?: "png" | "jpeg";
}): Promise<{ buffer: Buffer; labels: number; skipped: number }> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
const type = opts.type ?? "png";
const maxLabels =
typeof opts.maxLabels === "number" && Number.isFinite(opts.maxLabels)
? Math.max(1, Math.floor(opts.maxLabels))
: 150;
const viewport = await page.evaluate(() => ({
scrollX: window.scrollX || 0,
scrollY: window.scrollY || 0,
width: window.innerWidth || 0,
height: window.innerHeight || 0,
}));
const refs = Object.keys(opts.refs ?? {});
const boxes: Array<{ ref: string; x: number; y: number; w: number; h: number }> = [];
let skipped = 0;
for (const ref of refs) {
if (boxes.length >= maxLabels) {
skipped += 1;
continue;
}
try {
const box = await refLocator(page, ref).boundingBox();
if (!box) {
skipped += 1;
continue;
}
const x0 = box.x;
const y0 = box.y;
const x1 = box.x + box.width;
const y1 = box.y + box.height;
const vx0 = viewport.scrollX;
const vy0 = viewport.scrollY;
const vx1 = viewport.scrollX + viewport.width;
const vy1 = viewport.scrollY + viewport.height;
if (x1 < vx0 || x0 > vx1 || y1 < vy0 || y0 > vy1) {
skipped += 1;
continue;
}
boxes.push({
ref,
x: x0 - viewport.scrollX,
y: y0 - viewport.scrollY,
w: Math.max(1, box.width),
h: Math.max(1, box.height),
});
} catch {
skipped += 1;
}
}
try {
if (boxes.length > 0) {
await page.evaluate((labels) => {
const existing = document.querySelectorAll("[data-openclaw-labels]");
existing.forEach((el) => el.remove());
const root = document.createElement("div");
root.setAttribute("data-openclaw-labels", "1");
root.style.position = "fixed";
root.style.left = "0";
root.style.top = "0";
root.style.zIndex = "2147483647";
root.style.pointerEvents = "none";
root.style.fontFamily =
'"SF Mono","SFMono-Regular",Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace';
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
for (const label of labels) {
const box = document.createElement("div");
box.setAttribute("data-openclaw-labels", "1");
box.style.position = "absolute";
box.style.left = `${label.x}px`;
box.style.top = `${label.y}px`;
box.style.width = `${label.w}px`;
box.style.height = `${label.h}px`;
box.style.border = "2px solid #ffb020";
box.style.boxSizing = "border-box";
const tag = document.createElement("div");
tag.setAttribute("data-openclaw-labels", "1");
tag.textContent = label.ref;
tag.style.position = "absolute";
tag.style.left = `${label.x}px`;
tag.style.top = `${clamp(label.y - 18, 0, 20000)}px`;
tag.style.background = "#ffb020";
tag.style.color = "#1a1a1a";
tag.style.fontSize = "12px";
tag.style.lineHeight = "14px";
tag.style.padding = "1px 4px";
tag.style.borderRadius = "3px";
tag.style.boxShadow = "0 1px 2px rgba(0,0,0,0.35)";
tag.style.whiteSpace = "nowrap";
root.appendChild(box);
root.appendChild(tag);
}
document.documentElement.appendChild(root);
}, boxes);
}
const buffer = await page.screenshot({ type });
return { buffer, labels: boxes.length, skipped };
} finally {
await page
.evaluate(() => {
const existing = document.querySelectorAll("[data-openclaw-labels]");
existing.forEach((el) => el.remove());
})
.catch(() => {});
}
}
export async function setInputFilesViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
inputRef?: string;
element?: string;
paths: string[];
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
if (!opts.paths.length) {
throw new Error("paths are required");
}
const inputRef = typeof opts.inputRef === "string" ? opts.inputRef.trim() : "";
const element = typeof opts.element === "string" ? opts.element.trim() : "";
if (inputRef && element) {
throw new Error("inputRef and element are mutually exclusive");
}
if (!inputRef && !element) {
throw new Error("inputRef or element is required");
}
const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first();
const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
rootDir: DEFAULT_UPLOAD_DIR,
requestedPaths: opts.paths,
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
});
if (!uploadPathsResult.ok) {
throw new Error(uploadPathsResult.error);
}
const resolvedPaths = uploadPathsResult.paths;
try {
await locator.setInputFiles(resolvedPaths);
} catch (err) {
throw toAIFriendlyError(err, inputRef || element);
}
try {
const handle = await locator.elementHandle();
if (handle) {
await handle.evaluate((el) => {
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
});
}
} catch {
// Best-effort for sites that don't react to setInputFiles alone.
}
}
const MAX_BATCH_DEPTH = 5;
async function executeSingleAction(
action: BrowserActRequest,
cdpUrl: string,
targetId?: string,
evaluateEnabled?: boolean,
depth = 0,
): Promise<void> {
if (depth > MAX_BATCH_DEPTH) {
throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`);
}
const effectiveTargetId = action.targetId ?? targetId;
switch (action.kind) {
case "click":
await clickViaPlaywright({
cdpUrl,
targetId: effectiveTargetId,
ref: action.ref,
selector: action.selector,
doubleClick: action.doubleClick,
button: action.button as "left" | "right" | "middle" | undefined,
modifiers: action.modifiers as Array<
"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift"
>,
delayMs: action.delayMs,
timeoutMs: action.timeoutMs,
});
break;
case "type":
await typeViaPlaywright({
cdpUrl,
targetId: effectiveTargetId,
ref: action.ref,
selector: action.selector,
text: action.text,
submit: action.submit,
slowly: action.slowly,
timeoutMs: action.timeoutMs,
});
break;
case "press":
await pressKeyViaPlaywright({
cdpUrl,
targetId: effectiveTargetId,
key: action.key,
delayMs: action.delayMs,
});
break;
case "hover":
await hoverViaPlaywright({
cdpUrl,
targetId: effectiveTargetId,
ref: action.ref,
selector: action.selector,
timeoutMs: action.timeoutMs,
});
break;
case "scrollIntoView":
await scrollIntoViewViaPlaywright({
cdpUrl,
targetId: effectiveTargetId,
ref: action.ref,
selector: action.selector,
timeoutMs: action.timeoutMs,
});
break;
case "drag":
await dragViaPlaywright({
cdpUrl,
targetId: effectiveTargetId,
startRef: action.startRef,
startSelector: action.startSelector,
endRef: action.endRef,
endSelector: action.endSelector,
timeoutMs: action.timeoutMs,
});
break;
case "select":
await selectOptionViaPlaywright({
cdpUrl,
targetId: effectiveTargetId,
ref: action.ref,
selector: action.selector,
values: action.values,
timeoutMs: action.timeoutMs,
});
break;
case "fill":
await fillFormViaPlaywright({
cdpUrl,
targetId: effectiveTargetId,
fields: action.fields,
timeoutMs: action.timeoutMs,
});
break;
case "resize":
await resizeViewportViaPlaywright({
cdpUrl,
targetId: effectiveTargetId,
width: action.width,
height: action.height,
});
break;
case "wait":
if (action.fn && !evaluateEnabled) {
throw new Error("wait --fn is disabled by config (browser.evaluateEnabled=false)");
}
await waitForViaPlaywright({
cdpUrl,
targetId: effectiveTargetId,
timeMs: action.timeMs,
text: action.text,
textGone: action.textGone,
selector: action.selector,
url: action.url,
loadState: action.loadState,
fn: action.fn,
timeoutMs: action.timeoutMs,
});
break;
case "evaluate":
if (!evaluateEnabled) {
throw new Error("act:evaluate is disabled by config (browser.evaluateEnabled=false)");
}
await evaluateViaPlaywright({
cdpUrl,
targetId: effectiveTargetId,
fn: action.fn,
ref: action.ref,
timeoutMs: action.timeoutMs,
});
break;
case "close":
await closePageViaPlaywright({
cdpUrl,
targetId: effectiveTargetId,
});
break;
case "batch":
await batchViaPlaywright({
cdpUrl,
targetId: effectiveTargetId,
actions: action.actions,
stopOnError: action.stopOnError,
evaluateEnabled,
depth: depth + 1,
});
break;
default:
throw new Error(`Unsupported batch action kind: ${(action as { kind: string }).kind}`);
}
}
export async function batchViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
actions: BrowserActRequest[];
stopOnError?: boolean;
evaluateEnabled?: boolean;
depth?: number;
}): Promise<{ results: Array<{ ok: boolean; error?: string }> }> {
const depth = opts.depth ?? 0;
if (depth > MAX_BATCH_DEPTH) {
throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`);
}
if (opts.actions.length > MAX_BATCH_ACTIONS) {
throw new Error(`Batch exceeds maximum of ${MAX_BATCH_ACTIONS} actions`);
}
const results: Array<{ ok: boolean; error?: string }> = [];
for (const action of opts.actions) {
try {
await executeSingleAction(action, opts.cdpUrl, opts.targetId, opts.evaluateEnabled, depth);
results.push({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
results.push({ ok: false, error: message });
if (opts.stopOnError !== false) {
break;
}
}
}
return { results };
}

View File

@@ -0,0 +1,108 @@
import { formatCliCommand } from "../cli/command-format.js";
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
import { normalizeTimeoutMs } from "./pw-tools-core.shared.js";
import { matchBrowserUrlPattern } from "./url-pattern.js";
export async function responseBodyViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
url: string;
timeoutMs?: number;
maxChars?: number;
}): Promise<{
url: string;
status?: number;
headers?: Record<string, string>;
body: string;
truncated?: boolean;
}> {
const pattern = String(opts.url ?? "").trim();
if (!pattern) {
throw new Error("url is required");
}
const maxChars =
typeof opts.maxChars === "number" && Number.isFinite(opts.maxChars)
? Math.max(1, Math.min(5_000_000, Math.floor(opts.maxChars)))
: 200_000;
const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
const page = await getPageForTargetId(opts);
ensurePageState(page);
const promise = new Promise<unknown>((resolve, reject) => {
let done = false;
let timer: NodeJS.Timeout | undefined;
let handler: ((resp: unknown) => void) | undefined;
const cleanup = () => {
if (timer) {
clearTimeout(timer);
}
timer = undefined;
if (handler) {
page.off("response", handler as never);
}
};
handler = (resp: unknown) => {
if (done) {
return;
}
const r = resp as { url?: () => string };
const u = r.url?.() || "";
if (!matchBrowserUrlPattern(pattern, u)) {
return;
}
done = true;
cleanup();
resolve(resp);
};
page.on("response", handler as never);
timer = setTimeout(() => {
if (done) {
return;
}
done = true;
cleanup();
reject(
new Error(
`Response not found for url pattern "${pattern}". Run '${formatCliCommand("openclaw browser requests")}' to inspect recent network activity.`,
),
);
}, timeout);
});
const resp = (await promise) as {
url?: () => string;
status?: () => number;
headers?: () => Record<string, string>;
body?: () => Promise<Buffer>;
text?: () => Promise<string>;
};
const url = resp.url?.() || "";
const status = resp.status?.();
const headers = resp.headers?.();
let bodyText = "";
try {
if (typeof resp.text === "function") {
bodyText = await resp.text();
} else if (typeof resp.body === "function") {
const buf = await resp.body();
bodyText = new TextDecoder("utf-8").decode(buf);
}
} catch (err) {
throw new Error(`Failed to read response body for "${url}": ${String(err)}`, { cause: err });
}
const trimmed = bodyText.length > maxChars ? bodyText.slice(0, maxChars) : bodyText;
return {
url,
status,
headers,
body: trimmed,
truncated: bodyText.length > maxChars ? true : undefined,
};
}

View File

@@ -0,0 +1,85 @@
import { parseRoleRef } from "./pw-role-snapshot.js";
let nextUploadArmId = 0;
let nextDialogArmId = 0;
let nextDownloadArmId = 0;
export function bumpUploadArmId(): number {
nextUploadArmId += 1;
return nextUploadArmId;
}
export function bumpDialogArmId(): number {
nextDialogArmId += 1;
return nextDialogArmId;
}
export function bumpDownloadArmId(): number {
nextDownloadArmId += 1;
return nextDownloadArmId;
}
export function requireRef(value: unknown): string {
const raw = typeof value === "string" ? value.trim() : "";
const roleRef = raw ? parseRoleRef(raw) : null;
const ref = roleRef ?? (raw.startsWith("@") ? raw.slice(1) : raw);
if (!ref) {
throw new Error("ref is required");
}
return ref;
}
export function requireRefOrSelector(
ref: string | undefined,
selector: string | undefined,
): { ref?: string; selector?: string } {
const trimmedRef = typeof ref === "string" ? ref.trim() : "";
const trimmedSelector = typeof selector === "string" ? selector.trim() : "";
if (!trimmedRef && !trimmedSelector) {
throw new Error("ref or selector is required");
}
return {
ref: trimmedRef || undefined,
selector: trimmedSelector || undefined,
};
}
export function normalizeTimeoutMs(timeoutMs: number | undefined, fallback: number) {
return Math.max(500, Math.min(120_000, timeoutMs ?? fallback));
}
export function toAIFriendlyError(error: unknown, selector: string): Error {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("strict mode violation")) {
const countMatch = message.match(/resolved to (\d+) elements/);
const count = countMatch ? countMatch[1] : "multiple";
return new Error(
`Selector "${selector}" matched ${count} elements. ` +
`Run a new snapshot to get updated refs, or use a different ref.`,
);
}
if (
(message.includes("Timeout") || message.includes("waiting for")) &&
(message.includes("to be visible") || message.includes("not visible"))
) {
return new Error(
`Element "${selector}" not found or not visible. ` +
`Run a new snapshot to see current page elements.`,
);
}
if (
message.includes("intercepts pointer events") ||
message.includes("not visible") ||
message.includes("not receive pointer events")
) {
return new Error(
`Element "${selector}" is not interactable (hidden or covered). ` +
`Try scrolling it into view, closing overlays, or re-snapshotting.`,
);
}
return error instanceof Error ? error : new Error(message);
}

View File

@@ -0,0 +1,262 @@
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js";
import {
assertBrowserNavigationAllowed,
assertBrowserNavigationRedirectChainAllowed,
assertBrowserNavigationResultAllowed,
withBrowserNavigationPolicy,
} from "./navigation-guard.js";
import {
buildRoleSnapshotFromAiSnapshot,
buildRoleSnapshotFromAriaSnapshot,
getRoleSnapshotStats,
type RoleSnapshotOptions,
type RoleRefMap,
} from "./pw-role-snapshot.js";
import {
ensurePageState,
forceDisconnectPlaywrightForTarget,
getPageForTargetId,
storeRoleRefsForTarget,
type WithSnapshotForAI,
} from "./pw-session.js";
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
export async function snapshotAriaViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
limit?: number;
}): Promise<{ nodes: AriaSnapshotNode[] }> {
const limit = Math.max(1, Math.min(2000, Math.floor(opts.limit ?? 500)));
const page = await getPageForTargetId({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
});
ensurePageState(page);
const res = (await withPageScopedCdpClient({
cdpUrl: opts.cdpUrl,
page,
targetId: opts.targetId,
fn: async (send) => {
await send("Accessibility.enable").catch(() => {});
return (await send("Accessibility.getFullAXTree")) as {
nodes?: RawAXNode[];
};
},
})) as {
nodes?: RawAXNode[];
};
const nodes = Array.isArray(res?.nodes) ? res.nodes : [];
return { nodes: formatAriaSnapshot(nodes, limit) };
}
export async function snapshotAiViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
timeoutMs?: number;
maxChars?: number;
}): Promise<{ snapshot: string; truncated?: boolean; refs: RoleRefMap }> {
const page = await getPageForTargetId({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
});
ensurePageState(page);
const maybe = page as unknown as WithSnapshotForAI;
if (!maybe._snapshotForAI) {
throw new Error("Playwright _snapshotForAI is not available. Upgrade playwright-core.");
}
const result = await maybe._snapshotForAI({
timeout: Math.max(500, Math.min(60_000, Math.floor(opts.timeoutMs ?? 5000))),
track: "response",
});
let snapshot = String(result?.full ?? "");
const maxChars = opts.maxChars;
const limit =
typeof maxChars === "number" && Number.isFinite(maxChars) && maxChars > 0
? Math.floor(maxChars)
: undefined;
let truncated = false;
if (limit && snapshot.length > limit) {
snapshot = `${snapshot.slice(0, limit)}\n\n[...TRUNCATED - page too large]`;
truncated = true;
}
const built = buildRoleSnapshotFromAiSnapshot(snapshot);
storeRoleRefsForTarget({
page,
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
refs: built.refs,
mode: "aria",
});
return truncated ? { snapshot, truncated, refs: built.refs } : { snapshot, refs: built.refs };
}
export async function snapshotRoleViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
selector?: string;
frameSelector?: string;
refsMode?: "role" | "aria";
options?: RoleSnapshotOptions;
}): Promise<{
snapshot: string;
refs: Record<string, { role: string; name?: string; nth?: number }>;
stats: { lines: number; chars: number; refs: number; interactive: number };
}> {
const page = await getPageForTargetId({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
});
ensurePageState(page);
if (opts.refsMode === "aria") {
if (opts.selector?.trim() || opts.frameSelector?.trim()) {
throw new Error("refs=aria does not support selector/frame snapshots yet.");
}
const maybe = page as unknown as WithSnapshotForAI;
if (!maybe._snapshotForAI) {
throw new Error("refs=aria requires Playwright _snapshotForAI support.");
}
const result = await maybe._snapshotForAI({
timeout: 5000,
track: "response",
});
const built = buildRoleSnapshotFromAiSnapshot(String(result?.full ?? ""), opts.options);
storeRoleRefsForTarget({
page,
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
refs: built.refs,
mode: "aria",
});
return {
snapshot: built.snapshot,
refs: built.refs,
stats: getRoleSnapshotStats(built.snapshot, built.refs),
};
}
const frameSelector = opts.frameSelector?.trim() || "";
const selector = opts.selector?.trim() || "";
const locator = frameSelector
? selector
? page.frameLocator(frameSelector).locator(selector)
: page.frameLocator(frameSelector).locator(":root")
: selector
? page.locator(selector)
: page.locator(":root");
const ariaSnapshot = await locator.ariaSnapshot();
const built = buildRoleSnapshotFromAriaSnapshot(String(ariaSnapshot ?? ""), opts.options);
storeRoleRefsForTarget({
page,
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
refs: built.refs,
frameSelector: frameSelector || undefined,
mode: "role",
});
return {
snapshot: built.snapshot,
refs: built.refs,
stats: getRoleSnapshotStats(built.snapshot, built.refs),
};
}
export async function navigateViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
url: string;
timeoutMs?: number;
ssrfPolicy?: SsrFPolicy;
}): Promise<{ url: string }> {
const isRetryableNavigateError = (err: unknown): boolean => {
const msg =
typeof err === "string"
? err.toLowerCase()
: err instanceof Error
? err.message.toLowerCase()
: "";
return (
msg.includes("frame has been detached") ||
msg.includes("target page, context or browser has been closed")
);
};
const url = String(opts.url ?? "").trim();
if (!url) {
throw new Error("url is required");
}
await assertBrowserNavigationAllowed({
url,
...withBrowserNavigationPolicy(opts.ssrfPolicy),
});
const timeout = Math.max(1000, Math.min(120_000, opts.timeoutMs ?? 20_000));
let page = await getPageForTargetId(opts);
ensurePageState(page);
const navigate = async () => await page.goto(url, { timeout });
let response;
try {
response = await navigate();
} catch (err) {
if (!isRetryableNavigateError(err)) {
throw err;
}
// Extension relays can briefly drop CDP during renderer swaps/navigation.
// Force a clean reconnect, then retry once on the refreshed page handle.
await forceDisconnectPlaywrightForTarget({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
reason: "retry navigate after detached frame",
}).catch(() => {});
page = await getPageForTargetId(opts);
ensurePageState(page);
response = await navigate();
}
await assertBrowserNavigationRedirectChainAllowed({
request: response?.request(),
...withBrowserNavigationPolicy(opts.ssrfPolicy),
});
const finalUrl = page.url();
await assertBrowserNavigationResultAllowed({
url: finalUrl,
...withBrowserNavigationPolicy(opts.ssrfPolicy),
});
return { url: finalUrl };
}
export async function resizeViewportViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
width: number;
height: number;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
await page.setViewportSize({
width: Math.max(1, Math.floor(opts.width)),
height: Math.max(1, Math.floor(opts.height)),
});
}
export async function closePageViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
await page.close();
}
export async function pdfViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
}): Promise<{ buffer: Buffer }> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const buffer = await page.pdf({ printBackground: true });
return { buffer };
}

View File

@@ -0,0 +1,215 @@
import { devices as playwrightDevices } from "playwright-core";
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
export async function setOfflineViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
offline: boolean;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
await page.context().setOffline(Boolean(opts.offline));
}
export async function setExtraHTTPHeadersViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
headers: Record<string, string>;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
await page.context().setExtraHTTPHeaders(opts.headers);
}
export async function setHttpCredentialsViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
username?: string;
password?: string;
clear?: boolean;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
if (opts.clear) {
await page.context().setHTTPCredentials(null);
return;
}
const username = String(opts.username ?? "");
const password = String(opts.password ?? "");
if (!username) {
throw new Error("username is required (or set clear=true)");
}
await page.context().setHTTPCredentials({ username, password });
}
export async function setGeolocationViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
latitude?: number;
longitude?: number;
accuracy?: number;
origin?: string;
clear?: boolean;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const context = page.context();
if (opts.clear) {
await context.setGeolocation(null);
await context.clearPermissions().catch(() => {});
return;
}
if (typeof opts.latitude !== "number" || typeof opts.longitude !== "number") {
throw new Error("latitude and longitude are required (or set clear=true)");
}
await context.setGeolocation({
latitude: opts.latitude,
longitude: opts.longitude,
accuracy: typeof opts.accuracy === "number" ? opts.accuracy : undefined,
});
const origin =
opts.origin?.trim() ||
(() => {
try {
return new URL(page.url()).origin;
} catch {
return "";
}
})();
if (origin) {
await context.grantPermissions(["geolocation"], { origin }).catch(() => {});
}
}
export async function emulateMediaViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
colorScheme: "dark" | "light" | "no-preference" | null;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
await page.emulateMedia({ colorScheme: opts.colorScheme });
}
export async function setLocaleViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
locale: string;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const locale = String(opts.locale ?? "").trim();
if (!locale) {
throw new Error("locale is required");
}
await withPageScopedCdpClient({
cdpUrl: opts.cdpUrl,
page,
targetId: opts.targetId,
fn: async (send) => {
try {
await send("Emulation.setLocaleOverride", { locale });
} catch (err) {
if (String(err).includes("Another locale override is already in effect")) {
return;
}
throw err;
}
},
});
}
export async function setTimezoneViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
timezoneId: string;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const timezoneId = String(opts.timezoneId ?? "").trim();
if (!timezoneId) {
throw new Error("timezoneId is required");
}
await withPageScopedCdpClient({
cdpUrl: opts.cdpUrl,
page,
targetId: opts.targetId,
fn: async (send) => {
try {
await send("Emulation.setTimezoneOverride", { timezoneId });
} catch (err) {
const msg = String(err);
if (msg.includes("Timezone override is already in effect")) {
return;
}
if (msg.includes("Invalid timezone")) {
throw new Error(`Invalid timezone ID: ${timezoneId}`, { cause: err });
}
throw err;
}
},
});
}
export async function setDeviceViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
name: string;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const name = String(opts.name ?? "").trim();
if (!name) {
throw new Error("device name is required");
}
const descriptor = (playwrightDevices as Record<string, unknown>)[name] as
| {
userAgent?: string;
viewport?: { width: number; height: number };
deviceScaleFactor?: number;
isMobile?: boolean;
hasTouch?: boolean;
locale?: string;
}
| undefined;
if (!descriptor) {
throw new Error(`Unknown device "${name}".`);
}
if (descriptor.viewport) {
await page.setViewportSize({
width: descriptor.viewport.width,
height: descriptor.viewport.height,
});
}
await withPageScopedCdpClient({
cdpUrl: opts.cdpUrl,
page,
targetId: opts.targetId,
fn: async (send) => {
if (descriptor.userAgent || descriptor.locale) {
await send("Emulation.setUserAgentOverride", {
userAgent: descriptor.userAgent ?? "",
acceptLanguage: descriptor.locale ?? undefined,
});
}
if (descriptor.viewport) {
await send("Emulation.setDeviceMetricsOverride", {
mobile: Boolean(descriptor.isMobile),
width: descriptor.viewport.width,
height: descriptor.viewport.height,
deviceScaleFactor: descriptor.deviceScaleFactor ?? 1,
screenWidth: descriptor.viewport.width,
screenHeight: descriptor.viewport.height,
});
}
if (descriptor.hasTouch) {
await send("Emulation.setTouchEmulationEnabled", {
enabled: true,
});
}
},
});
}

View File

@@ -0,0 +1,128 @@
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
export async function cookiesGetViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
}): Promise<{ cookies: unknown[] }> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const cookies = await page.context().cookies();
return { cookies };
}
export async function cookiesSetViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
cookie: {
name: string;
value: string;
url?: string;
domain?: string;
path?: string;
expires?: number;
httpOnly?: boolean;
secure?: boolean;
sameSite?: "Lax" | "None" | "Strict";
};
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const cookie = opts.cookie;
if (!cookie.name || cookie.value === undefined) {
throw new Error("cookie name and value are required");
}
const hasUrl = typeof cookie.url === "string" && cookie.url.trim();
const hasDomainPath =
typeof cookie.domain === "string" &&
cookie.domain.trim() &&
typeof cookie.path === "string" &&
cookie.path.trim();
if (!hasUrl && !hasDomainPath) {
throw new Error("cookie requires url, or domain+path");
}
await page.context().addCookies([cookie]);
}
export async function cookiesClearViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
await page.context().clearCookies();
}
type StorageKind = "local" | "session";
export async function storageGetViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
kind: StorageKind;
key?: string;
}): Promise<{ values: Record<string, string> }> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const kind = opts.kind;
const key = typeof opts.key === "string" ? opts.key : undefined;
const values = await page.evaluate(
({ kind: kind2, key: key2 }) => {
const store = kind2 === "session" ? window.sessionStorage : window.localStorage;
if (key2) {
const value = store.getItem(key2);
return value === null ? {} : { [key2]: value };
}
const out: Record<string, string> = {};
for (let i = 0; i < store.length; i += 1) {
const k = store.key(i);
if (!k) {
continue;
}
const v = store.getItem(k);
if (v !== null) {
out[k] = v;
}
}
return out;
},
{ kind, key },
);
return { values: values ?? {} };
}
export async function storageSetViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
kind: StorageKind;
key: string;
value: string;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const key = String(opts.key ?? "");
if (!key) {
throw new Error("key is required");
}
await page.evaluate(
({ kind, key: k, value }) => {
const store = kind === "session" ? window.sessionStorage : window.localStorage;
store.setItem(k, value);
},
{ kind: opts.kind, key, value: String(opts.value ?? "") },
);
}
export async function storageClearViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
kind: StorageKind;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
await page.evaluate(
({ kind }) => {
const store = kind === "session" ? window.sessionStorage : window.localStorage;
store.clear();
},
{ kind: opts.kind },
);
}

View File

@@ -0,0 +1,66 @@
import { beforeEach, vi } from "vitest";
let currentPage: Record<string, unknown> | null = null;
let currentRefLocator: Record<string, unknown> | null = null;
let pageState: {
console: unknown[];
armIdUpload: number;
armIdDialog: number;
armIdDownload: number;
} = {
console: [],
armIdUpload: 0,
armIdDialog: 0,
armIdDownload: 0,
};
const sessionMocks = vi.hoisted(() => ({
getPageForTargetId: vi.fn(async () => {
if (!currentPage) {
throw new Error("missing page");
}
return currentPage;
}),
ensurePageState: vi.fn(() => pageState),
forceDisconnectPlaywrightForTarget: vi.fn(async () => {}),
restoreRoleRefsForTarget: vi.fn(() => {}),
storeRoleRefsForTarget: vi.fn(() => {}),
refLocator: vi.fn(() => {
if (!currentRefLocator) {
throw new Error("missing locator");
}
return currentRefLocator;
}),
rememberRoleRefsForTarget: vi.fn(() => {}),
}));
vi.mock("./pw-session.js", () => sessionMocks);
export function getPwToolsCoreSessionMocks() {
return sessionMocks;
}
export function setPwToolsCoreCurrentPage(page: Record<string, unknown> | null) {
currentPage = page;
}
export function setPwToolsCoreCurrentRefLocator(locator: Record<string, unknown> | null) {
currentRefLocator = locator;
}
export function installPwToolsCoreTestHooks() {
beforeEach(() => {
currentPage = null;
currentRefLocator = null;
pageState = {
console: [],
armIdUpload: 0,
armIdDialog: 0,
armIdDownload: 0,
};
for (const fn of Object.values(sessionMocks)) {
fn.mockClear();
}
});
}

View File

@@ -0,0 +1,45 @@
import { writeViaSiblingTempPath } from "./output-atomic.js";
import { DEFAULT_TRACE_DIR } from "./paths.js";
import { ensureContextState, getPageForTargetId } from "./pw-session.js";
export async function traceStartViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
screenshots?: boolean;
snapshots?: boolean;
sources?: boolean;
}): Promise<void> {
const page = await getPageForTargetId(opts);
const context = page.context();
const ctxState = ensureContextState(context);
if (ctxState.traceActive) {
throw new Error("Trace already running. Stop the current trace before starting a new one.");
}
await context.tracing.start({
screenshots: opts.screenshots ?? true,
snapshots: opts.snapshots ?? true,
sources: opts.sources ?? false,
});
ctxState.traceActive = true;
}
export async function traceStopViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
path: string;
}): Promise<void> {
const page = await getPageForTargetId(opts);
const context = page.context();
const ctxState = ensureContextState(context);
if (!ctxState.traceActive) {
throw new Error("No active trace. Start a trace before stopping it.");
}
await writeViaSiblingTempPath({
rootDir: DEFAULT_TRACE_DIR,
targetPath: opts.path,
writeTemp: async (tempPath) => {
await context.tracing.stop({ path: tempPath });
},
});
ctxState.traceActive = false;
}

View File

@@ -0,0 +1,8 @@
export * from "./pw-tools-core.activity.js";
export * from "./pw-tools-core.downloads.js";
export * from "./pw-tools-core.interactions.js";
export * from "./pw-tools-core.responses.js";
export * from "./pw-tools-core.snapshot.js";
export * from "./pw-tools-core.state.js";
export * from "./pw-tools-core.storage.js";
export * from "./pw-tools-core.trace.js";

View File

@@ -0,0 +1,49 @@
type BrowserRequestProfileParams = {
query?: Record<string, unknown>;
body?: unknown;
profile?: string | null;
};
export function normalizeBrowserRequestPath(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return trimmed;
}
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (withLeadingSlash.length <= 1) {
return withLeadingSlash;
}
return withLeadingSlash.replace(/\/+$/, "");
}
export function isPersistentBrowserProfileMutation(method: string, path: string): boolean {
const normalizedPath = normalizeBrowserRequestPath(path);
if (
method === "POST" &&
(normalizedPath === "/profiles/create" || normalizedPath === "/reset-profile")
) {
return true;
}
return method === "DELETE" && /^\/profiles\/[^/]+$/.test(normalizedPath);
}
export function resolveRequestedBrowserProfile(
params: BrowserRequestProfileParams,
): string | undefined {
const queryProfile =
typeof params.query?.profile === "string" ? params.query.profile.trim() : undefined;
if (queryProfile) {
return queryProfile;
}
if (params.body && typeof params.body === "object") {
const bodyProfile =
"profile" in params.body && typeof params.body.profile === "string"
? params.body.profile.trim()
: undefined;
if (bodyProfile) {
return bodyProfile;
}
}
const explicitProfile = typeof params.profile === "string" ? params.profile.trim() : undefined;
return explicitProfile || undefined;
}

View File

@@ -0,0 +1,107 @@
import { createConfigIO, getRuntimeConfigSnapshot } from "../config/config.js";
import { resolveBrowserConfig, resolveProfile, type ResolvedBrowserProfile } from "./config.js";
import type { BrowserServerState } from "./server-context.types.js";
function changedProfileInvariants(
current: ResolvedBrowserProfile,
next: ResolvedBrowserProfile,
): string[] {
const changed: string[] = [];
if (current.cdpUrl !== next.cdpUrl) {
changed.push("cdpUrl");
}
if (current.cdpPort !== next.cdpPort) {
changed.push("cdpPort");
}
if (current.driver !== next.driver) {
changed.push("driver");
}
if (current.attachOnly !== next.attachOnly) {
changed.push("attachOnly");
}
if (current.cdpIsLoopback !== next.cdpIsLoopback) {
changed.push("cdpIsLoopback");
}
if ((current.userDataDir ?? "") !== (next.userDataDir ?? "")) {
changed.push("userDataDir");
}
return changed;
}
function applyResolvedConfig(
current: BrowserServerState,
freshResolved: BrowserServerState["resolved"],
) {
current.resolved = {
...freshResolved,
// Keep the runtime evaluate gate stable across request-time profile refreshes.
// Security-sensitive behavior should only change via full runtime config reload,
// not as a side effect of resolving profiles/tabs during a request.
evaluateEnabled: current.resolved.evaluateEnabled,
};
for (const [name, runtime] of current.profiles) {
const nextProfile = resolveProfile(freshResolved, name);
if (nextProfile) {
const changed = changedProfileInvariants(runtime.profile, nextProfile);
if (changed.length > 0) {
runtime.reconcile = {
previousProfile: runtime.profile,
reason: `profile invariants changed: ${changed.join(", ")}`,
};
runtime.lastTargetId = null;
}
runtime.profile = nextProfile;
continue;
}
runtime.reconcile = {
previousProfile: runtime.profile,
reason: "profile removed from config",
};
runtime.lastTargetId = null;
if (!runtime.running) {
current.profiles.delete(name);
}
}
}
export function refreshResolvedBrowserConfigFromDisk(params: {
current: BrowserServerState;
refreshConfigFromDisk: boolean;
mode: "cached" | "fresh";
}) {
if (!params.refreshConfigFromDisk) {
return;
}
// Route-level browser config hot reload should observe on-disk changes immediately.
// The shared loadConfig() helper may return a cached snapshot for the configured TTL,
// which can leave request-time browser guards stale (for example evaluateEnabled).
const cfg = getRuntimeConfigSnapshot() ?? createConfigIO().loadConfig();
const freshResolved = resolveBrowserConfig(cfg.browser, cfg);
applyResolvedConfig(params.current, freshResolved);
}
export function resolveBrowserProfileWithHotReload(params: {
current: BrowserServerState;
refreshConfigFromDisk: boolean;
name: string;
}): ResolvedBrowserProfile | null {
refreshResolvedBrowserConfigFromDisk({
current: params.current,
refreshConfigFromDisk: params.refreshConfigFromDisk,
mode: "cached",
});
let profile = resolveProfile(params.current.resolved, params.name);
if (profile) {
return profile;
}
// Hot-reload: profile missing; retry with a fresh disk read without flushing the global cache.
refreshResolvedBrowserConfigFromDisk({
current: params.current,
refreshConfigFromDisk: params.refreshConfigFromDisk,
mode: "fresh",
});
profile = resolveProfile(params.current.resolved, params.name);
return profile;
}

View File

@@ -0,0 +1,123 @@
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
import type { BrowserRouteContext } from "../server-context.js";
import {
readBody,
requirePwAi,
resolveTargetIdFromBody,
withRouteTabContext,
} from "./agent.shared.js";
import { ensureOutputRootDir, resolveWritableOutputPathOrRespond } from "./output-paths.js";
import { DEFAULT_DOWNLOAD_DIR } from "./path-output.js";
import type { BrowserRouteRegistrar } from "./types.js";
import { jsonError, toNumber, toStringOrEmpty } from "./utils.js";
function buildDownloadRequestBase(cdpUrl: string, targetId: string, timeoutMs: number | undefined) {
return {
cdpUrl,
targetId,
timeoutMs: timeoutMs ?? undefined,
};
}
export function registerBrowserAgentActDownloadRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.post("/wait/download", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const out = toStringOrEmpty(body.path) || "";
const timeoutMs = toNumber(body.timeoutMs);
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(
res,
501,
"download waiting is not supported for existing-session profiles yet.",
);
}
const pw = await requirePwAi(res, "wait for download");
if (!pw) {
return;
}
await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR);
let downloadPath: string | undefined;
if (out.trim()) {
const resolvedDownloadPath = await resolveWritableOutputPathOrRespond({
res,
rootDir: DEFAULT_DOWNLOAD_DIR,
requestedPath: out,
scopeLabel: "downloads directory",
});
if (!resolvedDownloadPath) {
return;
}
downloadPath = resolvedDownloadPath;
}
const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
const result = await pw.waitForDownloadViaPlaywright({
...requestBase,
path: downloadPath,
});
res.json({ ok: true, targetId: tab.targetId, download: result });
},
});
});
app.post("/download", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref);
const out = toStringOrEmpty(body.path);
const timeoutMs = toNumber(body.timeoutMs);
if (!ref) {
return jsonError(res, 400, "ref is required");
}
if (!out) {
return jsonError(res, 400, "path is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(
res,
501,
"downloads are not supported for existing-session profiles yet.",
);
}
const pw = await requirePwAi(res, "download");
if (!pw) {
return;
}
await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR);
const downloadPath = await resolveWritableOutputPathOrRespond({
res,
rootDir: DEFAULT_DOWNLOAD_DIR,
requestedPath: out,
scopeLabel: "downloads directory",
});
if (!downloadPath) {
return;
}
const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
const result = await pw.downloadViaPlaywright({
...requestBase,
ref,
path: downloadPath,
});
res.json({ ok: true, targetId: tab.targetId, download: result });
},
});
});
}

View File

@@ -0,0 +1,197 @@
import { evaluateChromeMcpScript, uploadChromeMcpFile } from "../chrome-mcp.js";
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
import type { BrowserRouteContext } from "../server-context.js";
import {
readBody,
requirePwAi,
resolveTargetIdFromBody,
withRouteTabContext,
} from "./agent.shared.js";
import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "./path-output.js";
import type { BrowserRouteRegistrar } from "./types.js";
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
export function registerBrowserAgentActHookRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.post("/hooks/file-chooser", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref) || undefined;
const inputRef = toStringOrEmpty(body.inputRef) || undefined;
const element = toStringOrEmpty(body.element) || undefined;
const paths = toStringArray(body.paths) ?? [];
const timeoutMs = toNumber(body.timeoutMs);
if (!paths.length) {
return jsonError(res, 400, "paths are required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
const uploadPathsResult = await resolveExistingPathsWithinRoot({
rootDir: DEFAULT_UPLOAD_DIR,
requestedPaths: paths,
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
});
if (!uploadPathsResult.ok) {
res.status(400).json({ error: uploadPathsResult.error });
return;
}
const resolvedPaths = uploadPathsResult.paths;
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (element) {
return jsonError(
res,
501,
"existing-session file uploads do not support element selectors; use ref/inputRef.",
);
}
if (resolvedPaths.length !== 1) {
return jsonError(
res,
501,
"existing-session file uploads currently support one file at a time.",
);
}
const uid = inputRef || ref;
if (!uid) {
return jsonError(res, 501, "existing-session file uploads require ref or inputRef.");
}
await uploadChromeMcpFile({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid,
filePath: resolvedPaths[0] ?? "",
});
return res.json({ ok: true });
}
const pw = await requirePwAi(res, "file chooser hook");
if (!pw) {
return;
}
if (inputRef || element) {
if (ref) {
return jsonError(res, 400, "ref cannot be combined with inputRef/element");
}
await pw.setInputFilesViaPlaywright({
cdpUrl,
targetId: tab.targetId,
inputRef,
element,
paths: resolvedPaths,
});
} else {
await pw.armFileUploadViaPlaywright({
cdpUrl,
targetId: tab.targetId,
paths: resolvedPaths,
timeoutMs: timeoutMs ?? undefined,
});
if (ref) {
await pw.clickViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref,
});
}
}
res.json({ ok: true });
},
});
});
app.post("/hooks/dialog", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const accept = toBoolean(body.accept);
const promptText = toStringOrEmpty(body.promptText) || undefined;
const timeoutMs = toNumber(body.timeoutMs);
if (accept === undefined) {
return jsonError(res, 400, "accept is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (timeoutMs) {
return jsonError(
res,
501,
"existing-session dialog handling does not support timeoutMs.",
);
}
await evaluateChromeMcpScript({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fn: `() => {
const state = (window.__openclawDialogHook ??= {});
if (!state.originals) {
state.originals = {
alert: window.alert.bind(window),
confirm: window.confirm.bind(window),
prompt: window.prompt.bind(window),
};
}
const originals = state.originals;
const restore = () => {
window.alert = originals.alert;
window.confirm = originals.confirm;
window.prompt = originals.prompt;
delete window.__openclawDialogHook;
};
window.alert = (...args) => {
try {
return undefined;
} finally {
restore();
}
};
window.confirm = (...args) => {
try {
return ${accept ? "true" : "false"};
} finally {
restore();
}
};
window.prompt = (...args) => {
try {
return ${accept ? JSON.stringify(promptText ?? "") : "null"};
} finally {
restore();
}
};
return true;
}`,
});
return res.json({ ok: true });
}
const pw = await requirePwAi(res, "dialog hook");
if (!pw) {
return;
}
await pw.armDialogViaPlaywright({
cdpUrl,
targetId: tab.targetId,
accept,
promptText,
timeoutMs: timeoutMs ?? undefined,
});
res.json({ ok: true });
},
});
});
}

View File

@@ -0,0 +1,53 @@
export const ACT_KINDS = [
"batch",
"click",
"close",
"drag",
"evaluate",
"fill",
"hover",
"scrollIntoView",
"press",
"resize",
"select",
"type",
"wait",
] as const;
export type ActKind = (typeof ACT_KINDS)[number];
export function isActKind(value: unknown): value is ActKind {
if (typeof value !== "string") {
return false;
}
return (ACT_KINDS as readonly string[]).includes(value);
}
export type ClickButton = "left" | "right" | "middle";
export type ClickModifier = "Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift";
const ALLOWED_CLICK_MODIFIERS = new Set<ClickModifier>([
"Alt",
"Control",
"ControlOrMeta",
"Meta",
"Shift",
]);
export function parseClickButton(raw: string): ClickButton | undefined {
if (raw === "left" || raw === "right" || raw === "middle") {
return raw;
}
return undefined;
}
export function parseClickModifiers(raw: string[]): {
modifiers?: ClickModifier[];
error?: string;
} {
const invalid = raw.filter((m) => !ALLOWED_CLICK_MODIFIERS.has(m as ClickModifier));
if (invalid.length) {
return { error: "modifiers must be Alt|Control|ControlOrMeta|Meta|Shift" };
}
return { modifiers: raw.length ? (raw as ClickModifier[]) : undefined };
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
import crypto from "node:crypto";
import path from "node:path";
import type { BrowserRouteContext } from "../server-context.js";
import {
readBody,
resolveTargetIdFromBody,
resolveTargetIdFromQuery,
withPlaywrightRouteContext,
} from "./agent.shared.js";
import { resolveWritableOutputPathOrRespond } from "./output-paths.js";
import { DEFAULT_TRACE_DIR } from "./path-output.js";
import type { BrowserRouteRegistrar } from "./types.js";
import { toBoolean, toStringOrEmpty } from "./utils.js";
export function registerBrowserAgentDebugRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.get("/console", async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const level = typeof req.query.level === "string" ? req.query.level : "";
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "console messages",
run: async ({ cdpUrl, tab, pw }) => {
const messages = await pw.getConsoleMessagesViaPlaywright({
cdpUrl,
targetId: tab.targetId,
level: level.trim() || undefined,
});
res.json({ ok: true, messages, targetId: tab.targetId });
},
});
});
app.get("/errors", async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const clear = toBoolean(req.query.clear) ?? false;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "page errors",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.getPageErrorsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
clear,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
});
app.get("/requests", async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const filter = typeof req.query.filter === "string" ? req.query.filter : "";
const clear = toBoolean(req.query.clear) ?? false;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "network requests",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.getNetworkRequestsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
filter: filter.trim() || undefined,
clear,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
});
app.post("/trace/start", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const screenshots = toBoolean(body.screenshots) ?? undefined;
const snapshots = toBoolean(body.snapshots) ?? undefined;
const sources = toBoolean(body.sources) ?? undefined;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "trace start",
run: async ({ cdpUrl, tab, pw }) => {
await pw.traceStartViaPlaywright({
cdpUrl,
targetId: tab.targetId,
screenshots,
snapshots,
sources,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/trace/stop", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const out = toStringOrEmpty(body.path) || "";
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "trace stop",
run: async ({ cdpUrl, tab, pw }) => {
const id = crypto.randomUUID();
const tracePath = await resolveWritableOutputPathOrRespond({
res,
rootDir: DEFAULT_TRACE_DIR,
requestedPath: out,
scopeLabel: "trace directory",
defaultFileName: `browser-trace-${id}.zip`,
ensureRootDir: true,
});
if (!tracePath) {
return;
}
await pw.traceStopViaPlaywright({
cdpUrl,
targetId: tab.targetId,
path: tracePath,
});
res.json({
ok: true,
targetId: tab.targetId,
path: path.resolve(tracePath),
});
},
});
});
}

View File

@@ -0,0 +1,148 @@
import { toBrowserErrorResponse } from "../errors.js";
import type { PwAiModule } from "../pw-ai-module.js";
import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import type { BrowserRequest, BrowserResponse } from "./types.js";
import { getProfileContext, jsonError } from "./utils.js";
export const SELECTOR_UNSUPPORTED_MESSAGE = [
"Error: 'selector' is not supported. Use 'ref' from snapshot instead.",
"",
"Example workflow:",
"1. snapshot action to get page state with refs",
'2. act with ref: "e123" to interact with element',
"",
"This is more reliable for modern SPAs.",
].join("\n");
export function readBody(req: BrowserRequest): Record<string, unknown> {
const body = req.body as Record<string, unknown> | undefined;
if (!body || typeof body !== "object" || Array.isArray(body)) {
return {};
}
return body;
}
export function resolveTargetIdFromBody(body: Record<string, unknown>): string | undefined {
const targetId = typeof body.targetId === "string" ? body.targetId.trim() : "";
return targetId || undefined;
}
export function resolveTargetIdFromQuery(query: Record<string, unknown>): string | undefined {
const targetId = typeof query.targetId === "string" ? query.targetId.trim() : "";
return targetId || undefined;
}
export function handleRouteError(ctx: BrowserRouteContext, res: BrowserResponse, err: unknown) {
const mapped = ctx.mapTabError(err);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
const browserMapped = toBrowserErrorResponse(err);
if (browserMapped) {
return jsonError(res, browserMapped.status, browserMapped.message);
}
jsonError(res, 500, String(err));
}
export function resolveProfileContext(
req: BrowserRequest,
res: BrowserResponse,
ctx: BrowserRouteContext,
): ProfileContext | null {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
jsonError(res, profileCtx.status, profileCtx.error);
return null;
}
return profileCtx;
}
export async function getPwAiModule(): Promise<PwAiModule | null> {
return await getPwAiModuleBase({ mode: "soft" });
}
export async function requirePwAi(
res: BrowserResponse,
feature: string,
): Promise<PwAiModule | null> {
const mod = await getPwAiModule();
if (mod) {
return mod;
}
jsonError(
res,
501,
[
`Playwright is not available in this gateway build; '${feature}' is unsupported.`,
"Install the full Playwright package (not playwright-core) and restart the gateway, or reinstall with browser support.",
"Docs: /tools/browser#playwright-requirement",
].join("\n"),
);
return null;
}
type RouteTabContext = {
profileCtx: ProfileContext;
tab: Awaited<ReturnType<ProfileContext["ensureTabAvailable"]>>;
cdpUrl: string;
};
type RouteTabPwContext = RouteTabContext & {
pw: PwAiModule;
};
type RouteWithTabParams<T> = {
req: BrowserRequest;
res: BrowserResponse;
ctx: BrowserRouteContext;
targetId?: string;
run: (ctx: RouteTabContext) => Promise<T>;
};
export async function withRouteTabContext<T>(
params: RouteWithTabParams<T>,
): Promise<T | undefined> {
const profileCtx = resolveProfileContext(params.req, params.res, params.ctx);
if (!profileCtx) {
return undefined;
}
try {
const tab = await profileCtx.ensureTabAvailable(params.targetId);
return await params.run({
profileCtx,
tab,
cdpUrl: profileCtx.profile.cdpUrl,
});
} catch (err) {
handleRouteError(params.ctx, params.res, err);
return undefined;
}
}
type RouteWithPwParams<T> = {
req: BrowserRequest;
res: BrowserResponse;
ctx: BrowserRouteContext;
targetId?: string;
feature: string;
run: (ctx: RouteTabPwContext) => Promise<T>;
};
export async function withPlaywrightRouteContext<T>(
params: RouteWithPwParams<T>,
): Promise<T | undefined> {
return await withRouteTabContext({
req: params.req,
res: params.res,
ctx: params.ctx,
targetId: params.targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
const pw = await requirePwAi(params.res, params.feature);
if (!pw) {
return undefined as T | undefined;
}
return await params.run({ profileCtx, tab, cdpUrl, pw });
},
});
}

View File

@@ -0,0 +1,97 @@
import type { ResolvedBrowserProfile } from "../config.js";
import {
DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH,
DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS,
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
} from "../constants.js";
import {
resolveDefaultSnapshotFormat,
shouldUsePlaywrightForAriaSnapshot,
shouldUsePlaywrightForScreenshot,
} from "../profile-capabilities.js";
import { toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
export type BrowserSnapshotPlan = {
format: "ai" | "aria";
mode?: "efficient";
labels?: boolean;
limit?: number;
resolvedMaxChars?: number;
interactive?: boolean;
compact?: boolean;
depth?: number;
refsMode?: "aria" | "role";
selectorValue?: string;
frameSelectorValue?: string;
wantsRoleSnapshot: boolean;
};
export function resolveSnapshotPlan(params: {
profile: ResolvedBrowserProfile;
query: Record<string, unknown>;
hasPlaywright: boolean;
}): BrowserSnapshotPlan {
const mode = params.query.mode === "efficient" ? "efficient" : undefined;
const labels = toBoolean(params.query.labels) ?? undefined;
const explicitFormat =
params.query.format === "aria" ? "aria" : params.query.format === "ai" ? "ai" : undefined;
const format = resolveDefaultSnapshotFormat({
profile: params.profile,
hasPlaywright: params.hasPlaywright,
explicitFormat,
mode,
});
const limitRaw = typeof params.query.limit === "string" ? Number(params.query.limit) : undefined;
const hasMaxChars = Object.hasOwn(params.query, "maxChars");
const maxCharsRaw =
typeof params.query.maxChars === "string" ? Number(params.query.maxChars) : undefined;
const limit = Number.isFinite(limitRaw) ? limitRaw : undefined;
const maxChars =
typeof maxCharsRaw === "number" && Number.isFinite(maxCharsRaw) && maxCharsRaw > 0
? Math.floor(maxCharsRaw)
: undefined;
const resolvedMaxChars =
format === "ai"
? hasMaxChars
? maxChars
: mode === "efficient"
? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS
: DEFAULT_AI_SNAPSHOT_MAX_CHARS
: undefined;
const interactiveRaw = toBoolean(params.query.interactive);
const compactRaw = toBoolean(params.query.compact);
const depthRaw = toNumber(params.query.depth);
const refsModeRaw = toStringOrEmpty(params.query.refs).trim();
const refsMode: "aria" | "role" | undefined =
refsModeRaw === "aria" ? "aria" : refsModeRaw === "role" ? "role" : undefined;
const interactive = interactiveRaw ?? (mode === "efficient" ? true : undefined);
const compact = compactRaw ?? (mode === "efficient" ? true : undefined);
const depth =
depthRaw ?? (mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH : undefined);
const selectorValue = toStringOrEmpty(params.query.selector).trim() || undefined;
const frameSelectorValue = toStringOrEmpty(params.query.frame).trim() || undefined;
return {
format,
mode,
labels,
limit,
resolvedMaxChars,
interactive,
compact,
depth,
refsMode,
selectorValue,
frameSelectorValue,
wantsRoleSnapshot:
labels === true ||
mode === "efficient" ||
interactive === true ||
compact === true ||
depth !== undefined ||
Boolean(selectorValue) ||
Boolean(frameSelectorValue),
};
}
export { shouldUsePlaywrightForAriaSnapshot, shouldUsePlaywrightForScreenshot };

View File

@@ -0,0 +1,602 @@
import path from "node:path";
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
import { captureScreenshot, snapshotAria } from "../cdp.js";
import {
evaluateChromeMcpScript,
navigateChromeMcpPage,
takeChromeMcpScreenshot,
takeChromeMcpSnapshot,
} from "../chrome-mcp.js";
import {
buildAiSnapshotFromChromeMcpSnapshot,
flattenChromeMcpSnapshotToAriaNodes,
} from "../chrome-mcp.snapshot.js";
import {
assertBrowserNavigationAllowed,
assertBrowserNavigationResultAllowed,
} from "../navigation-guard.js";
import { withBrowserNavigationPolicy } from "../navigation-guard.js";
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
import {
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
normalizeBrowserScreenshot,
} from "../screenshot.js";
import type { BrowserRouteContext } from "../server-context.js";
import {
getPwAiModule,
handleRouteError,
readBody,
requirePwAi,
resolveProfileContext,
withPlaywrightRouteContext,
withRouteTabContext,
} from "./agent.shared.js";
import {
resolveSnapshotPlan,
shouldUsePlaywrightForAriaSnapshot,
shouldUsePlaywrightForScreenshot,
} from "./agent.snapshot.plan.js";
import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { jsonError, toBoolean, toStringOrEmpty } from "./utils.js";
const CHROME_MCP_OVERLAY_ATTR = "data-openclaw-mcp-overlay";
async function clearChromeMcpOverlay(params: {
profileName: string;
userDataDir?: string;
targetId: string;
}): Promise<void> {
await evaluateChromeMcpScript({
profileName: params.profileName,
userDataDir: params.userDataDir,
targetId: params.targetId,
fn: `() => {
document.querySelectorAll("[${CHROME_MCP_OVERLAY_ATTR}]").forEach((node) => node.remove());
return true;
}`,
}).catch(() => {});
}
async function renderChromeMcpLabels(params: {
profileName: string;
userDataDir?: string;
targetId: string;
refs: string[];
}): Promise<{ labels: number; skipped: number }> {
const refList = JSON.stringify(params.refs);
const result = await evaluateChromeMcpScript({
profileName: params.profileName,
userDataDir: params.userDataDir,
targetId: params.targetId,
args: params.refs,
fn: `(...elements) => {
const refs = ${refList};
document.querySelectorAll("[${CHROME_MCP_OVERLAY_ATTR}]").forEach((node) => node.remove());
const root = document.createElement("div");
root.setAttribute("${CHROME_MCP_OVERLAY_ATTR}", "labels");
root.style.position = "fixed";
root.style.inset = "0";
root.style.pointerEvents = "none";
root.style.zIndex = "2147483647";
let labels = 0;
let skipped = 0;
elements.forEach((el, index) => {
if (!(el instanceof Element)) {
skipped += 1;
return;
}
const rect = el.getBoundingClientRect();
if (rect.width <= 0 && rect.height <= 0) {
skipped += 1;
return;
}
labels += 1;
const badge = document.createElement("div");
badge.setAttribute("${CHROME_MCP_OVERLAY_ATTR}", "label");
badge.textContent = refs[index] || String(labels);
badge.style.position = "fixed";
badge.style.left = \`\${Math.max(0, rect.left)}px\`;
badge.style.top = \`\${Math.max(0, rect.top)}px\`;
badge.style.transform = "translateY(-100%)";
badge.style.padding = "2px 6px";
badge.style.borderRadius = "999px";
badge.style.background = "#FF4500";
badge.style.color = "#fff";
badge.style.font = "600 12px ui-monospace, SFMono-Regular, Menlo, monospace";
badge.style.boxShadow = "0 2px 6px rgba(0,0,0,0.35)";
badge.style.whiteSpace = "nowrap";
root.appendChild(badge);
});
document.documentElement.appendChild(root);
return { labels, skipped };
}`,
});
const labels =
result &&
typeof result === "object" &&
typeof (result as { labels?: unknown }).labels === "number"
? (result as { labels: number }).labels
: 0;
const skipped =
result &&
typeof result === "object" &&
typeof (result as { skipped?: unknown }).skipped === "number"
? (result as { skipped: number }).skipped
: 0;
return { labels, skipped };
}
async function saveNormalizedScreenshotResponse(params: {
res: BrowserResponse;
buffer: Buffer;
type: "png" | "jpeg";
targetId: string;
url: string;
}) {
const normalized = await normalizeBrowserScreenshot(params.buffer, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
});
await saveBrowserMediaResponse({
res: params.res,
buffer: normalized.buffer,
contentType: normalized.contentType ?? `image/${params.type}`,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
targetId: params.targetId,
url: params.url,
});
}
async function saveBrowserMediaResponse(params: {
res: BrowserResponse;
buffer: Buffer;
contentType: string;
maxBytes: number;
targetId: string;
url: string;
}) {
await ensureMediaDir();
const saved = await saveMediaBuffer(
params.buffer,
params.contentType,
"browser",
params.maxBytes,
);
params.res.json({
ok: true,
path: path.resolve(saved.path),
targetId: params.targetId,
url: params.url,
});
}
/** Resolve the correct targetId after a navigation that may trigger a renderer swap. */
export async function resolveTargetIdAfterNavigate(opts: {
oldTargetId: string;
navigatedUrl: string;
listTabs: () => Promise<Array<{ targetId: string; url: string }>>;
}): Promise<string> {
let currentTargetId = opts.oldTargetId;
try {
const pickReplacement = (
tabs: Array<{ targetId: string; url: string }>,
options?: { allowSingleTabFallback?: boolean },
) => {
if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) {
return opts.oldTargetId;
}
const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl);
if (byUrl.length === 1) {
return byUrl[0]?.targetId ?? opts.oldTargetId;
}
const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId);
if (uniqueReplacement.length === 1) {
return uniqueReplacement[0]?.targetId ?? opts.oldTargetId;
}
if (options?.allowSingleTabFallback && tabs.length === 1) {
return tabs[0]?.targetId ?? opts.oldTargetId;
}
return opts.oldTargetId;
};
currentTargetId = pickReplacement(await opts.listTabs());
if (currentTargetId === opts.oldTargetId) {
await new Promise((r) => setTimeout(r, 800));
currentTargetId = pickReplacement(await opts.listTabs(), {
allowSingleTabFallback: true,
});
}
} catch {
// Best-effort: fall back to pre-navigation targetId
}
return currentTargetId;
}
export function registerBrowserAgentSnapshotRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.post("/navigate", async (req, res) => {
const body = readBody(req);
const url = toStringOrEmpty(body.url);
const targetId = toStringOrEmpty(body.targetId) || undefined;
if (!url) {
return jsonError(res, 400, "url is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const result = await navigateChromeMcpPage({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
url,
});
await assertBrowserNavigationResultAllowed({ url: result.url, ...ssrfPolicyOpts });
return res.json({ ok: true, targetId: tab.targetId, ...result });
}
const pw = await requirePwAi(res, "navigate");
if (!pw) {
return;
}
const result = await pw.navigateViaPlaywright({
cdpUrl,
targetId: tab.targetId,
url,
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
});
const currentTargetId = await resolveTargetIdAfterNavigate({
oldTargetId: tab.targetId,
navigatedUrl: result.url,
listTabs: () => profileCtx.listTabs(),
});
res.json({ ok: true, targetId: currentTargetId, ...result });
},
});
});
app.post("/pdf", async (req, res) => {
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) {
return;
}
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(
res,
501,
"pdf is not supported for existing-session profiles yet; use screenshot/snapshot instead.",
);
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "pdf",
run: async ({ cdpUrl, tab, pw }) => {
const pdf = await pw.pdfViaPlaywright({
cdpUrl,
targetId: tab.targetId,
});
await saveBrowserMediaResponse({
res,
buffer: pdf.buffer,
contentType: "application/pdf",
maxBytes: pdf.buffer.byteLength,
targetId: tab.targetId,
url: tab.url,
});
},
});
});
app.post("/screenshot", async (req, res) => {
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const fullPage = toBoolean(body.fullPage) ?? false;
const ref = toStringOrEmpty(body.ref) || undefined;
const element = toStringOrEmpty(body.element) || undefined;
const type = body.type === "jpeg" ? "jpeg" : "png";
if (fullPage && (ref || element)) {
return jsonError(res, 400, "fullPage is not supported for element screenshots");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (element) {
return jsonError(
res,
400,
"element screenshots are not supported for existing-session profiles; use ref from snapshot.",
);
}
const buffer = await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: ref,
fullPage,
format: type,
});
await saveNormalizedScreenshotResponse({
res,
buffer,
type,
targetId: tab.targetId,
url: tab.url,
});
return;
}
let buffer: Buffer;
const shouldUsePlaywright = shouldUsePlaywrightForScreenshot({
profile: profileCtx.profile,
wsUrl: tab.wsUrl,
ref,
element,
});
if (shouldUsePlaywright) {
const pw = await requirePwAi(res, "screenshot");
if (!pw) {
return;
}
const snap = await pw.takeScreenshotViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref,
element,
fullPage,
type,
});
buffer = snap.buffer;
} else {
buffer = await captureScreenshot({
wsUrl: tab.wsUrl ?? "",
fullPage,
format: type,
quality: type === "jpeg" ? 85 : undefined,
});
}
await saveNormalizedScreenshotResponse({
res,
buffer,
type,
targetId: tab.targetId,
url: tab.url,
});
},
});
});
app.get("/snapshot", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) {
return;
}
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const hasPlaywright = Boolean(await getPwAiModule());
const plan = resolveSnapshotPlan({
profile: profileCtx.profile,
query: req.query,
hasPlaywright,
});
try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
return jsonError(res, 400, "labels/mode=efficient require format=ai");
}
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (plan.selectorValue || plan.frameSelectorValue) {
return jsonError(
res,
400,
"selector/frame snapshots are not supported for existing-session profiles; snapshot the whole page and use refs.",
);
}
const snapshot = await takeChromeMcpSnapshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
});
if (plan.format === "aria") {
return res.json({
ok: true,
format: "aria",
targetId: tab.targetId,
url: tab.url,
nodes: flattenChromeMcpSnapshotToAriaNodes(snapshot, plan.limit),
});
}
const built = buildAiSnapshotFromChromeMcpSnapshot({
root: snapshot,
options: {
interactive: plan.interactive ?? undefined,
compact: plan.compact ?? undefined,
maxDepth: plan.depth ?? undefined,
},
maxChars: plan.resolvedMaxChars,
});
if (plan.labels) {
const refs = Object.keys(built.refs);
const labelResult = await renderChromeMcpLabels({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
refs,
});
try {
const labeled = await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
format: "png",
});
const normalized = await normalizeBrowserScreenshot(labeled, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
});
await ensureMediaDir();
const saved = await saveMediaBuffer(
normalized.buffer,
normalized.contentType ?? "image/png",
"browser",
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
);
return res.json({
ok: true,
format: "ai",
targetId: tab.targetId,
url: tab.url,
labels: true,
labelsCount: labelResult.labels,
labelsSkipped: labelResult.skipped,
imagePath: path.resolve(saved.path),
imageType: normalized.contentType?.includes("jpeg") ? "jpeg" : "png",
...built,
});
} finally {
await clearChromeMcpOverlay({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
});
}
}
return res.json({
ok: true,
format: "ai",
targetId: tab.targetId,
url: tab.url,
...built,
});
}
if (plan.format === "ai") {
const pw = await requirePwAi(res, "ai snapshot");
if (!pw) {
return;
}
const roleSnapshotArgs = {
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
selector: plan.selectorValue,
frameSelector: plan.frameSelectorValue,
refsMode: plan.refsMode,
options: {
interactive: plan.interactive ?? undefined,
compact: plan.compact ?? undefined,
maxDepth: plan.depth ?? undefined,
},
};
const snap = plan.wantsRoleSnapshot
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs)
: await pw
.snapshotAiViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
...(typeof plan.resolvedMaxChars === "number"
? { maxChars: plan.resolvedMaxChars }
: {}),
})
.catch(async (err) => {
// Public-API fallback when Playwright's private _snapshotForAI is missing.
if (String(err).toLowerCase().includes("_snapshotforai")) {
return await pw.snapshotRoleViaPlaywright(roleSnapshotArgs);
}
throw err;
});
if (plan.labels) {
const labeled = await pw.screenshotWithLabelsViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
refs: "refs" in snap ? snap.refs : {},
type: "png",
});
const normalized = await normalizeBrowserScreenshot(labeled.buffer, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
});
await ensureMediaDir();
const saved = await saveMediaBuffer(
normalized.buffer,
normalized.contentType ?? "image/png",
"browser",
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
);
const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png";
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
labels: true,
labelsCount: labeled.labels,
labelsSkipped: labeled.skipped,
imagePath: path.resolve(saved.path),
imageType,
...snap,
});
}
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...snap,
});
}
const snap = shouldUsePlaywrightForAriaSnapshot({
profile: profileCtx.profile,
wsUrl: tab.wsUrl,
})
? (() => {
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
// Also covers cases where wsUrl is missing/unusable.
return requirePwAi(res, "aria snapshot").then(async (pw) => {
if (!pw) {
return null;
}
return await pw.snapshotAriaViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
limit: plan.limit,
});
});
})()
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit: plan.limit });
const resolved = await Promise.resolve(snap);
if (!resolved) {
return;
}
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...resolved,
});
} catch (err) {
handleRouteError(ctx, res, err);
}
});
}

View File

@@ -0,0 +1,451 @@
import type { BrowserRouteContext } from "../server-context.js";
import {
readBody,
resolveTargetIdFromBody,
resolveTargetIdFromQuery,
withPlaywrightRouteContext,
} from "./agent.shared.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
type StorageKind = "local" | "session";
export function parseStorageKind(raw: string): StorageKind | null {
if (raw === "local" || raw === "session") {
return raw;
}
return null;
}
export function parseStorageMutationRequest(
kindParam: unknown,
body: Record<string, unknown>,
): { kind: StorageKind | null; targetId: string | undefined } {
return {
kind: parseStorageKind(toStringOrEmpty(kindParam)),
targetId: resolveTargetIdFromBody(body),
};
}
export function parseRequiredStorageMutationRequest(
kindParam: unknown,
body: Record<string, unknown>,
): { kind: StorageKind; targetId: string | undefined } | null {
const parsed = parseStorageMutationRequest(kindParam, body);
if (!parsed.kind) {
return null;
}
return {
kind: parsed.kind,
targetId: parsed.targetId,
};
}
function parseStorageMutationOrRespond(
res: BrowserResponse,
kindParam: unknown,
body: Record<string, unknown>,
) {
const parsed = parseRequiredStorageMutationRequest(kindParam, body);
if (!parsed) {
jsonError(res, 400, "kind must be local|session");
return null;
}
return parsed;
}
function parseStorageMutationFromRequest(req: BrowserRequest, res: BrowserResponse) {
const body = readBody(req);
const parsed = parseStorageMutationOrRespond(res, req.params.kind, body);
if (!parsed) {
return null;
}
return { body, parsed };
}
export function registerBrowserAgentStorageRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.get("/cookies", async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "cookies",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.cookiesGetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
});
app.post("/cookies/set", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const cookie =
body.cookie && typeof body.cookie === "object" && !Array.isArray(body.cookie)
? (body.cookie as Record<string, unknown>)
: null;
if (!cookie) {
return jsonError(res, 400, "cookie is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "cookies set",
run: async ({ cdpUrl, tab, pw }) => {
await pw.cookiesSetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
cookie: {
name: toStringOrEmpty(cookie.name),
value: toStringOrEmpty(cookie.value),
url: toStringOrEmpty(cookie.url) || undefined,
domain: toStringOrEmpty(cookie.domain) || undefined,
path: toStringOrEmpty(cookie.path) || undefined,
expires: toNumber(cookie.expires) ?? undefined,
httpOnly: toBoolean(cookie.httpOnly) ?? undefined,
secure: toBoolean(cookie.secure) ?? undefined,
sameSite:
cookie.sameSite === "Lax" ||
cookie.sameSite === "None" ||
cookie.sameSite === "Strict"
? cookie.sameSite
: undefined,
},
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/cookies/clear", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "cookies clear",
run: async ({ cdpUrl, tab, pw }) => {
await pw.cookiesClearViaPlaywright({
cdpUrl,
targetId: tab.targetId,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.get("/storage/:kind", async (req, res) => {
const kind = parseStorageKind(toStringOrEmpty(req.params.kind));
if (!kind) {
return jsonError(res, 400, "kind must be local|session");
}
const targetId = resolveTargetIdFromQuery(req.query);
const key = toStringOrEmpty(req.query.key);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "storage get",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.storageGetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind,
key: key.trim() || undefined,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
});
app.post("/storage/:kind/set", async (req, res) => {
const mutation = parseStorageMutationFromRequest(req, res);
if (!mutation) {
return;
}
const key = toStringOrEmpty(mutation.body.key);
if (!key) {
return jsonError(res, 400, "key is required");
}
const value = typeof mutation.body.value === "string" ? mutation.body.value : "";
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId: mutation.parsed.targetId,
feature: "storage set",
run: async ({ cdpUrl, tab, pw }) => {
await pw.storageSetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind: mutation.parsed.kind,
key,
value,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/storage/:kind/clear", async (req, res) => {
const mutation = parseStorageMutationFromRequest(req, res);
if (!mutation) {
return;
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId: mutation.parsed.targetId,
feature: "storage clear",
run: async ({ cdpUrl, tab, pw }) => {
await pw.storageClearViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind: mutation.parsed.kind,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/set/offline", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const offline = toBoolean(body.offline);
if (offline === undefined) {
return jsonError(res, 400, "offline is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "offline",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setOfflineViaPlaywright({
cdpUrl,
targetId: tab.targetId,
offline,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/set/headers", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const headers =
body.headers && typeof body.headers === "object" && !Array.isArray(body.headers)
? (body.headers as Record<string, unknown>)
: null;
if (!headers) {
return jsonError(res, 400, "headers is required");
}
const parsed: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) {
if (typeof v === "string") {
parsed[k] = v;
}
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "headers",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setExtraHTTPHeadersViaPlaywright({
cdpUrl,
targetId: tab.targetId,
headers: parsed,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/set/credentials", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const clear = toBoolean(body.clear) ?? false;
const username = toStringOrEmpty(body.username) || undefined;
const password = typeof body.password === "string" ? body.password : undefined;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "http credentials",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setHttpCredentialsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
username,
password,
clear,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/set/geolocation", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const clear = toBoolean(body.clear) ?? false;
const latitude = toNumber(body.latitude);
const longitude = toNumber(body.longitude);
const accuracy = toNumber(body.accuracy) ?? undefined;
const origin = toStringOrEmpty(body.origin) || undefined;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "geolocation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setGeolocationViaPlaywright({
cdpUrl,
targetId: tab.targetId,
latitude,
longitude,
accuracy,
origin,
clear,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/set/media", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const schemeRaw = toStringOrEmpty(body.colorScheme);
const colorScheme =
schemeRaw === "dark" || schemeRaw === "light" || schemeRaw === "no-preference"
? schemeRaw
: schemeRaw === "none"
? null
: undefined;
if (colorScheme === undefined) {
return jsonError(res, 400, "colorScheme must be dark|light|no-preference|none");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "media emulation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.emulateMediaViaPlaywright({
cdpUrl,
targetId: tab.targetId,
colorScheme,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/set/timezone", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const timezoneId = toStringOrEmpty(body.timezoneId);
if (!timezoneId) {
return jsonError(res, 400, "timezoneId is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "timezone",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setTimezoneViaPlaywright({
cdpUrl,
targetId: tab.targetId,
timezoneId,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/set/locale", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const locale = toStringOrEmpty(body.locale);
if (!locale) {
return jsonError(res, 400, "locale is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "locale",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setLocaleViaPlaywright({
cdpUrl,
targetId: tab.targetId,
locale,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/set/device", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const name = toStringOrEmpty(body.name);
if (!name) {
return jsonError(res, 400, "name is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "device emulation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setDeviceViaPlaywright({
cdpUrl,
targetId: tab.targetId,
name,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
}

View File

@@ -0,0 +1,13 @@
import type { BrowserRouteContext } from "../server-context.js";
import { registerBrowserAgentActRoutes } from "./agent.act.js";
import { registerBrowserAgentDebugRoutes } from "./agent.debug.js";
import { registerBrowserAgentSnapshotRoutes } from "./agent.snapshot.js";
import { registerBrowserAgentStorageRoutes } from "./agent.storage.js";
import type { BrowserRouteRegistrar } from "./types.js";
export function registerBrowserAgentRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
registerBrowserAgentSnapshotRoutes(app, ctx);
registerBrowserAgentActRoutes(app, ctx);
registerBrowserAgentDebugRoutes(app, ctx);
registerBrowserAgentStorageRoutes(app, ctx);
}

View File

@@ -0,0 +1,225 @@
import { getChromeMcpPid } from "../chrome-mcp.js";
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
import { toBrowserErrorResponse } from "../errors.js";
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
import { createBrowserProfilesService } from "../profiles-service.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import { resolveProfileContext } from "./agent.shared.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
function handleBrowserRouteError(res: BrowserResponse, err: unknown) {
const mapped = toBrowserErrorResponse(err);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
jsonError(res, 500, String(err));
}
async function withBasicProfileRoute(params: {
req: BrowserRequest;
res: BrowserResponse;
ctx: BrowserRouteContext;
run: (profileCtx: ProfileContext) => Promise<void>;
}) {
const profileCtx = resolveProfileContext(params.req, params.res, params.ctx);
if (!profileCtx) {
return;
}
try {
await params.run(profileCtx);
} catch (err) {
return handleBrowserRouteError(params.res, err);
}
}
async function withProfilesServiceMutation(params: {
res: BrowserResponse;
ctx: BrowserRouteContext;
run: (service: ReturnType<typeof createBrowserProfilesService>) => Promise<unknown>;
}) {
try {
const service = createBrowserProfilesService(params.ctx);
const result = await params.run(service);
params.res.json(result);
} catch (err) {
return handleBrowserRouteError(params.res, err);
}
}
export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
// List all profiles with their status
app.get("/profiles", async (_req, res) => {
try {
const service = createBrowserProfilesService(ctx);
const profiles = await service.listProfiles();
res.json({ profiles });
} catch (err) {
jsonError(res, 500, String(err));
}
});
// Get status (profile-aware)
app.get("/", async (req, res) => {
let current: ReturnType<typeof ctx.state>;
try {
current = ctx.state();
} catch {
return jsonError(res, 503, "browser server not started");
}
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
try {
const [cdpHttp, cdpReady] = await Promise.all([
profileCtx.isHttpReachable(300),
profileCtx.isReachable(600),
]);
const profileState = current.profiles.get(profileCtx.profile.name);
const capabilities = getBrowserProfileCapabilities(profileCtx.profile);
let detectedBrowser: string | null = null;
let detectedExecutablePath: string | null = null;
let detectError: string | null = null;
try {
const detected = resolveBrowserExecutableForPlatform(current.resolved, process.platform);
if (detected) {
detectedBrowser = detected.kind;
detectedExecutablePath = detected.path;
}
} catch (err) {
detectError = String(err);
}
res.json({
enabled: current.resolved.enabled,
profile: profileCtx.profile.name,
driver: profileCtx.profile.driver,
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
running: cdpReady,
cdpReady,
cdpHttp,
pid: capabilities.usesChromeMcp
? getChromeMcpPid(profileCtx.profile.name)
: (profileState?.running?.pid ?? null),
cdpPort: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpPort,
cdpUrl: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpUrl,
chosenBrowser: profileState?.running?.exe.kind ?? null,
detectedBrowser,
detectedExecutablePath,
detectError,
userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null,
color: profileCtx.profile.color,
headless: current.resolved.headless,
noSandbox: current.resolved.noSandbox,
executablePath: current.resolved.executablePath ?? null,
attachOnly: profileCtx.profile.attachOnly,
});
} catch (err) {
const mapped = toBrowserErrorResponse(err);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
jsonError(res, 500, String(err));
}
});
// Start browser (profile-aware)
app.post("/start", async (req, res) => {
await withBasicProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
await profileCtx.ensureBrowserAvailable();
res.json({ ok: true, profile: profileCtx.profile.name });
},
});
});
// Stop browser (profile-aware)
app.post("/stop", async (req, res) => {
await withBasicProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
const result = await profileCtx.stopRunningBrowser();
res.json({
ok: true,
stopped: result.stopped,
profile: profileCtx.profile.name,
});
},
});
});
// Reset profile (profile-aware)
app.post("/reset-profile", async (req, res) => {
await withBasicProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
const result = await profileCtx.resetProfile();
res.json({ ok: true, profile: profileCtx.profile.name, ...result });
},
});
});
// Create a new profile
app.post("/profiles/create", async (req, res) => {
const name = toStringOrEmpty((req.body as { name?: unknown })?.name);
const color = toStringOrEmpty((req.body as { color?: unknown })?.color);
const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl);
const userDataDir = toStringOrEmpty((req.body as { userDataDir?: unknown })?.userDataDir);
const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver);
if (!name) {
return jsonError(res, 400, "name is required");
}
if (driver && driver !== "openclaw" && driver !== "clawd" && driver !== "existing-session") {
return jsonError(
res,
400,
`unsupported profile driver "${driver}"; use "openclaw", "clawd", or "existing-session"`,
);
}
await withProfilesServiceMutation({
res,
ctx,
run: async (service) =>
await service.createProfile({
name,
color: color || undefined,
cdpUrl: cdpUrl || undefined,
userDataDir: userDataDir || undefined,
driver:
driver === "existing-session"
? "existing-session"
: driver === "openclaw" || driver === "clawd"
? "openclaw"
: undefined,
}),
});
});
// Delete a profile
app.delete("/profiles/:name", async (req, res) => {
const name = toStringOrEmpty(req.params.name);
if (!name) {
return jsonError(res, 400, "profile name is required");
}
await withProfilesServiceMutation({
res,
ctx,
run: async (service) => await service.deleteProfile(name),
});
});
}

View File

@@ -0,0 +1,133 @@
import { escapeRegExp } from "../../utils.js";
import type { BrowserRouteContext } from "../server-context.js";
import { registerBrowserRoutes } from "./index.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
type BrowserDispatchRequest = {
method: "GET" | "POST" | "DELETE";
path: string;
query?: Record<string, unknown>;
body?: unknown;
signal?: AbortSignal;
};
type BrowserDispatchResponse = {
status: number;
body: unknown;
};
type RouteEntry = {
method: BrowserDispatchRequest["method"];
path: string;
regex: RegExp;
paramNames: string[];
handler: (req: BrowserRequest, res: BrowserResponse) => void | Promise<void>;
};
function compileRoute(path: string): { regex: RegExp; paramNames: string[] } {
const paramNames: string[] = [];
const parts = path.split("/").map((part) => {
if (part.startsWith(":")) {
const name = part.slice(1);
paramNames.push(name);
return "([^/]+)";
}
return escapeRegExp(part);
});
return { regex: new RegExp(`^${parts.join("/")}$`), paramNames };
}
function createRegistry() {
const routes: RouteEntry[] = [];
const register =
(method: RouteEntry["method"]) => (path: string, handler: RouteEntry["handler"]) => {
const { regex, paramNames } = compileRoute(path);
routes.push({ method, path, regex, paramNames, handler });
};
const router: BrowserRouteRegistrar = {
get: register("GET"),
post: register("POST"),
delete: register("DELETE"),
};
return { routes, router };
}
function normalizePath(path: string) {
if (!path) {
return "/";
}
return path.startsWith("/") ? path : `/${path}`;
}
export function createBrowserRouteDispatcher(ctx: BrowserRouteContext) {
const registry = createRegistry();
registerBrowserRoutes(registry.router, ctx);
return {
dispatch: async (req: BrowserDispatchRequest): Promise<BrowserDispatchResponse> => {
const method = req.method;
const path = normalizePath(req.path);
const query = req.query ?? {};
const body = req.body;
const signal = req.signal;
const match = registry.routes.find((route) => {
if (route.method !== method) {
return false;
}
return route.regex.test(path);
});
if (!match) {
return { status: 404, body: { error: "Not Found" } };
}
const exec = match.regex.exec(path);
const params: Record<string, string> = {};
if (exec) {
for (const [idx, name] of match.paramNames.entries()) {
const value = exec[idx + 1];
if (typeof value === "string") {
try {
params[name] = decodeURIComponent(value);
} catch {
return {
status: 400,
body: { error: `invalid path parameter encoding: ${name}` },
};
}
}
}
}
let status = 200;
let payload: unknown = undefined;
const res: BrowserResponse = {
status(code) {
status = code;
return res;
},
json(bodyValue) {
payload = bodyValue;
},
};
try {
await match.handler(
{
params,
query,
body,
signal,
},
res,
);
} catch (err) {
return { status: 500, body: { error: String(err) } };
}
return { status, body: payload };
},
};
}
export type { BrowserDispatchRequest, BrowserDispatchResponse };

View File

@@ -0,0 +1,11 @@
import type { BrowserRouteContext } from "../server-context.js";
import { registerBrowserAgentRoutes } from "./agent.js";
import { registerBrowserBasicRoutes } from "./basic.js";
import { registerBrowserTabRoutes } from "./tabs.js";
import type { BrowserRouteRegistrar } from "./types.js";
export function registerBrowserRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
registerBrowserBasicRoutes(app, ctx);
registerBrowserTabRoutes(app, ctx);
registerBrowserAgentRoutes(app, ctx);
}

View File

@@ -0,0 +1,31 @@
import fs from "node:fs/promises";
import { resolveWritablePathWithinRoot } from "./path-output.js";
import type { BrowserResponse } from "./types.js";
export async function ensureOutputRootDir(rootDir: string): Promise<void> {
await fs.mkdir(rootDir, { recursive: true });
}
export async function resolveWritableOutputPathOrRespond(params: {
res: BrowserResponse;
rootDir: string;
requestedPath: string;
scopeLabel: string;
defaultFileName?: string;
ensureRootDir?: boolean;
}): Promise<string | null> {
if (params.ensureRootDir) {
await ensureOutputRootDir(params.rootDir);
}
const pathResult = await resolveWritablePathWithinRoot({
rootDir: params.rootDir,
requestedPath: params.requestedPath,
scopeLabel: params.scopeLabel,
defaultFileName: params.defaultFileName,
});
if (!pathResult.ok) {
params.res.status(400).json({ error: pathResult.error });
return null;
}
return pathResult.path;
}

View File

@@ -0,0 +1 @@
export * from "../paths.js";

View File

@@ -0,0 +1,230 @@
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "../errors.js";
import {
assertBrowserNavigationAllowed,
withBrowserNavigationPolicy,
} from "../navigation-guard.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
function resolveTabsProfileContext(
req: BrowserRequest,
res: BrowserResponse,
ctx: BrowserRouteContext,
) {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
jsonError(res, profileCtx.status, profileCtx.error);
return null;
}
return profileCtx;
}
function handleTabsRouteError(
ctx: BrowserRouteContext,
res: BrowserResponse,
err: unknown,
opts?: { mapTabError?: boolean },
) {
if (opts?.mapTabError) {
const mapped = ctx.mapTabError(err);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
}
return jsonError(res, 500, String(err));
}
async function withTabsProfileRoute(params: {
req: BrowserRequest;
res: BrowserResponse;
ctx: BrowserRouteContext;
mapTabError?: boolean;
run: (profileCtx: ProfileContext) => Promise<void>;
}) {
const profileCtx = resolveTabsProfileContext(params.req, params.res, params.ctx);
if (!profileCtx) {
return;
}
try {
await params.run(profileCtx);
} catch (err) {
handleTabsRouteError(params.ctx, params.res, err, { mapTabError: params.mapTabError });
}
}
async function ensureBrowserRunning(profileCtx: ProfileContext, res: BrowserResponse) {
if (!(await profileCtx.isReachable(300))) {
jsonError(
res,
new BrowserProfileUnavailableError("browser not running").status,
"browser not running",
);
return false;
}
return true;
}
function resolveIndexedTab(
tabs: Awaited<ReturnType<ProfileContext["listTabs"]>>,
index: number | undefined,
) {
return typeof index === "number" ? tabs[index] : tabs.at(0);
}
function parseRequiredTargetId(res: BrowserResponse, rawTargetId: unknown): string | null {
const targetId = toStringOrEmpty(rawTargetId);
if (!targetId) {
jsonError(res, 400, "targetId is required");
return null;
}
return targetId;
}
async function runTabTargetMutation(params: {
req: BrowserRequest;
res: BrowserResponse;
ctx: BrowserRouteContext;
targetId: string;
mutate: (profileCtx: ProfileContext, targetId: string) => Promise<void>;
}) {
await withTabsProfileRoute({
req: params.req,
res: params.res,
ctx: params.ctx,
mapTabError: true,
run: async (profileCtx) => {
if (!(await ensureBrowserRunning(profileCtx, params.res))) {
return;
}
await params.mutate(profileCtx, params.targetId);
params.res.json({ ok: true });
},
});
}
export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
app.get("/tabs", async (req, res) => {
await withTabsProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
const reachable = await profileCtx.isReachable(300);
if (!reachable) {
return res.json({ running: false, tabs: [] as unknown[] });
}
const tabs = await profileCtx.listTabs();
res.json({ running: true, tabs });
},
});
});
app.post("/tabs/open", async (req, res) => {
const url = toStringOrEmpty((req.body as { url?: unknown })?.url);
if (!url) {
return jsonError(res, 400, "url is required");
}
await withTabsProfileRoute({
req,
res,
ctx,
mapTabError: true,
run: async (profileCtx) => {
await assertBrowserNavigationAllowed({
url,
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
});
await profileCtx.ensureBrowserAvailable();
const tab = await profileCtx.openTab(url);
res.json(tab);
},
});
});
app.post("/tabs/focus", async (req, res) => {
const targetId = parseRequiredTargetId(res, (req.body as { targetId?: unknown })?.targetId);
if (!targetId) {
return;
}
await runTabTargetMutation({
req,
res,
ctx,
targetId,
mutate: async (profileCtx, id) => {
await profileCtx.focusTab(id);
},
});
});
app.delete("/tabs/:targetId", async (req, res) => {
const targetId = parseRequiredTargetId(res, req.params.targetId);
if (!targetId) {
return;
}
await runTabTargetMutation({
req,
res,
ctx,
targetId,
mutate: async (profileCtx, id) => {
await profileCtx.closeTab(id);
},
});
});
app.post("/tabs/action", async (req, res) => {
const action = toStringOrEmpty((req.body as { action?: unknown })?.action);
const index = toNumber((req.body as { index?: unknown })?.index);
await withTabsProfileRoute({
req,
res,
ctx,
mapTabError: true,
run: async (profileCtx) => {
if (action === "list") {
const reachable = await profileCtx.isReachable(300);
if (!reachable) {
return res.json({ ok: true, tabs: [] as unknown[] });
}
const tabs = await profileCtx.listTabs();
return res.json({ ok: true, tabs });
}
if (action === "new") {
await profileCtx.ensureBrowserAvailable();
const tab = await profileCtx.openTab("about:blank");
return res.json({ ok: true, tab });
}
if (action === "close") {
const tabs = await profileCtx.listTabs();
const target = resolveIndexedTab(tabs, index);
if (!target) {
throw new BrowserTabNotFoundError();
}
await profileCtx.closeTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });
}
if (action === "select") {
if (typeof index !== "number") {
return jsonError(res, 400, "index is required");
}
const tabs = await profileCtx.listTabs();
const target = tabs[index];
if (!target) {
throw new BrowserTabNotFoundError();
}
await profileCtx.focusTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });
}
return jsonError(res, 400, "unknown tab action");
},
});
});
}

View File

@@ -0,0 +1,36 @@
import type { BrowserResponse, BrowserRouteHandler, BrowserRouteRegistrar } from "./types.js";
export function createBrowserRouteApp() {
const getHandlers = new Map<string, BrowserRouteHandler>();
const postHandlers = new Map<string, BrowserRouteHandler>();
const deleteHandlers = new Map<string, BrowserRouteHandler>();
const app: BrowserRouteRegistrar = {
get: (path, handler) => void getHandlers.set(path, handler),
post: (path, handler) => void postHandlers.set(path, handler),
delete: (path, handler) => void deleteHandlers.set(path, handler),
};
return { app, getHandlers, postHandlers, deleteHandlers };
}
export function createBrowserRouteResponse() {
let statusCode = 200;
let jsonBody: unknown;
const res: BrowserResponse = {
status(code) {
statusCode = code;
return res;
},
json(body) {
jsonBody = body;
},
};
return {
res,
get statusCode() {
return statusCode;
},
get body() {
return jsonBody;
},
};
}

View File

@@ -0,0 +1,26 @@
export type BrowserRequest = {
params: Record<string, string>;
query: Record<string, unknown>;
body?: unknown;
/**
* Optional abort signal for in-process dispatch. This lets callers enforce
* timeouts and (where supported) cancel long-running operations.
*/
signal?: AbortSignal;
};
export type BrowserResponse = {
status: (code: number) => BrowserResponse;
json: (body: unknown) => void;
};
export type BrowserRouteHandler = (
req: BrowserRequest,
res: BrowserResponse,
) => void | Promise<void>;
export type BrowserRouteRegistrar = {
get: (path: string, handler: BrowserRouteHandler) => void;
post: (path: string, handler: BrowserRouteHandler) => void;
delete: (path: string, handler: BrowserRouteHandler) => void;
};

View File

@@ -0,0 +1,73 @@
import { parseBooleanValue } from "../../utils/boolean.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import type { BrowserRequest, BrowserResponse } from "./types.js";
/**
* Extract profile name from query string or body and get profile context.
* Query string takes precedence over body for consistency with GET routes.
*/
export function getProfileContext(
req: BrowserRequest,
ctx: BrowserRouteContext,
): ProfileContext | { error: string; status: number } {
let profileName: string | undefined;
// Check query string first (works for GET and POST)
if (typeof req.query.profile === "string") {
profileName = req.query.profile.trim() || undefined;
}
// Fall back to body for POST requests
if (!profileName && req.body && typeof req.body === "object") {
const body = req.body as Record<string, unknown>;
if (typeof body.profile === "string") {
profileName = body.profile.trim() || undefined;
}
}
try {
return ctx.forProfile(profileName);
} catch (err) {
return { error: String(err), status: 404 };
}
}
export function jsonError(res: BrowserResponse, status: number, message: string) {
res.status(status).json({ error: message });
}
export function toStringOrEmpty(value: unknown) {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value).trim();
}
return "";
}
export function toNumber(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
export function toBoolean(value: unknown) {
return parseBooleanValue(value, {
truthy: ["true", "1", "yes"],
falsy: ["false", "0", "no"],
});
}
export function toStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const strings = value.map((v) => toStringOrEmpty(v)).filter(Boolean);
return strings.length ? strings : undefined;
}

View File

@@ -0,0 +1,60 @@
import type { Server } from "node:http";
import { isPwAiLoaded } from "./pw-ai-state.js";
import type { BrowserServerState } from "./server-context.js";
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
export async function createBrowserRuntimeState(params: {
resolved: BrowserServerState["resolved"];
port: number;
server?: Server | null;
onWarn: (message: string) => void;
}): Promise<BrowserServerState> {
const state: BrowserServerState = {
server: params.server ?? null,
port: params.port,
resolved: params.resolved,
profiles: new Map(),
};
await ensureExtensionRelayForProfiles({
resolved: params.resolved,
onWarn: params.onWarn,
});
return state;
}
export async function stopBrowserRuntime(params: {
current: BrowserServerState | null;
getState: () => BrowserServerState | null;
clearState: () => void;
closeServer?: boolean;
onWarn: (message: string) => void;
}): Promise<void> {
if (!params.current) {
return;
}
await stopKnownBrowserProfiles({
getState: params.getState,
onWarn: params.onWarn,
});
if (params.closeServer && params.current.server) {
await new Promise<void>((resolve) => {
params.current?.server?.close(() => resolve());
});
}
params.clearState();
if (!isPwAiLoaded()) {
return;
}
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection();
} catch {
// ignore
}
}

View File

@@ -0,0 +1,26 @@
import path from "node:path";
export function sanitizeUntrustedFileName(fileName: string, fallbackName: string): string {
const trimmed = String(fileName ?? "").trim();
if (!trimmed) {
return fallbackName;
}
let base = path.posix.basename(trimmed);
base = path.win32.basename(base);
let cleaned = "";
for (let i = 0; i < base.length; i++) {
const code = base.charCodeAt(i);
if (code < 0x20 || code === 0x7f) {
continue;
}
cleaned += base[i];
}
base = cleaned.trim();
if (!base || base === "." || base === "..") {
return fallbackName;
}
if (base.length > 200) {
base = base.slice(0, 200);
}
return base;
}

View File

@@ -0,0 +1,58 @@
import {
buildImageResizeSideGrid,
getImageMetadata,
IMAGE_REDUCE_QUALITY_STEPS,
resizeToJpeg,
} from "../media/image-ops.js";
export const DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE = 2000;
export const DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES = 5 * 1024 * 1024;
export async function normalizeBrowserScreenshot(
buffer: Buffer,
opts?: {
maxSide?: number;
maxBytes?: number;
},
): Promise<{ buffer: Buffer; contentType?: "image/jpeg" }> {
const maxSide = Math.max(1, Math.round(opts?.maxSide ?? DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE));
const maxBytes = Math.max(1, Math.round(opts?.maxBytes ?? DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES));
const meta = await getImageMetadata(buffer);
const width = Number(meta?.width ?? 0);
const height = Number(meta?.height ?? 0);
const maxDim = Math.max(width, height);
if (buffer.byteLength <= maxBytes && (maxDim === 0 || (width <= maxSide && height <= maxSide))) {
return { buffer };
}
const sideStart = maxDim > 0 ? Math.min(maxSide, maxDim) : maxSide;
const sideGrid = buildImageResizeSideGrid(maxSide, sideStart);
let smallest: { buffer: Buffer; size: number } | null = null;
for (const side of sideGrid) {
for (const quality of IMAGE_REDUCE_QUALITY_STEPS) {
const out = await resizeToJpeg({
buffer,
maxSide: side,
quality,
withoutEnlargement: true,
});
if (!smallest || out.byteLength < smallest.size) {
smallest = { buffer: out, size: out.byteLength };
}
if (out.byteLength <= maxBytes) {
return { buffer: out, contentType: "image/jpeg" };
}
}
}
const best = smallest?.buffer ?? buffer;
throw new Error(
`Browser screenshot could not be reduced below ${(maxBytes / (1024 * 1024)).toFixed(0)}MB (got ${(best.byteLength / (1024 * 1024)).toFixed(2)}MB)`,
);
}

View File

@@ -0,0 +1,293 @@
import fs from "node:fs";
import {
CHROME_MCP_ATTACH_READY_POLL_MS,
CHROME_MCP_ATTACH_READY_WINDOW_MS,
PROFILE_ATTACH_RETRY_TIMEOUT_MS,
PROFILE_POST_RESTART_WS_TIMEOUT_MS,
resolveCdpReachabilityTimeouts,
} from "./cdp-timeouts.js";
import {
closeChromeMcpSession,
ensureChromeMcpAvailable,
listChromeMcpTabs,
} from "./chrome-mcp.js";
import {
isChromeCdpReady,
isChromeReachable,
launchOpenClawChrome,
stopOpenClawChrome,
} from "./chrome.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserProfileUnavailableError } from "./errors.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import {
CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS,
CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS,
CDP_READY_AFTER_LAUNCH_POLL_MS,
CDP_READY_AFTER_LAUNCH_WINDOW_MS,
} from "./server-context.constants.js";
import type {
BrowserServerState,
ContextOptions,
ProfileRuntimeState,
} from "./server-context.types.js";
type AvailabilityDeps = {
opts: ContextOptions;
profile: ResolvedBrowserProfile;
state: () => BrowserServerState;
getProfileState: () => ProfileRuntimeState;
setProfileRunning: (running: ProfileRuntimeState["running"]) => void;
};
type AvailabilityOps = {
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
isReachable: (timeoutMs?: number) => Promise<boolean>;
ensureBrowserAvailable: () => Promise<void>;
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
};
export function createProfileAvailability({
opts,
profile,
state,
getProfileState,
setProfileRunning,
}: AvailabilityDeps): AvailabilityOps {
const capabilities = getBrowserProfileCapabilities(profile);
const resolveTimeouts = (timeoutMs: number | undefined) =>
resolveCdpReachabilityTimeouts({
profileIsLoopback: profile.cdpIsLoopback,
timeoutMs,
remoteHttpTimeoutMs: state().resolved.remoteCdpTimeoutMs,
remoteHandshakeTimeoutMs: state().resolved.remoteCdpHandshakeTimeoutMs,
});
const isReachable = async (timeoutMs?: number) => {
if (capabilities.usesChromeMcp) {
// listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required
await listChromeMcpTabs(profile.name, profile.userDataDir);
return true;
}
const { httpTimeoutMs, wsTimeoutMs } = resolveTimeouts(timeoutMs);
return await isChromeCdpReady(
profile.cdpUrl,
httpTimeoutMs,
wsTimeoutMs,
state().resolved.ssrfPolicy,
);
};
const isHttpReachable = async (timeoutMs?: number) => {
if (capabilities.usesChromeMcp) {
return await isReachable(timeoutMs);
}
const { httpTimeoutMs } = resolveTimeouts(timeoutMs);
return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, state().resolved.ssrfPolicy);
};
const attachRunning = (running: NonNullable<ProfileRuntimeState["running"]>) => {
setProfileRunning(running);
running.proc.on("exit", () => {
// Guard against server teardown (e.g., SIGUSR1 restart)
if (!opts.getState()) {
return;
}
const profileState = getProfileState();
if (profileState.running?.pid === running.pid) {
setProfileRunning(null);
}
});
};
const closePlaywrightBrowserConnectionForProfile = async (cdpUrl?: string): Promise<void> => {
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined);
} catch {
// ignore
}
};
const reconcileProfileRuntime = async (): Promise<void> => {
const profileState = getProfileState();
const reconcile = profileState.reconcile;
if (!reconcile) {
return;
}
profileState.reconcile = null;
profileState.lastTargetId = null;
const previousProfile = reconcile.previousProfile;
if (profileState.running) {
await stopOpenClawChrome(profileState.running).catch(() => {});
setProfileRunning(null);
}
if (getBrowserProfileCapabilities(previousProfile).usesChromeMcp) {
await closeChromeMcpSession(previousProfile.name).catch(() => false);
}
await closePlaywrightBrowserConnectionForProfile(previousProfile.cdpUrl);
if (previousProfile.cdpUrl !== profile.cdpUrl) {
await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl);
}
};
const waitForCdpReadyAfterLaunch = async (): Promise<void> => {
// launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS.
// If a follow-up call races ahead, we can hit PortInUseError trying to launch again on the same port.
const deadlineMs = Date.now() + CDP_READY_AFTER_LAUNCH_WINDOW_MS;
while (Date.now() < deadlineMs) {
const remainingMs = Math.max(0, deadlineMs - Date.now());
// Keep each attempt short; loopback profiles derive a WS timeout from this value.
const attemptTimeoutMs = Math.max(
CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS,
Math.min(CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS, remainingMs),
);
if (await isReachable(attemptTimeoutMs)) {
return;
}
await new Promise((r) => setTimeout(r, CDP_READY_AFTER_LAUNCH_POLL_MS));
}
throw new Error(
`Chrome CDP websocket for profile "${profile.name}" is not reachable after start.`,
);
};
const waitForChromeMcpReadyAfterAttach = async (): Promise<void> => {
const deadlineMs = Date.now() + CHROME_MCP_ATTACH_READY_WINDOW_MS;
let lastError: unknown;
while (Date.now() < deadlineMs) {
try {
await listChromeMcpTabs(profile.name, profile.userDataDir);
return;
} catch (err) {
lastError = err;
}
await new Promise((r) => setTimeout(r, CHROME_MCP_ATTACH_READY_POLL_MS));
}
const detail = lastError instanceof Error ? ` Last error: ${lastError.message}` : "";
throw new BrowserProfileUnavailableError(
`Chrome MCP existing-session attach for profile "${profile.name}" timed out waiting for tabs to become available.` +
` Approve the browser attach prompt, keep the browser open, and retry.${detail}`,
);
};
const ensureBrowserAvailable = async (): Promise<void> => {
await reconcileProfileRuntime();
if (capabilities.usesChromeMcp) {
if (profile.userDataDir && !fs.existsSync(profile.userDataDir)) {
throw new BrowserProfileUnavailableError(
`Browser user data directory not found for profile "${profile.name}": ${profile.userDataDir}`,
);
}
await ensureChromeMcpAvailable(profile.name, profile.userDataDir);
await waitForChromeMcpReadyAfterAttach();
return;
}
const current = state();
const remoteCdp = capabilities.isRemote;
const attachOnly = profile.attachOnly;
const profileState = getProfileState();
const httpReachable = await isHttpReachable();
if (!httpReachable) {
if ((attachOnly || remoteCdp) && opts.onEnsureAttachTarget) {
await opts.onEnsureAttachTarget(profile);
if (await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS)) {
return;
}
}
// Browser control service can restart while a loopback OpenClaw browser is still
// alive. Give that pre-existing browser one longer probe window before falling
// back to local executable resolution.
if (!attachOnly && !remoteCdp && profile.cdpIsLoopback && !profileState.running) {
if (
(await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS)) &&
(await isReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))
) {
return;
}
}
if (attachOnly || remoteCdp) {
throw new BrowserProfileUnavailableError(
remoteCdp
? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
);
}
const launched = await launchOpenClawChrome(current.resolved, profile);
attachRunning(launched);
try {
await waitForCdpReadyAfterLaunch();
} catch (err) {
await stopOpenClawChrome(launched).catch(() => {});
setProfileRunning(null);
throw err;
}
return;
}
// Port is reachable - check if we own it.
if (await isReachable()) {
return;
}
// HTTP responds but WebSocket fails. For attachOnly/remote profiles, never perform
// local ownership/restart handling; just run attach retries and surface attach errors.
if (attachOnly || remoteCdp) {
if (opts.onEnsureAttachTarget) {
await opts.onEnsureAttachTarget(profile);
if (await isReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS)) {
return;
}
}
throw new BrowserProfileUnavailableError(
remoteCdp
? `Remote CDP websocket for profile "${profile.name}" is not reachable.`
: `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`,
);
}
// HTTP responds but WebSocket fails - port in use by something else.
if (!profileState.running) {
throw new BrowserProfileUnavailableError(
`Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. ` +
`Run action=reset-profile profile=${profile.name} to kill the process.`,
);
}
await stopOpenClawChrome(profileState.running);
setProfileRunning(null);
const relaunched = await launchOpenClawChrome(current.resolved, profile);
attachRunning(relaunched);
if (!(await isReachable(PROFILE_POST_RESTART_WS_TIMEOUT_MS))) {
throw new Error(
`Chrome CDP websocket for profile "${profile.name}" is not reachable after restart.`,
);
}
};
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
await reconcileProfileRuntime();
if (capabilities.usesChromeMcp) {
const stopped = await closeChromeMcpSession(profile.name);
return { stopped };
}
const profileState = getProfileState();
if (!profileState.running) {
return { stopped: false };
}
await stopOpenClawChrome(profileState.running);
setProfileRunning(null);
return { stopped: true };
};
return {
isHttpReachable,
isReachable,
ensureBrowserAvailable,
stopRunningBrowser,
};
}

View File

@@ -0,0 +1,15 @@
import { vi } from "vitest";
import { installChromeUserDataDirHooks } from "./chrome-user-data-dir.test-harness.js";
const chromeUserDataDir = { dir: "/tmp/openclaw" };
installChromeUserDataDirHooks(chromeUserDataDir);
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => true),
isChromeReachable: vi.fn(async () => true),
launchOpenClawChrome: vi.fn(async () => {
throw new Error("unexpected launch");
}),
resolveOpenClawUserDataDir: vi.fn(() => chromeUserDataDir.dir),
stopOpenClawChrome: vi.fn(async () => {}),
}));

View File

@@ -0,0 +1,9 @@
export const MANAGED_BROWSER_PAGE_TAB_LIMIT = 8;
export const OPEN_TAB_DISCOVERY_WINDOW_MS = 2000;
export const OPEN_TAB_DISCOVERY_POLL_MS = 100;
export const CDP_READY_AFTER_LAUNCH_WINDOW_MS = 8000;
export const CDP_READY_AFTER_LAUNCH_POLL_MS = 100;
export const CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS = 75;
export const CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS = 250;

View File

@@ -0,0 +1,107 @@
import { vi } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import type { BrowserServerState } from "./server-context.js";
import { createBrowserRouteContext } from "./server-context.js";
export const originalFetch = globalThis.fetch;
export function makeState(
profile: "remote" | "openclaw",
): BrowserServerState & { profiles: Map<string, { lastTargetId?: string | null }> } {
return {
// oxlint-disable-next-line typescript/no-explicit-any
server: null as any,
port: 0,
resolved: {
enabled: true,
controlPort: 18791,
cdpPortRangeStart: 18800,
cdpPortRangeEnd: 18899,
cdpProtocol: profile === "remote" ? "https" : "http",
cdpHost: profile === "remote" ? "browserless.example" : "127.0.0.1",
cdpIsLoopback: profile !== "remote",
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
evaluateEnabled: false,
extraArgs: [],
color: "#FF4500",
headless: true,
noSandbox: false,
attachOnly: false,
ssrfPolicy: { allowPrivateNetwork: true },
defaultProfile: profile,
profiles: {
remote: {
cdpUrl: "https://browserless.example/chrome?token=abc",
cdpPort: 443,
color: "#00AA00",
},
openclaw: { cdpPort: 18800, color: "#FF4500" },
},
},
profiles: new Map(),
};
}
export function makeUnexpectedFetchMock() {
return vi.fn(async () => {
throw new Error("unexpected fetch");
});
}
export function createRemoteRouteHarness(fetchMock?: (url: unknown) => Promise<Response>) {
const activeFetchMock = fetchMock ?? makeUnexpectedFetchMock();
global.fetch = withFetchPreconnect(activeFetchMock);
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
return { state, remote: ctx.forProfile("remote"), fetchMock: activeFetchMock };
}
export function createSequentialPageLister<T>(responses: T[]) {
return async () => {
const next = responses.shift();
if (!next) {
throw new Error("no more responses");
}
return next;
};
}
type JsonListEntry = {
id: string;
title: string;
url: string;
webSocketDebuggerUrl: string;
type: "page";
};
export function createJsonListFetchMock(entries: JsonListEntry[]) {
return async (url: unknown) => {
const u = String(url);
if (!u.includes("/json/list")) {
throw new Error(`unexpected fetch: ${u}`);
}
return {
ok: true,
json: async () => entries,
} as unknown as Response;
};
}
function makeManagedTab(id: string, ordinal: number): JsonListEntry {
return {
id,
title: String(ordinal),
url: `http://127.0.0.1:300${ordinal}`,
webSocketDebuggerUrl: `ws://127.0.0.1/devtools/page/${id}`,
type: "page",
};
}
export function makeManagedTabsWithNew(params?: { newFirst?: boolean }): JsonListEntry[] {
const oldTabs = Array.from({ length: 8 }, (_, index) =>
makeManagedTab(`OLD${index + 1}`, index + 1),
);
const newTab = makeManagedTab("NEW", 9);
return params?.newFirst ? [newTab, ...oldTabs] : [...oldTabs, newTab];
}

View File

@@ -0,0 +1,67 @@
import fs from "node:fs";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserResetUnsupportedError } from "./errors.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import type { ProfileRuntimeState } from "./server-context.types.js";
import { movePathToTrash } from "./trash.js";
type ResetDeps = {
profile: ResolvedBrowserProfile;
getProfileState: () => ProfileRuntimeState;
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
resolveOpenClawUserDataDir: (profileName: string) => string;
};
type ResetOps = {
resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>;
};
async function closePlaywrightBrowserConnectionForProfile(cdpUrl?: string): Promise<void> {
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined);
} catch {
// ignore
}
}
export function createProfileResetOps({
profile,
getProfileState,
stopRunningBrowser,
isHttpReachable,
resolveOpenClawUserDataDir,
}: ResetDeps): ResetOps {
const capabilities = getBrowserProfileCapabilities(profile);
const resetProfile = async () => {
if (!capabilities.supportsReset) {
throw new BrowserResetUnsupportedError(
`reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`,
);
}
const userDataDir = resolveOpenClawUserDataDir(profile.name);
const profileState = getProfileState();
const httpReachable = await isHttpReachable(300);
if (httpReachable && !profileState.running) {
// Port in use but not by us - kill it.
await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl);
}
if (profileState.running) {
await stopRunningBrowser();
}
await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl);
if (!fs.existsSync(userDataDir)) {
return { moved: false, from: userDataDir };
}
const moved = await movePathToTrash(userDataDir);
return { moved: true, from: userDataDir, to: moved };
};
return { resetProfile };
}

View File

@@ -0,0 +1,153 @@
import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
import { appendCdpPath } from "./cdp.js";
import { closeChromeMcpTab, focusChromeMcpTab } from "./chrome-mcp.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import type { PwAiModule } from "./pw-ai-module.js";
import { getPwAiModule } from "./pw-ai-module.js";
import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js";
import { resolveTargetIdFromTabs } from "./target-id.js";
type SelectionDeps = {
profile: ResolvedBrowserProfile;
getProfileState: () => ProfileRuntimeState;
ensureBrowserAvailable: () => Promise<void>;
listTabs: () => Promise<BrowserTab[]>;
openTab: (url: string) => Promise<BrowserTab>;
};
type SelectionOps = {
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
focusTab: (targetId: string) => Promise<void>;
closeTab: (targetId: string) => Promise<void>;
};
export function createProfileSelectionOps({
profile,
getProfileState,
ensureBrowserAvailable,
listTabs,
openTab,
}: SelectionDeps): SelectionOps {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
const capabilities = getBrowserProfileCapabilities(profile);
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
await ensureBrowserAvailable();
const profileState = getProfileState();
const tabs1 = await listTabs();
if (tabs1.length === 0) {
await openTab("about:blank");
}
const tabs = await listTabs();
const candidates = capabilities.supportsPerTabWs ? tabs.filter((t) => Boolean(t.wsUrl)) : tabs;
const resolveById = (raw: string) => {
const resolved = resolveTargetIdFromTabs(raw, candidates);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") {
return "AMBIGUOUS" as const;
}
return null;
}
return candidates.find((t) => t.targetId === resolved.targetId) ?? null;
};
const pickDefault = () => {
const last = profileState.lastTargetId?.trim() || "";
const lastResolved = last ? resolveById(last) : null;
if (lastResolved && lastResolved !== "AMBIGUOUS") {
return lastResolved;
}
// Prefer a real page tab first (avoid service workers/background targets).
const page = candidates.find((t) => (t.type ?? "page") === "page");
return page ?? candidates.at(0) ?? null;
};
const chosen = targetId ? resolveById(targetId) : pickDefault();
if (chosen === "AMBIGUOUS") {
throw new BrowserTargetAmbiguousError();
}
if (!chosen) {
throw new BrowserTabNotFoundError();
}
profileState.lastTargetId = chosen.targetId;
return chosen;
};
const resolveTargetIdOrThrow = async (targetId: string): Promise<string> => {
const tabs = await listTabs();
const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") {
throw new BrowserTargetAmbiguousError();
}
throw new BrowserTabNotFoundError();
}
return resolved.targetId;
};
const focusTab = async (targetId: string): Promise<void> => {
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
if (capabilities.usesChromeMcp) {
await focusChromeMcpTab(profile.name, resolvedTargetId, profile.userDataDir);
const profileState = getProfileState();
profileState.lastTargetId = resolvedTargetId;
return;
}
if (capabilities.usesPersistentPlaywright) {
const mod = await getPwAiModule({ mode: "strict" });
const focusPageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
?.focusPageByTargetIdViaPlaywright;
if (typeof focusPageByTargetIdViaPlaywright === "function") {
await focusPageByTargetIdViaPlaywright({
cdpUrl: profile.cdpUrl,
targetId: resolvedTargetId,
});
const profileState = getProfileState();
profileState.lastTargetId = resolvedTargetId;
return;
}
}
await fetchOk(appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`));
const profileState = getProfileState();
profileState.lastTargetId = resolvedTargetId;
};
const closeTab = async (targetId: string): Promise<void> => {
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
if (capabilities.usesChromeMcp) {
await closeChromeMcpTab(profile.name, resolvedTargetId, profile.userDataDir);
return;
}
// For remote profiles, use Playwright's persistent connection to close tabs
if (capabilities.usesPersistentPlaywright) {
const mod = await getPwAiModule({ mode: "strict" });
const closePageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
?.closePageByTargetIdViaPlaywright;
if (typeof closePageByTargetIdViaPlaywright === "function") {
await closePageByTargetIdViaPlaywright({
cdpUrl: profile.cdpUrl,
targetId: resolvedTargetId,
});
return;
}
}
await fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`));
};
return {
ensureTabAvailable,
focusTab,
closeTab,
};
}

View File

@@ -0,0 +1,243 @@
import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js";
import { fetchJson, fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
import { listChromeMcpTabs, openChromeMcpTab } from "./chrome-mcp.js";
import type { ResolvedBrowserProfile } from "./config.js";
import {
assertBrowserNavigationAllowed,
assertBrowserNavigationResultAllowed,
InvalidBrowserNavigationUrlError,
requiresInspectableBrowserNavigationRedirects,
withBrowserNavigationPolicy,
} from "./navigation-guard.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import type { PwAiModule } from "./pw-ai-module.js";
import { getPwAiModule } from "./pw-ai-module.js";
import {
MANAGED_BROWSER_PAGE_TAB_LIMIT,
OPEN_TAB_DISCOVERY_POLL_MS,
OPEN_TAB_DISCOVERY_WINDOW_MS,
} from "./server-context.constants.js";
import type {
BrowserServerState,
BrowserTab,
ProfileRuntimeState,
} from "./server-context.types.js";
type TabOpsDeps = {
profile: ResolvedBrowserProfile;
state: () => BrowserServerState;
getProfileState: () => ProfileRuntimeState;
};
type ProfileTabOps = {
listTabs: () => Promise<BrowserTab[]>;
openTab: (url: string) => Promise<BrowserTab>;
};
/**
* Normalize a CDP WebSocket URL to use the correct base URL.
*/
function normalizeWsUrl(raw: string | undefined, cdpBaseUrl: string): string | undefined {
if (!raw) {
return undefined;
}
try {
return normalizeCdpWsUrl(raw, cdpBaseUrl);
} catch {
return raw;
}
}
type CdpTarget = {
id?: string;
title?: string;
url?: string;
webSocketDebuggerUrl?: string;
type?: string;
};
export function createProfileTabOps({
profile,
state,
getProfileState,
}: TabOpsDeps): ProfileTabOps {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
const capabilities = getBrowserProfileCapabilities(profile);
const listTabs = async (): Promise<BrowserTab[]> => {
if (capabilities.usesChromeMcp) {
return await listChromeMcpTabs(profile.name, profile.userDataDir);
}
if (capabilities.usesPersistentPlaywright) {
const mod = await getPwAiModule({ mode: "strict" });
const listPagesViaPlaywright = (mod as Partial<PwAiModule> | null)?.listPagesViaPlaywright;
if (typeof listPagesViaPlaywright === "function") {
const pages = await listPagesViaPlaywright({ cdpUrl: profile.cdpUrl });
return pages.map((p) => ({
targetId: p.targetId,
title: p.title,
url: p.url,
type: p.type,
}));
}
}
const raw = await fetchJson<
Array<{
id?: string;
title?: string;
url?: string;
webSocketDebuggerUrl?: string;
type?: string;
}>
>(appendCdpPath(cdpHttpBase, "/json/list"));
return raw
.map((t) => ({
targetId: t.id ?? "",
title: t.title ?? "",
url: t.url ?? "",
wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl, profile.cdpUrl),
type: t.type,
}))
.filter((t) => Boolean(t.targetId));
};
const enforceManagedTabLimit = async (keepTargetId: string): Promise<void> => {
const profileState = getProfileState();
if (
!capabilities.supportsManagedTabLimit ||
state().resolved.attachOnly ||
!profileState.running
) {
return;
}
const pageTabs = await listTabs()
.then((tabs) => tabs.filter((tab) => (tab.type ?? "page") === "page"))
.catch(() => [] as BrowserTab[]);
if (pageTabs.length <= MANAGED_BROWSER_PAGE_TAB_LIMIT) {
return;
}
const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId);
const excessCount = pageTabs.length - MANAGED_BROWSER_PAGE_TAB_LIMIT;
for (const tab of candidates.slice(0, excessCount)) {
void fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${tab.targetId}`)).catch(() => {
// best-effort cleanup only
});
}
};
const triggerManagedTabLimit = (keepTargetId: string): void => {
void enforceManagedTabLimit(keepTargetId).catch(() => {
// best-effort cleanup only
});
};
const openTab = async (url: string): Promise<BrowserTab> => {
const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);
if (capabilities.usesChromeMcp) {
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const page = await openChromeMcpTab(profile.name, url, profile.userDataDir);
const profileState = getProfileState();
profileState.lastTargetId = page.targetId;
await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts });
return page;
}
if (capabilities.usesPersistentPlaywright) {
const mod = await getPwAiModule({ mode: "strict" });
const createPageViaPlaywright = (mod as Partial<PwAiModule> | null)?.createPageViaPlaywright;
if (typeof createPageViaPlaywright === "function") {
const page = await createPageViaPlaywright({
cdpUrl: profile.cdpUrl,
url,
...ssrfPolicyOpts,
});
const profileState = getProfileState();
profileState.lastTargetId = page.targetId;
triggerManagedTabLimit(page.targetId);
return {
targetId: page.targetId,
title: page.title,
url: page.url,
type: page.type,
};
}
}
if (requiresInspectableBrowserNavigationRedirects(state().resolved.ssrfPolicy)) {
throw new InvalidBrowserNavigationUrlError(
"Navigation blocked: strict browser SSRF policy requires Playwright-backed redirect-hop inspection",
);
}
const createdViaCdp = await createTargetViaCdp({
cdpUrl: profile.cdpUrl,
url,
...ssrfPolicyOpts,
})
.then((r) => r.targetId)
.catch(() => null);
if (createdViaCdp) {
const profileState = getProfileState();
profileState.lastTargetId = createdViaCdp;
const deadline = Date.now() + OPEN_TAB_DISCOVERY_WINDOW_MS;
while (Date.now() < deadline) {
const tabs = await listTabs().catch(() => [] as BrowserTab[]);
const found = tabs.find((t) => t.targetId === createdViaCdp);
if (found) {
await assertBrowserNavigationResultAllowed({ url: found.url, ...ssrfPolicyOpts });
triggerManagedTabLimit(found.targetId);
return found;
}
await new Promise((r) => setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS));
}
triggerManagedTabLimit(createdViaCdp);
return { targetId: createdViaCdp, title: "", url, type: "page" };
}
const encoded = encodeURIComponent(url);
const endpointUrl = new URL(appendCdpPath(cdpHttpBase, "/json/new"));
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const endpoint = endpointUrl.search
? (() => {
endpointUrl.searchParams.set("url", url);
return endpointUrl.toString();
})()
: `${endpointUrl.toString()}?${encoded}`;
const created = await fetchJson<CdpTarget>(endpoint, CDP_JSON_NEW_TIMEOUT_MS, {
method: "PUT",
}).catch(async (err) => {
if (String(err).includes("HTTP 405")) {
return await fetchJson<CdpTarget>(endpoint, CDP_JSON_NEW_TIMEOUT_MS);
}
throw err;
});
if (!created.id) {
throw new Error("Failed to open tab (missing id)");
}
const profileState = getProfileState();
profileState.lastTargetId = created.id;
const resolvedUrl = created.url ?? url;
await assertBrowserNavigationResultAllowed({ url: resolvedUrl, ...ssrfPolicyOpts });
triggerManagedTabLimit(created.id);
return {
targetId: created.id,
title: created.title ?? "",
url: resolvedUrl,
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl),
type: created.type,
};
};
return {
listTabs,
openTab,
};
}

View File

@@ -0,0 +1,258 @@
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { resolveProfile } from "./config.js";
import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import {
refreshResolvedBrowserConfigFromDisk,
resolveBrowserProfileWithHotReload,
} from "./resolved-config-refresh.js";
import { createProfileAvailability } from "./server-context.availability.js";
import { createProfileResetOps } from "./server-context.reset.js";
import { createProfileSelectionOps } from "./server-context.selection.js";
import { createProfileTabOps } from "./server-context.tab-ops.js";
import type {
BrowserServerState,
BrowserRouteContext,
BrowserTab,
ContextOptions,
ProfileContext,
ProfileRuntimeState,
ProfileStatus,
} from "./server-context.types.js";
export type {
BrowserRouteContext,
BrowserServerState,
BrowserTab,
ProfileContext,
ProfileRuntimeState,
ProfileStatus,
} from "./server-context.types.js";
export function listKnownProfileNames(state: BrowserServerState): string[] {
const names = new Set(Object.keys(state.resolved.profiles));
for (const name of state.profiles.keys()) {
names.add(name);
}
return [...names];
}
/**
* Create a profile-scoped context for browser operations.
*/
function createProfileContext(
opts: ContextOptions,
profile: ResolvedBrowserProfile,
): ProfileContext {
const state = () => {
const current = opts.getState();
if (!current) {
throw new Error("Browser server not started");
}
return current;
};
const getProfileState = (): ProfileRuntimeState => {
const current = state();
let profileState = current.profiles.get(profile.name);
if (!profileState) {
profileState = { profile, running: null, lastTargetId: null, reconcile: null };
current.profiles.set(profile.name, profileState);
}
return profileState;
};
const setProfileRunning = (running: ProfileRuntimeState["running"]) => {
const profileState = getProfileState();
profileState.running = running;
};
const { listTabs, openTab } = createProfileTabOps({
profile,
state,
getProfileState,
});
const { ensureBrowserAvailable, isHttpReachable, isReachable, stopRunningBrowser } =
createProfileAvailability({
opts,
profile,
state,
getProfileState,
setProfileRunning,
});
const { ensureTabAvailable, focusTab, closeTab } = createProfileSelectionOps({
profile,
getProfileState,
ensureBrowserAvailable,
listTabs,
openTab,
});
const { resetProfile } = createProfileResetOps({
profile,
getProfileState,
stopRunningBrowser,
isHttpReachable,
resolveOpenClawUserDataDir,
});
return {
profile,
ensureBrowserAvailable,
ensureTabAvailable,
isHttpReachable,
isReachable,
listTabs,
openTab,
focusTab,
closeTab,
stopRunningBrowser,
resetProfile,
};
}
export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteContext {
const refreshConfigFromDisk = opts.refreshConfigFromDisk === true;
const state = () => {
const current = opts.getState();
if (!current) {
throw new Error("Browser server not started");
}
return current;
};
const forProfile = (profileName?: string): ProfileContext => {
const current = state();
const name = profileName ?? current.resolved.defaultProfile;
const profile = resolveBrowserProfileWithHotReload({
current,
refreshConfigFromDisk,
name,
});
if (!profile) {
const available = Object.keys(current.resolved.profiles).join(", ");
throw new BrowserProfileNotFoundError(
`Profile "${name}" not found. Available profiles: ${available || "(none)"}`,
);
}
return createProfileContext(opts, profile);
};
const listProfiles = async (): Promise<ProfileStatus[]> => {
const current = state();
refreshResolvedBrowserConfigFromDisk({
current,
refreshConfigFromDisk,
mode: "cached",
});
const result: ProfileStatus[] = [];
for (const name of listKnownProfileNames(current)) {
const profileState = current.profiles.get(name);
const profile = resolveProfile(current.resolved, name) ?? profileState?.profile;
if (!profile) {
continue;
}
const capabilities = getBrowserProfileCapabilities(profile);
let tabCount = 0;
let running = false;
const profileCtx = createProfileContext(opts, profile);
if (capabilities.usesChromeMcp) {
try {
running = await profileCtx.isReachable(300);
if (running) {
const tabs = await profileCtx.listTabs();
tabCount = tabs.filter((t) => t.type === "page").length;
}
} catch {
// Chrome MCP not available
}
} else if (profileState?.running) {
running = true;
try {
const tabs = await profileCtx.listTabs();
tabCount = tabs.filter((t) => t.type === "page").length;
} catch {
// Browser might not be responsive
}
} else {
// Check if something is listening on the port
try {
const reachable = await isChromeReachable(
profile.cdpUrl,
200,
current.resolved.ssrfPolicy,
);
if (reachable) {
running = true;
const tabs = await profileCtx.listTabs().catch(() => []);
tabCount = tabs.filter((t) => t.type === "page").length;
}
} catch {
// Not reachable
}
}
result.push({
name,
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
cdpPort: capabilities.usesChromeMcp ? null : profile.cdpPort,
cdpUrl: capabilities.usesChromeMcp ? null : profile.cdpUrl,
color: profile.color,
driver: profile.driver,
running,
tabCount,
isDefault: name === current.resolved.defaultProfile,
isRemote: !profile.cdpIsLoopback,
missingFromConfig: !(name in current.resolved.profiles) || undefined,
reconcileReason: profileState?.reconcile?.reason ?? null,
});
}
return result;
};
// Create default profile context for backward compatibility
const getDefaultContext = () => forProfile();
const mapTabError = (err: unknown) => {
const browserMapped = toBrowserErrorResponse(err);
if (browserMapped) {
return browserMapped;
}
if (err instanceof SsrFBlockedError) {
return { status: 400, message: err.message };
}
if (err instanceof InvalidBrowserNavigationUrlError) {
return { status: 400, message: err.message };
}
return null;
};
return {
state,
forProfile,
listProfiles,
// Legacy methods delegate to default profile
ensureBrowserAvailable: () => getDefaultContext().ensureBrowserAvailable(),
ensureTabAvailable: (targetId) => getDefaultContext().ensureTabAvailable(targetId),
isHttpReachable: (timeoutMs) => getDefaultContext().isHttpReachable(timeoutMs),
isReachable: (timeoutMs) => getDefaultContext().isReachable(timeoutMs),
listTabs: () => getDefaultContext().listTabs(),
openTab: (url) => getDefaultContext().openTab(url),
focusTab: (targetId) => getDefaultContext().focusTab(targetId),
closeTab: (targetId) => getDefaultContext().closeTab(targetId),
stopRunningBrowser: () => getDefaultContext().stopRunningBrowser(),
resetProfile: () => getDefaultContext().resetProfile(),
mapTabError,
};
}

View File

@@ -0,0 +1,74 @@
import type { Server } from "node:http";
import type { RunningChrome } from "./chrome.js";
import type { BrowserTransport } from "./client.js";
import type { BrowserTab } from "./client.js";
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
export type { BrowserTab };
/**
* Runtime state for a single profile's Chrome instance.
*/
export type ProfileRuntimeState = {
profile: ResolvedBrowserProfile;
running: RunningChrome | null;
/** Sticky tab selection when callers omit targetId (keeps snapshot+act consistent). */
lastTargetId?: string | null;
reconcile?: {
previousProfile: ResolvedBrowserProfile;
reason: string;
} | null;
};
export type BrowserServerState = {
server?: Server | null;
port: number;
resolved: ResolvedBrowserConfig;
profiles: Map<string, ProfileRuntimeState>;
};
type BrowserProfileActions = {
ensureBrowserAvailable: () => Promise<void>;
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
isReachable: (timeoutMs?: number) => Promise<boolean>;
listTabs: () => Promise<BrowserTab[]>;
openTab: (url: string) => Promise<BrowserTab>;
focusTab: (targetId: string) => Promise<void>;
closeTab: (targetId: string) => Promise<void>;
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>;
};
export type BrowserRouteContext = {
state: () => BrowserServerState;
forProfile: (profileName?: string) => ProfileContext;
listProfiles: () => Promise<ProfileStatus[]>;
// Legacy methods delegate to default profile for backward compatibility
mapTabError: (err: unknown) => { status: number; message: string } | null;
} & BrowserProfileActions;
export type ProfileContext = {
profile: ResolvedBrowserProfile;
} & BrowserProfileActions;
export type ProfileStatus = {
name: string;
transport: BrowserTransport;
cdpPort: number | null;
cdpUrl: string | null;
color: string;
driver: ResolvedBrowserProfile["driver"];
running: boolean;
tabCount: number;
isDefault: boolean;
isRemote: boolean;
missingFromConfig?: boolean;
reconcileReason?: string | null;
};
export type ContextOptions = {
getState: () => BrowserServerState | null;
onEnsureAttachTarget?: (profile: ResolvedBrowserProfile) => Promise<void>;
refreshConfigFromDisk?: boolean;
};

View File

@@ -0,0 +1,47 @@
import { stopOpenClawChrome } from "./chrome.js";
import type { ResolvedBrowserConfig } from "./config.js";
import {
type BrowserServerState,
createBrowserRouteContext,
listKnownProfileNames,
} from "./server-context.js";
export async function ensureExtensionRelayForProfiles(_params: {
resolved: ResolvedBrowserConfig;
onWarn: (message: string) => void;
}) {
// Intentional no-op: the Chrome extension relay path has been removed.
// runtime-lifecycle still calls this helper, so keep the stub until the next
// breaking cleanup rather than changing the call graph in a patch release.
}
export async function stopKnownBrowserProfiles(params: {
getState: () => BrowserServerState | null;
onWarn: (message: string) => void;
}) {
const current = params.getState();
if (!current) {
return;
}
const ctx = createBrowserRouteContext({
getState: params.getState,
refreshConfigFromDisk: true,
});
try {
for (const name of listKnownProfileNames(current)) {
try {
const runtime = current.profiles.get(name);
if (runtime?.running) {
await stopOpenClawChrome(runtime.running);
runtime.running = null;
continue;
}
await ctx.forProfile(name).stopRunningBrowser();
} catch {
// ignore
}
}
} catch (err) {
params.onWarn(`openclaw browser stop failed: ${String(err)}`);
}
}

View File

@@ -0,0 +1,37 @@
import type { Express } from "express";
import express from "express";
import { browserMutationGuardMiddleware } from "./csrf.js";
import { isAuthorizedBrowserRequest } from "./http-auth.js";
export function installBrowserCommonMiddleware(app: Express) {
app.use((req, res, next) => {
const ctrl = new AbortController();
const abort = () => ctrl.abort(new Error("request aborted"));
req.once("aborted", abort);
res.once("close", () => {
if (!res.writableEnded) {
abort();
}
});
// Make the signal available to browser route handlers (best-effort).
(req as unknown as { signal?: AbortSignal }).signal = ctrl.signal;
next();
});
app.use(express.json({ limit: "1mb" }));
app.use(browserMutationGuardMiddleware());
}
export function installBrowserAuthMiddleware(
app: Express,
auth: { token?: string; password?: string },
) {
if (!auth.token && !auth.password) {
return;
}
app.use((req, res, next) => {
if (isAuthorizedBrowserRequest(req, auth)) {
return next();
}
res.status(401).send("Unauthorized");
});
}

View File

@@ -0,0 +1,28 @@
import {
getBrowserControlServerBaseUrl,
installBrowserControlServerHooks,
startBrowserControlServerFromConfig,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-fetch.js";
export function installAgentContractHooks() {
installBrowserControlServerHooks();
}
export async function startServerAndBase(): Promise<string> {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
return base;
}
export async function postJson<T>(url: string, body?: unknown): Promise<T> {
const realFetch = getBrowserTestFetch();
const res = await realFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return (await res.json()) as T;
}

View File

@@ -0,0 +1,471 @@
import { afterEach, beforeEach, vi } from "vitest";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import { installChromeUserDataDirHooks } from "./chrome-user-data-dir.test-harness.js";
import { getFreePort } from "./test-port.js";
export { getFreePort } from "./test-port.js";
type HarnessState = {
testPort: number;
cdpBaseUrl: string;
reachable: boolean;
cfgAttachOnly: boolean;
cfgEvaluateEnabled: boolean;
cfgDefaultProfile: string;
cfgProfiles: Record<
string,
{
cdpPort?: number;
cdpUrl?: string;
color: string;
driver?: "openclaw" | "existing-session";
attachOnly?: boolean;
}
>;
createTargetId: string | null;
prevGatewayPort: string | undefined;
prevGatewayToken: string | undefined;
prevGatewayPassword: string | undefined;
};
const state: HarnessState = {
testPort: 0,
cdpBaseUrl: "",
reachable: false,
cfgAttachOnly: false,
cfgEvaluateEnabled: true,
cfgDefaultProfile: "openclaw",
cfgProfiles: {},
createTargetId: null,
prevGatewayPort: undefined,
prevGatewayToken: undefined,
prevGatewayPassword: undefined,
};
export function getBrowserControlServerTestState(): HarnessState {
return state;
}
export function getBrowserControlServerBaseUrl(): string {
return `http://127.0.0.1:${state.testPort}`;
}
export function restoreGatewayPortEnv(prevGatewayPort: string | undefined): void {
if (prevGatewayPort === undefined) {
delete process.env.OPENCLAW_GATEWAY_PORT;
return;
}
process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort;
}
export function setBrowserControlServerCreateTargetId(targetId: string | null): void {
state.createTargetId = targetId;
}
export function setBrowserControlServerAttachOnly(attachOnly: boolean): void {
state.cfgAttachOnly = attachOnly;
}
export function setBrowserControlServerEvaluateEnabled(enabled: boolean): void {
state.cfgEvaluateEnabled = enabled;
}
export function setBrowserControlServerReachable(reachable: boolean): void {
state.reachable = reachable;
}
export function setBrowserControlServerProfiles(
profiles: HarnessState["cfgProfiles"],
defaultProfile = Object.keys(profiles)[0] ?? "openclaw",
): void {
state.cfgProfiles = profiles;
state.cfgDefaultProfile = defaultProfile;
}
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn<() => Promise<{ targetId: string }>>(async () => {
throw new Error("cdp disabled");
}),
snapshotAria: vi.fn(async () => ({
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
})),
}));
export function getCdpMocks(): { createTargetViaCdp: MockFn; snapshotAria: MockFn } {
return cdpMocks as unknown as { createTargetViaCdp: MockFn; snapshotAria: MockFn };
}
const pwMocks = vi.hoisted(() => ({
armDialogViaPlaywright: vi.fn(async () => {}),
armFileUploadViaPlaywright: vi.fn(async () => {}),
batchViaPlaywright: vi.fn(async () => ({ results: [] })),
clickViaPlaywright: vi.fn(async () => {}),
closePageViaPlaywright: vi.fn(async () => {}),
closePlaywrightBrowserConnection: vi.fn(async () => {}),
downloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
dragViaPlaywright: vi.fn(async () => {}),
evaluateViaPlaywright: vi.fn(async () => "ok"),
fillFormViaPlaywright: vi.fn(async () => {}),
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
hoverViaPlaywright: vi.fn(async () => {}),
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
pressKeyViaPlaywright: vi.fn(async () => {}),
responseBodyViaPlaywright: vi.fn(async () => ({
url: "https://example.com/api/data",
status: 200,
headers: { "content-type": "application/json" },
body: '{"ok":true}',
})),
resizeViewportViaPlaywright: vi.fn(async () => {}),
selectOptionViaPlaywright: vi.fn(async () => {}),
setInputFilesViaPlaywright: vi.fn(async () => {}),
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
traceStopViaPlaywright: vi.fn(async () => {}),
takeScreenshotViaPlaywright: vi.fn(async () => ({
buffer: Buffer.from("png"),
})),
typeViaPlaywright: vi.fn(async () => {}),
waitForDownloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
waitForViaPlaywright: vi.fn(async () => {}),
}));
export function getPwMocks(): Record<string, MockFn> {
return pwMocks as unknown as Record<string, MockFn>;
}
const chromeMcpMocks = vi.hoisted(() => ({
clickChromeMcpElement: vi.fn(async () => {}),
closeChromeMcpSession: vi.fn(async () => true),
closeChromeMcpTab: vi.fn(async () => {}),
dragChromeMcpElement: vi.fn(async () => {}),
ensureChromeMcpAvailable: vi.fn(async () => {}),
evaluateChromeMcpScript: vi.fn(async () => true),
fillChromeMcpElement: vi.fn(async () => {}),
fillChromeMcpForm: vi.fn(async () => {}),
focusChromeMcpTab: vi.fn(async () => {}),
getChromeMcpPid: vi.fn(() => 4321),
hoverChromeMcpElement: vi.fn(async () => {}),
listChromeMcpTabs: vi.fn(async () => [
{ targetId: "7", title: "", url: "https://example.com", type: "page" },
]),
navigateChromeMcpPage: vi.fn(async ({ url }: { url: string }) => ({ url })),
openChromeMcpTab: vi.fn(async (_profile: string, url: string) => ({
targetId: "8",
title: "",
url,
type: "page",
})),
pressChromeMcpKey: vi.fn(async () => {}),
resizeChromeMcpPage: vi.fn(async () => {}),
takeChromeMcpScreenshot: vi.fn(async () => Buffer.from("png")),
takeChromeMcpSnapshot: vi.fn(async () => ({
id: "root",
role: "document",
name: "Example",
children: [{ id: "btn-1", role: "button", name: "Continue" }],
})),
uploadChromeMcpFile: vi.fn(async () => {}),
}));
export function getChromeMcpMocks(): Record<string, MockFn> {
return chromeMcpMocks as unknown as Record<string, MockFn>;
}
const chromeUserDataDir = vi.hoisted(() => ({ dir: "/tmp/openclaw" }));
installChromeUserDataDirHooks(chromeUserDataDir);
type BrowserServerModule = typeof import("./server.js");
let browserServerModule: BrowserServerModule | null = null;
async function loadBrowserServerModule(): Promise<BrowserServerModule> {
if (browserServerModule) {
return browserServerModule;
}
vi.resetModules();
browserServerModule = await import("./server.js");
return browserServerModule;
}
function makeProc(pid = 123) {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
return {
pid,
killed: false,
exitCode: null as number | null,
on: (event: string, cb: (...args: unknown[]) => void) => {
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) {
cb(0);
}
},
kill: () => {
return true;
},
};
}
const proc = makeProc();
function defaultProfilesForState(testPort: number): HarnessState["cfgProfiles"] {
return {
openclaw: { cdpPort: testPort + 9, color: "#FF4500" },
};
}
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
const loadConfig = () => {
return {
browser: {
enabled: true,
evaluateEnabled: state.cfgEvaluateEnabled,
color: "#FF4500",
attachOnly: state.cfgAttachOnly,
headless: true,
defaultProfile: state.cfgDefaultProfile,
profiles:
Object.keys(state.cfgProfiles).length > 0
? state.cfgProfiles
: defaultProfilesForState(state.testPort),
},
};
};
const writeConfigFile = vi.fn(async () => {});
return {
...actual,
createConfigIO: vi.fn(() => ({
loadConfig,
writeConfigFile,
})),
getRuntimeConfigSnapshot: vi.fn(() => null),
loadConfig,
writeConfigFile,
};
});
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
export function getLaunchCalls() {
return launchCalls;
}
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => state.reachable),
isChromeReachable: vi.fn(async () => state.reachable),
launchOpenClawChrome: vi.fn(async (_resolved: unknown, profile: { cdpPort: number }) => {
launchCalls.push({ port: profile.cdpPort });
state.reachable = true;
return {
pid: 123,
exe: { kind: "chrome", path: "/fake/chrome" },
userDataDir: chromeUserDataDir.dir,
cdpPort: profile.cdpPort,
startedAt: Date.now(),
proc,
};
}),
resolveOpenClawUserDataDir: vi.fn(() => chromeUserDataDir.dir),
stopOpenClawChrome: vi.fn(async () => {
state.reachable = false;
}),
}));
vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
getHeadersWithAuth: vi.fn(() => ({})),
appendCdpPath: vi.fn((cdpUrl: string, cdpPath: string) => {
const base = cdpUrl.replace(/\/$/, "");
const suffix = cdpPath.startsWith("/") ? cdpPath : `/${cdpPath}`;
return `${base}${suffix}`;
}),
}));
vi.mock("./pw-ai.js", () => pwMocks);
vi.mock("./chrome-mcp.js", () => chromeMcpMocks);
vi.mock("../media/store.js", () => ({
MEDIA_MAX_BYTES: 5 * 1024 * 1024,
ensureMediaDir: vi.fn(async () => {}),
getMediaDir: vi.fn(() => "/tmp"),
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
}));
vi.mock("./screenshot.js", () => ({
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
buffer: buf,
contentType: "image/png",
})),
}));
export async function startBrowserControlServerFromConfig() {
const server = await loadBrowserServerModule();
return await server.startBrowserControlServerFromConfig();
}
export async function stopBrowserControlServer(): Promise<void> {
const server = browserServerModule;
browserServerModule = null;
if (!server) {
return;
}
await server.stopBrowserControlServer();
}
export function makeResponse(
body: unknown,
init?: { ok?: boolean; status?: number; text?: string },
): Response {
const ok = init?.ok ?? true;
const status = init?.status ?? 200;
const text = init?.text ?? "";
return {
ok,
status,
json: async () => body,
text: async () => text,
} as unknown as Response;
}
function mockClearAll(obj: Record<string, { mockClear: () => unknown }>) {
for (const fn of Object.values(obj)) {
fn.mockClear();
}
}
export async function resetBrowserControlServerTestContext(): Promise<void> {
state.reachable = false;
state.cfgAttachOnly = false;
state.cfgEvaluateEnabled = true;
state.cfgDefaultProfile = "openclaw";
state.cfgProfiles = defaultProfilesForState(state.testPort);
state.createTargetId = null;
mockClearAll(pwMocks);
mockClearAll(cdpMocks);
mockClearAll(chromeMcpMocks);
state.testPort = await getFreePort();
state.cdpBaseUrl = `http://127.0.0.1:${state.testPort + 9}`;
state.cfgProfiles = defaultProfilesForState(state.testPort);
state.prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
process.env.OPENCLAW_GATEWAY_PORT = String(state.testPort - 2);
// Avoid flaky auth coupling: some suites temporarily set gateway env auth
// which would make the browser control server require auth.
state.prevGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN;
state.prevGatewayPassword = process.env.OPENCLAW_GATEWAY_PASSWORD;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
}
export function restoreGatewayAuthEnv(
prevGatewayToken: string | undefined,
prevGatewayPassword: string | undefined,
): void {
if (prevGatewayToken === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = prevGatewayToken;
}
if (prevGatewayPassword === undefined) {
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
} else {
process.env.OPENCLAW_GATEWAY_PASSWORD = prevGatewayPassword;
}
}
export async function cleanupBrowserControlServerTestContext(): Promise<void> {
vi.unstubAllGlobals();
vi.restoreAllMocks();
restoreGatewayPortEnv(state.prevGatewayPort);
restoreGatewayAuthEnv(state.prevGatewayToken, state.prevGatewayPassword);
await stopBrowserControlServer();
}
export function installBrowserControlServerHooks() {
beforeEach(async () => {
vi.useRealTimers();
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (state.createTargetId) {
return { targetId: state.createTargetId };
}
throw new Error("cdp disabled");
});
await resetBrowserControlServerTestContext();
await loadBrowserServerModule();
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!state.reachable) {
return makeResponse([]);
}
return makeResponse([
{
id: "abcd1234",
title: "Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
type: "page",
},
{
id: "abce9999",
title: "Other",
url: "https://other",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
type: "page",
},
]);
}
if (u.includes("/json/new?")) {
if (init?.method === "PUT") {
putNewCalls += 1;
if (putNewCalls === 1) {
return makeResponse({}, { ok: false, status: 405, text: "" });
}
}
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) {
return makeResponse("ok");
}
if (u.includes("/json/close/")) {
return makeResponse("ok");
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
await cleanupBrowserControlServerTestContext();
});
}

View File

@@ -0,0 +1 @@
export * from "../server.js";

View File

@@ -0,0 +1,189 @@
import { browserCloseTab } from "./client.js";
export type TrackedSessionBrowserTab = {
sessionKey: string;
targetId: string;
baseUrl?: string;
profile?: string;
trackedAt: number;
};
const trackedTabsBySession = new Map<string, Map<string, TrackedSessionBrowserTab>>();
function normalizeSessionKey(raw: string): string {
return raw.trim().toLowerCase();
}
function normalizeTargetId(raw: string): string {
return raw.trim();
}
function normalizeProfile(raw?: string): string | undefined {
if (!raw) {
return undefined;
}
const trimmed = raw.trim();
return trimmed ? trimmed.toLowerCase() : undefined;
}
function normalizeBaseUrl(raw?: string): string | undefined {
if (!raw) {
return undefined;
}
const trimmed = raw.trim();
return trimmed ? trimmed : undefined;
}
function toTrackedTabId(params: { targetId: string; baseUrl?: string; profile?: string }): string {
return `${params.targetId}\u0000${params.baseUrl ?? ""}\u0000${params.profile ?? ""}`;
}
function isIgnorableCloseError(err: unknown): boolean {
const message = String(err).toLowerCase();
return (
message.includes("tab not found") ||
message.includes("target closed") ||
message.includes("target not found") ||
message.includes("no such target")
);
}
export function trackSessionBrowserTab(params: {
sessionKey?: string;
targetId?: string;
baseUrl?: string;
profile?: string;
}): void {
const sessionKeyRaw = params.sessionKey?.trim();
const targetIdRaw = params.targetId?.trim();
if (!sessionKeyRaw || !targetIdRaw) {
return;
}
const sessionKey = normalizeSessionKey(sessionKeyRaw);
const targetId = normalizeTargetId(targetIdRaw);
const baseUrl = normalizeBaseUrl(params.baseUrl);
const profile = normalizeProfile(params.profile);
const tracked: TrackedSessionBrowserTab = {
sessionKey,
targetId,
baseUrl,
profile,
trackedAt: Date.now(),
};
const trackedId = toTrackedTabId(tracked);
let trackedForSession = trackedTabsBySession.get(sessionKey);
if (!trackedForSession) {
trackedForSession = new Map();
trackedTabsBySession.set(sessionKey, trackedForSession);
}
trackedForSession.set(trackedId, tracked);
}
export function untrackSessionBrowserTab(params: {
sessionKey?: string;
targetId?: string;
baseUrl?: string;
profile?: string;
}): void {
const sessionKeyRaw = params.sessionKey?.trim();
const targetIdRaw = params.targetId?.trim();
if (!sessionKeyRaw || !targetIdRaw) {
return;
}
const sessionKey = normalizeSessionKey(sessionKeyRaw);
const trackedForSession = trackedTabsBySession.get(sessionKey);
if (!trackedForSession) {
return;
}
const trackedId = toTrackedTabId({
targetId: normalizeTargetId(targetIdRaw),
baseUrl: normalizeBaseUrl(params.baseUrl),
profile: normalizeProfile(params.profile),
});
trackedForSession.delete(trackedId);
if (trackedForSession.size === 0) {
trackedTabsBySession.delete(sessionKey);
}
}
function takeTrackedTabsForSessionKeys(
sessionKeys: Array<string | undefined>,
): TrackedSessionBrowserTab[] {
const uniqueSessionKeys = new Set<string>();
for (const key of sessionKeys) {
if (!key?.trim()) {
continue;
}
uniqueSessionKeys.add(normalizeSessionKey(key));
}
if (uniqueSessionKeys.size === 0) {
return [];
}
const seenTrackedIds = new Set<string>();
const tabs: TrackedSessionBrowserTab[] = [];
for (const sessionKey of uniqueSessionKeys) {
const trackedForSession = trackedTabsBySession.get(sessionKey);
if (!trackedForSession || trackedForSession.size === 0) {
continue;
}
trackedTabsBySession.delete(sessionKey);
for (const tracked of trackedForSession.values()) {
const trackedId = toTrackedTabId(tracked);
if (seenTrackedIds.has(trackedId)) {
continue;
}
seenTrackedIds.add(trackedId);
tabs.push(tracked);
}
}
return tabs;
}
export async function closeTrackedBrowserTabsForSessions(params: {
sessionKeys: Array<string | undefined>;
closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise<void>;
onWarn?: (message: string) => void;
}): Promise<number> {
const tabs = takeTrackedTabsForSessionKeys(params.sessionKeys);
if (tabs.length === 0) {
return 0;
}
const closeTab =
params.closeTab ??
(async (tab: { targetId: string; baseUrl?: string; profile?: string }) => {
await browserCloseTab(tab.baseUrl, tab.targetId, {
profile: tab.profile,
});
});
let closed = 0;
for (const tab of tabs) {
try {
await closeTab({
targetId: tab.targetId,
baseUrl: tab.baseUrl,
profile: tab.profile,
});
closed += 1;
} catch (err) {
if (!isIgnorableCloseError(err)) {
params.onWarn?.(`failed to close tracked browser tab ${tab.targetId}: ${String(err)}`);
}
}
}
return closed;
}
export function __resetTrackedSessionBrowserTabsForTests(): void {
trackedTabsBySession.clear();
}
export function __countTrackedSessionBrowserTabsForTests(sessionKey?: string): number {
if (typeof sessionKey === "string" && sessionKey.trim()) {
return trackedTabsBySession.get(normalizeSessionKey(sessionKey))?.size ?? 0;
}
let count = 0;
for (const tracked of trackedTabsBySession.values()) {
count += tracked.size;
}
return count;
}

View File

@@ -0,0 +1,63 @@
/**
* Shared ARIA role classification sets used by both the Playwright and Chrome MCP
* snapshot paths. Keep these in sync — divergence causes the two drivers to produce
* different snapshot output for the same page.
*/
/** Roles that represent user-interactive elements and always get a ref. */
export const INTERACTIVE_ROLES = new Set([
"button",
"checkbox",
"combobox",
"link",
"listbox",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"radio",
"searchbox",
"slider",
"spinbutton",
"switch",
"tab",
"textbox",
"treeitem",
]);
/** Roles that carry meaningful content and get a ref when named. */
export const CONTENT_ROLES = new Set([
"article",
"cell",
"columnheader",
"gridcell",
"heading",
"listitem",
"main",
"navigation",
"region",
"rowheader",
]);
/** Structural/container roles — typically skipped in compact mode. */
export const STRUCTURAL_ROLES = new Set([
"application",
"directory",
"document",
"generic",
"grid",
"group",
"ignored",
"list",
"menu",
"menubar",
"none",
"presentation",
"row",
"rowgroup",
"table",
"tablist",
"toolbar",
"tree",
"treegrid",
]);

View File

@@ -0,0 +1,30 @@
export type TargetIdResolution =
| { ok: true; targetId: string }
| { ok: false; reason: "not_found" | "ambiguous"; matches?: string[] };
export function resolveTargetIdFromTabs(
input: string,
tabs: Array<{ targetId: string }>,
): TargetIdResolution {
const needle = input.trim();
if (!needle) {
return { ok: false, reason: "not_found" };
}
const exact = tabs.find((t) => t.targetId === needle);
if (exact) {
return { ok: true, targetId: exact.targetId };
}
const lower = needle.toLowerCase();
const matches = tabs.map((t) => t.targetId).filter((id) => id.toLowerCase().startsWith(lower));
const only = matches.length === 1 ? matches[0] : undefined;
if (only) {
return { ok: true, targetId: only };
}
if (matches.length === 0) {
return { ok: false, reason: "not_found" };
}
return { ok: false, reason: "ambiguous", matches };
}

View File

@@ -0,0 +1,30 @@
import { createRequire } from "node:module";
type FetchLike = ((input: string | URL, init?: RequestInit) => Promise<Response>) & {
mock?: unknown;
};
export type BrowserTestFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
function isUsableFetch(value: unknown): value is FetchLike {
return typeof value === "function" && !("mock" in (value as FetchLike));
}
export function getBrowserTestFetch(): BrowserTestFetch {
const require = createRequire(import.meta.url);
const vitest = (globalThis as { vi?: { doUnmock?: (id: string) => void } }).vi;
vitest?.doUnmock?.("undici");
try {
delete require.cache[require.resolve("undici")];
} catch {
// Best-effort cache bust for shared-thread test workers.
}
const { fetch } = require("undici") as typeof import("undici");
if (isUsableFetch(fetch)) {
return (input, init) => fetch(input, init);
}
if (isUsableFetch(globalThis.fetch)) {
return (input, init) => globalThis.fetch(input, init);
}
throw new TypeError("fetch is not a function");
}

View File

@@ -0,0 +1,18 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
export async function getFreePort(): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
const s = createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => {
const assigned = (s.address() as AddressInfo).port;
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) {
return port;
}
}
}

Some files were not shown because too many files have changed in this diff Show More