Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

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,82 @@
import { afterEach, describe, expect, it } from "vitest";
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "./bridge-server.js";
import type { ResolvedBrowserConfig } from "./config.js";
import {
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";
function buildResolvedConfig(): ResolvedBrowserConfig {
return {
enabled: true,
evaluateEnabled: false,
controlPort: 0,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
extraArgs: [],
color: DEFAULT_OPENCLAW_BROWSER_COLOR,
executablePath: undefined,
headless: true,
noSandbox: false,
attachOnly: true,
defaultProfile: DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
profiles: {
[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]: {
cdpPort: 1,
color: DEFAULT_OPENCLAW_BROWSER_COLOR,
},
},
} as unknown as ResolvedBrowserConfig;
}
describe("startBrowserBridgeServer auth", () => {
const servers: Array<{ stop: () => Promise<void> }> = [];
async function expectAuthFlow(
authConfig: { authToken?: string; authPassword?: string },
headers: Record<string, string>,
) {
const bridge = await startBrowserBridgeServer({
resolved: buildResolvedConfig(),
...authConfig,
});
servers.push({ stop: () => stopBrowserBridgeServer(bridge.server) });
const unauth = await fetch(`${bridge.baseUrl}/`);
expect(unauth.status).toBe(401);
const authed = await fetch(`${bridge.baseUrl}/`, { headers });
expect(authed.status).toBe(200);
}
afterEach(async () => {
while (servers.length) {
const s = servers.pop();
if (s) {
await s.stop();
}
}
});
it("rejects unauthenticated requests when authToken is set", async () => {
await expectAuthFlow({ authToken: "secret-token" }, { Authorization: "Bearer secret-token" });
});
it("accepts x-openclaw-password when authPassword is set", async () => {
await expectAuthFlow(
{ authPassword: "secret-password" },
{ "x-openclaw-password": "secret-password" },
);
});
it("requires auth params", async () => {
await expect(
startBrowserBridgeServer({
resolved: buildResolvedConfig(),
}),
).rejects.toThrow(/requires auth/i);
});
});

View File

@@ -0,0 +1,110 @@
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;
};
export async function startBrowserBridgeServer(params: {
resolved: ResolvedBrowserConfig;
host?: string;
port?: number;
authToken?: string;
authPassword?: string;
onEnsureAttachTarget?: (profile: ProfileContext["profile"]) => Promise<void>;
resolveSandboxNoVncToken?: (token: string) => string | 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) => {
const rawToken = typeof req.query?.token === "string" ? req.query.token.trim() : "";
if (!rawToken) {
res.status(400).send("Missing token");
return;
}
const redirectUrl = params.resolveSandboxNoVncToken?.(rawToken);
if (!redirectUrl) {
res.status(404).send("Invalid or expired token");
return;
}
res.setHeader("Cache-Control", "no-store");
res.redirect(302, redirectUrl);
});
}
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,247 @@
import { describe, expect, it, vi } from "vitest";
import { appendCdpPath, getHeadersWithAuth } from "./cdp.helpers.js";
import { __test } from "./client-fetch.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { shouldRejectBrowserMutation } from "./csrf.js";
import {
ensureChromeExtensionRelayServer,
stopChromeExtensionRelayServer,
} from "./extension-relay.js";
import { toBoolean } from "./routes/utils.js";
import type { BrowserServerState } from "./server-context.js";
import { listKnownProfileNames } from "./server-context.js";
import { resolveTargetIdFromTabs } from "./target-id.js";
import { getFreePort } from "./test-port.js";
describe("toBoolean", () => {
it("parses yes/no and 1/0", () => {
expect(toBoolean("yes")).toBe(true);
expect(toBoolean("1")).toBe(true);
expect(toBoolean("no")).toBe(false);
expect(toBoolean("0")).toBe(false);
});
it("returns undefined for on/off strings", () => {
expect(toBoolean("on")).toBeUndefined();
expect(toBoolean("off")).toBeUndefined();
});
it("passes through boolean values", () => {
expect(toBoolean(true)).toBe(true);
expect(toBoolean(false)).toBe(false);
});
});
describe("browser target id resolution", () => {
it("resolves exact ids", () => {
const res = resolveTargetIdFromTabs("FULL", [{ targetId: "AAA" }, { targetId: "FULL" }]);
expect(res).toEqual({ ok: true, targetId: "FULL" });
});
it("resolves unique prefixes (case-insensitive)", () => {
const res = resolveTargetIdFromTabs("57a01309", [
{ targetId: "57A01309E14B5DEE0FB41F908515A2FC" },
]);
expect(res).toEqual({
ok: true,
targetId: "57A01309E14B5DEE0FB41F908515A2FC",
});
});
it("fails on ambiguous prefixes", () => {
const res = resolveTargetIdFromTabs("57A0", [
{ targetId: "57A01309E14B5DEE0FB41F908515A2FC" },
{ targetId: "57A0BEEF000000000000000000000000" },
]);
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.reason).toBe("ambiguous");
expect(res.matches?.length).toBe(2);
}
});
it("fails when no tab matches", () => {
const res = resolveTargetIdFromTabs("NOPE", [{ targetId: "AAA" }]);
expect(res).toEqual({ ok: false, reason: "not_found" });
});
});
describe("browser CSRF loopback mutation guard", () => {
it("rejects mutating methods from non-loopback origin", () => {
expect(
shouldRejectBrowserMutation({
method: "POST",
origin: "https://evil.example",
}),
).toBe(true);
});
it("allows mutating methods from loopback origin", () => {
expect(
shouldRejectBrowserMutation({
method: "POST",
origin: "http://127.0.0.1:18789",
}),
).toBe(false);
expect(
shouldRejectBrowserMutation({
method: "POST",
origin: "http://localhost:18789",
}),
).toBe(false);
});
it("allows mutating methods without origin/referer (non-browser clients)", () => {
expect(
shouldRejectBrowserMutation({
method: "POST",
}),
).toBe(false);
});
it("rejects mutating methods with origin=null", () => {
expect(
shouldRejectBrowserMutation({
method: "POST",
origin: "null",
}),
).toBe(true);
});
it("rejects mutating methods from non-loopback referer", () => {
expect(
shouldRejectBrowserMutation({
method: "POST",
referer: "https://evil.example/attack",
}),
).toBe(true);
});
it("rejects cross-site mutations via Sec-Fetch-Site when present", () => {
expect(
shouldRejectBrowserMutation({
method: "POST",
secFetchSite: "cross-site",
}),
).toBe(true);
});
it("does not reject non-mutating methods", () => {
expect(
shouldRejectBrowserMutation({
method: "GET",
origin: "https://evil.example",
}),
).toBe(false);
expect(
shouldRejectBrowserMutation({
method: "OPTIONS",
origin: "https://evil.example",
}),
).toBe(false);
});
});
describe("cdp.helpers", () => {
it("preserves query params when appending CDP paths", () => {
const url = appendCdpPath("https://example.com?token=abc", "/json/version");
expect(url).toBe("https://example.com/json/version?token=abc");
});
it("appends paths under a base prefix", () => {
const url = appendCdpPath("https://example.com/chrome/?token=abc", "json/list");
expect(url).toBe("https://example.com/chrome/json/list?token=abc");
});
it("adds basic auth headers when credentials are present", () => {
const headers = getHeadersWithAuth("https://user:pass@example.com");
expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`);
});
it("keeps preexisting authorization headers", () => {
const headers = getHeadersWithAuth("https://user:pass@example.com", {
Authorization: "Bearer token",
});
expect(headers.Authorization).toBe("Bearer token");
});
it("does not add relay header for unknown loopback ports", () => {
const headers = getHeadersWithAuth("http://127.0.0.1:19444/json/version");
expect(headers["x-openclaw-relay-token"]).toBeUndefined();
});
it("adds relay header for known relay ports", async () => {
const port = await getFreePort();
const cdpUrl = `http://127.0.0.1:${port}`;
const prev = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token";
try {
await ensureChromeExtensionRelayServer({ cdpUrl });
const headers = getHeadersWithAuth(`${cdpUrl}/json/version`);
expect(headers["x-openclaw-relay-token"]).toBeTruthy();
expect(headers["x-openclaw-relay-token"]).not.toBe("test-gateway-token");
} finally {
await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {});
if (prev === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = prev;
}
}
});
});
describe("fetchBrowserJson loopback auth (bridge auth registry)", () => {
it("falls back to per-port bridge auth when config auth is not available", async () => {
const port = 18765;
const getBridgeAuthForPort = vi.fn((candidate: number) =>
candidate === port ? { token: "registry-token" } : undefined,
);
const init = __test.withLoopbackBrowserAuth(`http://127.0.0.1:${port}/`, undefined, {
loadConfig: () => ({}),
resolveBrowserControlAuth: () => ({}),
getBridgeAuthForPort,
});
const headers = new Headers(init.headers ?? {});
expect(headers.get("authorization")).toBe("Bearer registry-token");
expect(getBridgeAuthForPort).toHaveBeenCalledWith(port);
});
});
describe("browser server-context listKnownProfileNames", () => {
it("includes configured and runtime-only profile names", () => {
const resolved = resolveBrowserConfig({
defaultProfile: "openclaw",
profiles: {
openclaw: { cdpPort: 18800, color: "#FF4500" },
},
});
const openclaw = resolveProfile(resolved, "openclaw");
if (!openclaw) {
throw new Error("expected openclaw profile");
}
const state: BrowserServerState = {
server: null as unknown as BrowserServerState["server"],
port: 18791,
resolved,
profiles: new Map([
[
"stale-removed",
{
profile: { ...openclaw, name: "stale-removed" },
running: null,
},
],
]),
};
expect(listKnownProfileNames(state).toSorted()).toEqual([
"chrome",
"openclaw",
"stale-removed",
]);
});
});

View File

@@ -0,0 +1,180 @@
import WebSocket from "ws";
import { isLoopbackHost } from "../gateway/net.js";
import { rawDataToString } from "../infra/ws.js";
import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js";
export { isLoopbackHost };
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 relayHeaders = getChromeExtensionRelayAuthHeaders(url);
const mergedHeaders = { ...relayHeaders, ...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();
}
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 = 1500, init?: RequestInit): Promise<T> {
const res = await fetchChecked(url, timeoutMs, init);
return (await res.json()) as T;
}
async function fetchChecked(url: string, timeoutMs = 1500, 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 fetch(url, { ...init, headers, signal: ctrl.signal });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res;
} finally {
clearTimeout(t);
}
}
export async function fetchOk(url: string, timeoutMs = 1500, init?: RequestInit): Promise<void> {
await fetchChecked(url, timeoutMs, init);
}
export async function withCdpSocket<T>(
wsUrl: string,
fn: (send: CdpSendFn) => Promise<T>,
opts?: { headers?: Record<string, string>; handshakeTimeoutMs?: number },
): Promise<T> {
const headers = getHeadersWithAuth(wsUrl, opts?.headers ?? {});
const handshakeTimeoutMs =
typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs)
? Math.max(1, Math.floor(opts.handshakeTimeoutMs))
: 5000;
const ws = new WebSocket(wsUrl, {
handshakeTimeout: handshakeTimeoutMs,
...(Object.keys(headers).length ? { headers } : {}),
});
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,255 @@
import { createServer } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import { type WebSocket, WebSocketServer } from "ws";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { rawDataToString } from "../infra/ws.js";
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
describe("cdp", () => {
let httpServer: ReturnType<typeof createServer> | null = null;
let wsServer: WebSocketServer | null = null;
const startWsServer = async () => {
wsServer = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => wsServer?.once("listening", resolve));
return (wsServer.address() as { port: number }).port;
};
const startWsServerWithMessages = async (
onMessage: (
msg: { id?: number; method?: string; params?: Record<string, unknown> },
socket: WebSocket,
) => void,
) => {
const wsPort = await startWsServer();
if (!wsServer) {
throw new Error("ws server not initialized");
}
wsServer.on("connection", (socket) => {
socket.on("message", (data) => {
const msg = JSON.parse(rawDataToString(data)) as {
id?: number;
method?: string;
params?: Record<string, unknown>;
};
onMessage(msg, socket);
});
});
return wsPort;
};
const startVersionHttpServer = async (versionBody: Record<string, unknown>) => {
httpServer = createServer((req, res) => {
if (req.url === "/json/version") {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(versionBody));
return;
}
res.statusCode = 404;
res.end("not found");
});
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
return (httpServer.address() as { port: number }).port;
};
afterEach(async () => {
await new Promise<void>((resolve) => {
if (!httpServer) {
return resolve();
}
httpServer.close(() => resolve());
httpServer = null;
});
await new Promise<void>((resolve) => {
if (!wsServer) {
return resolve();
}
wsServer.close(() => resolve());
wsServer = null;
});
});
it("creates a target via the browser websocket", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method !== "Target.createTarget") {
return;
}
socket.send(
JSON.stringify({
id: msg.id,
result: { targetId: "TARGET_123" },
}),
);
});
const httpPort = await startVersionHttpServer({
webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`,
});
const created = await createTargetViaCdp({
cdpUrl: `http://127.0.0.1:${httpPort}`,
url: "https://example.com",
});
expect(created.targetId).toBe("TARGET_123");
});
it("blocks private navigation targets by default", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
await expect(
createTargetViaCdp({
cdpUrl: "http://127.0.0.1:9222",
url: "http://127.0.0.1:8080",
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it("blocks unsupported non-network navigation URLs", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
await expect(
createTargetViaCdp({
cdpUrl: "http://127.0.0.1:9222",
url: "file:///etc/passwd",
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it("allows private navigation targets when explicitly configured", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method !== "Target.createTarget") {
return;
}
expect(msg.params?.url).toBe("http://127.0.0.1:8080");
socket.send(
JSON.stringify({
id: msg.id,
result: { targetId: "TARGET_LOCAL" },
}),
);
});
const httpPort = await startVersionHttpServer({
webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`,
});
const created = await createTargetViaCdp({
cdpUrl: `http://127.0.0.1:${httpPort}`,
url: "http://127.0.0.1:8080",
ssrfPolicy: { allowPrivateNetwork: true },
});
expect(created.targetId).toBe("TARGET_LOCAL");
});
it("evaluates javascript via CDP", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
expect(msg.params?.expression).toBe("1+1");
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { type: "number", value: 2 } },
}),
);
}
});
const res = await evaluateJavaScript({
wsUrl: `ws://127.0.0.1:${wsPort}`,
expression: "1+1",
});
expect(res.result.type).toBe("number");
expect(res.result.value).toBe(2);
});
it("fails when /json/version omits webSocketDebuggerUrl", async () => {
const httpPort = await startVersionHttpServer({});
await expect(
createTargetViaCdp({
cdpUrl: `http://127.0.0.1:${httpPort}`,
url: "https://example.com",
}),
).rejects.toThrow("CDP /json/version missing webSocketDebuggerUrl");
});
it("captures an aria snapshot via CDP", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method === "Accessibility.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Accessibility.getFullAXTree") {
socket.send(
JSON.stringify({
id: msg.id,
result: {
nodes: [
{
nodeId: "1",
role: { value: "RootWebArea" },
name: { value: "" },
childIds: ["2"],
},
{
nodeId: "2",
role: { value: "button" },
name: { value: "OK" },
backendDOMNodeId: 42,
childIds: [],
},
],
},
}),
);
}
});
const snap = await snapshotAria({ wsUrl: `ws://127.0.0.1:${wsPort}` });
expect(snap.nodes.length).toBe(2);
expect(snap.nodes[0]?.role).toBe("RootWebArea");
expect(snap.nodes[1]?.role).toBe("button");
expect(snap.nodes[1]?.name).toBe("OK");
expect(snap.nodes[1]?.backendDOMNodeId).toBe(42);
expect(snap.nodes[1]?.depth).toBe(1);
});
it("normalizes loopback websocket URLs for remote CDP hosts", () => {
const normalized = normalizeCdpWsUrl(
"ws://127.0.0.1:9222/devtools/browser/ABC",
"http://example.com:9222",
);
expect(normalized).toBe("ws://example.com:9222/devtools/browser/ABC");
});
it("propagates auth and query params onto normalized websocket URLs", () => {
const normalized = normalizeCdpWsUrl(
"ws://127.0.0.1:9222/devtools/browser/ABC",
"https://user:pass@example.com?token=abc",
);
expect(normalized).toBe("wss://user:pass@example.com/devtools/browser/ABC?token=abc");
});
it("upgrades ws to wss when CDP uses https", () => {
const normalized = normalizeCdpWsUrl(
"ws://production-sfo.browserless.io",
"https://production-sfo.browserless.io?token=abc",
);
expect(normalized).toBe("wss://production-sfo.browserless.io/?token=abc");
});
});

462
openclaw/src/browser/cdp.ts Normal file
View File

@@ -0,0 +1,462 @@
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { appendCdpPath, fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
export { appendCdpPath, fetchJson, fetchOk, getHeadersWithAuth } from "./cdp.helpers.js";
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
const ws = new URL(wsUrl);
const cdp = new URL(cdpUrl);
if (isLoopbackHost(ws.hostname) && !isLoopbackHost(cdp.hostname)) {
ws.hostname = cdp.hostname;
const cdpPort = cdp.port || (cdp.protocol === "https:" ? "443" : "80");
if (cdpPort) {
ws.port = cdpPort;
}
ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:";
}
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),
});
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
appendCdpPath(opts.cdpUrl, "/json/version"),
1500,
);
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
const wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
if (!wsUrl) {
throw new Error("CDP /json/version missing webSocketDebuggerUrl");
}
return await withCdpSocket(wsUrl, async (send) => {
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,110 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
type BackgroundUtilsModule = {
buildRelayWsUrl: (port: number, gatewayToken: string) => Promise<string>;
deriveRelayToken: (gatewayToken: string, port: number) => Promise<string>;
isRetryableReconnectError: (err: unknown) => boolean;
reconnectDelayMs: (
attempt: number,
opts?: { baseMs?: number; maxMs?: number; jitterMs?: number; random?: () => number },
) => number;
};
const require = createRequire(import.meta.url);
const BACKGROUND_UTILS_MODULE = "../../assets/chrome-extension/background-utils.js";
async function loadBackgroundUtils(): Promise<BackgroundUtilsModule> {
try {
return require(BACKGROUND_UTILS_MODULE) as BackgroundUtilsModule;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("Unexpected token 'export'")) {
throw error;
}
return (await import(BACKGROUND_UTILS_MODULE)) as BackgroundUtilsModule;
}
}
const { buildRelayWsUrl, deriveRelayToken, isRetryableReconnectError, reconnectDelayMs } =
await loadBackgroundUtils();
describe("chrome extension background utils", () => {
it("derives relay token as HMAC-SHA256 of gateway token and port", async () => {
const relayToken = await deriveRelayToken("test-gateway-token", 18792);
expect(relayToken).toMatch(/^[0-9a-f]{64}$/);
const relayToken2 = await deriveRelayToken("test-gateway-token", 18792);
expect(relayToken).toBe(relayToken2);
const differentPort = await deriveRelayToken("test-gateway-token", 9999);
expect(relayToken).not.toBe(differentPort);
});
it("builds websocket url with derived relay token", async () => {
const url = await buildRelayWsUrl(18792, "test-token");
expect(url).toMatch(/^ws:\/\/127\.0\.0\.1:18792\/extension\?token=[0-9a-f]{64}$/);
});
it("throws when gateway token is missing", async () => {
await expect(buildRelayWsUrl(18792, "")).rejects.toThrow(/Missing gatewayToken/);
await expect(buildRelayWsUrl(18792, " ")).rejects.toThrow(/Missing gatewayToken/);
});
it("uses exponential backoff from attempt index", () => {
expect(reconnectDelayMs(0, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
1000,
);
expect(reconnectDelayMs(1, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
2000,
);
expect(reconnectDelayMs(4, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
16000,
);
});
it("caps reconnect delay at max", () => {
const delay = reconnectDelayMs(20, {
baseMs: 1000,
maxMs: 30000,
jitterMs: 0,
random: () => 0,
});
expect(delay).toBe(30000);
});
it("adds jitter using injected random source", () => {
const delay = reconnectDelayMs(3, {
baseMs: 1000,
maxMs: 30000,
jitterMs: 1000,
random: () => 0.25,
});
expect(delay).toBe(8250);
});
it("sanitizes invalid attempts and options", () => {
expect(reconnectDelayMs(-2, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
1000,
);
expect(
reconnectDelayMs(Number.NaN, {
baseMs: Number.NaN,
maxMs: Number.NaN,
jitterMs: Number.NaN,
random: () => 0,
}),
).toBe(1000);
});
it("marks missing token errors as non-retryable", () => {
expect(
isRetryableReconnectError(
new Error("Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)"),
),
).toBe(false);
});
it("keeps transient network errors retryable", () => {
expect(isRetryableReconnectError(new Error("WebSocket connect timeout"))).toBe(true);
expect(isRetryableReconnectError(new Error("Relay server not reachable"))).toBe(true);
});
});

View File

@@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
type ExtensionManifest = {
background?: { service_worker?: string; type?: string };
permissions?: string[];
};
function readManifest(): ExtensionManifest {
const path = resolve(process.cwd(), "assets/chrome-extension/manifest.json");
return JSON.parse(readFileSync(path, "utf8")) as ExtensionManifest;
}
describe("chrome extension manifest", () => {
it("keeps background worker configured as module", () => {
const manifest = readManifest();
expect(manifest.background?.service_worker).toBe("background.js");
expect(manifest.background?.type).toBe("module");
});
it("includes resilience permissions", () => {
const permissions = readManifest().permissions ?? [];
expect(permissions).toContain("alarms");
expect(permissions).toContain("webNavigation");
expect(permissions).toContain("storage");
expect(permissions).toContain("debugger");
});
});

View File

@@ -0,0 +1,113 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
type RelayCheckResponse = {
status?: number;
ok?: boolean;
error?: string;
contentType?: string;
json?: unknown;
};
type RelayCheckStatus =
| { action: "throw"; error: string }
| { action: "status"; kind: "ok" | "error"; message: string };
type RelayCheckExceptionStatus = { kind: "error"; message: string };
type OptionsValidationModule = {
classifyRelayCheckResponse: (
res: RelayCheckResponse | null | undefined,
port: number,
) => RelayCheckStatus;
classifyRelayCheckException: (err: unknown, port: number) => RelayCheckExceptionStatus;
};
const require = createRequire(import.meta.url);
const OPTIONS_VALIDATION_MODULE = "../../assets/chrome-extension/options-validation.js";
async function loadOptionsValidation(): Promise<OptionsValidationModule> {
try {
return require(OPTIONS_VALIDATION_MODULE) as OptionsValidationModule;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("Unexpected token 'export'")) {
throw error;
}
return (await import(OPTIONS_VALIDATION_MODULE)) as OptionsValidationModule;
}
}
const { classifyRelayCheckException, classifyRelayCheckResponse } = await loadOptionsValidation();
describe("chrome extension options validation", () => {
it("maps 401 response to token rejected error", () => {
const result = classifyRelayCheckResponse({ status: 401, ok: false }, 18792);
expect(result).toEqual({
action: "status",
kind: "error",
message: "Gateway token rejected. Check token and save again.",
});
});
it("maps non-json 200 response to wrong-port error", () => {
const result = classifyRelayCheckResponse(
{ status: 200, ok: true, contentType: "text/html; charset=utf-8", json: null },
18792,
);
expect(result).toEqual({
action: "status",
kind: "error",
message:
"Wrong port: this is likely the gateway, not the relay. Use gateway port + 3 (for gateway 18789, relay is 18792).",
});
});
it("maps json response without CDP keys to wrong-port error", () => {
const result = classifyRelayCheckResponse(
{ status: 200, ok: true, contentType: "application/json", json: { ok: true } },
18792,
);
expect(result).toEqual({
action: "status",
kind: "error",
message:
"Wrong port: expected relay /json/version response. Use gateway port + 3 (for gateway 18789, relay is 18792).",
});
});
it("maps valid relay json response to success", () => {
const result = classifyRelayCheckResponse(
{
status: 200,
ok: true,
contentType: "application/json",
json: { Browser: "Chrome/136", "Protocol-Version": "1.3" },
},
19004,
);
expect(result).toEqual({
action: "status",
kind: "ok",
message: "Relay reachable and authenticated at http://127.0.0.1:19004/",
});
});
it("maps syntax/json exceptions to wrong-endpoint error", () => {
const result = classifyRelayCheckException(new Error("SyntaxError: Unexpected token <"), 18792);
expect(result).toEqual({
kind: "error",
message:
"Wrong port: this is not a relay endpoint. Use gateway port + 3 (for gateway 18789, relay is 18792).",
});
});
it("maps generic exceptions to relay unreachable error", () => {
const result = classifyRelayCheckException(new Error("TypeError: Failed to fetch"), 18792);
expect(result).toEqual({
kind: "error",
message:
"Relay not reachable/authenticated at http://127.0.0.1:18792/. Start OpenClaw browser relay and verify token.",
});
});
});

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,77 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { resolveBrowserExecutableForPlatform } from "./chrome.executables.js";
vi.mock("node:child_process", () => ({
execFileSync: vi.fn(),
}));
vi.mock("node:fs", () => {
const existsSync = vi.fn();
const readFileSync = vi.fn();
return {
existsSync,
readFileSync,
default: { existsSync, readFileSync },
};
});
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
describe("browser default executable detection", () => {
const launchServicesPlist = "com.apple.launchservices.secure.plist";
const chromeExecutablePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
function mockMacDefaultBrowser(bundleId: string, appPath = ""): void {
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
const argsStr = Array.isArray(args) ? args.join(" ") : "";
if (cmd === "/usr/bin/plutil" && argsStr.includes("LSHandlers")) {
return JSON.stringify([{ LSHandlerURLScheme: "http", LSHandlerRoleAll: bundleId }]);
}
if (cmd === "/usr/bin/osascript" && argsStr.includes("path to application id")) {
return appPath;
}
if (cmd === "/usr/bin/defaults") {
return "Google Chrome";
}
return "";
});
}
function mockChromeExecutableExists(): void {
vi.mocked(fs.existsSync).mockImplementation((p) => {
const value = String(p);
if (value.includes(launchServicesPlist)) {
return true;
}
return value.includes(chromeExecutablePath);
});
}
beforeEach(() => {
vi.clearAllMocks();
});
it("prefers default Chromium browser on macOS", () => {
mockMacDefaultBrowser("com.google.Chrome", "/Applications/Google Chrome.app");
mockChromeExecutableExists();
const exe = resolveBrowserExecutableForPlatform(
{} as Parameters<typeof resolveBrowserExecutableForPlatform>[0],
"darwin",
);
expect(exe?.path).toContain("Google Chrome.app/Contents/MacOS/Google Chrome");
expect(exe?.kind).toBe("chrome");
});
it("falls back when default browser is non-Chromium on macOS", () => {
mockMacDefaultBrowser("com.apple.Safari");
mockChromeExecutableExists();
const exe = resolveBrowserExecutableForPlatform(
{} as Parameters<typeof resolveBrowserExecutableForPlatform>[0],
"darwin",
);
expect(exe?.path).toContain("Google Chrome.app/Contents/MacOS/Google Chrome");
});
});

View File

@@ -0,0 +1,625 @@
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 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",
"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;
}
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 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 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 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,290 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
decorateOpenClawProfile,
ensureProfileCleanExit,
findChromeExecutableMac,
findChromeExecutableWindows,
isChromeReachable,
resolveBrowserExecutableForPlatform,
stopOpenClawChrome,
} from "./chrome.js";
import {
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";
async function readJson(filePath: string): Promise<Record<string, unknown>> {
const raw = await fsp.readFile(filePath, "utf-8");
return JSON.parse(raw) as Record<string, unknown>;
}
async function readDefaultProfileFromLocalState(
userDataDir: string,
): Promise<Record<string, unknown>> {
const localState = await readJson(path.join(userDataDir, "Local State"));
const profile = localState.profile as Record<string, unknown>;
const infoCache = profile.info_cache as Record<string, unknown>;
return infoCache.Default as Record<string, unknown>;
}
describe("browser chrome profile decoration", () => {
let fixtureRoot = "";
let fixtureCount = 0;
const createUserDataDir = async () => {
const dir = path.join(fixtureRoot, `profile-${fixtureCount++}`);
await fsp.mkdir(dir, { recursive: true });
return dir;
};
beforeAll(async () => {
fixtureRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-suite-"));
});
afterAll(async () => {
if (fixtureRoot) {
await fsp.rm(fixtureRoot, { recursive: true, force: true });
}
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("writes expected name + signed ARGB seed to Chrome prefs", async () => {
const userDataDir = await createUserDataDir();
decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });
const expectedSignedArgb = ((0xff << 24) | 0xff4500) >> 0;
const def = await readDefaultProfileFromLocalState(userDataDir);
expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
expect(def.shortcut_name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
expect(def.profile_color_seed).toBe(expectedSignedArgb);
expect(def.profile_highlight_color).toBe(expectedSignedArgb);
expect(def.default_avatar_fill_color).toBe(expectedSignedArgb);
expect(def.default_avatar_stroke_color).toBe(expectedSignedArgb);
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
const browser = prefs.browser as Record<string, unknown>;
const theme = browser.theme as Record<string, unknown>;
const autogenerated = prefs.autogenerated as Record<string, unknown>;
const autogeneratedTheme = autogenerated.theme as Record<string, unknown>;
expect(theme.user_color2).toBe(expectedSignedArgb);
expect(autogeneratedTheme.color).toBe(expectedSignedArgb);
const marker = await fsp.readFile(
path.join(userDataDir, ".openclaw-profile-decorated"),
"utf-8",
);
expect(marker.trim()).toMatch(/^\d+$/);
});
it("best-effort writes name when color is invalid", async () => {
const userDataDir = await createUserDataDir();
decorateOpenClawProfile(userDataDir, { color: "lobster-orange" });
const def = await readDefaultProfileFromLocalState(userDataDir);
expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
expect(def.profile_color_seed).toBeUndefined();
});
it("recovers from missing/invalid preference files", async () => {
const userDataDir = await createUserDataDir();
await fsp.mkdir(path.join(userDataDir, "Default"), { recursive: true });
await fsp.writeFile(path.join(userDataDir, "Local State"), "{", "utf-8"); // invalid JSON
await fsp.writeFile(
path.join(userDataDir, "Default", "Preferences"),
"[]", // valid JSON but wrong shape
"utf-8",
);
decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });
const localState = await readJson(path.join(userDataDir, "Local State"));
expect(typeof localState.profile).toBe("object");
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
expect(typeof prefs.profile).toBe("object");
});
it("writes clean exit prefs to avoid restore prompts", async () => {
const userDataDir = await createUserDataDir();
ensureProfileCleanExit(userDataDir);
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
expect(prefs.exit_type).toBe("Normal");
expect(prefs.exited_cleanly).toBe(true);
});
it("is idempotent when rerun on an existing profile", async () => {
const userDataDir = await createUserDataDir();
decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });
decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
const profile = prefs.profile as Record<string, unknown>;
expect(profile.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
});
});
describe("browser chrome helpers", () => {
function mockExistsSync(match: (pathValue: string) => boolean) {
return vi.spyOn(fs, "existsSync").mockImplementation((p) => match(String(p)));
}
function makeProc(overrides?: Partial<{ killed: boolean; exitCode: number | null }>) {
return {
killed: overrides?.killed ?? false,
exitCode: overrides?.exitCode ?? null,
kill: vi.fn(),
};
}
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("picks the first existing Chrome candidate on macOS", () => {
const exists = mockExistsSync((pathValue) =>
pathValue.includes("Google Chrome.app/Contents/MacOS/Google Chrome"),
);
const exe = findChromeExecutableMac();
expect(exe?.kind).toBe("chrome");
expect(exe?.path).toMatch(/Google Chrome\.app/);
exists.mockRestore();
});
it("returns null when no Chrome candidate exists", () => {
const exists = vi.spyOn(fs, "existsSync").mockReturnValue(false);
expect(findChromeExecutableMac()).toBeNull();
exists.mockRestore();
});
it("picks the first existing Chrome candidate on Windows", () => {
vi.stubEnv("LOCALAPPDATA", "C:\\Users\\Test\\AppData\\Local");
const exists = mockExistsSync((pathStr) => {
return (
pathStr.includes("Google\\Chrome\\Application\\chrome.exe") ||
pathStr.includes("BraveSoftware\\Brave-Browser\\Application\\brave.exe") ||
pathStr.includes("Microsoft\\Edge\\Application\\msedge.exe")
);
});
const exe = findChromeExecutableWindows();
expect(exe?.kind).toBe("chrome");
expect(exe?.path).toMatch(/chrome\.exe$/);
exists.mockRestore();
});
it("finds Chrome in Program Files on Windows", () => {
const marker = path.win32.join("Program Files", "Google", "Chrome");
const exists = mockExistsSync((pathValue) => pathValue.includes(marker));
const exe = findChromeExecutableWindows();
expect(exe?.kind).toBe("chrome");
expect(exe?.path).toMatch(/chrome\.exe$/);
exists.mockRestore();
});
it("returns null when no Chrome candidate exists on Windows", () => {
const exists = vi.spyOn(fs, "existsSync").mockReturnValue(false);
expect(findChromeExecutableWindows()).toBeNull();
exists.mockRestore();
});
it("resolves Windows executables without LOCALAPPDATA", () => {
vi.stubEnv("LOCALAPPDATA", "");
vi.stubEnv("ProgramFiles", "C:\\Program Files");
vi.stubEnv("ProgramFiles(x86)", "C:\\Program Files (x86)");
const marker = path.win32.join(
"Program Files",
"Google",
"Chrome",
"Application",
"chrome.exe",
);
const exists = mockExistsSync((pathValue) => pathValue.includes(marker));
const exe = resolveBrowserExecutableForPlatform(
{} as Parameters<typeof resolveBrowserExecutableForPlatform>[0],
"win32",
);
expect(exe?.kind).toBe("chrome");
expect(exe?.path).toMatch(/chrome\.exe$/);
exists.mockRestore();
});
it("reports reachability based on /json/version", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
} as unknown as Response),
);
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(true);
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
json: async () => ({}),
} as unknown as Response),
);
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false);
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom")));
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false);
});
it("stopOpenClawChrome no-ops when process is already killed", async () => {
const proc = makeProc({ killed: true });
await stopOpenClawChrome(
{
proc,
cdpPort: 12345,
} as unknown as Parameters<typeof stopOpenClawChrome>[0],
10,
);
expect(proc.kill).not.toHaveBeenCalled();
});
it("stopOpenClawChrome sends SIGTERM and returns once CDP is down", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("down")));
const proc = makeProc();
await stopOpenClawChrome(
{
proc,
cdpPort: 12345,
} as unknown as Parameters<typeof stopOpenClawChrome>[0],
10,
);
expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
});
it("stopOpenClawChrome escalates to SIGKILL when CDP stays reachable", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
} as unknown as Response),
);
const proc = makeProc();
await stopOpenClawChrome(
{
proc,
cdpPort: 12345,
} as unknown as Parameters<typeof stopOpenClawChrome>[0],
1,
);
expect(proc.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
expect(proc.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
});
});

