Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
34
openclaw/src/browser/bridge-auth-registry.ts
Normal file
34
openclaw/src/browser/bridge-auth-registry.ts
Normal 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);
|
||||
}
|
||||
82
openclaw/src/browser/bridge-server.auth.test.ts
Normal file
82
openclaw/src/browser/bridge-server.auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
110
openclaw/src/browser/bridge-server.ts
Normal file
110
openclaw/src/browser/bridge-server.ts
Normal 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());
|
||||
});
|
||||
}
|
||||
247
openclaw/src/browser/browser-utils.test.ts
Normal file
247
openclaw/src/browser/browser-utils.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
180
openclaw/src/browser/cdp.helpers.ts
Normal file
180
openclaw/src/browser/cdp.helpers.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
255
openclaw/src/browser/cdp.test.ts
Normal file
255
openclaw/src/browser/cdp.test.ts
Normal 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
462
openclaw/src/browser/cdp.ts
Normal 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;
|
||||
};
|
||||
110
openclaw/src/browser/chrome-extension-background-utils.test.ts
Normal file
110
openclaw/src/browser/chrome-extension-background-utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
29
openclaw/src/browser/chrome-extension-manifest.test.ts
Normal file
29
openclaw/src/browser/chrome-extension-manifest.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
113
openclaw/src/browser/chrome-extension-options-validation.test.ts
Normal file
113
openclaw/src/browser/chrome-extension-options-validation.test.ts
Normal 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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
18
openclaw/src/browser/chrome-user-data-dir.test-harness.ts
Normal file
18
openclaw/src/browser/chrome-user-data-dir.test-harness.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
77
openclaw/src/browser/chrome.default-browser.test.ts
Normal file
77
openclaw/src/browser/chrome.default-browser.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
625
openclaw/src/browser/chrome.executables.ts
Normal file
625
openclaw/src/browser/chrome.executables.ts
Normal 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;
|
||||
}
|
||||
198
openclaw/src/browser/chrome.profile-decoration.ts
Normal file
198
openclaw/src/browser/chrome.profile-decoration.ts
Normal 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);
|
||||
}
|
||||
290
openclaw/src/browser/chrome.test.ts
Normal file
290
openclaw/src/browser/chrome.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
350
openclaw/src/browser/chrome.ts
Normal file
350
openclaw/src/browser/chrome.ts
Normal 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
|
||||
}
|
||||
}
|
||||
259
openclaw/src/browser/client-actions-core.ts
Normal file
259
openclaw/src/browser/client-actions-core.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
184
openclaw/src/browser/client-actions-observe.ts
Normal file
184
openclaw/src/browser/client-actions-observe.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
284
openclaw/src/browser/client-actions-state.ts
Normal file
284
openclaw/src/browser/client-actions-state.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
16
openclaw/src/browser/client-actions-types.ts
Normal file
16
openclaw/src/browser/client-actions-types.ts
Normal 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 };
|
||||
11
openclaw/src/browser/client-actions-url.ts
Normal file
11
openclaw/src/browser/client-actions-url.ts
Normal 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}`;
|
||||
}
|
||||
4
openclaw/src/browser/client-actions.ts
Normal file
4
openclaw/src/browser/client-actions.ts
Normal 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";
|
||||
117
openclaw/src/browser/client-fetch.loopback-auth.test.ts
Normal file
117
openclaw/src/browser/client-fetch.loopback-auth.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
260
openclaw/src/browser/client-fetch.ts
Normal file
260
openclaw/src/browser/client-fetch.ts
Normal 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,
|
||||
};
|
||||
273
openclaw/src/browser/client.test.ts
Normal file
273
openclaw/src/browser/client.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
337
openclaw/src/browser/client.ts
Normal file
337
openclaw/src/browser/client.ts
Normal 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.
|
||||
201
openclaw/src/browser/config.test.ts
Normal file
201
openclaw/src/browser/config.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
310
openclaw/src/browser/config.ts
Normal file
310
openclaw/src/browser/config.ts
Normal 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;
|
||||
}
|
||||
8
openclaw/src/browser/constants.ts
Normal file
8
openclaw/src/browser/constants.ts
Normal 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;
|
||||
135
openclaw/src/browser/control-auth.auto-token.test.ts
Normal file
135
openclaw/src/browser/control-auth.auto-token.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
97
openclaw/src/browser/control-auth.test.ts
Normal file
97
openclaw/src/browser/control-auth.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
95
openclaw/src/browser/control-auth.ts
Normal file
95
openclaw/src/browser/control-auth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
80
openclaw/src/browser/control-service.ts
Normal file
80
openclaw/src/browser/control-service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
87
openclaw/src/browser/csrf.ts
Normal file
87
openclaw/src/browser/csrf.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
132
openclaw/src/browser/extension-relay-auth.test.ts
Normal file
132
openclaw/src/browser/extension-relay-auth.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
73
openclaw/src/browser/extension-relay-auth.ts
Normal file
73
openclaw/src/browser/extension-relay-auth.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
742
openclaw/src/browser/extension-relay.test.ts
Normal file
742
openclaw/src/browser/extension-relay.test.ts
Normal 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()));
|
||||
});
|
||||
});
|
||||
976
openclaw/src/browser/extension-relay.ts
Normal file
976
openclaw/src/browser/extension-relay.ts
Normal 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;
|
||||
}
|
||||
32
openclaw/src/browser/form-fields.ts
Normal file
32
openclaw/src/browser/form-fields.ts
Normal 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 };
|
||||
}
|
||||
63
openclaw/src/browser/http-auth.ts
Normal file
63
openclaw/src/browser/http-auth.ts
Normal 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;
|
||||
}
|
||||
123
openclaw/src/browser/navigation-guard.test.ts
Normal file
123
openclaw/src/browser/navigation-guard.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
92
openclaw/src/browser/navigation-guard.ts
Normal file
92
openclaw/src/browser/navigation-guard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
312
openclaw/src/browser/paths.test.ts
Normal file
312
openclaw/src/browser/paths.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
247
openclaw/src/browser/paths.ts
Normal file
247
openclaw/src/browser/paths.ts
Normal 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 };
|
||||
}
|
||||
147
openclaw/src/browser/profiles-service.test.ts
Normal file
147
openclaw/src/browser/profiles-service.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
187
openclaw/src/browser/profiles-service.ts
Normal file
187
openclaw/src/browser/profiles-service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
236
openclaw/src/browser/profiles.test.ts
Normal file
236
openclaw/src/browser/profiles.test.ts
Normal 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"]));
|
||||
});
|
||||
});
|
||||
113
openclaw/src/browser/profiles.ts
Normal file
113
openclaw/src/browser/profiles.ts
Normal 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()));
|
||||
}
|
||||
40
openclaw/src/browser/proxy-files.ts
Normal file
40
openclaw/src/browser/proxy-files.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
openclaw/src/browser/pw-ai-module.ts
Normal file
51
openclaw/src/browser/pw-ai-module.ts
Normal 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;
|
||||
}
|
||||
9
openclaw/src/browser/pw-ai-state.ts
Normal file
9
openclaw/src/browser/pw-ai-state.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
let pwAiLoaded = false;
|
||||
|
||||
export function markPwAiLoaded(): void {
|
||||
pwAiLoaded = true;
|
||||
}
|
||||
|
||||
export function isPwAiLoaded(): boolean {
|
||||
return pwAiLoaded;
|
||||
}
|
||||
184
openclaw/src/browser/pw-ai.test.ts
Normal file
184
openclaw/src/browser/pw-ai.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
65
openclaw/src/browser/pw-ai.ts
Normal file
65
openclaw/src/browser/pw-ai.ts
Normal 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";
|
||||
90
openclaw/src/browser/pw-role-snapshot.test.ts
Normal file
90
openclaw/src/browser/pw-role-snapshot.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
454
openclaw/src/browser/pw-role-snapshot.ts
Normal file
454
openclaw/src/browser/pw-role-snapshot.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
45
openclaw/src/browser/pw-session.browserless.live.test.ts
Normal file
45
openclaw/src/browser/pw-session.browserless.live.test.ts
Normal 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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
15
openclaw/src/browser/pw-session.mock-setup.ts
Normal file
15
openclaw/src/browser/pw-session.mock-setup.ts
Normal 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),
|
||||
}));
|
||||
141
openclaw/src/browser/pw-session.test.ts
Normal file
141
openclaw/src/browser/pw-session.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
815
openclaw/src/browser/pw-session.ts
Normal file
815
openclaw/src/browser/pw-session.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
68
openclaw/src/browser/pw-tools-core.activity.ts
Normal file
68
openclaw/src/browser/pw-tools-core.activity.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
294
openclaw/src/browser/pw-tools-core.downloads.ts
Normal file
294
openclaw/src/browser/pw-tools-core.downloads.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
657
openclaw/src/browser/pw-tools-core.interactions.ts
Normal file
657
openclaw/src/browser/pw-tools-core.interactions.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
123
openclaw/src/browser/pw-tools-core.responses.ts
Normal file
123
openclaw/src/browser/pw-tools-core.responses.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
70
openclaw/src/browser/pw-tools-core.shared.ts
Normal file
70
openclaw/src/browser/pw-tools-core.shared.ts
Normal 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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
221
openclaw/src/browser/pw-tools-core.snapshot.ts
Normal file
221
openclaw/src/browser/pw-tools-core.snapshot.ts
Normal 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 };
|
||||
}
|
||||
209
openclaw/src/browser/pw-tools-core.state.ts
Normal file
209
openclaw/src/browser/pw-tools-core.state.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
128
openclaw/src/browser/pw-tools-core.storage.ts
Normal file
128
openclaw/src/browser/pw-tools-core.storage.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
64
openclaw/src/browser/pw-tools-core.test-harness.ts
Normal file
64
openclaw/src/browser/pw-tools-core.test-harness.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
37
openclaw/src/browser/pw-tools-core.trace.ts
Normal file
37
openclaw/src/browser/pw-tools-core.trace.ts
Normal 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;
|
||||
}
|
||||
8
openclaw/src/browser/pw-tools-core.ts
Normal file
8
openclaw/src/browser/pw-tools-core.ts
Normal 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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
58
openclaw/src/browser/resolved-config-refresh.ts
Normal file
58
openclaw/src/browser/resolved-config-refresh.ts
Normal 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;
|
||||
}
|
||||
97
openclaw/src/browser/routes/agent.act.download.ts
Normal file
97
openclaw/src/browser/routes/agent.act.download.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
100
openclaw/src/browser/routes/agent.act.hooks.ts
Normal file
100
openclaw/src/browser/routes/agent.act.hooks.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
52
openclaw/src/browser/routes/agent.act.shared.ts
Normal file
52
openclaw/src/browser/routes/agent.act.shared.ts
Normal 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 };
|
||||
}
|
||||
380
openclaw/src/browser/routes/agent.act.ts
Normal file
380
openclaw/src/browser/routes/agent.act.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
147
openclaw/src/browser/routes/agent.debug.ts
Normal file
147
openclaw/src/browser/routes/agent.debug.ts
Normal 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),
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
39
openclaw/src/browser/routes/agent.shared.test.ts
Normal file
39
openclaw/src/browser/routes/agent.shared.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
143
openclaw/src/browser/routes/agent.shared.ts
Normal file
143
openclaw/src/browser/routes/agent.shared.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
333
openclaw/src/browser/routes/agent.snapshot.ts
Normal file
333
openclaw/src/browser/routes/agent.snapshot.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
65
openclaw/src/browser/routes/agent.storage.test.ts
Normal file
65
openclaw/src/browser/routes/agent.storage.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
451
openclaw/src/browser/routes/agent.storage.ts
Normal file
451
openclaw/src/browser/routes/agent.storage.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
13
openclaw/src/browser/routes/agent.ts
Normal file
13
openclaw/src/browser/routes/agent.ts
Normal 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);
|
||||
}
|
||||
202
openclaw/src/browser/routes/basic.ts
Normal file
202
openclaw/src/browser/routes/basic.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
73
openclaw/src/browser/routes/dispatcher.abort.test.ts
Normal file
73
openclaw/src/browser/routes/dispatcher.abort.test.ts
Normal 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") },
|
||||
});
|
||||
});
|
||||
});
|
||||
133
openclaw/src/browser/routes/dispatcher.ts
Normal file
133
openclaw/src/browser/routes/dispatcher.ts
Normal 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 };
|
||||
11
openclaw/src/browser/routes/index.ts
Normal file
11
openclaw/src/browser/routes/index.ts
Normal 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);
|
||||
}
|
||||
31
openclaw/src/browser/routes/output-paths.ts
Normal file
31
openclaw/src/browser/routes/output-paths.ts
Normal 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;
|
||||
}
|
||||
1
openclaw/src/browser/routes/path-output.ts
Normal file
1
openclaw/src/browser/routes/path-output.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../paths.js";
|
||||
217
openclaw/src/browser/routes/tabs.ts
Normal file
217
openclaw/src/browser/routes/tabs.ts
Normal 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");
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
26
openclaw/src/browser/routes/types.ts
Normal file
26
openclaw/src/browser/routes/types.ts
Normal 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;
|
||||
};
|
||||
73
openclaw/src/browser/routes/utils.ts
Normal file
73
openclaw/src/browser/routes/utils.ts
Normal 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
Reference in New Issue
Block a user