Files
openclaw/src/node-host/invoke-browser.ts
2026-03-08 23:46:10 +00:00

334 lines
10 KiB
TypeScript

import fsPromises from "node:fs/promises";
import { resolveBrowserConfig } from "../browser/config.js";
import {
createBrowserControlContext,
startBrowserControlServiceFromConfig,
} from "../browser/control-service.js";
import { createBrowserRouteDispatcher } from "../browser/routes/dispatcher.js";
import { loadConfig } from "../config/config.js";
import { detectMime } from "../media/mime.js";
import { withTimeout } from "./with-timeout.js";
type BrowserProxyParams = {
method?: string;
path?: string;
query?: Record<string, string | number | boolean | null | undefined>;
body?: unknown;
timeoutMs?: number;
profile?: string;
};
type BrowserProxyFile = {
path: string;
base64: string;
mimeType?: string;
};
type BrowserProxyResult = {
result: unknown;
files?: BrowserProxyFile[];
};
const BROWSER_PROXY_MAX_FILE_BYTES = 10 * 1024 * 1024;
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
const BROWSER_PROXY_STATUS_TIMEOUT_MS = 750;
function normalizeProfileAllowlist(raw?: string[]): string[] {
return Array.isArray(raw) ? raw.map((entry) => entry.trim()).filter(Boolean) : [];
}
function resolveBrowserProxyConfig() {
const cfg = loadConfig();
const proxy = cfg.nodeHost?.browserProxy;
const allowProfiles = normalizeProfileAllowlist(proxy?.allowProfiles);
const enabled = proxy?.enabled !== false;
return { enabled, allowProfiles };
}
let browserControlReady: Promise<void> | null = null;
async function ensureBrowserControlService(): Promise<void> {
if (browserControlReady) {
return browserControlReady;
}
browserControlReady = (async () => {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
if (!resolved.enabled) {
throw new Error("browser control disabled");
}
const started = await startBrowserControlServiceFromConfig();
if (!started) {
throw new Error("browser control disabled");
}
})();
return browserControlReady;
}
function isProfileAllowed(params: { allowProfiles: string[]; profile?: string | null }) {
const { allowProfiles, profile } = params;
if (!allowProfiles.length) {
return true;
}
if (!profile) {
return false;
}
return allowProfiles.includes(profile.trim());
}
function collectBrowserProxyPaths(payload: unknown): string[] {
const paths = new Set<string>();
const obj =
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : null;
if (!obj) {
return [];
}
if (typeof obj.path === "string" && obj.path.trim()) {
paths.add(obj.path.trim());
}
if (typeof obj.imagePath === "string" && obj.imagePath.trim()) {
paths.add(obj.imagePath.trim());
}
const download = obj.download;
if (download && typeof download === "object") {
const dlPath = (download as Record<string, unknown>).path;
if (typeof dlPath === "string" && dlPath.trim()) {
paths.add(dlPath.trim());
}
}
return [...paths];
}
async function readBrowserProxyFile(filePath: string): Promise<BrowserProxyFile | null> {
const stat = await fsPromises.stat(filePath).catch(() => null);
if (!stat || !stat.isFile()) {
return null;
}
if (stat.size > BROWSER_PROXY_MAX_FILE_BYTES) {
throw new Error(
`browser proxy file exceeds ${Math.round(BROWSER_PROXY_MAX_FILE_BYTES / (1024 * 1024))}MB`,
);
}
const buffer = await fsPromises.readFile(filePath);
const mimeType = await detectMime({ buffer, filePath });
return { path: filePath, base64: buffer.toString("base64"), mimeType };
}
function decodeParams<T>(raw?: string | null): T {
if (!raw) {
throw new Error("INVALID_REQUEST: paramsJSON required");
}
return JSON.parse(raw) as T;
}
function resolveBrowserProxyTimeout(timeoutMs?: number): number {
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
? Math.max(1, Math.floor(timeoutMs))
: DEFAULT_BROWSER_PROXY_TIMEOUT_MS;
}
function isBrowserProxyTimeoutError(err: unknown): boolean {
return String(err).includes("browser proxy request timed out");
}
function isWsBackedBrowserProxyPath(path: string): boolean {
return (
path === "/act" ||
path === "/navigate" ||
path === "/pdf" ||
path === "/screenshot" ||
path === "/snapshot"
);
}
async function readBrowserProxyStatus(params: {
dispatcher: ReturnType<typeof createBrowserRouteDispatcher>;
profile?: string;
}): Promise<Record<string, unknown> | null> {
const query = params.profile ? { profile: params.profile } : {};
try {
const response = await withTimeout(
(signal) =>
params.dispatcher.dispatch({
method: "GET",
path: "/",
query,
signal,
}),
BROWSER_PROXY_STATUS_TIMEOUT_MS,
"browser proxy status",
);
if (response.status >= 400 || !response.body || typeof response.body !== "object") {
return null;
}
const body = response.body as Record<string, unknown>;
return {
running: body.running,
cdpHttp: body.cdpHttp,
cdpReady: body.cdpReady,
cdpUrl: body.cdpUrl,
};
} catch {
return null;
}
}
function formatBrowserProxyTimeoutMessage(params: {
method: string;
path: string;
profile?: string;
timeoutMs: number;
wsBacked: boolean;
status: Record<string, unknown> | null;
}): string {
const parts = [
`browser proxy timed out for ${params.method} ${params.path} after ${params.timeoutMs}ms`,
params.wsBacked ? "ws-backed browser action" : "browser action",
];
if (params.profile) {
parts.push(`profile=${params.profile}`);
}
if (params.status) {
const statusParts = [
`running=${String(params.status.running)}`,
`cdpHttp=${String(params.status.cdpHttp)}`,
`cdpReady=${String(params.status.cdpReady)}`,
];
if (typeof params.status.cdpUrl === "string" && params.status.cdpUrl.trim()) {
statusParts.push(`cdpUrl=${params.status.cdpUrl}`);
}
parts.push(`status(${statusParts.join(", ")})`);
}
return parts.join("; ");
}
export async function runBrowserProxyCommand(paramsJSON?: string | null): Promise<string> {
const params = decodeParams<BrowserProxyParams>(paramsJSON);
const pathValue = typeof params.path === "string" ? params.path.trim() : "";
if (!pathValue) {
throw new Error("INVALID_REQUEST: path required");
}
const proxyConfig = resolveBrowserProxyConfig();
if (!proxyConfig.enabled) {
throw new Error("UNAVAILABLE: node browser proxy disabled");
}
await ensureBrowserControlService();
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const requestedProfile = typeof params.profile === "string" ? params.profile.trim() : "";
const allowedProfiles = proxyConfig.allowProfiles;
if (allowedProfiles.length > 0) {
if (pathValue !== "/profiles") {
const profileToCheck = requestedProfile || resolved.defaultProfile;
if (!isProfileAllowed({ allowProfiles: allowedProfiles, profile: profileToCheck })) {
throw new Error("INVALID_REQUEST: browser profile not allowed");
}
} else if (requestedProfile) {
if (!isProfileAllowed({ allowProfiles: allowedProfiles, profile: requestedProfile })) {
throw new Error("INVALID_REQUEST: browser profile not allowed");
}
}
}
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
const path = pathValue.startsWith("/") ? pathValue : `/${pathValue}`;
const body = params.body;
const timeoutMs = resolveBrowserProxyTimeout(params.timeoutMs);
const query: Record<string, unknown> = {};
if (requestedProfile) {
query.profile = requestedProfile;
}
const rawQuery = params.query ?? {};
for (const [key, value] of Object.entries(rawQuery)) {
if (value === undefined || value === null) {
continue;
}
query[key] = typeof value === "string" ? value : String(value);
}
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
let response;
try {
response = await withTimeout(
(signal) =>
dispatcher.dispatch({
method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET",
path,
query,
body,
signal,
}),
timeoutMs,
"browser proxy request",
);
} catch (err) {
if (!isBrowserProxyTimeoutError(err)) {
throw err;
}
const profileForStatus = requestedProfile || resolved.defaultProfile;
const status = await readBrowserProxyStatus({
dispatcher,
profile: path === "/profiles" ? undefined : profileForStatus,
});
throw new Error(
formatBrowserProxyTimeoutMessage({
method,
path,
profile: path === "/profiles" ? undefined : profileForStatus || undefined,
timeoutMs,
wsBacked: isWsBackedBrowserProxyPath(path),
status,
}),
{ cause: err },
);
}
if (response.status >= 400) {
const message =
response.body && typeof response.body === "object" && "error" in response.body
? String((response.body as { error?: unknown }).error)
: `HTTP ${response.status}`;
throw new Error(message);
}
const result = response.body;
if (allowedProfiles.length > 0 && path === "/profiles") {
const obj =
typeof result === "object" && result !== null ? (result as Record<string, unknown>) : {};
const profiles = Array.isArray(obj.profiles) ? obj.profiles : [];
obj.profiles = profiles.filter((entry) => {
if (!entry || typeof entry !== "object") {
return false;
}
const name = (entry as Record<string, unknown>).name;
return typeof name === "string" && allowedProfiles.includes(name);
});
}
let files: BrowserProxyFile[] | undefined;
const paths = collectBrowserProxyPaths(result);
if (paths.length > 0) {
const loaded = await Promise.all(
paths.map(async (p) => {
try {
const file = await readBrowserProxyFile(p);
if (!file) {
throw new Error("file not found");
}
return file;
} catch (err) {
throw new Error(`browser proxy file read failed for ${p}: ${String(err)}`, {
cause: err,
});
}
}),
);
if (loaded.length > 0) {
files = loaded;
}
}
const payload: BrowserProxyResult = files ? { result, files } : { result };
return JSON.stringify(payload);
}