View File

@@ -0,0 +1,350 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import WebSocket from "ws";
import { ensurePortAvailable } from "../infra/ports.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { CONFIG_DIR } from "../utils.js";
import { appendCdpPath } from "./cdp.helpers.js";
import { getHeadersWithAuth, 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: ChildProcessWithoutNullStreams;
};
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 async function isChromeReachable(cdpUrl: string, timeoutMs = 500): Promise<boolean> {
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
return Boolean(version);
}
type ChromeVersion = {
webSocketDebuggerUrl?: string;
Browser?: string;
"User-Agent"?: string;
};
async function fetchChromeVersion(cdpUrl: string, timeoutMs = 500): Promise<ChromeVersion | null> {
const ctrl = new AbortController();
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
try {
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
const res = await fetch(versionUrl, {
signal: ctrl.signal,
headers: getHeadersWithAuth(versionUrl),
});
if (!res.ok) {
return null;
}
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 = 500,
): Promise<string | null> {
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
if (!wsUrl) {
return null;
}
return normalizeCdpWsUrl(wsUrl, cdpUrl);
}
async function canOpenWebSocket(wsUrl: string, timeoutMs = 800): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const headers = getHeadersWithAuth(wsUrl);
const ws = new WebSocket(wsUrl, {
handshakeTimeout: timeoutMs,
...(Object.keys(headers).length ? { headers } : {}),
});
const timer = setTimeout(
() => {
try {
ws.terminate();
} catch {
// ignore
}
resolve(false);
},
Math.max(50, timeoutMs + 25),
);
ws.once("open", () => {
clearTimeout(timer);
try {
ws.close();
} catch {
// ignore
}
resolve(true);
});
ws.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
}
export async function isChromeCdpReady(
cdpUrl: string,
timeoutMs = 500,
handshakeTimeoutMs = 800,
): Promise<boolean> {
const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs);
if (!wsUrl) {
return false;
}
return await canOpenWebSocket(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: 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) {
// Best-effort; older Chromes may ignore.
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");
}
// Stealth: hide navigator.webdriver from automation detection (#80)
args.push("--disable-blink-features=AutomationControlled");
// Append user-configured extra arguments (e.g., stealth flags, window size)
if (resolved.extraArgs.length > 0) {
args.push(...resolved.extraArgs);
}
// Always open a blank tab to ensure a target exists.
args.push("about:blank");
return spawn(exe.path, args, {
stdio: "pipe",
env: {
...process.env,
// Reduce accidental sharing with the user's env.
HOME: os.homedir(),
},
});
};
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() + 10_000;
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() + 5000;
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();
// Wait for CDP to come up.
const readyDeadline = Date.now() + 15_000;
while (Date.now() < readyDeadline) {
if (await isChromeReachable(profile.cdpUrl, 500)) {
break;
}
await new Promise((r) => setTimeout(r, 200));
}
if (!(await isChromeReachable(profile.cdpUrl, 500))) {
try {
proc.kill("SIGKILL");
} catch {
// ignore
}
throw new Error(
`Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".`,
);
}
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 = 2500) {
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), 200))) {
return;
}
await new Promise((r) => setTimeout(r, 100));
}
try {
proc.kill("SIGKILL");
} catch {
// ignore
}
}

View File

@@ -0,0 +1,259 @@
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;
targetId?: string;
doubleClick?: boolean;
button?: string;
modifiers?: string[];
timeoutMs?: number;
}
| {
kind: "type";
ref: string;
text: string;
targetId?: string;
submit?: boolean;
slowly?: boolean;
timeoutMs?: number;
}
| { kind: "press"; key: string; targetId?: string; delayMs?: number }
| { kind: "hover"; ref: string; targetId?: string; timeoutMs?: number }
| {
kind: "scrollIntoView";
ref: string;
targetId?: string;
timeoutMs?: number;
}
| {
kind: "drag";
startRef: string;
endRef: string;
targetId?: string;
timeoutMs?: number;
}
| {
kind: "select";
ref: 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 };
export type BrowserActResponse = {
ok: true;
targetId: string;
url?: string;
result?: unknown;
};
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,284 @@
import type { BrowserActionOk, BrowserActionTargetOk } from "./client-actions-types.js";
import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js";
import { fetchBrowserJson } from "./client-fetch.js";
export async function browserCookies(
baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise<{ ok: true; targetId: string; cookies: unknown[] }> {
const q = new URLSearchParams();
if (opts.targetId) {
q.set("targetId", opts.targetId);
}
if (opts.profile) {
q.set("profile", opts.profile);
}
const suffix = q.toString() ? `?${q.toString()}` : "";
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> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/set${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, cookie: opts.cookie }),
timeoutMs: 20000,
});
}
export async function browserCookiesClear(
baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/clear${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
timeoutMs: 20000,
});
}
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 q = new URLSearchParams();
if (opts.targetId) {
q.set("targetId", opts.targetId);
}
if (opts.key) {
q.set("key", opts.key);
}
if (opts.profile) {
q.set("profile", opts.profile);
}
const suffix = q.toString() ? `?${q.toString()}` : "";
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> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/storage/${opts.kind}/set${q}`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
key: opts.key,
value: opts.value,
}),
timeoutMs: 20000,
},
);
}
export async function browserStorageClear(
baseUrl: string | undefined,
opts: { kind: "local" | "session"; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/storage/${opts.kind}/clear${q}`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
timeoutMs: 20000,
},
);
}
export async function browserSetOffline(
baseUrl: string | undefined,
opts: { offline: boolean; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/offline${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, offline: opts.offline }),
timeoutMs: 20000,
});
}
export async function browserSetHeaders(
baseUrl: string | undefined,
opts: {
headers: Record<string, string>;
targetId?: string;
profile?: string;
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/headers${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, headers: opts.headers }),
timeoutMs: 20000,
});
}
export async function browserSetHttpCredentials(
baseUrl: string | undefined,
opts: {
username?: string;
password?: string;
clear?: boolean;
targetId?: string;
profile?: string;
} = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/set/credentials${q}`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
username: opts.username,
password: opts.password,
clear: opts.clear,
}),
timeoutMs: 20000,
},
);
}
export async function browserSetGeolocation(
baseUrl: string | undefined,
opts: {
latitude?: number;
longitude?: number;
accuracy?: number;
origin?: string;
clear?: boolean;
targetId?: string;
profile?: string;
} = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/set/geolocation${q}`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
latitude: opts.latitude,
longitude: opts.longitude,
accuracy: opts.accuracy,
origin: opts.origin,
clear: opts.clear,
}),
timeoutMs: 20000,
},
);
}
export async function browserSetMedia(
baseUrl: string | undefined,
opts: {
colorScheme: "dark" | "light" | "no-preference" | "none";
targetId?: string;
profile?: string;
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/media${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
colorScheme: opts.colorScheme,
}),
timeoutMs: 20000,
});
}
export async function browserSetTimezone(
baseUrl: string | undefined,
opts: { timezoneId: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/timezone${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
timezoneId: opts.timezoneId,
}),
timeoutMs: 20000,
});
}
export async function browserSetLocale(
baseUrl: string | undefined,
opts: { locale: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/locale${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, locale: opts.locale }),
timeoutMs: 20000,
});
}
export async function browserSetDevice(
baseUrl: string | undefined,
opts: { name: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/device${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, name: opts.name }),
timeoutMs: 20000,
});
}
export async function browserClearPermissions(
baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/set/geolocation${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, clear: true }),
timeoutMs: 20000,
});
}

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,117 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({
gateway: {
auth: {
token: "loopback-token",
},
},
})),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: mocks.loadConfig,
};
});
vi.mock("./control-service.js", () => ({
createBrowserControlContext: vi.fn(() => ({})),
startBrowserControlServiceFromConfig: vi.fn(async () => ({ ok: true })),
}));
vi.mock("./routes/dispatcher.js", () => ({
createBrowserRouteDispatcher: vi.fn(() => ({
dispatch: vi.fn(async () => ({ status: 200, body: { ok: true } })),
})),
}));
import { fetchBrowserJson } from "./client-fetch.js";
function stubJsonFetchOk() {
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("fetchBrowserJson loopback auth", () => {
beforeEach(() => {
vi.restoreAllMocks();
mocks.loadConfig.mockClear();
mocks.loadConfig.mockReturnValue({
gateway: {
auth: {
token: "loopback-token",
},
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("adds bearer auth for loopback absolute HTTP URLs", async () => {
const fetchMock = stubJsonFetchOk();
const res = await fetchBrowserJson<{ ok: boolean }>("http://127.0.0.1:18888/");
expect(res.ok).toBe(true);
const init = fetchMock.mock.calls[0]?.[1];
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer loopback-token");
});
it("does not inject auth for non-loopback absolute URLs", async () => {
const fetchMock = stubJsonFetchOk();
await fetchBrowserJson<{ ok: boolean }>("http://example.com/");
const init = fetchMock.mock.calls[0]?.[1];
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBeNull();
});
it("keeps caller-supplied auth header", async () => {
const fetchMock = stubJsonFetchOk();
await fetchBrowserJson<{ ok: boolean }>("http://localhost:18888/", {
headers: {
Authorization: "Bearer caller-token",
},
});
const init = fetchMock.mock.calls[0]?.[1];
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer caller-token");
});
it("injects auth for IPv6 loopback absolute URLs", async () => {
const fetchMock = stubJsonFetchOk();
await fetchBrowserJson<{ ok: boolean }>("http://[::1]:18888/");
const init = fetchMock.mock.calls[0]?.[1];
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer loopback-token");
});
it("injects auth for IPv4-mapped IPv6 loopback URLs", async () => {
const fetchMock = stubJsonFetchOk();
await fetchBrowserJson<{ ok: boolean }>("http://[::ffff:127.0.0.1]:18888/");
const init = fetchMock.mock.calls[0]?.[1];
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer loopback-token");
});
});

View File

@@ -0,0 +1,260 @@
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,
});
}
function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error {
const isLocal = !isAbsoluteHttp(url);
// Human-facing hint for logs/diagnostics.
const operatorHint = isLocal
? `Restart the OpenClaw gateway (OpenClaw.app menubar, or \`${formatCliCommand("openclaw gateway")}\`).`
: "If this is a sandboxed session, ensure the sandbox browser is running.";
// Model-facing suffix: explicitly tell the LLM NOT to retry.
// Without this, models see "try again" and enter an infinite tool-call loop.
const modelHint =
"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 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(
`Can't reach the OpenClaw browser control service (timed out after ${timeoutMs}ms). ${operatorHint} ${modelHint}`,
);
}
return new Error(
`Can't reach the OpenClaw browser control service. ${operatorHint} ${modelHint} (${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) {
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;
try {
if (isAbsoluteHttp(url)) {
const httpInit = withLoopbackBrowserAuth(url, init);
return await fetchHttpJson<T>(url, { ...httpInit, timeoutMs });
}
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) {
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;
}
throw enhanceBrowserFetchError(url, err, timeoutMs);
}
}
export const __test = {
withLoopbackBrowserAuth: withLoopbackBrowserAuthImpl,
};

View File

@@ -0,0 +1,273 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
browserAct,
browserArmDialog,
browserArmFileChooser,
browserConsoleMessages,
browserNavigate,
browserPdfSave,
browserScreenshotAction,
} from "./client-actions.js";
import { browserOpenTab, browserSnapshot, browserStatus, browserTabs } from "./client.js";
describe("browser client", () => {
function stubSnapshotFetch(calls: string[]) {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
calls.push(url);
return {
ok: true,
json: async () => ({
ok: true,
format: "ai",
targetId: "t1",
url: "https://x",
snapshot: "ok",
}),
} as unknown as Response;
}),
);
}
afterEach(() => {
vi.unstubAllGlobals();
});
it("wraps connection failures with a sandbox hint", async () => {
const refused = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1"), {
code: "ECONNREFUSED",
});
const fetchFailed = Object.assign(new TypeError("fetch failed"), {
cause: refused,
});
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(fetchFailed));
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/sandboxed session/i);
});
it("adds useful timeout messaging for abort-like failures", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("aborted")));
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/timed out/i);
});
it("surfaces non-2xx responses with body text", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 409,
text: async () => "conflict",
} as unknown as Response),
);
await expect(
browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }),
).rejects.toThrow(/conflict/i);
});
it("adds labels + efficient mode query params to snapshots", async () => {
const calls: string[] = [];
stubSnapshotFetch(calls);
await expect(
browserSnapshot("http://127.0.0.1:18791", {
format: "ai",
labels: true,
mode: "efficient",
}),
).resolves.toMatchObject({ ok: true, format: "ai" });
const snapshotCall = calls.find((url) => url.includes("/snapshot?"));
expect(snapshotCall).toBeTruthy();
const parsed = new URL(snapshotCall as string);
expect(parsed.searchParams.get("labels")).toBe("1");
expect(parsed.searchParams.get("mode")).toBe("efficient");
});
it("adds refs=aria to snapshots when requested", async () => {
const calls: string[] = [];
stubSnapshotFetch(calls);
await browserSnapshot("http://127.0.0.1:18791", {
format: "ai",
refs: "aria",
});
const snapshotCall = calls.find((url) => url.includes("/snapshot?"));
expect(snapshotCall).toBeTruthy();
const parsed = new URL(snapshotCall as string);
expect(parsed.searchParams.get("refs")).toBe("aria");
});
it("uses the expected endpoints + methods for common calls", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
calls.push({ url, init });
if (url.endsWith("/tabs") && (!init || init.method === undefined)) {
return {
ok: true,
json: async () => ({
running: true,
tabs: [{ targetId: "t1", title: "T", url: "https://x" }],
}),
} as unknown as Response;
}
if (url.endsWith("/tabs/open")) {
return {
ok: true,
json: async () => ({
targetId: "t2",
title: "N",
url: "https://y",
}),
} as unknown as Response;
}
if (url.endsWith("/navigate")) {
return {
ok: true,
json: async () => ({
ok: true,
targetId: "t1",
url: "https://y",
}),
} as unknown as Response;
}
if (url.endsWith("/act")) {
return {
ok: true,
json: async () => ({
ok: true,
targetId: "t1",
url: "https://x",
result: 1,
}),
} as unknown as Response;
}
if (url.endsWith("/hooks/file-chooser")) {
return {
ok: true,
json: async () => ({ ok: true }),
} as unknown as Response;
}
if (url.endsWith("/hooks/dialog")) {
return {
ok: true,
json: async () => ({ ok: true }),
} as unknown as Response;
}
if (url.includes("/console?")) {
return {
ok: true,
json: async () => ({
ok: true,
targetId: "t1",
messages: [],
}),
} as unknown as Response;
}
if (url.endsWith("/pdf")) {
return {
ok: true,
json: async () => ({
ok: true,
path: "/tmp/a.pdf",
targetId: "t1",
url: "https://x",
}),
} as unknown as Response;
}
if (url.endsWith("/screenshot")) {
return {
ok: true,
json: async () => ({
ok: true,
path: "/tmp/a.png",
targetId: "t1",
url: "https://x",
}),
} as unknown as Response;
}
if (url.includes("/snapshot?")) {
return {
ok: true,
json: async () => ({
ok: true,
format: "aria",
targetId: "t1",
url: "https://x",
nodes: [],
}),
} as unknown as Response;
}
return {
ok: true,
json: async () => ({
enabled: true,
running: true,
pid: 1,
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
chosenBrowser: "chrome",
userDataDir: "/tmp",
color: "#FF4500",
headless: false,
noSandbox: false,
executablePath: null,
attachOnly: false,
}),
} as unknown as Response;
}),
);
await expect(browserStatus("http://127.0.0.1:18791")).resolves.toMatchObject({
running: true,
cdpPort: 18792,
});
await expect(browserTabs("http://127.0.0.1:18791")).resolves.toHaveLength(1);
await expect(
browserOpenTab("http://127.0.0.1:18791", "https://example.com"),
).resolves.toMatchObject({ targetId: "t2" });
await expect(
browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }),
).resolves.toMatchObject({ ok: true, format: "aria" });
await expect(
browserNavigate("http://127.0.0.1:18791", { url: "https://example.com" }),
).resolves.toMatchObject({ ok: true, targetId: "t1" });
await expect(
browserAct("http://127.0.0.1:18791", { kind: "click", ref: "1" }),
).resolves.toMatchObject({ ok: true, targetId: "t1" });
await expect(
browserArmFileChooser("http://127.0.0.1:18791", {
paths: ["/tmp/a.txt"],
}),
).resolves.toMatchObject({ ok: true });
await expect(
browserArmDialog("http://127.0.0.1:18791", { accept: true }),
).resolves.toMatchObject({ ok: true });
await expect(
browserConsoleMessages("http://127.0.0.1:18791", { level: "error" }),
).resolves.toMatchObject({ ok: true, targetId: "t1" });
await expect(browserPdfSave("http://127.0.0.1:18791")).resolves.toMatchObject({
ok: true,
path: "/tmp/a.pdf",
});
await expect(
browserScreenshotAction("http://127.0.0.1:18791", { fullPage: true }),
).resolves.toMatchObject({ ok: true, path: "/tmp/a.png" });
expect(calls.some((c) => c.url.endsWith("/tabs"))).toBe(true);
const open = calls.find((c) => c.url.endsWith("/tabs/open"));
expect(open?.init?.method).toBe("POST");
const screenshot = calls.find((c) => c.url.endsWith("/screenshot"));
expect(screenshot?.init?.method).toBe("POST");
});
});

View File

@@ -0,0 +1,337 @@
import { fetchBrowserJson } from "./client-fetch.js";
export type BrowserStatus = {
enabled: boolean;
profile?: string;
running: boolean;
cdpReady?: boolean;
cdpHttp?: boolean;
pid: number | null;
cdpPort: number;
cdpUrl?: string;
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;
cdpPort: number;
cdpUrl: string;
color: string;
running: boolean;
tabCount: number;
isDefault: boolean;
isRemote: boolean;
};
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;
cdpPort: number;
cdpUrl: string;
color: string;
isRemote: boolean;
};
export async function browserCreateProfile(
baseUrl: string | undefined,
opts: {
name: string;
color?: string;
cdpUrl?: string;
driver?: "openclaw" | "extension";
},
): 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,
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();
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,201 @@
import { describe, expect, it } from "vitest";
import { withEnv } from "../test-utils/env.js";
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
describe("browser config", () => {
it("defaults to enabled with loopback defaults and lobster-orange color", () => {
const resolved = resolveBrowserConfig(undefined);
expect(resolved.enabled).toBe(true);
expect(resolved.controlPort).toBe(18791);
expect(resolved.color).toBe("#FF4500");
expect(shouldStartLocalBrowserServer(resolved)).toBe(true);
expect(resolved.cdpHost).toBe("127.0.0.1");
expect(resolved.cdpProtocol).toBe("http");
const profile = resolveProfile(resolved, resolved.defaultProfile);
expect(profile?.name).toBe("chrome");
expect(profile?.driver).toBe("extension");
expect(profile?.cdpPort).toBe(18792);
expect(profile?.cdpUrl).toBe("http://127.0.0.1:18792");
const openclaw = resolveProfile(resolved, "openclaw");
expect(openclaw?.driver).toBe("openclaw");
expect(openclaw?.cdpPort).toBe(18800);
expect(openclaw?.cdpUrl).toBe("http://127.0.0.1:18800");
expect(resolved.remoteCdpTimeoutMs).toBe(1500);
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000);
});
it("derives default ports from OPENCLAW_GATEWAY_PORT when unset", () => {
withEnv({ OPENCLAW_GATEWAY_PORT: "19001" }, () => {
const resolved = resolveBrowserConfig(undefined);
expect(resolved.controlPort).toBe(19003);
const chrome = resolveProfile(resolved, "chrome");
expect(chrome?.driver).toBe("extension");
expect(chrome?.cdpPort).toBe(19004);
expect(chrome?.cdpUrl).toBe("http://127.0.0.1:19004");
const openclaw = resolveProfile(resolved, "openclaw");
expect(openclaw?.cdpPort).toBe(19012);
expect(openclaw?.cdpUrl).toBe("http://127.0.0.1:19012");
});
});
it("derives default ports from gateway.port when env is unset", () => {
withEnv({ OPENCLAW_GATEWAY_PORT: undefined }, () => {
const resolved = resolveBrowserConfig(undefined, { gateway: { port: 19011 } });
expect(resolved.controlPort).toBe(19013);
const chrome = resolveProfile(resolved, "chrome");
expect(chrome?.driver).toBe("extension");
expect(chrome?.cdpPort).toBe(19014);
expect(chrome?.cdpUrl).toBe("http://127.0.0.1:19014");
const openclaw = resolveProfile(resolved, "openclaw");
expect(openclaw?.cdpPort).toBe(19022);
expect(openclaw?.cdpUrl).toBe("http://127.0.0.1:19022");
});
});
it("normalizes hex colors", () => {
const resolved = resolveBrowserConfig({
color: "ff4500",
});
expect(resolved.color).toBe("#FF4500");
});
it("supports custom remote CDP timeouts", () => {
const resolved = resolveBrowserConfig({
remoteCdpTimeoutMs: 2200,
remoteCdpHandshakeTimeoutMs: 5000,
});
expect(resolved.remoteCdpTimeoutMs).toBe(2200);
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(5000);
});
it("falls back to default color for invalid hex", () => {
const resolved = resolveBrowserConfig({
color: "#GGGGGG",
});
expect(resolved.color).toBe("#FF4500");
});
it("treats non-loopback cdpUrl as remote", () => {
const resolved = resolveBrowserConfig({
cdpUrl: "http://example.com:9222",
});
const profile = resolveProfile(resolved, "openclaw");
expect(profile?.cdpIsLoopback).toBe(false);
});
it("supports explicit CDP URLs for the default profile", () => {
const resolved = resolveBrowserConfig({
cdpUrl: "http://example.com:9222",
});
const profile = resolveProfile(resolved, "openclaw");
expect(profile?.cdpPort).toBe(9222);
expect(profile?.cdpUrl).toBe("http://example.com:9222");
expect(profile?.cdpIsLoopback).toBe(false);
});
it("uses profile cdpUrl when provided", () => {
const resolved = resolveBrowserConfig({
profiles: {
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
},
});
const remote = resolveProfile(resolved, "remote");
expect(remote?.cdpUrl).toBe("http://10.0.0.42:9222");
expect(remote?.cdpHost).toBe("10.0.0.42");
expect(remote?.cdpIsLoopback).toBe(false);
});
it("uses base protocol for profiles with only cdpPort", () => {
const resolved = resolveBrowserConfig({
cdpUrl: "https://example.com:9443",
profiles: {
work: { cdpPort: 18801, color: "#0066CC" },
},
});
const work = resolveProfile(resolved, "work");
expect(work?.cdpUrl).toBe("https://example.com:18801");
});
it("rejects unsupported protocols", () => {
expect(() => resolveBrowserConfig({ cdpUrl: "ws://127.0.0.1:18791" })).toThrow(/must be http/i);
});
it("does not add the built-in chrome extension profile if the derived relay port is already used", () => {
const resolved = resolveBrowserConfig({
profiles: {
openclaw: { cdpPort: 18792, color: "#FF4500" },
},
});
expect(resolveProfile(resolved, "chrome")).toBe(null);
expect(resolved.defaultProfile).toBe("openclaw");
});
it("defaults extraArgs to empty array when not provided", () => {
const resolved = resolveBrowserConfig(undefined);
expect(resolved.extraArgs).toEqual([]);
});
it("passes through valid extraArgs strings", () => {
const resolved = resolveBrowserConfig({
extraArgs: ["--no-sandbox", "--disable-gpu"],
});
expect(resolved.extraArgs).toEqual(["--no-sandbox", "--disable-gpu"]);
});
it("filters out empty strings and whitespace-only entries from extraArgs", () => {
const resolved = resolveBrowserConfig({
extraArgs: ["--flag", "", " ", "--other"],
});
expect(resolved.extraArgs).toEqual(["--flag", "--other"]);
});
it("filters out non-string entries from extraArgs", () => {
const resolved = resolveBrowserConfig({
extraArgs: ["--flag", 42, null, undefined, true, "--other"] as unknown as string[],
});
expect(resolved.extraArgs).toEqual(["--flag", "--other"]);
});
it("defaults extraArgs to empty array when set to non-array", () => {
const resolved = resolveBrowserConfig({
extraArgs: "not-an-array" as unknown as string[],
});
expect(resolved.extraArgs).toEqual([]);
});
it("resolves browser SSRF policy when configured", () => {
const resolved = resolveBrowserConfig({
ssrfPolicy: {
allowPrivateNetwork: true,
allowedHostnames: [" localhost ", ""],
hostnameAllowlist: [" *.trusted.example ", " "],
},
});
expect(resolved.ssrfPolicy).toEqual({
dangerouslyAllowPrivateNetwork: true,
allowedHostnames: ["localhost"],
hostnameAllowlist: ["*.trusted.example"],
});
});
it("defaults browser SSRF policy to trusted-network mode", () => {
const resolved = resolveBrowserConfig({});
expect(resolved.ssrfPolicy).toEqual({
dangerouslyAllowPrivateNetwork: true,
});
});
it("supports explicit strict mode by disabling private network access", () => {
const resolved = resolveBrowserConfig({
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: false,
},
});
expect(resolved.ssrfPolicy).toEqual({});
});
});

View File

@@ -0,0 +1,310 @@
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 {
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, getUsedPorts } from "./profiles.js";
export type ResolvedBrowserConfig = {
enabled: boolean;
evaluateEnabled: boolean;
controlPort: 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;
color: string;
driver: "openclaw" | "extension";
};
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 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);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`${label} must be http(s), got: ${parsed.protocol.replace(":", "")}`);
}
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) {
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,
): 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,
};
}
return result;
}
/**
* Ensure a built-in "chrome" profile exists for the Chrome extension relay.
*
* Note: this is an OpenClaw browser profile (routing config), not a Chrome user profile.
* It points at the local relay CDP endpoint (controlPort + 1).
*/
function ensureDefaultChromeExtensionProfile(
profiles: Record<string, BrowserProfileConfig>,
controlPort: number,
): Record<string, BrowserProfileConfig> {
const result = { ...profiles };
if (result.chrome) {
return result;
}
const relayPort = controlPort + 1;
if (!Number.isFinite(relayPort) || relayPort <= 0 || relayPort > 65535) {
return result;
}
// Avoid adding the built-in profile if the derived relay port is already used by another profile
// (legacy single-profile configs may use controlPort+1 for openclaw/openclaw CDP).
if (getUsedPorts(result).has(relayPort)) {
return result;
}
result.chrome = {
driver: "extension",
cdpUrl: `http://127.0.0.1:${relayPort}`,
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 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 profiles = ensureDefaultChromeExtensionProfile(
ensureDefaultProfile(cfg?.profiles, defaultColor, legacyCdpPort, derivedCdpRange.start),
controlPort,
);
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
const defaultProfile =
defaultProfileFromConfig ??
(profiles[DEFAULT_BROWSER_DEFAULT_PROFILE_NAME]
? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME
: DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
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,
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 === "extension" ? "extension" : "openclaw";
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,
};
}
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 = "chrome";
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,135 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { expectGeneratedTokenPersistedToGatewayAuth } from "../test-utils/auth-token-assertions.js";
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn<() => OpenClawConfig>(),
writeConfigFile: vi.fn(async (_cfg: OpenClawConfig) => {}),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: mocks.loadConfig,
writeConfigFile: mocks.writeConfigFile,
};
});
import { ensureBrowserControlAuth } from "./control-auth.js";
describe("ensureBrowserControlAuth", () => {
const expectExplicitModeSkipsAutoAuth = async (mode: "password" | "none") => {
const cfg: OpenClawConfig = {
gateway: {
auth: { mode },
},
browser: {
enabled: true,
},
};
const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(result).toEqual({ auth: {} });
expect(mocks.loadConfig).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
};
const expectGeneratedTokenPersisted = (result: {
generatedToken?: string;
auth: { token?: string };
}) => {
expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1);
expectGeneratedTokenPersistedToGatewayAuth({
generatedToken: result.generatedToken,
authToken: result.auth.token,
persistedConfig: mocks.writeConfigFile.mock.calls[0]?.[0],
});
};
beforeEach(() => {
vi.restoreAllMocks();
mocks.loadConfig.mockClear();
mocks.writeConfigFile.mockClear();
});
it("returns existing auth and skips writes", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
token: "already-set",
},
},
};
const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(result).toEqual({ auth: { token: "already-set" } });
expect(mocks.loadConfig).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("auto-generates and persists a token when auth is missing", async () => {
const cfg: OpenClawConfig = {
browser: {
enabled: true,
},
};
mocks.loadConfig.mockReturnValue({
browser: {
enabled: true,
},
});
const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expectGeneratedTokenPersisted(result);
});
it("skips auto-generation in test env", async () => {
const cfg: OpenClawConfig = {
browser: {
enabled: true,
},
};
const result = await ensureBrowserControlAuth({
cfg,
env: { NODE_ENV: "test" } as NodeJS.ProcessEnv,
});
expect(result).toEqual({ auth: {} });
expect(mocks.loadConfig).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("respects explicit password mode", async () => {
await expectExplicitModeSkipsAutoAuth("password");
});
it("respects explicit none mode", async () => {
await expectExplicitModeSkipsAutoAuth("none");
});
it("reuses auth from latest config snapshot", async () => {
const cfg: OpenClawConfig = {
browser: {
enabled: true,
},
};
mocks.loadConfig.mockReturnValue({
gateway: {
auth: {
token: "latest-token",
},
},
browser: {
enabled: true,
},
});
const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(result).toEqual({ auth: { token: "latest-token" } });
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.js";
import { ensureBrowserControlAuth } from "./control-auth.js";
describe("ensureBrowserControlAuth", () => {
async function expectNoAutoGeneratedAuth(cfg: OpenClawConfig): Promise<void> {
const result = await ensureBrowserControlAuth({
cfg,
env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" },
});
expect(result.generatedToken).toBeUndefined();
expect(result.auth.token).toBeUndefined();
expect(result.auth.password).toBeUndefined();
}
describe("trusted-proxy mode", () => {
it("should not auto-generate token when auth mode is trusted-proxy", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
},
},
trustedProxies: ["192.168.1.1"],
},
};
await expectNoAutoGeneratedAuth(cfg);
});
});
describe("password mode", () => {
it("should not auto-generate token when auth mode is password (even if password not set)", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "password",
},
},
};
await expectNoAutoGeneratedAuth(cfg);
});
});
describe("none mode", () => {
it("should not auto-generate token when auth mode is none", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "none",
},
},
};
await expectNoAutoGeneratedAuth(cfg);
});
});
describe("token mode", () => {
it("should return existing token if configured", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
token: "existing-token-123",
},
},
};
const result = await ensureBrowserControlAuth({
cfg,
env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" },
});
expect(result.generatedToken).toBeUndefined();
expect(result.auth.token).toBe("existing-token-123");
});
it("should skip auto-generation in test environment", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
},
},
};
const result = await ensureBrowserControlAuth({
cfg,
env: { NODE_ENV: "test" },
});
expect(result.generatedToken).toBeUndefined();
expect(result.auth.token).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,95 @@
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 = resolveBrowserControlAuth(ensured.cfg, env);
return {
auth: ensuredAuth,
generatedToken: ensured.generatedToken,
};
}

View File

@@ -0,0 +1,80 @@
import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveBrowserConfig } from "./config.js";
import { ensureBrowserControlAuth } from "./control-auth.js";
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
let state: BrowserServerState | null = null;
const log = createSubsystemLogger("browser");
const logService = log.child("service");
export function getBrowserControlState(): BrowserServerState | null {
return state;
}
export function createBrowserControlContext() {
return createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: true,
});
}
export async function startBrowserControlServiceFromConfig(): Promise<BrowserServerState | null> {
if (state) {
return state;
}
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
if (!resolved.enabled) {
return null;
}
try {
const ensured = await ensureBrowserControlAuth({ cfg });
if (ensured.generatedToken) {
logService.info("No browser auth configured; generated gateway.auth.token automatically.");
}
} catch (err) {
logService.warn(`failed to auto-configure browser auth: ${String(err)}`);
}
state = {
server: null,
port: resolved.controlPort,
resolved,
profiles: new Map(),
};
await ensureExtensionRelayForProfiles({
resolved,
onWarn: (message) => logService.warn(message),
});
logService.info(
`Browser control service ready (profiles=${Object.keys(resolved.profiles).length})`,
);
return state;
}
export async function stopBrowserControlService(): Promise<void> {
const current = state;
if (!current) {
return;
}
await stopKnownBrowserProfiles({
getState: () => state,
onWarn: (message) => logService.warn(message),
});
state = null;
// Optional: Playwright is not always available (e.g. embedded gateway builds).
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection();
} catch {
// ignore
}
}

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,132 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
probeAuthenticatedOpenClawRelay,
resolveRelayAcceptedTokensForPort,
resolveRelayAuthTokenForPort,
} from "./extension-relay-auth.js";
import { getFreePort } from "./test-port.js";
async function withRelayServer(
handler: (req: IncomingMessage, res: ServerResponse) => void,
run: (params: { port: number }) => Promise<void>,
) {
const port = await getFreePort();
const server = createServer(handler);
await new Promise<void>((resolve, reject) => {
server.listen(port, "127.0.0.1", () => resolve());
server.once("error", reject);
});
try {
const actualPort = (server.address() as AddressInfo).port;
await run({ port: actualPort });
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
describe("extension-relay-auth", () => {
const TEST_GATEWAY_TOKEN = "test-gateway-token";
let prevGatewayToken: string | undefined;
beforeEach(() => {
prevGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = TEST_GATEWAY_TOKEN;
});
afterEach(() => {
if (prevGatewayToken === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = prevGatewayToken;
}
});
it("derives deterministic relay tokens per port", () => {
const tokenA1 = resolveRelayAuthTokenForPort(18790);
const tokenA2 = resolveRelayAuthTokenForPort(18790);
const tokenB = resolveRelayAuthTokenForPort(18791);
expect(tokenA1).toBe(tokenA2);
expect(tokenA1).not.toBe(tokenB);
expect(tokenA1).not.toBe(TEST_GATEWAY_TOKEN);
});
it("accepts both relay-scoped and raw gateway tokens for compatibility", () => {
const tokens = resolveRelayAcceptedTokensForPort(18790);
expect(tokens).toContain(TEST_GATEWAY_TOKEN);
expect(tokens[0]).not.toBe(TEST_GATEWAY_TOKEN);
expect(tokens[0]).toBe(resolveRelayAuthTokenForPort(18790));
});
it("accepts authenticated openclaw relay probe responses", async () => {
let seenToken: string | undefined;
await withRelayServer(
(req, res) => {
if (!req.url?.startsWith("/json/version")) {
res.writeHead(404);
res.end("not found");
return;
}
const header = req.headers["x-openclaw-relay-token"];
seenToken = Array.isArray(header) ? header[0] : header;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ Browser: "OpenClaw/extension-relay" }));
},
async ({ port }) => {
const token = resolveRelayAuthTokenForPort(port);
const ok = await probeAuthenticatedOpenClawRelay({
baseUrl: `http://127.0.0.1:${port}`,
relayAuthHeader: "x-openclaw-relay-token",
relayAuthToken: token,
});
expect(ok).toBe(true);
expect(seenToken).toBe(token);
},
);
});
it("rejects unauthenticated probe responses", async () => {
await withRelayServer(
(req, res) => {
if (!req.url?.startsWith("/json/version")) {
res.writeHead(404);
res.end("not found");
return;
}
res.writeHead(401);
res.end("Unauthorized");
},
async ({ port }) => {
const ok = await probeAuthenticatedOpenClawRelay({
baseUrl: `http://127.0.0.1:${port}`,
relayAuthHeader: "x-openclaw-relay-token",
relayAuthToken: "irrelevant",
});
expect(ok).toBe(false);
},
);
});
it("rejects probe responses with wrong browser identity", async () => {
await withRelayServer(
(req, res) => {
if (!req.url?.startsWith("/json/version")) {
res.writeHead(404);
res.end("not found");
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ Browser: "FakeRelay" }));
},
async ({ port }) => {
const ok = await probeAuthenticatedOpenClawRelay({
baseUrl: `http://127.0.0.1:${port}`,
relayAuthHeader: "x-openclaw-relay-token",
relayAuthToken: "irrelevant",
});
expect(ok).toBe(false);
},
);
});
});

View File

@@ -0,0 +1,73 @@
import { createHmac } from "node:crypto";
import { loadConfig } from "../config/config.js";
const RELAY_TOKEN_CONTEXT = "openclaw-extension-relay-v1";
const DEFAULT_RELAY_PROBE_TIMEOUT_MS = 500;
const OPENCLAW_RELAY_BROWSER = "OpenClaw/extension-relay";
function resolveGatewayAuthToken(): string | null {
const envToken =
process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || process.env.CLAWDBOT_GATEWAY_TOKEN?.trim();
if (envToken) {
return envToken;
}
try {
const cfg = loadConfig();
const configToken = cfg.gateway?.auth?.token?.trim();
if (configToken) {
return configToken;
}
} catch {
// ignore config read failures; caller can fallback to per-process random token
}
return null;
}
function deriveRelayAuthToken(gatewayToken: string, port: number): string {
return createHmac("sha256", gatewayToken).update(`${RELAY_TOKEN_CONTEXT}:${port}`).digest("hex");
}
export function resolveRelayAcceptedTokensForPort(port: number): string[] {
const gatewayToken = resolveGatewayAuthToken();
if (!gatewayToken) {
throw new Error(
"extension relay requires gateway auth token (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)",
);
}
const relayToken = deriveRelayAuthToken(gatewayToken, port);
if (relayToken === gatewayToken) {
return [relayToken];
}
return [relayToken, gatewayToken];
}
export function resolveRelayAuthTokenForPort(port: number): string {
return resolveRelayAcceptedTokensForPort(port)[0];
}
export async function probeAuthenticatedOpenClawRelay(params: {
baseUrl: string;
relayAuthHeader: string;
relayAuthToken: string;
timeoutMs?: number;
}): Promise<boolean> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), params.timeoutMs ?? DEFAULT_RELAY_PROBE_TIMEOUT_MS);
try {
const versionUrl = new URL("/json/version", `${params.baseUrl}/`).toString();
const res = await fetch(versionUrl, {
signal: ctrl.signal,
headers: { [params.relayAuthHeader]: params.relayAuthToken },
});
if (!res.ok) {
return false;
}
const body = (await res.json()) as { Browser?: unknown };
const browserName = typeof body?.Browser === "string" ? body.Browser.trim() : "";
return browserName === OPENCLAW_RELAY_BROWSER;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}

View File

@@ -0,0 +1,742 @@
import { createServer } from "node:http";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import WebSocket from "ws";
import { captureEnv } from "../test-utils/env.js";
import {
ensureChromeExtensionRelayServer,
getChromeExtensionRelayAuthHeaders,
stopChromeExtensionRelayServer,
} from "./extension-relay.js";
import { getFreePort } from "./test-port.js";
const RELAY_MESSAGE_TIMEOUT_MS = 2_000;
const RELAY_LIST_MATCH_TIMEOUT_MS = 1_500;
const RELAY_TEST_TIMEOUT_MS = 10_000;
function waitForOpen(ws: WebSocket) {
return new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", reject);
});
}
function waitForError(ws: WebSocket) {
return new Promise<Error>((resolve, reject) => {
ws.once("error", (err) => resolve(err instanceof Error ? err : new Error(String(err))));
ws.once("open", () => reject(new Error("expected websocket error")));
});
}
function waitForClose(ws: WebSocket, timeoutMs = RELAY_MESSAGE_TIMEOUT_MS) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error("timeout"));
}, timeoutMs);
ws.once("close", () => {
clearTimeout(timer);
resolve();
});
ws.once("error", (err) => {
clearTimeout(timer);
reject(err instanceof Error ? err : new Error(String(err)));
});
});
}
function relayAuthHeaders(url: string) {
return getChromeExtensionRelayAuthHeaders(url);
}
function createMessageQueue(ws: WebSocket) {
const queue: string[] = [];
let waiter: ((value: string) => void) | null = null;
let waiterReject: ((err: Error) => void) | null = null;
let waiterTimer: NodeJS.Timeout | null = null;
const flushWaiter = (value: string) => {
if (!waiter) {
return false;
}
const resolve = waiter;
waiter = null;
const reject = waiterReject;
waiterReject = null;
if (waiterTimer) {
clearTimeout(waiterTimer);
}
waiterTimer = null;
if (reject) {
// no-op (kept for symmetry)
}
resolve(value);
return true;
};
ws.on("message", (data) => {
const text =
typeof data === "string"
? data
: Buffer.isBuffer(data)
? data.toString("utf8")
: Array.isArray(data)
? Buffer.concat(data).toString("utf8")
: Buffer.from(data).toString("utf8");
if (flushWaiter(text)) {
return;
}
queue.push(text);
});
ws.on("error", (err) => {
if (!waiterReject) {
return;
}
const reject = waiterReject;
waiterReject = null;
waiter = null;
if (waiterTimer) {
clearTimeout(waiterTimer);
}
waiterTimer = null;
reject(err instanceof Error ? err : new Error(String(err)));
});
const next = (timeoutMs = RELAY_MESSAGE_TIMEOUT_MS) =>
new Promise<string>((resolve, reject) => {
const existing = queue.shift();
if (existing !== undefined) {
return resolve(existing);
}
waiter = resolve;
waiterReject = reject;
waiterTimer = setTimeout(() => {
waiter = null;
waiterReject = null;
waiterTimer = null;
reject(new Error("timeout"));
}, timeoutMs);
});
return { next };
}
async function waitForListMatch<T>(
fetchList: () => Promise<T>,
predicate: (value: T) => boolean,
timeoutMs = RELAY_LIST_MATCH_TIMEOUT_MS,
intervalMs = 50,
): Promise<T> {
let latest: T | undefined;
await expect
.poll(
async () => {
latest = await fetchList();
return predicate(latest);
},
{ timeout: timeoutMs, interval: intervalMs },
)
.toBe(true);
if (latest === undefined) {
throw new Error("expected list value");
}
return latest;
}
describe("chrome extension relay server", () => {
const TEST_GATEWAY_TOKEN = "test-gateway-token";
let cdpUrl = "";
let envSnapshot: ReturnType<typeof captureEnv>;
beforeEach(() => {
envSnapshot = captureEnv([
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS",
"OPENCLAW_EXTENSION_RELAY_COMMAND_RECONNECT_WAIT_MS",
]);
process.env.OPENCLAW_GATEWAY_TOKEN = TEST_GATEWAY_TOKEN;
delete process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS;
delete process.env.OPENCLAW_EXTENSION_RELAY_COMMAND_RECONNECT_WAIT_MS;
});
afterEach(async () => {
if (cdpUrl) {
await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {});
cdpUrl = "";
}
envSnapshot.restore();
});
async function startRelayWithExtension() {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext);
return { port, ext };
}
it("advertises CDP WS only when extension is connected", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const v1 = (await fetch(`${cdpUrl}/json/version`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as {
webSocketDebuggerUrl?: string;
};
expect(v1.webSocketDebuggerUrl).toBeUndefined();
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext);
const v2 = (await fetch(`${cdpUrl}/json/version`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as {
webSocketDebuggerUrl?: string;
};
expect(String(v2.webSocketDebuggerUrl ?? "")).toContain(`/cdp`);
ext.close();
});
it("uses relay-scoped token only for known relay ports", async () => {
const port = await getFreePort();
const unknown = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`);
expect(unknown).toEqual({});
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const headers = getChromeExtensionRelayAuthHeaders(cdpUrl);
expect(Object.keys(headers)).toContain("x-openclaw-relay-token");
expect(headers["x-openclaw-relay-token"]).not.toBe(TEST_GATEWAY_TOKEN);
});
it("rejects CDP access without relay auth token", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const res = await fetch(`${cdpUrl}/json/version`);
expect(res.status).toBe(401);
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`);
const err = await waitForError(cdp);
expect(err.message).toContain("401");
});
it("returns 400 for malformed percent-encoding in target action routes", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const res = await fetch(`${cdpUrl}/json/activate/%E0%A4%A`, {
headers: relayAuthHeaders(cdpUrl),
});
expect(res.status).toBe(400);
expect(await res.text()).toContain("invalid targetId encoding");
});
it("deduplicates concurrent relay starts for the same requested port", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
const [first, second] = await Promise.all([
ensureChromeExtensionRelayServer({ cdpUrl }),
ensureChromeExtensionRelayServer({ cdpUrl }),
]);
expect(first).toBe(second);
expect(first.port).toBe(port);
});
it("allows CORS preflight from chrome-extension origins", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const origin = "chrome-extension://abcdefghijklmnop";
const res = await fetch(`${cdpUrl}/json/version`, {
method: "OPTIONS",
headers: {
Origin: origin,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "x-openclaw-relay-token",
},
});
expect(res.status).toBe(204);
expect(res.headers.get("access-control-allow-origin")).toBe(origin);
expect(res.headers.get("access-control-allow-headers") ?? "").toContain(
"x-openclaw-relay-token",
);
});
it("rejects CORS preflight from non-extension origins", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const res = await fetch(`${cdpUrl}/json/version`, {
method: "OPTIONS",
headers: {
Origin: "https://example.com",
"Access-Control-Request-Method": "GET",
},
});
expect(res.status).toBe(403);
});
it("returns CORS headers on JSON responses for extension origins", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const origin = "chrome-extension://abcdefghijklmnop";
const res = await fetch(`${cdpUrl}/json/version`, {
headers: {
Origin: origin,
...relayAuthHeaders(cdpUrl),
},
});
expect(res.status).toBe(200);
expect(res.headers.get("access-control-allow-origin")).toBe(origin);
});
it("rejects extension websocket access without relay auth token", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`);
const err = await waitForError(ext);
expect(err.message).toContain("401");
});
it("rejects a second live extension connection with 409", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const ext1 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext1);
const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
const err = await waitForError(ext2);
expect(err.message).toContain("409");
ext1.close();
});
it("allows immediate reconnect when prior extension socket is closing", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const ext1 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext1);
const ext1Closed = new Promise<void>((resolve) => ext1.once("close", () => resolve()));
ext1.close();
const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext2);
await ext1Closed;
const status = (await fetch(`${cdpUrl}/extension/status`).then((r) => r.json())) as {
connected?: boolean;
};
expect(status.connected).toBe(true);
ext2.close();
});
it("keeps CDP clients alive across a brief extension reconnect", async () => {
const { port, ext: ext1 } = await startRelayWithExtension();
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
});
await waitForOpen(cdp);
let cdpClosed = false;
cdp.once("close", () => {
cdpClosed = true;
});
const ext1Closed = waitForClose(ext1, 2_000);
ext1.close();
await ext1Closed;
await new Promise((r) => setTimeout(r, 200));
const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext2);
await new Promise((r) => setTimeout(r, 200));
expect(cdpClosed).toBe(false);
cdp.close();
ext2.close();
});
it("waits briefly for extension reconnect before failing CDP commands", async () => {
const { port, ext: ext1 } = await startRelayWithExtension();
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
});
await waitForOpen(cdp);
const cdpQueue = createMessageQueue(cdp);
const ext1Closed = waitForClose(ext1, 2_000);
ext1.close();
await ext1Closed;
cdp.send(JSON.stringify({ id: 41, method: "Runtime.enable" }));
await new Promise((r) => setTimeout(r, 150));
const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
const ext2Queue = createMessageQueue(ext2);
await waitForOpen(ext2);
while (true) {
const msg = JSON.parse(await ext2Queue.next(4_000)) as {
id?: number;
method?: string;
};
if (msg.method === "ping") {
ext2.send(JSON.stringify({ method: "pong" }));
continue;
}
if (msg.method === "forwardCDPCommand" && typeof msg.id === "number") {
ext2.send(JSON.stringify({ id: msg.id, result: { ok: true } }));
break;
}
}
const response = JSON.parse(await cdpQueue.next(6_000)) as {
id?: number;
result?: { ok?: boolean };
error?: { message?: string };
};
expect(response.id).toBe(41);
expect(response.error).toBeUndefined();
expect(response.result?.ok).toBe(true);
cdp.close();
ext2.close();
});
it("closes CDP clients after reconnect grace when extension stays disconnected", async () => {
process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "150";
const { port, ext } = await startRelayWithExtension();
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
});
await waitForOpen(cdp);
ext.close();
await waitForClose(cdp, 2_000);
});
it("accepts extension websocket access with relay token query param", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const token = relayAuthHeaders(`ws://127.0.0.1:${port}/extension`)["x-openclaw-relay-token"];
expect(token).toBeTruthy();
const ext = new WebSocket(
`ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(String(token))}`,
);
await waitForOpen(ext);
ext.close();
});
it("accepts /json endpoints with relay token query param", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const token = relayAuthHeaders(cdpUrl)["x-openclaw-relay-token"];
expect(token).toBeTruthy();
const versionRes = await fetch(
`${cdpUrl}/json/version?token=${encodeURIComponent(String(token))}`,
);
expect(versionRes.status).toBe(200);
});
it("accepts raw gateway token for relay auth compatibility", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const versionRes = await fetch(`${cdpUrl}/json/version`, {
headers: { "x-openclaw-relay-token": TEST_GATEWAY_TOKEN },
});
expect(versionRes.status).toBe(200);
const ext = new WebSocket(
`ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`,
);
await waitForOpen(ext);
ext.close();
});
it(
"tracks attached page targets and exposes them via CDP + /json/list",
async () => {
const { port, ext } = await startRelayWithExtension();
// Simulate a tab attach coming from the extension.
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "cb-tab-1",
targetInfo: {
targetId: "t1",
type: "page",
title: "Example",
url: "https://example.com",
},
waitingForDebugger: false,
},
},
}),
);
const list = (await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{
id?: string;
url?: string;
title?: string;
}>;
expect(list.some((t) => t.id === "t1" && t.url === "https://example.com")).toBe(true);
// Simulate navigation updating tab metadata.
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.targetInfoChanged",
params: {
targetInfo: {
targetId: "t1",
type: "page",
title: "DER STANDARD",
url: "https://www.derstandard.at/",
},
},
},
}),
);
const list2 = await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{
id?: string;
url?: string;
title?: string;
}>,
(list) =>
list.some(
(t) =>
t.id === "t1" &&
t.url === "https://www.derstandard.at/" &&
t.title === "DER STANDARD",
),
);
expect(
list2.some(
(t) =>
t.id === "t1" && t.url === "https://www.derstandard.at/" && t.title === "DER STANDARD",
),
).toBe(true);
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
});
await waitForOpen(cdp);
const q = createMessageQueue(cdp);
cdp.send(JSON.stringify({ id: 1, method: "Target.getTargets" }));
const res1 = JSON.parse(await q.next()) as { id: number; result?: unknown };
expect(res1.id).toBe(1);
expect(JSON.stringify(res1.result ?? {})).toContain("t1");
cdp.send(
JSON.stringify({
id: 2,
method: "Target.attachToTarget",
params: { targetId: "t1" },
}),
);
const received: Array<{
id?: number;
method?: string;
result?: unknown;
params?: unknown;
}> = [];
received.push(JSON.parse(await q.next()) as never);
received.push(JSON.parse(await q.next()) as never);
const res2 = received.find((m) => m.id === 2);
expect(res2?.id).toBe(2);
expect(JSON.stringify(res2?.result ?? {})).toContain("cb-tab-1");
const evt = received.find((m) => m.method === "Target.attachedToTarget");
expect(evt?.method).toBe("Target.attachedToTarget");
expect(JSON.stringify(evt?.params ?? {})).toContain("t1");
cdp.close();
ext.close();
},
RELAY_TEST_TIMEOUT_MS,
);
it("rebroadcasts attach when a session id is reused for a new target", async () => {
const { port, ext } = await startRelayWithExtension();
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
});
await waitForOpen(cdp);
const q = createMessageQueue(cdp);
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "shared-session",
targetInfo: {
targetId: "t1",
type: "page",
title: "First",
url: "https://example.com",
},
waitingForDebugger: false,
},
},
}),
);
const first = JSON.parse(await q.next()) as { method?: string; params?: unknown };
expect(first.method).toBe("Target.attachedToTarget");
expect(JSON.stringify(first.params ?? {})).toContain("t1");
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "shared-session",
targetInfo: {
targetId: "t2",
type: "page",
title: "Second",
url: "https://example.org",
},
waitingForDebugger: false,
},
},
}),
);
const received: Array<{ method?: string; params?: unknown }> = [];
received.push(JSON.parse(await q.next()) as never);
received.push(JSON.parse(await q.next()) as never);
const detached = received.find((m) => m.method === "Target.detachedFromTarget");
const attached = received.find((m) => m.method === "Target.attachedToTarget");
expect(JSON.stringify(detached?.params ?? {})).toContain("t1");
expect(JSON.stringify(attached?.params ?? {})).toContain("t2");
cdp.close();
ext.close();
});
it("reuses an already-bound relay port when another process owns it", async () => {
const port = await getFreePort();
let probeToken: string | undefined;
const fakeRelay = createServer((req, res) => {
if (req.url?.startsWith("/json/version")) {
const header = req.headers["x-openclaw-relay-token"];
probeToken = Array.isArray(header) ? header[0] : header;
if (!probeToken) {
res.writeHead(401);
res.end("Unauthorized");
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ Browser: "OpenClaw/extension-relay" }));
return;
}
if (req.url?.startsWith("/extension/status")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ connected: false }));
return;
}
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end("OK");
});
await new Promise<void>((resolve, reject) => {
fakeRelay.listen(port, "127.0.0.1", () => resolve());
fakeRelay.once("error", reject);
});
try {
cdpUrl = `http://127.0.0.1:${port}`;
const relay = await ensureChromeExtensionRelayServer({ cdpUrl });
expect(relay.port).toBe(port);
const status = (await fetch(`${cdpUrl}/extension/status`).then((r) => r.json())) as {
connected?: boolean;
};
expect(status.connected).toBe(false);
expect(probeToken).toBeTruthy();
expect(probeToken).not.toBe("test-gateway-token");
} finally {
await new Promise<void>((resolve) => fakeRelay.close(() => resolve()));
}
});
it("does not swallow EADDRINUSE when occupied port is not an openclaw relay", async () => {
const port = await getFreePort();
const blocker = createServer((_, res) => {
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end("not-relay");
});
await new Promise<void>((resolve, reject) => {
blocker.listen(port, "127.0.0.1", () => resolve());
blocker.once("error", reject);
});
const blockedUrl = `http://127.0.0.1:${port}`;
await expect(ensureChromeExtensionRelayServer({ cdpUrl: blockedUrl })).rejects.toThrow(
/EADDRINUSE/i,
);
await new Promise<void>((resolve) => blocker.close(() => resolve()));
});
});

View File

@@ -0,0 +1,976 @@
import type { IncomingMessage } from "node:http";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import type { Duplex } from "node:stream";
import WebSocket, { WebSocketServer } from "ws";
import { isLoopbackAddress, isLoopbackHost } from "../gateway/net.js";
import { rawDataToString } from "../infra/ws.js";
import {
probeAuthenticatedOpenClawRelay,
resolveRelayAcceptedTokensForPort,
resolveRelayAuthTokenForPort,
} from "./extension-relay-auth.js";
type CdpCommand = {
id: number;
method: string;
params?: unknown;
sessionId?: string;
};
type CdpResponse = {
id: number;
result?: unknown;
error?: { message: string };
sessionId?: string;
};
type CdpEvent = {
method: string;
params?: unknown;
sessionId?: string;
};
type ExtensionForwardCommandMessage = {
id: number;
method: "forwardCDPCommand";
params: { method: string; params?: unknown; sessionId?: string };
};
type ExtensionResponseMessage = {
id: number;
result?: unknown;
error?: string;
};
type ExtensionForwardEventMessage = {
method: "forwardCDPEvent";
params: { method: string; params?: unknown; sessionId?: string };
};
type ExtensionPingMessage = { method: "ping" };
type ExtensionPongMessage = { method: "pong" };
type ExtensionMessage =
| ExtensionResponseMessage
| ExtensionForwardEventMessage
| ExtensionPongMessage;
type TargetInfo = {
targetId: string;
type?: string;
title?: string;
url?: string;
attached?: boolean;
};
type AttachedToTargetEvent = {
sessionId: string;
targetInfo: TargetInfo;
waitingForDebugger?: boolean;
};
type DetachedFromTargetEvent = {
sessionId: string;
targetId?: string;
};
type ConnectedTarget = {
sessionId: string;
targetId: string;
targetInfo: TargetInfo;
};
const RELAY_AUTH_HEADER = "x-openclaw-relay-token";
const DEFAULT_EXTENSION_RECONNECT_GRACE_MS = 5_000;
const DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS = 3_000;
function headerValue(value: string | string[] | undefined): string | undefined {
if (!value) {
return undefined;
}
if (Array.isArray(value)) {
return value[0];
}
return value;
}
function getHeader(req: IncomingMessage, name: string): string | undefined {
return headerValue(req.headers[name.toLowerCase()]);
}
function getRelayAuthTokenFromRequest(req: IncomingMessage, url?: URL): string | undefined {
const headerToken = getHeader(req, RELAY_AUTH_HEADER)?.trim();
if (headerToken) {
return headerToken;
}
const queryToken = url?.searchParams.get("token")?.trim();
if (queryToken) {
return queryToken;
}
return undefined;
}
export type ChromeExtensionRelayServer = {
host: string;
port: number;
baseUrl: string;
cdpWsUrl: string;
extensionConnected: () => boolean;
stop: () => Promise<void>;
};
type RelayRuntime = {
server: ChromeExtensionRelayServer;
relayAuthToken: string;
};
function parseUrlPort(parsed: URL): number | null {
const port =
parsed.port?.trim() !== "" ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80;
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
return null;
}
return port;
}
function parseBaseUrl(raw: string): {
host: string;
port: number;
baseUrl: string;
} {
const parsed = new URL(raw.trim().replace(/\/$/, ""));
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`extension relay cdpUrl must be http(s), got ${parsed.protocol}`);
}
const host = parsed.hostname;
const port = parseUrlPort(parsed);
if (!port) {
throw new Error(`extension relay cdpUrl has invalid port: ${parsed.port || "(empty)"}`);
}
return { host, port, baseUrl: parsed.toString().replace(/\/$/, "") };
}
function text(res: Duplex, status: number, bodyText: string) {
const body = Buffer.from(bodyText);
res.write(
`HTTP/1.1 ${status} ${status === 200 ? "OK" : "ERR"}\r\n` +
"Content-Type: text/plain; charset=utf-8\r\n" +
`Content-Length: ${body.length}\r\n` +
"Connection: close\r\n" +
"\r\n",
);
res.write(body);
res.end();
}
function rejectUpgrade(socket: Duplex, status: number, bodyText: string) {
text(socket, status, bodyText);
try {
socket.destroy();
} catch {
// ignore
}
}
function envMsOrDefault(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw || raw.trim() === "") {
return fallback;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return parsed;
}
const relayRuntimeByPort = new Map<number, RelayRuntime>();
const relayInitByPort = new Map<number, Promise<ChromeExtensionRelayServer>>();
function isAddrInUseError(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code?: unknown }).code === "EADDRINUSE"
);
}
function relayAuthTokenForUrl(url: string): string | null {
try {
const parsed = new URL(url);
if (!isLoopbackHost(parsed.hostname)) {
return null;
}
const port = parseUrlPort(parsed);
if (!port) {
return null;
}
return relayRuntimeByPort.get(port)?.relayAuthToken ?? null;
} catch {
return null;
}
}
export function getChromeExtensionRelayAuthHeaders(url: string): Record<string, string> {
const token = relayAuthTokenForUrl(url);
if (!token) {
return {};
}
return { [RELAY_AUTH_HEADER]: token };
}
export async function ensureChromeExtensionRelayServer(opts: {
cdpUrl: string;
}): Promise<ChromeExtensionRelayServer> {
const info = parseBaseUrl(opts.cdpUrl);
if (!isLoopbackHost(info.host)) {
throw new Error(`extension relay requires loopback cdpUrl host (got ${info.host})`);
}
const existing = relayRuntimeByPort.get(info.port);
if (existing) {
return existing.server;
}
const inFlight = relayInitByPort.get(info.port);
if (inFlight) {
return await inFlight;
}
const extensionReconnectGraceMs = envMsOrDefault(
"OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS",
DEFAULT_EXTENSION_RECONNECT_GRACE_MS,
);
const extensionCommandReconnectWaitMs = envMsOrDefault(
"OPENCLAW_EXTENSION_RELAY_COMMAND_RECONNECT_WAIT_MS",
DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS,
);
const initPromise = (async (): Promise<ChromeExtensionRelayServer> => {
const relayAuthToken = resolveRelayAuthTokenForPort(info.port);
const relayAuthTokens = new Set(resolveRelayAcceptedTokensForPort(info.port));
let extensionWs: WebSocket | null = null;
const cdpClients = new Set<WebSocket>();
const connectedTargets = new Map<string, ConnectedTarget>();
const extensionConnected = () => extensionWs?.readyState === WebSocket.OPEN;
let extensionDisconnectCleanupTimer: NodeJS.Timeout | null = null;
const extensionReconnectWaiters = new Set<(connected: boolean) => void>();
const flushExtensionReconnectWaiters = (connected: boolean) => {
if (extensionReconnectWaiters.size === 0) {
return;
}
const waiters = Array.from(extensionReconnectWaiters);
extensionReconnectWaiters.clear();
for (const waiter of waiters) {
waiter(connected);
}
};
const clearExtensionDisconnectCleanupTimer = () => {
if (!extensionDisconnectCleanupTimer) {
return;
}
clearTimeout(extensionDisconnectCleanupTimer);
extensionDisconnectCleanupTimer = null;
};
const closeCdpClientsAfterExtensionDisconnect = () => {
connectedTargets.clear();
for (const client of cdpClients) {
try {
client.close(1011, "extension disconnected");
} catch {
// ignore
}
}
cdpClients.clear();
flushExtensionReconnectWaiters(false);
};
const scheduleExtensionDisconnectCleanup = () => {
clearExtensionDisconnectCleanupTimer();
extensionDisconnectCleanupTimer = setTimeout(() => {
extensionDisconnectCleanupTimer = null;
if (extensionConnected()) {
return;
}
closeCdpClientsAfterExtensionDisconnect();
}, extensionReconnectGraceMs);
};
const waitForExtensionReconnect = async (timeoutMs: number): Promise<boolean> => {
if (extensionConnected()) {
return true;
}
return await new Promise<boolean>((resolve) => {
let settled = false;
const waiter = (connected: boolean) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
extensionReconnectWaiters.delete(waiter);
resolve(connected);
};
const timer = setTimeout(() => {
waiter(false);
}, timeoutMs);
extensionReconnectWaiters.add(waiter);
});
};
const pendingExtension = new Map<
number,
{
resolve: (v: unknown) => void;
reject: (e: Error) => void;
timer: NodeJS.Timeout;
}
>();
let nextExtensionId = 1;
const sendToExtension = async (payload: ExtensionForwardCommandMessage): Promise<unknown> => {
const ws = extensionWs;
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error("Chrome extension not connected");
}
ws.send(JSON.stringify(payload));
return await new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
pendingExtension.delete(payload.id);
reject(new Error(`extension request timeout: ${payload.params.method}`));
}, 30_000);
pendingExtension.set(payload.id, { resolve, reject, timer });
});
};
const broadcastToCdpClients = (evt: CdpEvent) => {
const msg = JSON.stringify(evt);
for (const ws of cdpClients) {
if (ws.readyState !== WebSocket.OPEN) {
continue;
}
ws.send(msg);
}
};
const sendResponseToCdp = (ws: WebSocket, res: CdpResponse) => {
if (ws.readyState !== WebSocket.OPEN) {
return;
}
ws.send(JSON.stringify(res));
};
const ensureTargetEventsForClient = (ws: WebSocket, mode: "autoAttach" | "discover") => {
for (const target of connectedTargets.values()) {
if (mode === "autoAttach") {
ws.send(
JSON.stringify({
method: "Target.attachedToTarget",
params: {
sessionId: target.sessionId,
targetInfo: { ...target.targetInfo, attached: true },
waitingForDebugger: false,
},
} satisfies CdpEvent),
);
} else {
ws.send(
JSON.stringify({
method: "Target.targetCreated",
params: { targetInfo: { ...target.targetInfo, attached: true } },
} satisfies CdpEvent),
);
}
}
};
const routeCdpCommand = async (cmd: CdpCommand): Promise<unknown> => {
switch (cmd.method) {
case "Browser.getVersion":
return {
protocolVersion: "1.3",
product: "Chrome/OpenClaw-Extension-Relay",
revision: "0",
userAgent: "OpenClaw-Extension-Relay",
jsVersion: "V8",
};
case "Browser.setDownloadBehavior":
return {};
case "Target.setAutoAttach":
case "Target.setDiscoverTargets":
return {};
case "Target.getTargets":
return {
targetInfos: Array.from(connectedTargets.values()).map((t) => ({
...t.targetInfo,
attached: true,
})),
};
case "Target.getTargetInfo": {
const params = (cmd.params ?? {}) as { targetId?: string };
const targetId = typeof params.targetId === "string" ? params.targetId : undefined;
if (targetId) {
for (const t of connectedTargets.values()) {
if (t.targetId === targetId) {
return { targetInfo: t.targetInfo };
}
}
}
if (cmd.sessionId && connectedTargets.has(cmd.sessionId)) {
const t = connectedTargets.get(cmd.sessionId);
if (t) {
return { targetInfo: t.targetInfo };
}
}
const first = Array.from(connectedTargets.values())[0];
return { targetInfo: first?.targetInfo };
}
case "Target.attachToTarget": {
const params = (cmd.params ?? {}) as { targetId?: string };
const targetId = typeof params.targetId === "string" ? params.targetId : undefined;
if (!targetId) {
throw new Error("targetId required");
}
for (const t of connectedTargets.values()) {
if (t.targetId === targetId) {
return { sessionId: t.sessionId };
}
}
throw new Error("target not found");
}
default: {
const id = nextExtensionId++;
return await sendToExtension({
id,
method: "forwardCDPCommand",
params: {
method: cmd.method,
sessionId: cmd.sessionId,
params: cmd.params,
},
});
}
}
};
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", info.baseUrl);
const path = url.pathname;
const origin = getHeader(req, "origin");
const isChromeExtensionOrigin =
typeof origin === "string" && origin.startsWith("chrome-extension://");
if (isChromeExtensionOrigin && origin) {
// Let extension pages call relay HTTP endpoints cross-origin.
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
}
// Handle CORS preflight requests from the browser extension.
if (req.method === "OPTIONS") {
if (origin && !isChromeExtensionOrigin) {
res.writeHead(403);
res.end("Forbidden");
return;
}
const requestedHeaders = (getHeader(req, "access-control-request-headers") ?? "")
.split(",")
.map((header) => header.trim().toLowerCase())
.filter((header) => header.length > 0);
const allowedHeaders = new Set(["content-type", RELAY_AUTH_HEADER, ...requestedHeaders]);
res.writeHead(204, {
"Access-Control-Allow-Origin": origin ?? "*",
"Access-Control-Allow-Methods": "GET, PUT, POST, OPTIONS",
"Access-Control-Allow-Headers": Array.from(allowedHeaders).join(", "),
"Access-Control-Max-Age": "86400",
Vary: "Origin, Access-Control-Request-Headers",
});
res.end();
return;
}
if (path.startsWith("/json")) {
const token = getRelayAuthTokenFromRequest(req, url);
if (!token || !relayAuthTokens.has(token)) {
res.writeHead(401);
res.end("Unauthorized");
return;
}
}
if (req.method === "HEAD" && path === "/") {
res.writeHead(200);
res.end();
return;
}
if (path === "/") {
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end("OK");
return;
}
if (path === "/extension/status") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ connected: extensionConnected() }));
return;
}
const hostHeader = req.headers.host?.trim() || `${info.host}:${info.port}`;
const wsHost = `ws://${hostHeader}`;
const cdpWsUrl = `${wsHost}/cdp`;
if (
(path === "/json/version" || path === "/json/version/") &&
(req.method === "GET" || req.method === "PUT")
) {
const payload: Record<string, unknown> = {
Browser: "OpenClaw/extension-relay",
"Protocol-Version": "1.3",
};
// Only advertise the WS URL if a real extension is connected.
if (extensionConnected()) {
payload.webSocketDebuggerUrl = cdpWsUrl;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(payload));
return;
}
const listPaths = new Set(["/json", "/json/", "/json/list", "/json/list/"]);
if (listPaths.has(path) && (req.method === "GET" || req.method === "PUT")) {
const list = Array.from(connectedTargets.values()).map((t) => ({
id: t.targetId,
type: t.targetInfo.type ?? "page",
title: t.targetInfo.title ?? "",
description: t.targetInfo.title ?? "",
url: t.targetInfo.url ?? "",
webSocketDebuggerUrl: cdpWsUrl,
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${cdpWsUrl.replace("ws://", "")}`,
}));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(list));
return;
}
const handleTargetActionRoute = (
match: RegExpMatchArray | null,
cdpMethod: "Target.activateTarget" | "Target.closeTarget",
): boolean => {
if (!match || (req.method !== "GET" && req.method !== "PUT")) {
return false;
}
let targetId = "";
try {
targetId = decodeURIComponent(match[1] ?? "").trim();
} catch {
res.writeHead(400);
res.end("invalid targetId encoding");
return true;
}
if (!targetId) {
res.writeHead(400);
res.end("targetId required");
return true;
}
void (async () => {
try {
await sendToExtension({
id: nextExtensionId++,
method: "forwardCDPCommand",
params: { method: cdpMethod, params: { targetId } },
});
} catch {
// ignore
}
})();
res.writeHead(200);
res.end("OK");
return true;
};
if (
handleTargetActionRoute(path.match(/^\/json\/activate\/(.+)$/), "Target.activateTarget")
) {
return;
}
if (handleTargetActionRoute(path.match(/^\/json\/close\/(.+)$/), "Target.closeTarget")) {
return;
}
res.writeHead(404);
res.end("not found");
});
const wssExtension = new WebSocketServer({ noServer: true });
const wssCdp = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
const url = new URL(req.url ?? "/", info.baseUrl);
const pathname = url.pathname;
const remote = req.socket.remoteAddress;
if (!isLoopbackAddress(remote)) {
rejectUpgrade(socket, 403, "Forbidden");
return;
}
const origin = headerValue(req.headers.origin);
if (origin && !origin.startsWith("chrome-extension://")) {
rejectUpgrade(socket, 403, "Forbidden: invalid origin");
return;
}
if (pathname === "/extension") {
const token = getRelayAuthTokenFromRequest(req, url);
if (!token || !relayAuthTokens.has(token)) {
rejectUpgrade(socket, 401, "Unauthorized");
return;
}
// MV3 worker reconnect races can leave a stale non-OPEN socket reference.
if (extensionWs && extensionWs.readyState !== WebSocket.OPEN) {
try {
extensionWs.terminate();
} catch {
// ignore
}
extensionWs = null;
}
if (extensionConnected()) {
rejectUpgrade(socket, 409, "Extension already connected");
return;
}
wssExtension.handleUpgrade(req, socket, head, (ws) => {
wssExtension.emit("connection", ws, req);
});
return;
}
if (pathname === "/cdp") {
const token = getRelayAuthTokenFromRequest(req, url);
if (!token || !relayAuthTokens.has(token)) {
rejectUpgrade(socket, 401, "Unauthorized");
return;
}
if (!extensionConnected()) {
rejectUpgrade(socket, 503, "Extension not connected");
return;
}
wssCdp.handleUpgrade(req, socket, head, (ws) => {
wssCdp.emit("connection", ws, req);
});
return;
}
rejectUpgrade(socket, 404, "Not Found");
});
wssExtension.on("connection", (ws) => {
extensionWs = ws;
clearExtensionDisconnectCleanupTimer();
flushExtensionReconnectWaiters(true);
const ping = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) {
return;
}
ws.send(JSON.stringify({ method: "ping" } satisfies ExtensionPingMessage));
}, 5000);
ws.on("message", (data) => {
if (extensionWs !== ws) {
return;
}
let parsed: ExtensionMessage | null = null;
try {
parsed = JSON.parse(rawDataToString(data)) as ExtensionMessage;
} catch {
return;
}
if (
parsed &&
typeof parsed === "object" &&
"id" in parsed &&
typeof parsed.id === "number"
) {
const pending = pendingExtension.get(parsed.id);
if (!pending) {
return;
}
pendingExtension.delete(parsed.id);
clearTimeout(pending.timer);
if ("error" in parsed && typeof parsed.error === "string" && parsed.error.trim()) {
pending.reject(new Error(parsed.error));
} else {
pending.resolve(parsed.result);
}
return;
}
if (parsed && typeof parsed === "object" && "method" in parsed) {
if ((parsed as ExtensionPongMessage).method === "pong") {
return;
}
if ((parsed as ExtensionForwardEventMessage).method !== "forwardCDPEvent") {
return;
}
const evt = parsed as ExtensionForwardEventMessage;
const method = evt.params?.method;
const params = evt.params?.params;
const sessionId = evt.params?.sessionId;
if (!method || typeof method !== "string") {
return;
}
if (method === "Target.attachedToTarget") {
const attached = (params ?? {}) as AttachedToTargetEvent;
const targetType = attached?.targetInfo?.type ?? "page";
if (targetType !== "page") {
return;
}
if (attached?.sessionId && attached?.targetInfo?.targetId) {
const prev = connectedTargets.get(attached.sessionId);
const nextTargetId = attached.targetInfo.targetId;
const prevTargetId = prev?.targetId;
const changedTarget = Boolean(prev && prevTargetId && prevTargetId !== nextTargetId);
connectedTargets.set(attached.sessionId, {
sessionId: attached.sessionId,
targetId: nextTargetId,
targetInfo: attached.targetInfo,
});
if (changedTarget && prevTargetId) {
broadcastToCdpClients({
method: "Target.detachedFromTarget",
params: { sessionId: attached.sessionId, targetId: prevTargetId },
sessionId: attached.sessionId,
});
}
if (!prev || changedTarget) {
broadcastToCdpClients({ method, params, sessionId });
}
return;
}
}
if (method === "Target.detachedFromTarget") {
const detached = (params ?? {}) as DetachedFromTargetEvent;
if (detached?.sessionId) {
connectedTargets.delete(detached.sessionId);
}
broadcastToCdpClients({ method, params, sessionId });
return;
}
// Keep cached tab metadata fresh for /json/list.
// After navigation, Chrome updates URL/title via Target.targetInfoChanged.
if (method === "Target.targetInfoChanged") {
const changed = (params ?? {}) as { targetInfo?: { targetId?: string; type?: string } };
const targetInfo = changed?.targetInfo;
const targetId = targetInfo?.targetId;
if (targetId && (targetInfo?.type ?? "page") === "page") {
for (const [sid, target] of connectedTargets) {
if (target.targetId !== targetId) {
continue;
}
connectedTargets.set(sid, {
...target,
targetInfo: { ...target.targetInfo, ...(targetInfo as object) },
});
}
}
}
broadcastToCdpClients({ method, params, sessionId });
}
});
ws.on("close", () => {
clearInterval(ping);
if (extensionWs !== ws) {
return;
}
extensionWs = null;
for (const [, pending] of pendingExtension) {
clearTimeout(pending.timer);
pending.reject(new Error("extension disconnected"));
}
pendingExtension.clear();
scheduleExtensionDisconnectCleanup();
});
});
wssCdp.on("connection", (ws) => {
cdpClients.add(ws);
ws.on("message", async (data) => {
let cmd: CdpCommand | null = null;
try {
cmd = JSON.parse(rawDataToString(data)) as CdpCommand;
} catch {
return;
}
if (!cmd || typeof cmd !== "object") {
return;
}
if (typeof cmd.id !== "number" || typeof cmd.method !== "string") {
return;
}
if (!extensionConnected()) {
const reconnected = await waitForExtensionReconnect(extensionCommandReconnectWaitMs);
if (!reconnected || !extensionConnected()) {
sendResponseToCdp(ws, {
id: cmd.id,
sessionId: cmd.sessionId,
error: { message: "Extension not connected" },
});
return;
}
}
try {
const result = await routeCdpCommand(cmd);
if (cmd.method === "Target.setAutoAttach" && !cmd.sessionId) {
ensureTargetEventsForClient(ws, "autoAttach");
}
if (cmd.method === "Target.setDiscoverTargets") {
const discover = (cmd.params ?? {}) as { discover?: boolean };
if (discover.discover === true) {
ensureTargetEventsForClient(ws, "discover");
}
}
if (cmd.method === "Target.attachToTarget") {
const params = (cmd.params ?? {}) as { targetId?: string };
const targetId = typeof params.targetId === "string" ? params.targetId : undefined;
if (targetId) {
const target = Array.from(connectedTargets.values()).find(
(t) => t.targetId === targetId,
);
if (target) {
ws.send(
JSON.stringify({
method: "Target.attachedToTarget",
params: {
sessionId: target.sessionId,
targetInfo: { ...target.targetInfo, attached: true },
waitingForDebugger: false,
},
} satisfies CdpEvent),
);
}
}
}
sendResponseToCdp(ws, { id: cmd.id, sessionId: cmd.sessionId, result });
} catch (err) {
sendResponseToCdp(ws, {
id: cmd.id,
sessionId: cmd.sessionId,
error: { message: err instanceof Error ? err.message : String(err) },
});
}
});
ws.on("close", () => {
cdpClients.delete(ws);
});
});
try {
await new Promise<void>((resolve, reject) => {
server.listen(info.port, info.host, () => resolve());
server.once("error", reject);
});
} catch (err) {
if (
isAddrInUseError(err) &&
(await probeAuthenticatedOpenClawRelay({
baseUrl: info.baseUrl,
relayAuthHeader: RELAY_AUTH_HEADER,
relayAuthToken,
}))
) {
const existingRelay: ChromeExtensionRelayServer = {
host: info.host,
port: info.port,
baseUrl: info.baseUrl,
cdpWsUrl: `ws://${info.host}:${info.port}/cdp`,
extensionConnected: () => false,
stop: async () => {
relayRuntimeByPort.delete(info.port);
},
};
relayRuntimeByPort.set(info.port, { server: existingRelay, relayAuthToken });
return existingRelay;
}
throw err;
}
const addr = server.address() as AddressInfo | null;
const port = addr?.port ?? info.port;
const host = info.host;
const baseUrl = `${new URL(info.baseUrl).protocol}//${host}:${port}`;
const relay: ChromeExtensionRelayServer = {
host,
port,
baseUrl,
cdpWsUrl: `ws://${host}:${port}/cdp`,
extensionConnected,
stop: async () => {
relayRuntimeByPort.delete(port);
clearExtensionDisconnectCleanupTimer();
flushExtensionReconnectWaiters(false);
for (const [, pending] of pendingExtension) {
clearTimeout(pending.timer);
pending.reject(new Error("server stopping"));
}
pendingExtension.clear();
try {
extensionWs?.close(1001, "server stopping");
} catch {
// ignore
}
for (const ws of cdpClients) {
try {
ws.close(1001, "server stopping");
} catch {
// ignore
}
}
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
wssExtension.close();
wssCdp.close();
},
};
relayRuntimeByPort.set(port, { server: relay, relayAuthToken });
return relay;
})();
relayInitByPort.set(info.port, initPromise);
try {
return await initPromise;
} finally {
relayInitByPort.delete(info.port);
}
}
export async function stopChromeExtensionRelayServer(opts: { cdpUrl: string }): Promise<boolean> {
const info = parseBaseUrl(opts.cdpUrl);
const existing = relayRuntimeByPort.get(info.port);
if (!existing) {
return false;
}
await existing.server.stop();
return true;
}

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,123 @@
import { describe, expect, it, vi } from "vitest";
import { SsrFBlockedError, type LookupFn } from "../infra/net/ssrf.js";
import {
assertBrowserNavigationAllowed,
assertBrowserNavigationResultAllowed,
InvalidBrowserNavigationUrlError,
} from "./navigation-guard.js";
function createLookupFn(address: string): LookupFn {
const family = address.includes(":") ? 6 : 4;
return vi.fn(async () => [{ address, family }]) as unknown as LookupFn;
}
describe("browser navigation guard", () => {
it("blocks private loopback URLs by default", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "http://127.0.0.1:8080",
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
});
it("allows about:blank", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "about:blank",
}),
).resolves.toBeUndefined();
});
it("blocks file URLs", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "file:///etc/passwd",
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
});
it("blocks data URLs", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "data:text/html,<h1>owned</h1>",
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
});
it("blocks javascript URLs", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "javascript:alert(1)",
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
});
it("blocks non-blank about URLs", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "about:srcdoc",
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
});
it("allows blocked hostnames when explicitly allowed", async () => {
const lookupFn = createLookupFn("127.0.0.1");
await expect(
assertBrowserNavigationAllowed({
url: "http://agent.internal:3000",
ssrfPolicy: {
allowedHostnames: ["agent.internal"],
},
lookupFn,
}),
).resolves.toBeUndefined();
expect(lookupFn).toHaveBeenCalledWith("agent.internal", { all: true });
});
it("blocks hostnames that resolve to private addresses by default", async () => {
const lookupFn = createLookupFn("127.0.0.1");
await expect(
assertBrowserNavigationAllowed({
url: "https://example.com",
lookupFn,
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
});
it("allows hostnames that resolve to public addresses", async () => {
const lookupFn = createLookupFn("93.184.216.34");
await expect(
assertBrowserNavigationAllowed({
url: "https://example.com",
lookupFn,
}),
).resolves.toBeUndefined();
expect(lookupFn).toHaveBeenCalledWith("example.com", { all: true });
});
it("rejects invalid URLs", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "not a url",
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
});
it("validates final network URLs after navigation", async () => {
const lookupFn = createLookupFn("127.0.0.1");
await expect(
assertBrowserNavigationResultAllowed({
url: "http://private.test",
lookupFn,
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
});
it("ignores non-network browser-internal final URLs", async () => {
await expect(
assertBrowserNavigationResultAllowed({
url: "chrome-error://chromewebdata/",
}),
).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,92 @@
import {
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 function withBrowserNavigationPolicy(
ssrfPolicy?: SsrFPolicy,
): BrowserNavigationPolicyOptions {
return ssrfPolicy ? { 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}"`,
);
}
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);
}
}

View File

@@ -0,0 +1,312 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
resolveExistingPathsWithinRoot,
resolvePathsWithinRoot,
resolvePathWithinRoot,
resolveStrictExistingPathsWithinRoot,
resolveWritablePathWithinRoot,
} from "./paths.js";
async function createFixtureRoot(): Promise<{ baseDir: string; uploadsDir: string }> {
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-browser-paths-"));
const uploadsDir = path.join(baseDir, "uploads");
await fs.mkdir(uploadsDir, { recursive: true });
return { baseDir, uploadsDir };
}
async function withFixtureRoot<T>(
run: (ctx: { baseDir: string; uploadsDir: string }) => Promise<T>,
): Promise<T> {
const fixture = await createFixtureRoot();
try {
return await run(fixture);
} finally {
await fs.rm(fixture.baseDir, { recursive: true, force: true });
}
}
describe("resolveExistingPathsWithinRoot", () => {
function expectInvalidResult(
result: Awaited<ReturnType<typeof resolveExistingPathsWithinRoot>>,
expectedSnippet: string,
) {
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain(expectedSnippet);
}
}
function resolveWithinUploads(params: {
uploadsDir: string;
requestedPaths: string[];
}): Promise<Awaited<ReturnType<typeof resolveExistingPathsWithinRoot>>> {
return resolveExistingPathsWithinRoot({
rootDir: params.uploadsDir,
requestedPaths: params.requestedPaths,
scopeLabel: "uploads directory",
});
}
it("accepts existing files under the upload root", async () => {
await withFixtureRoot(async ({ uploadsDir }) => {
const nestedDir = path.join(uploadsDir, "nested");
await fs.mkdir(nestedDir, { recursive: true });
const filePath = path.join(nestedDir, "ok.txt");
await fs.writeFile(filePath, "ok", "utf8");
const result = await resolveWithinUploads({
uploadsDir,
requestedPaths: [filePath],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.paths).toEqual([await fs.realpath(filePath)]);
}
});
});
it("rejects traversal outside the upload root", async () => {
await withFixtureRoot(async ({ baseDir, uploadsDir }) => {
const outsidePath = path.join(baseDir, "outside.txt");
await fs.writeFile(outsidePath, "nope", "utf8");
const result = await resolveWithinUploads({
uploadsDir,
requestedPaths: ["../outside.txt"],
});
expectInvalidResult(result, "must stay within uploads directory");
});
});
it("rejects blank paths", async () => {
await withFixtureRoot(async ({ uploadsDir }) => {
const result = await resolveWithinUploads({
uploadsDir,
requestedPaths: [" "],
});
expectInvalidResult(result, "path is required");
});
});
it("keeps lexical in-root paths when files do not exist yet", async () => {
await withFixtureRoot(async ({ uploadsDir }) => {
const result = await resolveWithinUploads({
uploadsDir,
requestedPaths: ["missing.txt"],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.paths).toEqual([path.join(uploadsDir, "missing.txt")]);
}
});
});
it("rejects directory paths inside upload root", async () => {
await withFixtureRoot(async ({ uploadsDir }) => {
const nestedDir = path.join(uploadsDir, "nested");
await fs.mkdir(nestedDir, { recursive: true });
const result = await resolveWithinUploads({
uploadsDir,
requestedPaths: ["nested"],
});
expectInvalidResult(result, "regular non-symlink file");
});
});
it.runIf(process.platform !== "win32")(
"rejects symlink escapes outside upload root",
async () => {
await withFixtureRoot(async ({ baseDir, uploadsDir }) => {
const outsidePath = path.join(baseDir, "secret.txt");
await fs.writeFile(outsidePath, "secret", "utf8");
const symlinkPath = path.join(uploadsDir, "leak.txt");
await fs.symlink(outsidePath, symlinkPath);
const result = await resolveWithinUploads({
uploadsDir,
requestedPaths: ["leak.txt"],
});
expectInvalidResult(result, "regular non-symlink file");
});
},
);
it.runIf(process.platform !== "win32")(
"accepts canonical absolute paths when upload root is a symlink alias",
async () => {
await withFixtureRoot(async ({ baseDir }) => {
const canonicalUploadsDir = path.join(baseDir, "canonical", "uploads");
const aliasedUploadsDir = path.join(baseDir, "uploads-link");
await fs.mkdir(canonicalUploadsDir, { recursive: true });
await fs.symlink(canonicalUploadsDir, aliasedUploadsDir);
const filePath = path.join(canonicalUploadsDir, "ok.txt");
await fs.writeFile(filePath, "ok", "utf8");
const canonicalPath = await fs.realpath(filePath);
const firstPass = await resolveWithinUploads({
uploadsDir: aliasedUploadsDir,
requestedPaths: [path.join(aliasedUploadsDir, "ok.txt")],
});
expect(firstPass.ok).toBe(true);
const secondPass = await resolveWithinUploads({
uploadsDir: aliasedUploadsDir,
requestedPaths: [canonicalPath],
});
expect(secondPass.ok).toBe(true);
if (secondPass.ok) {
expect(secondPass.paths).toEqual([canonicalPath]);
}
});
},
);
it.runIf(process.platform !== "win32")(
"rejects canonical absolute paths outside symlinked upload root",
async () => {
await withFixtureRoot(async ({ baseDir }) => {
const canonicalUploadsDir = path.join(baseDir, "canonical", "uploads");
const aliasedUploadsDir = path.join(baseDir, "uploads-link");
await fs.mkdir(canonicalUploadsDir, { recursive: true });
await fs.symlink(canonicalUploadsDir, aliasedUploadsDir);
const outsideDir = path.join(baseDir, "outside");
await fs.mkdir(outsideDir, { recursive: true });
const outsideFile = path.join(outsideDir, "secret.txt");
await fs.writeFile(outsideFile, "secret", "utf8");
const result = await resolveWithinUploads({
uploadsDir: aliasedUploadsDir,
requestedPaths: [await fs.realpath(outsideFile)],
});
expectInvalidResult(result, "must stay within uploads directory");
});
},
);
});
describe("resolveStrictExistingPathsWithinRoot", () => {
function expectInvalidResult(
result: Awaited<ReturnType<typeof resolveStrictExistingPathsWithinRoot>>,
expectedSnippet: string,
) {
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain(expectedSnippet);
}
}
it("rejects missing files instead of returning lexical fallbacks", async () => {
await withFixtureRoot(async ({ uploadsDir }) => {
const result = await resolveStrictExistingPathsWithinRoot({
rootDir: uploadsDir,
requestedPaths: ["missing.txt"],
scopeLabel: "uploads directory",
});
expectInvalidResult(result, "regular non-symlink file");
});
});
});
describe("resolvePathWithinRoot", () => {
it("uses default file name when requested path is blank", () => {
const result = resolvePathWithinRoot({
rootDir: "/tmp/uploads",
requestedPath: " ",
scopeLabel: "uploads directory",
defaultFileName: "fallback.txt",
});
expect(result).toEqual({
ok: true,
path: path.resolve("/tmp/uploads", "fallback.txt"),
});
});
it("rejects root-level path aliases that do not point to a file", () => {
const result = resolvePathWithinRoot({
rootDir: "/tmp/uploads",
requestedPath: ".",
scopeLabel: "uploads directory",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("must stay within uploads directory");
}
});
});
describe("resolveWritablePathWithinRoot", () => {
it("accepts a writable path under root when parent is a real directory", async () => {
await withFixtureRoot(async ({ uploadsDir }) => {
const result = await resolveWritablePathWithinRoot({
rootDir: uploadsDir,
requestedPath: "safe.txt",
scopeLabel: "uploads directory",
});
expect(result).toEqual({
ok: true,
path: path.resolve(uploadsDir, "safe.txt"),
});
});
});
it.runIf(process.platform !== "win32")(
"rejects write paths routed through a symlinked parent directory",
async () => {
await withFixtureRoot(async ({ baseDir, uploadsDir }) => {
const outsideDir = path.join(baseDir, "outside");
await fs.mkdir(outsideDir, { recursive: true });
const symlinkDir = path.join(uploadsDir, "escape-link");
await fs.symlink(outsideDir, symlinkDir);
const result = await resolveWritablePathWithinRoot({
rootDir: uploadsDir,
requestedPath: "escape-link/pwned.txt",
scopeLabel: "uploads directory",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("must stay within uploads directory");
}
});
},
);
});
describe("resolvePathsWithinRoot", () => {
it("resolves all valid in-root paths", () => {
const result = resolvePathsWithinRoot({
rootDir: "/tmp/uploads",
requestedPaths: ["a.txt", "nested/b.txt"],
scopeLabel: "uploads directory",
});
expect(result).toEqual({
ok: true,
paths: [path.resolve("/tmp/uploads", "a.txt"), path.resolve("/tmp/uploads", "nested/b.txt")],
});
});
it("returns the first path validation error", () => {
const result = resolvePathsWithinRoot({
rootDir: "/tmp/uploads",
requestedPaths: ["a.txt", "../outside.txt", "b.txt"],
scopeLabel: "uploads directory",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("must stay within uploads directory");
}
});
});

View File

@@ -0,0 +1,247 @@
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";
}
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;
}
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,147 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveBrowserConfig } from "./config.js";
import { createBrowserProfilesService } from "./profiles-service.js";
import type { BrowserRouteContext, BrowserServerState } from "./server-context.js";
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: vi.fn(),
writeConfigFile: vi.fn(async () => {}),
};
});
vi.mock("./trash.js", () => ({
movePathToTrash: vi.fn(async (targetPath: string) => targetPath),
}));
vi.mock("./chrome.js", () => ({
resolveOpenClawUserDataDir: vi.fn(() => "/tmp/openclaw-test/openclaw/user-data"),
}));
import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveOpenClawUserDataDir } from "./chrome.js";
import { movePathToTrash } from "./trash.js";
function createCtx(resolved: BrowserServerState["resolved"]) {
const state: BrowserServerState = {
server: null as unknown as BrowserServerState["server"],
port: 0,
resolved,
profiles: new Map(),
};
const ctx = {
state: () => state,
listProfiles: vi.fn(async () => []),
forProfile: vi.fn(() => ({
stopRunningBrowser: vi.fn(async () => ({ stopped: true })),
})),
} as unknown as BrowserRouteContext;
return { state, ctx };
}
describe("BrowserProfilesService", () => {
it("allocates next local port for new profiles", async () => {
const resolved = resolveBrowserConfig({});
const { ctx, state } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({ name: "work" });
expect(result.cdpPort).toBe(18801);
expect(result.isRemote).toBe(false);
expect(state.resolved.profiles.work?.cdpPort).toBe(18801);
expect(writeConfigFile).toHaveBeenCalled();
});
it("accepts per-profile cdpUrl for remote Chrome", async () => {
const resolved = resolveBrowserConfig({});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({
name: "remote",
cdpUrl: "http://10.0.0.42:9222",
});
expect(result.cdpUrl).toBe("http://10.0.0.42:9222");
expect(result.cdpPort).toBe(9222);
expect(result.isRemote).toBe(true);
expect(writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
browser: expect.objectContaining({
profiles: expect.objectContaining({
remote: expect.objectContaining({
cdpUrl: "http://10.0.0.42:9222",
}),
}),
}),
}),
);
});
it("deletes remote profiles without stopping or removing local data", async () => {
const resolved = resolveBrowserConfig({
profiles: {
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
},
});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({
browser: {
defaultProfile: "openclaw",
profiles: {
openclaw: { cdpPort: 18800, color: "#FF4500" },
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
},
},
});
const service = createBrowserProfilesService(ctx);
const result = await service.deleteProfile("remote");
expect(result.deleted).toBe(false);
expect(ctx.forProfile).not.toHaveBeenCalled();
expect(movePathToTrash).not.toHaveBeenCalled();
});
it("deletes local profiles and moves data to Trash", async () => {
const resolved = resolveBrowserConfig({
profiles: {
work: { cdpPort: 18801, color: "#0066CC" },
},
});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({
browser: {
defaultProfile: "openclaw",
profiles: {
openclaw: { cdpPort: 18800, color: "#FF4500" },
work: { cdpPort: 18801, color: "#0066CC" },
},
},
});
const tempDir = fs.mkdtempSync(path.join("/tmp", "openclaw-profile-"));
const userDataDir = path.join(tempDir, "work", "user-data");
fs.mkdirSync(path.dirname(userDataDir), { recursive: true });
vi.mocked(resolveOpenClawUserDataDir).mockReturnValue(userDataDir);
const service = createBrowserProfilesService(ctx);
const result = await service.deleteProfile("work");
expect(result.deleted).toBe(true);
expect(movePathToTrash).toHaveBeenCalledWith(path.dirname(userDataDir));
});
});

View File

@@ -0,0 +1,187 @@
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 { resolveOpenClawUserDataDir } from "./chrome.js";
import { parseHttpUrl, resolveProfile } from "./config.js";
import { DEFAULT_BROWSER_DEFAULT_PROFILE_NAME } from "./constants.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;
driver?: "openclaw" | "extension";
};
export type CreateProfileResult = {
ok: true;
profile: string;
cdpPort: number;
cdpUrl: string;
color: string;
isRemote: boolean;
};
export type DeleteProfileResult = {
ok: true;
profile: string;
deleted: boolean;
};
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
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 driver = params.driver === "extension" ? "extension" : undefined;
if (!isValidProfileName(name)) {
throw new Error("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 Error(`profile "${name}" already exists`);
}
const cfg = loadConfig();
const rawProfiles = cfg.browser?.profiles ?? {};
if (name in rawProfiles) {
throw new Error(`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 (rawCdpUrl) {
const parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
profileConfig = {
cdpUrl: parsed.normalized,
...(driver ? { driver } : {}),
color: profileColor,
};
} else {
const usedPorts = getUsedPorts(resolvedProfiles);
const range = deriveDefaultBrowserCdpPortRange(state.resolved.controlPort);
const cdpPort = allocateCdpPort(usedPorts, range);
if (cdpPort === null) {
throw new Error("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 Error(`profile "${name}" not found after creation`);
}
return {
ok: true,
profile: name,
cdpPort: resolved.cdpPort,
cdpUrl: resolved.cdpUrl,
color: resolved.color,
isRemote: !resolved.cdpIsLoopback,
};
};
const deleteProfile = async (nameRaw: string): Promise<DeleteProfileResult> => {
const name = nameRaw.trim();
if (!name) {
throw new Error("profile name is required");
}
if (!isValidProfileName(name)) {
throw new Error("invalid profile name");
}
const cfg = loadConfig();
const profiles = cfg.browser?.profiles ?? {};
if (!(name in profiles)) {
throw new Error(`profile "${name}" not found`);
}
const defaultProfile = cfg.browser?.defaultProfile ?? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME;
if (name === defaultProfile) {
throw new Error(
`cannot delete the default profile "${name}"; change browser.defaultProfile first`,
);
}
let deleted = false;
const state = ctx.state();
const resolved = resolveProfile(state.resolved, name);
if (resolved?.cdpIsLoopback) {
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,236 @@
import { describe, expect, it } from "vitest";
import { resolveBrowserConfig } from "./config.js";
import {
allocateCdpPort,
allocateColor,
CDP_PORT_RANGE_END,
CDP_PORT_RANGE_START,
getUsedColors,
getUsedPorts,
isValidProfileName,
PROFILE_COLORS,
} from "./profiles.js";
describe("profile name validation", () => {
it.each(["openclaw", "work", "my-profile", "test123", "a", "a-b-c-1-2-3", "1test"])(
"accepts valid lowercase name: %s",
(name) => {
expect(isValidProfileName(name)).toBe(true);
},
);
it("rejects empty or missing names", () => {
expect(isValidProfileName("")).toBe(false);
// @ts-expect-error testing invalid input
expect(isValidProfileName(null)).toBe(false);
// @ts-expect-error testing invalid input
expect(isValidProfileName(undefined)).toBe(false);
});
it("rejects names that are too long", () => {
const longName = "a".repeat(65);
expect(isValidProfileName(longName)).toBe(false);
const maxName = "a".repeat(64);
expect(isValidProfileName(maxName)).toBe(true);
});
it.each([
"MyProfile",
"PROFILE",
"Work",
"my profile",
"my_profile",
"my.profile",
"my/profile",
"my@profile",
"-invalid",
"--double",
])("rejects invalid name: %s", (name) => {
expect(isValidProfileName(name)).toBe(false);
});
});
describe("port allocation", () => {
it("allocates within an explicit range", () => {
const usedPorts = new Set<number>();
expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(20000);
usedPorts.add(20000);
expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(20001);
});
it("allocates next available port from default range", () => {
const cases = [
{ name: "none used", used: new Set<number>(), expected: CDP_PORT_RANGE_START },
{
name: "sequentially used start ports",
used: new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 1]),
expected: CDP_PORT_RANGE_START + 2,
},
{
name: "first gap wins",
used: new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 2]),
expected: CDP_PORT_RANGE_START + 1,
},
{
name: "ignores outside-range ports",
used: new Set([1, 2, 3, 50000]),
expected: CDP_PORT_RANGE_START,
},
] as const;
for (const testCase of cases) {
expect(allocateCdpPort(testCase.used), testCase.name).toBe(testCase.expected);
}
});
it("returns null when all ports are exhausted", () => {
const usedPorts = new Set<number>();
for (let port = CDP_PORT_RANGE_START; port <= CDP_PORT_RANGE_END; port++) {
usedPorts.add(port);
}
expect(allocateCdpPort(usedPorts)).toBeNull();
});
});
describe("getUsedPorts", () => {
it("returns empty set for undefined profiles", () => {
expect(getUsedPorts(undefined)).toEqual(new Set());
});
it("extracts ports from profile configs", () => {
const profiles = {
openclaw: { cdpPort: 18792 },
work: { cdpPort: 18793 },
personal: { cdpPort: 18795 },
};
const used = getUsedPorts(profiles);
expect(used).toEqual(new Set([18792, 18793, 18795]));
});
it("extracts ports from cdpUrl when cdpPort is missing", () => {
const profiles = {
remote: { cdpUrl: "http://10.0.0.42:9222" },
secure: { cdpUrl: "https://example.com:9443" },
};
const used = getUsedPorts(profiles);
expect(used).toEqual(new Set([9222, 9443]));
});
it("ignores invalid cdpUrl values", () => {
const profiles = {
bad: { cdpUrl: "notaurl" },
};
const used = getUsedPorts(profiles);
expect(used.size).toBe(0);
});
});
describe("port collision prevention", () => {
it("raw config vs resolved config - shows the data source difference", () => {
// This demonstrates WHY the route handler must use resolved config
// Fresh config with no profiles defined (like a new install)
const rawConfigProfiles = undefined;
const usedFromRaw = getUsedPorts(rawConfigProfiles);
// Raw config shows empty - no ports used
expect(usedFromRaw.size).toBe(0);
// But resolved config has implicit openclaw at 18800
const resolved = resolveBrowserConfig({});
const usedFromResolved = getUsedPorts(resolved.profiles);
expect(usedFromResolved.has(CDP_PORT_RANGE_START)).toBe(true);
});
it("create-profile must use resolved config to avoid port collision", () => {
// The route handler must use state.resolved.profiles, not raw config
// Simulate what happens with raw config (empty) vs resolved config
const rawConfig: { browser: { profiles?: Record<string, { cdpPort?: number }> } } = {
browser: {},
}; // Fresh config, no profiles
const buggyUsedPorts = getUsedPorts(rawConfig.browser?.profiles);
const buggyAllocatedPort = allocateCdpPort(buggyUsedPorts);
// Raw config: first allocation gets 18800
expect(buggyAllocatedPort).toBe(CDP_PORT_RANGE_START);
// Resolved config: includes implicit openclaw at 18800
const resolved = resolveBrowserConfig(
rawConfig.browser as Parameters<typeof resolveBrowserConfig>[0],
);
const fixedUsedPorts = getUsedPorts(resolved.profiles);
const fixedAllocatedPort = allocateCdpPort(fixedUsedPorts);
// Resolved: first NEW profile gets 18801, avoiding collision
expect(fixedAllocatedPort).toBe(CDP_PORT_RANGE_START + 1);
});
});
describe("color allocation", () => {
it("allocates next unused color from palette", () => {
const cases = [
{ name: "none used", used: new Set<string>(), expected: PROFILE_COLORS[0] },
{
name: "first color used",
used: new Set([PROFILE_COLORS[0].toUpperCase()]),
expected: PROFILE_COLORS[1],
},
{
name: "multiple used colors",
used: new Set([
PROFILE_COLORS[0].toUpperCase(),
PROFILE_COLORS[1].toUpperCase(),
PROFILE_COLORS[2].toUpperCase(),
]),
expected: PROFILE_COLORS[3],
},
] as const;
for (const testCase of cases) {
expect(allocateColor(testCase.used), testCase.name).toBe(testCase.expected);
}
});
it("handles case-insensitive color matching", () => {
const usedColors = new Set(["#ff4500"]); // lowercase
// Should still skip this color (case-insensitive)
// Note: allocateColor compares against uppercase, so lowercase won't match
// This tests the current behavior
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[0]); // returns first since lowercase doesn't match
});
it("cycles when all colors are used", () => {
const usedColors = new Set(PROFILE_COLORS.map((c) => c.toUpperCase()));
// Should cycle based on count
const result = allocateColor(usedColors);
expect(PROFILE_COLORS).toContain(result);
});
it("cycles based on count when palette exhausted", () => {
// Add all colors plus some extras
const usedColors = new Set([
...PROFILE_COLORS.map((c) => c.toUpperCase()),
"#AAAAAA",
"#BBBBBB",
]);
const result = allocateColor(usedColors);
// Index should be (10 + 2) % 10 = 2
expect(result).toBe(PROFILE_COLORS[2]);
});
});
describe("getUsedColors", () => {
it("returns empty set when no color profiles are configured", () => {
expect(getUsedColors(undefined)).toEqual(new Set());
});
it("extracts and uppercases colors from profile configs", () => {
const profiles = {
openclaw: { color: "#ff4500" },
work: { color: "#0066CC" },
};
const used = getUsedColors(profiles);
expect(used).toEqual(new Set(["#FF4500", "#0066CC"]));
});
});

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", buffer.byteLength);
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,184 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
vi.mock("playwright-core", () => ({
chromium: {
connectOverCDP: vi.fn(),
},
}));
type FakeSession = {
send: ReturnType<typeof vi.fn>;
detach: ReturnType<typeof vi.fn>;
};
function createPage(opts: { targetId: string; snapshotFull?: string; hasSnapshotForAI?: boolean }) {
const session: FakeSession = {
send: vi.fn().mockResolvedValue({
targetInfo: { targetId: opts.targetId },
}),
detach: vi.fn().mockResolvedValue(undefined),
};
const context = {
newCDPSession: vi.fn().mockResolvedValue(session),
};
const click = vi.fn().mockResolvedValue(undefined);
const dblclick = vi.fn().mockResolvedValue(undefined);
const fill = vi.fn().mockResolvedValue(undefined);
const locator = vi.fn().mockReturnValue({ click, dblclick, fill });
const page = {
context: () => context,
locator,
on: vi.fn(),
...(opts.hasSnapshotForAI === false
? {}
: {
_snapshotForAI: vi.fn().mockResolvedValue({ full: opts.snapshotFull ?? "SNAP" }),
}),
};
return { page, session, locator, click, fill };
}
function createBrowser(pages: unknown[]) {
const ctx = {
pages: () => pages,
on: vi.fn(),
};
return {
contexts: () => [ctx],
on: vi.fn(),
close: vi.fn().mockResolvedValue(undefined),
} as unknown as import("playwright-core").Browser;
}
let chromiumMock: typeof import("playwright-core").chromium;
let snapshotAiViaPlaywright: typeof import("./pw-tools-core.snapshot.js").snapshotAiViaPlaywright;
let clickViaPlaywright: typeof import("./pw-tools-core.interactions.js").clickViaPlaywright;
let closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection;
beforeAll(async () => {
const pw = await import("playwright-core");
chromiumMock = pw.chromium;
({ snapshotAiViaPlaywright } = await import("./pw-tools-core.snapshot.js"));
({ clickViaPlaywright } = await import("./pw-tools-core.interactions.js"));
({ closePlaywrightBrowserConnection } = await import("./pw-session.js"));
});
afterEach(async () => {
await closePlaywrightBrowserConnection();
vi.clearAllMocks();
});
describe("pw-ai", () => {
it("captures an ai snapshot via Playwright for a specific target", async () => {
const p1 = createPage({ targetId: "T1", snapshotFull: "ONE" });
const p2 = createPage({ targetId: "T2", snapshotFull: "TWO" });
const browser = createBrowser([p1.page, p2.page]);
(chromiumMock.connectOverCDP as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(browser);
const res = await snapshotAiViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T2",
});
expect(res.snapshot).toBe("TWO");
expect(p1.session.detach).toHaveBeenCalledTimes(1);
expect(p2.session.detach).toHaveBeenCalledTimes(1);
});
it("registers aria refs from ai snapshots for act commands", async () => {
const snapshot = ['- button "OK" [ref=e1]', '- link "Docs" [ref=e2]'].join("\n");
const p1 = createPage({ targetId: "T1", snapshotFull: snapshot });
const browser = createBrowser([p1.page]);
(chromiumMock.connectOverCDP as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(browser);
const res = await snapshotAiViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
});
expect(res.refs).toMatchObject({
e1: { role: "button", name: "OK" },
e2: { role: "link", name: "Docs" },
});
await clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "e1",
});
expect(p1.locator).toHaveBeenCalledWith("aria-ref=e1");
expect(p1.click).toHaveBeenCalledTimes(1);
});
it("truncates oversized snapshots", async () => {
const longSnapshot = "A".repeat(20);
const p1 = createPage({ targetId: "T1", snapshotFull: longSnapshot });
const browser = createBrowser([p1.page]);
(chromiumMock.connectOverCDP as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(browser);
const res = await snapshotAiViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
maxChars: 10,
});
expect(res.truncated).toBe(true);
expect(res.snapshot.startsWith("AAAAAAAAAA")).toBe(true);
expect(res.snapshot).toContain("TRUNCATED");
});
it("clicks a ref using aria-ref locator", async () => {
const p1 = createPage({ targetId: "T1" });
const browser = createBrowser([p1.page]);
(chromiumMock.connectOverCDP as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(browser);
await clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "76",
});
expect(p1.locator).toHaveBeenCalledWith("aria-ref=76");
expect(p1.click).toHaveBeenCalledTimes(1);
});
it("fails with a clear error when _snapshotForAI is missing", async () => {
const p1 = createPage({ targetId: "T1", hasSnapshotForAI: false });
const browser = createBrowser([p1.page]);
(chromiumMock.connectOverCDP as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(browser);
await expect(
snapshotAiViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
}),
).rejects.toThrow(/_snapshotForAI/i);
});
it("reuses the CDP connection for repeated calls", async () => {
const p1 = createPage({ targetId: "T1", snapshotFull: "ONE" });
const browser = createBrowser([p1.page]);
const connect = vi.spyOn(chromiumMock, "connectOverCDP");
connect.mockResolvedValue(browser);
await snapshotAiViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
});
await clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
});
expect(connect).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,65 @@
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,
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,90 @@
import { describe, expect, it } from "vitest";
import {
buildRoleSnapshotFromAiSnapshot,
buildRoleSnapshotFromAriaSnapshot,
getRoleSnapshotStats,
parseRoleRef,
} from "./pw-role-snapshot.js";
describe("pw-role-snapshot", () => {
it("adds refs for interactive elements", () => {
const aria = [
'- heading "Example" [level=1]',
"- paragraph: hello",
'- button "Submit"',
" - generic",
'- link "Learn more"',
].join("\n");
const res = buildRoleSnapshotFromAriaSnapshot(aria, { interactive: true });
expect(res.snapshot).toContain("[ref=e1]");
expect(res.snapshot).toContain("[ref=e2]");
expect(res.snapshot).toContain('- button "Submit" [ref=e1]');
expect(res.snapshot).toContain('- link "Learn more" [ref=e2]');
expect(Object.keys(res.refs)).toEqual(["e1", "e2"]);
expect(res.refs.e1).toMatchObject({ role: "button", name: "Submit" });
expect(res.refs.e2).toMatchObject({ role: "link", name: "Learn more" });
});
it("uses nth only when duplicates exist", () => {
const aria = ['- button "OK"', '- button "OK"', '- button "Cancel"'].join("\n");
const res = buildRoleSnapshotFromAriaSnapshot(aria);
expect(res.snapshot).toContain("[ref=e1]");
expect(res.snapshot).toContain("[ref=e2] [nth=1]");
expect(res.refs.e1?.nth).toBe(0);
expect(res.refs.e2?.nth).toBe(1);
expect(res.refs.e3?.nth).toBeUndefined();
});
it("respects maxDepth", () => {
const aria = ['- region "Main"', " - group", ' - button "Deep"'].join("\n");
const res = buildRoleSnapshotFromAriaSnapshot(aria, { maxDepth: 1 });
expect(res.snapshot).toContain('- region "Main"');
expect(res.snapshot).toContain(" - group");
expect(res.snapshot).not.toContain("button");
});
it("computes stats", () => {
const aria = ['- button "OK"', '- button "Cancel"'].join("\n");
const res = buildRoleSnapshotFromAriaSnapshot(aria);
const stats = getRoleSnapshotStats(res.snapshot, res.refs);
expect(stats.refs).toBe(2);
expect(stats.interactive).toBe(2);
expect(stats.lines).toBeGreaterThan(0);
expect(stats.chars).toBeGreaterThan(0);
});
it("returns a helpful message when no interactive elements exist", () => {
const aria = ['- heading "Hello"', "- paragraph: world"].join("\n");
const res = buildRoleSnapshotFromAriaSnapshot(aria, { interactive: true });
expect(res.snapshot).toBe("(no interactive elements)");
expect(Object.keys(res.refs)).toEqual([]);
});
it("parses role refs", () => {
expect(parseRoleRef("e12")).toBe("e12");
expect(parseRoleRef("@e12")).toBe("e12");
expect(parseRoleRef("ref=e12")).toBe("e12");
expect(parseRoleRef("12")).toBeNull();
expect(parseRoleRef("")).toBeNull();
});
it("preserves Playwright aria-ref ids in ai snapshots", () => {
const ai = [
"- navigation [ref=e1]:",
' - link "Home" [ref=e5]',
' - heading "Title" [ref=e6]',
' - button "Save" [ref=e7] [cursor=pointer]:',
" - paragraph: hello",
].join("\n");
const res = buildRoleSnapshotFromAiSnapshot(ai, { interactive: true });
expect(res.snapshot).toContain("[ref=e5]");
expect(res.snapshot).toContain('- link "Home"');
expect(res.snapshot).toContain('- button "Save"');
expect(res.snapshot).not.toContain("navigation");
expect(res.snapshot).not.toContain("heading");
expect(Object.keys(res.refs).toSorted()).toEqual(["e5", "e7"]);
expect(res.refs.e5).toMatchObject({ role: "link", name: "Home" });
expect(res.refs.e7).toMatchObject({ role: "button", name: "Save" });
});
});

View File

@@ -0,0 +1,454 @@
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;
};
const INTERACTIVE_ROLES = new Set([
"button",
"link",
"textbox",
"checkbox",
"radio",
"combobox",
"listbox",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"searchbox",
"slider",
"spinbutton",
"switch",
"tab",
"treeitem",
]);
const CONTENT_ROLES = new Set([
"heading",
"cell",
"gridcell",
"columnheader",
"rowheader",
"listitem",
"article",
"region",
"main",
"navigation",
]);
const STRUCTURAL_ROLES = new Set([
"generic",
"group",
"list",
"table",
"row",
"rowgroup",
"grid",
"treegrid",
"menu",
"menubar",
"toolbar",
"tablist",
"tree",
"directory",
"document",
"application",
"presentation",
"none",
]);
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,45 @@
import { describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST);
const CDP_URL = process.env.OPENCLAW_LIVE_BROWSER_CDP_URL?.trim() || "";
const describeLive = LIVE && CDP_URL ? describe : describe.skip;
async function waitFor(
fn: () => Promise<boolean>,
opts: { timeoutMs: number; intervalMs: number },
): Promise<void> {
await expect.poll(fn, { timeout: opts.timeoutMs, interval: opts.intervalMs }).toBe(true);
}
describeLive("browser (live): remote CDP tab persistence", () => {
it("creates, lists, focuses, and closes tabs via Playwright", { timeout: 60_000 }, async () => {
const pw = await import("./pw-ai.js");
await pw.closePlaywrightBrowserConnection().catch(() => {});
const created = await pw.createPageViaPlaywright({ cdpUrl: CDP_URL, url: "about:blank" });
try {
await waitFor(
async () => {
const pages = await pw.listPagesViaPlaywright({ cdpUrl: CDP_URL });
return pages.some((p) => p.targetId === created.targetId);
},
{ timeoutMs: 10_000, intervalMs: 250 },
);
await pw.focusPageByTargetIdViaPlaywright({ cdpUrl: CDP_URL, targetId: created.targetId });
await pw.closePageByTargetIdViaPlaywright({ cdpUrl: CDP_URL, targetId: created.targetId });
await waitFor(
async () => {
const pages = await pw.listPagesViaPlaywright({ cdpUrl: CDP_URL });
return !pages.some((p) => p.targetId === created.targetId);
},
{ timeoutMs: 10_000, intervalMs: 250 },
);
} finally {
await pw.closePlaywrightBrowserConnection().catch(() => {});
}
});
});

View File

@@ -0,0 +1,87 @@
import { chromium } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as chromeModule from "./chrome.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import { closePlaywrightBrowserConnection, createPageViaPlaywright } from "./pw-session.js";
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
function installBrowserMocks() {
const pageOn = vi.fn();
const pageGoto = vi.fn(async () => {});
const pageTitle = vi.fn(async () => "");
const pageUrl = vi.fn(() => "about:blank");
const contextOn = vi.fn();
const browserOn = vi.fn();
const browserClose = vi.fn(async () => {});
const sessionSend = vi.fn(async (method: string) => {
if (method === "Target.getTargetInfo") {
return { targetInfo: { targetId: "TARGET_1" } };
}
return {};
});
const sessionDetach = vi.fn(async () => {});
const context = {
pages: () => [],
on: contextOn,
newPage: vi.fn(async () => page),
newCDPSession: vi.fn(async () => ({
send: sessionSend,
detach: sessionDetach,
})),
} as unknown as import("playwright-core").BrowserContext;
const page = {
on: pageOn,
context: () => context,
goto: pageGoto,
title: pageTitle,
url: pageUrl,
} as unknown as import("playwright-core").Page;
const browser = {
contexts: () => [context],
on: browserOn,
close: browserClose,
} as unknown as import("playwright-core").Browser;
connectOverCdpSpy.mockResolvedValue(browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
return { pageGoto, browserClose };
}
afterEach(async () => {
connectOverCdpSpy.mockClear();
getChromeWebSocketUrlSpy.mockClear();
await closePlaywrightBrowserConnection().catch(() => {});
});
describe("pw-session createPageViaPlaywright navigation guard", () => {
it("blocks unsupported non-network URLs", async () => {
const { pageGoto } = installBrowserMocks();
await expect(
createPageViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
url: "file:///etc/passwd",
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
expect(pageGoto).not.toHaveBeenCalled();
});
it("allows about:blank without network navigation", async () => {
const { pageGoto } = installBrowserMocks();
const created = await createPageViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
url: "about:blank",
});
expect(created.targetId).toBe("TARGET_1");
expect(pageGoto).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,53 @@
import { chromium } from "playwright-core";
import { describe, expect, it, vi } from "vitest";
import * as chromeModule from "./chrome.js";
import { closePlaywrightBrowserConnection, getPageForTargetId } from "./pw-session.js";
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
describe("pw-session getPageForTargetId", () => {
it("falls back to the only page when CDP session attachment is blocked (extension relays)", async () => {
connectOverCdpSpy.mockClear();
getChromeWebSocketUrlSpy.mockClear();
const pageOn = vi.fn();
const contextOn = vi.fn();
const browserOn = vi.fn();
const browserClose = vi.fn(async () => {});
const context = {
pages: () => [],
on: contextOn,
newCDPSession: vi.fn(async () => {
throw new Error("Not allowed");
}),
} as unknown as import("playwright-core").BrowserContext;
const page = {
on: pageOn,
context: () => context,
} as unknown as import("playwright-core").Page;
// Fill pages() after page exists.
(context as unknown as { pages: () => unknown[] }).pages = () => [page];
const browser = {
contexts: () => [context],
on: browserOn,
close: browserClose,
} as unknown as import("playwright-core").Browser;
connectOverCdpSpy.mockResolvedValue(browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
const resolved = await getPageForTargetId({
cdpUrl: "http://127.0.0.1:18792",
targetId: "NOT_A_TAB",
});
expect(resolved).toBe(page);
await closePlaywrightBrowserConnection();
expect(browserClose).toHaveBeenCalled();
});
});

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,141 @@
import type { Page } from "playwright-core";
import { describe, expect, it, vi } from "vitest";
import {
ensurePageState,
refLocator,
rememberRoleRefsForTarget,
restoreRoleRefsForTarget,
} from "./pw-session.js";
function fakePage(): {
page: Page;
handlers: Map<string, Array<(...args: unknown[]) => void>>;
mocks: {
on: ReturnType<typeof vi.fn>;
getByRole: ReturnType<typeof vi.fn>;
frameLocator: ReturnType<typeof vi.fn>;
locator: ReturnType<typeof vi.fn>;
};
} {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
const on = vi.fn((event: string, cb: (...args: unknown[]) => void) => {
const list = handlers.get(event) ?? [];
list.push(cb);
handlers.set(event, list);
return undefined as unknown;
});
const getByRole = vi.fn(() => ({ nth: vi.fn(() => ({ ok: true })) }));
const frameLocator = vi.fn(() => ({
getByRole: vi.fn(() => ({ nth: vi.fn(() => ({ ok: true })) })),
}));
const locator = vi.fn(() => ({ nth: vi.fn(() => ({ ok: true })) }));
const page = {
on,
getByRole,
frameLocator,
locator,
} as unknown as Page;
return { page, handlers, mocks: { on, getByRole, frameLocator, locator } };
}
describe("pw-session refLocator", () => {
it("uses frameLocator for role refs when snapshot was scoped to a frame", () => {
const { page, mocks } = fakePage();
const state = ensurePageState(page);
state.roleRefs = { e1: { role: "button", name: "OK" } };
state.roleRefsFrameSelector = "iframe#main";
refLocator(page, "e1");
expect(mocks.frameLocator).toHaveBeenCalledWith("iframe#main");
});
it("uses page getByRole for role refs by default", () => {
const { page, mocks } = fakePage();
const state = ensurePageState(page);
state.roleRefs = { e1: { role: "button", name: "OK" } };
refLocator(page, "e1");
expect(mocks.getByRole).toHaveBeenCalled();
});
it("uses aria-ref locators when refs mode is aria", () => {
const { page, mocks } = fakePage();
const state = ensurePageState(page);
state.roleRefsMode = "aria";
refLocator(page, "e1");
expect(mocks.locator).toHaveBeenCalledWith("aria-ref=e1");
});
});
describe("pw-session role refs cache", () => {
it("restores refs for a different Page instance (same CDP targetId)", () => {
const cdpUrl = "http://127.0.0.1:9222";
const targetId = "t1";
rememberRoleRefsForTarget({
cdpUrl,
targetId,
refs: { e1: { role: "button", name: "OK" } },
frameSelector: "iframe#main",
});
const { page, mocks } = fakePage();
restoreRoleRefsForTarget({ cdpUrl, targetId, page });
refLocator(page, "e1");
expect(mocks.frameLocator).toHaveBeenCalledWith("iframe#main");
});
});
describe("pw-session ensurePageState", () => {
it("tracks page errors and network requests (best-effort)", () => {
const { page, handlers } = fakePage();
const state = ensurePageState(page);
const req = {
method: () => "GET",
url: () => "https://example.com/api",
resourceType: () => "xhr",
failure: () => ({ errorText: "net::ERR_FAILED" }),
} as unknown as import("playwright-core").Request;
const resp = {
request: () => req,
status: () => 500,
ok: () => false,
} as unknown as import("playwright-core").Response;
handlers.get("request")?.[0]?.(req);
handlers.get("response")?.[0]?.(resp);
handlers.get("requestfailed")?.[0]?.(req);
handlers.get("pageerror")?.[0]?.(new Error("boom"));
expect(state.errors.at(-1)?.message).toBe("boom");
expect(state.requests.at(-1)).toMatchObject({
method: "GET",
url: "https://example.com/api",
resourceType: "xhr",
status: 500,
ok: false,
failureText: "net::ERR_FAILED",
});
});
it("drops state on page close", () => {
const { page, handlers } = fakePage();
const state1 = ensurePageState(page);
handlers.get("close")?.[0]?.();
const state2 = ensurePageState(page);
expect(state2).not.toBe(state1);
expect(state2.console).toEqual([]);
expect(state2.errors).toEqual([]);
expect(state2.requests).toEqual([]);
});
});

View File

@@ -0,0 +1,815 @@
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 { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import { getChromeWebSocketUrl } from "./chrome.js";
import {
assertBrowserNavigationAllowed,
assertBrowserNavigationResultAllowed,
withBrowserNavigationPolicy,
} from "./navigation-guard.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;
let cached: ConnectedBrowser | null = null;
let connecting: Promise<ConnectedBrowser> | null = null;
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);
if (cached?.cdpUrl === normalized) {
return cached;
}
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);
const browser = await chromium.connectOverCDP(endpoint, { timeout, headers });
const onDisconnected = () => {
if (cached?.browser === browser) {
cached = null;
}
};
const connected: ConnectedBrowser = { browser, cdpUrl: normalized, onDisconnected };
cached = connected;
browser.on("disconnected", onDisconnected);
observeBrowser(browser);
return connected;
} catch (err) {
lastErr = err;
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);
};
connecting = connectWithRetry().finally(() => {
connecting = null;
});
return await connecting;
}
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(() => {});
}
}
async function findPageByTargetId(
browser: Browser,
targetId: string,
cdpUrl?: string,
): Promise<Page | null> {
const pages = await getAllPages(browser);
let resolvedViaCdp = false;
// First, try the standard CDP session approach
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;
}
}
// Extension relays can block CDP attachment APIs entirely. If that happens and
// Playwright only exposes one page, return it as the best available mapping.
if (!resolvedViaCdp && pages.length === 1) {
return pages[0];
}
// If CDP sessions fail (e.g., extension relay blocks Target.attachToBrowserTarget),
// fall back to URL-based matching using the /json/list endpoint
if (cdpUrl) {
try {
const baseUrl = cdpUrl
.replace(/\/+$/, "")
.replace(/^ws:/, "http:")
.replace(/\/cdp$/, "");
const listUrl = `${baseUrl}/json/list`;
const response = await fetch(listUrl, { headers: getHeadersWithAuth(listUrl) });
if (response.ok) {
const targets = (await response.json()) as Array<{
id: string;
url: string;
title?: string;
}>;
const target = targets.find((t) => t.id === targetId);
if (target) {
// Try to find a page with matching URL
const urlMatch = pages.filter((p) => p.url() === target.url);
if (urlMatch.length === 1) {
return urlMatch[0];
}
// If multiple URL matches, use index-based matching as fallback
// This works when Playwright and the relay enumerate tabs in the same order
if (urlMatch.length > 1) {
const sameUrlTargets = targets.filter((t) => t.url === target.url);
if (sameUrlTargets.length === urlMatch.length) {
const idx = sameUrlTargets.findIndex((t) => t.id === targetId);
if (idx >= 0 && idx < urlMatch.length) {
return urlMatch[idx];
}
}
}
}
}
} catch {
// Ignore fetch errors and fall through to return null
}
}
return null;
}
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) {
// Extension relays can block CDP attachment APIs (e.g. Target.attachToBrowserTarget),
// which prevents us from resolving a page's targetId via newCDPSession(). If Playwright
// only exposes a single Page, use it as a best-effort fallback.
if (pages.length === 1) {
return first;
}
throw new Error("tab not found");
}
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(): Promise<void> {
const cur = cached;
cached = null;
connecting = null;
if (!cur) {
return;
}
if (cur.onDisconnected && typeof cur.browser.off === "function") {
cur.browser.off("disconnected", cur.onDisconnected);
}
await cur.browser.close().catch(() => {});
}
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 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);
if (cached?.cdpUrl !== normalized) {
return;
}
const cur = cached;
cached = null;
// Also clear `connecting` so the next call does a fresh connectOverCDP
// rather than awaiting a stale promise.
connecting = null;
if (cur) {
// Remove the "disconnected" listener to prevent the old browser's teardown
// from racing with a fresh connection and nulling the new `cached`.
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,
});
await page.goto(targetUrl, { timeout: 30_000 }).catch(() => {
// Navigation might fail for some URLs, but page is still created
});
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 { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) {
throw new Error("tab not found");
}
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 { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) {
throw new Error("tab not found");
}
try {
await page.bringToFront();
} catch (err) {
const session = await page.context().newCDPSession(page);
try {
await session.send("Page.bringToFront");
return;
} catch {
throw err;
} finally {
await session.detach().catch(() => {});
}
}
}

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,95 @@
import { describe, expect, it, vi } from "vitest";
import {
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
setPwToolsCoreCurrentRefLocator,
} from "./pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
const mod = await import("./pw-tools-core.js");
describe("pw-tools-core", () => {
it("clamps timeoutMs for scrollIntoView", async () => {
const scrollIntoViewIfNeeded = vi.fn(async () => {});
setPwToolsCoreCurrentRefLocator({ scrollIntoViewIfNeeded });
setPwToolsCoreCurrentPage({});
await mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
timeoutMs: 50,
});
expect(scrollIntoViewIfNeeded).toHaveBeenCalledWith({ timeout: 500 });
});
it.each([
{
name: "strict mode violations for scrollIntoView",
errorMessage: 'Error: strict mode violation: locator("aria-ref=1") resolved to 2 elements',
expectedMessage: /Run a new snapshot/i,
},
{
name: "not-visible timeouts for scrollIntoView",
errorMessage: 'Timeout 5000ms exceeded. waiting for locator("aria-ref=1") to be visible',
expectedMessage: /not found or not visible/i,
},
])("rewrites $name", async ({ errorMessage, expectedMessage }) => {
const scrollIntoViewIfNeeded = vi.fn(async () => {
throw new Error(errorMessage);
});
setPwToolsCoreCurrentRefLocator({ scrollIntoViewIfNeeded });
setPwToolsCoreCurrentPage({});
await expect(
mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
}),
).rejects.toThrow(expectedMessage);
});
it.each([
{
name: "strict mode violations into snapshot hints",
errorMessage: 'Error: strict mode violation: locator("aria-ref=1") resolved to 2 elements',
expectedMessage: /Run a new snapshot/i,
},
{
name: "not-visible timeouts into snapshot hints",
errorMessage: 'Timeout 5000ms exceeded. waiting for locator("aria-ref=1") to be visible',
expectedMessage: /not found or not visible/i,
},
])("rewrites $name", async ({ errorMessage, expectedMessage }) => {
const click = vi.fn(async () => {
throw new Error(errorMessage);
});
setPwToolsCoreCurrentRefLocator({ click });
setPwToolsCoreCurrentPage({});
await expect(
mod.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
}),
).rejects.toThrow(expectedMessage);
});
it("rewrites covered/hidden errors into interactable hints", async () => {
const click = vi.fn(async () => {
throw new Error(
"Element is not receiving pointer events because another element intercepts pointer events",
);
});
setPwToolsCoreCurrentRefLocator({ click });
setPwToolsCoreCurrentPage({});
await expect(
mod.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
}),
).rejects.toThrow(/not interactable/i);
});
});

View File

@@ -0,0 +1,294 @@
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 { 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";
function sanitizeDownloadFileName(fileName: string): string {
const trimmed = String(fileName ?? "").trim();
if (!trimmed) {
return "download.bin";
}
// `suggestedFilename()` is untrusted (influenced by remote servers). Force a basename so
// path separators/traversal can't escape the downloads dir on any platform.
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 "download.bin";
}
if (base.length > 200) {
base = base.slice(0, 200);
}
return base;
}
function buildTempDownloadPath(fileName: string): string {
const id = crypto.randomUUID();
const safeName = sanitizeDownloadFileName(fileName);
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 resolvedOutPath = outPath?.trim() || buildTempDownloadPath(suggested);
await fs.mkdir(path.dirname(resolvedOutPath), { recursive: true });
await download.saveAs?.(resolvedOutPath);
return {
url: download.url?.() || "",
suggestedFilename: suggested,
path: path.resolve(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,92 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
let page: { evaluate: ReturnType<typeof vi.fn> } | null = null;
let locator: { evaluate: ReturnType<typeof vi.fn> } | null = null;
const forceDisconnectPlaywrightForTarget = vi.fn(async () => {});
const getPageForTargetId = vi.fn(async () => {
if (!page) {
throw new Error("test: page not set");
}
return page;
});
const ensurePageState = vi.fn(() => {});
const restoreRoleRefsForTarget = vi.fn(() => {});
const refLocator = vi.fn(() => {
if (!locator) {
throw new Error("test: locator not set");
}
return locator;
});
vi.mock("./pw-session.js", () => {
return {
ensurePageState,
forceDisconnectPlaywrightForTarget,
getPageForTargetId,
refLocator,
restoreRoleRefsForTarget,
};
});
let evaluateViaPlaywright: typeof import("./pw-tools-core.interactions.js").evaluateViaPlaywright;
function createPendingEval() {
let evalCalled!: () => void;
const evalCalledPromise = new Promise<void>((resolve) => {
evalCalled = resolve;
});
return {
evalCalledPromise,
resolveEvalCalled: evalCalled,
};
}
describe("evaluateViaPlaywright (abort)", () => {
beforeAll(async () => {
({ evaluateViaPlaywright } = await import("./pw-tools-core.interactions.js"));
});
beforeEach(() => {
vi.clearAllMocks();
});
it.each([
{ label: "page.evaluate", fn: "() => 1" },
{ label: "locator.evaluate", fn: "(el) => el.textContent", ref: "e1" },
])("rejects when aborted after $label starts", async ({ fn, ref }) => {
const ctrl = new AbortController();
const pending = createPendingEval();
const pendingPromise = new Promise(() => {});
page = {
evaluate: vi.fn(() => {
if (!ref) {
pending.resolveEvalCalled();
}
return pendingPromise;
}),
};
locator = {
evaluate: vi.fn(() => {
if (ref) {
pending.resolveEvalCalled();
}
return pendingPromise;
}),
};
const p = evaluateViaPlaywright({
cdpUrl: "http://127.0.0.1:9222",
fn,
ref,
signal: ctrl.signal,
});
await pending.evalCalledPromise;
ctrl.abort(new Error("aborted by test"));
await expect(p).rejects.toThrow("aborted by test");
expect(forceDisconnectPlaywrightForTarget).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,111 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
let page: Record<string, unknown> | null = null;
let locator: Record<string, unknown> | null = null;
const getPageForTargetId = vi.fn(async () => {
if (!page) {
throw new Error("test: page not set");
}
return page;
});
const ensurePageState = vi.fn(() => ({}));
const restoreRoleRefsForTarget = vi.fn(() => {});
const refLocator = vi.fn(() => {
if (!locator) {
throw new Error("test: locator not set");
}
return locator;
});
const forceDisconnectPlaywrightForTarget = vi.fn(async () => {});
const resolveStrictExistingPathsWithinRoot =
vi.fn<typeof import("./paths.js").resolveStrictExistingPathsWithinRoot>();
vi.mock("./pw-session.js", () => {
return {
ensurePageState,
forceDisconnectPlaywrightForTarget,
getPageForTargetId,
refLocator,
restoreRoleRefsForTarget,
};
});
vi.mock("./paths.js", () => {
return {
DEFAULT_UPLOAD_DIR: "/tmp/openclaw/uploads",
resolveStrictExistingPathsWithinRoot,
};
});
let setInputFilesViaPlaywright: typeof import("./pw-tools-core.interactions.js").setInputFilesViaPlaywright;
describe("setInputFilesViaPlaywright", () => {
beforeAll(async () => {
({ setInputFilesViaPlaywright } = await import("./pw-tools-core.interactions.js"));
});
beforeEach(() => {
vi.clearAllMocks();
page = null;
locator = null;
resolveStrictExistingPathsWithinRoot.mockResolvedValue({
ok: true,
paths: ["/private/tmp/openclaw/uploads/ok.txt"],
});
});
it("revalidates upload paths and uses resolved canonical paths for inputRef", async () => {
const setInputFiles = vi.fn(async () => {});
locator = {
setInputFiles,
elementHandle: vi.fn(async () => null),
};
page = {
locator: vi.fn(() => ({ first: () => locator })),
};
await setInputFilesViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
inputRef: "e7",
paths: ["/tmp/openclaw/uploads/ok.txt"],
});
expect(resolveStrictExistingPathsWithinRoot).toHaveBeenCalledWith({
rootDir: "/tmp/openclaw/uploads",
requestedPaths: ["/tmp/openclaw/uploads/ok.txt"],
scopeLabel: "uploads directory (/tmp/openclaw/uploads)",
});
expect(refLocator).toHaveBeenCalledWith(page, "e7");
expect(setInputFiles).toHaveBeenCalledWith(["/private/tmp/openclaw/uploads/ok.txt"]);
});
it("throws and skips setInputFiles when use-time validation fails", async () => {
resolveStrictExistingPathsWithinRoot.mockResolvedValueOnce({
ok: false,
error: "Invalid path: must stay within uploads directory",
});
const setInputFiles = vi.fn(async () => {});
locator = {
setInputFiles,
elementHandle: vi.fn(async () => null),
};
page = {
locator: vi.fn(() => ({ first: () => locator })),
};
await expect(
setInputFilesViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
element: "input[type=file]",
paths: ["/tmp/openclaw/uploads/missing.txt"],
}),
).rejects.toThrow("Invalid path: must stay within uploads directory");
expect(setInputFiles).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,657 @@
import type { 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, toAIFriendlyError } from "./pw-tools-core.shared.js";
export async function highlightViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
ref: string;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
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;
doubleClick?: boolean;
button?: "left" | "right" | "middle";
modifiers?: Array<"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift">;
timeoutMs?: number;
}): Promise<void> {
const page = await getPageForTargetId({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
});
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
const ref = requireRef(opts.ref);
const locator = refLocator(page, ref);
const timeout = Math.max(500, Math.min(60_000, Math.floor(opts.timeoutMs ?? 8000)));
try {
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, ref);
}
}
export async function hoverViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
ref: string;
timeoutMs?: number;
}): Promise<void> {
const ref = requireRef(opts.ref);
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
try {
await refLocator(page, ref).hover({
timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)),
});
} catch (err) {
throw toAIFriendlyError(err, ref);
}
}
export async function dragViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
startRef: string;
endRef: string;
timeoutMs?: number;
}): Promise<void> {
const startRef = requireRef(opts.startRef);
const endRef = requireRef(opts.endRef);
if (!startRef || !endRef) {
throw new Error("startRef and endRef are required");
}
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
try {
await refLocator(page, startRef).dragTo(refLocator(page, endRef), {
timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)),
});
} catch (err) {
throw toAIFriendlyError(err, `${startRef} -> ${endRef}`);
}
}
export async function selectOptionViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
ref: string;
values: string[];
timeoutMs?: number;
}): Promise<void> {
const ref = requireRef(opts.ref);
if (!opts.values?.length) {
throw new Error("values are required");
}
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
try {
await refLocator(page, ref).selectOption(opts.values, {
timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)),
});
} catch (err) {
throw toAIFriendlyError(err, ref);
}
}
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;
text: string;
submit?: boolean;
slowly?: boolean;
timeoutMs?: number;
}): Promise<void> {
const text = String(opts.text ?? "");
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
const ref = requireRef(opts.ref);
const locator = refLocator(page, ref);
const timeout = Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000));
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, ref);
}
}
export async function fillFormViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
fields: BrowserFormField[];
timeoutMs?: number;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
const timeout = Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000));
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 getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
// 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,
});
if (!abortPromise) {
return await evalPromise;
}
try {
return await Promise.race([evalPromise, abortPromise]);
} catch (err) {
// If abort wins the race, the underlying evaluate may reject later; ensure we don't
// surface it as an unhandled rejection.
void evalPromise.catch(() => {});
throw err;
}
}
// 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,
});
if (!abortPromise) {
return await evalPromise;
}
try {
return await Promise.race([evalPromise, abortPromise]);
} catch (err) {
void evalPromise.catch(() => {});
throw err;
}
} finally {
if (signal && abortListener) {
signal.removeEventListener("abort", abortListener);
}
}
}
export async function scrollIntoViewViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
ref: string;
timeoutMs?: number;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
const ref = requireRef(opts.ref);
const locator = refLocator(page, ref);
try {
await locator.scrollIntoViewIfNeeded({ timeout });
} catch (err) {
throw toAIFriendlyError(err, ref);
}
}
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(Math.max(0, opts.timeMs));
}
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.
}
}

View File

@@ -0,0 +1,153 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_UPLOAD_DIR } from "./paths.js";
import {
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
} from "./pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
const mod = await import("./pw-tools-core.js");
describe("pw-tools-core", () => {
it("last file-chooser arm wins", async () => {
const firstPath = path.join(DEFAULT_UPLOAD_DIR, `vitest-arm-1-${crypto.randomUUID()}.txt`);
const secondPath = path.join(DEFAULT_UPLOAD_DIR, `vitest-arm-2-${crypto.randomUUID()}.txt`);
await fs.mkdir(DEFAULT_UPLOAD_DIR, { recursive: true });
await Promise.all([
fs.writeFile(firstPath, "1", "utf8"),
fs.writeFile(secondPath, "2", "utf8"),
]);
const secondCanonicalPath = await fs.realpath(secondPath);
let resolve1: ((value: unknown) => void) | null = null;
let resolve2: ((value: unknown) => void) | null = null;
const fc1 = { setFiles: vi.fn(async () => {}) };
const fc2 = { setFiles: vi.fn(async () => {}) };
const waitForEvent = vi
.fn()
.mockImplementationOnce(
() =>
new Promise((r) => {
resolve1 = r;
}),
)
.mockImplementationOnce(
() =>
new Promise((r) => {
resolve2 = r;
}),
);
setPwToolsCoreCurrentPage({
waitForEvent,
keyboard: { press: vi.fn(async () => {}) },
});
try {
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
paths: [firstPath],
});
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
paths: [secondPath],
});
if (!resolve1 || !resolve2) {
throw new Error("file chooser handlers were not registered");
}
(resolve1 as (value: unknown) => void)(fc1);
(resolve2 as (value: unknown) => void)(fc2);
await Promise.resolve();
expect(fc1.setFiles).not.toHaveBeenCalled();
await vi.waitFor(() => {
expect(fc2.setFiles).toHaveBeenCalledWith([secondCanonicalPath]);
});
} finally {
await Promise.all([fs.rm(firstPath, { force: true }), fs.rm(secondPath, { force: true })]);
}
});
it("arms the next dialog and accepts/dismisses (default timeout)", async () => {
const accept = vi.fn(async () => {});
const dismiss = vi.fn(async () => {});
const dialog = { accept, dismiss };
const waitForEvent = vi.fn(async () => dialog);
setPwToolsCoreCurrentPage({
waitForEvent,
});
await mod.armDialogViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
accept: true,
promptText: "x",
});
await Promise.resolve();
expect(waitForEvent).toHaveBeenCalledWith("dialog", { timeout: 120_000 });
expect(accept).toHaveBeenCalledWith("x");
expect(dismiss).not.toHaveBeenCalled();
accept.mockClear();
dismiss.mockClear();
waitForEvent.mockClear();
await mod.armDialogViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
accept: false,
});
await Promise.resolve();
expect(waitForEvent).toHaveBeenCalledWith("dialog", { timeout: 120_000 });
expect(dismiss).toHaveBeenCalled();
expect(accept).not.toHaveBeenCalled();
});
it("waits for selector, url, load state, and function", async () => {
const waitForSelector = vi.fn(async () => {});
const waitForURL = vi.fn(async () => {});
const waitForLoadState = vi.fn(async () => {});
const waitForFunction = vi.fn(async () => {});
const waitForTimeout = vi.fn(async () => {});
const page = {
locator: vi.fn(() => ({
first: () => ({ waitFor: waitForSelector }),
})),
waitForURL,
waitForLoadState,
waitForFunction,
waitForTimeout,
getByText: vi.fn(() => ({ first: () => ({ waitFor: vi.fn() }) })),
};
setPwToolsCoreCurrentPage(page);
await mod.waitForViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
selector: "#main",
url: "**/dash",
loadState: "networkidle",
fn: "window.ready===true",
timeoutMs: 1234,
timeMs: 50,
});
expect(waitForTimeout).toHaveBeenCalledWith(50);
expect(page.locator as ReturnType<typeof vi.fn>).toHaveBeenCalledWith("#main");
expect(waitForSelector).toHaveBeenCalledWith({
state: "visible",
timeout: 1234,
});
expect(waitForURL).toHaveBeenCalledWith("**/dash", { timeout: 1234 });
expect(waitForLoadState).toHaveBeenCalledWith("networkidle", {
timeout: 1234,
});
expect(waitForFunction).toHaveBeenCalledWith("window.ready===true", {
timeout: 1234,
});
});
});

View File

@@ -0,0 +1,123 @@
import { formatCliCommand } from "../cli/command-format.js";
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
import { normalizeTimeoutMs } from "./pw-tools-core.shared.js";
function matchUrlPattern(pattern: string, url: string): boolean {
const p = pattern.trim();
if (!p) {
return false;
}
if (p === url) {
return true;
}
if (p.includes("*")) {
const escaped = p.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
const regex = new RegExp(`^${escaped.replace(/\*\*/g, ".*").replace(/\*/g, ".*")}$`);
return regex.test(url);
}
return url.includes(p);
}
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 (!matchUrlPattern(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,159 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_UPLOAD_DIR } from "./paths.js";
import {
getPwToolsCoreSessionMocks,
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
setPwToolsCoreCurrentRefLocator,
} from "./pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
const sessionMocks = getPwToolsCoreSessionMocks();
const mod = await import("./pw-tools-core.js");
describe("pw-tools-core", () => {
it("screenshots an element selector", async () => {
const elementScreenshot = vi.fn(async () => Buffer.from("E"));
const page = {
locator: vi.fn(() => ({
first: () => ({ screenshot: elementScreenshot }),
})),
screenshot: vi.fn(async () => Buffer.from("P")),
};
setPwToolsCoreCurrentPage(page);
const res = await mod.takeScreenshotViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
element: "#main",
type: "png",
});
expect(res.buffer.toString()).toBe("E");
expect(sessionMocks.getPageForTargetId).toHaveBeenCalled();
expect(page.locator as ReturnType<typeof vi.fn>).toHaveBeenCalledWith("#main");
expect(elementScreenshot).toHaveBeenCalledWith({ type: "png" });
});
it("screenshots a ref locator", async () => {
const refScreenshot = vi.fn(async () => Buffer.from("R"));
setPwToolsCoreCurrentRefLocator({ screenshot: refScreenshot });
const page = {
locator: vi.fn(),
screenshot: vi.fn(async () => Buffer.from("P")),
};
setPwToolsCoreCurrentPage(page);
const res = await mod.takeScreenshotViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "76",
type: "jpeg",
});
expect(res.buffer.toString()).toBe("R");
expect(sessionMocks.refLocator).toHaveBeenCalledWith(page, "76");
expect(refScreenshot).toHaveBeenCalledWith({ type: "jpeg" });
});
it("rejects fullPage for element or ref screenshots", async () => {
setPwToolsCoreCurrentRefLocator({ screenshot: vi.fn(async () => Buffer.from("R")) });
setPwToolsCoreCurrentPage({
locator: vi.fn(() => ({
first: () => ({ screenshot: vi.fn(async () => Buffer.from("E")) }),
})),
screenshot: vi.fn(async () => Buffer.from("P")),
});
await expect(
mod.takeScreenshotViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
element: "#x",
fullPage: true,
}),
).rejects.toThrow(/fullPage is not supported/i);
await expect(
mod.takeScreenshotViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
fullPage: true,
}),
).rejects.toThrow(/fullPage is not supported/i);
});
it("arms the next file chooser and sets files (default timeout)", async () => {
const uploadPath = path.join(DEFAULT_UPLOAD_DIR, `vitest-upload-${crypto.randomUUID()}.txt`);
await fs.mkdir(path.dirname(uploadPath), { recursive: true });
await fs.writeFile(uploadPath, "fixture", "utf8");
const canonicalUploadPath = await fs.realpath(uploadPath);
const fileChooser = { setFiles: vi.fn(async () => {}) };
const waitForEvent = vi.fn(async (_event: string, _opts: unknown) => fileChooser);
setPwToolsCoreCurrentPage({
waitForEvent,
keyboard: { press: vi.fn(async () => {}) },
});
try {
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
paths: [uploadPath],
});
// waitForEvent is awaited immediately; handler continues async.
await Promise.resolve();
expect(waitForEvent).toHaveBeenCalledWith("filechooser", {
timeout: 120_000,
});
await vi.waitFor(() => {
expect(fileChooser.setFiles).toHaveBeenCalledWith([canonicalUploadPath]);
});
} finally {
await fs.rm(uploadPath, { force: true });
}
});
it("revalidates file-chooser paths at use-time and cancels missing files", async () => {
const missingPath = path.join(DEFAULT_UPLOAD_DIR, `vitest-missing-${crypto.randomUUID()}.txt`);
const fileChooser = { setFiles: vi.fn(async () => {}) };
const press = vi.fn(async () => {});
const waitForEvent = vi.fn(async () => fileChooser);
setPwToolsCoreCurrentPage({
waitForEvent,
keyboard: { press },
});
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
paths: [missingPath],
});
await Promise.resolve();
await vi.waitFor(() => {
expect(press).toHaveBeenCalledWith("Escape");
});
expect(fileChooser.setFiles).not.toHaveBeenCalled();
});
it("arms the next file chooser and escapes if no paths provided", async () => {
const fileChooser = { setFiles: vi.fn(async () => {}) };
const press = vi.fn(async () => {});
const waitForEvent = vi.fn(async () => fileChooser);
setPwToolsCoreCurrentPage({
waitForEvent,
keyboard: { press },
});
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
paths: [],
});
await Promise.resolve();
expect(fileChooser.setFiles).not.toHaveBeenCalled();
expect(press).toHaveBeenCalledWith("Escape");
});
});

View File

@@ -0,0 +1,70 @@
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 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,47 @@
import { describe, expect, it, vi } from "vitest";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import {
getPwToolsCoreSessionMocks,
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
} from "./pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
const mod = await import("./pw-tools-core.snapshot.js");
describe("pw-tools-core.snapshot navigate guard", () => {
it("blocks unsupported non-network URLs before page lookup", async () => {
const goto = vi.fn(async () => {});
setPwToolsCoreCurrentPage({
goto,
url: vi.fn(() => "about:blank"),
});
await expect(
mod.navigateViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
url: "file:///etc/passwd",
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
expect(getPwToolsCoreSessionMocks().getPageForTargetId).not.toHaveBeenCalled();
expect(goto).not.toHaveBeenCalled();
});
it("navigates valid network URLs with clamped timeout", async () => {
const goto = vi.fn(async () => {});
setPwToolsCoreCurrentPage({
goto,
url: vi.fn(() => "https://example.com"),
});
const result = await mod.navigateViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
url: "https://example.com",
timeoutMs: 10,
});
expect(goto).toHaveBeenCalledWith("https://example.com", { timeout: 1000 });
expect(result.url).toBe("https://example.com");
});
});

View File

@@ -0,0 +1,221 @@
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js";
import {
assertBrowserNavigationAllowed,
assertBrowserNavigationResultAllowed,
withBrowserNavigationPolicy,
} from "./navigation-guard.js";
import {
buildRoleSnapshotFromAiSnapshot,
buildRoleSnapshotFromAriaSnapshot,
getRoleSnapshotStats,
type RoleSnapshotOptions,
type RoleRefMap,
} from "./pw-role-snapshot.js";
import {
ensurePageState,
getPageForTargetId,
storeRoleRefsForTarget,
type WithSnapshotForAI,
} from "./pw-session.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 session = await page.context().newCDPSession(page);
try {
await session.send("Accessibility.enable").catch(() => {});
const res = (await session.send("Accessibility.getFullAXTree")) as {
nodes?: RawAXNode[];
};
const nodes = Array.isArray(res?.nodes) ? res.nodes : [];
return { nodes: formatAriaSnapshot(nodes, limit) };
} finally {
await session.detach().catch(() => {});
}
}
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 url = String(opts.url ?? "").trim();
if (!url) {
throw new Error("url is required");
}
await assertBrowserNavigationAllowed({
url,
...withBrowserNavigationPolicy(opts.ssrfPolicy),
});
const page = await getPageForTargetId(opts);
ensurePageState(page);
await page.goto(url, {
timeout: Math.max(1000, Math.min(120_000, opts.timeoutMs ?? 20_000)),
});
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,209 @@
import type { CDPSession, Page } from "playwright-core";
import { devices as playwrightDevices } from "playwright-core";
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
async function withCdpSession<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 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 withCdpSession(page, async (session) => {
try {
await session.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 withCdpSession(page, async (session) => {
try {
await session.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 withCdpSession(page, async (session) => {
if (descriptor.userAgent || descriptor.locale) {
await session.send("Emulation.setUserAgentOverride", {
userAgent: descriptor.userAgent ?? "",
acceptLanguage: descriptor.locale ?? undefined,
});
}
if (descriptor.viewport) {
await session.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 session.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,64 @@
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),
restoreRoleRefsForTarget: 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,37 @@
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 context.tracing.stop({ path: opts.path });
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,225 @@
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getPwToolsCoreSessionMocks,
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
setPwToolsCoreCurrentRefLocator,
} from "./pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
const sessionMocks = getPwToolsCoreSessionMocks();
const tmpDirMocks = vi.hoisted(() => ({
resolvePreferredOpenClawTmpDir: vi.fn(() => "/tmp/openclaw"),
}));
vi.mock("../infra/tmp-openclaw-dir.js", () => tmpDirMocks);
const mod = await import("./pw-tools-core.js");
describe("pw-tools-core", () => {
beforeEach(() => {
for (const fn of Object.values(tmpDirMocks)) {
fn.mockClear();
}
tmpDirMocks.resolvePreferredOpenClawTmpDir.mockReturnValue("/tmp/openclaw");
});
async function waitForImplicitDownloadOutput(params: {
downloadUrl: string;
suggestedFilename: string;
}) {
const harness = createDownloadEventHarness();
const saveAs = vi.fn(async () => {});
const p = mod.waitForDownloadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
timeoutMs: 1000,
});
await Promise.resolve();
harness.trigger({
url: () => params.downloadUrl,
suggestedFilename: () => params.suggestedFilename,
saveAs,
});
const res = await p;
const outPath = (vi.mocked(saveAs).mock.calls as unknown as Array<[string]>)[0]?.[0];
return { res, outPath };
}
function createDownloadEventHarness() {
let downloadHandler: ((download: unknown) => void) | undefined;
const on = vi.fn((event: string, handler: (download: unknown) => void) => {
if (event === "download") {
downloadHandler = handler;
}
});
const off = vi.fn();
setPwToolsCoreCurrentPage({ on, off });
return {
trigger: (download: unknown) => {
downloadHandler?.(download);
},
expectArmed: () => {
expect(downloadHandler).toBeDefined();
},
};
}
it("waits for the next download and saves it", async () => {
const harness = createDownloadEventHarness();
const saveAs = vi.fn(async () => {});
const download = {
url: () => "https://example.com/file.bin",
suggestedFilename: () => "file.bin",
saveAs,
};
const targetPath = path.resolve("/tmp/file.bin");
const p = mod.waitForDownloadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
path: targetPath,
timeoutMs: 1000,
});
await Promise.resolve();
harness.expectArmed();
harness.trigger(download);
const res = await p;
expect(saveAs).toHaveBeenCalledWith(targetPath);
expect(res.path).toBe(targetPath);
});
it("clicks a ref and saves the resulting download", async () => {
const harness = createDownloadEventHarness();
const click = vi.fn(async () => {});
setPwToolsCoreCurrentRefLocator({ click });
const saveAs = vi.fn(async () => {});
const download = {
url: () => "https://example.com/report.pdf",
suggestedFilename: () => "report.pdf",
saveAs,
};
const targetPath = path.resolve("/tmp/report.pdf");
const p = mod.downloadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "e12",
path: targetPath,
timeoutMs: 1000,
});
await Promise.resolve();
harness.expectArmed();
expect(click).toHaveBeenCalledWith({ timeout: 1000 });
harness.trigger(download);
const res = await p;
expect(saveAs).toHaveBeenCalledWith(targetPath);
expect(res.path).toBe(targetPath);
});
it("uses preferred tmp dir when waiting for download without explicit path", async () => {
tmpDirMocks.resolvePreferredOpenClawTmpDir.mockReturnValue("/tmp/openclaw-preferred");
const { res, outPath } = await waitForImplicitDownloadOutput({
downloadUrl: "https://example.com/file.bin",
suggestedFilename: "file.bin",
});
expect(typeof outPath).toBe("string");
const expectedRootedDownloadsDir = path.join(
path.sep,
"tmp",
"openclaw-preferred",
"downloads",
);
const expectedDownloadsTail = `${path.join("tmp", "openclaw-preferred", "downloads")}${path.sep}`;
expect(path.dirname(String(outPath))).toBe(expectedRootedDownloadsDir);
expect(path.basename(String(outPath))).toMatch(/-file\.bin$/);
expect(path.normalize(res.path)).toContain(path.normalize(expectedDownloadsTail));
expect(tmpDirMocks.resolvePreferredOpenClawTmpDir).toHaveBeenCalled();
});
it("sanitizes suggested download filenames to prevent traversal escapes", async () => {
tmpDirMocks.resolvePreferredOpenClawTmpDir.mockReturnValue("/tmp/openclaw-preferred");
const { res, outPath } = await waitForImplicitDownloadOutput({
downloadUrl: "https://example.com/evil",
suggestedFilename: "../../../../etc/passwd",
});
expect(typeof outPath).toBe("string");
expect(path.dirname(String(outPath))).toBe(
path.join(path.sep, "tmp", "openclaw-preferred", "downloads"),
);
expect(path.basename(String(outPath))).toMatch(/-passwd$/);
expect(path.normalize(res.path)).toContain(
path.normalize(`${path.join("tmp", "openclaw-preferred", "downloads")}${path.sep}`),
);
});
it("waits for a matching response and returns its body", async () => {
let responseHandler: ((resp: unknown) => void) | undefined;
const on = vi.fn((event: string, handler: (resp: unknown) => void) => {
if (event === "response") {
responseHandler = handler;
}
});
const off = vi.fn();
setPwToolsCoreCurrentPage({ on, off });
const resp = {
url: () => "https://example.com/api/data",
status: () => 200,
headers: () => ({ "content-type": "application/json" }),
text: async () => '{"ok":true,"value":123}',
};
const p = mod.responseBodyViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
url: "**/api/data",
timeoutMs: 1000,
maxChars: 10,
});
await Promise.resolve();
expect(responseHandler).toBeDefined();
responseHandler?.(resp);
const res = await p;
expect(res.url).toBe("https://example.com/api/data");
expect(res.status).toBe(200);
expect(res.body).toBe('{"ok":true');
expect(res.truncated).toBe(true);
});
it("scrolls a ref into view (default timeout)", async () => {
const scrollIntoViewIfNeeded = vi.fn(async () => {});
setPwToolsCoreCurrentRefLocator({ scrollIntoViewIfNeeded });
const page = {};
setPwToolsCoreCurrentPage(page);
await mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
});
expect(sessionMocks.refLocator).toHaveBeenCalledWith(page, "1");
expect(scrollIntoViewIfNeeded).toHaveBeenCalledWith({ timeout: 20_000 });
});
it("requires a ref for scrollIntoView", async () => {
setPwToolsCoreCurrentRefLocator({ scrollIntoViewIfNeeded: vi.fn(async () => {}) });
setPwToolsCoreCurrentPage({});
await expect(
mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: " ",
}),
).rejects.toThrow(/ref is required/i);
});
});

View File

@@ -0,0 +1,58 @@
import { createConfigIO, loadConfig } from "../config/config.js";
import { resolveBrowserConfig, resolveProfile, type ResolvedBrowserProfile } from "./config.js";
import type { BrowserServerState } from "./server-context.types.js";
function applyResolvedConfig(
current: BrowserServerState,
freshResolved: BrowserServerState["resolved"],
) {
current.resolved = freshResolved;
for (const [name, runtime] of current.profiles) {
const nextProfile = resolveProfile(freshResolved, name);
if (nextProfile) {
runtime.profile = nextProfile;
continue;
}
if (!runtime.running) {
current.profiles.delete(name);
}
}
}
export function refreshResolvedBrowserConfigFromDisk(params: {
current: BrowserServerState;
refreshConfigFromDisk: boolean;
mode: "cached" | "fresh";
}) {
if (!params.refreshConfigFromDisk) {
return;
}
const cfg = params.mode === "fresh" ? createConfigIO().loadConfig() : 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,97 @@
import type { BrowserRouteContext } from "../server-context.js";
import { readBody, resolveTargetIdFromBody, withPlaywrightRouteContext } 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 withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "wait for download",
run: async ({ cdpUrl, tab, pw }) => {
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 withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "download",
run: async ({ cdpUrl, tab, pw }) => {
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,100 @@
import type { BrowserRouteContext } from "../server-context.js";
import { readBody, resolveTargetIdFromBody, withPlaywrightRouteContext } 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 withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "file chooser hook",
run: async ({ cdpUrl, tab, pw }) => {
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 (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 withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "dialog hook",
run: async ({ cdpUrl, tab, pw }) => {
await pw.armDialogViaPlaywright({
cdpUrl,
targetId: tab.targetId,
accept,
promptText,
timeoutMs: timeoutMs ?? undefined,
});
res.json({ ok: true });
},
});
});
}

View File

@@ -0,0 +1,52 @@
export const ACT_KINDS = [
"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 };
}

View File

@@ -0,0 +1,380 @@
import type { BrowserFormField } from "../client-actions-core.js";
import { normalizeBrowserFormField } from "../form-fields.js";
import type { BrowserRouteContext } from "../server-context.js";
import { registerBrowserAgentActDownloadRoutes } from "./agent.act.download.js";
import { registerBrowserAgentActHookRoutes } from "./agent.act.hooks.js";
import {
type ActKind,
isActKind,
parseClickButton,
parseClickModifiers,
} from "./agent.act.shared.js";
import {
readBody,
resolveTargetIdFromBody,
withPlaywrightRouteContext,
SELECTOR_UNSUPPORTED_MESSAGE,
} from "./agent.shared.js";
import type { BrowserRouteRegistrar } from "./types.js";
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
export function registerBrowserAgentActRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.post("/act", async (req, res) => {
const body = readBody(req);
const kindRaw = toStringOrEmpty(body.kind);
if (!isActKind(kindRaw)) {
return jsonError(res, 400, "kind is required");
}
const kind: ActKind = kindRaw;
const targetId = resolveTargetIdFromBody(body);
if (Object.hasOwn(body, "selector") && kind !== "wait") {
return jsonError(res, 400, SELECTOR_UNSUPPORTED_MESSAGE);
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: `act:${kind}`,
run: async ({ cdpUrl, tab, pw }) => {
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
switch (kind) {
case "click": {
const ref = toStringOrEmpty(body.ref);
if (!ref) {
return jsonError(res, 400, "ref is required");
}
const doubleClick = toBoolean(body.doubleClick) ?? false;
const timeoutMs = toNumber(body.timeoutMs);
const buttonRaw = toStringOrEmpty(body.button) || "";
const button = buttonRaw ? parseClickButton(buttonRaw) : undefined;
if (buttonRaw && !button) {
return jsonError(res, 400, "button must be left|right|middle");
}
const modifiersRaw = toStringArray(body.modifiers) ?? [];
const parsedModifiers = parseClickModifiers(modifiersRaw);
if (parsedModifiers.error) {
return jsonError(res, 400, parsedModifiers.error);
}
const modifiers = parsedModifiers.modifiers;
const clickRequest: Parameters<typeof pw.clickViaPlaywright>[0] = {
cdpUrl,
targetId: tab.targetId,
ref,
doubleClick,
};
if (button) {
clickRequest.button = button;
}
if (modifiers) {
clickRequest.modifiers = modifiers;
}
if (timeoutMs) {
clickRequest.timeoutMs = timeoutMs;
}
await pw.clickViaPlaywright(clickRequest);
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
}
case "type": {
const ref = toStringOrEmpty(body.ref);
if (!ref) {
return jsonError(res, 400, "ref is required");
}
if (typeof body.text !== "string") {
return jsonError(res, 400, "text is required");
}
const text = body.text;
const submit = toBoolean(body.submit) ?? false;
const slowly = toBoolean(body.slowly) ?? false;
const timeoutMs = toNumber(body.timeoutMs);
const typeRequest: Parameters<typeof pw.typeViaPlaywright>[0] = {
cdpUrl,
targetId: tab.targetId,
ref,
text,
submit,
slowly,
};
if (timeoutMs) {
typeRequest.timeoutMs = timeoutMs;
}
await pw.typeViaPlaywright(typeRequest);
return res.json({ ok: true, targetId: tab.targetId });
}
case "press": {
const key = toStringOrEmpty(body.key);
if (!key) {
return jsonError(res, 400, "key is required");
}
const delayMs = toNumber(body.delayMs);
await pw.pressKeyViaPlaywright({
cdpUrl,
targetId: tab.targetId,
key,
delayMs: delayMs ?? undefined,
});
return res.json({ ok: true, targetId: tab.targetId });
}
case "hover": {
const ref = toStringOrEmpty(body.ref);
if (!ref) {
return jsonError(res, 400, "ref is required");
}
const timeoutMs = toNumber(body.timeoutMs);
await pw.hoverViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref,
timeoutMs: timeoutMs ?? undefined,
});
return res.json({ ok: true, targetId: tab.targetId });
}
case "scrollIntoView": {
const ref = toStringOrEmpty(body.ref);
if (!ref) {
return jsonError(res, 400, "ref is required");
}
const timeoutMs = toNumber(body.timeoutMs);
const scrollRequest: Parameters<typeof pw.scrollIntoViewViaPlaywright>[0] = {
cdpUrl,
targetId: tab.targetId,
ref,
};
if (timeoutMs) {
scrollRequest.timeoutMs = timeoutMs;
}
await pw.scrollIntoViewViaPlaywright(scrollRequest);
return res.json({ ok: true, targetId: tab.targetId });
}
case "drag": {
const startRef = toStringOrEmpty(body.startRef);
const endRef = toStringOrEmpty(body.endRef);
if (!startRef || !endRef) {
return jsonError(res, 400, "startRef and endRef are required");
}
const timeoutMs = toNumber(body.timeoutMs);
await pw.dragViaPlaywright({
cdpUrl,
targetId: tab.targetId,
startRef,
endRef,
timeoutMs: timeoutMs ?? undefined,
});
return res.json({ ok: true, targetId: tab.targetId });
}
case "select": {
const ref = toStringOrEmpty(body.ref);
const values = toStringArray(body.values);
if (!ref || !values?.length) {
return jsonError(res, 400, "ref and values are required");
}
const timeoutMs = toNumber(body.timeoutMs);
await pw.selectOptionViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref,
values,
timeoutMs: timeoutMs ?? undefined,
});
return res.json({ ok: true, targetId: tab.targetId });
}
case "fill": {
const rawFields = Array.isArray(body.fields) ? body.fields : [];
const fields = rawFields
.map((field) => {
if (!field || typeof field !== "object") {
return null;
}
return normalizeBrowserFormField(field as Record<string, unknown>);
})
.filter((field): field is BrowserFormField => field !== null);
if (!fields.length) {
return jsonError(res, 400, "fields are required");
}
const timeoutMs = toNumber(body.timeoutMs);
await pw.fillFormViaPlaywright({
cdpUrl,
targetId: tab.targetId,
fields,
timeoutMs: timeoutMs ?? undefined,
});
return res.json({ ok: true, targetId: tab.targetId });
}
case "resize": {
const width = toNumber(body.width);
const height = toNumber(body.height);
if (!width || !height) {
return jsonError(res, 400, "width and height are required");
}
await pw.resizeViewportViaPlaywright({
cdpUrl,
targetId: tab.targetId,
width,
height,
});
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
}
case "wait": {
const timeMs = toNumber(body.timeMs);
const text = toStringOrEmpty(body.text) || undefined;
const textGone = toStringOrEmpty(body.textGone) || undefined;
const selector = toStringOrEmpty(body.selector) || undefined;
const url = toStringOrEmpty(body.url) || undefined;
const loadStateRaw = toStringOrEmpty(body.loadState);
const loadState =
loadStateRaw === "load" ||
loadStateRaw === "domcontentloaded" ||
loadStateRaw === "networkidle"
? loadStateRaw
: undefined;
const fn = toStringOrEmpty(body.fn) || undefined;
const timeoutMs = toNumber(body.timeoutMs) ?? undefined;
if (fn && !evaluateEnabled) {
return jsonError(
res,
403,
[
"wait --fn is disabled by config (browser.evaluateEnabled=false).",
"Docs: /gateway/configuration#browser-openclaw-managed-browser",
].join("\n"),
);
}
if (
timeMs === undefined &&
!text &&
!textGone &&
!selector &&
!url &&
!loadState &&
!fn
) {
return jsonError(
res,
400,
"wait requires at least one of: timeMs, text, textGone, selector, url, loadState, fn",
);
}
await pw.waitForViaPlaywright({
cdpUrl,
targetId: tab.targetId,
timeMs,
text,
textGone,
selector,
url,
loadState,
fn,
timeoutMs,
});
return res.json({ ok: true, targetId: tab.targetId });
}
case "evaluate": {
if (!evaluateEnabled) {
return jsonError(
res,
403,
[
"act:evaluate is disabled by config (browser.evaluateEnabled=false).",
"Docs: /gateway/configuration#browser-openclaw-managed-browser",
].join("\n"),
);
}
const fn = toStringOrEmpty(body.fn);
if (!fn) {
return jsonError(res, 400, "fn is required");
}
const ref = toStringOrEmpty(body.ref) || undefined;
const evalTimeoutMs = toNumber(body.timeoutMs);
const evalRequest: Parameters<typeof pw.evaluateViaPlaywright>[0] = {
cdpUrl,
targetId: tab.targetId,
fn,
ref,
signal: req.signal,
};
if (evalTimeoutMs !== undefined) {
evalRequest.timeoutMs = evalTimeoutMs;
}
const result = await pw.evaluateViaPlaywright(evalRequest);
return res.json({
ok: true,
targetId: tab.targetId,
url: tab.url,
result,
});
}
case "close": {
await pw.closePageViaPlaywright({ cdpUrl, targetId: tab.targetId });
return res.json({ ok: true, targetId: tab.targetId });
}
default: {
return jsonError(res, 400, "unsupported kind");
}
}
},
});
});
registerBrowserAgentActHookRoutes(app, ctx);
registerBrowserAgentActDownloadRoutes(app, ctx);
app.post("/response/body", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const url = toStringOrEmpty(body.url);
const timeoutMs = toNumber(body.timeoutMs);
const maxChars = toNumber(body.maxChars);
if (!url) {
return jsonError(res, 400, "url is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "response body",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.responseBodyViaPlaywright({
cdpUrl,
targetId: tab.targetId,
url,
timeoutMs: timeoutMs ?? undefined,
maxChars: maxChars ?? undefined,
});
res.json({ ok: true, targetId: tab.targetId, response: result });
},
});
});
app.post("/highlight", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref);
if (!ref) {
return jsonError(res, 400, "ref is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "highlight",
run: async ({ cdpUrl, tab, pw }) => {
await pw.highlightViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
}

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,39 @@
import { describe, expect, it } from "vitest";
import { readBody, resolveTargetIdFromBody, resolveTargetIdFromQuery } from "./agent.shared.js";
import type { BrowserRequest } from "./types.js";
function requestWithBody(body: unknown): BrowserRequest {
return {
params: {},
query: {},
body,
};
}
describe("browser route shared helpers", () => {
describe("readBody", () => {
it("returns object bodies", () => {
expect(readBody(requestWithBody({ one: 1 }))).toEqual({ one: 1 });
});
it("normalizes non-object bodies to empty object", () => {
expect(readBody(requestWithBody(null))).toEqual({});
expect(readBody(requestWithBody("text"))).toEqual({});
expect(readBody(requestWithBody(["x"]))).toEqual({});
});
});
describe("target id parsing", () => {
it("extracts and trims targetId from body", () => {
expect(resolveTargetIdFromBody({ targetId: " tab-1 " })).toBe("tab-1");
expect(resolveTargetIdFromBody({ targetId: " " })).toBeUndefined();
expect(resolveTargetIdFromBody({ targetId: 123 })).toBeUndefined();
});
it("extracts and trims targetId from query", () => {
expect(resolveTargetIdFromQuery({ targetId: " tab-2 " })).toBe("tab-2");
expect(resolveTargetIdFromQuery({ targetId: "" })).toBeUndefined();
expect(resolveTargetIdFromQuery({ targetId: false })).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,143 @@
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);
}
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,333 @@
import path from "node:path";
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
import { captureScreenshot, snapshotAria } from "../cdp.js";
import {
DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH,
DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS,
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
} from "../constants.js";
import { withBrowserNavigationPolicy } from "../navigation-guard.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 type { BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
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,
});
}
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 withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "navigate",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.navigateViaPlaywright({
cdpUrl,
targetId: tab.targetId,
url,
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
});
app.post("/pdf", async (req, res) => {
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
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 }) => {
let buffer: Buffer;
const shouldUsePlaywright =
profileCtx.profile.driver === "extension" ||
!tab.wsUrl ||
Boolean(ref) ||
Boolean(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,
});
}
const normalized = await normalizeBrowserScreenshot(buffer, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
});
await saveBrowserMediaResponse({
res,
buffer: normalized.buffer,
contentType: normalized.contentType ?? `image/${type}`,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
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 mode = req.query.mode === "efficient" ? "efficient" : undefined;
const labels = toBoolean(req.query.labels) ?? undefined;
const explicitFormat =
req.query.format === "aria" ? "aria" : req.query.format === "ai" ? "ai" : undefined;
const format = explicitFormat ?? (mode ? "ai" : (await getPwAiModule()) ? "ai" : "aria");
const limitRaw = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
const hasMaxChars = Object.hasOwn(req.query, "maxChars");
const maxCharsRaw =
typeof req.query.maxChars === "string" ? Number(req.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(req.query.interactive);
const compactRaw = toBoolean(req.query.compact);
const depthRaw = toNumber(req.query.depth);
const refsModeRaw = toStringOrEmpty(req.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 selector = toStringOrEmpty(req.query.selector);
const frameSelector = toStringOrEmpty(req.query.frame);
const selectorValue = selector.trim() || undefined;
const frameSelectorValue = frameSelector.trim() || undefined;
try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
if ((labels || mode === "efficient") && format === "aria") {
return jsonError(res, 400, "labels/mode=efficient require format=ai");
}
if (format === "ai") {
const pw = await requirePwAi(res, "ai snapshot");
if (!pw) {
return;
}
const wantsRoleSnapshot =
labels === true ||
mode === "efficient" ||
interactive === true ||
compact === true ||
depth !== undefined ||
Boolean(selectorValue) ||
Boolean(frameSelectorValue);
const roleSnapshotArgs = {
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
selector: selectorValue,
frameSelector: frameSelectorValue,
refsMode,
options: {
interactive: interactive ?? undefined,
compact: compact ?? undefined,
maxDepth: depth ?? undefined,
},
};
const snap = wantsRoleSnapshot
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs)
: await pw
.snapshotAiViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
...(typeof resolvedMaxChars === "number" ? { maxChars: 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 (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,
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,
targetId: tab.targetId,
url: tab.url,
...snap,
});
}
const snap =
profileCtx.profile.driver === "extension" || !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,
});
});
})()
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit });
const resolved = await Promise.resolve(snap);
if (!resolved) {
return;
}
return res.json({
ok: true,
format,
targetId: tab.targetId,
url: tab.url,
...resolved,
});
} catch (err) {
handleRouteError(ctx, res, err);
}
});
}

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
parseRequiredStorageMutationRequest,
parseStorageKind,
parseStorageMutationRequest,
} from "./agent.storage.js";
describe("browser storage route parsing", () => {
describe("parseStorageKind", () => {
it("accepts local and session", () => {
expect(parseStorageKind("local")).toBe("local");
expect(parseStorageKind("session")).toBe("session");
});
it("rejects unsupported values", () => {
expect(parseStorageKind("cookie")).toBeNull();
expect(parseStorageKind("")).toBeNull();
});
});
describe("parseStorageMutationRequest", () => {
it("returns parsed kind and trimmed target id", () => {
expect(
parseStorageMutationRequest("local", {
targetId: " page-1 ",
}),
).toEqual({
kind: "local",
targetId: "page-1",
});
});
it("returns null kind and undefined target id for invalid values", () => {
expect(
parseStorageMutationRequest("invalid", {
targetId: " ",
}),
).toEqual({
kind: null,
targetId: undefined,
});
});
});
describe("parseRequiredStorageMutationRequest", () => {
it("returns parsed request for supported kinds", () => {
expect(
parseRequiredStorageMutationRequest("session", {
targetId: " tab-9 ",
}),
).toEqual({
kind: "session",
targetId: "tab-9",
});
});
it("returns null for unsupported kind", () => {
expect(
parseRequiredStorageMutationRequest("cookie", {
targetId: "tab-1",
}),
).toBeNull();
});
});
});

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,202 @@
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.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";
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) {
jsonError(params.res, 500, String(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);
}
const [cdpHttp, cdpReady] = await Promise.all([
profileCtx.isHttpReachable(300),
profileCtx.isReachable(600),
]);
const profileState = current.profiles.get(profileCtx.profile.name);
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,
running: cdpReady,
cdpReady,
cdpHttp,
pid: profileState?.running?.pid ?? null,
cdpPort: profileCtx.profile.cdpPort,
cdpUrl: profileCtx.profile.cdpUrl,
chosenBrowser: profileState?.running?.exe.kind ?? null,
detectedBrowser,
detectedExecutablePath,
detectError,
userDataDir: profileState?.running?.userDataDir ?? null,
color: profileCtx.profile.color,
headless: current.resolved.headless,
noSandbox: current.resolved.noSandbox,
executablePath: current.resolved.executablePath ?? null,
attachOnly: current.resolved.attachOnly,
});
});
// 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 driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver) as
| "openclaw"
| "extension"
| "";
if (!name) {
return jsonError(res, 400, "name is required");
}
try {
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({
name,
color: color || undefined,
cdpUrl: cdpUrl || undefined,
driver: driver === "extension" ? "extension" : undefined,
});
res.json(result);
} catch (err) {
const msg = String(err);
if (msg.includes("already exists")) {
return jsonError(res, 409, msg);
}
if (msg.includes("invalid profile name")) {
return jsonError(res, 400, msg);
}
if (msg.includes("no available CDP ports")) {
return jsonError(res, 507, msg);
}
if (msg.includes("cdpUrl")) {
return jsonError(res, 400, msg);
}
jsonError(res, 500, msg);
}
});
// 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");
}
try {
const service = createBrowserProfilesService(ctx);
const result = await service.deleteProfile(name);
res.json(result);
} catch (err) {
const msg = String(err);
if (msg.includes("invalid profile name")) {
return jsonError(res, 400, msg);
}
if (msg.includes("default profile")) {
return jsonError(res, 400, msg);
}
if (msg.includes("not found")) {
return jsonError(res, 404, msg);
}
jsonError(res, 500, msg);
}
});
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import type { BrowserRouteContext } from "../server-context.js";
vi.mock("./index.js", () => {
return {
registerBrowserRoutes(app: { get: (path: string, handler: unknown) => void }) {
app.get(
"/slow",
async (req: { signal?: AbortSignal }, res: { json: (body: unknown) => void }) => {
const signal = req.signal;
await new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason ?? new Error("aborted"));
return;
}
const onAbort = () => reject(signal?.reason ?? new Error("aborted"));
signal?.addEventListener("abort", onAbort, { once: true });
queueMicrotask(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
});
});
res.json({ ok: true });
},
);
app.get(
"/echo/:id",
async (
req: { params?: Record<string, string> },
res: { json: (body: unknown) => void },
) => {
res.json({ id: req.params?.id ?? null });
},
);
},
};
});
describe("browser route dispatcher (abort)", () => {
it("propagates AbortSignal and lets handlers observe abort", async () => {
const { createBrowserRouteDispatcher } = await import("./dispatcher.js");
const dispatcher = createBrowserRouteDispatcher({} as BrowserRouteContext);
const ctrl = new AbortController();
const promise = dispatcher.dispatch({
method: "GET",
path: "/slow",
signal: ctrl.signal,
});
ctrl.abort(new Error("timed out"));
await expect(promise).resolves.toMatchObject({
status: 500,
body: { error: expect.stringContaining("timed out") },
});
});
it("returns 400 for malformed percent-encoding in route params", async () => {
const { createBrowserRouteDispatcher } = await import("./dispatcher.js");
const dispatcher = createBrowserRouteDispatcher({} as BrowserRouteContext);
await expect(
dispatcher.dispatch({
method: "GET",
path: "/echo/%E0%A4%A",
}),
).resolves.toMatchObject({
status: 400,
body: { error: expect.stringContaining("invalid path parameter encoding") },
});
});
});

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,217 @@
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, 409, "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 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) {
return jsonError(res, 404, "tab not found");
}
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) {
return jsonError(res, 404, "tab not found");
}
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,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;
}

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