diff --git a/docs/tools/browser.md b/docs/tools/browser.md index ea23264ff82..c3461198ea4 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -42,6 +42,44 @@ openclaw browser --browser-profile openclaw snapshot If you get “Browser disabled”, enable it in config (see below) and restart the Gateway. +## Plugin control + +The default `browser` tool is now a bundled plugin that ships enabled by +default. That means you can disable or replace it without removing the rest of +OpenClaw's plugin system: + +```json5 +{ + plugins: { + entries: { + browser: { + enabled: false, + }, + }, + }, +} +``` + +Disable the bundled plugin before installing another plugin that provides the +same `browser` tool name. The default browser experience needs both: + +- `plugins.entries.browser.enabled` not disabled +- `browser.enabled=true` + +If you turn off only the plugin, the bundled browser CLI (`openclaw browser`), +gateway method (`browser.request`), agent tool, and default browser control +service all disappear together. Your `browser.*` config stays intact for a +replacement plugin to reuse. + +The bundled browser plugin also owns the browser runtime implementation now. +Core keeps only shared Plugin SDK helpers plus compatibility re-exports for +older internal import paths. In practice, removing or replacing +`extensions/browser` removes the browser feature set instead of leaving a +second core-owned runtime behind. + +Browser config changes still require a Gateway restart so the bundled plugin +can re-register its browser service with the new settings. + ## Profiles: `openclaw` vs `user` - `openclaw`: managed, isolated browser (no extension required). diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index cd58fa3e97a..2419d81a6b1 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -104,6 +104,7 @@ and the [Plugin SDK Overview](/plugins/sdk-overview). + - `browser` — bundled browser plugin for the browser tool, `openclaw browser` CLI, `browser.request` gateway method, browser runtime, and default browser control service (enabled by default; disable before replacing it) - `copilot-proxy` — VS Code Copilot Proxy bridge (disabled by default) diff --git a/extensions/browser/src/browser/bridge-auth-registry.ts b/extensions/browser/src/browser/bridge-auth-registry.ts new file mode 100644 index 00000000000..ef9346bf340 --- /dev/null +++ b/extensions/browser/src/browser/bridge-auth-registry.ts @@ -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(); + +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); +} diff --git a/extensions/browser/src/browser/bridge-server.ts b/extensions/browser/src/browser/bridge-server.ts new file mode 100644 index 00000000000..c1d0c082201 --- /dev/null +++ b/extensions/browser/src/browser/bridge-server.ts @@ -0,0 +1,146 @@ +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import express from "express"; +import { isLoopbackHost } from "../gateway/net.js"; +import { deleteBridgeAuthForPort, setBridgeAuthForPort } from "./bridge-auth-registry.js"; +import type { ResolvedBrowserConfig } from "./config.js"; +import { registerBrowserRoutes } from "./routes/index.js"; +import type { BrowserRouteRegistrar } from "./routes/types.js"; +import { + type BrowserServerState, + createBrowserRouteContext, + type ProfileContext, +} from "./server-context.js"; +import { + installBrowserAuthMiddleware, + installBrowserCommonMiddleware, +} from "./server-middleware.js"; + +export type BrowserBridge = { + server: Server; + port: number; + baseUrl: string; + state: BrowserServerState; +}; + +type ResolvedNoVncObserver = { + noVncPort: number; + password?: string; +}; + +function buildNoVncBootstrapHtml(params: ResolvedNoVncObserver): string { + const hash = new URLSearchParams({ + autoconnect: "1", + resize: "remote", + }); + if (params.password?.trim()) { + hash.set("password", params.password); + } + const targetUrl = `http://127.0.0.1:${params.noVncPort}/vnc.html#${hash.toString()}`; + const encodedTarget = JSON.stringify(targetUrl); + return ` + + + + + + OpenClaw noVNC Observer + + +

Opening sandbox observer...

+ + +`; +} + +export async function startBrowserBridgeServer(params: { + resolved: ResolvedBrowserConfig; + host?: string; + port?: number; + authToken?: string; + authPassword?: string; + onEnsureAttachTarget?: (profile: ProfileContext["profile"]) => Promise; + resolveSandboxNoVncToken?: (token: string) => ResolvedNoVncObserver | null; +}): Promise { + const host = params.host ?? "127.0.0.1"; + if (!isLoopbackHost(host)) { + throw new Error(`bridge server must bind to loopback host (got ${host})`); + } + const port = params.port ?? 0; + + const app = express(); + installBrowserCommonMiddleware(app); + + if (params.resolveSandboxNoVncToken) { + app.get("/sandbox/novnc", (req, res) => { + res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + res.setHeader("Referrer-Policy", "no-referrer"); + const rawToken = typeof req.query?.token === "string" ? req.query.token.trim() : ""; + if (!rawToken) { + res.status(400).send("Missing token"); + return; + } + const resolved = params.resolveSandboxNoVncToken?.(rawToken); + if (!resolved) { + res.status(404).send("Invalid or expired token"); + return; + } + res.type("html").status(200).send(buildNoVncBootstrapHtml(resolved)); + }); + } + + const authToken = params.authToken?.trim() || undefined; + const authPassword = params.authPassword?.trim() || undefined; + if (!authToken && !authPassword) { + throw new Error("bridge server requires auth (authToken/authPassword missing)"); + } + installBrowserAuthMiddleware(app, { token: authToken, password: authPassword }); + + const state: BrowserServerState = { + server: null as unknown as Server, + port, + resolved: params.resolved, + profiles: new Map(), + }; + + const ctx = createBrowserRouteContext({ + getState: () => state, + onEnsureAttachTarget: params.onEnsureAttachTarget, + }); + registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx); + + const server = await new Promise((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 { + try { + const address = server.address() as AddressInfo | null; + if (address?.port) { + deleteBridgeAuthForPort(address.port); + } + } catch { + // ignore + } + await new Promise((resolve) => { + server.close(() => resolve()); + }); +} diff --git a/extensions/browser/src/browser/cdp-proxy-bypass.ts b/extensions/browser/src/browser/cdp-proxy-bypass.ts new file mode 100644 index 00000000000..8db5276fc51 --- /dev/null +++ b/extensions/browser/src/browser/cdp-proxy-bypass.ts @@ -0,0 +1,151 @@ +/** + * Proxy bypass for CDP (Chrome DevTools Protocol) localhost connections. + * + * When HTTP_PROXY / HTTPS_PROXY / ALL_PROXY environment variables are set, + * CDP connections to localhost/127.0.0.1 can be incorrectly routed through + * the proxy, causing browser control to fail. + * + * @see https://github.com/nicepkg/openclaw/issues/31219 + */ +import http from "node:http"; +import https from "node:https"; +import { isLoopbackHost } from "../gateway/net.js"; +import { hasProxyEnvConfigured } from "../infra/net/proxy-env.js"; + +/** HTTP agent that never uses a proxy — for localhost CDP connections. */ +const directHttpAgent = new http.Agent(); +const directHttpsAgent = new https.Agent(); + +/** + * Returns a plain (non-proxy) agent for WebSocket or HTTP connections + * when the target is a loopback address. Returns `undefined` otherwise + * so callers fall through to their default behaviour. + */ +export function getDirectAgentForCdp(url: string): http.Agent | https.Agent | undefined { + try { + const parsed = new URL(url); + if (isLoopbackHost(parsed.hostname)) { + return parsed.protocol === "https:" || parsed.protocol === "wss:" + ? directHttpsAgent + : directHttpAgent; + } + } catch { + // not a valid URL — let caller handle it + } + return undefined; +} + +/** + * Returns `true` when any proxy-related env var is set that could + * interfere with loopback connections. + */ +export function hasProxyEnv(): boolean { + return hasProxyEnvConfigured(); +} + +const LOOPBACK_ENTRIES = "localhost,127.0.0.1,[::1]"; + +function noProxyAlreadyCoversLocalhost(): boolean { + const current = process.env.NO_PROXY || process.env.no_proxy || ""; + return ( + current.includes("localhost") && current.includes("127.0.0.1") && current.includes("[::1]") + ); +} + +export async function withNoProxyForLocalhost(fn: () => Promise): Promise { + return await withNoProxyForCdpUrl("http://127.0.0.1", fn); +} + +function isLoopbackCdpUrl(url: string): boolean { + try { + return isLoopbackHost(new URL(url).hostname); + } catch { + return false; + } +} + +type NoProxySnapshot = { + noProxy: string | undefined; + noProxyLower: string | undefined; + applied: string; +}; + +class NoProxyLeaseManager { + private leaseCount = 0; + private snapshot: NoProxySnapshot | null = null; + + acquire(url: string): (() => void) | null { + if (!isLoopbackCdpUrl(url) || !hasProxyEnv()) { + return null; + } + + if (this.leaseCount === 0 && !noProxyAlreadyCoversLocalhost()) { + const noProxy = process.env.NO_PROXY; + const noProxyLower = process.env.no_proxy; + const current = noProxy || noProxyLower || ""; + const applied = current ? `${current},${LOOPBACK_ENTRIES}` : LOOPBACK_ENTRIES; + process.env.NO_PROXY = applied; + process.env.no_proxy = applied; + this.snapshot = { noProxy, noProxyLower, applied }; + } + + this.leaseCount += 1; + let released = false; + return () => { + if (released) { + return; + } + released = true; + this.release(); + }; + } + + private release() { + if (this.leaseCount <= 0) { + return; + } + this.leaseCount -= 1; + if (this.leaseCount > 0 || !this.snapshot) { + return; + } + + const { noProxy, noProxyLower, applied } = this.snapshot; + const currentNoProxy = process.env.NO_PROXY; + const currentNoProxyLower = process.env.no_proxy; + const untouched = + currentNoProxy === applied && + (currentNoProxyLower === applied || currentNoProxyLower === undefined); + if (untouched) { + if (noProxy !== undefined) { + process.env.NO_PROXY = noProxy; + } else { + delete process.env.NO_PROXY; + } + if (noProxyLower !== undefined) { + process.env.no_proxy = noProxyLower; + } else { + delete process.env.no_proxy; + } + } + + this.snapshot = null; + } +} + +const noProxyLeaseManager = new NoProxyLeaseManager(); + +/** + * Scoped NO_PROXY bypass for loopback CDP URLs. + * + * This wrapper only mutates env vars for loopback destinations. On restore, + * it avoids clobbering external NO_PROXY changes that happened while calls + * were in-flight. + */ +export async function withNoProxyForCdpUrl(url: string, fn: () => Promise): Promise { + const release = noProxyLeaseManager.acquire(url); + try { + return await fn(); + } finally { + release?.(); + } +} diff --git a/extensions/browser/src/browser/cdp-timeouts.ts b/extensions/browser/src/browser/cdp-timeouts.ts new file mode 100644 index 00000000000..1014972e42c --- /dev/null +++ b/extensions/browser/src/browser/cdp-timeouts.ts @@ -0,0 +1,56 @@ +export const CDP_HTTP_REQUEST_TIMEOUT_MS = 1500; +export const CDP_WS_HANDSHAKE_TIMEOUT_MS = 5000; +export const CDP_JSON_NEW_TIMEOUT_MS = 1500; + +export const CHROME_REACHABILITY_TIMEOUT_MS = 500; +export const CHROME_WS_READY_TIMEOUT_MS = 800; +export const CHROME_BOOTSTRAP_PREFS_TIMEOUT_MS = 10_000; +export const CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS = 5000; +export const CHROME_LAUNCH_READY_WINDOW_MS = 15_000; +export const CHROME_LAUNCH_READY_POLL_MS = 200; +export const CHROME_STOP_TIMEOUT_MS = 2500; +export const CHROME_STOP_PROBE_TIMEOUT_MS = 200; +export const CHROME_STDERR_HINT_MAX_CHARS = 2000; + +export const PROFILE_HTTP_REACHABILITY_TIMEOUT_MS = 300; +export const PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS = 200; +export const PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS = 2000; +export const PROFILE_ATTACH_RETRY_TIMEOUT_MS = 1200; +export const PROFILE_POST_RESTART_WS_TIMEOUT_MS = 600; +export const CHROME_MCP_ATTACH_READY_WINDOW_MS = 8000; +export const CHROME_MCP_ATTACH_READY_POLL_MS = 200; + +function normalizeTimeoutMs(value: number | undefined): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + return Math.max(1, Math.floor(value)); +} + +export function resolveCdpReachabilityTimeouts(params: { + profileIsLoopback: boolean; + timeoutMs?: number; + remoteHttpTimeoutMs: number; + remoteHandshakeTimeoutMs: number; +}): { httpTimeoutMs: number; wsTimeoutMs: number } { + const normalized = normalizeTimeoutMs(params.timeoutMs); + if (params.profileIsLoopback) { + const httpTimeoutMs = normalized ?? PROFILE_HTTP_REACHABILITY_TIMEOUT_MS; + const wsTimeoutMs = Math.max( + PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS, + Math.min(PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS, httpTimeoutMs * 2), + ); + return { httpTimeoutMs, wsTimeoutMs }; + } + + if (normalized !== undefined) { + return { + httpTimeoutMs: Math.max(normalized, params.remoteHttpTimeoutMs), + wsTimeoutMs: Math.max(normalized * 2, params.remoteHandshakeTimeoutMs), + }; + } + return { + httpTimeoutMs: params.remoteHttpTimeoutMs, + wsTimeoutMs: params.remoteHandshakeTimeoutMs, + }; +} diff --git a/extensions/browser/src/browser/cdp.helpers.ts b/extensions/browser/src/browser/cdp.helpers.ts new file mode 100644 index 00000000000..3bc02362b55 --- /dev/null +++ b/extensions/browser/src/browser/cdp.helpers.ts @@ -0,0 +1,280 @@ +import WebSocket from "ws"; +import { isLoopbackHost } from "../gateway/net.js"; +import { type SsrFPolicy, resolvePinnedHostnameWithPolicy } from "../infra/net/ssrf.js"; +import { rawDataToString } from "../infra/ws.js"; +import { redactSensitiveText } from "../logging/redact.js"; +import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; +import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js"; +import { resolveBrowserRateLimitMessage } from "./client-fetch.js"; + +export { isLoopbackHost }; + +/** + * Returns true when the URL uses a WebSocket protocol (ws: or wss:). + * Used to distinguish direct-WebSocket CDP endpoints + * from HTTP(S) endpoints that require /json/version discovery. + */ +export function isWebSocketUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "ws:" || parsed.protocol === "wss:"; + } catch { + return false; + } +} + +export async function assertCdpEndpointAllowed( + cdpUrl: string, + ssrfPolicy?: SsrFPolicy, +): Promise { + if (!ssrfPolicy) { + return; + } + const parsed = new URL(cdpUrl); + if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) { + throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`); + } + await resolvePinnedHostnameWithPolicy(parsed.hostname, { + policy: ssrfPolicy, + }); +} + +export function redactCdpUrl(cdpUrl: string | null | undefined): string | null | undefined { + if (typeof cdpUrl !== "string") { + return cdpUrl; + } + const trimmed = cdpUrl.trim(); + if (!trimmed) { + return trimmed; + } + try { + const parsed = new URL(trimmed); + parsed.username = ""; + parsed.password = ""; + return redactSensitiveText(parsed.toString().replace(/\/$/, "")); + } catch { + return redactSensitiveText(trimmed); + } +} + +type CdpResponse = { + id: number; + result?: unknown; + error?: { message?: string }; +}; + +type Pending = { + resolve: (value: unknown) => void; + reject: (err: Error) => void; +}; + +export type CdpSendFn = ( + method: string, + params?: Record, + sessionId?: string, +) => Promise; + +export function getHeadersWithAuth(url: string, headers: Record = {}) { + const mergedHeaders = { ...headers }; + try { + const parsed = new URL(url); + const hasAuthHeader = Object.keys(mergedHeaders).some( + (key) => key.toLowerCase() === "authorization", + ); + if (hasAuthHeader) { + return mergedHeaders; + } + if (parsed.username || parsed.password) { + const auth = Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64"); + return { ...mergedHeaders, Authorization: `Basic ${auth}` }; + } + } catch { + // ignore + } + return mergedHeaders; +} + +export function appendCdpPath(cdpUrl: string, path: string): string { + const url = new URL(cdpUrl); + const basePath = url.pathname.replace(/\/$/, ""); + const suffix = path.startsWith("/") ? path : `/${path}`; + url.pathname = `${basePath}${suffix}`; + return url.toString(); +} + +export function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string { + try { + const url = new URL(cdpUrl); + if (url.protocol === "ws:") { + url.protocol = "http:"; + } else if (url.protocol === "wss:") { + url.protocol = "https:"; + } + url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, ""); + url.pathname = url.pathname.replace(/\/cdp$/, ""); + return url.toString().replace(/\/$/, ""); + } catch { + // Best-effort fallback for non-URL-ish inputs. + return cdpUrl + .replace(/^ws:/, "http:") + .replace(/^wss:/, "https:") + .replace(/\/devtools\/browser\/.*$/, "") + .replace(/\/cdp$/, "") + .replace(/\/$/, ""); + } +} + +function createCdpSender(ws: WebSocket) { + let nextId = 1; + const pending = new Map(); + + const send: CdpSendFn = ( + method: string, + params?: Record, + sessionId?: string, + ) => { + const id = nextId++; + const msg = { id, method, params, sessionId }; + ws.send(JSON.stringify(msg)); + return new Promise((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( + url: string, + timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS, + init?: RequestInit, +): Promise { + const res = await fetchCdpChecked(url, timeoutMs, init); + return (await res.json()) as T; +} + +export async function fetchCdpChecked( + url: string, + timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS, + init?: RequestInit, +): Promise { + const ctrl = new AbortController(); + const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs); + try { + const headers = getHeadersWithAuth(url, (init?.headers as Record) || {}); + const res = await withNoProxyForCdpUrl(url, () => + fetch(url, { ...init, headers, signal: ctrl.signal }), + ); + if (!res.ok) { + if (res.status === 429) { + // Do not reflect upstream response text into the error surface (log/agent injection risk) + throw new Error(`${resolveBrowserRateLimitMessage(url)} Do NOT retry the browser tool.`); + } + throw new Error(`HTTP ${res.status}`); + } + return res; + } finally { + clearTimeout(t); + } +} + +export async function fetchOk( + url: string, + timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS, + init?: RequestInit, +): Promise { + await fetchCdpChecked(url, timeoutMs, init); +} + +export function openCdpWebSocket( + wsUrl: string, + opts?: { headers?: Record; handshakeTimeoutMs?: number }, +): WebSocket { + const headers = getHeadersWithAuth(wsUrl, opts?.headers ?? {}); + const handshakeTimeoutMs = + typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs) + ? Math.max(1, Math.floor(opts.handshakeTimeoutMs)) + : CDP_WS_HANDSHAKE_TIMEOUT_MS; + const agent = getDirectAgentForCdp(wsUrl); + return new WebSocket(wsUrl, { + handshakeTimeout: handshakeTimeoutMs, + ...(Object.keys(headers).length ? { headers } : {}), + ...(agent ? { agent } : {}), + }); +} + +export async function withCdpSocket( + wsUrl: string, + fn: (send: CdpSendFn) => Promise, + opts?: { headers?: Record; handshakeTimeoutMs?: number }, +): Promise { + const ws = openCdpWebSocket(wsUrl, opts); + const { send, closeWithError } = createCdpSender(ws); + + const openPromise = new Promise((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 + } + } +} diff --git a/extensions/browser/src/browser/cdp.ts b/extensions/browser/src/browser/cdp.ts new file mode 100644 index 00000000000..d8b9994089b --- /dev/null +++ b/extensions/browser/src/browser/cdp.ts @@ -0,0 +1,485 @@ +import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { + appendCdpPath, + fetchJson, + isLoopbackHost, + isWebSocketUrl, + withCdpSocket, +} from "./cdp.helpers.js"; +import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js"; + +export { + appendCdpPath, + fetchJson, + fetchOk, + getHeadersWithAuth, + isWebSocketUrl, +} from "./cdp.helpers.js"; + +export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string { + const ws = new URL(wsUrl); + const cdp = new URL(cdpUrl); + // Treat 0.0.0.0 and :: as wildcard bind addresses that need rewriting. + // Containerized browsers (e.g. browserless) report ws://0.0.0.0: + // in /json/version — these must be rewritten to the external cdpUrl host:port. + const isWildcardBind = ws.hostname === "0.0.0.0" || ws.hostname === "[::]"; + if ((isLoopbackHost(ws.hostname) || isWildcardBind) && !isLoopbackHost(cdp.hostname)) { + ws.hostname = cdp.hostname; + const cdpPort = cdp.port || (cdp.protocol === "https:" ? "443" : "80"); + if (cdpPort) { + ws.port = cdpPort; + } + ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:"; + } + if (cdp.protocol === "https:" && ws.protocol === "ws:") { + ws.protocol = "wss:"; + } + if (!ws.username && !ws.password && (cdp.username || cdp.password)) { + ws.username = cdp.username; + ws.password = cdp.password; + } + for (const [key, value] of cdp.searchParams.entries()) { + if (!ws.searchParams.has(key)) { + ws.searchParams.append(key, value); + } + } + return ws.toString(); +} + +export async function captureScreenshotPng(opts: { + wsUrl: string; + fullPage?: boolean; +}): Promise { + 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 { + return await withCdpSocket(opts.wsUrl, async (send) => { + await send("Page.enable"); + + let clip: { x: number; y: number; width: number; height: number; scale: number } | undefined; + if (opts.fullPage) { + const metrics = (await send("Page.getLayoutMetrics")) as { + cssContentSize?: { width?: number; height?: number }; + contentSize?: { width?: number; height?: number }; + }; + const size = metrics?.cssContentSize ?? metrics?.contentSize; + const width = Number(size?.width ?? 0); + const height = Number(size?.height ?? 0); + if (width > 0 && height > 0) { + clip = { x: 0, y: 0, width, height, scale: 1 }; + } + } + + const format = opts.format ?? "png"; + const quality = + format === "jpeg" ? Math.max(0, Math.min(100, Math.round(opts.quality ?? 85))) : undefined; + + const result = (await send("Page.captureScreenshot", { + format, + ...(quality !== undefined ? { quality } : {}), + fromSurface: true, + captureBeyondViewport: true, + ...(clip ? { clip } : {}), + })) as { data?: string }; + + const base64 = result?.data; + if (!base64) { + throw new Error("Screenshot failed: missing data"); + } + return Buffer.from(base64, "base64"); + }); +} + +export async function createTargetViaCdp(opts: { + cdpUrl: string; + url: string; + ssrfPolicy?: SsrFPolicy; +}): Promise<{ targetId: string }> { + await assertBrowserNavigationAllowed({ + url: opts.url, + ...withBrowserNavigationPolicy(opts.ssrfPolicy), + }); + + let wsUrl: string; + if (isWebSocketUrl(opts.cdpUrl)) { + // Direct WebSocket URL — skip /json/version discovery. + wsUrl = opts.cdpUrl; + } else { + // Standard HTTP(S) CDP endpoint — discover WebSocket URL via /json/version. + const version = await fetchJson<{ webSocketDebuggerUrl?: string }>( + appendCdpPath(opts.cdpUrl, "/json/version"), + 1500, + ); + const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim(); + wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : ""; + if (!wsUrl) { + throw new Error("CDP /json/version missing webSocketDebuggerUrl"); + } + } + + return await withCdpSocket(wsUrl, async (send) => { + const created = (await send("Target.createTarget", { url: opts.url })) as { + targetId?: string; + }; + const targetId = String(created?.targetId ?? "").trim(); + if (!targetId) { + throw new Error("CDP Target.createTarget returned no targetId"); + } + return { targetId }; + }); +} + +export type CdpRemoteObject = { + type: string; + subtype?: string; + value?: unknown; + description?: string; + unserializableValue?: string; + preview?: unknown; +}; + +export type CdpExceptionDetails = { + text?: string; + lineNumber?: number; + columnNumber?: number; + exception?: CdpRemoteObject; + stackTrace?: unknown; +}; + +export async function evaluateJavaScript(opts: { + wsUrl: string; + expression: string; + awaitPromise?: boolean; + returnByValue?: boolean; +}): Promise<{ + result: CdpRemoteObject; + exceptionDetails?: CdpExceptionDetails; +}> { + return await withCdpSocket(opts.wsUrl, async (send) => { + await send("Runtime.enable").catch(() => {}); + const evaluated = (await send("Runtime.evaluate", { + expression: opts.expression, + awaitPromise: Boolean(opts.awaitPromise), + returnByValue: opts.returnByValue ?? true, + userGesture: true, + includeCommandLineAPI: true, + })) as { + result?: CdpRemoteObject; + exceptionDetails?: CdpExceptionDetails; + }; + + const result = evaluated?.result; + if (!result) { + throw new Error("CDP Runtime.evaluate returned no result"); + } + return { result, exceptionDetails: evaluated.exceptionDetails }; + }); +} + +export type AriaSnapshotNode = { + ref: string; + role: string; + name: string; + value?: string; + description?: string; + backendDOMNodeId?: number; + depth: number; +}; + +export type RawAXNode = { + nodeId?: string; + role?: { value?: string }; + name?: { value?: string }; + value?: { value?: string }; + description?: { value?: string }; + childIds?: string[]; + backendDOMNodeId?: number; +}; + +function axValue(v: unknown): string { + if (!v || typeof v !== "object") { + return ""; + } + const value = (v as { value?: unknown }).value; + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return ""; +} + +export function formatAriaSnapshot(nodes: RawAXNode[], limit: number): AriaSnapshotNode[] { + const byId = new Map(); + 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(); + 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"; + 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; +}; diff --git a/extensions/browser/src/browser/chrome-mcp.snapshot.ts b/extensions/browser/src/browser/chrome-mcp.snapshot.ts new file mode 100644 index 00000000000..f0a1413736a --- /dev/null +++ b/extensions/browser/src/browser/chrome-mcp.snapshot.ts @@ -0,0 +1,193 @@ +import type { SnapshotAriaNode } from "./client.js"; +import { + getRoleSnapshotStats, + type RoleRefMap, + type RoleSnapshotOptions, +} from "./pw-role-snapshot.js"; +import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js"; + +export type ChromeMcpSnapshotNode = { + id?: string; + role?: string; + name?: string; + value?: string | number | boolean; + description?: string; + children?: ChromeMcpSnapshotNode[]; +}; + +function normalizeRole(node: ChromeMcpSnapshotNode): string { + const role = typeof node.role === "string" ? node.role.trim().toLowerCase() : ""; + return role || "generic"; +} + +function normalizeString(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed || undefined; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return undefined; +} + +function escapeQuoted(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} + +function shouldIncludeNode(params: { + role: string; + name?: string; + options?: RoleSnapshotOptions; +}): boolean { + if (params.options?.interactive && !INTERACTIVE_ROLES.has(params.role)) { + return false; + } + if (params.options?.compact && STRUCTURAL_ROLES.has(params.role) && !params.name) { + return false; + } + return true; +} + +function shouldCreateRef(role: string, name?: string): boolean { + return INTERACTIVE_ROLES.has(role) || (CONTENT_ROLES.has(role) && Boolean(name)); +} + +type DuplicateTracker = { + counts: Map; + keysByRef: Map; + duplicates: Set; +}; + +function createDuplicateTracker(): DuplicateTracker { + return { + counts: new Map(), + keysByRef: new Map(), + duplicates: new Set(), + }; +} + +function registerRef( + tracker: DuplicateTracker, + ref: string, + role: string, + name?: string, +): number | undefined { + const key = `${role}:${name ?? ""}`; + const count = tracker.counts.get(key) ?? 0; + tracker.counts.set(key, count + 1); + tracker.keysByRef.set(ref, key); + if (count > 0) { + tracker.duplicates.add(key); + return count; + } + return undefined; +} + +export function flattenChromeMcpSnapshotToAriaNodes( + root: ChromeMcpSnapshotNode, + limit = 500, +): SnapshotAriaNode[] { + const boundedLimit = Math.max(1, Math.min(2000, Math.floor(limit))); + const out: SnapshotAriaNode[] = []; + + const visit = (node: ChromeMcpSnapshotNode, depth: number) => { + if (out.length >= boundedLimit) { + return; + } + const ref = normalizeString(node.id); + if (ref) { + out.push({ + ref, + role: normalizeRole(node), + name: normalizeString(node.name) ?? "", + value: normalizeString(node.value), + description: normalizeString(node.description), + depth, + }); + } + for (const child of node.children ?? []) { + visit(child, depth + 1); + if (out.length >= boundedLimit) { + return; + } + } + }; + + visit(root, 0); + return out; +} + +export function buildAiSnapshotFromChromeMcpSnapshot(params: { + root: ChromeMcpSnapshotNode; + options?: RoleSnapshotOptions; + maxChars?: number; +}): { + snapshot: string; + truncated?: boolean; + refs: RoleRefMap; + stats: { lines: number; chars: number; refs: number; interactive: number }; +} { + const refs: RoleRefMap = {}; + const tracker = createDuplicateTracker(); + const lines: string[] = []; + + const visit = (node: ChromeMcpSnapshotNode, depth: number) => { + const role = normalizeRole(node); + const name = normalizeString(node.name); + const value = normalizeString(node.value); + const description = normalizeString(node.description); + const maxDepth = params.options?.maxDepth; + if (maxDepth !== undefined && depth > maxDepth) { + return; + } + + const includeNode = shouldIncludeNode({ role, name, options: params.options }); + if (includeNode) { + let line = `${" ".repeat(depth)}- ${role}`; + if (name) { + line += ` "${escapeQuoted(name)}"`; + } + const ref = normalizeString(node.id); + if (ref && shouldCreateRef(role, name)) { + const nth = registerRef(tracker, ref, role, name); + refs[ref] = nth === undefined ? { role, name } : { role, name, nth }; + line += ` [ref=${ref}]`; + } + if (value) { + line += ` value="${escapeQuoted(value)}"`; + } + if (description) { + line += ` description="${escapeQuoted(description)}"`; + } + lines.push(line); + } + + for (const child of node.children ?? []) { + visit(child, depth + 1); + } + }; + + visit(params.root, 0); + + for (const [ref, data] of Object.entries(refs)) { + const key = tracker.keysByRef.get(ref); + if (key && !tracker.duplicates.has(key)) { + delete data.nth; + } + } + + let snapshot = lines.join("\n"); + let truncated = false; + const maxChars = + typeof params.maxChars === "number" && Number.isFinite(params.maxChars) && params.maxChars > 0 + ? Math.floor(params.maxChars) + : undefined; + if (maxChars && snapshot.length > maxChars) { + snapshot = `${snapshot.slice(0, maxChars)}\n\n[...TRUNCATED - page too large]`; + truncated = true; + } + + const stats = getRoleSnapshotStats(snapshot, refs); + return truncated ? { snapshot, truncated, refs, stats } : { snapshot, refs, stats }; +} diff --git a/extensions/browser/src/browser/chrome-mcp.ts b/extensions/browser/src/browser/chrome-mcp.ts new file mode 100644 index 00000000000..bc724d2eaea --- /dev/null +++ b/extensions/browser/src/browser/chrome-mcp.ts @@ -0,0 +1,650 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js"; +import type { BrowserTab } from "./client.js"; +import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js"; + +type ChromeMcpStructuredPage = { + id: number; + url?: string; + selected?: boolean; +}; + +type ChromeMcpToolResult = { + structuredContent?: Record; + content?: Array>; + isError?: boolean; +}; + +type ChromeMcpSession = { + client: Client; + transport: StdioClientTransport; + ready: Promise; +}; + +type ChromeMcpSessionFactory = ( + profileName: string, + userDataDir?: string, +) => Promise; + +const DEFAULT_CHROME_MCP_COMMAND = "npx"; +const DEFAULT_CHROME_MCP_ARGS = [ + "-y", + "chrome-devtools-mcp@latest", + "--autoConnect", + // Direct chrome-devtools-mcp launches do not enable structuredContent by default. + "--experimentalStructuredContent", + "--experimental-page-id-routing", +]; + +const sessions = new Map(); +const pendingSessions = new Map>(); +let sessionFactory: ChromeMcpSessionFactory | null = null; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asPages(value: unknown): ChromeMcpStructuredPage[] { + if (!Array.isArray(value)) { + return []; + } + const out: ChromeMcpStructuredPage[] = []; + for (const entry of value) { + const record = asRecord(entry); + if (!record || typeof record.id !== "number") { + continue; + } + out.push({ + id: record.id, + url: typeof record.url === "string" ? record.url : undefined, + selected: record.selected === true, + }); + } + return out; +} + +function parsePageId(targetId: string): number { + const parsed = Number.parseInt(targetId.trim(), 10); + if (!Number.isFinite(parsed)) { + throw new BrowserTabNotFoundError(); + } + return parsed; +} + +function toBrowserTabs(pages: ChromeMcpStructuredPage[]): BrowserTab[] { + return pages.map((page) => ({ + targetId: String(page.id), + title: "", + url: page.url ?? "", + type: "page", + })); +} + +function extractStructuredContent(result: ChromeMcpToolResult): Record { + return asRecord(result.structuredContent) ?? {}; +} + +function extractTextContent(result: ChromeMcpToolResult): string[] { + const content = Array.isArray(result.content) ? result.content : []; + return content + .map((entry) => { + const record = asRecord(entry); + return record && typeof record.text === "string" ? record.text : ""; + }) + .filter(Boolean); +} + +function extractTextPages(result: ChromeMcpToolResult): ChromeMcpStructuredPage[] { + const pages: ChromeMcpStructuredPage[] = []; + for (const block of extractTextContent(result)) { + for (const line of block.split(/\r?\n/)) { + const match = line.match(/^\s*(\d+):\s+(.+?)(?:\s+\[(selected)\])?\s*$/i); + if (!match) { + continue; + } + pages.push({ + id: Number.parseInt(match[1] ?? "", 10), + url: match[2]?.trim() || undefined, + selected: Boolean(match[3]), + }); + } + } + return pages; +} + +function extractStructuredPages(result: ChromeMcpToolResult): ChromeMcpStructuredPage[] { + const structured = asPages(extractStructuredContent(result).pages); + return structured.length > 0 ? structured : extractTextPages(result); +} + +function extractSnapshot(result: ChromeMcpToolResult): ChromeMcpSnapshotNode { + const structured = extractStructuredContent(result); + const snapshot = asRecord(structured.snapshot); + if (!snapshot) { + throw new Error("Chrome MCP snapshot response was missing structured snapshot data."); + } + return snapshot as unknown as ChromeMcpSnapshotNode; +} + +function extractJsonBlock(text: string): unknown { + const match = text.match(/```json\s*([\s\S]*?)\s*```/i); + const raw = match?.[1]?.trim() || text.trim(); + return raw ? JSON.parse(raw) : null; +} + +function extractMessageText(result: ChromeMcpToolResult): string { + const message = extractStructuredContent(result).message; + if (typeof message === "string" && message.trim()) { + return message; + } + const blocks = extractTextContent(result); + return blocks.find((block) => block.trim()) ?? ""; +} + +function extractToolErrorMessage(result: ChromeMcpToolResult, name: string): string { + const message = extractMessageText(result).trim(); + return message || `Chrome MCP tool "${name}" failed.`; +} + +function extractJsonMessage(result: ChromeMcpToolResult): unknown { + const candidates = [extractMessageText(result), ...extractTextContent(result)].filter((text) => + text.trim(), + ); + let lastError: unknown; + for (const candidate of candidates) { + try { + return extractJsonBlock(candidate); + } catch (err) { + lastError = err; + } + } + if (lastError) { + throw lastError; + } + return null; +} + +function normalizeChromeMcpUserDataDir(userDataDir?: string): string | undefined { + const trimmed = userDataDir?.trim(); + return trimmed ? trimmed : undefined; +} + +function buildChromeMcpSessionCacheKey(profileName: string, userDataDir?: string): string { + return JSON.stringify([profileName, normalizeChromeMcpUserDataDir(userDataDir) ?? ""]); +} + +function cacheKeyMatchesProfileName(cacheKey: string, profileName: string): boolean { + try { + const parsed = JSON.parse(cacheKey); + return Array.isArray(parsed) && parsed[0] === profileName; + } catch { + return false; + } +} + +async function closeChromeMcpSessionsForProfile( + profileName: string, + keepKey?: string, +): Promise { + let closed = false; + + for (const key of Array.from(pendingSessions.keys())) { + if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) { + pendingSessions.delete(key); + closed = true; + } + } + + for (const [key, session] of Array.from(sessions.entries())) { + if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) { + sessions.delete(key); + closed = true; + await session.client.close().catch(() => {}); + } + } + + return closed; +} + +export function buildChromeMcpArgs(userDataDir?: string): string[] { + const normalizedUserDataDir = normalizeChromeMcpUserDataDir(userDataDir); + return normalizedUserDataDir + ? [...DEFAULT_CHROME_MCP_ARGS, "--userDataDir", normalizedUserDataDir] + : [...DEFAULT_CHROME_MCP_ARGS]; +} + +async function createRealSession( + profileName: string, + userDataDir?: string, +): Promise { + const transport = new StdioClientTransport({ + command: DEFAULT_CHROME_MCP_COMMAND, + args: buildChromeMcpArgs(userDataDir), + stderr: "pipe", + }); + const client = new Client( + { + name: "openclaw-browser", + version: "0.0.0", + }, + {}, + ); + + const ready = (async () => { + try { + await client.connect(transport); + const tools = await client.listTools(); + if (!tools.tools.some((tool) => tool.name === "list_pages")) { + throw new Error("Chrome MCP server did not expose the expected navigation tools."); + } + } catch (err) { + await client.close().catch(() => {}); + const targetLabel = userDataDir + ? `the configured Chromium user data dir (${userDataDir})` + : "Google Chrome's default profile"; + throw new BrowserProfileUnavailableError( + `Chrome MCP existing-session attach failed for profile "${profileName}". ` + + `Make sure ${targetLabel} is running locally with remote debugging enabled. ` + + `Details: ${String(err)}`, + ); + } + })(); + + return { + client, + transport, + ready, + }; +} + +async function getSession(profileName: string, userDataDir?: string): Promise { + const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir); + await closeChromeMcpSessionsForProfile(profileName, cacheKey); + + let session = sessions.get(cacheKey); + if (session && session.transport.pid === null) { + sessions.delete(cacheKey); + session = undefined; + } + if (!session) { + let pending = pendingSessions.get(cacheKey); + if (!pending) { + pending = (async () => { + const created = await (sessionFactory ?? createRealSession)(profileName, userDataDir); + if (pendingSessions.get(cacheKey) === pending) { + sessions.set(cacheKey, created); + } else { + await created.client.close().catch(() => {}); + } + return created; + })(); + pendingSessions.set(cacheKey, pending); + } + try { + session = await pending; + } finally { + if (pendingSessions.get(cacheKey) === pending) { + pendingSessions.delete(cacheKey); + } + } + } + try { + await session.ready; + return session; + } catch (err) { + const current = sessions.get(cacheKey); + if (current?.transport === session.transport) { + sessions.delete(cacheKey); + } + throw err; + } +} + +async function callTool( + profileName: string, + userDataDir: string | undefined, + name: string, + args: Record = {}, +): Promise { + const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir); + const session = await getSession(profileName, userDataDir); + let result: ChromeMcpToolResult; + try { + result = (await session.client.callTool({ + name, + arguments: args, + })) as ChromeMcpToolResult; + } catch (err) { + // Transport/connection error — tear down session so it reconnects on next call + sessions.delete(cacheKey); + await session.client.close().catch(() => {}); + throw err; + } + // Tool-level errors (element not found, script error, etc.) don't indicate a + // broken connection — don't tear down the session for these. + if (result.isError) { + throw new Error(extractToolErrorMessage(result, name)); + } + return result; +} + +async function withTempFile(fn: (filePath: string) => Promise): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-mcp-")); + const filePath = path.join(dir, randomUUID()); + try { + return await fn(filePath); + } finally { + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); + } +} + +async function findPageById( + profileName: string, + pageId: number, + userDataDir?: string, +): Promise { + const pages = await listChromeMcpPages(profileName, userDataDir); + const page = pages.find((entry) => entry.id === pageId); + if (!page) { + throw new BrowserTabNotFoundError(); + } + return page; +} + +export async function ensureChromeMcpAvailable( + profileName: string, + userDataDir?: string, +): Promise { + await getSession(profileName, userDataDir); +} + +export function getChromeMcpPid(profileName: string): number | null { + for (const [key, session] of sessions.entries()) { + if (cacheKeyMatchesProfileName(key, profileName)) { + return session.transport.pid ?? null; + } + } + return null; +} + +export async function closeChromeMcpSession(profileName: string): Promise { + return await closeChromeMcpSessionsForProfile(profileName); +} + +export async function stopAllChromeMcpSessions(): Promise { + const names = [...new Set([...sessions.keys()].map((key) => JSON.parse(key)[0] as string))]; + for (const name of names) { + await closeChromeMcpSession(name).catch(() => {}); + } +} + +export async function listChromeMcpPages( + profileName: string, + userDataDir?: string, +): Promise { + const result = await callTool(profileName, userDataDir, "list_pages"); + return extractStructuredPages(result); +} + +export async function listChromeMcpTabs( + profileName: string, + userDataDir?: string, +): Promise { + return toBrowserTabs(await listChromeMcpPages(profileName, userDataDir)); +} + +export async function openChromeMcpTab( + profileName: string, + url: string, + userDataDir?: string, +): Promise { + const result = await callTool(profileName, userDataDir, "new_page", { url }); + const pages = extractStructuredPages(result); + const chosen = pages.find((page) => page.selected) ?? pages.at(-1); + if (!chosen) { + throw new Error("Chrome MCP did not return the created page."); + } + return { + targetId: String(chosen.id), + title: "", + url: chosen.url ?? url, + type: "page", + }; +} + +export async function focusChromeMcpTab( + profileName: string, + targetId: string, + userDataDir?: string, +): Promise { + await callTool(profileName, userDataDir, "select_page", { + pageId: parsePageId(targetId), + bringToFront: true, + }); +} + +export async function closeChromeMcpTab( + profileName: string, + targetId: string, + userDataDir?: string, +): Promise { + await callTool(profileName, userDataDir, "close_page", { pageId: parsePageId(targetId) }); +} + +export async function navigateChromeMcpPage(params: { + profileName: string; + userDataDir?: string; + targetId: string; + url: string; + timeoutMs?: number; +}): Promise<{ url: string }> { + await callTool(params.profileName, params.userDataDir, "navigate_page", { + pageId: parsePageId(params.targetId), + type: "url", + url: params.url, + ...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}), + }); + const page = await findPageById( + params.profileName, + parsePageId(params.targetId), + params.userDataDir, + ); + return { url: page.url ?? params.url }; +} + +export async function takeChromeMcpSnapshot(params: { + profileName: string; + userDataDir?: string; + targetId: string; +}): Promise { + const result = await callTool(params.profileName, params.userDataDir, "take_snapshot", { + pageId: parsePageId(params.targetId), + }); + return extractSnapshot(result); +} + +export async function takeChromeMcpScreenshot(params: { + profileName: string; + userDataDir?: string; + targetId: string; + uid?: string; + fullPage?: boolean; + format?: "png" | "jpeg"; +}): Promise { + return await withTempFile(async (filePath) => { + await callTool(params.profileName, params.userDataDir, "take_screenshot", { + pageId: parsePageId(params.targetId), + filePath, + format: params.format ?? "png", + ...(params.uid ? { uid: params.uid } : {}), + ...(params.fullPage ? { fullPage: true } : {}), + }); + return await fs.readFile(filePath); + }); +} + +export async function clickChromeMcpElement(params: { + profileName: string; + userDataDir?: string; + targetId: string; + uid: string; + doubleClick?: boolean; +}): Promise { + await callTool(params.profileName, params.userDataDir, "click", { + pageId: parsePageId(params.targetId), + uid: params.uid, + ...(params.doubleClick ? { dblClick: true } : {}), + }); +} + +export async function fillChromeMcpElement(params: { + profileName: string; + userDataDir?: string; + targetId: string; + uid: string; + value: string; +}): Promise { + await callTool(params.profileName, params.userDataDir, "fill", { + pageId: parsePageId(params.targetId), + uid: params.uid, + value: params.value, + }); +} + +export async function fillChromeMcpForm(params: { + profileName: string; + userDataDir?: string; + targetId: string; + elements: Array<{ uid: string; value: string }>; +}): Promise { + await callTool(params.profileName, params.userDataDir, "fill_form", { + pageId: parsePageId(params.targetId), + elements: params.elements, + }); +} + +export async function hoverChromeMcpElement(params: { + profileName: string; + userDataDir?: string; + targetId: string; + uid: string; +}): Promise { + await callTool(params.profileName, params.userDataDir, "hover", { + pageId: parsePageId(params.targetId), + uid: params.uid, + }); +} + +export async function dragChromeMcpElement(params: { + profileName: string; + userDataDir?: string; + targetId: string; + fromUid: string; + toUid: string; +}): Promise { + await callTool(params.profileName, params.userDataDir, "drag", { + pageId: parsePageId(params.targetId), + from_uid: params.fromUid, + to_uid: params.toUid, + }); +} + +export async function uploadChromeMcpFile(params: { + profileName: string; + userDataDir?: string; + targetId: string; + uid: string; + filePath: string; +}): Promise { + await callTool(params.profileName, params.userDataDir, "upload_file", { + pageId: parsePageId(params.targetId), + uid: params.uid, + filePath: params.filePath, + }); +} + +export async function pressChromeMcpKey(params: { + profileName: string; + userDataDir?: string; + targetId: string; + key: string; +}): Promise { + await callTool(params.profileName, params.userDataDir, "press_key", { + pageId: parsePageId(params.targetId), + key: params.key, + }); +} + +export async function resizeChromeMcpPage(params: { + profileName: string; + userDataDir?: string; + targetId: string; + width: number; + height: number; +}): Promise { + await callTool(params.profileName, params.userDataDir, "resize_page", { + pageId: parsePageId(params.targetId), + width: params.width, + height: params.height, + }); +} + +export async function handleChromeMcpDialog(params: { + profileName: string; + userDataDir?: string; + targetId: string; + action: "accept" | "dismiss"; + promptText?: string; +}): Promise { + await callTool(params.profileName, params.userDataDir, "handle_dialog", { + pageId: parsePageId(params.targetId), + action: params.action, + ...(params.promptText ? { promptText: params.promptText } : {}), + }); +} + +export async function evaluateChromeMcpScript(params: { + profileName: string; + userDataDir?: string; + targetId: string; + fn: string; + args?: string[]; +}): Promise { + const result = await callTool(params.profileName, params.userDataDir, "evaluate_script", { + pageId: parsePageId(params.targetId), + function: params.fn, + ...(params.args?.length ? { args: params.args } : {}), + }); + return extractJsonMessage(result); +} + +export async function waitForChromeMcpText(params: { + profileName: string; + userDataDir?: string; + targetId: string; + text: string[]; + timeoutMs?: number; +}): Promise { + await callTool(params.profileName, params.userDataDir, "wait_for", { + pageId: parsePageId(params.targetId), + text: params.text, + ...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}), + }); +} + +export function setChromeMcpSessionFactoryForTest(factory: ChromeMcpSessionFactory | null): void { + sessionFactory = factory; +} + +export async function resetChromeMcpSessionsForTest(): Promise { + sessionFactory = null; + pendingSessions.clear(); + await stopAllChromeMcpSessions(); +} diff --git a/extensions/browser/src/browser/chrome-user-data-dir.test-harness.ts b/extensions/browser/src/browser/chrome-user-data-dir.test-harness.ts new file mode 100644 index 00000000000..e3edce48acd --- /dev/null +++ b/extensions/browser/src/browser/chrome-user-data-dir.test-harness.ts @@ -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 }); + }); +} diff --git a/extensions/browser/src/browser/chrome.executables.ts b/extensions/browser/src/browser/chrome.executables.ts new file mode 100644 index 00000000000..9a45e92a0a9 --- /dev/null +++ b/extensions/browser/src/browser/chrome.executables.ts @@ -0,0 +1,721 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { ResolvedBrowserConfig } from "./config.js"; + +export type BrowserExecutable = { + kind: "brave" | "canary" | "chromium" | "chrome" | "custom" | "edge"; + path: string; +}; + +const CHROME_VERSION_RE = /(\d+)(?:\.\d+){0,3}/; + +const CHROMIUM_BUNDLE_IDS = new Set([ + "com.google.Chrome", + "com.google.Chrome.beta", + "com.google.Chrome.canary", + "com.google.Chrome.dev", + "com.brave.Browser", + "com.brave.Browser.beta", + "com.brave.Browser.nightly", + "com.microsoft.Edge", + "com.microsoft.EdgeBeta", + "com.microsoft.EdgeDev", + "com.microsoft.EdgeCanary", + // Edge LaunchServices IDs (used in macOS default browser registration — + // these differ from CFBundleIdentifier and are what plutil returns) + "com.microsoft.edgemac", + "com.microsoft.edgemac.beta", + "com.microsoft.edgemac.dev", + "com.microsoft.edgemac.canary", + "org.chromium.Chromium", + "com.vivaldi.Vivaldi", + "com.operasoftware.Opera", + "com.operasoftware.OperaGX", + "com.yandex.desktop.yandex-browser", + "company.thebrowser.Browser", // Arc +]); + +const CHROMIUM_DESKTOP_IDS = new Set([ + "google-chrome.desktop", + "google-chrome-beta.desktop", + "google-chrome-unstable.desktop", + "brave-browser.desktop", + "microsoft-edge.desktop", + "microsoft-edge-beta.desktop", + "microsoft-edge-dev.desktop", + "microsoft-edge-canary.desktop", + "chromium.desktop", + "chromium-browser.desktop", + "vivaldi.desktop", + "vivaldi-stable.desktop", + "opera.desktop", + "opera-gx.desktop", + "yandex-browser.desktop", + "org.chromium.Chromium.desktop", +]); + +const CHROMIUM_EXE_NAMES = new Set([ + "chrome.exe", + "msedge.exe", + "brave.exe", + "brave-browser.exe", + "chromium.exe", + "vivaldi.exe", + "opera.exe", + "launcher.exe", + "yandex.exe", + "yandexbrowser.exe", + // mac/linux names + "google chrome", + "google chrome canary", + "brave browser", + "microsoft edge", + "chromium", + "chrome", + "brave", + "msedge", + "brave-browser", + "google-chrome", + "google-chrome-stable", + "google-chrome-beta", + "google-chrome-unstable", + "microsoft-edge", + "microsoft-edge-beta", + "microsoft-edge-dev", + "microsoft-edge-canary", + "chromium-browser", + "vivaldi", + "vivaldi-stable", + "opera", + "opera-stable", + "opera-gx", + "yandex-browser", +]); + +function exists(filePath: string) { + try { + return fs.existsSync(filePath); + } catch { + return false; + } +} + +function execText( + command: string, + args: string[], + timeoutMs = 1200, + maxBuffer = 1024 * 1024, +): string | null { + try { + const output = execFileSync(command, args, { + timeout: timeoutMs, + encoding: "utf8", + maxBuffer, + }); + return String(output ?? "").trim() || null; + } catch { + return null; + } +} + +function inferKindFromIdentifier(identifier: string): BrowserExecutable["kind"] { + const id = identifier.toLowerCase(); + if (id.includes("brave")) { + return "brave"; + } + if (id.includes("edge")) { + return "edge"; + } + if (id.includes("chromium")) { + return "chromium"; + } + if (id.includes("canary")) { + return "canary"; + } + if ( + id.includes("opera") || + id.includes("vivaldi") || + id.includes("yandex") || + id.includes("thebrowser") + ) { + return "chromium"; + } + return "chrome"; +} + +function inferKindFromExecutableName(name: string): BrowserExecutable["kind"] { + const lower = name.toLowerCase(); + if (lower.includes("brave")) { + return "brave"; + } + if (lower.includes("edge") || lower.includes("msedge")) { + return "edge"; + } + if (lower.includes("chromium")) { + return "chromium"; + } + if (lower.includes("canary") || lower.includes("sxs")) { + return "canary"; + } + if (lower.includes("opera") || lower.includes("vivaldi") || lower.includes("yandex")) { + return "chromium"; + } + return "chrome"; +} + +function detectDefaultChromiumExecutable(platform: NodeJS.Platform): BrowserExecutable | null { + if (platform === "darwin") { + return detectDefaultChromiumExecutableMac(); + } + if (platform === "linux") { + return detectDefaultChromiumExecutableLinux(); + } + if (platform === "win32") { + return detectDefaultChromiumExecutableWindows(); + } + return null; +} + +function detectDefaultChromiumExecutableMac(): BrowserExecutable | null { + const bundleId = detectDefaultBrowserBundleIdMac(); + if (!bundleId || !CHROMIUM_BUNDLE_IDS.has(bundleId)) { + return null; + } + + const appPathRaw = execText("/usr/bin/osascript", [ + "-e", + `POSIX path of (path to application id "${bundleId}")`, + ]); + if (!appPathRaw) { + return null; + } + const appPath = appPathRaw.trim().replace(/\/$/, ""); + const exeName = execText("/usr/bin/defaults", [ + "read", + path.join(appPath, "Contents", "Info"), + "CFBundleExecutable", + ]); + if (!exeName) { + return null; + } + const exePath = path.join(appPath, "Contents", "MacOS", exeName.trim()); + if (!exists(exePath)) { + return null; + } + return { kind: inferKindFromIdentifier(bundleId), path: exePath }; +} + +function detectDefaultBrowserBundleIdMac(): string | null { + const plistPath = path.join( + os.homedir(), + "Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist", + ); + if (!exists(plistPath)) { + return null; + } + const handlersRaw = execText( + "/usr/bin/plutil", + ["-extract", "LSHandlers", "json", "-o", "-", "--", plistPath], + 2000, + 5 * 1024 * 1024, + ); + if (!handlersRaw) { + return null; + } + let handlers: unknown; + try { + handlers = JSON.parse(handlersRaw); + } catch { + return null; + } + if (!Array.isArray(handlers)) { + return null; + } + + const resolveScheme = (scheme: string) => { + let candidate: string | null = null; + for (const entry of handlers) { + if (!entry || typeof entry !== "object") { + continue; + } + const record = entry as Record; + 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 | null { + for (const candidate of candidates) { + if (exists(candidate.path)) { + return candidate; + } + } + + return null; +} + +function findFirstChromeExecutable(candidates: string[]): BrowserExecutable | null { + for (const candidate of candidates) { + if (exists(candidate)) { + return { + kind: + candidate.toLowerCase().includes("sxs") || candidate.toLowerCase().includes("canary") + ? "canary" + : "chrome", + path: candidate, + }; + } + } + + return null; +} + +export function findChromeExecutableMac(): BrowserExecutable | null { + const candidates: Array = [ + { + kind: "chrome", + path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + }, + { + kind: "chrome", + path: path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + }, + { + kind: "brave", + path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + }, + { + kind: "brave", + path: path.join(os.homedir(), "Applications/Brave Browser.app/Contents/MacOS/Brave Browser"), + }, + { + kind: "edge", + path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + }, + { + kind: "edge", + path: path.join( + os.homedir(), + "Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + ), + }, + { + kind: "chromium", + path: "/Applications/Chromium.app/Contents/MacOS/Chromium", + }, + { + kind: "chromium", + path: path.join(os.homedir(), "Applications/Chromium.app/Contents/MacOS/Chromium"), + }, + { + kind: "canary", + path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + }, + { + kind: "canary", + path: path.join( + os.homedir(), + "Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + ), + }, + ]; + + return findFirstExecutable(candidates); +} + +export function findGoogleChromeExecutableMac(): BrowserExecutable | null { + return findFirstChromeExecutable([ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + path.join( + os.homedir(), + "Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + ), + ]); +} + +export function findChromeExecutableLinux(): BrowserExecutable | null { + const candidates: Array = [ + { kind: "chrome", path: "/usr/bin/google-chrome" }, + { kind: "chrome", path: "/usr/bin/google-chrome-stable" }, + { kind: "chrome", path: "/usr/bin/chrome" }, + { kind: "brave", path: "/usr/bin/brave-browser" }, + { kind: "brave", path: "/usr/bin/brave-browser-stable" }, + { kind: "brave", path: "/usr/bin/brave" }, + { kind: "brave", path: "/snap/bin/brave" }, + { kind: "edge", path: "/usr/bin/microsoft-edge" }, + { kind: "edge", path: "/usr/bin/microsoft-edge-stable" }, + { kind: "chromium", path: "/usr/bin/chromium" }, + { kind: "chromium", path: "/usr/bin/chromium-browser" }, + { kind: "chromium", path: "/snap/bin/chromium" }, + ]; + + return findFirstExecutable(candidates); +} + +export function findGoogleChromeExecutableLinux(): BrowserExecutable | null { + return findFirstChromeExecutable([ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome-beta", + "/usr/bin/google-chrome-unstable", + "/snap/bin/google-chrome", + ]); +} + +export function findChromeExecutableWindows(): BrowserExecutable | null { + const localAppData = process.env.LOCALAPPDATA ?? ""; + const programFiles = process.env.ProgramFiles ?? "C:\\Program Files"; + // Must use bracket notation: variable name contains parentheses + const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"; + + const joinWin = path.win32.join; + const candidates: Array = []; + + if (localAppData) { + // Chrome (user install) + candidates.push({ + kind: "chrome", + path: joinWin(localAppData, "Google", "Chrome", "Application", "chrome.exe"), + }); + // Brave (user install) + candidates.push({ + kind: "brave", + path: joinWin(localAppData, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + }); + // Edge (user install) + candidates.push({ + kind: "edge", + path: joinWin(localAppData, "Microsoft", "Edge", "Application", "msedge.exe"), + }); + // Chromium (user install) + candidates.push({ + kind: "chromium", + path: joinWin(localAppData, "Chromium", "Application", "chrome.exe"), + }); + // Chrome Canary (user install) + candidates.push({ + kind: "canary", + path: joinWin(localAppData, "Google", "Chrome SxS", "Application", "chrome.exe"), + }); + } + + // Chrome (system install, 64-bit) + candidates.push({ + kind: "chrome", + path: joinWin(programFiles, "Google", "Chrome", "Application", "chrome.exe"), + }); + // Chrome (system install, 32-bit on 64-bit Windows) + candidates.push({ + kind: "chrome", + path: joinWin(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"), + }); + // Brave (system install, 64-bit) + candidates.push({ + kind: "brave", + path: joinWin(programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + }); + // Brave (system install, 32-bit on 64-bit Windows) + candidates.push({ + kind: "brave", + path: joinWin(programFilesX86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + }); + // Edge (system install, 64-bit) + candidates.push({ + kind: "edge", + path: joinWin(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"), + }); + // Edge (system install, 32-bit on 64-bit Windows) + candidates.push({ + kind: "edge", + path: joinWin(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"), + }); + + return findFirstExecutable(candidates); +} + +export function findGoogleChromeExecutableWindows(): BrowserExecutable | null { + const localAppData = process.env.LOCALAPPDATA ?? ""; + const programFiles = process.env.ProgramFiles ?? "C:\\Program Files"; + const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"; + const joinWin = path.win32.join; + const candidates: string[] = []; + + if (localAppData) { + candidates.push(joinWin(localAppData, "Google", "Chrome", "Application", "chrome.exe")); + candidates.push(joinWin(localAppData, "Google", "Chrome SxS", "Application", "chrome.exe")); + } + + candidates.push(joinWin(programFiles, "Google", "Chrome", "Application", "chrome.exe")); + candidates.push(joinWin(programFilesX86, "Google", "Chrome", "Application", "chrome.exe")); + + return findFirstChromeExecutable(candidates); +} + +export function resolveGoogleChromeExecutableForPlatform( + platform: NodeJS.Platform, +): BrowserExecutable | null { + if (platform === "darwin") { + return findGoogleChromeExecutableMac(); + } + if (platform === "linux") { + return findGoogleChromeExecutableLinux(); + } + if (platform === "win32") { + return findGoogleChromeExecutableWindows(); + } + return null; +} + +export function readBrowserVersion(executablePath: string): string | null { + const output = execText(executablePath, ["--version"], 2000); + if (!output) { + return null; + } + return output.replace(/\s+/g, " ").trim(); +} + +export function parseBrowserMajorVersion(rawVersion: string | null | undefined): number | null { + const match = String(rawVersion ?? "").match(CHROME_VERSION_RE); + if (!match?.[1]) { + return null; + } + const major = Number.parseInt(match[1], 10); + return Number.isFinite(major) ? major : null; +} + +export function resolveBrowserExecutableForPlatform( + resolved: ResolvedBrowserConfig, + platform: NodeJS.Platform, +): BrowserExecutable | null { + if (resolved.executablePath) { + if (!exists(resolved.executablePath)) { + throw new Error(`browser.executablePath not found: ${resolved.executablePath}`); + } + return { kind: "custom", path: resolved.executablePath }; + } + + const detected = detectDefaultChromiumExecutable(platform); + if (detected) { + return detected; + } + + if (platform === "darwin") { + return findChromeExecutableMac(); + } + if (platform === "linux") { + return findChromeExecutableLinux(); + } + if (platform === "win32") { + return findChromeExecutableWindows(); + } + return null; +} diff --git a/extensions/browser/src/browser/chrome.profile-decoration.ts b/extensions/browser/src/browser/chrome.profile-decoration.ts new file mode 100644 index 00000000000..8739860e2a4 --- /dev/null +++ b/extensions/browser/src/browser/chrome.profile-decoration.ts @@ -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 | 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; + } catch { + return null; + } +} + +function safeWriteJson(filePath: string, data: Record) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); +} + +function setDeep(obj: Record, keys: string[], value: unknown) { + let node: Record = 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; + } + 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).info_cache + : null; + const info = + typeof infoCache === "object" && + infoCache !== null && + !Array.isArray(infoCache) && + typeof (infoCache as Record).Default === "object" && + (infoCache as Record).Default !== null && + !Array.isArray((infoCache as Record).Default) + ? ((infoCache as Record).Default as Record) + : null; + + const prefs = safeReadJson(preferencesPath); + const browserTheme = (() => { + const browser = prefs?.browser; + const theme = + typeof browser === "object" && browser !== null && !Array.isArray(browser) + ? (browser as Record).theme + : null; + return typeof theme === "object" && theme !== null && !Array.isArray(theme) + ? (theme as Record) + : null; + })(); + + const autogeneratedTheme = (() => { + const autogenerated = prefs?.autogenerated; + const theme = + typeof autogenerated === "object" && autogenerated !== null && !Array.isArray(autogenerated) + ? (autogenerated as Record).theme + : null; + return typeof theme === "object" && theme !== null && !Array.isArray(theme) + ? (theme as Record) + : 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); +} diff --git a/extensions/browser/src/browser/chrome.ts b/extensions/browser/src/browser/chrome.ts new file mode 100644 index 00000000000..47aef9a8e3e --- /dev/null +++ b/extensions/browser/src/browser/chrome.ts @@ -0,0 +1,477 @@ +import { type ChildProcess, type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { ensurePortAvailable } from "../infra/ports.js"; +import { rawDataToString } from "../infra/ws.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { CONFIG_DIR } from "../utils.js"; +import { + CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS, + CHROME_BOOTSTRAP_PREFS_TIMEOUT_MS, + CHROME_LAUNCH_READY_POLL_MS, + CHROME_LAUNCH_READY_WINDOW_MS, + CHROME_REACHABILITY_TIMEOUT_MS, + CHROME_STDERR_HINT_MAX_CHARS, + CHROME_STOP_PROBE_TIMEOUT_MS, + CHROME_STOP_TIMEOUT_MS, + CHROME_WS_READY_TIMEOUT_MS, +} from "./cdp-timeouts.js"; +import { + appendCdpPath, + assertCdpEndpointAllowed, + fetchCdpChecked, + isWebSocketUrl, + openCdpWebSocket, +} from "./cdp.helpers.js"; +import { normalizeCdpWsUrl } from "./cdp.js"; +import { + type BrowserExecutable, + resolveBrowserExecutableForPlatform, +} from "./chrome.executables.js"; +import { + decorateOpenClawProfile, + ensureProfileCleanExit, + isProfileDecorated, +} from "./chrome.profile-decoration.js"; +import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js"; +import { + DEFAULT_OPENCLAW_BROWSER_COLOR, + DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, +} from "./constants.js"; + +const log = createSubsystemLogger("browser").child("chrome"); + +export type { BrowserExecutable } from "./chrome.executables.js"; +export { + findChromeExecutableLinux, + findChromeExecutableMac, + findChromeExecutableWindows, + resolveBrowserExecutableForPlatform, +} from "./chrome.executables.js"; +export { + decorateOpenClawProfile, + ensureProfileCleanExit, + isProfileDecorated, +} from "./chrome.profile-decoration.js"; + +function exists(filePath: string) { + try { + return fs.existsSync(filePath); + } catch { + return false; + } +} + +export type RunningChrome = { + pid: number; + exe: BrowserExecutable; + userDataDir: string; + cdpPort: number; + startedAt: number; + proc: ChildProcess; +}; + +function resolveBrowserExecutable(resolved: ResolvedBrowserConfig): BrowserExecutable | null { + return resolveBrowserExecutableForPlatform(resolved, process.platform); +} + +export function resolveOpenClawUserDataDir(profileName = DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME) { + return path.join(CONFIG_DIR, "browser", profileName, "user-data"); +} + +function cdpUrlForPort(cdpPort: number) { + return `http://127.0.0.1:${cdpPort}`; +} + +export function buildOpenClawChromeLaunchArgs(params: { + resolved: ResolvedBrowserConfig; + profile: ResolvedBrowserProfile; + userDataDir: string; +}): string[] { + const { resolved, profile, userDataDir } = params; + const args: string[] = [ + `--remote-debugging-port=${profile.cdpPort}`, + `--user-data-dir=${userDataDir}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-sync", + "--disable-background-networking", + "--disable-component-update", + "--disable-features=Translate,MediaRouter", + "--disable-session-crashed-bubble", + "--hide-crash-restore-bubble", + "--password-store=basic", + ]; + + if (resolved.headless) { + args.push("--headless=new"); + args.push("--disable-gpu"); + } + if (resolved.noSandbox) { + args.push("--no-sandbox"); + args.push("--disable-setuid-sandbox"); + } + if (process.platform === "linux") { + args.push("--disable-dev-shm-usage"); + } + if (resolved.extraArgs.length > 0) { + args.push(...resolved.extraArgs); + } + + return args; +} + +async function canOpenWebSocket(url: string, timeoutMs: number): Promise { + return new Promise((resolve) => { + const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs }); + ws.once("open", () => { + try { + ws.close(); + } catch { + // ignore + } + resolve(true); + }); + ws.once("error", () => resolve(false)); + }); +} + +export async function isChromeReachable( + cdpUrl: string, + timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, + ssrfPolicy?: SsrFPolicy, +): Promise { + try { + await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); + if (isWebSocketUrl(cdpUrl)) { + // Direct WebSocket endpoint — probe via WS handshake. + return await canOpenWebSocket(cdpUrl, timeoutMs); + } + const version = await fetchChromeVersion(cdpUrl, timeoutMs, ssrfPolicy); + return Boolean(version); + } catch { + return false; + } +} + +type ChromeVersion = { + webSocketDebuggerUrl?: string; + Browser?: string; + "User-Agent"?: string; +}; + +async function fetchChromeVersion( + cdpUrl: string, + timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, + ssrfPolicy?: SsrFPolicy, +): Promise { + const ctrl = new AbortController(); + const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs); + try { + await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); + const versionUrl = appendCdpPath(cdpUrl, "/json/version"); + const res = await fetchCdpChecked(versionUrl, timeoutMs, { signal: ctrl.signal }); + const data = (await res.json()) as ChromeVersion; + if (!data || typeof data !== "object") { + return null; + } + return data; + } catch { + return null; + } finally { + clearTimeout(t); + } +} + +export async function getChromeWebSocketUrl( + cdpUrl: string, + timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, + ssrfPolicy?: SsrFPolicy, +): Promise { + await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); + if (isWebSocketUrl(cdpUrl)) { + // Direct WebSocket endpoint — the cdpUrl is already the WebSocket URL. + return cdpUrl; + } + const version = await fetchChromeVersion(cdpUrl, timeoutMs, ssrfPolicy); + const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim(); + if (!wsUrl) { + return null; + } + return normalizeCdpWsUrl(wsUrl, cdpUrl); +} + +async function canRunCdpHealthCommand( + wsUrl: string, + timeoutMs = CHROME_WS_READY_TIMEOUT_MS, +): Promise { + return await new Promise((resolve) => { + const ws = openCdpWebSocket(wsUrl, { + handshakeTimeoutMs: timeoutMs, + }); + let settled = false; + const onMessage = (raw: Parameters[0]) => { + if (settled) { + return; + } + let parsed: { id?: unknown; result?: unknown } | null = null; + try { + parsed = JSON.parse(rawDataToString(raw)) as { id?: unknown; result?: unknown }; + } catch { + return; + } + if (parsed?.id !== 1) { + return; + } + finish(Boolean(parsed.result && typeof parsed.result === "object")); + }; + + const finish = (value: boolean) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + ws.off("message", onMessage); + try { + ws.close(); + } catch { + // ignore + } + resolve(value); + }; + const timer = setTimeout( + () => { + try { + ws.terminate(); + } catch { + // ignore + } + finish(false); + }, + Math.max(50, timeoutMs + 25), + ); + + ws.once("open", () => { + try { + ws.send( + JSON.stringify({ + id: 1, + method: "Browser.getVersion", + }), + ); + } catch { + finish(false); + } + }); + + ws.on("message", onMessage); + + ws.once("error", () => { + finish(false); + }); + ws.once("close", () => { + finish(false); + }); + }); +} + +export async function isChromeCdpReady( + cdpUrl: string, + timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, + handshakeTimeoutMs = CHROME_WS_READY_TIMEOUT_MS, + ssrfPolicy?: SsrFPolicy, +): Promise { + const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs, ssrfPolicy).catch(() => null); + if (!wsUrl) { + return false; + } + return await canRunCdpHealthCommand(wsUrl, handshakeTimeoutMs); +} + +export async function launchOpenClawChrome( + resolved: ResolvedBrowserConfig, + profile: ResolvedBrowserProfile, +): Promise { + if (!profile.cdpIsLoopback) { + throw new Error(`Profile "${profile.name}" is remote; cannot launch local Chrome.`); + } + await ensurePortAvailable(profile.cdpPort); + + const exe = resolveBrowserExecutable(resolved); + if (!exe) { + throw new Error( + "No supported browser found (Chrome/Brave/Edge/Chromium on macOS, Linux, or Windows).", + ); + } + + const userDataDir = resolveOpenClawUserDataDir(profile.name); + fs.mkdirSync(userDataDir, { recursive: true }); + + const needsDecorate = !isProfileDecorated( + userDataDir, + profile.name, + (profile.color ?? DEFAULT_OPENCLAW_BROWSER_COLOR).toUpperCase(), + ); + + // First launch to create preference files if missing, then decorate and relaunch. + const spawnOnce = () => { + const args = buildOpenClawChromeLaunchArgs({ + resolved, + profile, + userDataDir, + }); + // stdio tuple: discard stdout to prevent buffer saturation in constrained + // environments (e.g. Docker), while keeping stderr piped for diagnostics. + // Cast to ChildProcessWithoutNullStreams so callers can use .stderr safely; + // the tuple overload resolution varies across @types/node versions. + return spawn(exe.path, args, { + stdio: ["ignore", "ignore", "pipe"], + env: { + ...process.env, + // Reduce accidental sharing with the user's env. + HOME: os.homedir(), + }, + }) as unknown as ChildProcessWithoutNullStreams; + }; + + const startedAt = Date.now(); + + const localStatePath = path.join(userDataDir, "Local State"); + const preferencesPath = path.join(userDataDir, "Default", "Preferences"); + const needsBootstrap = !exists(localStatePath) || !exists(preferencesPath); + + // If the profile doesn't exist yet, bootstrap it once so Chrome creates defaults. + // Then decorate (if needed) before the "real" run. + if (needsBootstrap) { + const bootstrap = spawnOnce(); + const deadline = Date.now() + CHROME_BOOTSTRAP_PREFS_TIMEOUT_MS; + while (Date.now() < deadline) { + if (exists(localStatePath) && exists(preferencesPath)) { + break; + } + await new Promise((r) => setTimeout(r, 100)); + } + try { + bootstrap.kill("SIGTERM"); + } catch { + // ignore + } + const exitDeadline = Date.now() + CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS; + while (Date.now() < exitDeadline) { + if (bootstrap.exitCode != null) { + break; + } + await new Promise((r) => setTimeout(r, 50)); + } + } + + if (needsDecorate) { + try { + decorateOpenClawProfile(userDataDir, { + name: profile.name, + color: profile.color, + }); + log.info(`🦞 openclaw browser profile decorated (${profile.color})`); + } catch (err) { + log.warn(`openclaw browser profile decoration failed: ${String(err)}`); + } + } + + try { + ensureProfileCleanExit(userDataDir); + } catch (err) { + log.warn(`openclaw browser clean-exit prefs failed: ${String(err)}`); + } + + const proc = spawnOnce(); + + // Collect stderr for diagnostics in case Chrome fails to start. + // The listener is removed on success to avoid unbounded memory growth + // from a long-lived Chrome process that emits periodic warnings. + const stderrChunks: Buffer[] = []; + const onStderr = (chunk: Buffer) => { + stderrChunks.push(chunk); + }; + proc.stderr?.on("data", onStderr); + + // Wait for CDP to come up. + const readyDeadline = Date.now() + CHROME_LAUNCH_READY_WINDOW_MS; + while (Date.now() < readyDeadline) { + if (await isChromeReachable(profile.cdpUrl)) { + break; + } + await new Promise((r) => setTimeout(r, CHROME_LAUNCH_READY_POLL_MS)); + } + + if (!(await isChromeReachable(profile.cdpUrl))) { + const stderrOutput = Buffer.concat(stderrChunks).toString("utf8").trim(); + const stderrHint = stderrOutput + ? `\nChrome stderr:\n${stderrOutput.slice(0, CHROME_STDERR_HINT_MAX_CHARS)}` + : ""; + const sandboxHint = + process.platform === "linux" && !resolved.noSandbox + ? "\nHint: If running in a container or as root, try setting browser.noSandbox: true in config." + : ""; + try { + proc.kill("SIGKILL"); + } catch { + // ignore + } + throw new Error( + `Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".${sandboxHint}${stderrHint}`, + ); + } + + // Chrome started successfully — detach the stderr listener and release the buffer. + proc.stderr?.off("data", onStderr); + stderrChunks.length = 0; + + const pid = proc.pid ?? -1; + log.info( + `🦞 openclaw browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`, + ); + + return { + pid, + exe, + userDataDir, + cdpPort: profile.cdpPort, + startedAt, + proc, + }; +} + +export async function stopOpenClawChrome( + running: RunningChrome, + timeoutMs = CHROME_STOP_TIMEOUT_MS, +) { + const proc = running.proc; + if (proc.killed) { + return; + } + try { + proc.kill("SIGTERM"); + } catch { + // ignore + } + + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (!proc.exitCode && proc.killed) { + break; + } + if (!(await isChromeReachable(cdpUrlForPort(running.cdpPort), CHROME_STOP_PROBE_TIMEOUT_MS))) { + return; + } + await new Promise((r) => setTimeout(r, 100)); + } + + try { + proc.kill("SIGKILL"); + } catch { + // ignore + } +} diff --git a/extensions/browser/src/browser/client-actions-core.ts b/extensions/browser/src/browser/client-actions-core.ts new file mode 100644 index 00000000000..149ca54fadf --- /dev/null +++ b/extensions/browser/src/browser/client-actions-core.ts @@ -0,0 +1,279 @@ +import type { + BrowserActionOk, + BrowserActionPathResult, + BrowserActionTabResult, +} from "./client-actions-types.js"; +import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js"; +import { fetchBrowserJson } from "./client-fetch.js"; + +export type BrowserFormField = { + ref: string; + type: string; + value?: string | number | boolean; +}; + +export type BrowserActRequest = + | { + kind: "click"; + ref?: string; + selector?: string; + targetId?: string; + doubleClick?: boolean; + button?: string; + modifiers?: string[]; + delayMs?: number; + timeoutMs?: number; + } + | { + kind: "type"; + ref?: string; + selector?: string; + text: string; + targetId?: string; + submit?: boolean; + slowly?: boolean; + timeoutMs?: number; + } + | { kind: "press"; key: string; targetId?: string; delayMs?: number } + | { + kind: "hover"; + ref?: string; + selector?: string; + targetId?: string; + timeoutMs?: number; + } + | { + kind: "scrollIntoView"; + ref?: string; + selector?: string; + targetId?: string; + timeoutMs?: number; + } + | { + kind: "drag"; + startRef?: string; + startSelector?: string; + endRef?: string; + endSelector?: string; + targetId?: string; + timeoutMs?: number; + } + | { + kind: "select"; + ref?: string; + selector?: string; + values: string[]; + targetId?: string; + timeoutMs?: number; + } + | { + kind: "fill"; + fields: BrowserFormField[]; + targetId?: string; + timeoutMs?: number; + } + | { kind: "resize"; width: number; height: number; targetId?: string } + | { + kind: "wait"; + timeMs?: number; + text?: string; + textGone?: string; + selector?: string; + url?: string; + loadState?: "load" | "domcontentloaded" | "networkidle"; + fn?: string; + targetId?: string; + timeoutMs?: number; + } + | { kind: "evaluate"; fn: string; ref?: string; targetId?: string; timeoutMs?: number } + | { kind: "close"; targetId?: string } + | { + kind: "batch"; + actions: BrowserActRequest[]; + targetId?: string; + stopOnError?: boolean; + }; + +export type BrowserActResponse = { + ok: true; + targetId: string; + url?: string; + result?: unknown; + results?: Array<{ ok: boolean; error?: string }>; +}; + +export type BrowserDownloadPayload = { + url: string; + suggestedFilename: string; + path: string; +}; + +type BrowserDownloadResult = { ok: true; targetId: string; download: BrowserDownloadPayload }; + +async function postDownloadRequest( + baseUrl: string | undefined, + route: "/wait/download" | "/download", + body: Record, + profile?: string, +): Promise { + const q = buildProfileQuery(profile); + return await fetchBrowserJson(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 { + const q = buildProfileQuery(opts.profile); + return await fetchBrowserJson(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 { + const q = buildProfileQuery(opts.profile); + return await fetchBrowserJson(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 { + const q = buildProfileQuery(opts.profile); + return await fetchBrowserJson(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 { + 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 { + 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 { + const q = buildProfileQuery(opts?.profile); + return await fetchBrowserJson(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 { + const q = buildProfileQuery(opts.profile); + return await fetchBrowserJson(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, + }); +} diff --git a/extensions/browser/src/browser/client-actions-observe.ts b/extensions/browser/src/browser/client-actions-observe.ts new file mode 100644 index 00000000000..7f7d8cd6926 --- /dev/null +++ b/extensions/browser/src/browser/client-actions-observe.ts @@ -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 { + const q = buildProfileQuery(opts.profile); + return await fetchBrowserJson(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 { + const q = buildProfileQuery(opts.profile); + return await fetchBrowserJson(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 { + const q = buildProfileQuery(opts.profile); + return await fetchBrowserJson(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 { + const q = buildProfileQuery(opts.profile); + return await fetchBrowserJson(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; + body: string; + truncated?: boolean; + }; +}> { + const q = buildProfileQuery(opts.profile); + return await fetchBrowserJson<{ + ok: true; + targetId: string; + response: { + url: string; + status?: number; + headers?: Record; + 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, + }); +} diff --git a/extensions/browser/src/browser/client-actions-state.ts b/extensions/browser/src/browser/client-actions-state.ts new file mode 100644 index 00000000000..a5d87aaec2d --- /dev/null +++ b/extensions/browser/src/browser/client-actions-state.ts @@ -0,0 +1,278 @@ +import type { BrowserActionOk, BrowserActionTargetOk } from "./client-actions-types.js"; +import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js"; +import { fetchBrowserJson } from "./client-fetch.js"; + +type TargetedProfileOptions = { + targetId?: string; + profile?: string; +}; + +type HttpCredentialsOptions = TargetedProfileOptions & { + username?: string; + password?: string; + clear?: boolean; +}; + +type GeolocationOptions = TargetedProfileOptions & { + latitude?: number; + longitude?: number; + accuracy?: number; + origin?: string; + clear?: boolean; +}; + +function buildStateQuery(params: { targetId?: string; key?: string; profile?: string }): string { + const query = new URLSearchParams(); + if (params.targetId) { + query.set("targetId", params.targetId); + } + if (params.key) { + query.set("key", params.key); + } + if (params.profile) { + query.set("profile", params.profile); + } + const suffix = query.toString(); + return suffix ? `?${suffix}` : ""; +} + +async function postProfileJson( + baseUrl: string | undefined, + params: { path: string; profile?: string; body: unknown }, +): Promise { + const query = buildProfileQuery(params.profile); + return await fetchBrowserJson(withBaseUrl(baseUrl, `${params.path}${query}`), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params.body), + timeoutMs: 20000, + }); +} + +async function postTargetedProfileJson( + baseUrl: string | undefined, + params: { + path: string; + opts: { targetId?: string; profile?: string }; + body: Record; + }, +): Promise { + return await postProfileJson(baseUrl, { + path: params.path, + profile: params.opts.profile, + body: { + targetId: params.opts.targetId, + ...params.body, + }, + }); +} + +export async function browserCookies( + baseUrl: string | undefined, + opts: { targetId?: string; profile?: string } = {}, +): Promise<{ ok: true; targetId: string; cookies: unknown[] }> { + const suffix = buildStateQuery({ targetId: opts.targetId, profile: opts.profile }); + return await fetchBrowserJson<{ + ok: true; + targetId: string; + cookies: unknown[]; + }>(withBaseUrl(baseUrl, `/cookies${suffix}`), { timeoutMs: 20000 }); +} + +export async function browserCookiesSet( + baseUrl: string | undefined, + opts: { + cookie: Record; + targetId?: string; + profile?: string; + }, +): Promise { + return await postProfileJson(baseUrl, { + path: "/cookies/set", + profile: opts.profile, + body: { targetId: opts.targetId, cookie: opts.cookie }, + }); +} + +export async function browserCookiesClear( + baseUrl: string | undefined, + opts: { targetId?: string; profile?: string } = {}, +): Promise { + return await postProfileJson(baseUrl, { + path: "/cookies/clear", + profile: opts.profile, + body: { targetId: opts.targetId }, + }); +} + +export async function browserStorageGet( + baseUrl: string | undefined, + opts: { + kind: "local" | "session"; + key?: string; + targetId?: string; + profile?: string; + }, +): Promise<{ ok: true; targetId: string; values: Record }> { + const suffix = buildStateQuery({ targetId: opts.targetId, key: opts.key, profile: opts.profile }); + return await fetchBrowserJson<{ + ok: true; + targetId: string; + values: Record; + }>(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 { + return await postProfileJson(baseUrl, { + path: `/storage/${opts.kind}/set`, + profile: opts.profile, + body: { + targetId: opts.targetId, + key: opts.key, + value: opts.value, + }, + }); +} + +export async function browserStorageClear( + baseUrl: string | undefined, + opts: { kind: "local" | "session"; targetId?: string; profile?: string }, +): Promise { + return await postProfileJson(baseUrl, { + path: `/storage/${opts.kind}/clear`, + profile: opts.profile, + body: { targetId: opts.targetId }, + }); +} + +export async function browserSetOffline( + baseUrl: string | undefined, + opts: { offline: boolean; targetId?: string; profile?: string }, +): Promise { + return await postProfileJson(baseUrl, { + path: "/set/offline", + profile: opts.profile, + body: { targetId: opts.targetId, offline: opts.offline }, + }); +} + +export async function browserSetHeaders( + baseUrl: string | undefined, + opts: { + headers: Record; + targetId?: string; + profile?: string; + }, +): Promise { + return await postProfileJson(baseUrl, { + path: "/set/headers", + profile: opts.profile, + body: { targetId: opts.targetId, headers: opts.headers }, + }); +} + +export async function browserSetHttpCredentials( + baseUrl: string | undefined, + opts: HttpCredentialsOptions = {}, +): Promise { + return await postTargetedProfileJson(baseUrl, { + path: "/set/credentials", + opts, + body: { + username: opts.username, + password: opts.password, + clear: opts.clear, + }, + }); +} + +export async function browserSetGeolocation( + baseUrl: string | undefined, + opts: GeolocationOptions = {}, +): Promise { + return await postTargetedProfileJson(baseUrl, { + path: "/set/geolocation", + opts, + body: { + latitude: opts.latitude, + longitude: opts.longitude, + accuracy: opts.accuracy, + origin: opts.origin, + clear: opts.clear, + }, + }); +} + +export async function browserSetMedia( + baseUrl: string | undefined, + opts: { + colorScheme: "dark" | "light" | "no-preference" | "none"; + targetId?: string; + profile?: string; + }, +): Promise { + return await postProfileJson(baseUrl, { + path: "/set/media", + profile: opts.profile, + body: { + targetId: opts.targetId, + colorScheme: opts.colorScheme, + }, + }); +} + +export async function browserSetTimezone( + baseUrl: string | undefined, + opts: { timezoneId: string; targetId?: string; profile?: string }, +): Promise { + return await postProfileJson(baseUrl, { + path: "/set/timezone", + profile: opts.profile, + body: { + targetId: opts.targetId, + timezoneId: opts.timezoneId, + }, + }); +} + +export async function browserSetLocale( + baseUrl: string | undefined, + opts: { locale: string; targetId?: string; profile?: string }, +): Promise { + return await postProfileJson(baseUrl, { + path: "/set/locale", + profile: opts.profile, + body: { targetId: opts.targetId, locale: opts.locale }, + }); +} + +export async function browserSetDevice( + baseUrl: string | undefined, + opts: { name: string; targetId?: string; profile?: string }, +): Promise { + return await postProfileJson(baseUrl, { + path: "/set/device", + profile: opts.profile, + body: { targetId: opts.targetId, name: opts.name }, + }); +} + +export async function browserClearPermissions( + baseUrl: string | undefined, + opts: { targetId?: string; profile?: string } = {}, +): Promise { + return await postProfileJson(baseUrl, { + path: "/set/geolocation", + profile: opts.profile, + body: { targetId: opts.targetId, clear: true }, + }); +} diff --git a/extensions/browser/src/browser/client-actions-types.ts b/extensions/browser/src/browser/client-actions-types.ts new file mode 100644 index 00000000000..9ad0d820da2 --- /dev/null +++ b/extensions/browser/src/browser/client-actions-types.ts @@ -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 }; diff --git a/extensions/browser/src/browser/client-actions-url.ts b/extensions/browser/src/browser/client-actions-url.ts new file mode 100644 index 00000000000..25c47fa6dba --- /dev/null +++ b/extensions/browser/src/browser/client-actions-url.ts @@ -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}`; +} diff --git a/extensions/browser/src/browser/client-actions.ts b/extensions/browser/src/browser/client-actions.ts new file mode 100644 index 00000000000..c495f5d01c5 --- /dev/null +++ b/extensions/browser/src/browser/client-actions.ts @@ -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"; diff --git a/extensions/browser/src/browser/client-fetch.ts b/extensions/browser/src/browser/client-fetch.ts new file mode 100644 index 00000000000..e321c5a1e62 --- /dev/null +++ b/extensions/browser/src/browser/client-fetch.ts @@ -0,0 +1,345 @@ +import { formatCliCommand } from "../cli/command-format.js"; +import { loadConfig } from "../config/config.js"; +import { isLoopbackHost } from "../gateway/net.js"; +import { getBridgeAuthForPort } from "./bridge-auth-registry.js"; +import { resolveBrowserControlAuth } from "./control-auth.js"; +import { + createBrowserControlContext, + startBrowserControlServiceFromConfig, +} from "./control-service.js"; +import { createBrowserRouteDispatcher } from "./routes/dispatcher.js"; + +// Application-level error from the browser control service (service is reachable +// but returned an error response). Must NOT be wrapped with "Can't reach ..." messaging. +class BrowserServiceError extends Error { + constructor(message: string) { + super(message); + this.name = "BrowserServiceError"; + } +} + +type LoopbackBrowserAuthDeps = { + loadConfig: typeof loadConfig; + resolveBrowserControlAuth: typeof resolveBrowserControlAuth; + getBridgeAuthForPort: typeof getBridgeAuthForPort; +}; + +function isAbsoluteHttp(url: string): boolean { + return /^https?:\/\//i.test(url.trim()); +} + +function isLoopbackHttpUrl(url: string): boolean { + try { + return isLoopbackHost(new URL(url).hostname); + } catch { + return false; + } +} + +function withLoopbackBrowserAuthImpl( + url: string, + init: (RequestInit & { timeoutMs?: number }) | undefined, + deps: LoopbackBrowserAuthDeps, +): RequestInit & { timeoutMs?: number } { + const headers = new Headers(init?.headers ?? {}); + if (headers.has("authorization") || headers.has("x-openclaw-password")) { + return { ...init, headers }; + } + if (!isLoopbackHttpUrl(url)) { + return { ...init, headers }; + } + + try { + const cfg = deps.loadConfig(); + const auth = deps.resolveBrowserControlAuth(cfg); + if (auth.token) { + headers.set("Authorization", `Bearer ${auth.token}`); + return { ...init, headers }; + } + if (auth.password) { + headers.set("x-openclaw-password", auth.password); + return { ...init, headers }; + } + } catch { + // ignore config/auth lookup failures and continue without auth headers + } + + // Sandbox bridge servers can run with per-process ephemeral auth on dynamic ports. + // Fall back to the in-memory registry if config auth is not available. + try { + const parsed = new URL(url); + const port = + parsed.port && Number.parseInt(parsed.port, 10) > 0 + ? Number.parseInt(parsed.port, 10) + : parsed.protocol === "https:" + ? 443 + : 80; + const bridgeAuth = deps.getBridgeAuthForPort(port); + if (bridgeAuth?.token) { + headers.set("Authorization", `Bearer ${bridgeAuth.token}`); + } else if (bridgeAuth?.password) { + headers.set("x-openclaw-password", bridgeAuth.password); + } + } catch { + // ignore + } + + return { ...init, headers }; +} + +function withLoopbackBrowserAuth( + url: string, + init: (RequestInit & { timeoutMs?: number }) | undefined, +): RequestInit & { timeoutMs?: number } { + return withLoopbackBrowserAuthImpl(url, init, { + loadConfig, + resolveBrowserControlAuth, + getBridgeAuthForPort, + }); +} + +const BROWSER_TOOL_MODEL_HINT = + "Do NOT retry the browser tool — it will keep failing. " + + "Use an alternative approach or inform the user that the browser is currently unavailable."; + +const BROWSER_SERVICE_RATE_LIMIT_MESSAGE = + "Browser service rate limit reached. " + + "Wait for the current session to complete, or retry later."; + +const BROWSERBASE_RATE_LIMIT_MESSAGE = + "Browserbase rate limit reached (max concurrent sessions). " + + "Wait for the current session to complete, or upgrade your plan."; + +function isRateLimitStatus(status: number): boolean { + return status === 429; +} + +function isBrowserbaseUrl(url: string): boolean { + if (!isAbsoluteHttp(url)) { + return false; + } + try { + const host = new URL(url).hostname.toLowerCase(); + return host === "browserbase.com" || host.endsWith(".browserbase.com"); + } catch { + return false; + } +} + +export function resolveBrowserRateLimitMessage(url: string): string { + return isBrowserbaseUrl(url) + ? BROWSERBASE_RATE_LIMIT_MESSAGE + : BROWSER_SERVICE_RATE_LIMIT_MESSAGE; +} + +function resolveBrowserFetchOperatorHint(url: string): string { + const isLocal = !isAbsoluteHttp(url); + return isLocal + ? `Restart the OpenClaw gateway (OpenClaw.app menubar, or \`${formatCliCommand("openclaw gateway")}\`).` + : "If this is a sandboxed session, ensure the sandbox browser is running."; +} + +function normalizeErrorMessage(err: unknown): string { + if (err instanceof Error && err.message.trim().length > 0) { + return err.message.trim(); + } + return String(err); +} + +function appendBrowserToolModelHint(message: string): string { + if (message.includes(BROWSER_TOOL_MODEL_HINT)) { + return message; + } + return `${message} ${BROWSER_TOOL_MODEL_HINT}`; +} + +async function discardResponseBody(res: Response): Promise { + try { + await res.body?.cancel(); + } catch { + // Best effort only; we're already returning a stable error message. + } +} + +function enhanceDispatcherPathError(url: string, err: unknown): Error { + const msg = normalizeErrorMessage(err); + const suffix = `${resolveBrowserFetchOperatorHint(url)} ${BROWSER_TOOL_MODEL_HINT}`; + const normalized = msg.endsWith(".") ? msg : `${msg}.`; + return new Error(`${normalized} ${suffix}`, err instanceof Error ? { cause: err } : undefined); +} + +function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error { + const operatorHint = resolveBrowserFetchOperatorHint(url); + const msg = String(err); + const msgLower = msg.toLowerCase(); + const looksLikeTimeout = + msgLower.includes("timed out") || + msgLower.includes("timeout") || + msgLower.includes("aborted") || + msgLower.includes("abort") || + msgLower.includes("aborterror"); + if (looksLikeTimeout) { + return new Error( + appendBrowserToolModelHint( + `Can't reach the OpenClaw browser control service (timed out after ${timeoutMs}ms). ${operatorHint}`, + ), + ); + } + return new Error( + appendBrowserToolModelHint( + `Can't reach the OpenClaw browser control service. ${operatorHint} (${msg})`, + ), + ); +} + +async function fetchHttpJson( + url: string, + init: RequestInit & { timeoutMs?: number }, +): Promise { + const timeoutMs = init.timeoutMs ?? 5000; + const ctrl = new AbortController(); + const upstreamSignal = init.signal; + let upstreamAbortListener: (() => void) | undefined; + if (upstreamSignal) { + if (upstreamSignal.aborted) { + ctrl.abort(upstreamSignal.reason); + } else { + upstreamAbortListener = () => ctrl.abort(upstreamSignal.reason); + upstreamSignal.addEventListener("abort", upstreamAbortListener, { once: true }); + } + } + + const t = setTimeout(() => ctrl.abort(new Error("timed out")), timeoutMs); + try { + const res = await fetch(url, { ...init, signal: ctrl.signal }); + if (!res.ok) { + if (isRateLimitStatus(res.status)) { + // Do not reflect upstream response text into the error surface (log/agent injection risk) + await discardResponseBody(res); + throw new BrowserServiceError( + `${resolveBrowserRateLimitMessage(url)} ${BROWSER_TOOL_MODEL_HINT}`, + ); + } + const text = await res.text().catch(() => ""); + throw new BrowserServiceError(text || `HTTP ${res.status}`); + } + return (await res.json()) as T; + } finally { + clearTimeout(t); + if (upstreamSignal && upstreamAbortListener) { + upstreamSignal.removeEventListener("abort", upstreamAbortListener); + } + } +} + +export async function fetchBrowserJson( + url: string, + init?: RequestInit & { timeoutMs?: number }, +): Promise { + const timeoutMs = init?.timeoutMs ?? 5000; + let isDispatcherPath = false; + try { + if (isAbsoluteHttp(url)) { + const httpInit = withLoopbackBrowserAuth(url, init); + return await fetchHttpJson(url, { ...httpInit, timeoutMs }); + } + isDispatcherPath = true; + const started = await startBrowserControlServiceFromConfig(); + if (!started) { + throw new Error("browser control disabled"); + } + const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext()); + const parsed = new URL(url, "http://localhost"); + const query: Record = {}; + 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 = 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 | undefined; + if (timeoutMs) { + timer = setTimeout(() => abortCtrl.abort(new Error("timed out")), timeoutMs); + } + + const dispatchPromise = dispatcher.dispatch({ + method: + init?.method?.toUpperCase() === "DELETE" + ? "DELETE" + : init?.method?.toUpperCase() === "POST" + ? "POST" + : "GET", + path: parsed.pathname, + query, + body, + signal: abortCtrl.signal, + }); + + const result = await Promise.race([dispatchPromise, abortPromise]).finally(() => { + if (timer) { + clearTimeout(timer); + } + if (abortListener) { + abortCtrl.signal.removeEventListener("abort", abortListener); + } + if (upstreamSignal && upstreamAbortListener) { + upstreamSignal.removeEventListener("abort", upstreamAbortListener); + } + }); + + if (result.status >= 400) { + if (isRateLimitStatus(result.status)) { + // Do not reflect upstream response text into the error surface (log/agent injection risk) + throw new BrowserServiceError( + `${resolveBrowserRateLimitMessage(url)} ${BROWSER_TOOL_MODEL_HINT}`, + ); + } + const message = + result.body && typeof result.body === "object" && "error" in result.body + ? String((result.body as { error?: unknown }).error) + : `HTTP ${result.status}`; + throw new BrowserServiceError(message); + } + return result.body as T; + } catch (err) { + if (err instanceof BrowserServiceError) { + throw err; + } + // Dispatcher-path failures are service-operation failures, not network + // reachability failures. Keep the original context, but retain anti-retry hints. + if (isDispatcherPath) { + throw enhanceDispatcherPathError(url, err); + } + throw enhanceBrowserFetchError(url, err, timeoutMs); + } +} + +export const __test = { + withLoopbackBrowserAuth: withLoopbackBrowserAuthImpl, +}; diff --git a/extensions/browser/src/browser/client.ts b/extensions/browser/src/browser/client.ts new file mode 100644 index 00000000000..d7d8690147f --- /dev/null +++ b/extensions/browser/src/browser/client.ts @@ -0,0 +1,351 @@ +import { fetchBrowserJson } from "./client-fetch.js"; + +export type BrowserTransport = "cdp" | "chrome-mcp"; + +export type BrowserStatus = { + enabled: boolean; + profile?: string; + driver?: "openclaw" | "existing-session"; + transport?: BrowserTransport; + running: boolean; + cdpReady?: boolean; + cdpHttp?: boolean; + pid: number | null; + cdpPort: number | null; + cdpUrl?: string | null; + chosenBrowser: string | null; + detectedBrowser?: string | null; + detectedExecutablePath?: string | null; + detectError?: string | null; + userDataDir: string | null; + color: string; + headless: boolean; + noSandbox?: boolean; + executablePath?: string | null; + attachOnly: boolean; +}; + +export type ProfileStatus = { + name: string; + transport?: BrowserTransport; + cdpPort: number | null; + cdpUrl: string | null; + color: string; + driver: "openclaw" | "existing-session"; + running: boolean; + tabCount: number; + isDefault: boolean; + isRemote: boolean; + missingFromConfig?: boolean; + reconcileReason?: string | null; +}; + +export type BrowserResetProfileResult = { + ok: true; + moved: boolean; + from: string; + to?: string; +}; + +export type BrowserTab = { + targetId: string; + title: string; + url: string; + wsUrl?: string; + type?: string; +}; + +export type SnapshotAriaNode = { + ref: string; + role: string; + name: string; + value?: string; + description?: string; + backendDOMNodeId?: number; + depth: number; +}; + +export type SnapshotResult = + | { + ok: true; + format: "aria"; + targetId: string; + url: string; + nodes: SnapshotAriaNode[]; + } + | { + ok: true; + format: "ai"; + targetId: string; + url: string; + snapshot: string; + truncated?: boolean; + refs?: Record; + 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 { + const q = buildProfileQuery(opts?.profile); + return await fetchBrowserJson(withBaseUrl(baseUrl, `/${q}`), { + timeoutMs: 1500, + }); +} + +export async function browserProfiles(baseUrl?: string): Promise { + const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>( + withBaseUrl(baseUrl, `/profiles`), + { + timeoutMs: 3000, + }, + ); + return res.profiles ?? []; +} + +export async function browserStart(baseUrl?: string, opts?: { profile?: string }): Promise { + 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 { + 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 { + const q = buildProfileQuery(opts?.profile); + return await fetchBrowserJson( + withBaseUrl(baseUrl, `/reset-profile${q}`), + { + method: "POST", + timeoutMs: 20000, + }, + ); +} + +export type BrowserCreateProfileResult = { + ok: true; + profile: string; + transport?: BrowserTransport; + cdpPort: number | null; + cdpUrl: string | null; + userDataDir: string | null; + color: string; + isRemote: boolean; +}; + +export async function browserCreateProfile( + baseUrl: string | undefined, + opts: { + name: string; + color?: string; + cdpUrl?: string; + userDataDir?: string; + driver?: "openclaw" | "existing-session"; + }, +): Promise { + return await fetchBrowserJson( + withBaseUrl(baseUrl, `/profiles/create`), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: opts.name, + color: opts.color, + cdpUrl: opts.cdpUrl, + userDataDir: opts.userDataDir, + driver: opts.driver, + }), + timeoutMs: 10000, + }, + ); +} + +export type BrowserDeleteProfileResult = { + ok: true; + profile: string; + deleted: boolean; +}; + +export async function browserDeleteProfile( + baseUrl: string | undefined, + profile: string, +): Promise { + return await fetchBrowserJson( + withBaseUrl(baseUrl, `/profiles/${encodeURIComponent(profile)}`), + { + method: "DELETE", + timeoutMs: 20000, + }, + ); +} + +export async function browserTabs( + baseUrl?: string, + opts?: { profile?: string }, +): Promise { + 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 { + const q = buildProfileQuery(opts?.profile); + return await fetchBrowserJson(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 { + 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 { + 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 { + 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 { + const q = new URLSearchParams(); + if (opts.format) { + q.set("format", opts.format); + } + if (opts.targetId) { + q.set("targetId", opts.targetId); + } + if (typeof opts.limit === "number") { + q.set("limit", String(opts.limit)); + } + if (typeof opts.maxChars === "number" && Number.isFinite(opts.maxChars)) { + q.set("maxChars", String(opts.maxChars)); + } + if (opts.refs === "aria" || opts.refs === "role") { + q.set("refs", opts.refs); + } + if (typeof opts.interactive === "boolean") { + q.set("interactive", String(opts.interactive)); + } + if (typeof opts.compact === "boolean") { + q.set("compact", String(opts.compact)); + } + if (typeof opts.depth === "number" && Number.isFinite(opts.depth)) { + q.set("depth", String(opts.depth)); + } + if (opts.selector?.trim()) { + q.set("selector", opts.selector.trim()); + } + if (opts.frame?.trim()) { + q.set("frame", opts.frame.trim()); + } + if (opts.labels === true) { + q.set("labels", "1"); + } + if (opts.mode) { + q.set("mode", opts.mode); + } + if (opts.profile) { + q.set("profile", opts.profile); + } + return await fetchBrowserJson(withBaseUrl(baseUrl, `/snapshot?${q.toString()}`), { + timeoutMs: 20000, + }); +} + +// Actions beyond the basic read-only commands live in client-actions.ts. diff --git a/extensions/browser/src/browser/config.ts b/extensions/browser/src/browser/config.ts new file mode 100644 index 00000000000..a5bc131766a --- /dev/null +++ b/extensions/browser/src/browser/config.ts @@ -0,0 +1,365 @@ +import type { BrowserConfig, BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; +import { resolveGatewayPort } from "../config/paths.js"; +import { + deriveDefaultBrowserCdpPortRange, + deriveDefaultBrowserControlPort, + DEFAULT_BROWSER_CONTROL_PORT, +} from "../config/port-defaults.js"; +import { isLoopbackHost } from "../gateway/net.js"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { resolveUserPath } from "../utils.js"; +import { + DEFAULT_OPENCLAW_BROWSER_COLOR, + DEFAULT_OPENCLAW_BROWSER_ENABLED, + DEFAULT_BROWSER_EVALUATE_ENABLED, + DEFAULT_BROWSER_DEFAULT_PROFILE_NAME, + DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, +} from "./constants.js"; +import { CDP_PORT_RANGE_START } from "./profiles.js"; + +export type ResolvedBrowserConfig = { + enabled: boolean; + evaluateEnabled: boolean; + controlPort: number; + cdpPortRangeStart: number; + cdpPortRangeEnd: number; + cdpProtocol: "http" | "https"; + cdpHost: string; + cdpIsLoopback: boolean; + remoteCdpTimeoutMs: number; + remoteCdpHandshakeTimeoutMs: number; + color: string; + executablePath?: string; + headless: boolean; + noSandbox: boolean; + attachOnly: boolean; + defaultProfile: string; + profiles: Record; + ssrfPolicy?: SsrFPolicy; + extraArgs: string[]; +}; + +export type ResolvedBrowserProfile = { + name: string; + cdpPort: number; + cdpUrl: string; + cdpHost: string; + cdpIsLoopback: boolean; + userDataDir?: string; + color: string; + driver: "openclaw" | "existing-session"; + attachOnly: boolean; +}; + +function normalizeHexColor(raw: string | undefined) { + const value = (raw ?? "").trim(); + if (!value) { + return DEFAULT_OPENCLAW_BROWSER_COLOR; + } + const normalized = value.startsWith("#") ? value : `#${value}`; + if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) { + return DEFAULT_OPENCLAW_BROWSER_COLOR; + } + return normalized.toUpperCase(); +} + +function normalizeTimeoutMs(raw: number | undefined, fallback: number) { + const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback; + return value < 0 ? fallback : value; +} + +function resolveCdpPortRangeStart( + rawStart: number | undefined, + fallbackStart: number, + rangeSpan: number, +) { + const start = + typeof rawStart === "number" && Number.isFinite(rawStart) + ? Math.floor(rawStart) + : fallbackStart; + if (start < 1 || start > 65535) { + throw new Error(`browser.cdpPortRangeStart must be between 1 and 65535, got: ${start}`); + } + const maxStart = 65535 - rangeSpan; + if (start > maxStart) { + throw new Error( + `browser.cdpPortRangeStart (${start}) is too high for a ${rangeSpan + 1}-port range; max is ${maxStart}.`, + ); + } + return start; +} + +function normalizeStringList(raw: string[] | undefined): string[] | undefined { + if (!Array.isArray(raw) || raw.length === 0) { + return undefined; + } + const values = raw + .map((value) => value.trim()) + .filter((value): value is string => value.length > 0); + return values.length > 0 ? values : undefined; +} + +function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined { + const allowPrivateNetwork = cfg?.ssrfPolicy?.allowPrivateNetwork; + const dangerouslyAllowPrivateNetwork = cfg?.ssrfPolicy?.dangerouslyAllowPrivateNetwork; + const allowedHostnames = normalizeStringList(cfg?.ssrfPolicy?.allowedHostnames); + const hostnameAllowlist = normalizeStringList(cfg?.ssrfPolicy?.hostnameAllowlist); + const hasExplicitPrivateSetting = + allowPrivateNetwork !== undefined || dangerouslyAllowPrivateNetwork !== undefined; + // Browser defaults to trusted-network mode unless explicitly disabled by policy. + const resolvedAllowPrivateNetwork = + dangerouslyAllowPrivateNetwork === true || + allowPrivateNetwork === true || + !hasExplicitPrivateSetting; + + if ( + !resolvedAllowPrivateNetwork && + !hasExplicitPrivateSetting && + !allowedHostnames && + !hostnameAllowlist + ) { + return undefined; + } + + return { + ...(resolvedAllowPrivateNetwork ? { dangerouslyAllowPrivateNetwork: true } : {}), + ...(allowedHostnames ? { allowedHostnames } : {}), + ...(hostnameAllowlist ? { hostnameAllowlist } : {}), + }; +} + +export function parseHttpUrl(raw: string, label: string) { + const trimmed = raw.trim(); + const parsed = new URL(trimmed); + const allowed = ["http:", "https:", "ws:", "wss:"]; + if (!allowed.includes(parsed.protocol)) { + throw new Error(`${label} must be http(s) or ws(s), got: ${parsed.protocol.replace(":", "")}`); + } + + const isSecure = parsed.protocol === "https:" || parsed.protocol === "wss:"; + const port = + parsed.port && Number.parseInt(parsed.port, 10) > 0 + ? Number.parseInt(parsed.port, 10) + : isSecure + ? 443 + : 80; + + if (Number.isNaN(port) || port <= 0 || port > 65535) { + throw new Error(`${label} has invalid port: ${parsed.port}`); + } + + return { + parsed, + port, + normalized: parsed.toString().replace(/\/$/, ""), + }; +} + +/** + * Ensure the default "openclaw" profile exists in the profiles map. + * Auto-creates it with the legacy CDP port (from browser.cdpUrl) or first port if missing. + */ +function ensureDefaultProfile( + profiles: Record | undefined, + defaultColor: string, + legacyCdpPort?: number, + derivedDefaultCdpPort?: number, + legacyCdpUrl?: string, +): Record { + const result = { ...profiles }; + if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) { + result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = { + cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? CDP_PORT_RANGE_START, + color: defaultColor, + // Preserve the full cdpUrl for ws/wss endpoints so resolveProfile() + // doesn't reconstruct from cdpProtocol/cdpHost/cdpPort (which drops + // the WebSocket protocol and query params like API keys). + ...(legacyCdpUrl ? { cdpUrl: legacyCdpUrl } : {}), + }; + } + return result; +} + +/** + * Ensure a built-in "user" profile exists for Chrome's existing-session attach flow. + */ +function ensureDefaultUserBrowserProfile( + profiles: Record, +): Record { + const result = { ...profiles }; + if (result.user) { + return result; + } + result.user = { + driver: "existing-session", + attachOnly: true, + color: "#00AA00", + }; + return result; +} + +export function resolveBrowserConfig( + cfg: BrowserConfig | undefined, + rootConfig?: OpenClawConfig, +): ResolvedBrowserConfig { + const enabled = cfg?.enabled ?? DEFAULT_OPENCLAW_BROWSER_ENABLED; + const evaluateEnabled = cfg?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED; + const gatewayPort = resolveGatewayPort(rootConfig); + const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT); + const defaultColor = normalizeHexColor(cfg?.color); + const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500); + const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs( + cfg?.remoteCdpHandshakeTimeoutMs, + Math.max(2000, remoteCdpTimeoutMs * 2), + ); + + const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort); + const cdpRangeSpan = derivedCdpRange.end - derivedCdpRange.start; + const cdpPortRangeStart = resolveCdpPortRangeStart( + cfg?.cdpPortRangeStart, + derivedCdpRange.start, + cdpRangeSpan, + ); + const cdpPortRangeEnd = cdpPortRangeStart + cdpRangeSpan; + + const rawCdpUrl = (cfg?.cdpUrl ?? "").trim(); + let cdpInfo: + | { + parsed: URL; + port: number; + normalized: string; + } + | undefined; + if (rawCdpUrl) { + cdpInfo = parseHttpUrl(rawCdpUrl, "browser.cdpUrl"); + } else { + const derivedPort = controlPort + 1; + if (derivedPort > 65535) { + throw new Error( + `Derived CDP port (${derivedPort}) is too high; check gateway port configuration.`, + ); + } + const derived = new URL(`http://127.0.0.1:${derivedPort}`); + cdpInfo = { + parsed: derived, + port: derivedPort, + normalized: derived.toString().replace(/\/$/, ""), + }; + } + + const headless = cfg?.headless === true; + const noSandbox = cfg?.noSandbox === true; + const attachOnly = cfg?.attachOnly === true; + const executablePath = cfg?.executablePath?.trim() || undefined; + + const defaultProfileFromConfig = cfg?.defaultProfile?.trim() || undefined; + // Use legacy cdpUrl port for backward compatibility when no profiles configured + const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined; + const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:"; + const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined; + const profiles = ensureDefaultUserBrowserProfile( + ensureDefaultProfile( + cfg?.profiles, + defaultColor, + legacyCdpPort, + cdpPortRangeStart, + legacyCdpUrl, + ), + ); + const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http"; + + const defaultProfile = + defaultProfileFromConfig ?? + (profiles[DEFAULT_BROWSER_DEFAULT_PROFILE_NAME] + ? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME + : profiles[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] + ? DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME + : "user"); + + const extraArgs = Array.isArray(cfg?.extraArgs) + ? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0) + : []; + const ssrfPolicy = resolveBrowserSsrFPolicy(cfg); + return { + enabled, + evaluateEnabled, + controlPort, + cdpPortRangeStart, + cdpPortRangeEnd, + cdpProtocol, + cdpHost: cdpInfo.parsed.hostname, + cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname), + remoteCdpTimeoutMs, + remoteCdpHandshakeTimeoutMs, + color: defaultColor, + executablePath, + headless, + noSandbox, + attachOnly, + defaultProfile, + profiles, + ssrfPolicy, + extraArgs, + }; +} + +/** + * Resolve a profile by name from the config. + * Returns null if the profile doesn't exist. + */ +export function resolveProfile( + resolved: ResolvedBrowserConfig, + profileName: string, +): ResolvedBrowserProfile | null { + const profile = resolved.profiles[profileName]; + if (!profile) { + return null; + } + + const rawProfileUrl = profile.cdpUrl?.trim() ?? ""; + let cdpHost = resolved.cdpHost; + let cdpPort = profile.cdpPort ?? 0; + let cdpUrl = ""; + const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw"; + + if (driver === "existing-session") { + // existing-session uses Chrome MCP auto-connect; no CDP port/URL needed + return { + name: profileName, + cdpPort: 0, + cdpUrl: "", + cdpHost: "", + cdpIsLoopback: true, + userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined, + color: profile.color, + driver, + attachOnly: true, + }; + } + + if (rawProfileUrl) { + const parsed = parseHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`); + cdpHost = parsed.parsed.hostname; + cdpPort = parsed.port; + cdpUrl = parsed.normalized; + } else if (cdpPort) { + cdpUrl = `${resolved.cdpProtocol}://${resolved.cdpHost}:${cdpPort}`; + } else { + throw new Error(`Profile "${profileName}" must define cdpPort or cdpUrl.`); + } + + return { + name: profileName, + cdpPort, + cdpUrl, + cdpHost, + cdpIsLoopback: isLoopbackHost(cdpHost), + color: profile.color, + driver, + attachOnly: profile.attachOnly ?? resolved.attachOnly, + }; +} + +export function shouldStartLocalBrowserServer(_resolved: ResolvedBrowserConfig) { + return true; +} diff --git a/extensions/browser/src/browser/constants.ts b/extensions/browser/src/browser/constants.ts new file mode 100644 index 00000000000..952bf9190a5 --- /dev/null +++ b/extensions/browser/src/browser/constants.ts @@ -0,0 +1,8 @@ +export const DEFAULT_OPENCLAW_BROWSER_ENABLED = true; +export const DEFAULT_BROWSER_EVALUATE_ENABLED = true; +export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500"; +export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw"; +export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw"; +export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 80_000; +export const DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS = 10_000; +export const DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH = 6; diff --git a/extensions/browser/src/browser/control-auth.ts b/extensions/browser/src/browser/control-auth.ts new file mode 100644 index 00000000000..be7c66ab498 --- /dev/null +++ b/extensions/browser/src/browser/control-auth.ts @@ -0,0 +1,98 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { loadConfig } from "../config/config.js"; +import { resolveGatewayAuth } from "../gateway/auth.js"; +import { ensureGatewayStartupAuth } from "../gateway/startup-auth.js"; + +export type BrowserControlAuth = { + token?: string; + password?: string; +}; + +export function resolveBrowserControlAuth( + cfg: OpenClawConfig | undefined, + env: NodeJS.ProcessEnv = process.env, +): BrowserControlAuth { + const auth = resolveGatewayAuth({ + authConfig: cfg?.gateway?.auth, + env, + tailscaleMode: cfg?.gateway?.tailscale?.mode, + }); + const token = typeof auth.token === "string" ? auth.token.trim() : ""; + const password = typeof auth.password === "string" ? auth.password.trim() : ""; + return { + token: token || undefined, + password: password || undefined, + }; +} + +function shouldAutoGenerateBrowserAuth(env: NodeJS.ProcessEnv): boolean { + const nodeEnv = (env.NODE_ENV ?? "").trim().toLowerCase(); + if (nodeEnv === "test") { + return false; + } + const vitest = (env.VITEST ?? "").trim().toLowerCase(); + if (vitest && vitest !== "0" && vitest !== "false" && vitest !== "off") { + return false; + } + return true; +} + +export async function ensureBrowserControlAuth(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): Promise<{ + auth: BrowserControlAuth; + generatedToken?: string; +}> { + const env = params.env ?? process.env; + const auth = resolveBrowserControlAuth(params.cfg, env); + if (auth.token || auth.password) { + return { auth }; + } + if (!shouldAutoGenerateBrowserAuth(env)) { + return { auth }; + } + + // Respect explicit password mode even if currently unset. + if (params.cfg.gateway?.auth?.mode === "password") { + return { auth }; + } + + if (params.cfg.gateway?.auth?.mode === "none") { + return { auth }; + } + + if (params.cfg.gateway?.auth?.mode === "trusted-proxy") { + return { auth }; + } + + // Re-read latest config to avoid racing with concurrent config writers. + const latestCfg = loadConfig(); + const latestAuth = resolveBrowserControlAuth(latestCfg, env); + if (latestAuth.token || latestAuth.password) { + return { auth: latestAuth }; + } + if (latestCfg.gateway?.auth?.mode === "password") { + return { auth: latestAuth }; + } + if (latestCfg.gateway?.auth?.mode === "none") { + return { auth: latestAuth }; + } + if (latestCfg.gateway?.auth?.mode === "trusted-proxy") { + return { auth: latestAuth }; + } + + const ensured = await ensureGatewayStartupAuth({ + cfg: latestCfg, + env, + persist: true, + }); + const ensuredAuth = { + token: ensured.auth.token, + password: ensured.auth.password, + }; + return { + auth: ensuredAuth, + generatedToken: ensured.generatedToken, + }; +} diff --git a/extensions/browser/src/browser/control-service.ts b/extensions/browser/src/browser/control-service.ts new file mode 100644 index 00000000000..06b0917d7b6 --- /dev/null +++ b/extensions/browser/src/browser/control-service.ts @@ -0,0 +1 @@ +export * from "../control-service.js"; diff --git a/extensions/browser/src/browser/csrf.ts b/extensions/browser/src/browser/csrf.ts new file mode 100644 index 00000000000..e743febcecf --- /dev/null +++ b/extensions/browser/src/browser/csrf.ts @@ -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(); + }; +} diff --git a/extensions/browser/src/browser/errors.ts b/extensions/browser/src/browser/errors.ts new file mode 100644 index 00000000000..b363de4b06e --- /dev/null +++ b/extensions/browser/src/browser/errors.ts @@ -0,0 +1,85 @@ +import { SsrFBlockedError } from "../infra/net/ssrf.js"; +import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; + +export class BrowserError extends Error { + status: number; + + constructor(message: string, status = 500, options?: ErrorOptions) { + super(message, options); + this.name = new.target.name; + this.status = status; + } +} + +export class BrowserValidationError extends BrowserError { + constructor(message: string, options?: ErrorOptions) { + super(message, 400, options); + } +} + +export class BrowserConfigurationError extends BrowserError { + constructor(message: string, options?: ErrorOptions) { + super(message, 400, options); + } +} + +export class BrowserTargetAmbiguousError extends BrowserError { + constructor(message = "ambiguous target id prefix", options?: ErrorOptions) { + super(message, 409, options); + } +} + +export class BrowserTabNotFoundError extends BrowserError { + constructor(message = "tab not found", options?: ErrorOptions) { + super(message, 404, options); + } +} + +export class BrowserProfileNotFoundError extends BrowserError { + constructor(message: string, options?: ErrorOptions) { + super(message, 404, options); + } +} + +export class BrowserConflictError extends BrowserError { + constructor(message: string, options?: ErrorOptions) { + super(message, 409, options); + } +} + +export class BrowserResetUnsupportedError extends BrowserError { + constructor(message: string, options?: ErrorOptions) { + super(message, 400, options); + } +} + +export class BrowserProfileUnavailableError extends BrowserError { + constructor(message: string, options?: ErrorOptions) { + super(message, 409, options); + } +} + +export class BrowserResourceExhaustedError extends BrowserError { + constructor(message: string, options?: ErrorOptions) { + super(message, 507, options); + } +} + +export function toBrowserErrorResponse(err: unknown): { + status: number; + message: string; +} | null { + if (err instanceof BrowserError) { + return { status: err.status, message: err.message }; + } + if (err instanceof SsrFBlockedError) { + return { status: 400, message: err.message }; + } + if ( + err instanceof InvalidBrowserNavigationUrlError || + (err instanceof Error && err.name === "InvalidBrowserNavigationUrlError") + ) { + return { status: 400, message: err.message }; + } + return null; +} diff --git a/extensions/browser/src/browser/form-fields.ts b/extensions/browser/src/browser/form-fields.ts new file mode 100644 index 00000000000..9e0dac4ddd6 --- /dev/null +++ b/extensions/browser/src/browser/form-fields.ts @@ -0,0 +1,32 @@ +import type { BrowserFormField } from "./client-actions-core.js"; + +export const DEFAULT_FILL_FIELD_TYPE = "text"; + +type BrowserFormFieldValue = NonNullable; + +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, +): 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 }; +} diff --git a/extensions/browser/src/browser/http-auth.ts b/extensions/browser/src/browser/http-auth.ts new file mode 100644 index 00000000000..df0ab440dea --- /dev/null +++ b/extensions/browser/src/browser/http-auth.ts @@ -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; +} diff --git a/extensions/browser/src/browser/navigation-guard.ts b/extensions/browser/src/browser/navigation-guard.ts new file mode 100644 index 00000000000..216140aba98 --- /dev/null +++ b/extensions/browser/src/browser/navigation-guard.ts @@ -0,0 +1,134 @@ +import { hasProxyEnvConfigured } from "../infra/net/proxy-env.js"; +import { + isPrivateNetworkAllowedByPolicy, + resolvePinnedHostnameWithPolicy, + type LookupFn, + type SsrFPolicy, +} from "../infra/net/ssrf.js"; + +const NETWORK_NAVIGATION_PROTOCOLS = new Set(["http:", "https:"]); +const SAFE_NON_NETWORK_URLS = new Set(["about:blank"]); + +function isAllowedNonNetworkNavigationUrl(parsed: URL): boolean { + // Keep non-network navigation explicit; about:blank is the only allowed bootstrap URL. + return SAFE_NON_NETWORK_URLS.has(parsed.href); +} + +export class InvalidBrowserNavigationUrlError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidBrowserNavigationUrlError"; + } +} + +export type BrowserNavigationPolicyOptions = { + ssrfPolicy?: SsrFPolicy; +}; + +export type BrowserNavigationRequestLike = { + url(): string; + redirectedFrom(): BrowserNavigationRequestLike | null; +}; + +export function withBrowserNavigationPolicy( + ssrfPolicy?: SsrFPolicy, +): BrowserNavigationPolicyOptions { + return ssrfPolicy ? { ssrfPolicy } : {}; +} + +export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFPolicy): boolean { + return !isPrivateNetworkAllowedByPolicy(ssrfPolicy); +} + +export async function assertBrowserNavigationAllowed( + opts: { + url: string; + lookupFn?: LookupFn; + } & BrowserNavigationPolicyOptions, +): Promise { + const rawUrl = String(opts.url ?? "").trim(); + if (!rawUrl) { + throw new InvalidBrowserNavigationUrlError("url is required"); + } + + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + throw new InvalidBrowserNavigationUrlError(`Invalid URL: ${rawUrl}`); + } + + if (!NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol)) { + if (isAllowedNonNetworkNavigationUrl(parsed)) { + return; + } + throw new InvalidBrowserNavigationUrlError( + `Navigation blocked: unsupported protocol "${parsed.protocol}"`, + ); + } + + // Browser network stacks may apply env proxy routing at connect-time, which + // can bypass strict destination-binding intent from pre-navigation DNS checks. + // In strict mode, fail closed unless private-network navigation is explicitly + // enabled by policy. + if (hasProxyEnvConfigured() && !isPrivateNetworkAllowedByPolicy(opts.ssrfPolicy)) { + throw new InvalidBrowserNavigationUrlError( + "Navigation blocked: strict browser SSRF policy cannot be enforced while env proxy variables are set", + ); + } + + await resolvePinnedHostnameWithPolicy(parsed.hostname, { + lookupFn: opts.lookupFn, + policy: opts.ssrfPolicy, + }); +} + +/** + * Best-effort post-navigation guard for final page URLs. + * Only validates network URLs (http/https) and about:blank to avoid false + * positives on browser-internal error pages (e.g. chrome-error://). + */ +export async function assertBrowserNavigationResultAllowed( + opts: { + url: string; + lookupFn?: LookupFn; + } & BrowserNavigationPolicyOptions, +): Promise { + const rawUrl = String(opts.url ?? "").trim(); + if (!rawUrl) { + return; + } + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return; + } + if ( + NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol) || + isAllowedNonNetworkNavigationUrl(parsed) + ) { + await assertBrowserNavigationAllowed(opts); + } +} + +export async function assertBrowserNavigationRedirectChainAllowed( + opts: { + request?: BrowserNavigationRequestLike | null; + lookupFn?: LookupFn; + } & BrowserNavigationPolicyOptions, +): Promise { + const chain: string[] = []; + let current = opts.request ?? null; + while (current) { + chain.push(current.url()); + current = current.redirectedFrom(); + } + for (const url of chain.toReversed()) { + await assertBrowserNavigationAllowed({ + url, + lookupFn: opts.lookupFn, + ssrfPolicy: opts.ssrfPolicy, + }); + } +} diff --git a/extensions/browser/src/browser/output-atomic.ts b/extensions/browser/src/browser/output-atomic.ts new file mode 100644 index 00000000000..541ad0901b6 --- /dev/null +++ b/extensions/browser/src/browser/output-atomic.ts @@ -0,0 +1,51 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { writeFileFromPathWithinRoot } from "../infra/fs-safe.js"; +import { sanitizeUntrustedFileName } from "./safe-filename.js"; + +function buildSiblingTempPath(targetPath: string): string { + const id = crypto.randomUUID(); + const safeTail = sanitizeUntrustedFileName(path.basename(targetPath), "output.bin"); + return path.join(path.dirname(targetPath), `.openclaw-output-${id}-${safeTail}.part`); +} + +export async function writeViaSiblingTempPath(params: { + rootDir: string; + targetPath: string; + writeTemp: (tempPath: string) => Promise; +}): Promise { + const rootDir = await fs + .realpath(path.resolve(params.rootDir)) + .catch(() => path.resolve(params.rootDir)); + const requestedTargetPath = path.resolve(params.targetPath); + const targetPath = await fs + .realpath(path.dirname(requestedTargetPath)) + .then((realDir) => path.join(realDir, path.basename(requestedTargetPath))) + .catch(() => requestedTargetPath); + const relativeTargetPath = path.relative(rootDir, targetPath); + if ( + !relativeTargetPath || + relativeTargetPath === ".." || + relativeTargetPath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeTargetPath) + ) { + throw new Error("Target path is outside the allowed root"); + } + const tempPath = buildSiblingTempPath(targetPath); + let renameSucceeded = false; + try { + await params.writeTemp(tempPath); + await writeFileFromPathWithinRoot({ + rootDir, + relativePath: relativeTargetPath, + sourcePath: tempPath, + mkdir: false, + }); + renameSucceeded = true; + } finally { + if (!renameSucceeded) { + await fs.rm(tempPath, { force: true }).catch(() => {}); + } + } +} diff --git a/extensions/browser/src/browser/paths.ts b/extensions/browser/src/browser/paths.ts new file mode 100644 index 00000000000..1506a2e2e91 --- /dev/null +++ b/extensions/browser/src/browser/paths.ts @@ -0,0 +1,256 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { SafeOpenError, openFileWithinRoot } from "../infra/fs-safe.js"; +import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; + +export const DEFAULT_BROWSER_TMP_DIR = resolvePreferredOpenClawTmpDir(); +export const DEFAULT_TRACE_DIR = DEFAULT_BROWSER_TMP_DIR; +export const DEFAULT_DOWNLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "downloads"); +export const DEFAULT_UPLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "uploads"); + +type InvalidPathResult = { ok: false; error: string }; + +function invalidPath(scopeLabel: string): InvalidPathResult { + return { + ok: false, + error: `Invalid path: must stay within ${scopeLabel}`, + }; +} + +async function resolveRealPathIfExists(targetPath: string): Promise { + try { + return await fs.realpath(targetPath); + } catch { + return undefined; + } +} + +async function resolveTrustedRootRealPath(rootDir: string): Promise { + try { + const rootLstat = await fs.lstat(rootDir); + if (!rootLstat.isDirectory() || rootLstat.isSymbolicLink()) { + return undefined; + } + return await fs.realpath(rootDir); + } catch { + return undefined; + } +} + +async function validateCanonicalPathWithinRoot(params: { + rootRealPath: string; + candidatePath: string; + expect: "directory" | "file"; +}): Promise<"ok" | "not-found" | "invalid"> { + try { + const candidateLstat = await fs.lstat(params.candidatePath); + if (candidateLstat.isSymbolicLink()) { + return "invalid"; + } + if (params.expect === "directory" && !candidateLstat.isDirectory()) { + return "invalid"; + } + if (params.expect === "file" && !candidateLstat.isFile()) { + return "invalid"; + } + if (params.expect === "file" && candidateLstat.nlink > 1) { + return "invalid"; + } + const candidateRealPath = await fs.realpath(params.candidatePath); + return isPathInside(params.rootRealPath, candidateRealPath) ? "ok" : "invalid"; + } catch (err) { + return isNotFoundPathError(err) ? "not-found" : "invalid"; + } +} + +export function resolvePathWithinRoot(params: { + rootDir: string; + requestedPath: string; + scopeLabel: string; + defaultFileName?: string; +}): { ok: true; path: string } | { ok: false; error: string } { + const root = path.resolve(params.rootDir); + const raw = params.requestedPath.trim(); + if (!raw) { + if (!params.defaultFileName) { + return { ok: false, error: "path is required" }; + } + return { ok: true, path: path.join(root, params.defaultFileName) }; + } + const resolved = path.resolve(root, raw); + const rel = path.relative(root, resolved); + if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) { + return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel}` }; + } + return { ok: true, path: resolved }; +} + +export async function resolveWritablePathWithinRoot(params: { + rootDir: string; + requestedPath: string; + scopeLabel: string; + defaultFileName?: string; +}): Promise<{ ok: true; path: string } | { ok: false; error: string }> { + const lexical = resolvePathWithinRoot(params); + if (!lexical.ok) { + return lexical; + } + + const rootDir = path.resolve(params.rootDir); + const rootRealPath = await resolveTrustedRootRealPath(rootDir); + if (!rootRealPath) { + return invalidPath(params.scopeLabel); + } + + const requestedPath = lexical.path; + const parentDir = path.dirname(requestedPath); + const parentStatus = await validateCanonicalPathWithinRoot({ + rootRealPath, + candidatePath: parentDir, + expect: "directory", + }); + if (parentStatus !== "ok") { + return invalidPath(params.scopeLabel); + } + + const targetStatus = await validateCanonicalPathWithinRoot({ + rootRealPath, + candidatePath: requestedPath, + expect: "file", + }); + if (targetStatus === "invalid") { + return invalidPath(params.scopeLabel); + } + + return lexical; +} + +export function resolvePathsWithinRoot(params: { + rootDir: string; + requestedPaths: string[]; + scopeLabel: string; +}): { ok: true; paths: string[] } | { ok: false; error: string } { + const resolvedPaths: string[] = []; + for (const raw of params.requestedPaths) { + const pathResult = resolvePathWithinRoot({ + rootDir: params.rootDir, + requestedPath: raw, + scopeLabel: params.scopeLabel, + }); + if (!pathResult.ok) { + return { ok: false, error: pathResult.error }; + } + resolvedPaths.push(pathResult.path); + } + return { ok: true, paths: resolvedPaths }; +} + +export async function resolveExistingPathsWithinRoot(params: { + rootDir: string; + requestedPaths: string[]; + scopeLabel: string; +}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> { + return await resolveCheckedPathsWithinRoot({ + ...params, + allowMissingFallback: true, + }); +} + +export async function resolveStrictExistingPathsWithinRoot(params: { + rootDir: string; + requestedPaths: string[]; + scopeLabel: string; +}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> { + return await resolveCheckedPathsWithinRoot({ + ...params, + allowMissingFallback: false, + }); +} + +async function resolveCheckedPathsWithinRoot(params: { + rootDir: string; + requestedPaths: string[]; + scopeLabel: string; + allowMissingFallback: boolean; +}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> { + const rootDir = path.resolve(params.rootDir); + // Keep historical behavior for missing roots and rely on openFileWithinRoot for final checks. + const rootRealPath = await resolveRealPathIfExists(rootDir); + + const isInRoot = (relativePath: string) => + Boolean(relativePath) && !relativePath.startsWith("..") && !path.isAbsolute(relativePath); + + const resolveExistingRelativePath = async ( + requestedPath: string, + ): Promise< + { ok: true; relativePath: string; fallbackPath: string } | { ok: false; error: string } + > => { + const raw = requestedPath.trim(); + const lexicalPathResult = resolvePathWithinRoot({ + rootDir, + requestedPath, + scopeLabel: params.scopeLabel, + }); + if (lexicalPathResult.ok) { + return { + ok: true, + relativePath: path.relative(rootDir, lexicalPathResult.path), + fallbackPath: lexicalPathResult.path, + }; + } + if (!rootRealPath || !raw || !path.isAbsolute(raw)) { + return lexicalPathResult; + } + try { + const resolvedExistingPath = await fs.realpath(raw); + const relativePath = path.relative(rootRealPath, resolvedExistingPath); + if (!isInRoot(relativePath)) { + return lexicalPathResult; + } + return { + ok: true, + relativePath, + fallbackPath: resolvedExistingPath, + }; + } catch { + return lexicalPathResult; + } + }; + + const resolvedPaths: string[] = []; + for (const raw of params.requestedPaths) { + const pathResult = await resolveExistingRelativePath(raw); + if (!pathResult.ok) { + return { ok: false, error: pathResult.error }; + } + + let opened: Awaited> | undefined; + try { + opened = await openFileWithinRoot({ + rootDir, + relativePath: pathResult.relativePath, + }); + resolvedPaths.push(opened.realPath); + } catch (err) { + if (params.allowMissingFallback && err instanceof SafeOpenError && err.code === "not-found") { + // Preserve historical behavior for paths that do not exist yet. + resolvedPaths.push(pathResult.fallbackPath); + continue; + } + if (err instanceof SafeOpenError && err.code === "outside-workspace") { + return { + ok: false, + error: `File is outside ${params.scopeLabel}`, + }; + } + return { + ok: false, + error: `Invalid path: must stay within ${params.scopeLabel} and be a regular non-symlink file`, + }; + } finally { + await opened?.handle.close().catch(() => {}); + } + } + return { ok: true, paths: resolvedPaths }; +} diff --git a/extensions/browser/src/browser/plugin-enabled.ts b/extensions/browser/src/browser/plugin-enabled.ts new file mode 100644 index 00000000000..cbcb43cff8e --- /dev/null +++ b/extensions/browser/src/browser/plugin-enabled.ts @@ -0,0 +1 @@ +export * from "../plugin-enabled.js"; diff --git a/extensions/browser/src/browser/plugin-service.ts b/extensions/browser/src/browser/plugin-service.ts new file mode 100644 index 00000000000..0d5513903e5 --- /dev/null +++ b/extensions/browser/src/browser/plugin-service.ts @@ -0,0 +1 @@ +export * from "../plugin-service.js"; diff --git a/extensions/browser/src/browser/profile-capabilities.ts b/extensions/browser/src/browser/profile-capabilities.ts new file mode 100644 index 00000000000..994894239d1 --- /dev/null +++ b/extensions/browser/src/browser/profile-capabilities.ts @@ -0,0 +1,93 @@ +import type { ResolvedBrowserProfile } from "./config.js"; + +export type BrowserProfileMode = "local-managed" | "local-existing-session" | "remote-cdp"; + +export type BrowserProfileCapabilities = { + mode: BrowserProfileMode; + isRemote: boolean; + /** Profile uses the Chrome DevTools MCP server (existing-session driver). */ + usesChromeMcp: boolean; + usesPersistentPlaywright: boolean; + supportsPerTabWs: boolean; + supportsJsonTabEndpoints: boolean; + supportsReset: boolean; + supportsManagedTabLimit: boolean; +}; + +export function getBrowserProfileCapabilities( + profile: ResolvedBrowserProfile, +): BrowserProfileCapabilities { + if (profile.driver === "existing-session") { + return { + mode: "local-existing-session", + isRemote: false, + usesChromeMcp: true, + usesPersistentPlaywright: false, + supportsPerTabWs: false, + supportsJsonTabEndpoints: false, + supportsReset: false, + supportsManagedTabLimit: false, + }; + } + + if (!profile.cdpIsLoopback) { + return { + mode: "remote-cdp", + isRemote: true, + usesChromeMcp: false, + usesPersistentPlaywright: true, + supportsPerTabWs: false, + supportsJsonTabEndpoints: false, + supportsReset: false, + supportsManagedTabLimit: false, + }; + } + + return { + mode: "local-managed", + isRemote: false, + usesChromeMcp: false, + usesPersistentPlaywright: false, + supportsPerTabWs: true, + supportsJsonTabEndpoints: true, + supportsReset: true, + supportsManagedTabLimit: true, + }; +} + +export function resolveDefaultSnapshotFormat(params: { + profile: ResolvedBrowserProfile; + hasPlaywright: boolean; + explicitFormat?: "ai" | "aria"; + mode?: "efficient"; +}): "ai" | "aria" { + if (params.explicitFormat) { + return params.explicitFormat; + } + if (params.mode === "efficient") { + return "ai"; + } + + const capabilities = getBrowserProfileCapabilities(params.profile); + if (capabilities.mode === "local-existing-session") { + return "ai"; + } + + return params.hasPlaywright ? "ai" : "aria"; +} + +export function shouldUsePlaywrightForScreenshot(params: { + profile: ResolvedBrowserProfile; + wsUrl?: string; + ref?: string; + element?: string; +}): boolean { + return !params.wsUrl || Boolean(params.ref) || Boolean(params.element); +} + +export function shouldUsePlaywrightForAriaSnapshot(params: { + profile: ResolvedBrowserProfile; + wsUrl?: string; +}): boolean { + return !params.wsUrl; +} diff --git a/extensions/browser/src/browser/profiles-service.ts b/extensions/browser/src/browser/profiles-service.ts new file mode 100644 index 00000000000..ea1f3b674c6 --- /dev/null +++ b/extensions/browser/src/browser/profiles-service.ts @@ -0,0 +1,258 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; +import { loadConfig, writeConfigFile } from "../config/config.js"; +import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js"; +import { resolveUserPath } from "../utils.js"; +import { resolveOpenClawUserDataDir } from "./chrome.js"; +import { parseHttpUrl, resolveProfile } from "./config.js"; +import { + BrowserConflictError, + BrowserProfileNotFoundError, + BrowserResourceExhaustedError, + BrowserValidationError, +} from "./errors.js"; +import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; +import { + allocateCdpPort, + allocateColor, + getUsedColors, + getUsedPorts, + isValidProfileName, +} from "./profiles.js"; +import type { BrowserRouteContext, ProfileStatus } from "./server-context.js"; +import { movePathToTrash } from "./trash.js"; + +export type CreateProfileParams = { + name: string; + color?: string; + cdpUrl?: string; + userDataDir?: string; + driver?: "openclaw" | "existing-session"; +}; + +export type CreateProfileResult = { + ok: true; + profile: string; + transport: "cdp" | "chrome-mcp"; + cdpPort: number | null; + cdpUrl: string | null; + userDataDir: string | null; + color: string; + isRemote: boolean; +}; + +export type DeleteProfileResult = { + ok: true; + profile: string; + deleted: boolean; +}; + +const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/; + +const cdpPortRange = (resolved: { + controlPort: number; + cdpPortRangeStart?: number; + cdpPortRangeEnd?: number; +}): { start: number; end: number } => { + const start = resolved.cdpPortRangeStart; + const end = resolved.cdpPortRangeEnd; + if ( + typeof start === "number" && + Number.isFinite(start) && + Number.isInteger(start) && + typeof end === "number" && + Number.isFinite(end) && + Number.isInteger(end) && + start > 0 && + end >= start && + end <= 65535 + ) { + return { start, end }; + } + + return deriveDefaultBrowserCdpPortRange(resolved.controlPort); +}; + +export function createBrowserProfilesService(ctx: BrowserRouteContext) { + const listProfiles = async (): Promise => { + return await ctx.listProfiles(); + }; + + const createProfile = async (params: CreateProfileParams): Promise => { + const name = params.name.trim(); + const rawCdpUrl = params.cdpUrl?.trim() || undefined; + const rawUserDataDir = params.userDataDir?.trim() || undefined; + const normalizedUserDataDir = rawUserDataDir ? resolveUserPath(rawUserDataDir) : undefined; + const driver = params.driver === "existing-session" ? "existing-session" : undefined; + + if (!isValidProfileName(name)) { + throw new BrowserValidationError( + "invalid profile name: use lowercase letters, numbers, and hyphens only", + ); + } + + const state = ctx.state(); + const resolvedProfiles = state.resolved.profiles; + if (name in resolvedProfiles) { + throw new BrowserConflictError(`profile "${name}" already exists`); + } + + const cfg = loadConfig(); + const rawProfiles = cfg.browser?.profiles ?? {}; + if (name in rawProfiles) { + throw new BrowserConflictError(`profile "${name}" already exists`); + } + + const usedColors = getUsedColors(resolvedProfiles); + const profileColor = + params.color && HEX_COLOR_RE.test(params.color) ? params.color : allocateColor(usedColors); + + let profileConfig: BrowserProfileConfig; + if (normalizedUserDataDir && driver !== "existing-session") { + throw new BrowserValidationError( + "driver=existing-session is required when userDataDir is provided", + ); + } + if (normalizedUserDataDir && !fs.existsSync(normalizedUserDataDir)) { + throw new BrowserValidationError( + `browser user data directory not found: ${normalizedUserDataDir}`, + ); + } + + if (rawCdpUrl) { + let parsed: ReturnType; + try { + parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl"); + } catch (err) { + throw new BrowserValidationError(String(err)); + } + if (driver === "existing-session") { + throw new BrowserValidationError( + "driver=existing-session does not accept cdpUrl; it attaches via the Chrome MCP auto-connect flow", + ); + } + profileConfig = { + cdpUrl: parsed.normalized, + ...(driver ? { driver } : {}), + color: profileColor, + }; + } else { + if (driver === "existing-session") { + // existing-session uses Chrome MCP auto-connect; no CDP port needed + profileConfig = { + driver, + attachOnly: true, + ...(normalizedUserDataDir ? { userDataDir: normalizedUserDataDir } : {}), + color: profileColor, + }; + } else { + const usedPorts = getUsedPorts(resolvedProfiles); + const range = cdpPortRange(state.resolved); + const cdpPort = allocateCdpPort(usedPorts, range); + if (cdpPort === null) { + throw new BrowserResourceExhaustedError("no available CDP ports in range"); + } + profileConfig = { + cdpPort, + ...(driver ? { driver } : {}), + color: profileColor, + }; + } + } + + const nextConfig: OpenClawConfig = { + ...cfg, + browser: { + ...cfg.browser, + profiles: { + ...rawProfiles, + [name]: profileConfig, + }, + }, + }; + + await writeConfigFile(nextConfig); + + state.resolved.profiles[name] = profileConfig; + const resolved = resolveProfile(state.resolved, name); + if (!resolved) { + throw new BrowserProfileNotFoundError(`profile "${name}" not found after creation`); + } + const capabilities = getBrowserProfileCapabilities(resolved); + + return { + ok: true, + profile: name, + transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp", + cdpPort: capabilities.usesChromeMcp ? null : resolved.cdpPort, + cdpUrl: capabilities.usesChromeMcp ? null : resolved.cdpUrl, + userDataDir: resolved.userDataDir ?? null, + color: resolved.color, + isRemote: !resolved.cdpIsLoopback, + }; + }; + + const deleteProfile = async (nameRaw: string): Promise => { + const name = nameRaw.trim(); + if (!name) { + throw new BrowserValidationError("profile name is required"); + } + if (!isValidProfileName(name)) { + throw new BrowserValidationError("invalid profile name"); + } + + const state = ctx.state(); + const cfg = loadConfig(); + const profiles = cfg.browser?.profiles ?? {}; + const defaultProfile = cfg.browser?.defaultProfile ?? state.resolved.defaultProfile; + if (name === defaultProfile) { + throw new BrowserValidationError( + `cannot delete the default profile "${name}"; change browser.defaultProfile first`, + ); + } + if (!(name in profiles)) { + throw new BrowserProfileNotFoundError(`profile "${name}" not found`); + } + + let deleted = false; + const resolved = resolveProfile(state.resolved, name); + + if (resolved?.cdpIsLoopback && resolved.driver === "openclaw") { + try { + await ctx.forProfile(name).stopRunningBrowser(); + } catch { + // ignore + } + + const userDataDir = resolveOpenClawUserDataDir(name); + const profileDir = path.dirname(userDataDir); + if (fs.existsSync(profileDir)) { + await movePathToTrash(profileDir); + deleted = true; + } + } + + const { [name]: _removed, ...remainingProfiles } = profiles; + const nextConfig: OpenClawConfig = { + ...cfg, + browser: { + ...cfg.browser, + profiles: remainingProfiles, + }, + }; + + await writeConfigFile(nextConfig); + + delete state.resolved.profiles[name]; + state.profiles.delete(name); + + return { ok: true, profile: name, deleted }; + }; + + return { + listProfiles, + createProfile, + deleteProfile, + }; +} diff --git a/extensions/browser/src/browser/profiles.ts b/extensions/browser/src/browser/profiles.ts new file mode 100644 index 00000000000..73d561d7bdb --- /dev/null +++ b/extensions/browser/src/browser/profiles.ts @@ -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, + 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 | undefined, +): Set { + if (!profiles) { + return new Set(); + } + const used = new Set(); + 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 { + // 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 | undefined, +): Set { + if (!profiles) { + return new Set(); + } + return new Set(Object.values(profiles).map((p) => p.color.toUpperCase())); +} diff --git a/extensions/browser/src/browser/proxy-files.ts b/extensions/browser/src/browser/proxy-files.ts new file mode 100644 index 00000000000..1d39d71a09e --- /dev/null +++ b/extensions/browser/src/browser/proxy-files.ts @@ -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(); + } + const mapping = new Map(); + for (const file of files) { + const buffer = Buffer.from(file.base64, "base64"); + const saved = await saveMediaBuffer(buffer, file.mimeType, "browser"); + mapping.set(file.path, saved.path); + } + return mapping; +} + +export function applyBrowserProxyPaths(result: unknown, mapping: Map) { + if (!result || typeof result !== "object") { + return; + } + const obj = result as Record; + 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; + if (typeof d.path === "string" && mapping.has(d.path)) { + d.path = mapping.get(d.path); + } + } +} diff --git a/extensions/browser/src/browser/pw-ai-module.ts b/extensions/browser/src/browser/pw-ai-module.ts new file mode 100644 index 00000000000..e5062b8fe74 --- /dev/null +++ b/extensions/browser/src/browser/pw-ai-module.ts @@ -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 | null = null; +let pwAiModuleStrict: Promise | 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 { + 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 { + 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; +} diff --git a/extensions/browser/src/browser/pw-ai-state.ts b/extensions/browser/src/browser/pw-ai-state.ts new file mode 100644 index 00000000000..58ce89f30d9 --- /dev/null +++ b/extensions/browser/src/browser/pw-ai-state.ts @@ -0,0 +1,9 @@ +let pwAiLoaded = false; + +export function markPwAiLoaded(): void { + pwAiLoaded = true; +} + +export function isPwAiLoaded(): boolean { + return pwAiLoaded; +} diff --git a/extensions/browser/src/browser/pw-ai.ts b/extensions/browser/src/browser/pw-ai.ts new file mode 100644 index 00000000000..f8d538b5394 --- /dev/null +++ b/extensions/browser/src/browser/pw-ai.ts @@ -0,0 +1,66 @@ +import { markPwAiLoaded } from "./pw-ai-state.js"; + +markPwAiLoaded(); + +export { + type BrowserConsoleMessage, + closePageByTargetIdViaPlaywright, + closePlaywrightBrowserConnection, + createPageViaPlaywright, + ensurePageState, + forceDisconnectPlaywrightForTarget, + focusPageByTargetIdViaPlaywright, + getPageForTargetId, + listPagesViaPlaywright, + refLocator, + type WithSnapshotForAI, +} from "./pw-session.js"; + +export { + armDialogViaPlaywright, + armFileUploadViaPlaywright, + batchViaPlaywright, + clickViaPlaywright, + closePageViaPlaywright, + cookiesClearViaPlaywright, + cookiesGetViaPlaywright, + cookiesSetViaPlaywright, + downloadViaPlaywright, + dragViaPlaywright, + emulateMediaViaPlaywright, + evaluateViaPlaywright, + fillFormViaPlaywright, + getConsoleMessagesViaPlaywright, + getNetworkRequestsViaPlaywright, + getPageErrorsViaPlaywright, + highlightViaPlaywright, + hoverViaPlaywright, + navigateViaPlaywright, + pdfViaPlaywright, + pressKeyViaPlaywright, + resizeViewportViaPlaywright, + responseBodyViaPlaywright, + scrollIntoViewViaPlaywright, + selectOptionViaPlaywright, + setDeviceViaPlaywright, + setExtraHTTPHeadersViaPlaywright, + setGeolocationViaPlaywright, + setHttpCredentialsViaPlaywright, + setInputFilesViaPlaywright, + setLocaleViaPlaywright, + setOfflineViaPlaywright, + setTimezoneViaPlaywright, + snapshotAiViaPlaywright, + snapshotAriaViaPlaywright, + snapshotRoleViaPlaywright, + screenshotWithLabelsViaPlaywright, + storageClearViaPlaywright, + storageGetViaPlaywright, + storageSetViaPlaywright, + takeScreenshotViaPlaywright, + traceStartViaPlaywright, + traceStopViaPlaywright, + typeViaPlaywright, + waitForDownloadViaPlaywright, + waitForViaPlaywright, +} from "./pw-tools-core.js"; diff --git a/extensions/browser/src/browser/pw-role-snapshot.ts b/extensions/browser/src/browser/pw-role-snapshot.ts new file mode 100644 index 00000000000..312abcf872f --- /dev/null +++ b/extensions/browser/src/browser/pw-role-snapshot.ts @@ -0,0 +1,402 @@ +import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js"; + +export type RoleRef = { + role: string; + name?: string; + /** Index used only when role+name duplicates exist. */ + nth?: number; +}; + +export type RoleRefMap = Record; + +export type RoleSnapshotStats = { + lines: number; + chars: number; + refs: number; + interactive: number; +}; + +export type RoleSnapshotOptions = { + /** Only include interactive elements (buttons, links, inputs, etc.). */ + interactive?: boolean; + /** Maximum depth to include (0 = root only). */ + maxDepth?: number; + /** Remove unnamed structural elements and empty branches. */ + compact?: boolean; +}; + +export function getRoleSnapshotStats(snapshot: string, refs: RoleRefMap): RoleSnapshotStats { + const interactive = Object.values(refs).filter((r) => INTERACTIVE_ROLES.has(r.role)).length; + return { + lines: snapshot.split("\n").length, + chars: snapshot.length, + refs: Object.keys(refs).length, + interactive, + }; +} + +function getIndentLevel(line: string): number { + const match = line.match(/^(\s*)/); + return match ? Math.floor(match[1].length / 2) : 0; +} + +function matchInteractiveSnapshotLine( + line: string, + options: RoleSnapshotOptions, +): { roleRaw: string; role: string; name?: string; suffix: string } | null { + const depth = getIndentLevel(line); + if (options.maxDepth !== undefined && depth > options.maxDepth) { + return null; + } + const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/); + if (!match) { + return null; + } + const [, , roleRaw, name, suffix] = match; + if (roleRaw.startsWith("/")) { + return null; + } + const role = roleRaw.toLowerCase(); + return { + roleRaw, + role, + ...(name ? { name } : {}), + suffix, + }; +} + +type RoleNameTracker = { + counts: Map; + refsByKey: Map; + getKey: (role: string, name?: string) => string; + getNextIndex: (role: string, name?: string) => number; + trackRef: (role: string, name: string | undefined, ref: string) => void; + getDuplicateKeys: () => Set; +}; + +function createRoleNameTracker(): RoleNameTracker { + const counts = new Map(); + const refsByKey = new Map(); + 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(); + 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>; + +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, + }; +} diff --git a/extensions/browser/src/browser/pw-session.mock-setup.ts b/extensions/browser/src/browser/pw-session.mock-setup.ts new file mode 100644 index 00000000000..0b176d536db --- /dev/null +++ b/extensions/browser/src/browser/pw-session.mock-setup.ts @@ -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), +})); diff --git a/extensions/browser/src/browser/pw-session.page-cdp.ts b/extensions/browser/src/browser/pw-session.page-cdp.ts new file mode 100644 index 00000000000..ccfc2ee7f34 --- /dev/null +++ b/extensions/browser/src/browser/pw-session.page-cdp.ts @@ -0,0 +1,33 @@ +import type { CDPSession, Page } from "playwright-core"; + +type PageCdpSend = (method: string, params?: Record) => Promise; + +async function withPlaywrightPageCdpSession( + page: Page, + fn: (session: CDPSession) => Promise, +): Promise { + const session = await page.context().newCDPSession(page); + try { + return await fn(session); + } finally { + await session.detach().catch(() => {}); + } +} + +export async function withPageScopedCdpClient(opts: { + cdpUrl: string; + page: Page; + targetId?: string; + fn: (send: PageCdpSend) => Promise; +}): Promise { + return await withPlaywrightPageCdpSession(opts.page, async (session) => { + return await opts.fn((method, params) => + ( + session.send as unknown as ( + method: string, + params?: Record, + ) => Promise + )(method, params), + ); + }); +} diff --git a/extensions/browser/src/browser/pw-session.ts b/extensions/browser/src/browser/pw-session.ts new file mode 100644 index 00000000000..97677557543 --- /dev/null +++ b/extensions/browser/src/browser/pw-session.ts @@ -0,0 +1,845 @@ +import type { + Browser, + BrowserContext, + ConsoleMessage, + Page, + Request, + Response, +} from "playwright-core"; +import { chromium } from "playwright-core"; +import { formatErrorMessage } from "../infra/errors.js"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; +import { + appendCdpPath, + fetchJson, + getHeadersWithAuth, + normalizeCdpHttpBaseForJsonEndpoints, + withCdpSocket, +} from "./cdp.helpers.js"; +import { normalizeCdpWsUrl } from "./cdp.js"; +import { getChromeWebSocketUrl } from "./chrome.js"; +import { BrowserTabNotFoundError } from "./errors.js"; +import { + assertBrowserNavigationAllowed, + assertBrowserNavigationRedirectChainAllowed, + assertBrowserNavigationResultAllowed, + withBrowserNavigationPolicy, +} from "./navigation-guard.js"; +import { withPageScopedCdpClient } from "./pw-session.page-cdp.js"; + +export type BrowserConsoleMessage = { + type: string; + text: string; + timestamp: string; + location?: { url?: string; lineNumber?: number; columnNumber?: number }; +}; + +export type BrowserPageError = { + message: string; + name?: string; + stack?: string; + timestamp: string; +}; + +export type BrowserNetworkRequest = { + id: string; + timestamp: string; + method: string; + url: string; + resourceType?: string; + status?: number; + ok?: boolean; + failureText?: string; +}; + +type SnapshotForAIResult = { full: string; incremental?: string }; +type SnapshotForAIOptions = { timeout?: number; track?: string }; + +export type WithSnapshotForAI = { + _snapshotForAI?: (options?: SnapshotForAIOptions) => Promise; +}; + +type TargetInfoResponse = { + targetInfo?: { + targetId?: string; + }; +}; + +type ConnectedBrowser = { + browser: Browser; + cdpUrl: string; + onDisconnected?: () => void; +}; + +type PageState = { + console: BrowserConsoleMessage[]; + errors: BrowserPageError[]; + requests: BrowserNetworkRequest[]; + requestIds: WeakMap; + 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; + roleRefsMode?: "role" | "aria"; + roleRefsFrameSelector?: string; +}; + +type RoleRefs = NonNullable; +type RoleRefsCacheEntry = { + refs: RoleRefs; + frameSelector?: string; + mode?: NonNullable; +}; + +type ContextState = { + traceActive: boolean; +}; + +const pageStates = new WeakMap(); +const contextStates = new WeakMap(); +const observedContexts = new WeakSet(); +const observedPages = new WeakSet(); + +// 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(); +const MAX_ROLE_REFS_CACHE = 50; + +const MAX_CONSOLE_MESSAGES = 500; +const MAX_PAGE_ERRORS = 200; +const MAX_NETWORK_REQUESTS = 500; + +const cachedByCdpUrl = new Map(); +const connectingByCdpUrl = new Map>(); + +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; +}): 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; +}): 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 { + const normalized = normalizeCdpUrl(cdpUrl); + const cached = cachedByCdpUrl.get(normalized); + if (cached) { + return cached; + } + const connecting = connectingByCdpUrl.get(normalized); + if (connecting) { + return await connecting; + } + + const connectWithRetry = async (): Promise => { + let lastErr: unknown; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const timeout = 5000 + attempt * 2000; + const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null); + const endpoint = wsUrl ?? normalized; + const headers = getHeadersWithAuth(endpoint); + // Bypass proxy for loopback CDP connections (#31219) + const browser = await withNoProxyForCdpUrl(endpoint, () => + chromium.connectOverCDP(endpoint, { timeout, headers }), + ); + const onDisconnected = () => { + const current = cachedByCdpUrl.get(normalized); + if (current?.browser === browser) { + cachedByCdpUrl.delete(normalized); + } + }; + const connected: ConnectedBrowser = { browser, cdpUrl: normalized, onDisconnected }; + cachedByCdpUrl.set(normalized, connected); + browser.on("disconnected", onDisconnected); + observeBrowser(browser); + return connected; + } catch (err) { + lastErr = err; + // Don't retry rate-limit errors; retrying worsens the 429. + const errMsg = err instanceof Error ? err.message : String(err); + if (errMsg.includes("rate limit")) { + break; + } + const delay = 250 + attempt * 250; + await new Promise((r) => setTimeout(r, delay)); + } + } + if (lastErr instanceof Error) { + throw lastErr; + } + const message = lastErr ? formatErrorMessage(lastErr) : "CDP connect failed"; + throw new Error(message); + }; + + const pending = connectWithRetry().finally(() => { + connectingByCdpUrl.delete(normalized); + }); + connectingByCdpUrl.set(normalized, pending); + + return await pending; +} + +async function getAllPages(browser: Browser): Promise { + const contexts = browser.contexts(); + const pages = contexts.flatMap((c) => c.pages()); + return pages; +} + +async function pageTargetId(page: Page): Promise { + const session = await page.context().newCDPSession(page); + try { + const info = (await session.send("Target.getTargetInfo")) as TargetInfoResponse; + const targetId = String(info?.targetInfo?.targetId ?? "").trim(); + return targetId || null; + } finally { + await session.detach().catch(() => {}); + } +} + +function matchPageByTargetList( + pages: Page[], + targets: Array<{ id: string; url: string; title?: string }>, + targetId: string, +): Page | null { + const target = targets.find((entry) => entry.id === targetId); + if (!target) { + return null; + } + + const urlMatch = pages.filter((page) => page.url() === target.url); + if (urlMatch.length === 1) { + return urlMatch[0] ?? null; + } + if (urlMatch.length > 1) { + const sameUrlTargets = targets.filter((entry) => entry.url === target.url); + if (sameUrlTargets.length === urlMatch.length) { + const idx = sameUrlTargets.findIndex((entry) => entry.id === targetId); + if (idx >= 0 && idx < urlMatch.length) { + return urlMatch[idx] ?? null; + } + } + } + return null; +} + +async function findPageByTargetIdViaTargetList( + pages: Page[], + targetId: string, + cdpUrl: string, +): Promise { + const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl); + const targets = await fetchJson< + Array<{ + id: string; + url: string; + title?: string; + }> + >(appendCdpPath(cdpHttpBase, "/json/list"), 2000); + return matchPageByTargetList(pages, targets, targetId); +} + +async function findPageByTargetId( + browser: Browser, + targetId: string, + cdpUrl?: string, +): Promise { + const pages = await getAllPages(browser); + let resolvedViaCdp = false; + for (const page of pages) { + let tid: string | null = null; + try { + tid = await pageTargetId(page); + resolvedViaCdp = true; + } catch { + tid = null; + } + if (tid && tid === targetId) { + return page; + } + } + if (cdpUrl) { + try { + return await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl); + } catch { + // Ignore fetch errors and fall through to return null. + } + } + if (!resolvedViaCdp && pages.length === 1) { + return pages[0] ?? null; + } + return null; +} + +async function resolvePageByTargetIdOrThrow(opts: { + cdpUrl: string; + targetId: string; +}): Promise { + const { browser } = await connectBrowser(opts.cdpUrl); + const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl); + if (!page) { + throw new BrowserTabNotFoundError(); + } + return page; +} + +export async function getPageForTargetId(opts: { + cdpUrl: string; + targetId?: string; +}): Promise { + const { browser } = await connectBrowser(opts.cdpUrl); + const pages = await getAllPages(browser); + if (!pages.length) { + throw new Error("No pages available in the connected browser."); + } + const first = pages[0]; + if (!opts.targetId) { + return first; + } + const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl); + if (!found) { + // If Playwright only exposes a single Page, use it as a best-effort fallback. + if (pages.length === 1) { + return first; + } + throw new BrowserTabNotFoundError(); + } + return found; +} + +export function refLocator(page: Page, ref: string) { + const normalized = ref.startsWith("@") + ? ref.slice(1) + : ref.startsWith("ref=") + ? ref.slice(4) + : ref; + + if (/^e\d+$/.test(normalized)) { + const state = pageStates.get(page); + if (state?.roleRefsMode === "aria") { + const scope = state.roleRefsFrameSelector + ? page.frameLocator(state.roleRefsFrameSelector) + : page; + return scope.locator(`aria-ref=${normalized}`); + } + const info = state?.roleRefs?.[normalized]; + if (!info) { + throw new Error( + `Unknown ref "${normalized}". Run a new snapshot and use a ref from that snapshot.`, + ); + } + const scope = state?.roleRefsFrameSelector + ? page.frameLocator(state.roleRefsFrameSelector) + : page; + const locAny = scope as unknown as { + getByRole: ( + role: never, + opts?: { name?: string; exact?: boolean }, + ) => ReturnType; + }; + const locator = info.name + ? locAny.getByRole(info.role as never, { name: info.name, exact: true }) + : locAny.getByRole(info.role as never); + return info.nth !== undefined ? locator.nth(info.nth) : locator; + } + + return page.locator(`aria-ref=${normalized}`); +} + +export async function closePlaywrightBrowserConnection(opts?: { cdpUrl?: string }): Promise { + const normalized = opts?.cdpUrl ? normalizeCdpUrl(opts.cdpUrl) : null; + + if (normalized) { + const cur = cachedByCdpUrl.get(normalized); + cachedByCdpUrl.delete(normalized); + connectingByCdpUrl.delete(normalized); + if (!cur) { + return; + } + if (cur.onDisconnected && typeof cur.browser.off === "function") { + cur.browser.off("disconnected", cur.onDisconnected); + } + await cur.browser.close().catch(() => {}); + return; + } + + const connections = Array.from(cachedByCdpUrl.values()); + cachedByCdpUrl.clear(); + connectingByCdpUrl.clear(); + for (const cur of connections) { + if (cur.onDisconnected && typeof cur.browser.off === "function") { + cur.browser.off("disconnected", cur.onDisconnected); + } + await cur.browser.close().catch(() => {}); + } +} + +function cdpSocketNeedsAttach(wsUrl: string): boolean { + try { + const pathname = new URL(wsUrl).pathname; + return ( + pathname === "/cdp" || pathname.endsWith("/cdp") || pathname.includes("/devtools/browser/") + ); + } catch { + return false; + } +} + +async function tryTerminateExecutionViaCdp(opts: { + cdpUrl: string; + targetId: string; +}): Promise { + 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 (work: Promise, ms: number): Promise => { + let timer: ReturnType | undefined; + const timeoutPromise = new Promise((_, 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 { + const normalized = normalizeCdpUrl(opts.cdpUrl); + const cur = cachedByCdpUrl.get(normalized); + if (!cur) { + return; + } + cachedByCdpUrl.delete(normalized); + // Also clear the per-url in-flight connect so the next call does a fresh connectOverCDP + // rather than awaiting a stale promise. + connectingByCdpUrl.delete(normalized); + // Remove the "disconnected" listener to prevent the old browser's teardown + // from racing with a fresh connection and nulling the new cached entry. + if (cur.onDisconnected && typeof cur.browser.off === "function") { + cur.browser.off("disconnected", cur.onDisconnected); + } + + // Best-effort: kill any stuck JS to unblock the target's execution context before we + // disconnect Playwright's CDP connection. + const targetId = opts.targetId?.trim() || ""; + if (targetId) { + await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId }).catch(() => {}); + } + + // Fire-and-forget: don't await because browser.close() may hang on the stuck CDP pipe. + cur.browser.close().catch(() => {}); +} + +/** + * List all pages/tabs from the persistent Playwright connection. + * Used for remote profiles where HTTP-based /json/list is ephemeral. + */ +export async function listPagesViaPlaywright(opts: { cdpUrl: string }): Promise< + Array<{ + targetId: string; + title: string; + url: string; + type: string; + }> +> { + const { browser } = await connectBrowser(opts.cdpUrl); + const pages = await getAllPages(browser); + const results: Array<{ + targetId: string; + title: string; + url: string; + type: string; + }> = []; + + for (const page of pages) { + const tid = await pageTargetId(page).catch(() => null); + if (tid) { + results.push({ + targetId: tid, + title: await page.title().catch(() => ""), + url: page.url(), + type: "page", + }); + } + } + return results; +} + +/** + * Create a new page/tab using the persistent Playwright connection. + * Used for remote profiles where HTTP-based /json/new is ephemeral. + * Returns the new page's targetId and metadata. + */ +export async function createPageViaPlaywright(opts: { + cdpUrl: string; + url: string; + ssrfPolicy?: SsrFPolicy; +}): Promise<{ + targetId: string; + title: string; + url: string; + type: string; +}> { + const { browser } = await connectBrowser(opts.cdpUrl); + const context = browser.contexts()[0] ?? (await browser.newContext()); + ensureContextState(context); + + const page = await context.newPage(); + ensurePageState(page); + + // Navigate to the URL + const targetUrl = opts.url.trim() || "about:blank"; + if (targetUrl !== "about:blank") { + const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy); + await assertBrowserNavigationAllowed({ + url: targetUrl, + ...navigationPolicy, + }); + const response = await page.goto(targetUrl, { timeout: 30_000 }).catch(() => { + // Navigation might fail for some URLs, but page is still created + return null; + }); + await assertBrowserNavigationRedirectChainAllowed({ + request: response?.request(), + ...navigationPolicy, + }); + await assertBrowserNavigationResultAllowed({ + url: page.url(), + ...navigationPolicy, + }); + } + + // Get the targetId for this page + const tid = await pageTargetId(page).catch(() => null); + if (!tid) { + throw new Error("Failed to get targetId for new page"); + } + + return { + targetId: tid, + title: await page.title().catch(() => ""), + url: page.url(), + type: "page", + }; +} + +/** + * Close a page/tab by targetId using the persistent Playwright connection. + * Used for remote profiles where HTTP-based /json/close is ephemeral. + */ +export async function closePageByTargetIdViaPlaywright(opts: { + cdpUrl: string; + targetId: string; +}): Promise { + const page = await resolvePageByTargetIdOrThrow(opts); + await page.close(); +} + +/** + * Focus a page/tab by targetId using the persistent Playwright connection. + * Used for remote profiles where HTTP-based /json/activate can be ephemeral. + */ +export async function focusPageByTargetIdViaPlaywright(opts: { + cdpUrl: string; + targetId: string; +}): Promise { + const page = await resolvePageByTargetIdOrThrow(opts); + try { + await page.bringToFront(); + } catch (err) { + try { + await withPageScopedCdpClient({ + cdpUrl: opts.cdpUrl, + page, + targetId: opts.targetId, + fn: async (send) => { + await send("Page.bringToFront"); + }, + }); + return; + } catch { + throw err; + } + } +} diff --git a/extensions/browser/src/browser/pw-tools-core.activity.ts b/extensions/browser/src/browser/pw-tools-core.activity.ts new file mode 100644 index 00000000000..33295060029 --- /dev/null +++ b/extensions/browser/src/browser/pw-tools-core.activity.ts @@ -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 { + 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); +} diff --git a/extensions/browser/src/browser/pw-tools-core.downloads.ts b/extensions/browser/src/browser/pw-tools-core.downloads.ts new file mode 100644 index 00000000000..6024ee09f41 --- /dev/null +++ b/extensions/browser/src/browser/pw-tools-core.downloads.ts @@ -0,0 +1,280 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { Page } from "playwright-core"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; +import { writeViaSiblingTempPath } from "./output-atomic.js"; +import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js"; +import { + ensurePageState, + getPageForTargetId, + refLocator, + restoreRoleRefsForTarget, +} from "./pw-session.js"; +import { + bumpDialogArmId, + bumpDownloadArmId, + bumpUploadArmId, + normalizeTimeoutMs, + requireRef, + toAIFriendlyError, +} from "./pw-tools-core.shared.js"; +import { sanitizeUntrustedFileName } from "./safe-filename.js"; + +function buildTempDownloadPath(fileName: string): string { + const id = crypto.randomUUID(); + const safeName = sanitizeUntrustedFileName(fileName, "download.bin"); + return path.join(resolvePreferredOpenClawTmpDir(), "downloads", `${id}-${safeName}`); +} + +function createPageDownloadWaiter(page: Page, timeoutMs: number) { + let done = false; + let timer: NodeJS.Timeout | undefined; + let handler: ((download: unknown) => void) | undefined; + + const cleanup = () => { + if (timer) { + clearTimeout(timer); + } + timer = undefined; + if (handler) { + page.off("download", handler as never); + handler = undefined; + } + }; + + const promise = new Promise((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; +}; + +async function saveDownloadPayload(download: DownloadPayload, outPath: string) { + const suggested = download.suggestedFilename?.() || "download.bin"; + const requestedPath = outPath?.trim(); + const resolvedOutPath = path.resolve(requestedPath || buildTempDownloadPath(suggested)); + await fs.mkdir(path.dirname(resolvedOutPath), { recursive: true }); + + if (!requestedPath) { + await download.saveAs?.(resolvedOutPath); + } else { + await writeViaSiblingTempPath({ + rootDir: path.dirname(resolvedOutPath), + targetPath: resolvedOutPath, + writeTemp: async (tempPath) => { + await download.saveAs?.(tempPath); + }, + }); + } + + return { + url: download.url?.() || "", + suggestedFilename: suggested, + path: resolvedOutPath, + }; +} + +async function awaitDownloadPayload(params: { + waiter: ReturnType; + state: ReturnType; + 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 { + 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 { + 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; + } +} diff --git a/extensions/browser/src/browser/pw-tools-core.interactions.ts b/extensions/browser/src/browser/pw-tools-core.interactions.ts new file mode 100644 index 00000000000..01abc5338f0 --- /dev/null +++ b/extensions/browser/src/browser/pw-tools-core.interactions.ts @@ -0,0 +1,891 @@ +import type { BrowserActRequest, BrowserFormField } from "./client-actions-core.js"; +import { DEFAULT_FILL_FIELD_TYPE } from "./form-fields.js"; +import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js"; +import { + ensurePageState, + forceDisconnectPlaywrightForTarget, + getPageForTargetId, + refLocator, + restoreRoleRefsForTarget, +} from "./pw-session.js"; +import { + normalizeTimeoutMs, + requireRef, + requireRefOrSelector, + toAIFriendlyError, +} from "./pw-tools-core.shared.js"; +import { closePageViaPlaywright, resizeViewportViaPlaywright } from "./pw-tools-core.snapshot.js"; + +type TargetOpts = { + cdpUrl: string; + targetId?: string; +}; + +const MAX_CLICK_DELAY_MS = 5_000; +const MAX_WAIT_TIME_MS = 30_000; +const MAX_BATCH_ACTIONS = 100; + +function resolveBoundedDelayMs(value: number | undefined, label: string, maxMs: number): number { + const normalized = Math.floor(value ?? 0); + if (!Number.isFinite(normalized) || normalized < 0) { + throw new Error(`${label} must be >= 0`); + } + if (normalized > maxMs) { + throw new Error(`${label} exceeds maximum of ${maxMs}ms`); + } + return normalized; +} + +async function getRestoredPageForTarget(opts: TargetOpts) { + const page = await getPageForTargetId(opts); + ensurePageState(page); + restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page }); + return page; +} + +function resolveInteractionTimeoutMs(timeoutMs?: number): number { + return Math.max(500, Math.min(60_000, Math.floor(timeoutMs ?? 8000))); +} + +async function awaitEvalWithAbort( + evalPromise: Promise, + abortPromise?: Promise, +): Promise { + if (!abortPromise) { + return await evalPromise; + } + try { + return await Promise.race([evalPromise, abortPromise]); + } catch (err) { + // If abort wins the race, evaluate may reject later; avoid unhandled rejections. + void evalPromise.catch(() => {}); + throw err; + } +} + +export async function highlightViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + ref: string; +}): Promise { + const page = await getRestoredPageForTarget(opts); + const ref = requireRef(opts.ref); + try { + await refLocator(page, ref).highlight(); + } catch (err) { + throw toAIFriendlyError(err, ref); + } +} + +export async function clickViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + ref?: string; + selector?: string; + doubleClick?: boolean; + button?: "left" | "right" | "middle"; + modifiers?: Array<"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift">; + delayMs?: number; + timeoutMs?: number; +}): Promise { + const resolved = requireRefOrSelector(opts.ref, opts.selector); + const page = await getRestoredPageForTarget(opts); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector!); + const timeout = resolveInteractionTimeoutMs(opts.timeoutMs); + try { + const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS); + if (delayMs > 0) { + await locator.hover({ timeout }); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + if (opts.doubleClick) { + await locator.dblclick({ + timeout, + button: opts.button, + modifiers: opts.modifiers, + }); + } else { + await locator.click({ + timeout, + button: opts.button, + modifiers: opts.modifiers, + }); + } + } catch (err) { + throw toAIFriendlyError(err, label); + } +} + +export async function hoverViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + ref?: string; + selector?: string; + timeoutMs?: number; +}): Promise { + const resolved = requireRefOrSelector(opts.ref, opts.selector); + const page = await getRestoredPageForTarget(opts); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector!); + try { + await locator.hover({ + timeout: resolveInteractionTimeoutMs(opts.timeoutMs), + }); + } catch (err) { + throw toAIFriendlyError(err, label); + } +} + +export async function dragViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + startRef?: string; + startSelector?: string; + endRef?: string; + endSelector?: string; + timeoutMs?: number; +}): Promise { + const resolvedStart = requireRefOrSelector(opts.startRef, opts.startSelector); + const resolvedEnd = requireRefOrSelector(opts.endRef, opts.endSelector); + const page = await getRestoredPageForTarget(opts); + const startLocator = resolvedStart.ref + ? refLocator(page, requireRef(resolvedStart.ref)) + : page.locator(resolvedStart.selector!); + const endLocator = resolvedEnd.ref + ? refLocator(page, requireRef(resolvedEnd.ref)) + : page.locator(resolvedEnd.selector!); + const startLabel = resolvedStart.ref ?? resolvedStart.selector!; + const endLabel = resolvedEnd.ref ?? resolvedEnd.selector!; + try { + await startLocator.dragTo(endLocator, { + timeout: resolveInteractionTimeoutMs(opts.timeoutMs), + }); + } catch (err) { + throw toAIFriendlyError(err, `${startLabel} -> ${endLabel}`); + } +} + +export async function selectOptionViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + ref?: string; + selector?: string; + values: string[]; + timeoutMs?: number; +}): Promise { + const resolved = requireRefOrSelector(opts.ref, opts.selector); + if (!opts.values?.length) { + throw new Error("values are required"); + } + const page = await getRestoredPageForTarget(opts); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector!); + try { + await locator.selectOption(opts.values, { + timeout: resolveInteractionTimeoutMs(opts.timeoutMs), + }); + } catch (err) { + throw toAIFriendlyError(err, label); + } +} + +export async function pressKeyViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + key: string; + delayMs?: number; +}): Promise { + const key = String(opts.key ?? "").trim(); + if (!key) { + throw new Error("key is required"); + } + const page = await getPageForTargetId(opts); + ensurePageState(page); + await page.keyboard.press(key, { + delay: Math.max(0, Math.floor(opts.delayMs ?? 0)), + }); +} + +export async function typeViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + ref?: string; + selector?: string; + text: string; + submit?: boolean; + slowly?: boolean; + timeoutMs?: number; +}): Promise { + const resolved = requireRefOrSelector(opts.ref, opts.selector); + const text = String(opts.text ?? ""); + const page = await getRestoredPageForTarget(opts); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector!); + const timeout = resolveInteractionTimeoutMs(opts.timeoutMs); + try { + if (opts.slowly) { + await locator.click({ timeout }); + await locator.type(text, { timeout, delay: 75 }); + } else { + await locator.fill(text, { timeout }); + } + if (opts.submit) { + await locator.press("Enter", { timeout }); + } + } catch (err) { + throw toAIFriendlyError(err, label); + } +} + +export async function fillFormViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + fields: BrowserFormField[]; + timeoutMs?: number; +}): Promise { + const page = await getRestoredPageForTarget(opts); + const timeout = resolveInteractionTimeoutMs(opts.timeoutMs); + for (const field of opts.fields) { + const ref = field.ref.trim(); + const type = (field.type || DEFAULT_FILL_FIELD_TYPE).trim() || DEFAULT_FILL_FIELD_TYPE; + const rawValue = field.value; + const value = + typeof rawValue === "string" + ? rawValue + : typeof rawValue === "number" || typeof rawValue === "boolean" + ? String(rawValue) + : ""; + if (!ref) { + continue; + } + const locator = refLocator(page, ref); + if (type === "checkbox" || type === "radio") { + const checked = + rawValue === true || rawValue === 1 || rawValue === "1" || rawValue === "true"; + try { + await locator.setChecked(checked, { timeout }); + } catch (err) { + throw toAIFriendlyError(err, ref); + } + continue; + } + try { + await locator.fill(value, { timeout }); + } catch (err) { + throw toAIFriendlyError(err, ref); + } + } +} + +export async function evaluateViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + fn: string; + ref?: string; + timeoutMs?: number; + signal?: AbortSignal; +}): Promise { + const fnText = String(opts.fn ?? "").trim(); + if (!fnText) { + throw new Error("function is required"); + } + const page = await getRestoredPageForTarget(opts); + // Clamp evaluate timeout to prevent permanently blocking Playwright's command queue. + // Without this, a long-running async evaluate blocks all subsequent page operations + // because Playwright serializes CDP commands per page. + // + // NOTE: Playwright's { timeout } on evaluate only applies to installing the function, + // NOT to its execution time. We must inject a Promise.race timeout into the browser + // context itself so async functions are bounded. + const outerTimeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); + // Leave headroom for routing/serialization overhead so the outer request timeout + // doesn't fire first and strand a long-running evaluate. + let evaluateTimeout = Math.max(1000, Math.min(120_000, outerTimeout - 500)); + evaluateTimeout = Math.min(evaluateTimeout, outerTimeout); + + const signal = opts.signal; + let abortListener: (() => void) | undefined; + let abortReject: ((reason: unknown) => void) | undefined; + let abortPromise: Promise | undefined; + if (signal) { + abortPromise = new Promise((_, reject) => { + abortReject = reject; + }); + // Ensure the abort promise never becomes an unhandled rejection if we throw early. + void abortPromise.catch(() => {}); + } + if (signal) { + const disconnect = () => { + void forceDisconnectPlaywrightForTarget({ + cdpUrl: opts.cdpUrl, + targetId: opts.targetId, + reason: "evaluate aborted", + }).catch(() => {}); + }; + if (signal.aborted) { + disconnect(); + throw signal.reason ?? new Error("aborted"); + } + abortListener = () => { + disconnect(); + abortReject?.(signal.reason ?? new Error("aborted")); + }; + signal.addEventListener("abort", abortListener, { once: true }); + // If the signal aborted between the initial check and listener registration, handle it. + if (signal.aborted) { + abortListener(); + throw signal.reason ?? new Error("aborted"); + } + } + + try { + if (opts.ref) { + const locator = refLocator(page, opts.ref); + // eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval + const elementEvaluator = new Function( + "el", + "args", + ` + "use strict"; + var fnBody = args.fnBody, timeoutMs = args.timeoutMs; + try { + var candidate = eval("(" + fnBody + ")"); + var result = typeof candidate === "function" ? candidate(el) : candidate; + if (result && typeof result.then === "function") { + return Promise.race([ + result, + new Promise(function(_, reject) { + setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs); + }) + ]); + } + return result; + } catch (err) { + throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err))); + } + `, + ) as (el: Element, args: { fnBody: string; timeoutMs: number }) => unknown; + const evalPromise = locator.evaluate(elementEvaluator, { + fnBody: fnText, + timeoutMs: evaluateTimeout, + }); + return await awaitEvalWithAbort(evalPromise, abortPromise); + } + + // eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval + const browserEvaluator = new Function( + "args", + ` + "use strict"; + var fnBody = args.fnBody, timeoutMs = args.timeoutMs; + try { + var candidate = eval("(" + fnBody + ")"); + var result = typeof candidate === "function" ? candidate() : candidate; + if (result && typeof result.then === "function") { + return Promise.race([ + result, + new Promise(function(_, reject) { + setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs); + }) + ]); + } + return result; + } catch (err) { + throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err))); + } + `, + ) as (args: { fnBody: string; timeoutMs: number }) => unknown; + const evalPromise = page.evaluate(browserEvaluator, { + fnBody: fnText, + timeoutMs: evaluateTimeout, + }); + return await awaitEvalWithAbort(evalPromise, abortPromise); + } finally { + if (signal && abortListener) { + signal.removeEventListener("abort", abortListener); + } + } +} + +export async function scrollIntoViewViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + ref?: string; + selector?: string; + timeoutMs?: number; +}): Promise { + const resolved = requireRefOrSelector(opts.ref, opts.selector); + const page = await getRestoredPageForTarget(opts); + const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); + + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector!); + try { + await locator.scrollIntoViewIfNeeded({ timeout }); + } catch (err) { + throw toAIFriendlyError(err, label); + } +} + +export async function waitForViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + timeMs?: number; + text?: string; + textGone?: string; + selector?: string; + url?: string; + loadState?: "load" | "domcontentloaded" | "networkidle"; + fn?: string; + timeoutMs?: number; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); + + if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) { + await page.waitForTimeout(resolveBoundedDelayMs(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS)); + } + if (opts.text) { + await page.getByText(opts.text).first().waitFor({ + state: "visible", + timeout, + }); + } + if (opts.textGone) { + await page.getByText(opts.textGone).first().waitFor({ + state: "hidden", + timeout, + }); + } + if (opts.selector) { + const selector = String(opts.selector).trim(); + if (selector) { + await page.locator(selector).first().waitFor({ state: "visible", timeout }); + } + } + if (opts.url) { + const url = String(opts.url).trim(); + if (url) { + await page.waitForURL(url, { timeout }); + } + } + if (opts.loadState) { + await page.waitForLoadState(opts.loadState, { timeout }); + } + if (opts.fn) { + const fn = String(opts.fn).trim(); + if (fn) { + await page.waitForFunction(fn, { timeout }); + } + } +} + +export async function takeScreenshotViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + ref?: string; + element?: string; + fullPage?: boolean; + type?: "png" | "jpeg"; +}): Promise<{ buffer: Buffer }> { + const page = await getPageForTargetId(opts); + ensurePageState(page); + restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page }); + const type = opts.type ?? "png"; + if (opts.ref) { + if (opts.fullPage) { + throw new Error("fullPage is not supported for element screenshots"); + } + const locator = refLocator(page, opts.ref); + const buffer = await locator.screenshot({ type }); + return { buffer }; + } + if (opts.element) { + if (opts.fullPage) { + throw new Error("fullPage is not supported for element screenshots"); + } + const locator = page.locator(opts.element).first(); + const buffer = await locator.screenshot({ type }); + return { buffer }; + } + const buffer = await page.screenshot({ + type, + fullPage: Boolean(opts.fullPage), + }); + return { buffer }; +} + +export async function screenshotWithLabelsViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + refs: Record; + 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 { + const page = await getPageForTargetId(opts); + ensurePageState(page); + restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page }); + if (!opts.paths.length) { + throw new Error("paths are required"); + } + const inputRef = typeof opts.inputRef === "string" ? opts.inputRef.trim() : ""; + const element = typeof opts.element === "string" ? opts.element.trim() : ""; + if (inputRef && element) { + throw new Error("inputRef and element are mutually exclusive"); + } + if (!inputRef && !element) { + throw new Error("inputRef or element is required"); + } + + const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first(); + const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({ + rootDir: DEFAULT_UPLOAD_DIR, + requestedPaths: opts.paths, + scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`, + }); + if (!uploadPathsResult.ok) { + throw new Error(uploadPathsResult.error); + } + const resolvedPaths = uploadPathsResult.paths; + + try { + await locator.setInputFiles(resolvedPaths); + } catch (err) { + throw toAIFriendlyError(err, inputRef || element); + } + try { + const handle = await locator.elementHandle(); + if (handle) { + await handle.evaluate((el) => { + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + }); + } + } catch { + // Best-effort for sites that don't react to setInputFiles alone. + } +} + +const MAX_BATCH_DEPTH = 5; + +async function executeSingleAction( + action: BrowserActRequest, + cdpUrl: string, + targetId?: string, + evaluateEnabled?: boolean, + depth = 0, +): Promise { + if (depth > MAX_BATCH_DEPTH) { + throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`); + } + const effectiveTargetId = action.targetId ?? targetId; + switch (action.kind) { + case "click": + await clickViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + ref: action.ref, + selector: action.selector, + doubleClick: action.doubleClick, + button: action.button as "left" | "right" | "middle" | undefined, + modifiers: action.modifiers as Array< + "Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift" + >, + delayMs: action.delayMs, + timeoutMs: action.timeoutMs, + }); + break; + case "type": + await typeViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + ref: action.ref, + selector: action.selector, + text: action.text, + submit: action.submit, + slowly: action.slowly, + timeoutMs: action.timeoutMs, + }); + break; + case "press": + await pressKeyViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + key: action.key, + delayMs: action.delayMs, + }); + break; + case "hover": + await hoverViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + ref: action.ref, + selector: action.selector, + timeoutMs: action.timeoutMs, + }); + break; + case "scrollIntoView": + await scrollIntoViewViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + ref: action.ref, + selector: action.selector, + timeoutMs: action.timeoutMs, + }); + break; + case "drag": + await dragViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + startRef: action.startRef, + startSelector: action.startSelector, + endRef: action.endRef, + endSelector: action.endSelector, + timeoutMs: action.timeoutMs, + }); + break; + case "select": + await selectOptionViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + ref: action.ref, + selector: action.selector, + values: action.values, + timeoutMs: action.timeoutMs, + }); + break; + case "fill": + await fillFormViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + fields: action.fields, + timeoutMs: action.timeoutMs, + }); + break; + case "resize": + await resizeViewportViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + width: action.width, + height: action.height, + }); + break; + case "wait": + if (action.fn && !evaluateEnabled) { + throw new Error("wait --fn is disabled by config (browser.evaluateEnabled=false)"); + } + await waitForViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + timeMs: action.timeMs, + text: action.text, + textGone: action.textGone, + selector: action.selector, + url: action.url, + loadState: action.loadState, + fn: action.fn, + timeoutMs: action.timeoutMs, + }); + break; + case "evaluate": + if (!evaluateEnabled) { + throw new Error("act:evaluate is disabled by config (browser.evaluateEnabled=false)"); + } + await evaluateViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + fn: action.fn, + ref: action.ref, + timeoutMs: action.timeoutMs, + }); + break; + case "close": + await closePageViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + }); + break; + case "batch": + await batchViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + actions: action.actions, + stopOnError: action.stopOnError, + evaluateEnabled, + depth: depth + 1, + }); + break; + default: + throw new Error(`Unsupported batch action kind: ${(action as { kind: string }).kind}`); + } +} + +export async function batchViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + actions: BrowserActRequest[]; + stopOnError?: boolean; + evaluateEnabled?: boolean; + depth?: number; +}): Promise<{ results: Array<{ ok: boolean; error?: string }> }> { + const depth = opts.depth ?? 0; + if (depth > MAX_BATCH_DEPTH) { + throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`); + } + if (opts.actions.length > MAX_BATCH_ACTIONS) { + throw new Error(`Batch exceeds maximum of ${MAX_BATCH_ACTIONS} actions`); + } + const results: Array<{ ok: boolean; error?: string }> = []; + for (const action of opts.actions) { + try { + await executeSingleAction(action, opts.cdpUrl, opts.targetId, opts.evaluateEnabled, depth); + results.push({ ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + results.push({ ok: false, error: message }); + if (opts.stopOnError !== false) { + break; + } + } + } + return { results }; +} diff --git a/extensions/browser/src/browser/pw-tools-core.responses.ts b/extensions/browser/src/browser/pw-tools-core.responses.ts new file mode 100644 index 00000000000..4b153692a20 --- /dev/null +++ b/extensions/browser/src/browser/pw-tools-core.responses.ts @@ -0,0 +1,108 @@ +import { formatCliCommand } from "../cli/command-format.js"; +import { ensurePageState, getPageForTargetId } from "./pw-session.js"; +import { normalizeTimeoutMs } from "./pw-tools-core.shared.js"; +import { matchBrowserUrlPattern } from "./url-pattern.js"; + +export async function responseBodyViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + url: string; + timeoutMs?: number; + maxChars?: number; +}): Promise<{ + url: string; + status?: number; + headers?: Record; + 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((resolve, reject) => { + let done = false; + let timer: NodeJS.Timeout | undefined; + let handler: ((resp: unknown) => void) | undefined; + + const cleanup = () => { + if (timer) { + clearTimeout(timer); + } + timer = undefined; + if (handler) { + page.off("response", handler as never); + } + }; + + handler = (resp: unknown) => { + if (done) { + return; + } + const r = resp as { url?: () => string }; + const u = r.url?.() || ""; + if (!matchBrowserUrlPattern(pattern, u)) { + return; + } + done = true; + cleanup(); + resolve(resp); + }; + + page.on("response", handler as never); + timer = setTimeout(() => { + if (done) { + return; + } + done = true; + cleanup(); + reject( + new Error( + `Response not found for url pattern "${pattern}". Run '${formatCliCommand("openclaw browser requests")}' to inspect recent network activity.`, + ), + ); + }, timeout); + }); + + const resp = (await promise) as { + url?: () => string; + status?: () => number; + headers?: () => Record; + body?: () => Promise; + text?: () => Promise; + }; + + 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, + }; +} diff --git a/extensions/browser/src/browser/pw-tools-core.shared.ts b/extensions/browser/src/browser/pw-tools-core.shared.ts new file mode 100644 index 00000000000..b6132de92bf --- /dev/null +++ b/extensions/browser/src/browser/pw-tools-core.shared.ts @@ -0,0 +1,85 @@ +import { parseRoleRef } from "./pw-role-snapshot.js"; + +let nextUploadArmId = 0; +let nextDialogArmId = 0; +let nextDownloadArmId = 0; + +export function bumpUploadArmId(): number { + nextUploadArmId += 1; + return nextUploadArmId; +} + +export function bumpDialogArmId(): number { + nextDialogArmId += 1; + return nextDialogArmId; +} + +export function bumpDownloadArmId(): number { + nextDownloadArmId += 1; + return nextDownloadArmId; +} + +export function requireRef(value: unknown): string { + const raw = typeof value === "string" ? value.trim() : ""; + const roleRef = raw ? parseRoleRef(raw) : null; + const ref = roleRef ?? (raw.startsWith("@") ? raw.slice(1) : raw); + if (!ref) { + throw new Error("ref is required"); + } + return ref; +} + +export function requireRefOrSelector( + ref: string | undefined, + selector: string | undefined, +): { ref?: string; selector?: string } { + const trimmedRef = typeof ref === "string" ? ref.trim() : ""; + const trimmedSelector = typeof selector === "string" ? selector.trim() : ""; + if (!trimmedRef && !trimmedSelector) { + throw new Error("ref or selector is required"); + } + return { + ref: trimmedRef || undefined, + selector: trimmedSelector || undefined, + }; +} + +export function normalizeTimeoutMs(timeoutMs: number | undefined, fallback: number) { + return Math.max(500, Math.min(120_000, timeoutMs ?? fallback)); +} + +export function toAIFriendlyError(error: unknown, selector: string): Error { + const message = error instanceof Error ? error.message : String(error); + + if (message.includes("strict mode violation")) { + const countMatch = message.match(/resolved to (\d+) elements/); + const count = countMatch ? countMatch[1] : "multiple"; + return new Error( + `Selector "${selector}" matched ${count} elements. ` + + `Run a new snapshot to get updated refs, or use a different ref.`, + ); + } + + if ( + (message.includes("Timeout") || message.includes("waiting for")) && + (message.includes("to be visible") || message.includes("not visible")) + ) { + return new Error( + `Element "${selector}" not found or not visible. ` + + `Run a new snapshot to see current page elements.`, + ); + } + + if ( + message.includes("intercepts pointer events") || + message.includes("not visible") || + message.includes("not receive pointer events") + ) { + return new Error( + `Element "${selector}" is not interactable (hidden or covered). ` + + `Try scrolling it into view, closing overlays, or re-snapshotting.`, + ); + } + + return error instanceof Error ? error : new Error(message); +} diff --git a/extensions/browser/src/browser/pw-tools-core.snapshot.ts b/extensions/browser/src/browser/pw-tools-core.snapshot.ts new file mode 100644 index 00000000000..09926626db1 --- /dev/null +++ b/extensions/browser/src/browser/pw-tools-core.snapshot.ts @@ -0,0 +1,262 @@ +import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js"; +import { + assertBrowserNavigationAllowed, + assertBrowserNavigationRedirectChainAllowed, + assertBrowserNavigationResultAllowed, + withBrowserNavigationPolicy, +} from "./navigation-guard.js"; +import { + buildRoleSnapshotFromAiSnapshot, + buildRoleSnapshotFromAriaSnapshot, + getRoleSnapshotStats, + type RoleSnapshotOptions, + type RoleRefMap, +} from "./pw-role-snapshot.js"; +import { + ensurePageState, + forceDisconnectPlaywrightForTarget, + getPageForTargetId, + storeRoleRefsForTarget, + type WithSnapshotForAI, +} from "./pw-session.js"; +import { withPageScopedCdpClient } from "./pw-session.page-cdp.js"; + +export async function snapshotAriaViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + limit?: number; +}): Promise<{ nodes: AriaSnapshotNode[] }> { + const limit = Math.max(1, Math.min(2000, Math.floor(opts.limit ?? 500))); + const page = await getPageForTargetId({ + cdpUrl: opts.cdpUrl, + targetId: opts.targetId, + }); + ensurePageState(page); + const res = (await withPageScopedCdpClient({ + cdpUrl: opts.cdpUrl, + page, + targetId: opts.targetId, + fn: async (send) => { + await send("Accessibility.enable").catch(() => {}); + return (await send("Accessibility.getFullAXTree")) as { + nodes?: RawAXNode[]; + }; + }, + })) as { + nodes?: RawAXNode[]; + }; + const nodes = Array.isArray(res?.nodes) ? res.nodes : []; + return { nodes: formatAriaSnapshot(nodes, limit) }; +} + +export async function snapshotAiViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + timeoutMs?: number; + maxChars?: number; +}): Promise<{ snapshot: string; truncated?: boolean; refs: RoleRefMap }> { + const page = await getPageForTargetId({ + cdpUrl: opts.cdpUrl, + targetId: opts.targetId, + }); + ensurePageState(page); + + const maybe = page as unknown as WithSnapshotForAI; + if (!maybe._snapshotForAI) { + throw new Error("Playwright _snapshotForAI is not available. Upgrade playwright-core."); + } + + const result = await maybe._snapshotForAI({ + timeout: Math.max(500, Math.min(60_000, Math.floor(opts.timeoutMs ?? 5000))), + track: "response", + }); + let snapshot = String(result?.full ?? ""); + const maxChars = opts.maxChars; + const limit = + typeof maxChars === "number" && Number.isFinite(maxChars) && maxChars > 0 + ? Math.floor(maxChars) + : undefined; + let truncated = false; + if (limit && snapshot.length > limit) { + snapshot = `${snapshot.slice(0, limit)}\n\n[...TRUNCATED - page too large]`; + truncated = true; + } + + const built = buildRoleSnapshotFromAiSnapshot(snapshot); + storeRoleRefsForTarget({ + page, + cdpUrl: opts.cdpUrl, + targetId: opts.targetId, + refs: built.refs, + mode: "aria", + }); + return truncated ? { snapshot, truncated, refs: built.refs } : { snapshot, refs: built.refs }; +} + +export async function snapshotRoleViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + selector?: string; + frameSelector?: string; + refsMode?: "role" | "aria"; + options?: RoleSnapshotOptions; +}): Promise<{ + snapshot: string; + refs: Record; + stats: { lines: number; chars: number; refs: number; interactive: number }; +}> { + const page = await getPageForTargetId({ + cdpUrl: opts.cdpUrl, + targetId: opts.targetId, + }); + ensurePageState(page); + + if (opts.refsMode === "aria") { + if (opts.selector?.trim() || opts.frameSelector?.trim()) { + throw new Error("refs=aria does not support selector/frame snapshots yet."); + } + const maybe = page as unknown as WithSnapshotForAI; + if (!maybe._snapshotForAI) { + throw new Error("refs=aria requires Playwright _snapshotForAI support."); + } + const result = await maybe._snapshotForAI({ + timeout: 5000, + track: "response", + }); + const built = buildRoleSnapshotFromAiSnapshot(String(result?.full ?? ""), opts.options); + storeRoleRefsForTarget({ + page, + cdpUrl: opts.cdpUrl, + targetId: opts.targetId, + refs: built.refs, + mode: "aria", + }); + return { + snapshot: built.snapshot, + refs: built.refs, + stats: getRoleSnapshotStats(built.snapshot, built.refs), + }; + } + + const frameSelector = opts.frameSelector?.trim() || ""; + const selector = opts.selector?.trim() || ""; + const locator = frameSelector + ? selector + ? page.frameLocator(frameSelector).locator(selector) + : page.frameLocator(frameSelector).locator(":root") + : selector + ? page.locator(selector) + : page.locator(":root"); + + const ariaSnapshot = await locator.ariaSnapshot(); + const built = buildRoleSnapshotFromAriaSnapshot(String(ariaSnapshot ?? ""), opts.options); + storeRoleRefsForTarget({ + page, + cdpUrl: opts.cdpUrl, + targetId: opts.targetId, + refs: built.refs, + frameSelector: frameSelector || undefined, + mode: "role", + }); + return { + snapshot: built.snapshot, + refs: built.refs, + stats: getRoleSnapshotStats(built.snapshot, built.refs), + }; +} + +export async function navigateViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + url: string; + timeoutMs?: number; + ssrfPolicy?: SsrFPolicy; +}): Promise<{ url: string }> { + const isRetryableNavigateError = (err: unknown): boolean => { + const msg = + typeof err === "string" + ? err.toLowerCase() + : err instanceof Error + ? err.message.toLowerCase() + : ""; + return ( + msg.includes("frame has been detached") || + msg.includes("target page, context or browser has been closed") + ); + }; + + const url = String(opts.url ?? "").trim(); + if (!url) { + throw new Error("url is required"); + } + await assertBrowserNavigationAllowed({ + url, + ...withBrowserNavigationPolicy(opts.ssrfPolicy), + }); + const timeout = Math.max(1000, Math.min(120_000, opts.timeoutMs ?? 20_000)); + let page = await getPageForTargetId(opts); + ensurePageState(page); + const navigate = async () => await page.goto(url, { timeout }); + let response; + try { + response = await navigate(); + } catch (err) { + if (!isRetryableNavigateError(err)) { + throw err; + } + // Extension relays can briefly drop CDP during renderer swaps/navigation. + // Force a clean reconnect, then retry once on the refreshed page handle. + await forceDisconnectPlaywrightForTarget({ + cdpUrl: opts.cdpUrl, + targetId: opts.targetId, + reason: "retry navigate after detached frame", + }).catch(() => {}); + page = await getPageForTargetId(opts); + ensurePageState(page); + response = await navigate(); + } + await assertBrowserNavigationRedirectChainAllowed({ + request: response?.request(), + ...withBrowserNavigationPolicy(opts.ssrfPolicy), + }); + const finalUrl = page.url(); + await assertBrowserNavigationResultAllowed({ + url: finalUrl, + ...withBrowserNavigationPolicy(opts.ssrfPolicy), + }); + return { url: finalUrl }; +} + +export async function resizeViewportViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + width: number; + height: number; +}): Promise { + 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 { + 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 }; +} diff --git a/extensions/browser/src/browser/pw-tools-core.state.ts b/extensions/browser/src/browser/pw-tools-core.state.ts new file mode 100644 index 00000000000..580fadba108 --- /dev/null +++ b/extensions/browser/src/browser/pw-tools-core.state.ts @@ -0,0 +1,215 @@ +import { devices as playwrightDevices } from "playwright-core"; +import { ensurePageState, getPageForTargetId } from "./pw-session.js"; +import { withPageScopedCdpClient } from "./pw-session.page-cdp.js"; + +export async function setOfflineViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + offline: boolean; +}): Promise { + 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; +}): Promise { + 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 { + 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 { + 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 { + 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 { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const locale = String(opts.locale ?? "").trim(); + if (!locale) { + throw new Error("locale is required"); + } + await withPageScopedCdpClient({ + cdpUrl: opts.cdpUrl, + page, + targetId: opts.targetId, + fn: async (send) => { + try { + await send("Emulation.setLocaleOverride", { locale }); + } catch (err) { + if (String(err).includes("Another locale override is already in effect")) { + return; + } + throw err; + } + }, + }); +} + +export async function setTimezoneViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + timezoneId: string; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const timezoneId = String(opts.timezoneId ?? "").trim(); + if (!timezoneId) { + throw new Error("timezoneId is required"); + } + await withPageScopedCdpClient({ + cdpUrl: opts.cdpUrl, + page, + targetId: opts.targetId, + fn: async (send) => { + try { + await send("Emulation.setTimezoneOverride", { timezoneId }); + } catch (err) { + const msg = String(err); + if (msg.includes("Timezone override is already in effect")) { + return; + } + if (msg.includes("Invalid timezone")) { + throw new Error(`Invalid timezone ID: ${timezoneId}`, { cause: err }); + } + throw err; + } + }, + }); +} + +export async function setDeviceViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + name: string; +}): Promise { + 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)[name] as + | { + userAgent?: string; + viewport?: { width: number; height: number }; + deviceScaleFactor?: number; + isMobile?: boolean; + hasTouch?: boolean; + locale?: string; + } + | undefined; + if (!descriptor) { + throw new Error(`Unknown device "${name}".`); + } + + if (descriptor.viewport) { + await page.setViewportSize({ + width: descriptor.viewport.width, + height: descriptor.viewport.height, + }); + } + + await withPageScopedCdpClient({ + cdpUrl: opts.cdpUrl, + page, + targetId: opts.targetId, + fn: async (send) => { + if (descriptor.userAgent || descriptor.locale) { + await send("Emulation.setUserAgentOverride", { + userAgent: descriptor.userAgent ?? "", + acceptLanguage: descriptor.locale ?? undefined, + }); + } + if (descriptor.viewport) { + await send("Emulation.setDeviceMetricsOverride", { + mobile: Boolean(descriptor.isMobile), + width: descriptor.viewport.width, + height: descriptor.viewport.height, + deviceScaleFactor: descriptor.deviceScaleFactor ?? 1, + screenWidth: descriptor.viewport.width, + screenHeight: descriptor.viewport.height, + }); + } + if (descriptor.hasTouch) { + await send("Emulation.setTouchEmulationEnabled", { + enabled: true, + }); + } + }, + }); +} diff --git a/extensions/browser/src/browser/pw-tools-core.storage.ts b/extensions/browser/src/browser/pw-tools-core.storage.ts new file mode 100644 index 00000000000..8126d66fa71 --- /dev/null +++ b/extensions/browser/src/browser/pw-tools-core.storage.ts @@ -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 { + 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 { + 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 }> { + 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 = {}; + 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 { + 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 { + const page = await getPageForTargetId(opts); + ensurePageState(page); + await page.evaluate( + ({ kind }) => { + const store = kind === "session" ? window.sessionStorage : window.localStorage; + store.clear(); + }, + { kind: opts.kind }, + ); +} diff --git a/extensions/browser/src/browser/pw-tools-core.test-harness.ts b/extensions/browser/src/browser/pw-tools-core.test-harness.ts new file mode 100644 index 00000000000..6111fa89aef --- /dev/null +++ b/extensions/browser/src/browser/pw-tools-core.test-harness.ts @@ -0,0 +1,66 @@ +import { beforeEach, vi } from "vitest"; + +let currentPage: Record | null = null; +let currentRefLocator: Record | null = null; +let pageState: { + console: unknown[]; + armIdUpload: number; + armIdDialog: number; + armIdDownload: number; +} = { + console: [], + armIdUpload: 0, + armIdDialog: 0, + armIdDownload: 0, +}; + +const sessionMocks = vi.hoisted(() => ({ + getPageForTargetId: vi.fn(async () => { + if (!currentPage) { + throw new Error("missing page"); + } + return currentPage; + }), + ensurePageState: vi.fn(() => pageState), + forceDisconnectPlaywrightForTarget: vi.fn(async () => {}), + restoreRoleRefsForTarget: vi.fn(() => {}), + storeRoleRefsForTarget: vi.fn(() => {}), + refLocator: vi.fn(() => { + if (!currentRefLocator) { + throw new Error("missing locator"); + } + return currentRefLocator; + }), + rememberRoleRefsForTarget: vi.fn(() => {}), +})); + +vi.mock("./pw-session.js", () => sessionMocks); + +export function getPwToolsCoreSessionMocks() { + return sessionMocks; +} + +export function setPwToolsCoreCurrentPage(page: Record | null) { + currentPage = page; +} + +export function setPwToolsCoreCurrentRefLocator(locator: Record | 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(); + } + }); +} diff --git a/extensions/browser/src/browser/pw-tools-core.trace.ts b/extensions/browser/src/browser/pw-tools-core.trace.ts new file mode 100644 index 00000000000..ce49eb77e07 --- /dev/null +++ b/extensions/browser/src/browser/pw-tools-core.trace.ts @@ -0,0 +1,45 @@ +import { writeViaSiblingTempPath } from "./output-atomic.js"; +import { DEFAULT_TRACE_DIR } from "./paths.js"; +import { ensureContextState, getPageForTargetId } from "./pw-session.js"; + +export async function traceStartViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + screenshots?: boolean; + snapshots?: boolean; + sources?: boolean; +}): Promise { + 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 { + const page = await getPageForTargetId(opts); + const context = page.context(); + const ctxState = ensureContextState(context); + if (!ctxState.traceActive) { + throw new Error("No active trace. Start a trace before stopping it."); + } + await writeViaSiblingTempPath({ + rootDir: DEFAULT_TRACE_DIR, + targetPath: opts.path, + writeTemp: async (tempPath) => { + await context.tracing.stop({ path: tempPath }); + }, + }); + ctxState.traceActive = false; +} diff --git a/extensions/browser/src/browser/pw-tools-core.ts b/extensions/browser/src/browser/pw-tools-core.ts new file mode 100644 index 00000000000..55a596280c6 --- /dev/null +++ b/extensions/browser/src/browser/pw-tools-core.ts @@ -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"; diff --git a/extensions/browser/src/browser/request-policy.ts b/extensions/browser/src/browser/request-policy.ts new file mode 100644 index 00000000000..6288c6b11a6 --- /dev/null +++ b/extensions/browser/src/browser/request-policy.ts @@ -0,0 +1,49 @@ +type BrowserRequestProfileParams = { + query?: Record; + body?: unknown; + profile?: string | null; +}; + +export function normalizeBrowserRequestPath(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + if (withLeadingSlash.length <= 1) { + return withLeadingSlash; + } + return withLeadingSlash.replace(/\/+$/, ""); +} + +export function isPersistentBrowserProfileMutation(method: string, path: string): boolean { + const normalizedPath = normalizeBrowserRequestPath(path); + if ( + method === "POST" && + (normalizedPath === "/profiles/create" || normalizedPath === "/reset-profile") + ) { + return true; + } + return method === "DELETE" && /^\/profiles\/[^/]+$/.test(normalizedPath); +} + +export function resolveRequestedBrowserProfile( + params: BrowserRequestProfileParams, +): string | undefined { + const queryProfile = + typeof params.query?.profile === "string" ? params.query.profile.trim() : undefined; + if (queryProfile) { + return queryProfile; + } + if (params.body && typeof params.body === "object") { + const bodyProfile = + "profile" in params.body && typeof params.body.profile === "string" + ? params.body.profile.trim() + : undefined; + if (bodyProfile) { + return bodyProfile; + } + } + const explicitProfile = typeof params.profile === "string" ? params.profile.trim() : undefined; + return explicitProfile || undefined; +} diff --git a/extensions/browser/src/browser/resolved-config-refresh.ts b/extensions/browser/src/browser/resolved-config-refresh.ts new file mode 100644 index 00000000000..1d20eecec94 --- /dev/null +++ b/extensions/browser/src/browser/resolved-config-refresh.ts @@ -0,0 +1,107 @@ +import { createConfigIO, getRuntimeConfigSnapshot } from "../config/config.js"; +import { resolveBrowserConfig, resolveProfile, type ResolvedBrowserProfile } from "./config.js"; +import type { BrowserServerState } from "./server-context.types.js"; + +function changedProfileInvariants( + current: ResolvedBrowserProfile, + next: ResolvedBrowserProfile, +): string[] { + const changed: string[] = []; + if (current.cdpUrl !== next.cdpUrl) { + changed.push("cdpUrl"); + } + if (current.cdpPort !== next.cdpPort) { + changed.push("cdpPort"); + } + if (current.driver !== next.driver) { + changed.push("driver"); + } + if (current.attachOnly !== next.attachOnly) { + changed.push("attachOnly"); + } + if (current.cdpIsLoopback !== next.cdpIsLoopback) { + changed.push("cdpIsLoopback"); + } + if ((current.userDataDir ?? "") !== (next.userDataDir ?? "")) { + changed.push("userDataDir"); + } + return changed; +} + +function applyResolvedConfig( + current: BrowserServerState, + freshResolved: BrowserServerState["resolved"], +) { + current.resolved = { + ...freshResolved, + // Keep the runtime evaluate gate stable across request-time profile refreshes. + // Security-sensitive behavior should only change via full runtime config reload, + // not as a side effect of resolving profiles/tabs during a request. + evaluateEnabled: current.resolved.evaluateEnabled, + }; + for (const [name, runtime] of current.profiles) { + const nextProfile = resolveProfile(freshResolved, name); + if (nextProfile) { + const changed = changedProfileInvariants(runtime.profile, nextProfile); + if (changed.length > 0) { + runtime.reconcile = { + previousProfile: runtime.profile, + reason: `profile invariants changed: ${changed.join(", ")}`, + }; + runtime.lastTargetId = null; + } + runtime.profile = nextProfile; + continue; + } + runtime.reconcile = { + previousProfile: runtime.profile, + reason: "profile removed from config", + }; + runtime.lastTargetId = null; + if (!runtime.running) { + current.profiles.delete(name); + } + } +} + +export function refreshResolvedBrowserConfigFromDisk(params: { + current: BrowserServerState; + refreshConfigFromDisk: boolean; + mode: "cached" | "fresh"; +}) { + if (!params.refreshConfigFromDisk) { + return; + } + + // Route-level browser config hot reload should observe on-disk changes immediately. + // The shared loadConfig() helper may return a cached snapshot for the configured TTL, + // which can leave request-time browser guards stale (for example evaluateEnabled). + const cfg = getRuntimeConfigSnapshot() ?? createConfigIO().loadConfig(); + const freshResolved = resolveBrowserConfig(cfg.browser, cfg); + applyResolvedConfig(params.current, freshResolved); +} + +export function resolveBrowserProfileWithHotReload(params: { + current: BrowserServerState; + refreshConfigFromDisk: boolean; + name: string; +}): ResolvedBrowserProfile | null { + refreshResolvedBrowserConfigFromDisk({ + current: params.current, + refreshConfigFromDisk: params.refreshConfigFromDisk, + mode: "cached", + }); + let profile = resolveProfile(params.current.resolved, params.name); + if (profile) { + return profile; + } + + // Hot-reload: profile missing; retry with a fresh disk read without flushing the global cache. + refreshResolvedBrowserConfigFromDisk({ + current: params.current, + refreshConfigFromDisk: params.refreshConfigFromDisk, + mode: "fresh", + }); + profile = resolveProfile(params.current.resolved, params.name); + return profile; +} diff --git a/extensions/browser/src/browser/routes/agent.act.download.ts b/extensions/browser/src/browser/routes/agent.act.download.ts new file mode 100644 index 00000000000..cfdf1362797 --- /dev/null +++ b/extensions/browser/src/browser/routes/agent.act.download.ts @@ -0,0 +1,123 @@ +import { getBrowserProfileCapabilities } from "../profile-capabilities.js"; +import type { BrowserRouteContext } from "../server-context.js"; +import { + readBody, + requirePwAi, + resolveTargetIdFromBody, + withRouteTabContext, +} from "./agent.shared.js"; +import { ensureOutputRootDir, resolveWritableOutputPathOrRespond } from "./output-paths.js"; +import { DEFAULT_DOWNLOAD_DIR } from "./path-output.js"; +import type { BrowserRouteRegistrar } from "./types.js"; +import { jsonError, toNumber, toStringOrEmpty } from "./utils.js"; + +function buildDownloadRequestBase(cdpUrl: string, targetId: string, timeoutMs: number | undefined) { + return { + cdpUrl, + targetId, + timeoutMs: timeoutMs ?? undefined, + }; +} + +export function registerBrowserAgentActDownloadRoutes( + app: BrowserRouteRegistrar, + ctx: BrowserRouteContext, +) { + app.post("/wait/download", async (req, res) => { + const body = readBody(req); + const targetId = resolveTargetIdFromBody(body); + const out = toStringOrEmpty(body.path) || ""; + const timeoutMs = toNumber(body.timeoutMs); + + await withRouteTabContext({ + req, + res, + ctx, + targetId, + run: async ({ profileCtx, cdpUrl, tab }) => { + if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { + return jsonError( + res, + 501, + "download waiting is not supported for existing-session profiles yet.", + ); + } + const pw = await requirePwAi(res, "wait for download"); + if (!pw) { + return; + } + await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR); + let downloadPath: string | undefined; + if (out.trim()) { + const resolvedDownloadPath = await resolveWritableOutputPathOrRespond({ + res, + rootDir: DEFAULT_DOWNLOAD_DIR, + requestedPath: out, + scopeLabel: "downloads directory", + }); + if (!resolvedDownloadPath) { + return; + } + downloadPath = resolvedDownloadPath; + } + const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs); + const result = await pw.waitForDownloadViaPlaywright({ + ...requestBase, + path: downloadPath, + }); + res.json({ ok: true, targetId: tab.targetId, download: result }); + }, + }); + }); + + app.post("/download", async (req, res) => { + const body = readBody(req); + const targetId = resolveTargetIdFromBody(body); + const ref = toStringOrEmpty(body.ref); + const out = toStringOrEmpty(body.path); + const timeoutMs = toNumber(body.timeoutMs); + if (!ref) { + return jsonError(res, 400, "ref is required"); + } + if (!out) { + return jsonError(res, 400, "path is required"); + } + + await withRouteTabContext({ + req, + res, + ctx, + targetId, + run: async ({ profileCtx, cdpUrl, tab }) => { + if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { + return jsonError( + res, + 501, + "downloads are not supported for existing-session profiles yet.", + ); + } + const pw = await requirePwAi(res, "download"); + if (!pw) { + return; + } + await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR); + const downloadPath = await resolveWritableOutputPathOrRespond({ + res, + rootDir: DEFAULT_DOWNLOAD_DIR, + requestedPath: out, + scopeLabel: "downloads directory", + }); + if (!downloadPath) { + return; + } + const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs); + const result = await pw.downloadViaPlaywright({ + ...requestBase, + ref, + path: downloadPath, + }); + res.json({ ok: true, targetId: tab.targetId, download: result }); + }, + }); + }); +} diff --git a/extensions/browser/src/browser/routes/agent.act.hooks.ts b/extensions/browser/src/browser/routes/agent.act.hooks.ts new file mode 100644 index 00000000000..a55e2f9b21e --- /dev/null +++ b/extensions/browser/src/browser/routes/agent.act.hooks.ts @@ -0,0 +1,197 @@ +import { evaluateChromeMcpScript, uploadChromeMcpFile } from "../chrome-mcp.js"; +import { getBrowserProfileCapabilities } from "../profile-capabilities.js"; +import type { BrowserRouteContext } from "../server-context.js"; +import { + readBody, + requirePwAi, + resolveTargetIdFromBody, + withRouteTabContext, +} from "./agent.shared.js"; +import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "./path-output.js"; +import type { BrowserRouteRegistrar } from "./types.js"; +import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js"; + +export function registerBrowserAgentActHookRoutes( + app: BrowserRouteRegistrar, + ctx: BrowserRouteContext, +) { + app.post("/hooks/file-chooser", async (req, res) => { + const body = readBody(req); + const targetId = resolveTargetIdFromBody(body); + const ref = toStringOrEmpty(body.ref) || undefined; + const inputRef = toStringOrEmpty(body.inputRef) || undefined; + const element = toStringOrEmpty(body.element) || undefined; + const paths = toStringArray(body.paths) ?? []; + const timeoutMs = toNumber(body.timeoutMs); + if (!paths.length) { + return jsonError(res, 400, "paths are required"); + } + + await withRouteTabContext({ + req, + res, + ctx, + targetId, + run: async ({ profileCtx, cdpUrl, tab }) => { + const uploadPathsResult = await resolveExistingPathsWithinRoot({ + rootDir: DEFAULT_UPLOAD_DIR, + requestedPaths: paths, + scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`, + }); + if (!uploadPathsResult.ok) { + res.status(400).json({ error: uploadPathsResult.error }); + return; + } + const resolvedPaths = uploadPathsResult.paths; + + if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { + if (element) { + return jsonError( + res, + 501, + "existing-session file uploads do not support element selectors; use ref/inputRef.", + ); + } + if (resolvedPaths.length !== 1) { + return jsonError( + res, + 501, + "existing-session file uploads currently support one file at a time.", + ); + } + const uid = inputRef || ref; + if (!uid) { + return jsonError(res, 501, "existing-session file uploads require ref or inputRef."); + } + await uploadChromeMcpFile({ + profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + uid, + filePath: resolvedPaths[0] ?? "", + }); + return res.json({ ok: true }); + } + + const pw = await requirePwAi(res, "file chooser hook"); + if (!pw) { + return; + } + + if (inputRef || element) { + if (ref) { + return jsonError(res, 400, "ref cannot be combined with inputRef/element"); + } + await pw.setInputFilesViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + inputRef, + element, + paths: resolvedPaths, + }); + } else { + await pw.armFileUploadViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + paths: resolvedPaths, + timeoutMs: timeoutMs ?? undefined, + }); + if (ref) { + await pw.clickViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + ref, + }); + } + } + res.json({ ok: true }); + }, + }); + }); + + app.post("/hooks/dialog", async (req, res) => { + const body = readBody(req); + const targetId = resolveTargetIdFromBody(body); + const accept = toBoolean(body.accept); + const promptText = toStringOrEmpty(body.promptText) || undefined; + const timeoutMs = toNumber(body.timeoutMs); + if (accept === undefined) { + return jsonError(res, 400, "accept is required"); + } + + await withRouteTabContext({ + req, + res, + ctx, + targetId, + run: async ({ profileCtx, cdpUrl, tab }) => { + if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { + if (timeoutMs) { + return jsonError( + res, + 501, + "existing-session dialog handling does not support timeoutMs.", + ); + } + await evaluateChromeMcpScript({ + profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + fn: `() => { + const state = (window.__openclawDialogHook ??= {}); + if (!state.originals) { + state.originals = { + alert: window.alert.bind(window), + confirm: window.confirm.bind(window), + prompt: window.prompt.bind(window), + }; + } + const originals = state.originals; + const restore = () => { + window.alert = originals.alert; + window.confirm = originals.confirm; + window.prompt = originals.prompt; + delete window.__openclawDialogHook; + }; + window.alert = (...args) => { + try { + return undefined; + } finally { + restore(); + } + }; + window.confirm = (...args) => { + try { + return ${accept ? "true" : "false"}; + } finally { + restore(); + } + }; + window.prompt = (...args) => { + try { + return ${accept ? JSON.stringify(promptText ?? "") : "null"}; + } finally { + restore(); + } + }; + return true; + }`, + }); + return res.json({ ok: true }); + } + const pw = await requirePwAi(res, "dialog hook"); + if (!pw) { + return; + } + await pw.armDialogViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + accept, + promptText, + timeoutMs: timeoutMs ?? undefined, + }); + res.json({ ok: true }); + }, + }); + }); +} diff --git a/extensions/browser/src/browser/routes/agent.act.shared.ts b/extensions/browser/src/browser/routes/agent.act.shared.ts new file mode 100644 index 00000000000..b22f35e7ef2 --- /dev/null +++ b/extensions/browser/src/browser/routes/agent.act.shared.ts @@ -0,0 +1,53 @@ +export const ACT_KINDS = [ + "batch", + "click", + "close", + "drag", + "evaluate", + "fill", + "hover", + "scrollIntoView", + "press", + "resize", + "select", + "type", + "wait", +] as const; + +export type ActKind = (typeof ACT_KINDS)[number]; + +export function isActKind(value: unknown): value is ActKind { + if (typeof value !== "string") { + return false; + } + return (ACT_KINDS as readonly string[]).includes(value); +} + +export type ClickButton = "left" | "right" | "middle"; +export type ClickModifier = "Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift"; + +const ALLOWED_CLICK_MODIFIERS = new Set([ + "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 }; +} diff --git a/extensions/browser/src/browser/routes/agent.act.ts b/extensions/browser/src/browser/routes/agent.act.ts new file mode 100644 index 00000000000..af0d8e40794 --- /dev/null +++ b/extensions/browser/src/browser/routes/agent.act.ts @@ -0,0 +1,1211 @@ +import { + clickChromeMcpElement, + closeChromeMcpTab, + dragChromeMcpElement, + evaluateChromeMcpScript, + fillChromeMcpElement, + fillChromeMcpForm, + hoverChromeMcpElement, + pressChromeMcpKey, + resizeChromeMcpPage, +} from "../chrome-mcp.js"; +import type { BrowserActRequest, BrowserFormField } from "../client-actions-core.js"; +import { normalizeBrowserFormField } from "../form-fields.js"; +import { getBrowserProfileCapabilities } from "../profile-capabilities.js"; +import type { BrowserRouteContext } from "../server-context.js"; +import { matchBrowserUrlPattern } from "../url-pattern.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, + requirePwAi, + resolveTargetIdFromBody, + withRouteTabContext, + SELECTOR_UNSUPPORTED_MESSAGE, +} from "./agent.shared.js"; +import type { BrowserRouteRegistrar } from "./types.js"; +import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function browserEvaluateDisabledMessage(action: "wait" | "evaluate"): string { + return [ + action === "wait" + ? "wait --fn is disabled by config (browser.evaluateEnabled=false)." + : "act:evaluate is disabled by config (browser.evaluateEnabled=false).", + "Docs: /gateway/configuration#browser-openclaw-managed-browser", + ].join("\n"); +} + +function buildExistingSessionWaitPredicate(params: { + text?: string; + textGone?: string; + selector?: string; + loadState?: "load" | "domcontentloaded" | "networkidle"; + fn?: string; +}): string | null { + const checks: string[] = []; + if (params.text) { + checks.push(`Boolean(document.body?.innerText?.includes(${JSON.stringify(params.text)}))`); + } + if (params.textGone) { + checks.push(`!document.body?.innerText?.includes(${JSON.stringify(params.textGone)})`); + } + if (params.selector) { + checks.push(`Boolean(document.querySelector(${JSON.stringify(params.selector)}))`); + } + if (params.loadState === "domcontentloaded") { + checks.push(`document.readyState === "interactive" || document.readyState === "complete"`); + } else if (params.loadState === "load") { + checks.push(`document.readyState === "complete"`); + } + if (params.fn) { + checks.push(`Boolean(await (${params.fn})())`); + } + if (checks.length === 0) { + return null; + } + return checks.length === 1 ? checks[0] : checks.map((check) => `(${check})`).join(" && "); +} + +async function waitForExistingSessionCondition(params: { + profileName: string; + userDataDir?: string; + targetId: string; + timeMs?: number; + text?: string; + textGone?: string; + selector?: string; + url?: string; + loadState?: "load" | "domcontentloaded" | "networkidle"; + fn?: string; + timeoutMs?: number; +}): Promise { + if (params.timeMs && params.timeMs > 0) { + await sleep(params.timeMs); + } + const predicate = buildExistingSessionWaitPredicate(params); + if (!predicate && !params.url) { + return; + } + const timeoutMs = Math.max(250, params.timeoutMs ?? 10_000); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + let ready = true; + if (predicate) { + ready = Boolean( + await evaluateChromeMcpScript({ + profileName: params.profileName, + userDataDir: params.userDataDir, + targetId: params.targetId, + fn: `async () => ${predicate}`, + }), + ); + } + if (ready && params.url) { + const currentUrl = await evaluateChromeMcpScript({ + profileName: params.profileName, + userDataDir: params.userDataDir, + targetId: params.targetId, + fn: "() => window.location.href", + }); + ready = typeof currentUrl === "string" && matchBrowserUrlPattern(params.url, currentUrl); + } + if (ready) { + return; + } + await sleep(250); + } + throw new Error("Timed out waiting for condition"); +} + +const SELECTOR_ALLOWED_KINDS: ReadonlySet = new Set([ + "batch", + "click", + "drag", + "hover", + "scrollIntoView", + "select", + "type", + "wait", +]); +const MAX_BATCH_ACTIONS = 100; +const MAX_BATCH_CLICK_DELAY_MS = 5_000; +const MAX_BATCH_WAIT_TIME_MS = 30_000; + +function normalizeBoundedNonNegativeMs( + value: unknown, + fieldName: string, + maxMs: number, +): number | undefined { + const ms = toNumber(value); + if (ms === undefined) { + return undefined; + } + if (ms < 0) { + throw new Error(`${fieldName} must be >= 0`); + } + const normalized = Math.floor(ms); + if (normalized > maxMs) { + throw new Error(`${fieldName} exceeds maximum of ${maxMs}ms`); + } + return normalized; +} + +function countBatchActions(actions: BrowserActRequest[]): number { + let count = 0; + for (const action of actions) { + count += 1; + if (action.kind === "batch") { + count += countBatchActions(action.actions); + } + } + return count; +} + +function validateBatchTargetIds(actions: BrowserActRequest[], targetId: string): string | null { + for (const action of actions) { + if (action.targetId && action.targetId !== targetId) { + return "batched action targetId must match request targetId"; + } + if (action.kind === "batch") { + const nestedError = validateBatchTargetIds(action.actions, targetId); + if (nestedError) { + return nestedError; + } + } + } + return null; +} + +function normalizeBatchAction(value: unknown): BrowserActRequest { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("batch actions must be objects"); + } + const raw = value as Record; + const kind = toStringOrEmpty(raw.kind); + if (!isActKind(kind)) { + throw new Error("batch actions must use a supported kind"); + } + + switch (kind) { + case "click": { + const ref = toStringOrEmpty(raw.ref) || undefined; + const selector = toStringOrEmpty(raw.selector) || undefined; + if (!ref && !selector) { + throw new Error("click requires ref or selector"); + } + const buttonRaw = toStringOrEmpty(raw.button); + const button = buttonRaw ? parseClickButton(buttonRaw) : undefined; + if (buttonRaw && !button) { + throw new Error("click button must be left|right|middle"); + } + const modifiersRaw = toStringArray(raw.modifiers) ?? []; + const parsedModifiers = parseClickModifiers(modifiersRaw); + if (parsedModifiers.error) { + throw new Error(parsedModifiers.error); + } + const doubleClick = toBoolean(raw.doubleClick); + const delayMs = normalizeBoundedNonNegativeMs( + raw.delayMs, + "click delayMs", + MAX_BATCH_CLICK_DELAY_MS, + ); + const timeoutMs = toNumber(raw.timeoutMs); + const targetId = toStringOrEmpty(raw.targetId) || undefined; + return { + kind, + ...(ref ? { ref } : {}), + ...(selector ? { selector } : {}), + ...(targetId ? { targetId } : {}), + ...(doubleClick !== undefined ? { doubleClick } : {}), + ...(button ? { button } : {}), + ...(parsedModifiers.modifiers ? { modifiers: parsedModifiers.modifiers } : {}), + ...(delayMs !== undefined ? { delayMs } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } + case "type": { + const ref = toStringOrEmpty(raw.ref) || undefined; + const selector = toStringOrEmpty(raw.selector) || undefined; + const text = raw.text; + if (!ref && !selector) { + throw new Error("type requires ref or selector"); + } + if (typeof text !== "string") { + throw new Error("type requires text"); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const submit = toBoolean(raw.submit); + const slowly = toBoolean(raw.slowly); + const timeoutMs = toNumber(raw.timeoutMs); + return { + kind, + ...(ref ? { ref } : {}), + ...(selector ? { selector } : {}), + text, + ...(targetId ? { targetId } : {}), + ...(submit !== undefined ? { submit } : {}), + ...(slowly !== undefined ? { slowly } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } + case "press": { + const key = toStringOrEmpty(raw.key); + if (!key) { + throw new Error("press requires key"); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const delayMs = toNumber(raw.delayMs); + return { + kind, + key, + ...(targetId ? { targetId } : {}), + ...(delayMs !== undefined ? { delayMs } : {}), + }; + } + case "hover": + case "scrollIntoView": { + const ref = toStringOrEmpty(raw.ref) || undefined; + const selector = toStringOrEmpty(raw.selector) || undefined; + if (!ref && !selector) { + throw new Error(`${kind} requires ref or selector`); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const timeoutMs = toNumber(raw.timeoutMs); + return { + kind, + ...(ref ? { ref } : {}), + ...(selector ? { selector } : {}), + ...(targetId ? { targetId } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } + case "drag": { + const startRef = toStringOrEmpty(raw.startRef) || undefined; + const startSelector = toStringOrEmpty(raw.startSelector) || undefined; + const endRef = toStringOrEmpty(raw.endRef) || undefined; + const endSelector = toStringOrEmpty(raw.endSelector) || undefined; + if (!startRef && !startSelector) { + throw new Error("drag requires startRef or startSelector"); + } + if (!endRef && !endSelector) { + throw new Error("drag requires endRef or endSelector"); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const timeoutMs = toNumber(raw.timeoutMs); + return { + kind, + ...(startRef ? { startRef } : {}), + ...(startSelector ? { startSelector } : {}), + ...(endRef ? { endRef } : {}), + ...(endSelector ? { endSelector } : {}), + ...(targetId ? { targetId } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } + case "select": { + const ref = toStringOrEmpty(raw.ref) || undefined; + const selector = toStringOrEmpty(raw.selector) || undefined; + const values = toStringArray(raw.values); + if ((!ref && !selector) || !values?.length) { + throw new Error("select requires ref/selector and values"); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const timeoutMs = toNumber(raw.timeoutMs); + return { + kind, + ...(ref ? { ref } : {}), + ...(selector ? { selector } : {}), + values, + ...(targetId ? { targetId } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } + case "fill": { + const rawFields = Array.isArray(raw.fields) ? raw.fields : []; + const fields = rawFields + .map((field) => { + if (!field || typeof field !== "object") { + return null; + } + return normalizeBrowserFormField(field as Record); + }) + .filter((field): field is BrowserFormField => field !== null); + if (!fields.length) { + throw new Error("fill requires fields"); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const timeoutMs = toNumber(raw.timeoutMs); + return { + kind, + fields, + ...(targetId ? { targetId } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } + case "resize": { + const width = toNumber(raw.width); + const height = toNumber(raw.height); + if (width === undefined || height === undefined) { + throw new Error("resize requires width and height"); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + return { + kind, + width, + height, + ...(targetId ? { targetId } : {}), + }; + } + case "wait": { + const loadStateRaw = toStringOrEmpty(raw.loadState); + const loadState = + loadStateRaw === "load" || + loadStateRaw === "domcontentloaded" || + loadStateRaw === "networkidle" + ? loadStateRaw + : undefined; + const timeMs = normalizeBoundedNonNegativeMs( + raw.timeMs, + "wait timeMs", + MAX_BATCH_WAIT_TIME_MS, + ); + const text = toStringOrEmpty(raw.text) || undefined; + const textGone = toStringOrEmpty(raw.textGone) || undefined; + const selector = toStringOrEmpty(raw.selector) || undefined; + const url = toStringOrEmpty(raw.url) || undefined; + const fn = toStringOrEmpty(raw.fn) || undefined; + if (timeMs === undefined && !text && !textGone && !selector && !url && !loadState && !fn) { + throw new Error( + "wait requires at least one of: timeMs, text, textGone, selector, url, loadState, fn", + ); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const timeoutMs = toNumber(raw.timeoutMs); + return { + kind, + ...(timeMs !== undefined ? { timeMs } : {}), + ...(text ? { text } : {}), + ...(textGone ? { textGone } : {}), + ...(selector ? { selector } : {}), + ...(url ? { url } : {}), + ...(loadState ? { loadState } : {}), + ...(fn ? { fn } : {}), + ...(targetId ? { targetId } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } + case "evaluate": { + const fn = toStringOrEmpty(raw.fn); + if (!fn) { + throw new Error("evaluate requires fn"); + } + const ref = toStringOrEmpty(raw.ref) || undefined; + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const timeoutMs = toNumber(raw.timeoutMs); + return { + kind, + fn, + ...(ref ? { ref } : {}), + ...(targetId ? { targetId } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } + case "close": { + const targetId = toStringOrEmpty(raw.targetId) || undefined; + return { + kind, + ...(targetId ? { targetId } : {}), + }; + } + case "batch": { + const actions = Array.isArray(raw.actions) ? raw.actions.map(normalizeBatchAction) : []; + if (!actions.length) { + throw new Error("batch requires actions"); + } + if (countBatchActions(actions) > MAX_BATCH_ACTIONS) { + throw new Error(`batch exceeds maximum of ${MAX_BATCH_ACTIONS} actions`); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const stopOnError = toBoolean(raw.stopOnError); + return { + kind, + actions, + ...(targetId ? { targetId } : {}), + ...(stopOnError !== undefined ? { stopOnError } : {}), + }; + } + } +} + +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") && !SELECTOR_ALLOWED_KINDS.has(kind)) { + return jsonError(res, 400, SELECTOR_UNSUPPORTED_MESSAGE); + } + const earlyFn = kind === "wait" || kind === "evaluate" ? toStringOrEmpty(body.fn) : ""; + if ( + (kind === "evaluate" || (kind === "wait" && earlyFn)) && + !ctx.state().resolved.evaluateEnabled + ) { + return jsonError( + res, + 403, + browserEvaluateDisabledMessage(kind === "evaluate" ? "evaluate" : "wait"), + ); + } + + await withRouteTabContext({ + req, + res, + ctx, + targetId, + run: async ({ profileCtx, cdpUrl, tab }) => { + const evaluateEnabled = ctx.state().resolved.evaluateEnabled; + const isExistingSession = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp; + const profileName = profileCtx.profile.name; + + switch (kind) { + case "click": { + const ref = toStringOrEmpty(body.ref) || undefined; + const selector = toStringOrEmpty(body.selector) || undefined; + if (!ref && !selector) { + return jsonError(res, 400, "ref or selector is required"); + } + const doubleClick = toBoolean(body.doubleClick) ?? false; + const timeoutMs = toNumber(body.timeoutMs); + const delayMs = toNumber(body.delayMs); + 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; + if (isExistingSession) { + if (selector) { + return jsonError( + res, + 501, + "existing-session click does not support selector targeting yet; use ref.", + ); + } + if ((button && button !== "left") || (modifiers && modifiers.length > 0)) { + return jsonError( + res, + 501, + "existing-session click currently supports left-click only (no button overrides/modifiers).", + ); + } + await clickChromeMcpElement({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + uid: ref!, + doubleClick, + }); + return res.json({ ok: true, targetId: tab.targetId, url: tab.url }); + } + const pw = await requirePwAi(res, `act:${kind}`); + if (!pw) { + return; + } + const clickRequest: Parameters[0] = { + cdpUrl, + targetId: tab.targetId, + doubleClick, + }; + if (ref) { + clickRequest.ref = ref; + } + if (selector) { + clickRequest.selector = selector; + } + if (button) { + clickRequest.button = button; + } + if (modifiers) { + clickRequest.modifiers = modifiers; + } + if (delayMs) { + clickRequest.delayMs = delayMs; + } + 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) || undefined; + const selector = toStringOrEmpty(body.selector) || undefined; + if (!ref && !selector) { + return jsonError(res, 400, "ref or selector 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); + if (isExistingSession) { + if (selector) { + return jsonError( + res, + 501, + "existing-session type does not support selector targeting yet; use ref.", + ); + } + if (slowly) { + return jsonError( + res, + 501, + "existing-session type does not support slowly=true; use fill/press instead.", + ); + } + await fillChromeMcpElement({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + uid: ref!, + value: text, + }); + if (submit) { + await pressChromeMcpKey({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + key: "Enter", + }); + } + return res.json({ ok: true, targetId: tab.targetId }); + } + const pw = await requirePwAi(res, `act:${kind}`); + if (!pw) { + return; + } + const typeRequest: Parameters[0] = { + cdpUrl, + targetId: tab.targetId, + text, + submit, + slowly, + }; + if (ref) { + typeRequest.ref = ref; + } + if (selector) { + typeRequest.selector = selector; + } + 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); + if (isExistingSession) { + if (delayMs) { + return jsonError(res, 501, "existing-session press does not support delayMs."); + } + await pressChromeMcpKey({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + key, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + const pw = await requirePwAi(res, `act:${kind}`); + if (!pw) { + return; + } + 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) || undefined; + const selector = toStringOrEmpty(body.selector) || undefined; + if (!ref && !selector) { + return jsonError(res, 400, "ref or selector is required"); + } + const timeoutMs = toNumber(body.timeoutMs); + if (isExistingSession) { + if (selector) { + return jsonError( + res, + 501, + "existing-session hover does not support selector targeting yet; use ref.", + ); + } + if (timeoutMs) { + return jsonError( + res, + 501, + "existing-session hover does not support timeoutMs overrides.", + ); + } + await hoverChromeMcpElement({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + uid: ref!, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + const pw = await requirePwAi(res, `act:${kind}`); + if (!pw) { + return; + } + await pw.hoverViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + ref, + selector, + timeoutMs: timeoutMs ?? undefined, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "scrollIntoView": { + const ref = toStringOrEmpty(body.ref) || undefined; + const selector = toStringOrEmpty(body.selector) || undefined; + if (!ref && !selector) { + return jsonError(res, 400, "ref or selector is required"); + } + const timeoutMs = toNumber(body.timeoutMs); + if (isExistingSession) { + if (selector) { + return jsonError( + res, + 501, + "existing-session scrollIntoView does not support selector targeting yet; use ref.", + ); + } + if (timeoutMs) { + return jsonError( + res, + 501, + "existing-session scrollIntoView does not support timeoutMs overrides.", + ); + } + await evaluateChromeMcpScript({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`, + args: [ref!], + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + const pw = await requirePwAi(res, `act:${kind}`); + if (!pw) { + return; + } + const scrollRequest: Parameters[0] = { + cdpUrl, + targetId: tab.targetId, + }; + if (ref) { + scrollRequest.ref = ref; + } + if (selector) { + scrollRequest.selector = selector; + } + if (timeoutMs) { + scrollRequest.timeoutMs = timeoutMs; + } + await pw.scrollIntoViewViaPlaywright(scrollRequest); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "drag": { + const startRef = toStringOrEmpty(body.startRef) || undefined; + const startSelector = toStringOrEmpty(body.startSelector) || undefined; + const endRef = toStringOrEmpty(body.endRef) || undefined; + const endSelector = toStringOrEmpty(body.endSelector) || undefined; + if (!startRef && !startSelector) { + return jsonError(res, 400, "startRef or startSelector is required"); + } + if (!endRef && !endSelector) { + return jsonError(res, 400, "endRef or endSelector is required"); + } + const timeoutMs = toNumber(body.timeoutMs); + if (isExistingSession) { + if (startSelector || endSelector) { + return jsonError( + res, + 501, + "existing-session drag does not support selector targeting yet; use startRef/endRef.", + ); + } + if (timeoutMs) { + return jsonError( + res, + 501, + "existing-session drag does not support timeoutMs overrides.", + ); + } + await dragChromeMcpElement({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + fromUid: startRef!, + toUid: endRef!, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + const pw = await requirePwAi(res, `act:${kind}`); + if (!pw) { + return; + } + await pw.dragViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + startRef, + startSelector, + endRef, + endSelector, + timeoutMs: timeoutMs ?? undefined, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "select": { + const ref = toStringOrEmpty(body.ref) || undefined; + const selector = toStringOrEmpty(body.selector) || undefined; + const values = toStringArray(body.values); + if ((!ref && !selector) || !values?.length) { + return jsonError(res, 400, "ref/selector and values are required"); + } + const timeoutMs = toNumber(body.timeoutMs); + if (isExistingSession) { + if (selector) { + return jsonError( + res, + 501, + "existing-session select does not support selector targeting yet; use ref.", + ); + } + if (values.length !== 1) { + return jsonError( + res, + 501, + "existing-session select currently supports a single value only.", + ); + } + if (timeoutMs) { + return jsonError( + res, + 501, + "existing-session select does not support timeoutMs overrides.", + ); + } + await fillChromeMcpElement({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + uid: ref!, + value: values[0] ?? "", + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + const pw = await requirePwAi(res, `act:${kind}`); + if (!pw) { + return; + } + await pw.selectOptionViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + ref, + selector, + 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); + }) + .filter((field): field is BrowserFormField => field !== null); + if (!fields.length) { + return jsonError(res, 400, "fields are required"); + } + const timeoutMs = toNumber(body.timeoutMs); + if (isExistingSession) { + if (timeoutMs) { + return jsonError( + res, + 501, + "existing-session fill does not support timeoutMs overrides.", + ); + } + await fillChromeMcpForm({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + elements: fields.map((field) => ({ + uid: field.ref, + value: String(field.value ?? ""), + })), + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + const pw = await requirePwAi(res, `act:${kind}`); + if (!pw) { + return; + } + 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"); + } + if (isExistingSession) { + await resizeChromeMcpPage({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + width, + height, + }); + return res.json({ ok: true, targetId: tab.targetId, url: tab.url }); + } + const pw = await requirePwAi(res, `act:${kind}`); + if (!pw) { + return; + } + 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, browserEvaluateDisabledMessage("wait")); + } + 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", + ); + } + if (isExistingSession) { + if (loadState === "networkidle") { + return jsonError( + res, + 501, + "existing-session wait does not support loadState=networkidle yet.", + ); + } + await waitForExistingSessionCondition({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + timeMs, + text, + textGone, + selector, + url, + loadState, + fn, + timeoutMs, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + const pw = await requirePwAi(res, `act:${kind}`); + if (!pw) { + return; + } + 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, browserEvaluateDisabledMessage("evaluate")); + } + 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); + if (isExistingSession) { + if (evalTimeoutMs !== undefined) { + return jsonError( + res, + 501, + "existing-session evaluate does not support timeoutMs overrides.", + ); + } + const result = await evaluateChromeMcpScript({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + fn, + args: ref ? [ref] : undefined, + }); + return res.json({ + ok: true, + targetId: tab.targetId, + url: tab.url, + result, + }); + } + const pw = await requirePwAi(res, `act:${kind}`); + if (!pw) { + return; + } + const evalRequest: Parameters[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": { + if (isExistingSession) { + await closeChromeMcpTab(profileName, tab.targetId, profileCtx.profile.userDataDir); + return res.json({ ok: true, targetId: tab.targetId }); + } + const pw = await requirePwAi(res, `act:${kind}`); + if (!pw) { + return; + } + await pw.closePageViaPlaywright({ cdpUrl, targetId: tab.targetId }); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "batch": { + if (isExistingSession) { + return jsonError( + res, + 501, + "existing-session batch is not supported yet; send actions individually.", + ); + } + const pw = await requirePwAi(res, `act:${kind}`); + if (!pw) { + return; + } + let actions: BrowserActRequest[]; + try { + actions = Array.isArray(body.actions) ? body.actions.map(normalizeBatchAction) : []; + } catch (err) { + return jsonError(res, 400, err instanceof Error ? err.message : String(err)); + } + if (!actions.length) { + return jsonError(res, 400, "actions are required"); + } + if (countBatchActions(actions) > MAX_BATCH_ACTIONS) { + return jsonError(res, 400, `batch exceeds maximum of ${MAX_BATCH_ACTIONS} actions`); + } + const targetIdError = validateBatchTargetIds(actions, tab.targetId); + if (targetIdError) { + return jsonError(res, 403, targetIdError); + } + const stopOnError = toBoolean(body.stopOnError) ?? true; + const result = await pw.batchViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + actions, + stopOnError, + evaluateEnabled, + }); + return res.json({ ok: true, targetId: tab.targetId, results: result.results }); + } + 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 withRouteTabContext({ + req, + res, + ctx, + targetId, + run: async ({ profileCtx, cdpUrl, tab }) => { + if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { + return jsonError( + res, + 501, + "response body is not supported for existing-session profiles yet.", + ); + } + const pw = await requirePwAi(res, "response body"); + if (!pw) { + return; + } + 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 withRouteTabContext({ + req, + res, + ctx, + targetId, + run: async ({ profileCtx, cdpUrl, tab }) => { + if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { + await evaluateChromeMcpScript({ + profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + args: [ref], + fn: `(el) => { + if (!(el instanceof Element)) { + return false; + } + el.scrollIntoView({ block: "center", inline: "center" }); + const previousOutline = el.style.outline; + const previousOffset = el.style.outlineOffset; + el.style.outline = "3px solid #FF4500"; + el.style.outlineOffset = "2px"; + setTimeout(() => { + el.style.outline = previousOutline; + el.style.outlineOffset = previousOffset; + }, 2000); + return true; + }`, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + const pw = await requirePwAi(res, "highlight"); + if (!pw) { + return; + } + await pw.highlightViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + ref, + }); + res.json({ ok: true, targetId: tab.targetId }); + }, + }); + }); +} diff --git a/extensions/browser/src/browser/routes/agent.debug.ts b/extensions/browser/src/browser/routes/agent.debug.ts new file mode 100644 index 00000000000..f5c0d7b2030 --- /dev/null +++ b/extensions/browser/src/browser/routes/agent.debug.ts @@ -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), + }); + }, + }); + }); +} diff --git a/extensions/browser/src/browser/routes/agent.shared.ts b/extensions/browser/src/browser/routes/agent.shared.ts new file mode 100644 index 00000000000..cc82e00d004 --- /dev/null +++ b/extensions/browser/src/browser/routes/agent.shared.ts @@ -0,0 +1,148 @@ +import { toBrowserErrorResponse } from "../errors.js"; +import type { PwAiModule } from "../pw-ai-module.js"; +import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js"; +import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; +import type { BrowserRequest, BrowserResponse } from "./types.js"; +import { getProfileContext, jsonError } from "./utils.js"; + +export const SELECTOR_UNSUPPORTED_MESSAGE = [ + "Error: 'selector' is not supported. Use 'ref' from snapshot instead.", + "", + "Example workflow:", + "1. snapshot action to get page state with refs", + '2. act with ref: "e123" to interact with element', + "", + "This is more reliable for modern SPAs.", +].join("\n"); + +export function readBody(req: BrowserRequest): Record { + const body = req.body as Record | undefined; + if (!body || typeof body !== "object" || Array.isArray(body)) { + return {}; + } + return body; +} + +export function resolveTargetIdFromBody(body: Record): string | undefined { + const targetId = typeof body.targetId === "string" ? body.targetId.trim() : ""; + return targetId || undefined; +} + +export function resolveTargetIdFromQuery(query: Record): string | undefined { + const targetId = typeof query.targetId === "string" ? query.targetId.trim() : ""; + return targetId || undefined; +} + +export function handleRouteError(ctx: BrowserRouteContext, res: BrowserResponse, err: unknown) { + const mapped = ctx.mapTabError(err); + if (mapped) { + return jsonError(res, mapped.status, mapped.message); + } + const browserMapped = toBrowserErrorResponse(err); + if (browserMapped) { + return jsonError(res, browserMapped.status, browserMapped.message); + } + jsonError(res, 500, String(err)); +} + +export function resolveProfileContext( + req: BrowserRequest, + res: BrowserResponse, + ctx: BrowserRouteContext, +): ProfileContext | null { + const profileCtx = getProfileContext(req, ctx); + if ("error" in profileCtx) { + jsonError(res, profileCtx.status, profileCtx.error); + return null; + } + return profileCtx; +} + +export async function getPwAiModule(): Promise { + return await getPwAiModuleBase({ mode: "soft" }); +} + +export async function requirePwAi( + res: BrowserResponse, + feature: string, +): Promise { + 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>; + cdpUrl: string; +}; + +type RouteTabPwContext = RouteTabContext & { + pw: PwAiModule; +}; + +type RouteWithTabParams = { + req: BrowserRequest; + res: BrowserResponse; + ctx: BrowserRouteContext; + targetId?: string; + run: (ctx: RouteTabContext) => Promise; +}; + +export async function withRouteTabContext( + params: RouteWithTabParams, +): Promise { + 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 = { + req: BrowserRequest; + res: BrowserResponse; + ctx: BrowserRouteContext; + targetId?: string; + feature: string; + run: (ctx: RouteTabPwContext) => Promise; +}; + +export async function withPlaywrightRouteContext( + params: RouteWithPwParams, +): Promise { + 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 }); + }, + }); +} diff --git a/extensions/browser/src/browser/routes/agent.snapshot.plan.ts b/extensions/browser/src/browser/routes/agent.snapshot.plan.ts new file mode 100644 index 00000000000..6c913400d90 --- /dev/null +++ b/extensions/browser/src/browser/routes/agent.snapshot.plan.ts @@ -0,0 +1,97 @@ +import type { ResolvedBrowserProfile } from "../config.js"; +import { + DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH, + DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS, + DEFAULT_AI_SNAPSHOT_MAX_CHARS, +} from "../constants.js"; +import { + resolveDefaultSnapshotFormat, + shouldUsePlaywrightForAriaSnapshot, + shouldUsePlaywrightForScreenshot, +} from "../profile-capabilities.js"; +import { toBoolean, toNumber, toStringOrEmpty } from "./utils.js"; + +export type BrowserSnapshotPlan = { + format: "ai" | "aria"; + mode?: "efficient"; + labels?: boolean; + limit?: number; + resolvedMaxChars?: number; + interactive?: boolean; + compact?: boolean; + depth?: number; + refsMode?: "aria" | "role"; + selectorValue?: string; + frameSelectorValue?: string; + wantsRoleSnapshot: boolean; +}; + +export function resolveSnapshotPlan(params: { + profile: ResolvedBrowserProfile; + query: Record; + hasPlaywright: boolean; +}): BrowserSnapshotPlan { + const mode = params.query.mode === "efficient" ? "efficient" : undefined; + const labels = toBoolean(params.query.labels) ?? undefined; + const explicitFormat = + params.query.format === "aria" ? "aria" : params.query.format === "ai" ? "ai" : undefined; + const format = resolveDefaultSnapshotFormat({ + profile: params.profile, + hasPlaywright: params.hasPlaywright, + explicitFormat, + mode, + }); + const limitRaw = typeof params.query.limit === "string" ? Number(params.query.limit) : undefined; + const hasMaxChars = Object.hasOwn(params.query, "maxChars"); + const maxCharsRaw = + typeof params.query.maxChars === "string" ? Number(params.query.maxChars) : undefined; + const limit = Number.isFinite(limitRaw) ? limitRaw : undefined; + const maxChars = + typeof maxCharsRaw === "number" && Number.isFinite(maxCharsRaw) && maxCharsRaw > 0 + ? Math.floor(maxCharsRaw) + : undefined; + const resolvedMaxChars = + format === "ai" + ? hasMaxChars + ? maxChars + : mode === "efficient" + ? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS + : DEFAULT_AI_SNAPSHOT_MAX_CHARS + : undefined; + const interactiveRaw = toBoolean(params.query.interactive); + const compactRaw = toBoolean(params.query.compact); + const depthRaw = toNumber(params.query.depth); + const refsModeRaw = toStringOrEmpty(params.query.refs).trim(); + const refsMode: "aria" | "role" | undefined = + refsModeRaw === "aria" ? "aria" : refsModeRaw === "role" ? "role" : undefined; + const interactive = interactiveRaw ?? (mode === "efficient" ? true : undefined); + const compact = compactRaw ?? (mode === "efficient" ? true : undefined); + const depth = + depthRaw ?? (mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH : undefined); + const selectorValue = toStringOrEmpty(params.query.selector).trim() || undefined; + const frameSelectorValue = toStringOrEmpty(params.query.frame).trim() || undefined; + + return { + format, + mode, + labels, + limit, + resolvedMaxChars, + interactive, + compact, + depth, + refsMode, + selectorValue, + frameSelectorValue, + wantsRoleSnapshot: + labels === true || + mode === "efficient" || + interactive === true || + compact === true || + depth !== undefined || + Boolean(selectorValue) || + Boolean(frameSelectorValue), + }; +} + +export { shouldUsePlaywrightForAriaSnapshot, shouldUsePlaywrightForScreenshot }; diff --git a/extensions/browser/src/browser/routes/agent.snapshot.ts b/extensions/browser/src/browser/routes/agent.snapshot.ts new file mode 100644 index 00000000000..7cb73049389 --- /dev/null +++ b/extensions/browser/src/browser/routes/agent.snapshot.ts @@ -0,0 +1,602 @@ +import path from "node:path"; +import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js"; +import { captureScreenshot, snapshotAria } from "../cdp.js"; +import { + evaluateChromeMcpScript, + navigateChromeMcpPage, + takeChromeMcpScreenshot, + takeChromeMcpSnapshot, +} from "../chrome-mcp.js"; +import { + buildAiSnapshotFromChromeMcpSnapshot, + flattenChromeMcpSnapshotToAriaNodes, +} from "../chrome-mcp.snapshot.js"; +import { + assertBrowserNavigationAllowed, + assertBrowserNavigationResultAllowed, +} from "../navigation-guard.js"; +import { withBrowserNavigationPolicy } from "../navigation-guard.js"; +import { getBrowserProfileCapabilities } from "../profile-capabilities.js"; +import { + DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, + DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, + normalizeBrowserScreenshot, +} from "../screenshot.js"; +import type { BrowserRouteContext } from "../server-context.js"; +import { + getPwAiModule, + handleRouteError, + readBody, + requirePwAi, + resolveProfileContext, + withPlaywrightRouteContext, + withRouteTabContext, +} from "./agent.shared.js"; +import { + resolveSnapshotPlan, + shouldUsePlaywrightForAriaSnapshot, + shouldUsePlaywrightForScreenshot, +} from "./agent.snapshot.plan.js"; +import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js"; +import { jsonError, toBoolean, toStringOrEmpty } from "./utils.js"; + +const CHROME_MCP_OVERLAY_ATTR = "data-openclaw-mcp-overlay"; + +async function clearChromeMcpOverlay(params: { + profileName: string; + userDataDir?: string; + targetId: string; +}): Promise { + await evaluateChromeMcpScript({ + profileName: params.profileName, + userDataDir: params.userDataDir, + targetId: params.targetId, + fn: `() => { + document.querySelectorAll("[${CHROME_MCP_OVERLAY_ATTR}]").forEach((node) => node.remove()); + return true; + }`, + }).catch(() => {}); +} + +async function renderChromeMcpLabels(params: { + profileName: string; + userDataDir?: string; + targetId: string; + refs: string[]; +}): Promise<{ labels: number; skipped: number }> { + const refList = JSON.stringify(params.refs); + const result = await evaluateChromeMcpScript({ + profileName: params.profileName, + userDataDir: params.userDataDir, + targetId: params.targetId, + args: params.refs, + fn: `(...elements) => { + const refs = ${refList}; + document.querySelectorAll("[${CHROME_MCP_OVERLAY_ATTR}]").forEach((node) => node.remove()); + const root = document.createElement("div"); + root.setAttribute("${CHROME_MCP_OVERLAY_ATTR}", "labels"); + root.style.position = "fixed"; + root.style.inset = "0"; + root.style.pointerEvents = "none"; + root.style.zIndex = "2147483647"; + let labels = 0; + let skipped = 0; + elements.forEach((el, index) => { + if (!(el instanceof Element)) { + skipped += 1; + return; + } + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 && rect.height <= 0) { + skipped += 1; + return; + } + labels += 1; + const badge = document.createElement("div"); + badge.setAttribute("${CHROME_MCP_OVERLAY_ATTR}", "label"); + badge.textContent = refs[index] || String(labels); + badge.style.position = "fixed"; + badge.style.left = \`\${Math.max(0, rect.left)}px\`; + badge.style.top = \`\${Math.max(0, rect.top)}px\`; + badge.style.transform = "translateY(-100%)"; + badge.style.padding = "2px 6px"; + badge.style.borderRadius = "999px"; + badge.style.background = "#FF4500"; + badge.style.color = "#fff"; + badge.style.font = "600 12px ui-monospace, SFMono-Regular, Menlo, monospace"; + badge.style.boxShadow = "0 2px 6px rgba(0,0,0,0.35)"; + badge.style.whiteSpace = "nowrap"; + root.appendChild(badge); + }); + document.documentElement.appendChild(root); + return { labels, skipped }; + }`, + }); + const labels = + result && + typeof result === "object" && + typeof (result as { labels?: unknown }).labels === "number" + ? (result as { labels: number }).labels + : 0; + const skipped = + result && + typeof result === "object" && + typeof (result as { skipped?: unknown }).skipped === "number" + ? (result as { skipped: number }).skipped + : 0; + return { labels, skipped }; +} + +async function saveNormalizedScreenshotResponse(params: { + res: BrowserResponse; + buffer: Buffer; + type: "png" | "jpeg"; + targetId: string; + url: string; +}) { + const normalized = await normalizeBrowserScreenshot(params.buffer, { + maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, + maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, + }); + await saveBrowserMediaResponse({ + res: params.res, + buffer: normalized.buffer, + contentType: normalized.contentType ?? `image/${params.type}`, + maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, + targetId: params.targetId, + url: params.url, + }); +} + +async function saveBrowserMediaResponse(params: { + res: BrowserResponse; + buffer: Buffer; + contentType: string; + maxBytes: number; + targetId: string; + url: string; +}) { + await ensureMediaDir(); + const saved = await saveMediaBuffer( + params.buffer, + params.contentType, + "browser", + params.maxBytes, + ); + params.res.json({ + ok: true, + path: path.resolve(saved.path), + targetId: params.targetId, + url: params.url, + }); +} + +/** Resolve the correct targetId after a navigation that may trigger a renderer swap. */ +export async function resolveTargetIdAfterNavigate(opts: { + oldTargetId: string; + navigatedUrl: string; + listTabs: () => Promise>; +}): Promise { + let currentTargetId = opts.oldTargetId; + try { + const pickReplacement = ( + tabs: Array<{ targetId: string; url: string }>, + options?: { allowSingleTabFallback?: boolean }, + ) => { + if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) { + return opts.oldTargetId; + } + const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl); + if (byUrl.length === 1) { + return byUrl[0]?.targetId ?? opts.oldTargetId; + } + const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId); + if (uniqueReplacement.length === 1) { + return uniqueReplacement[0]?.targetId ?? opts.oldTargetId; + } + if (options?.allowSingleTabFallback && tabs.length === 1) { + return tabs[0]?.targetId ?? opts.oldTargetId; + } + return opts.oldTargetId; + }; + + currentTargetId = pickReplacement(await opts.listTabs()); + if (currentTargetId === opts.oldTargetId) { + await new Promise((r) => setTimeout(r, 800)); + currentTargetId = pickReplacement(await opts.listTabs(), { + allowSingleTabFallback: true, + }); + } + } catch { + // Best-effort: fall back to pre-navigation targetId + } + return currentTargetId; +} + +export function registerBrowserAgentSnapshotRoutes( + app: BrowserRouteRegistrar, + ctx: BrowserRouteContext, +) { + app.post("/navigate", async (req, res) => { + const body = readBody(req); + const url = toStringOrEmpty(body.url); + const targetId = toStringOrEmpty(body.targetId) || undefined; + if (!url) { + return jsonError(res, 400, "url is required"); + } + await withRouteTabContext({ + req, + res, + ctx, + targetId, + run: async ({ profileCtx, tab, cdpUrl }) => { + if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { + const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy); + await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); + const result = await navigateChromeMcpPage({ + profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + url, + }); + await assertBrowserNavigationResultAllowed({ url: result.url, ...ssrfPolicyOpts }); + return res.json({ ok: true, targetId: tab.targetId, ...result }); + } + const pw = await requirePwAi(res, "navigate"); + if (!pw) { + return; + } + const result = await pw.navigateViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + url, + ...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy), + }); + const currentTargetId = await resolveTargetIdAfterNavigate({ + oldTargetId: tab.targetId, + navigatedUrl: result.url, + listTabs: () => profileCtx.listTabs(), + }); + res.json({ ok: true, targetId: currentTargetId, ...result }); + }, + }); + }); + + app.post("/pdf", async (req, res) => { + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) { + return; + } + if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { + return jsonError( + res, + 501, + "pdf is not supported for existing-session profiles yet; use screenshot/snapshot instead.", + ); + } + await withPlaywrightRouteContext({ + req, + res, + ctx, + targetId, + feature: "pdf", + run: async ({ cdpUrl, tab, pw }) => { + const pdf = await pw.pdfViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + }); + await saveBrowserMediaResponse({ + res, + buffer: pdf.buffer, + contentType: "application/pdf", + maxBytes: pdf.buffer.byteLength, + targetId: tab.targetId, + url: tab.url, + }); + }, + }); + }); + + app.post("/screenshot", async (req, res) => { + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const fullPage = toBoolean(body.fullPage) ?? false; + const ref = toStringOrEmpty(body.ref) || undefined; + const element = toStringOrEmpty(body.element) || undefined; + const type = body.type === "jpeg" ? "jpeg" : "png"; + + if (fullPage && (ref || element)) { + return jsonError(res, 400, "fullPage is not supported for element screenshots"); + } + + await withRouteTabContext({ + req, + res, + ctx, + targetId, + run: async ({ profileCtx, tab, cdpUrl }) => { + if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { + if (element) { + return jsonError( + res, + 400, + "element screenshots are not supported for existing-session profiles; use ref from snapshot.", + ); + } + const buffer = await takeChromeMcpScreenshot({ + profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + uid: ref, + fullPage, + format: type, + }); + await saveNormalizedScreenshotResponse({ + res, + buffer, + type, + targetId: tab.targetId, + url: tab.url, + }); + return; + } + + let buffer: Buffer; + const shouldUsePlaywright = shouldUsePlaywrightForScreenshot({ + profile: profileCtx.profile, + wsUrl: tab.wsUrl, + ref, + element, + }); + if (shouldUsePlaywright) { + const pw = await requirePwAi(res, "screenshot"); + if (!pw) { + return; + } + const snap = await pw.takeScreenshotViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + ref, + element, + fullPage, + type, + }); + buffer = snap.buffer; + } else { + buffer = await captureScreenshot({ + wsUrl: tab.wsUrl ?? "", + fullPage, + format: type, + quality: type === "jpeg" ? 85 : undefined, + }); + } + + await saveNormalizedScreenshotResponse({ + res, + buffer, + type, + targetId: tab.targetId, + url: tab.url, + }); + }, + }); + }); + + app.get("/snapshot", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) { + return; + } + const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; + const hasPlaywright = Boolean(await getPwAiModule()); + const plan = resolveSnapshotPlan({ + profile: profileCtx.profile, + query: req.query, + hasPlaywright, + }); + + try { + const tab = await profileCtx.ensureTabAvailable(targetId || undefined); + if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") { + return jsonError(res, 400, "labels/mode=efficient require format=ai"); + } + if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { + if (plan.selectorValue || plan.frameSelectorValue) { + return jsonError( + res, + 400, + "selector/frame snapshots are not supported for existing-session profiles; snapshot the whole page and use refs.", + ); + } + const snapshot = await takeChromeMcpSnapshot({ + profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + }); + if (plan.format === "aria") { + return res.json({ + ok: true, + format: "aria", + targetId: tab.targetId, + url: tab.url, + nodes: flattenChromeMcpSnapshotToAriaNodes(snapshot, plan.limit), + }); + } + const built = buildAiSnapshotFromChromeMcpSnapshot({ + root: snapshot, + options: { + interactive: plan.interactive ?? undefined, + compact: plan.compact ?? undefined, + maxDepth: plan.depth ?? undefined, + }, + maxChars: plan.resolvedMaxChars, + }); + if (plan.labels) { + const refs = Object.keys(built.refs); + const labelResult = await renderChromeMcpLabels({ + profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + refs, + }); + try { + const labeled = await takeChromeMcpScreenshot({ + profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + format: "png", + }); + const normalized = await normalizeBrowserScreenshot(labeled, { + maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, + maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, + }); + await ensureMediaDir(); + const saved = await saveMediaBuffer( + normalized.buffer, + normalized.contentType ?? "image/png", + "browser", + DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, + ); + return res.json({ + ok: true, + format: "ai", + targetId: tab.targetId, + url: tab.url, + labels: true, + labelsCount: labelResult.labels, + labelsSkipped: labelResult.skipped, + imagePath: path.resolve(saved.path), + imageType: normalized.contentType?.includes("jpeg") ? "jpeg" : "png", + ...built, + }); + } finally { + await clearChromeMcpOverlay({ + profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + }); + } + } + return res.json({ + ok: true, + format: "ai", + targetId: tab.targetId, + url: tab.url, + ...built, + }); + } + if (plan.format === "ai") { + const pw = await requirePwAi(res, "ai snapshot"); + if (!pw) { + return; + } + const roleSnapshotArgs = { + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + selector: plan.selectorValue, + frameSelector: plan.frameSelectorValue, + refsMode: plan.refsMode, + options: { + interactive: plan.interactive ?? undefined, + compact: plan.compact ?? undefined, + maxDepth: plan.depth ?? undefined, + }, + }; + + const snap = plan.wantsRoleSnapshot + ? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs) + : await pw + .snapshotAiViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + ...(typeof plan.resolvedMaxChars === "number" + ? { maxChars: plan.resolvedMaxChars } + : {}), + }) + .catch(async (err) => { + // Public-API fallback when Playwright's private _snapshotForAI is missing. + if (String(err).toLowerCase().includes("_snapshotforai")) { + return await pw.snapshotRoleViaPlaywright(roleSnapshotArgs); + } + throw err; + }); + if (plan.labels) { + const labeled = await pw.screenshotWithLabelsViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + refs: "refs" in snap ? snap.refs : {}, + type: "png", + }); + const normalized = await normalizeBrowserScreenshot(labeled.buffer, { + maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, + maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, + }); + await ensureMediaDir(); + const saved = await saveMediaBuffer( + normalized.buffer, + normalized.contentType ?? "image/png", + "browser", + DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, + ); + const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png"; + return res.json({ + ok: true, + format: plan.format, + targetId: tab.targetId, + url: tab.url, + labels: true, + labelsCount: labeled.labels, + labelsSkipped: labeled.skipped, + imagePath: path.resolve(saved.path), + imageType, + ...snap, + }); + } + + return res.json({ + ok: true, + format: plan.format, + targetId: tab.targetId, + url: tab.url, + ...snap, + }); + } + + const snap = shouldUsePlaywrightForAriaSnapshot({ + profile: profileCtx.profile, + wsUrl: tab.wsUrl, + }) + ? (() => { + // Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session. + // Also covers cases where wsUrl is missing/unusable. + return requirePwAi(res, "aria snapshot").then(async (pw) => { + if (!pw) { + return null; + } + return await pw.snapshotAriaViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + limit: plan.limit, + }); + }); + })() + : snapshotAria({ wsUrl: tab.wsUrl ?? "", limit: plan.limit }); + + const resolved = await Promise.resolve(snap); + if (!resolved) { + return; + } + return res.json({ + ok: true, + format: plan.format, + targetId: tab.targetId, + url: tab.url, + ...resolved, + }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); +} diff --git a/extensions/browser/src/browser/routes/agent.storage.ts b/extensions/browser/src/browser/routes/agent.storage.ts new file mode 100644 index 00000000000..86830ab27ce --- /dev/null +++ b/extensions/browser/src/browser/routes/agent.storage.ts @@ -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, +): { kind: StorageKind | null; targetId: string | undefined } { + return { + kind: parseStorageKind(toStringOrEmpty(kindParam)), + targetId: resolveTargetIdFromBody(body), + }; +} + +export function parseRequiredStorageMutationRequest( + kindParam: unknown, + body: Record, +): { 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, +) { + 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) + : 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) + : null; + if (!headers) { + return jsonError(res, 400, "headers is required"); + } + + const parsed: Record = {}; + 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 }); + }, + }); + }); +} diff --git a/extensions/browser/src/browser/routes/agent.ts b/extensions/browser/src/browser/routes/agent.ts new file mode 100644 index 00000000000..dc5e65433ac --- /dev/null +++ b/extensions/browser/src/browser/routes/agent.ts @@ -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); +} diff --git a/extensions/browser/src/browser/routes/basic.ts b/extensions/browser/src/browser/routes/basic.ts new file mode 100644 index 00000000000..b781bc62694 --- /dev/null +++ b/extensions/browser/src/browser/routes/basic.ts @@ -0,0 +1,225 @@ +import { getChromeMcpPid } from "../chrome-mcp.js"; +import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js"; +import { toBrowserErrorResponse } from "../errors.js"; +import { getBrowserProfileCapabilities } from "../profile-capabilities.js"; +import { createBrowserProfilesService } from "../profiles-service.js"; +import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; +import { resolveProfileContext } from "./agent.shared.js"; +import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js"; +import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js"; + +function handleBrowserRouteError(res: BrowserResponse, err: unknown) { + const mapped = toBrowserErrorResponse(err); + if (mapped) { + return jsonError(res, mapped.status, mapped.message); + } + jsonError(res, 500, String(err)); +} + +async function withBasicProfileRoute(params: { + req: BrowserRequest; + res: BrowserResponse; + ctx: BrowserRouteContext; + run: (profileCtx: ProfileContext) => Promise; +}) { + const profileCtx = resolveProfileContext(params.req, params.res, params.ctx); + if (!profileCtx) { + return; + } + try { + await params.run(profileCtx); + } catch (err) { + return handleBrowserRouteError(params.res, err); + } +} + +async function withProfilesServiceMutation(params: { + res: BrowserResponse; + ctx: BrowserRouteContext; + run: (service: ReturnType) => Promise; +}) { + try { + const service = createBrowserProfilesService(params.ctx); + const result = await params.run(service); + params.res.json(result); + } catch (err) { + return handleBrowserRouteError(params.res, err); + } +} + +export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) { + // List all profiles with their status + app.get("/profiles", async (_req, res) => { + try { + const service = createBrowserProfilesService(ctx); + const profiles = await service.listProfiles(); + res.json({ profiles }); + } catch (err) { + jsonError(res, 500, String(err)); + } + }); + + // Get status (profile-aware) + app.get("/", async (req, res) => { + let current: ReturnType; + try { + current = ctx.state(); + } catch { + return jsonError(res, 503, "browser server not started"); + } + + const profileCtx = getProfileContext(req, ctx); + if ("error" in profileCtx) { + return jsonError(res, profileCtx.status, profileCtx.error); + } + + try { + const [cdpHttp, cdpReady] = await Promise.all([ + profileCtx.isHttpReachable(300), + profileCtx.isReachable(600), + ]); + + const profileState = current.profiles.get(profileCtx.profile.name); + const capabilities = getBrowserProfileCapabilities(profileCtx.profile); + let detectedBrowser: string | null = null; + let detectedExecutablePath: string | null = null; + let detectError: string | null = null; + + try { + const detected = resolveBrowserExecutableForPlatform(current.resolved, process.platform); + if (detected) { + detectedBrowser = detected.kind; + detectedExecutablePath = detected.path; + } + } catch (err) { + detectError = String(err); + } + + res.json({ + enabled: current.resolved.enabled, + profile: profileCtx.profile.name, + driver: profileCtx.profile.driver, + transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp", + running: cdpReady, + cdpReady, + cdpHttp, + pid: capabilities.usesChromeMcp + ? getChromeMcpPid(profileCtx.profile.name) + : (profileState?.running?.pid ?? null), + cdpPort: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpPort, + cdpUrl: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpUrl, + chosenBrowser: profileState?.running?.exe.kind ?? null, + detectedBrowser, + detectedExecutablePath, + detectError, + userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null, + color: profileCtx.profile.color, + headless: current.resolved.headless, + noSandbox: current.resolved.noSandbox, + executablePath: current.resolved.executablePath ?? null, + attachOnly: profileCtx.profile.attachOnly, + }); + } catch (err) { + const mapped = toBrowserErrorResponse(err); + if (mapped) { + return jsonError(res, mapped.status, mapped.message); + } + jsonError(res, 500, String(err)); + } + }); + + // Start browser (profile-aware) + app.post("/start", async (req, res) => { + await withBasicProfileRoute({ + req, + res, + ctx, + run: async (profileCtx) => { + await profileCtx.ensureBrowserAvailable(); + res.json({ ok: true, profile: profileCtx.profile.name }); + }, + }); + }); + + // Stop browser (profile-aware) + app.post("/stop", async (req, res) => { + await withBasicProfileRoute({ + req, + res, + ctx, + run: async (profileCtx) => { + const result = await profileCtx.stopRunningBrowser(); + res.json({ + ok: true, + stopped: result.stopped, + profile: profileCtx.profile.name, + }); + }, + }); + }); + + // Reset profile (profile-aware) + app.post("/reset-profile", async (req, res) => { + await withBasicProfileRoute({ + req, + res, + ctx, + run: async (profileCtx) => { + const result = await profileCtx.resetProfile(); + res.json({ ok: true, profile: profileCtx.profile.name, ...result }); + }, + }); + }); + + // Create a new profile + app.post("/profiles/create", async (req, res) => { + const name = toStringOrEmpty((req.body as { name?: unknown })?.name); + const color = toStringOrEmpty((req.body as { color?: unknown })?.color); + const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl); + const userDataDir = toStringOrEmpty((req.body as { userDataDir?: unknown })?.userDataDir); + const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver); + + if (!name) { + return jsonError(res, 400, "name is required"); + } + if (driver && driver !== "openclaw" && driver !== "clawd" && driver !== "existing-session") { + return jsonError( + res, + 400, + `unsupported profile driver "${driver}"; use "openclaw", "clawd", or "existing-session"`, + ); + } + + await withProfilesServiceMutation({ + res, + ctx, + run: async (service) => + await service.createProfile({ + name, + color: color || undefined, + cdpUrl: cdpUrl || undefined, + userDataDir: userDataDir || undefined, + driver: + driver === "existing-session" + ? "existing-session" + : driver === "openclaw" || driver === "clawd" + ? "openclaw" + : undefined, + }), + }); + }); + + // Delete a profile + app.delete("/profiles/:name", async (req, res) => { + const name = toStringOrEmpty(req.params.name); + if (!name) { + return jsonError(res, 400, "profile name is required"); + } + + await withProfilesServiceMutation({ + res, + ctx, + run: async (service) => await service.deleteProfile(name), + }); + }); +} diff --git a/extensions/browser/src/browser/routes/dispatcher.ts b/extensions/browser/src/browser/routes/dispatcher.ts new file mode 100644 index 00000000000..3fe24e11041 --- /dev/null +++ b/extensions/browser/src/browser/routes/dispatcher.ts @@ -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; + 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; +}; + +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 => { + 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 = {}; + 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 }; diff --git a/extensions/browser/src/browser/routes/index.ts b/extensions/browser/src/browser/routes/index.ts new file mode 100644 index 00000000000..3c20ef1c646 --- /dev/null +++ b/extensions/browser/src/browser/routes/index.ts @@ -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); +} diff --git a/extensions/browser/src/browser/routes/output-paths.ts b/extensions/browser/src/browser/routes/output-paths.ts new file mode 100644 index 00000000000..4a11d3dc816 --- /dev/null +++ b/extensions/browser/src/browser/routes/output-paths.ts @@ -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 { + await fs.mkdir(rootDir, { recursive: true }); +} + +export async function resolveWritableOutputPathOrRespond(params: { + res: BrowserResponse; + rootDir: string; + requestedPath: string; + scopeLabel: string; + defaultFileName?: string; + ensureRootDir?: boolean; +}): Promise { + 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; +} diff --git a/extensions/browser/src/browser/routes/path-output.ts b/extensions/browser/src/browser/routes/path-output.ts new file mode 100644 index 00000000000..e23da97e1b2 --- /dev/null +++ b/extensions/browser/src/browser/routes/path-output.ts @@ -0,0 +1 @@ +export * from "../paths.js"; diff --git a/extensions/browser/src/browser/routes/tabs.ts b/extensions/browser/src/browser/routes/tabs.ts new file mode 100644 index 00000000000..d15838787b3 --- /dev/null +++ b/extensions/browser/src/browser/routes/tabs.ts @@ -0,0 +1,230 @@ +import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "../errors.js"; +import { + assertBrowserNavigationAllowed, + withBrowserNavigationPolicy, +} from "../navigation-guard.js"; +import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; +import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js"; +import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js"; + +function resolveTabsProfileContext( + req: BrowserRequest, + res: BrowserResponse, + ctx: BrowserRouteContext, +) { + const profileCtx = getProfileContext(req, ctx); + if ("error" in profileCtx) { + jsonError(res, profileCtx.status, profileCtx.error); + return null; + } + return profileCtx; +} + +function handleTabsRouteError( + ctx: BrowserRouteContext, + res: BrowserResponse, + err: unknown, + opts?: { mapTabError?: boolean }, +) { + if (opts?.mapTabError) { + const mapped = ctx.mapTabError(err); + if (mapped) { + return jsonError(res, mapped.status, mapped.message); + } + } + return jsonError(res, 500, String(err)); +} + +async function withTabsProfileRoute(params: { + req: BrowserRequest; + res: BrowserResponse; + ctx: BrowserRouteContext; + mapTabError?: boolean; + run: (profileCtx: ProfileContext) => Promise; +}) { + const profileCtx = resolveTabsProfileContext(params.req, params.res, params.ctx); + if (!profileCtx) { + return; + } + try { + await params.run(profileCtx); + } catch (err) { + handleTabsRouteError(params.ctx, params.res, err, { mapTabError: params.mapTabError }); + } +} + +async function ensureBrowserRunning(profileCtx: ProfileContext, res: BrowserResponse) { + if (!(await profileCtx.isReachable(300))) { + jsonError( + res, + new BrowserProfileUnavailableError("browser not running").status, + "browser not running", + ); + return false; + } + return true; +} + +function resolveIndexedTab( + tabs: Awaited>, + 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; +}) { + await withTabsProfileRoute({ + req: params.req, + res: params.res, + ctx: params.ctx, + mapTabError: true, + run: async (profileCtx) => { + if (!(await ensureBrowserRunning(profileCtx, params.res))) { + return; + } + await params.mutate(profileCtx, params.targetId); + params.res.json({ ok: true }); + }, + }); +} + +export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) { + app.get("/tabs", async (req, res) => { + await withTabsProfileRoute({ + req, + res, + ctx, + run: async (profileCtx) => { + const reachable = await profileCtx.isReachable(300); + if (!reachable) { + return res.json({ running: false, tabs: [] as unknown[] }); + } + const tabs = await profileCtx.listTabs(); + res.json({ running: true, tabs }); + }, + }); + }); + + app.post("/tabs/open", async (req, res) => { + const url = toStringOrEmpty((req.body as { url?: unknown })?.url); + if (!url) { + return jsonError(res, 400, "url is required"); + } + + await withTabsProfileRoute({ + req, + res, + ctx, + mapTabError: true, + run: async (profileCtx) => { + await assertBrowserNavigationAllowed({ + url, + ...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy), + }); + await profileCtx.ensureBrowserAvailable(); + const tab = await profileCtx.openTab(url); + res.json(tab); + }, + }); + }); + + app.post("/tabs/focus", async (req, res) => { + const targetId = parseRequiredTargetId(res, (req.body as { targetId?: unknown })?.targetId); + if (!targetId) { + return; + } + await runTabTargetMutation({ + req, + res, + ctx, + targetId, + mutate: async (profileCtx, id) => { + await profileCtx.focusTab(id); + }, + }); + }); + + app.delete("/tabs/:targetId", async (req, res) => { + const targetId = parseRequiredTargetId(res, req.params.targetId); + if (!targetId) { + return; + } + await runTabTargetMutation({ + req, + res, + ctx, + targetId, + mutate: async (profileCtx, id) => { + await profileCtx.closeTab(id); + }, + }); + }); + + app.post("/tabs/action", async (req, res) => { + const action = toStringOrEmpty((req.body as { action?: unknown })?.action); + const index = toNumber((req.body as { index?: unknown })?.index); + + await withTabsProfileRoute({ + req, + res, + ctx, + mapTabError: true, + run: async (profileCtx) => { + if (action === "list") { + const reachable = await profileCtx.isReachable(300); + if (!reachable) { + return res.json({ ok: true, tabs: [] as unknown[] }); + } + const tabs = await profileCtx.listTabs(); + return res.json({ ok: true, tabs }); + } + + if (action === "new") { + await profileCtx.ensureBrowserAvailable(); + const tab = await profileCtx.openTab("about:blank"); + return res.json({ ok: true, tab }); + } + + if (action === "close") { + const tabs = await profileCtx.listTabs(); + const target = resolveIndexedTab(tabs, index); + if (!target) { + throw new BrowserTabNotFoundError(); + } + await profileCtx.closeTab(target.targetId); + return res.json({ ok: true, targetId: target.targetId }); + } + + if (action === "select") { + if (typeof index !== "number") { + return jsonError(res, 400, "index is required"); + } + const tabs = await profileCtx.listTabs(); + const target = tabs[index]; + if (!target) { + throw new BrowserTabNotFoundError(); + } + await profileCtx.focusTab(target.targetId); + return res.json({ ok: true, targetId: target.targetId }); + } + + return jsonError(res, 400, "unknown tab action"); + }, + }); + }); +} diff --git a/extensions/browser/src/browser/routes/test-helpers.ts b/extensions/browser/src/browser/routes/test-helpers.ts new file mode 100644 index 00000000000..e6b046a9878 --- /dev/null +++ b/extensions/browser/src/browser/routes/test-helpers.ts @@ -0,0 +1,36 @@ +import type { BrowserResponse, BrowserRouteHandler, BrowserRouteRegistrar } from "./types.js"; + +export function createBrowserRouteApp() { + const getHandlers = new Map(); + const postHandlers = new Map(); + const deleteHandlers = new Map(); + const app: BrowserRouteRegistrar = { + get: (path, handler) => void getHandlers.set(path, handler), + post: (path, handler) => void postHandlers.set(path, handler), + delete: (path, handler) => void deleteHandlers.set(path, handler), + }; + return { app, getHandlers, postHandlers, deleteHandlers }; +} + +export function createBrowserRouteResponse() { + let statusCode = 200; + let jsonBody: unknown; + const res: BrowserResponse = { + status(code) { + statusCode = code; + return res; + }, + json(body) { + jsonBody = body; + }, + }; + return { + res, + get statusCode() { + return statusCode; + }, + get body() { + return jsonBody; + }, + }; +} diff --git a/extensions/browser/src/browser/routes/types.ts b/extensions/browser/src/browser/routes/types.ts new file mode 100644 index 00000000000..97d5ff470a7 --- /dev/null +++ b/extensions/browser/src/browser/routes/types.ts @@ -0,0 +1,26 @@ +export type BrowserRequest = { + params: Record; + query: Record; + 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; + +export type BrowserRouteRegistrar = { + get: (path: string, handler: BrowserRouteHandler) => void; + post: (path: string, handler: BrowserRouteHandler) => void; + delete: (path: string, handler: BrowserRouteHandler) => void; +}; diff --git a/extensions/browser/src/browser/routes/utils.ts b/extensions/browser/src/browser/routes/utils.ts new file mode 100644 index 00000000000..1c7eeb38c89 --- /dev/null +++ b/extensions/browser/src/browser/routes/utils.ts @@ -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; + 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; +} diff --git a/extensions/browser/src/browser/runtime-lifecycle.ts b/extensions/browser/src/browser/runtime-lifecycle.ts new file mode 100644 index 00000000000..7b181faea6e --- /dev/null +++ b/extensions/browser/src/browser/runtime-lifecycle.ts @@ -0,0 +1,60 @@ +import type { Server } from "node:http"; +import { isPwAiLoaded } from "./pw-ai-state.js"; +import type { BrowserServerState } from "./server-context.js"; +import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js"; + +export async function createBrowserRuntimeState(params: { + resolved: BrowserServerState["resolved"]; + port: number; + server?: Server | null; + onWarn: (message: string) => void; +}): Promise { + const state: BrowserServerState = { + server: params.server ?? null, + port: params.port, + resolved: params.resolved, + profiles: new Map(), + }; + + await ensureExtensionRelayForProfiles({ + resolved: params.resolved, + onWarn: params.onWarn, + }); + + return state; +} + +export async function stopBrowserRuntime(params: { + current: BrowserServerState | null; + getState: () => BrowserServerState | null; + clearState: () => void; + closeServer?: boolean; + onWarn: (message: string) => void; +}): Promise { + if (!params.current) { + return; + } + + await stopKnownBrowserProfiles({ + getState: params.getState, + onWarn: params.onWarn, + }); + + if (params.closeServer && params.current.server) { + await new Promise((resolve) => { + params.current?.server?.close(() => resolve()); + }); + } + + params.clearState(); + + if (!isPwAiLoaded()) { + return; + } + try { + const mod = await import("./pw-ai.js"); + await mod.closePlaywrightBrowserConnection(); + } catch { + // ignore + } +} diff --git a/extensions/browser/src/browser/safe-filename.ts b/extensions/browser/src/browser/safe-filename.ts new file mode 100644 index 00000000000..1508d528eaf --- /dev/null +++ b/extensions/browser/src/browser/safe-filename.ts @@ -0,0 +1,26 @@ +import path from "node:path"; + +export function sanitizeUntrustedFileName(fileName: string, fallbackName: string): string { + const trimmed = String(fileName ?? "").trim(); + if (!trimmed) { + return fallbackName; + } + let base = path.posix.basename(trimmed); + base = path.win32.basename(base); + let cleaned = ""; + for (let i = 0; i < base.length; i++) { + const code = base.charCodeAt(i); + if (code < 0x20 || code === 0x7f) { + continue; + } + cleaned += base[i]; + } + base = cleaned.trim(); + if (!base || base === "." || base === "..") { + return fallbackName; + } + if (base.length > 200) { + base = base.slice(0, 200); + } + return base; +} diff --git a/extensions/browser/src/browser/screenshot.ts b/extensions/browser/src/browser/screenshot.ts new file mode 100644 index 00000000000..35d39a354ed --- /dev/null +++ b/extensions/browser/src/browser/screenshot.ts @@ -0,0 +1,58 @@ +import { + buildImageResizeSideGrid, + getImageMetadata, + IMAGE_REDUCE_QUALITY_STEPS, + resizeToJpeg, +} from "../media/image-ops.js"; + +export const DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE = 2000; +export const DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES = 5 * 1024 * 1024; + +export async function normalizeBrowserScreenshot( + buffer: Buffer, + opts?: { + maxSide?: number; + maxBytes?: number; + }, +): Promise<{ buffer: Buffer; contentType?: "image/jpeg" }> { + const maxSide = Math.max(1, Math.round(opts?.maxSide ?? DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE)); + const maxBytes = Math.max(1, Math.round(opts?.maxBytes ?? DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES)); + + const meta = await getImageMetadata(buffer); + const width = Number(meta?.width ?? 0); + const height = Number(meta?.height ?? 0); + const maxDim = Math.max(width, height); + + if (buffer.byteLength <= maxBytes && (maxDim === 0 || (width <= maxSide && height <= maxSide))) { + return { buffer }; + } + + const sideStart = maxDim > 0 ? Math.min(maxSide, maxDim) : maxSide; + const sideGrid = buildImageResizeSideGrid(maxSide, sideStart); + + let smallest: { buffer: Buffer; size: number } | null = null; + + for (const side of sideGrid) { + for (const quality of IMAGE_REDUCE_QUALITY_STEPS) { + const out = await resizeToJpeg({ + buffer, + maxSide: side, + quality, + withoutEnlargement: true, + }); + + if (!smallest || out.byteLength < smallest.size) { + smallest = { buffer: out, size: out.byteLength }; + } + + if (out.byteLength <= maxBytes) { + return { buffer: out, contentType: "image/jpeg" }; + } + } + } + + const best = smallest?.buffer ?? buffer; + throw new Error( + `Browser screenshot could not be reduced below ${(maxBytes / (1024 * 1024)).toFixed(0)}MB (got ${(best.byteLength / (1024 * 1024)).toFixed(2)}MB)`, + ); +} diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts new file mode 100644 index 00000000000..783dbb9e782 --- /dev/null +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -0,0 +1,293 @@ +import fs from "node:fs"; +import { + CHROME_MCP_ATTACH_READY_POLL_MS, + CHROME_MCP_ATTACH_READY_WINDOW_MS, + PROFILE_ATTACH_RETRY_TIMEOUT_MS, + PROFILE_POST_RESTART_WS_TIMEOUT_MS, + resolveCdpReachabilityTimeouts, +} from "./cdp-timeouts.js"; +import { + closeChromeMcpSession, + ensureChromeMcpAvailable, + listChromeMcpTabs, +} from "./chrome-mcp.js"; +import { + isChromeCdpReady, + isChromeReachable, + launchOpenClawChrome, + stopOpenClawChrome, +} from "./chrome.js"; +import type { ResolvedBrowserProfile } from "./config.js"; +import { BrowserProfileUnavailableError } from "./errors.js"; +import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; +import { + CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS, + CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS, + CDP_READY_AFTER_LAUNCH_POLL_MS, + CDP_READY_AFTER_LAUNCH_WINDOW_MS, +} from "./server-context.constants.js"; +import type { + BrowserServerState, + ContextOptions, + ProfileRuntimeState, +} from "./server-context.types.js"; + +type AvailabilityDeps = { + opts: ContextOptions; + profile: ResolvedBrowserProfile; + state: () => BrowserServerState; + getProfileState: () => ProfileRuntimeState; + setProfileRunning: (running: ProfileRuntimeState["running"]) => void; +}; + +type AvailabilityOps = { + isHttpReachable: (timeoutMs?: number) => Promise; + isReachable: (timeoutMs?: number) => Promise; + ensureBrowserAvailable: () => Promise; + stopRunningBrowser: () => Promise<{ stopped: boolean }>; +}; + +export function createProfileAvailability({ + opts, + profile, + state, + getProfileState, + setProfileRunning, +}: AvailabilityDeps): AvailabilityOps { + const capabilities = getBrowserProfileCapabilities(profile); + const resolveTimeouts = (timeoutMs: number | undefined) => + resolveCdpReachabilityTimeouts({ + profileIsLoopback: profile.cdpIsLoopback, + timeoutMs, + remoteHttpTimeoutMs: state().resolved.remoteCdpTimeoutMs, + remoteHandshakeTimeoutMs: state().resolved.remoteCdpHandshakeTimeoutMs, + }); + + const isReachable = async (timeoutMs?: number) => { + if (capabilities.usesChromeMcp) { + // listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required + await listChromeMcpTabs(profile.name, profile.userDataDir); + return true; + } + const { httpTimeoutMs, wsTimeoutMs } = resolveTimeouts(timeoutMs); + return await isChromeCdpReady( + profile.cdpUrl, + httpTimeoutMs, + wsTimeoutMs, + state().resolved.ssrfPolicy, + ); + }; + + const isHttpReachable = async (timeoutMs?: number) => { + if (capabilities.usesChromeMcp) { + return await isReachable(timeoutMs); + } + const { httpTimeoutMs } = resolveTimeouts(timeoutMs); + return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, state().resolved.ssrfPolicy); + }; + + const attachRunning = (running: NonNullable) => { + setProfileRunning(running); + running.proc.on("exit", () => { + // Guard against server teardown (e.g., SIGUSR1 restart) + if (!opts.getState()) { + return; + } + const profileState = getProfileState(); + if (profileState.running?.pid === running.pid) { + setProfileRunning(null); + } + }); + }; + + const closePlaywrightBrowserConnectionForProfile = async (cdpUrl?: string): Promise => { + try { + const mod = await import("./pw-ai.js"); + await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined); + } catch { + // ignore + } + }; + + const reconcileProfileRuntime = async (): Promise => { + const profileState = getProfileState(); + const reconcile = profileState.reconcile; + if (!reconcile) { + return; + } + profileState.reconcile = null; + profileState.lastTargetId = null; + + const previousProfile = reconcile.previousProfile; + if (profileState.running) { + await stopOpenClawChrome(profileState.running).catch(() => {}); + setProfileRunning(null); + } + if (getBrowserProfileCapabilities(previousProfile).usesChromeMcp) { + await closeChromeMcpSession(previousProfile.name).catch(() => false); + } + await closePlaywrightBrowserConnectionForProfile(previousProfile.cdpUrl); + if (previousProfile.cdpUrl !== profile.cdpUrl) { + await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl); + } + }; + + const waitForCdpReadyAfterLaunch = async (): Promise => { + // launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS. + // If a follow-up call races ahead, we can hit PortInUseError trying to launch again on the same port. + const deadlineMs = Date.now() + CDP_READY_AFTER_LAUNCH_WINDOW_MS; + while (Date.now() < deadlineMs) { + const remainingMs = Math.max(0, deadlineMs - Date.now()); + // Keep each attempt short; loopback profiles derive a WS timeout from this value. + const attemptTimeoutMs = Math.max( + CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS, + Math.min(CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS, remainingMs), + ); + if (await isReachable(attemptTimeoutMs)) { + return; + } + await new Promise((r) => setTimeout(r, CDP_READY_AFTER_LAUNCH_POLL_MS)); + } + throw new Error( + `Chrome CDP websocket for profile "${profile.name}" is not reachable after start.`, + ); + }; + + const waitForChromeMcpReadyAfterAttach = async (): Promise => { + const deadlineMs = Date.now() + CHROME_MCP_ATTACH_READY_WINDOW_MS; + let lastError: unknown; + while (Date.now() < deadlineMs) { + try { + await listChromeMcpTabs(profile.name, profile.userDataDir); + return; + } catch (err) { + lastError = err; + } + await new Promise((r) => setTimeout(r, CHROME_MCP_ATTACH_READY_POLL_MS)); + } + const detail = lastError instanceof Error ? ` Last error: ${lastError.message}` : ""; + throw new BrowserProfileUnavailableError( + `Chrome MCP existing-session attach for profile "${profile.name}" timed out waiting for tabs to become available.` + + ` Approve the browser attach prompt, keep the browser open, and retry.${detail}`, + ); + }; + + const ensureBrowserAvailable = async (): Promise => { + await reconcileProfileRuntime(); + if (capabilities.usesChromeMcp) { + if (profile.userDataDir && !fs.existsSync(profile.userDataDir)) { + throw new BrowserProfileUnavailableError( + `Browser user data directory not found for profile "${profile.name}": ${profile.userDataDir}`, + ); + } + await ensureChromeMcpAvailable(profile.name, profile.userDataDir); + await waitForChromeMcpReadyAfterAttach(); + return; + } + const current = state(); + const remoteCdp = capabilities.isRemote; + const attachOnly = profile.attachOnly; + const profileState = getProfileState(); + const httpReachable = await isHttpReachable(); + + if (!httpReachable) { + if ((attachOnly || remoteCdp) && opts.onEnsureAttachTarget) { + await opts.onEnsureAttachTarget(profile); + if (await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS)) { + return; + } + } + // Browser control service can restart while a loopback OpenClaw browser is still + // alive. Give that pre-existing browser one longer probe window before falling + // back to local executable resolution. + if (!attachOnly && !remoteCdp && profile.cdpIsLoopback && !profileState.running) { + if ( + (await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS)) && + (await isReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS)) + ) { + return; + } + } + if (attachOnly || remoteCdp) { + throw new BrowserProfileUnavailableError( + remoteCdp + ? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.` + : `Browser attachOnly is enabled and profile "${profile.name}" is not running.`, + ); + } + const launched = await launchOpenClawChrome(current.resolved, profile); + attachRunning(launched); + try { + await waitForCdpReadyAfterLaunch(); + } catch (err) { + await stopOpenClawChrome(launched).catch(() => {}); + setProfileRunning(null); + throw err; + } + return; + } + + // Port is reachable - check if we own it. + if (await isReachable()) { + return; + } + + // HTTP responds but WebSocket fails. For attachOnly/remote profiles, never perform + // local ownership/restart handling; just run attach retries and surface attach errors. + if (attachOnly || remoteCdp) { + if (opts.onEnsureAttachTarget) { + await opts.onEnsureAttachTarget(profile); + if (await isReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS)) { + return; + } + } + throw new BrowserProfileUnavailableError( + remoteCdp + ? `Remote CDP websocket for profile "${profile.name}" is not reachable.` + : `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`, + ); + } + + // HTTP responds but WebSocket fails - port in use by something else. + if (!profileState.running) { + throw new BrowserProfileUnavailableError( + `Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. ` + + `Run action=reset-profile profile=${profile.name} to kill the process.`, + ); + } + + await stopOpenClawChrome(profileState.running); + setProfileRunning(null); + + const relaunched = await launchOpenClawChrome(current.resolved, profile); + attachRunning(relaunched); + + if (!(await isReachable(PROFILE_POST_RESTART_WS_TIMEOUT_MS))) { + throw new Error( + `Chrome CDP websocket for profile "${profile.name}" is not reachable after restart.`, + ); + } + }; + + const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => { + await reconcileProfileRuntime(); + if (capabilities.usesChromeMcp) { + const stopped = await closeChromeMcpSession(profile.name); + return { stopped }; + } + const profileState = getProfileState(); + if (!profileState.running) { + return { stopped: false }; + } + await stopOpenClawChrome(profileState.running); + setProfileRunning(null); + return { stopped: true }; + }; + + return { + isHttpReachable, + isReachable, + ensureBrowserAvailable, + stopRunningBrowser, + }; +} diff --git a/extensions/browser/src/browser/server-context.chrome-test-harness.ts b/extensions/browser/src/browser/server-context.chrome-test-harness.ts new file mode 100644 index 00000000000..95ebe8097e6 --- /dev/null +++ b/extensions/browser/src/browser/server-context.chrome-test-harness.ts @@ -0,0 +1,15 @@ +import { vi } from "vitest"; +import { installChromeUserDataDirHooks } from "./chrome-user-data-dir.test-harness.js"; + +const chromeUserDataDir = { dir: "/tmp/openclaw" }; +installChromeUserDataDirHooks(chromeUserDataDir); + +vi.mock("./chrome.js", () => ({ + isChromeCdpReady: vi.fn(async () => true), + isChromeReachable: vi.fn(async () => true), + launchOpenClawChrome: vi.fn(async () => { + throw new Error("unexpected launch"); + }), + resolveOpenClawUserDataDir: vi.fn(() => chromeUserDataDir.dir), + stopOpenClawChrome: vi.fn(async () => {}), +})); diff --git a/extensions/browser/src/browser/server-context.constants.ts b/extensions/browser/src/browser/server-context.constants.ts new file mode 100644 index 00000000000..9026aba537f --- /dev/null +++ b/extensions/browser/src/browser/server-context.constants.ts @@ -0,0 +1,9 @@ +export const MANAGED_BROWSER_PAGE_TAB_LIMIT = 8; + +export const OPEN_TAB_DISCOVERY_WINDOW_MS = 2000; +export const OPEN_TAB_DISCOVERY_POLL_MS = 100; + +export const CDP_READY_AFTER_LAUNCH_WINDOW_MS = 8000; +export const CDP_READY_AFTER_LAUNCH_POLL_MS = 100; +export const CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS = 75; +export const CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS = 250; diff --git a/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts b/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts new file mode 100644 index 00000000000..c5f65a4ce2a --- /dev/null +++ b/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts @@ -0,0 +1,107 @@ +import { vi } from "vitest"; +import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; +import type { BrowserServerState } from "./server-context.js"; +import { createBrowserRouteContext } from "./server-context.js"; + +export const originalFetch = globalThis.fetch; + +export function makeState( + profile: "remote" | "openclaw", +): BrowserServerState & { profiles: Map } { + return { + // oxlint-disable-next-line typescript/no-explicit-any + server: null as any, + port: 0, + resolved: { + enabled: true, + controlPort: 18791, + cdpPortRangeStart: 18800, + cdpPortRangeEnd: 18899, + cdpProtocol: profile === "remote" ? "https" : "http", + cdpHost: profile === "remote" ? "browserless.example" : "127.0.0.1", + cdpIsLoopback: profile !== "remote", + remoteCdpTimeoutMs: 1500, + remoteCdpHandshakeTimeoutMs: 3000, + evaluateEnabled: false, + extraArgs: [], + color: "#FF4500", + headless: true, + noSandbox: false, + attachOnly: false, + ssrfPolicy: { allowPrivateNetwork: true }, + defaultProfile: profile, + profiles: { + remote: { + cdpUrl: "https://browserless.example/chrome?token=abc", + cdpPort: 443, + color: "#00AA00", + }, + openclaw: { cdpPort: 18800, color: "#FF4500" }, + }, + }, + profiles: new Map(), + }; +} + +export function makeUnexpectedFetchMock() { + return vi.fn(async () => { + throw new Error("unexpected fetch"); + }); +} + +export function createRemoteRouteHarness(fetchMock?: (url: unknown) => Promise) { + const activeFetchMock = fetchMock ?? makeUnexpectedFetchMock(); + global.fetch = withFetchPreconnect(activeFetchMock); + const state = makeState("remote"); + const ctx = createBrowserRouteContext({ getState: () => state }); + return { state, remote: ctx.forProfile("remote"), fetchMock: activeFetchMock }; +} + +export function createSequentialPageLister(responses: T[]) { + return async () => { + const next = responses.shift(); + if (!next) { + throw new Error("no more responses"); + } + return next; + }; +} + +type JsonListEntry = { + id: string; + title: string; + url: string; + webSocketDebuggerUrl: string; + type: "page"; +}; + +export function createJsonListFetchMock(entries: JsonListEntry[]) { + return async (url: unknown) => { + const u = String(url); + if (!u.includes("/json/list")) { + throw new Error(`unexpected fetch: ${u}`); + } + return { + ok: true, + json: async () => entries, + } as unknown as Response; + }; +} + +function makeManagedTab(id: string, ordinal: number): JsonListEntry { + return { + id, + title: String(ordinal), + url: `http://127.0.0.1:300${ordinal}`, + webSocketDebuggerUrl: `ws://127.0.0.1/devtools/page/${id}`, + type: "page", + }; +} + +export function makeManagedTabsWithNew(params?: { newFirst?: boolean }): JsonListEntry[] { + const oldTabs = Array.from({ length: 8 }, (_, index) => + makeManagedTab(`OLD${index + 1}`, index + 1), + ); + const newTab = makeManagedTab("NEW", 9); + return params?.newFirst ? [newTab, ...oldTabs] : [...oldTabs, newTab]; +} diff --git a/extensions/browser/src/browser/server-context.reset.ts b/extensions/browser/src/browser/server-context.reset.ts new file mode 100644 index 00000000000..ea478f56f31 --- /dev/null +++ b/extensions/browser/src/browser/server-context.reset.ts @@ -0,0 +1,67 @@ +import fs from "node:fs"; +import type { ResolvedBrowserProfile } from "./config.js"; +import { BrowserResetUnsupportedError } from "./errors.js"; +import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; +import type { ProfileRuntimeState } from "./server-context.types.js"; +import { movePathToTrash } from "./trash.js"; + +type ResetDeps = { + profile: ResolvedBrowserProfile; + getProfileState: () => ProfileRuntimeState; + stopRunningBrowser: () => Promise<{ stopped: boolean }>; + isHttpReachable: (timeoutMs?: number) => Promise; + resolveOpenClawUserDataDir: (profileName: string) => string; +}; + +type ResetOps = { + resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>; +}; + +async function closePlaywrightBrowserConnectionForProfile(cdpUrl?: string): Promise { + try { + const mod = await import("./pw-ai.js"); + await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined); + } catch { + // ignore + } +} + +export function createProfileResetOps({ + profile, + getProfileState, + stopRunningBrowser, + isHttpReachable, + resolveOpenClawUserDataDir, +}: ResetDeps): ResetOps { + const capabilities = getBrowserProfileCapabilities(profile); + const resetProfile = async () => { + if (!capabilities.supportsReset) { + throw new BrowserResetUnsupportedError( + `reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`, + ); + } + + const userDataDir = resolveOpenClawUserDataDir(profile.name); + const profileState = getProfileState(); + const httpReachable = await isHttpReachable(300); + if (httpReachable && !profileState.running) { + // Port in use but not by us - kill it. + await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl); + } + + if (profileState.running) { + await stopRunningBrowser(); + } + + await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl); + + if (!fs.existsSync(userDataDir)) { + return { moved: false, from: userDataDir }; + } + + const moved = await movePathToTrash(userDataDir); + return { moved: true, from: userDataDir, to: moved }; + }; + + return { resetProfile }; +} diff --git a/extensions/browser/src/browser/server-context.selection.ts b/extensions/browser/src/browser/server-context.selection.ts new file mode 100644 index 00000000000..24248cebfd8 --- /dev/null +++ b/extensions/browser/src/browser/server-context.selection.ts @@ -0,0 +1,153 @@ +import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js"; +import { appendCdpPath } from "./cdp.js"; +import { closeChromeMcpTab, focusChromeMcpTab } from "./chrome-mcp.js"; +import type { ResolvedBrowserProfile } from "./config.js"; +import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.js"; +import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; +import type { PwAiModule } from "./pw-ai-module.js"; +import { getPwAiModule } from "./pw-ai-module.js"; +import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js"; +import { resolveTargetIdFromTabs } from "./target-id.js"; + +type SelectionDeps = { + profile: ResolvedBrowserProfile; + getProfileState: () => ProfileRuntimeState; + ensureBrowserAvailable: () => Promise; + listTabs: () => Promise; + openTab: (url: string) => Promise; +}; + +type SelectionOps = { + ensureTabAvailable: (targetId?: string) => Promise; + focusTab: (targetId: string) => Promise; + closeTab: (targetId: string) => Promise; +}; + +export function createProfileSelectionOps({ + profile, + getProfileState, + ensureBrowserAvailable, + listTabs, + openTab, +}: SelectionDeps): SelectionOps { + const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl); + const capabilities = getBrowserProfileCapabilities(profile); + + const ensureTabAvailable = async (targetId?: string): Promise => { + await ensureBrowserAvailable(); + const profileState = getProfileState(); + const tabs1 = await listTabs(); + if (tabs1.length === 0) { + await openTab("about:blank"); + } + + const tabs = await listTabs(); + const candidates = capabilities.supportsPerTabWs ? tabs.filter((t) => Boolean(t.wsUrl)) : tabs; + + const resolveById = (raw: string) => { + const resolved = resolveTargetIdFromTabs(raw, candidates); + if (!resolved.ok) { + if (resolved.reason === "ambiguous") { + return "AMBIGUOUS" as const; + } + return null; + } + return candidates.find((t) => t.targetId === resolved.targetId) ?? null; + }; + + const pickDefault = () => { + const last = profileState.lastTargetId?.trim() || ""; + const lastResolved = last ? resolveById(last) : null; + if (lastResolved && lastResolved !== "AMBIGUOUS") { + return lastResolved; + } + // Prefer a real page tab first (avoid service workers/background targets). + const page = candidates.find((t) => (t.type ?? "page") === "page"); + return page ?? candidates.at(0) ?? null; + }; + + const chosen = targetId ? resolveById(targetId) : pickDefault(); + + if (chosen === "AMBIGUOUS") { + throw new BrowserTargetAmbiguousError(); + } + if (!chosen) { + throw new BrowserTabNotFoundError(); + } + profileState.lastTargetId = chosen.targetId; + return chosen; + }; + + const resolveTargetIdOrThrow = async (targetId: string): Promise => { + const tabs = await listTabs(); + const resolved = resolveTargetIdFromTabs(targetId, tabs); + if (!resolved.ok) { + if (resolved.reason === "ambiguous") { + throw new BrowserTargetAmbiguousError(); + } + throw new BrowserTabNotFoundError(); + } + return resolved.targetId; + }; + + const focusTab = async (targetId: string): Promise => { + const resolvedTargetId = await resolveTargetIdOrThrow(targetId); + + if (capabilities.usesChromeMcp) { + await focusChromeMcpTab(profile.name, resolvedTargetId, profile.userDataDir); + const profileState = getProfileState(); + profileState.lastTargetId = resolvedTargetId; + return; + } + + if (capabilities.usesPersistentPlaywright) { + const mod = await getPwAiModule({ mode: "strict" }); + const focusPageByTargetIdViaPlaywright = (mod as Partial | null) + ?.focusPageByTargetIdViaPlaywright; + if (typeof focusPageByTargetIdViaPlaywright === "function") { + await focusPageByTargetIdViaPlaywright({ + cdpUrl: profile.cdpUrl, + targetId: resolvedTargetId, + }); + const profileState = getProfileState(); + profileState.lastTargetId = resolvedTargetId; + return; + } + } + + await fetchOk(appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`)); + const profileState = getProfileState(); + profileState.lastTargetId = resolvedTargetId; + }; + + const closeTab = async (targetId: string): Promise => { + const resolvedTargetId = await resolveTargetIdOrThrow(targetId); + + if (capabilities.usesChromeMcp) { + await closeChromeMcpTab(profile.name, resolvedTargetId, profile.userDataDir); + return; + } + + // For remote profiles, use Playwright's persistent connection to close tabs + if (capabilities.usesPersistentPlaywright) { + const mod = await getPwAiModule({ mode: "strict" }); + const closePageByTargetIdViaPlaywright = (mod as Partial | null) + ?.closePageByTargetIdViaPlaywright; + if (typeof closePageByTargetIdViaPlaywright === "function") { + await closePageByTargetIdViaPlaywright({ + cdpUrl: profile.cdpUrl, + targetId: resolvedTargetId, + }); + return; + } + } + + await fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`)); + }; + + return { + ensureTabAvailable, + focusTab, + closeTab, + }; +} diff --git a/extensions/browser/src/browser/server-context.tab-ops.ts b/extensions/browser/src/browser/server-context.tab-ops.ts new file mode 100644 index 00000000000..747082a7ff5 --- /dev/null +++ b/extensions/browser/src/browser/server-context.tab-ops.ts @@ -0,0 +1,243 @@ +import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js"; +import { fetchJson, fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js"; +import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js"; +import { listChromeMcpTabs, openChromeMcpTab } from "./chrome-mcp.js"; +import type { ResolvedBrowserProfile } from "./config.js"; +import { + assertBrowserNavigationAllowed, + assertBrowserNavigationResultAllowed, + InvalidBrowserNavigationUrlError, + requiresInspectableBrowserNavigationRedirects, + withBrowserNavigationPolicy, +} from "./navigation-guard.js"; +import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; +import type { PwAiModule } from "./pw-ai-module.js"; +import { getPwAiModule } from "./pw-ai-module.js"; +import { + MANAGED_BROWSER_PAGE_TAB_LIMIT, + OPEN_TAB_DISCOVERY_POLL_MS, + OPEN_TAB_DISCOVERY_WINDOW_MS, +} from "./server-context.constants.js"; +import type { + BrowserServerState, + BrowserTab, + ProfileRuntimeState, +} from "./server-context.types.js"; + +type TabOpsDeps = { + profile: ResolvedBrowserProfile; + state: () => BrowserServerState; + getProfileState: () => ProfileRuntimeState; +}; + +type ProfileTabOps = { + listTabs: () => Promise; + openTab: (url: string) => Promise; +}; + +/** + * Normalize a CDP WebSocket URL to use the correct base URL. + */ +function normalizeWsUrl(raw: string | undefined, cdpBaseUrl: string): string | undefined { + if (!raw) { + return undefined; + } + try { + return normalizeCdpWsUrl(raw, cdpBaseUrl); + } catch { + return raw; + } +} + +type CdpTarget = { + id?: string; + title?: string; + url?: string; + webSocketDebuggerUrl?: string; + type?: string; +}; + +export function createProfileTabOps({ + profile, + state, + getProfileState, +}: TabOpsDeps): ProfileTabOps { + const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl); + const capabilities = getBrowserProfileCapabilities(profile); + + const listTabs = async (): Promise => { + if (capabilities.usesChromeMcp) { + return await listChromeMcpTabs(profile.name, profile.userDataDir); + } + + if (capabilities.usesPersistentPlaywright) { + const mod = await getPwAiModule({ mode: "strict" }); + const listPagesViaPlaywright = (mod as Partial | null)?.listPagesViaPlaywright; + if (typeof listPagesViaPlaywright === "function") { + const pages = await listPagesViaPlaywright({ cdpUrl: profile.cdpUrl }); + return pages.map((p) => ({ + targetId: p.targetId, + title: p.title, + url: p.url, + type: p.type, + })); + } + } + + const raw = await fetchJson< + Array<{ + id?: string; + title?: string; + url?: string; + webSocketDebuggerUrl?: string; + type?: string; + }> + >(appendCdpPath(cdpHttpBase, "/json/list")); + return raw + .map((t) => ({ + targetId: t.id ?? "", + title: t.title ?? "", + url: t.url ?? "", + wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl, profile.cdpUrl), + type: t.type, + })) + .filter((t) => Boolean(t.targetId)); + }; + + const enforceManagedTabLimit = async (keepTargetId: string): Promise => { + const profileState = getProfileState(); + if ( + !capabilities.supportsManagedTabLimit || + state().resolved.attachOnly || + !profileState.running + ) { + return; + } + + const pageTabs = await listTabs() + .then((tabs) => tabs.filter((tab) => (tab.type ?? "page") === "page")) + .catch(() => [] as BrowserTab[]); + if (pageTabs.length <= MANAGED_BROWSER_PAGE_TAB_LIMIT) { + return; + } + + const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId); + const excessCount = pageTabs.length - MANAGED_BROWSER_PAGE_TAB_LIMIT; + for (const tab of candidates.slice(0, excessCount)) { + void fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${tab.targetId}`)).catch(() => { + // best-effort cleanup only + }); + } + }; + + const triggerManagedTabLimit = (keepTargetId: string): void => { + void enforceManagedTabLimit(keepTargetId).catch(() => { + // best-effort cleanup only + }); + }; + + const openTab = async (url: string): Promise => { + const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy); + + if (capabilities.usesChromeMcp) { + await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); + const page = await openChromeMcpTab(profile.name, url, profile.userDataDir); + const profileState = getProfileState(); + profileState.lastTargetId = page.targetId; + await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts }); + return page; + } + + if (capabilities.usesPersistentPlaywright) { + const mod = await getPwAiModule({ mode: "strict" }); + const createPageViaPlaywright = (mod as Partial | null)?.createPageViaPlaywright; + if (typeof createPageViaPlaywright === "function") { + const page = await createPageViaPlaywright({ + cdpUrl: profile.cdpUrl, + url, + ...ssrfPolicyOpts, + }); + const profileState = getProfileState(); + profileState.lastTargetId = page.targetId; + triggerManagedTabLimit(page.targetId); + return { + targetId: page.targetId, + title: page.title, + url: page.url, + type: page.type, + }; + } + } + + if (requiresInspectableBrowserNavigationRedirects(state().resolved.ssrfPolicy)) { + throw new InvalidBrowserNavigationUrlError( + "Navigation blocked: strict browser SSRF policy requires Playwright-backed redirect-hop inspection", + ); + } + + const createdViaCdp = await createTargetViaCdp({ + cdpUrl: profile.cdpUrl, + url, + ...ssrfPolicyOpts, + }) + .then((r) => r.targetId) + .catch(() => null); + + if (createdViaCdp) { + const profileState = getProfileState(); + profileState.lastTargetId = createdViaCdp; + const deadline = Date.now() + OPEN_TAB_DISCOVERY_WINDOW_MS; + while (Date.now() < deadline) { + const tabs = await listTabs().catch(() => [] as BrowserTab[]); + const found = tabs.find((t) => t.targetId === createdViaCdp); + if (found) { + await assertBrowserNavigationResultAllowed({ url: found.url, ...ssrfPolicyOpts }); + triggerManagedTabLimit(found.targetId); + return found; + } + await new Promise((r) => setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS)); + } + triggerManagedTabLimit(createdViaCdp); + return { targetId: createdViaCdp, title: "", url, type: "page" }; + } + + const encoded = encodeURIComponent(url); + const endpointUrl = new URL(appendCdpPath(cdpHttpBase, "/json/new")); + await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); + const endpoint = endpointUrl.search + ? (() => { + endpointUrl.searchParams.set("url", url); + return endpointUrl.toString(); + })() + : `${endpointUrl.toString()}?${encoded}`; + const created = await fetchJson(endpoint, CDP_JSON_NEW_TIMEOUT_MS, { + method: "PUT", + }).catch(async (err) => { + if (String(err).includes("HTTP 405")) { + return await fetchJson(endpoint, CDP_JSON_NEW_TIMEOUT_MS); + } + throw err; + }); + + if (!created.id) { + throw new Error("Failed to open tab (missing id)"); + } + const profileState = getProfileState(); + profileState.lastTargetId = created.id; + const resolvedUrl = created.url ?? url; + await assertBrowserNavigationResultAllowed({ url: resolvedUrl, ...ssrfPolicyOpts }); + triggerManagedTabLimit(created.id); + return { + targetId: created.id, + title: created.title ?? "", + url: resolvedUrl, + wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl), + type: created.type, + }; + }; + + return { + listTabs, + openTab, + }; +} diff --git a/extensions/browser/src/browser/server-context.ts b/extensions/browser/src/browser/server-context.ts new file mode 100644 index 00000000000..5b06a49964e --- /dev/null +++ b/extensions/browser/src/browser/server-context.ts @@ -0,0 +1,258 @@ +import { SsrFBlockedError } from "../infra/net/ssrf.js"; +import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js"; +import type { ResolvedBrowserProfile } from "./config.js"; +import { resolveProfile } from "./config.js"; +import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js"; +import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; +import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; +import { + refreshResolvedBrowserConfigFromDisk, + resolveBrowserProfileWithHotReload, +} from "./resolved-config-refresh.js"; +import { createProfileAvailability } from "./server-context.availability.js"; +import { createProfileResetOps } from "./server-context.reset.js"; +import { createProfileSelectionOps } from "./server-context.selection.js"; +import { createProfileTabOps } from "./server-context.tab-ops.js"; +import type { + BrowserServerState, + BrowserRouteContext, + BrowserTab, + ContextOptions, + ProfileContext, + ProfileRuntimeState, + ProfileStatus, +} from "./server-context.types.js"; + +export type { + BrowserRouteContext, + BrowserServerState, + BrowserTab, + ProfileContext, + ProfileRuntimeState, + ProfileStatus, +} from "./server-context.types.js"; + +export function listKnownProfileNames(state: BrowserServerState): string[] { + const names = new Set(Object.keys(state.resolved.profiles)); + for (const name of state.profiles.keys()) { + names.add(name); + } + return [...names]; +} + +/** + * Create a profile-scoped context for browser operations. + */ +function createProfileContext( + opts: ContextOptions, + profile: ResolvedBrowserProfile, +): ProfileContext { + const state = () => { + const current = opts.getState(); + if (!current) { + throw new Error("Browser server not started"); + } + return current; + }; + + const getProfileState = (): ProfileRuntimeState => { + const current = state(); + let profileState = current.profiles.get(profile.name); + if (!profileState) { + profileState = { profile, running: null, lastTargetId: null, reconcile: null }; + current.profiles.set(profile.name, profileState); + } + return profileState; + }; + + const setProfileRunning = (running: ProfileRuntimeState["running"]) => { + const profileState = getProfileState(); + profileState.running = running; + }; + + const { listTabs, openTab } = createProfileTabOps({ + profile, + state, + getProfileState, + }); + + const { ensureBrowserAvailable, isHttpReachable, isReachable, stopRunningBrowser } = + createProfileAvailability({ + opts, + profile, + state, + getProfileState, + setProfileRunning, + }); + + const { ensureTabAvailable, focusTab, closeTab } = createProfileSelectionOps({ + profile, + getProfileState, + ensureBrowserAvailable, + listTabs, + openTab, + }); + + const { resetProfile } = createProfileResetOps({ + profile, + getProfileState, + stopRunningBrowser, + isHttpReachable, + resolveOpenClawUserDataDir, + }); + + return { + profile, + ensureBrowserAvailable, + ensureTabAvailable, + isHttpReachable, + isReachable, + listTabs, + openTab, + focusTab, + closeTab, + stopRunningBrowser, + resetProfile, + }; +} + +export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteContext { + const refreshConfigFromDisk = opts.refreshConfigFromDisk === true; + + const state = () => { + const current = opts.getState(); + if (!current) { + throw new Error("Browser server not started"); + } + return current; + }; + + const forProfile = (profileName?: string): ProfileContext => { + const current = state(); + const name = profileName ?? current.resolved.defaultProfile; + const profile = resolveBrowserProfileWithHotReload({ + current, + refreshConfigFromDisk, + name, + }); + + if (!profile) { + const available = Object.keys(current.resolved.profiles).join(", "); + throw new BrowserProfileNotFoundError( + `Profile "${name}" not found. Available profiles: ${available || "(none)"}`, + ); + } + return createProfileContext(opts, profile); + }; + + const listProfiles = async (): Promise => { + const current = state(); + refreshResolvedBrowserConfigFromDisk({ + current, + refreshConfigFromDisk, + mode: "cached", + }); + const result: ProfileStatus[] = []; + + for (const name of listKnownProfileNames(current)) { + const profileState = current.profiles.get(name); + const profile = resolveProfile(current.resolved, name) ?? profileState?.profile; + if (!profile) { + continue; + } + const capabilities = getBrowserProfileCapabilities(profile); + + let tabCount = 0; + let running = false; + const profileCtx = createProfileContext(opts, profile); + + if (capabilities.usesChromeMcp) { + try { + running = await profileCtx.isReachable(300); + if (running) { + const tabs = await profileCtx.listTabs(); + tabCount = tabs.filter((t) => t.type === "page").length; + } + } catch { + // Chrome MCP not available + } + } else if (profileState?.running) { + running = true; + try { + const tabs = await profileCtx.listTabs(); + tabCount = tabs.filter((t) => t.type === "page").length; + } catch { + // Browser might not be responsive + } + } else { + // Check if something is listening on the port + try { + const reachable = await isChromeReachable( + profile.cdpUrl, + 200, + current.resolved.ssrfPolicy, + ); + if (reachable) { + running = true; + const tabs = await profileCtx.listTabs().catch(() => []); + tabCount = tabs.filter((t) => t.type === "page").length; + } + } catch { + // Not reachable + } + } + + result.push({ + name, + transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp", + cdpPort: capabilities.usesChromeMcp ? null : profile.cdpPort, + cdpUrl: capabilities.usesChromeMcp ? null : profile.cdpUrl, + color: profile.color, + driver: profile.driver, + running, + tabCount, + isDefault: name === current.resolved.defaultProfile, + isRemote: !profile.cdpIsLoopback, + missingFromConfig: !(name in current.resolved.profiles) || undefined, + reconcileReason: profileState?.reconcile?.reason ?? null, + }); + } + + return result; + }; + + // Create default profile context for backward compatibility + const getDefaultContext = () => forProfile(); + + const mapTabError = (err: unknown) => { + const browserMapped = toBrowserErrorResponse(err); + if (browserMapped) { + return browserMapped; + } + if (err instanceof SsrFBlockedError) { + return { status: 400, message: err.message }; + } + if (err instanceof InvalidBrowserNavigationUrlError) { + return { status: 400, message: err.message }; + } + return null; + }; + + return { + state, + forProfile, + listProfiles, + // Legacy methods delegate to default profile + ensureBrowserAvailable: () => getDefaultContext().ensureBrowserAvailable(), + ensureTabAvailable: (targetId) => getDefaultContext().ensureTabAvailable(targetId), + isHttpReachable: (timeoutMs) => getDefaultContext().isHttpReachable(timeoutMs), + isReachable: (timeoutMs) => getDefaultContext().isReachable(timeoutMs), + listTabs: () => getDefaultContext().listTabs(), + openTab: (url) => getDefaultContext().openTab(url), + focusTab: (targetId) => getDefaultContext().focusTab(targetId), + closeTab: (targetId) => getDefaultContext().closeTab(targetId), + stopRunningBrowser: () => getDefaultContext().stopRunningBrowser(), + resetProfile: () => getDefaultContext().resetProfile(), + mapTabError, + }; +} diff --git a/extensions/browser/src/browser/server-context.types.ts b/extensions/browser/src/browser/server-context.types.ts new file mode 100644 index 00000000000..b8ad7aa329d --- /dev/null +++ b/extensions/browser/src/browser/server-context.types.ts @@ -0,0 +1,74 @@ +import type { Server } from "node:http"; +import type { RunningChrome } from "./chrome.js"; +import type { BrowserTransport } from "./client.js"; +import type { BrowserTab } from "./client.js"; +import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js"; + +export type { BrowserTab }; + +/** + * Runtime state for a single profile's Chrome instance. + */ +export type ProfileRuntimeState = { + profile: ResolvedBrowserProfile; + running: RunningChrome | null; + /** Sticky tab selection when callers omit targetId (keeps snapshot+act consistent). */ + lastTargetId?: string | null; + reconcile?: { + previousProfile: ResolvedBrowserProfile; + reason: string; + } | null; +}; + +export type BrowserServerState = { + server?: Server | null; + port: number; + resolved: ResolvedBrowserConfig; + profiles: Map; +}; + +type BrowserProfileActions = { + ensureBrowserAvailable: () => Promise; + ensureTabAvailable: (targetId?: string) => Promise; + isHttpReachable: (timeoutMs?: number) => Promise; + isReachable: (timeoutMs?: number) => Promise; + listTabs: () => Promise; + openTab: (url: string) => Promise; + focusTab: (targetId: string) => Promise; + closeTab: (targetId: string) => Promise; + stopRunningBrowser: () => Promise<{ stopped: boolean }>; + resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>; +}; + +export type BrowserRouteContext = { + state: () => BrowserServerState; + forProfile: (profileName?: string) => ProfileContext; + listProfiles: () => Promise; + // Legacy methods delegate to default profile for backward compatibility + mapTabError: (err: unknown) => { status: number; message: string } | null; +} & BrowserProfileActions; + +export type ProfileContext = { + profile: ResolvedBrowserProfile; +} & BrowserProfileActions; + +export type ProfileStatus = { + name: string; + transport: BrowserTransport; + cdpPort: number | null; + cdpUrl: string | null; + color: string; + driver: ResolvedBrowserProfile["driver"]; + running: boolean; + tabCount: number; + isDefault: boolean; + isRemote: boolean; + missingFromConfig?: boolean; + reconcileReason?: string | null; +}; + +export type ContextOptions = { + getState: () => BrowserServerState | null; + onEnsureAttachTarget?: (profile: ResolvedBrowserProfile) => Promise; + refreshConfigFromDisk?: boolean; +}; diff --git a/extensions/browser/src/browser/server-lifecycle.ts b/extensions/browser/src/browser/server-lifecycle.ts new file mode 100644 index 00000000000..1dd322f2bc9 --- /dev/null +++ b/extensions/browser/src/browser/server-lifecycle.ts @@ -0,0 +1,47 @@ +import { stopOpenClawChrome } from "./chrome.js"; +import type { ResolvedBrowserConfig } from "./config.js"; +import { + type BrowserServerState, + createBrowserRouteContext, + listKnownProfileNames, +} from "./server-context.js"; + +export async function ensureExtensionRelayForProfiles(_params: { + resolved: ResolvedBrowserConfig; + onWarn: (message: string) => void; +}) { + // Intentional no-op: the Chrome extension relay path has been removed. + // runtime-lifecycle still calls this helper, so keep the stub until the next + // breaking cleanup rather than changing the call graph in a patch release. +} + +export async function stopKnownBrowserProfiles(params: { + getState: () => BrowserServerState | null; + onWarn: (message: string) => void; +}) { + const current = params.getState(); + if (!current) { + return; + } + const ctx = createBrowserRouteContext({ + getState: params.getState, + refreshConfigFromDisk: true, + }); + try { + for (const name of listKnownProfileNames(current)) { + try { + const runtime = current.profiles.get(name); + if (runtime?.running) { + await stopOpenClawChrome(runtime.running); + runtime.running = null; + continue; + } + await ctx.forProfile(name).stopRunningBrowser(); + } catch { + // ignore + } + } + } catch (err) { + params.onWarn(`openclaw browser stop failed: ${String(err)}`); + } +} diff --git a/extensions/browser/src/browser/server-middleware.ts b/extensions/browser/src/browser/server-middleware.ts new file mode 100644 index 00000000000..99eeb9f2268 --- /dev/null +++ b/extensions/browser/src/browser/server-middleware.ts @@ -0,0 +1,37 @@ +import type { Express } from "express"; +import express from "express"; +import { browserMutationGuardMiddleware } from "./csrf.js"; +import { isAuthorizedBrowserRequest } from "./http-auth.js"; + +export function installBrowserCommonMiddleware(app: Express) { + app.use((req, res, next) => { + const ctrl = new AbortController(); + const abort = () => ctrl.abort(new Error("request aborted")); + req.once("aborted", abort); + res.once("close", () => { + if (!res.writableEnded) { + abort(); + } + }); + // Make the signal available to browser route handlers (best-effort). + (req as unknown as { signal?: AbortSignal }).signal = ctrl.signal; + next(); + }); + app.use(express.json({ limit: "1mb" })); + app.use(browserMutationGuardMiddleware()); +} + +export function installBrowserAuthMiddleware( + app: Express, + auth: { token?: string; password?: string }, +) { + if (!auth.token && !auth.password) { + return; + } + app.use((req, res, next) => { + if (isAuthorizedBrowserRequest(req, auth)) { + return next(); + } + res.status(401).send("Unauthorized"); + }); +} diff --git a/extensions/browser/src/browser/server.agent-contract.test-harness.ts b/extensions/browser/src/browser/server.agent-contract.test-harness.ts new file mode 100644 index 00000000000..ea73714075f --- /dev/null +++ b/extensions/browser/src/browser/server.agent-contract.test-harness.ts @@ -0,0 +1,28 @@ +import { + getBrowserControlServerBaseUrl, + installBrowserControlServerHooks, + startBrowserControlServerFromConfig, +} from "./server.control-server.test-harness.js"; +import { getBrowserTestFetch } from "./test-fetch.js"; + +export function installAgentContractHooks() { + installBrowserControlServerHooks(); +} + +export async function startServerAndBase(): Promise { + await startBrowserControlServerFromConfig(); + const base = getBrowserControlServerBaseUrl(); + const realFetch = getBrowserTestFetch(); + await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json()); + return base; +} + +export async function postJson(url: string, body?: unknown): Promise { + const realFetch = getBrowserTestFetch(); + const res = await realFetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return (await res.json()) as T; +} diff --git a/extensions/browser/src/browser/server.control-server.test-harness.ts b/extensions/browser/src/browser/server.control-server.test-harness.ts new file mode 100644 index 00000000000..346708f7e9f --- /dev/null +++ b/extensions/browser/src/browser/server.control-server.test-harness.ts @@ -0,0 +1,471 @@ +import { afterEach, beforeEach, vi } from "vitest"; +import type { MockFn } from "../test-utils/vitest-mock-fn.js"; +import { installChromeUserDataDirHooks } from "./chrome-user-data-dir.test-harness.js"; +import { getFreePort } from "./test-port.js"; + +export { getFreePort } from "./test-port.js"; + +type HarnessState = { + testPort: number; + cdpBaseUrl: string; + reachable: boolean; + cfgAttachOnly: boolean; + cfgEvaluateEnabled: boolean; + cfgDefaultProfile: string; + cfgProfiles: Record< + string, + { + cdpPort?: number; + cdpUrl?: string; + color: string; + driver?: "openclaw" | "existing-session"; + attachOnly?: boolean; + } + >; + createTargetId: string | null; + prevGatewayPort: string | undefined; + prevGatewayToken: string | undefined; + prevGatewayPassword: string | undefined; +}; + +const state: HarnessState = { + testPort: 0, + cdpBaseUrl: "", + reachable: false, + cfgAttachOnly: false, + cfgEvaluateEnabled: true, + cfgDefaultProfile: "openclaw", + cfgProfiles: {}, + createTargetId: null, + prevGatewayPort: undefined, + prevGatewayToken: undefined, + prevGatewayPassword: undefined, +}; + +export function getBrowserControlServerTestState(): HarnessState { + return state; +} + +export function getBrowserControlServerBaseUrl(): string { + return `http://127.0.0.1:${state.testPort}`; +} + +export function restoreGatewayPortEnv(prevGatewayPort: string | undefined): void { + if (prevGatewayPort === undefined) { + delete process.env.OPENCLAW_GATEWAY_PORT; + return; + } + process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort; +} + +export function setBrowserControlServerCreateTargetId(targetId: string | null): void { + state.createTargetId = targetId; +} + +export function setBrowserControlServerAttachOnly(attachOnly: boolean): void { + state.cfgAttachOnly = attachOnly; +} + +export function setBrowserControlServerEvaluateEnabled(enabled: boolean): void { + state.cfgEvaluateEnabled = enabled; +} + +export function setBrowserControlServerReachable(reachable: boolean): void { + state.reachable = reachable; +} + +export function setBrowserControlServerProfiles( + profiles: HarnessState["cfgProfiles"], + defaultProfile = Object.keys(profiles)[0] ?? "openclaw", +): void { + state.cfgProfiles = profiles; + state.cfgDefaultProfile = defaultProfile; +} + +const cdpMocks = vi.hoisted(() => ({ + createTargetViaCdp: vi.fn<() => Promise<{ targetId: string }>>(async () => { + throw new Error("cdp disabled"); + }), + snapshotAria: vi.fn(async () => ({ + nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }], + })), +})); + +export function getCdpMocks(): { createTargetViaCdp: MockFn; snapshotAria: MockFn } { + return cdpMocks as unknown as { createTargetViaCdp: MockFn; snapshotAria: MockFn }; +} + +const pwMocks = vi.hoisted(() => ({ + armDialogViaPlaywright: vi.fn(async () => {}), + armFileUploadViaPlaywright: vi.fn(async () => {}), + batchViaPlaywright: vi.fn(async () => ({ results: [] })), + clickViaPlaywright: vi.fn(async () => {}), + closePageViaPlaywright: vi.fn(async () => {}), + closePlaywrightBrowserConnection: vi.fn(async () => {}), + downloadViaPlaywright: vi.fn(async () => ({ + url: "https://example.com/report.pdf", + suggestedFilename: "report.pdf", + path: "/tmp/report.pdf", + })), + dragViaPlaywright: vi.fn(async () => {}), + evaluateViaPlaywright: vi.fn(async () => "ok"), + fillFormViaPlaywright: vi.fn(async () => {}), + getConsoleMessagesViaPlaywright: vi.fn(async () => []), + hoverViaPlaywright: vi.fn(async () => {}), + scrollIntoViewViaPlaywright: vi.fn(async () => {}), + navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })), + pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })), + pressKeyViaPlaywright: vi.fn(async () => {}), + responseBodyViaPlaywright: vi.fn(async () => ({ + url: "https://example.com/api/data", + status: 200, + headers: { "content-type": "application/json" }, + body: '{"ok":true}', + })), + resizeViewportViaPlaywright: vi.fn(async () => {}), + selectOptionViaPlaywright: vi.fn(async () => {}), + setInputFilesViaPlaywright: vi.fn(async () => {}), + snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })), + traceStopViaPlaywright: vi.fn(async () => {}), + takeScreenshotViaPlaywright: vi.fn(async () => ({ + buffer: Buffer.from("png"), + })), + typeViaPlaywright: vi.fn(async () => {}), + waitForDownloadViaPlaywright: vi.fn(async () => ({ + url: "https://example.com/report.pdf", + suggestedFilename: "report.pdf", + path: "/tmp/report.pdf", + })), + waitForViaPlaywright: vi.fn(async () => {}), +})); + +export function getPwMocks(): Record { + return pwMocks as unknown as Record; +} + +const chromeMcpMocks = vi.hoisted(() => ({ + clickChromeMcpElement: vi.fn(async () => {}), + closeChromeMcpSession: vi.fn(async () => true), + closeChromeMcpTab: vi.fn(async () => {}), + dragChromeMcpElement: vi.fn(async () => {}), + ensureChromeMcpAvailable: vi.fn(async () => {}), + evaluateChromeMcpScript: vi.fn(async () => true), + fillChromeMcpElement: vi.fn(async () => {}), + fillChromeMcpForm: vi.fn(async () => {}), + focusChromeMcpTab: vi.fn(async () => {}), + getChromeMcpPid: vi.fn(() => 4321), + hoverChromeMcpElement: vi.fn(async () => {}), + listChromeMcpTabs: vi.fn(async () => [ + { targetId: "7", title: "", url: "https://example.com", type: "page" }, + ]), + navigateChromeMcpPage: vi.fn(async ({ url }: { url: string }) => ({ url })), + openChromeMcpTab: vi.fn(async (_profile: string, url: string) => ({ + targetId: "8", + title: "", + url, + type: "page", + })), + pressChromeMcpKey: vi.fn(async () => {}), + resizeChromeMcpPage: vi.fn(async () => {}), + takeChromeMcpScreenshot: vi.fn(async () => Buffer.from("png")), + takeChromeMcpSnapshot: vi.fn(async () => ({ + id: "root", + role: "document", + name: "Example", + children: [{ id: "btn-1", role: "button", name: "Continue" }], + })), + uploadChromeMcpFile: vi.fn(async () => {}), +})); + +export function getChromeMcpMocks(): Record { + return chromeMcpMocks as unknown as Record; +} + +const chromeUserDataDir = vi.hoisted(() => ({ dir: "/tmp/openclaw" })); +installChromeUserDataDirHooks(chromeUserDataDir); + +type BrowserServerModule = typeof import("./server.js"); +let browserServerModule: BrowserServerModule | null = null; + +async function loadBrowserServerModule(): Promise { + if (browserServerModule) { + return browserServerModule; + } + vi.resetModules(); + browserServerModule = await import("./server.js"); + return browserServerModule; +} + +function makeProc(pid = 123) { + const handlers = new Map void>>(); + return { + pid, + killed: false, + exitCode: null as number | null, + on: (event: string, cb: (...args: unknown[]) => void) => { + handlers.set(event, [...(handlers.get(event) ?? []), cb]); + return undefined; + }, + emitExit: () => { + for (const cb of handlers.get("exit") ?? []) { + cb(0); + } + }, + kill: () => { + return true; + }, + }; +} + +const proc = makeProc(); + +function defaultProfilesForState(testPort: number): HarnessState["cfgProfiles"] { + return { + openclaw: { cdpPort: testPort + 9, color: "#FF4500" }, + }; +} + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + const loadConfig = () => { + return { + browser: { + enabled: true, + evaluateEnabled: state.cfgEvaluateEnabled, + color: "#FF4500", + attachOnly: state.cfgAttachOnly, + headless: true, + defaultProfile: state.cfgDefaultProfile, + profiles: + Object.keys(state.cfgProfiles).length > 0 + ? state.cfgProfiles + : defaultProfilesForState(state.testPort), + }, + }; + }; + const writeConfigFile = vi.fn(async () => {}); + return { + ...actual, + createConfigIO: vi.fn(() => ({ + loadConfig, + writeConfigFile, + })), + getRuntimeConfigSnapshot: vi.fn(() => null), + loadConfig, + writeConfigFile, + }; +}); + +const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>); + +export function getLaunchCalls() { + return launchCalls; +} + +vi.mock("./chrome.js", () => ({ + isChromeCdpReady: vi.fn(async () => state.reachable), + isChromeReachable: vi.fn(async () => state.reachable), + launchOpenClawChrome: vi.fn(async (_resolved: unknown, profile: { cdpPort: number }) => { + launchCalls.push({ port: profile.cdpPort }); + state.reachable = true; + return { + pid: 123, + exe: { kind: "chrome", path: "/fake/chrome" }, + userDataDir: chromeUserDataDir.dir, + cdpPort: profile.cdpPort, + startedAt: Date.now(), + proc, + }; + }), + resolveOpenClawUserDataDir: vi.fn(() => chromeUserDataDir.dir), + stopOpenClawChrome: vi.fn(async () => { + state.reachable = false; + }), +})); + +vi.mock("./cdp.js", () => ({ + createTargetViaCdp: cdpMocks.createTargetViaCdp, + normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl), + snapshotAria: cdpMocks.snapshotAria, + getHeadersWithAuth: vi.fn(() => ({})), + appendCdpPath: vi.fn((cdpUrl: string, cdpPath: string) => { + const base = cdpUrl.replace(/\/$/, ""); + const suffix = cdpPath.startsWith("/") ? cdpPath : `/${cdpPath}`; + return `${base}${suffix}`; + }), +})); + +vi.mock("./pw-ai.js", () => pwMocks); + +vi.mock("./chrome-mcp.js", () => chromeMcpMocks); + +vi.mock("../media/store.js", () => ({ + MEDIA_MAX_BYTES: 5 * 1024 * 1024, + ensureMediaDir: vi.fn(async () => {}), + getMediaDir: vi.fn(() => "/tmp"), + saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })), +})); + +vi.mock("./screenshot.js", () => ({ + DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128, + DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64, + normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({ + buffer: buf, + contentType: "image/png", + })), +})); + +export async function startBrowserControlServerFromConfig() { + const server = await loadBrowserServerModule(); + return await server.startBrowserControlServerFromConfig(); +} + +export async function stopBrowserControlServer(): Promise { + const server = browserServerModule; + browserServerModule = null; + if (!server) { + return; + } + await server.stopBrowserControlServer(); +} + +export function makeResponse( + body: unknown, + init?: { ok?: boolean; status?: number; text?: string }, +): Response { + const ok = init?.ok ?? true; + const status = init?.status ?? 200; + const text = init?.text ?? ""; + return { + ok, + status, + json: async () => body, + text: async () => text, + } as unknown as Response; +} + +function mockClearAll(obj: Record unknown }>) { + for (const fn of Object.values(obj)) { + fn.mockClear(); + } +} + +export async function resetBrowserControlServerTestContext(): Promise { + state.reachable = false; + state.cfgAttachOnly = false; + state.cfgEvaluateEnabled = true; + state.cfgDefaultProfile = "openclaw"; + state.cfgProfiles = defaultProfilesForState(state.testPort); + state.createTargetId = null; + + mockClearAll(pwMocks); + mockClearAll(cdpMocks); + mockClearAll(chromeMcpMocks); + + state.testPort = await getFreePort(); + state.cdpBaseUrl = `http://127.0.0.1:${state.testPort + 9}`; + state.cfgProfiles = defaultProfilesForState(state.testPort); + state.prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT; + process.env.OPENCLAW_GATEWAY_PORT = String(state.testPort - 2); + // Avoid flaky auth coupling: some suites temporarily set gateway env auth + // which would make the browser control server require auth. + state.prevGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN; + state.prevGatewayPassword = process.env.OPENCLAW_GATEWAY_PASSWORD; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_PASSWORD; +} + +export function restoreGatewayAuthEnv( + prevGatewayToken: string | undefined, + prevGatewayPassword: string | undefined, +): void { + if (prevGatewayToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prevGatewayToken; + } + if (prevGatewayPassword === undefined) { + delete process.env.OPENCLAW_GATEWAY_PASSWORD; + } else { + process.env.OPENCLAW_GATEWAY_PASSWORD = prevGatewayPassword; + } +} + +export async function cleanupBrowserControlServerTestContext(): Promise { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + restoreGatewayPortEnv(state.prevGatewayPort); + restoreGatewayAuthEnv(state.prevGatewayToken, state.prevGatewayPassword); + await stopBrowserControlServer(); +} + +export function installBrowserControlServerHooks() { + beforeEach(async () => { + vi.useRealTimers(); + cdpMocks.createTargetViaCdp.mockImplementation(async () => { + if (state.createTargetId) { + return { targetId: state.createTargetId }; + } + throw new Error("cdp disabled"); + }); + + await resetBrowserControlServerTestContext(); + await loadBrowserServerModule(); + + // Minimal CDP JSON endpoints used by the server. + let putNewCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + const u = String(url); + if (u.includes("/json/list")) { + if (!state.reachable) { + return makeResponse([]); + } + return makeResponse([ + { + id: "abcd1234", + title: "Tab", + url: "https://example.com", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234", + type: "page", + }, + { + id: "abce9999", + title: "Other", + url: "https://other", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999", + type: "page", + }, + ]); + } + if (u.includes("/json/new?")) { + if (init?.method === "PUT") { + putNewCalls += 1; + if (putNewCalls === 1) { + return makeResponse({}, { ok: false, status: 405, text: "" }); + } + } + return makeResponse({ + id: "newtab1", + title: "", + url: "about:blank", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1", + type: "page", + }); + } + if (u.includes("/json/activate/")) { + return makeResponse("ok"); + } + if (u.includes("/json/close/")) { + return makeResponse("ok"); + } + return makeResponse({}, { ok: false, status: 500, text: "unexpected" }); + }), + ); + }); + + afterEach(async () => { + await cleanupBrowserControlServerTestContext(); + }); +} diff --git a/extensions/browser/src/browser/server.ts b/extensions/browser/src/browser/server.ts new file mode 100644 index 00000000000..e7546dc00c3 --- /dev/null +++ b/extensions/browser/src/browser/server.ts @@ -0,0 +1 @@ +export * from "../server.js"; diff --git a/extensions/browser/src/browser/session-tab-registry.ts b/extensions/browser/src/browser/session-tab-registry.ts new file mode 100644 index 00000000000..b81ceac3060 --- /dev/null +++ b/extensions/browser/src/browser/session-tab-registry.ts @@ -0,0 +1,189 @@ +import { browserCloseTab } from "./client.js"; + +export type TrackedSessionBrowserTab = { + sessionKey: string; + targetId: string; + baseUrl?: string; + profile?: string; + trackedAt: number; +}; + +const trackedTabsBySession = new Map>(); + +function normalizeSessionKey(raw: string): string { + return raw.trim().toLowerCase(); +} + +function normalizeTargetId(raw: string): string { + return raw.trim(); +} + +function normalizeProfile(raw?: string): string | undefined { + if (!raw) { + return undefined; + } + const trimmed = raw.trim(); + return trimmed ? trimmed.toLowerCase() : undefined; +} + +function normalizeBaseUrl(raw?: string): string | undefined { + if (!raw) { + return undefined; + } + const trimmed = raw.trim(); + return trimmed ? trimmed : undefined; +} + +function toTrackedTabId(params: { targetId: string; baseUrl?: string; profile?: string }): string { + return `${params.targetId}\u0000${params.baseUrl ?? ""}\u0000${params.profile ?? ""}`; +} + +function isIgnorableCloseError(err: unknown): boolean { + const message = String(err).toLowerCase(); + return ( + message.includes("tab not found") || + message.includes("target closed") || + message.includes("target not found") || + message.includes("no such target") + ); +} + +export function trackSessionBrowserTab(params: { + sessionKey?: string; + targetId?: string; + baseUrl?: string; + profile?: string; +}): void { + const sessionKeyRaw = params.sessionKey?.trim(); + const targetIdRaw = params.targetId?.trim(); + if (!sessionKeyRaw || !targetIdRaw) { + return; + } + const sessionKey = normalizeSessionKey(sessionKeyRaw); + const targetId = normalizeTargetId(targetIdRaw); + const baseUrl = normalizeBaseUrl(params.baseUrl); + const profile = normalizeProfile(params.profile); + const tracked: TrackedSessionBrowserTab = { + sessionKey, + targetId, + baseUrl, + profile, + trackedAt: Date.now(), + }; + const trackedId = toTrackedTabId(tracked); + let trackedForSession = trackedTabsBySession.get(sessionKey); + if (!trackedForSession) { + trackedForSession = new Map(); + trackedTabsBySession.set(sessionKey, trackedForSession); + } + trackedForSession.set(trackedId, tracked); +} + +export function untrackSessionBrowserTab(params: { + sessionKey?: string; + targetId?: string; + baseUrl?: string; + profile?: string; +}): void { + const sessionKeyRaw = params.sessionKey?.trim(); + const targetIdRaw = params.targetId?.trim(); + if (!sessionKeyRaw || !targetIdRaw) { + return; + } + const sessionKey = normalizeSessionKey(sessionKeyRaw); + const trackedForSession = trackedTabsBySession.get(sessionKey); + if (!trackedForSession) { + return; + } + const trackedId = toTrackedTabId({ + targetId: normalizeTargetId(targetIdRaw), + baseUrl: normalizeBaseUrl(params.baseUrl), + profile: normalizeProfile(params.profile), + }); + trackedForSession.delete(trackedId); + if (trackedForSession.size === 0) { + trackedTabsBySession.delete(sessionKey); + } +} + +function takeTrackedTabsForSessionKeys( + sessionKeys: Array, +): TrackedSessionBrowserTab[] { + const uniqueSessionKeys = new Set(); + for (const key of sessionKeys) { + if (!key?.trim()) { + continue; + } + uniqueSessionKeys.add(normalizeSessionKey(key)); + } + if (uniqueSessionKeys.size === 0) { + return []; + } + const seenTrackedIds = new Set(); + const tabs: TrackedSessionBrowserTab[] = []; + for (const sessionKey of uniqueSessionKeys) { + const trackedForSession = trackedTabsBySession.get(sessionKey); + if (!trackedForSession || trackedForSession.size === 0) { + continue; + } + trackedTabsBySession.delete(sessionKey); + for (const tracked of trackedForSession.values()) { + const trackedId = toTrackedTabId(tracked); + if (seenTrackedIds.has(trackedId)) { + continue; + } + seenTrackedIds.add(trackedId); + tabs.push(tracked); + } + } + return tabs; +} + +export async function closeTrackedBrowserTabsForSessions(params: { + sessionKeys: Array; + closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise; + onWarn?: (message: string) => void; +}): Promise { + const tabs = takeTrackedTabsForSessionKeys(params.sessionKeys); + if (tabs.length === 0) { + return 0; + } + const closeTab = + params.closeTab ?? + (async (tab: { targetId: string; baseUrl?: string; profile?: string }) => { + await browserCloseTab(tab.baseUrl, tab.targetId, { + profile: tab.profile, + }); + }); + let closed = 0; + for (const tab of tabs) { + try { + await closeTab({ + targetId: tab.targetId, + baseUrl: tab.baseUrl, + profile: tab.profile, + }); + closed += 1; + } catch (err) { + if (!isIgnorableCloseError(err)) { + params.onWarn?.(`failed to close tracked browser tab ${tab.targetId}: ${String(err)}`); + } + } + } + return closed; +} + +export function __resetTrackedSessionBrowserTabsForTests(): void { + trackedTabsBySession.clear(); +} + +export function __countTrackedSessionBrowserTabsForTests(sessionKey?: string): number { + if (typeof sessionKey === "string" && sessionKey.trim()) { + return trackedTabsBySession.get(normalizeSessionKey(sessionKey))?.size ?? 0; + } + let count = 0; + for (const tracked of trackedTabsBySession.values()) { + count += tracked.size; + } + return count; +} diff --git a/extensions/browser/src/browser/snapshot-roles.ts b/extensions/browser/src/browser/snapshot-roles.ts new file mode 100644 index 00000000000..8e5d873e557 --- /dev/null +++ b/extensions/browser/src/browser/snapshot-roles.ts @@ -0,0 +1,63 @@ +/** + * Shared ARIA role classification sets used by both the Playwright and Chrome MCP + * snapshot paths. Keep these in sync — divergence causes the two drivers to produce + * different snapshot output for the same page. + */ + +/** Roles that represent user-interactive elements and always get a ref. */ +export const INTERACTIVE_ROLES = new Set([ + "button", + "checkbox", + "combobox", + "link", + "listbox", + "menuitem", + "menuitemcheckbox", + "menuitemradio", + "option", + "radio", + "searchbox", + "slider", + "spinbutton", + "switch", + "tab", + "textbox", + "treeitem", +]); + +/** Roles that carry meaningful content and get a ref when named. */ +export const CONTENT_ROLES = new Set([ + "article", + "cell", + "columnheader", + "gridcell", + "heading", + "listitem", + "main", + "navigation", + "region", + "rowheader", +]); + +/** Structural/container roles — typically skipped in compact mode. */ +export const STRUCTURAL_ROLES = new Set([ + "application", + "directory", + "document", + "generic", + "grid", + "group", + "ignored", + "list", + "menu", + "menubar", + "none", + "presentation", + "row", + "rowgroup", + "table", + "tablist", + "toolbar", + "tree", + "treegrid", +]); diff --git a/extensions/browser/src/browser/target-id.ts b/extensions/browser/src/browser/target-id.ts new file mode 100644 index 00000000000..6ae0f31bf08 --- /dev/null +++ b/extensions/browser/src/browser/target-id.ts @@ -0,0 +1,30 @@ +export type TargetIdResolution = + | { ok: true; targetId: string } + | { ok: false; reason: "not_found" | "ambiguous"; matches?: string[] }; + +export function resolveTargetIdFromTabs( + input: string, + tabs: Array<{ targetId: string }>, +): TargetIdResolution { + const needle = input.trim(); + if (!needle) { + return { ok: false, reason: "not_found" }; + } + + const exact = tabs.find((t) => t.targetId === needle); + if (exact) { + return { ok: true, targetId: exact.targetId }; + } + + const lower = needle.toLowerCase(); + const matches = tabs.map((t) => t.targetId).filter((id) => id.toLowerCase().startsWith(lower)); + + const only = matches.length === 1 ? matches[0] : undefined; + if (only) { + return { ok: true, targetId: only }; + } + if (matches.length === 0) { + return { ok: false, reason: "not_found" }; + } + return { ok: false, reason: "ambiguous", matches }; +} diff --git a/extensions/browser/src/browser/test-fetch.ts b/extensions/browser/src/browser/test-fetch.ts new file mode 100644 index 00000000000..310c7728afe --- /dev/null +++ b/extensions/browser/src/browser/test-fetch.ts @@ -0,0 +1,30 @@ +import { createRequire } from "node:module"; + +type FetchLike = ((input: string | URL, init?: RequestInit) => Promise) & { + mock?: unknown; +}; + +export type BrowserTestFetch = (input: string | URL, init?: RequestInit) => Promise; + +function isUsableFetch(value: unknown): value is FetchLike { + return typeof value === "function" && !("mock" in (value as FetchLike)); +} + +export function getBrowserTestFetch(): BrowserTestFetch { + const require = createRequire(import.meta.url); + const vitest = (globalThis as { vi?: { doUnmock?: (id: string) => void } }).vi; + vitest?.doUnmock?.("undici"); + try { + delete require.cache[require.resolve("undici")]; + } catch { + // Best-effort cache bust for shared-thread test workers. + } + const { fetch } = require("undici") as typeof import("undici"); + if (isUsableFetch(fetch)) { + return (input, init) => fetch(input, init); + } + if (isUsableFetch(globalThis.fetch)) { + return (input, init) => globalThis.fetch(input, init); + } + throw new TypeError("fetch is not a function"); +} diff --git a/extensions/browser/src/browser/test-port.ts b/extensions/browser/src/browser/test-port.ts new file mode 100644 index 00000000000..860968df9a7 --- /dev/null +++ b/extensions/browser/src/browser/test-port.ts @@ -0,0 +1,18 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +export async function getFreePort(): Promise { + while (true) { + const port = await new Promise((resolve, reject) => { + const s = createServer(); + s.once("error", reject); + s.listen(0, "127.0.0.1", () => { + const assigned = (s.address() as AddressInfo).port; + s.close((err) => (err ? reject(err) : resolve(assigned))); + }); + }); + if (port < 65535) { + return port; + } + } +} diff --git a/extensions/browser/src/browser/trash.ts b/extensions/browser/src/browser/trash.ts new file mode 100644 index 00000000000..c0b1d6094d6 --- /dev/null +++ b/extensions/browser/src/browser/trash.ts @@ -0,0 +1,22 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { generateSecureToken } from "../infra/secure-random.js"; +import { runExec } from "../process/exec.js"; + +export async function movePathToTrash(targetPath: string): Promise { + try { + await runExec("trash", [targetPath], { timeoutMs: 10_000 }); + return targetPath; + } catch { + const trashDir = path.join(os.homedir(), ".Trash"); + fs.mkdirSync(trashDir, { recursive: true }); + const base = path.basename(targetPath); + let dest = path.join(trashDir, `${base}-${Date.now()}`); + if (fs.existsSync(dest)) { + dest = path.join(trashDir, `${base}-${Date.now()}-${generateSecureToken(6)}`); + } + fs.renameSync(targetPath, dest); + return dest; + } +} diff --git a/extensions/browser/src/browser/url-pattern.ts b/extensions/browser/src/browser/url-pattern.ts new file mode 100644 index 00000000000..2ff99657d26 --- /dev/null +++ b/extensions/browser/src/browser/url-pattern.ts @@ -0,0 +1,15 @@ +export function matchBrowserUrlPattern(pattern: string, url: string): boolean { + const trimmedPattern = pattern.trim(); + if (!trimmedPattern) { + return false; + } + if (trimmedPattern === url) { + return true; + } + if (trimmedPattern.includes("*")) { + const escaped = trimmedPattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); + const regex = new RegExp(`^${escaped.replace(/\*\*/g, ".*").replace(/\*/g, ".*")}$`); + return regex.test(url); + } + return url.includes(trimmedPattern); +} diff --git a/extensions/browser/src/config/config.ts b/extensions/browser/src/config/config.ts new file mode 100644 index 00000000000..700c049c4f3 --- /dev/null +++ b/extensions/browser/src/config/config.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/browser-support"; diff --git a/extensions/browser/src/config/paths.ts b/extensions/browser/src/config/paths.ts new file mode 100644 index 00000000000..700c049c4f3 --- /dev/null +++ b/extensions/browser/src/config/paths.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/browser-support"; diff --git a/extensions/browser/src/config/port-defaults.ts b/extensions/browser/src/config/port-defaults.ts new file mode 100644 index 00000000000..700c049c4f3 --- /dev/null +++ b/extensions/browser/src/config/port-defaults.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/browser-support"; diff --git a/src/agents/openclaw-tools.browser-plugin.integration.test.ts b/src/agents/openclaw-tools.browser-plugin.integration.test.ts new file mode 100644 index 00000000000..8371e562d60 --- /dev/null +++ b/src/agents/openclaw-tools.browser-plugin.integration.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { clearPluginLoaderCache } from "../plugins/loader.js"; +import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; +import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js"; +import { createOpenClawTools } from "./openclaw-tools.js"; + +function resetPluginState() { + clearPluginLoaderCache(); + clearPluginManifestRegistryCache(); + resetPluginRuntimeStateForTest(); +} + +describe("createOpenClawTools browser plugin integration", () => { + beforeEach(() => { + resetPluginState(); + }); + + afterEach(() => { + resetPluginState(); + }); + + it("loads the bundled browser plugin through normal plugin resolution", () => { + const tools = createOpenClawTools({ + config: { + plugins: { + allow: ["browser"], + }, + } as OpenClawConfig, + }); + + expect(tools.map((tool) => tool.name)).toContain("browser"); + }); + + it("omits the browser tool when the bundled browser plugin is disabled", () => { + const tools = createOpenClawTools({ + config: { + plugins: { + allow: ["browser"], + entries: { + browser: { + enabled: false, + }, + }, + }, + } as OpenClawConfig, + }); + + expect(tools.map((tool) => tool.name)).not.toContain("browser"); + }); +}); diff --git a/src/agents/openclaw-tools.plugin-context.test.ts b/src/agents/openclaw-tools.plugin-context.test.ts index 6a20a127898..6b07f169dbe 100644 --- a/src/agents/openclaw-tools.plugin-context.test.ts +++ b/src/agents/openclaw-tools.plugin-context.test.ts @@ -9,15 +9,19 @@ const { resolvePluginToolsMock } = vi.hoisted(() => ({ vi.mock("../plugins/tools.js", () => ({ resolvePluginTools: resolvePluginToolsMock, + copyPluginToolMeta: vi.fn(), getPluginToolMeta: vi.fn(() => undefined), })); -import { createOpenClawTools } from "./openclaw-tools.js"; -import { createOpenClawCodingTools } from "./pi-tools.js"; +let createOpenClawTools: typeof import("./openclaw-tools.js").createOpenClawTools; +let createOpenClawCodingTools: typeof import("./pi-tools.js").createOpenClawCodingTools; describe("createOpenClawTools plugin context", () => { - beforeEach(() => { + beforeEach(async () => { resolvePluginToolsMock.mockClear(); + vi.resetModules(); + ({ createOpenClawTools } = await import("./openclaw-tools.js")); + ({ createOpenClawCodingTools } = await import("./pi-tools.js")); }); it("forwards trusted requester sender identity to plugin tool context", () => { @@ -54,6 +58,25 @@ describe("createOpenClawTools plugin context", () => { ); }); + it("forwards browser session wiring to plugin tool context", () => { + createOpenClawTools({ + config: {} as never, + sandboxBrowserBridgeUrl: "http://127.0.0.1:9999", + allowHostBrowserControl: true, + }); + + expect(resolvePluginToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + browser: { + sandboxBridgeUrl: "http://127.0.0.1:9999", + allowHostControl: true, + }, + }), + }), + ); + }); + it("forwards gateway subagent binding for plugin tools", () => { createOpenClawTools({ config: {} as never, diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index aa08916e48c..a3ba6618a87 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -8,7 +8,6 @@ import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; import type { SpawnedToolContext } from "./spawned-context.js"; import type { ToolFsPolicy } from "./tool-fs-policy.js"; import { createAgentsListTool } from "./tools/agents-list-tool.js"; -import { createBrowserTool } from "./tools/browser-tool.js"; import { createCanvasTool } from "./tools/canvas-tool.js"; import type { AnyAgentTool } from "./tools/common.js"; import { createCronTool } from "./tools/cron-tool.js"; @@ -161,11 +160,6 @@ export function createOpenClawTools( requesterSenderId: options?.requesterSenderId ?? undefined, }); const tools: AnyAgentTool[] = [ - createBrowserTool({ - sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl, - allowHostControl: options?.allowHostBrowserControl, - agentSessionKey: options?.agentSessionKey, - }), createCanvasTool({ config: options?.config }), createNodesTool({ agentSessionKey: options?.agentSessionKey, @@ -255,6 +249,10 @@ export function createOpenClawTools( }), sessionKey: options?.agentSessionKey, sessionId: options?.sessionId, + browser: { + sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl, + allowHostControl: options?.allowHostBrowserControl, + }, messageChannel: options?.agentChannel, agentAccountId: options?.agentAccountId, requesterSenderId: options?.requesterSenderId ?? undefined, diff --git a/src/agents/tools/browser-tool.actions.ts b/src/agents/tools/browser-tool.actions.ts index 9dfb56ca3d8..61ac7bf75a8 100644 --- a/src/agents/tools/browser-tool.actions.ts +++ b/src/agents/tools/browser-tool.actions.ts @@ -1,396 +1 @@ -import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { browserAct, browserConsoleMessages } from "../../browser/client-actions.js"; -import { browserSnapshot, browserTabs } from "../../browser/client.js"; -import { resolveBrowserConfig, resolveProfile } from "../../browser/config.js"; -import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js"; -import { getBrowserProfileCapabilities } from "../../browser/profile-capabilities.js"; -import { loadConfig } from "../../config/config.js"; -import { wrapExternalContent } from "../../security/external-content.js"; -import { imageResultFromFile, jsonResult } from "./common.js"; - -const browserToolActionDeps = { - browserAct, - browserConsoleMessages, - browserSnapshot, - browserTabs, - imageResultFromFile, - loadConfig, -}; - -export const __testing = { - setDepsForTest( - overrides: Partial<{ - browserAct: typeof browserAct; - browserConsoleMessages: typeof browserConsoleMessages; - browserSnapshot: typeof browserSnapshot; - browserTabs: typeof browserTabs; - imageResultFromFile: typeof imageResultFromFile; - loadConfig: typeof loadConfig; - }> | null, - ) { - browserToolActionDeps.browserAct = overrides?.browserAct ?? browserAct; - browserToolActionDeps.browserConsoleMessages = - overrides?.browserConsoleMessages ?? browserConsoleMessages; - browserToolActionDeps.browserSnapshot = overrides?.browserSnapshot ?? browserSnapshot; - browserToolActionDeps.browserTabs = overrides?.browserTabs ?? browserTabs; - browserToolActionDeps.imageResultFromFile = - overrides?.imageResultFromFile ?? imageResultFromFile; - browserToolActionDeps.loadConfig = overrides?.loadConfig ?? loadConfig; - }, -}; - -type BrowserProxyRequest = (opts: { - method: string; - path: string; - query?: Record; - body?: unknown; - timeoutMs?: number; - profile?: string; -}) => Promise; - -function wrapBrowserExternalJson(params: { - kind: "snapshot" | "console" | "tabs"; - payload: unknown; - includeWarning?: boolean; -}): { wrappedText: string; safeDetails: Record } { - const extractedText = JSON.stringify(params.payload, null, 2); - const wrappedText = wrapExternalContent(extractedText, { - source: "browser", - includeWarning: params.includeWarning ?? true, - }); - return { - wrappedText, - safeDetails: { - ok: true, - externalContent: { - untrusted: true, - source: "browser", - kind: params.kind, - wrapped: true, - }, - }, - }; -} - -function formatTabsToolResult(tabs: unknown[]): AgentToolResult { - const wrapped = wrapBrowserExternalJson({ - kind: "tabs", - payload: { tabs }, - includeWarning: false, - }); - const content: AgentToolResult["content"] = [ - { type: "text", text: wrapped.wrappedText }, - ]; - return { - content, - details: { ...wrapped.safeDetails, tabCount: tabs.length }, - }; -} - -function formatConsoleToolResult(result: { - targetId?: string; - messages?: unknown[]; -}): AgentToolResult { - const wrapped = wrapBrowserExternalJson({ - kind: "console", - payload: result, - includeWarning: false, - }); - return { - content: [{ type: "text" as const, text: wrapped.wrappedText }], - details: { - ...wrapped.safeDetails, - targetId: typeof result.targetId === "string" ? result.targetId : undefined, - messageCount: Array.isArray(result.messages) ? result.messages.length : undefined, - }, - }; -} - -function isChromeStaleTargetError(profile: string | undefined, err: unknown): boolean { - if (!profile) { - return false; - } - if (profile === "user") { - const msg = String(err); - return msg.includes("404:") && msg.includes("tab not found"); - } - const cfg = browserToolActionDeps.loadConfig(); - const resolved = resolveBrowserConfig(cfg.browser, cfg); - const browserProfile = resolveProfile(resolved, profile); - if (!browserProfile || !getBrowserProfileCapabilities(browserProfile).usesChromeMcp) { - return false; - } - const msg = String(err); - return msg.includes("404:") && msg.includes("tab not found"); -} - -function stripTargetIdFromActRequest( - request: Parameters[1], -): Parameters[1] | null { - const targetId = typeof request.targetId === "string" ? request.targetId.trim() : undefined; - if (!targetId) { - return null; - } - const retryRequest = { ...request }; - delete retryRequest.targetId; - return retryRequest as Parameters[1]; -} - -function canRetryChromeActWithoutTargetId(request: Parameters[1]): boolean { - const typedRequest = request as Partial>; - const kind = - typeof typedRequest.kind === "string" - ? typedRequest.kind - : typeof typedRequest.action === "string" - ? typedRequest.action - : ""; - return kind === "hover" || kind === "scrollIntoView" || kind === "wait"; -} - -export async function executeTabsAction(params: { - baseUrl?: string; - profile?: string; - proxyRequest: BrowserProxyRequest | null; -}): Promise> { - const { baseUrl, profile, proxyRequest } = params; - if (proxyRequest) { - const result = await proxyRequest({ - method: "GET", - path: "/tabs", - profile, - }); - const tabs = (result as { tabs?: unknown[] }).tabs ?? []; - return formatTabsToolResult(tabs); - } - const tabs = await browserToolActionDeps.browserTabs(baseUrl, { profile }); - return formatTabsToolResult(tabs); -} - -export async function executeSnapshotAction(params: { - input: Record; - baseUrl?: string; - profile?: string; - proxyRequest: BrowserProxyRequest | null; -}): Promise> { - const { input, baseUrl, profile, proxyRequest } = params; - const snapshotDefaults = browserToolActionDeps.loadConfig().browser?.snapshotDefaults; - const format: "ai" | "aria" | undefined = - input.snapshotFormat === "ai" || input.snapshotFormat === "aria" - ? input.snapshotFormat - : undefined; - const mode: "efficient" | undefined = - input.mode === "efficient" - ? "efficient" - : format !== "aria" && snapshotDefaults?.mode === "efficient" - ? "efficient" - : undefined; - const labels = typeof input.labels === "boolean" ? input.labels : undefined; - const refs: "aria" | "role" | undefined = - input.refs === "aria" || input.refs === "role" ? input.refs : undefined; - const hasMaxChars = Object.hasOwn(input, "maxChars"); - const targetId = typeof input.targetId === "string" ? input.targetId.trim() : undefined; - const limit = - typeof input.limit === "number" && Number.isFinite(input.limit) ? input.limit : undefined; - const maxChars = - typeof input.maxChars === "number" && Number.isFinite(input.maxChars) && input.maxChars > 0 - ? Math.floor(input.maxChars) - : undefined; - const interactive = typeof input.interactive === "boolean" ? input.interactive : undefined; - const compact = typeof input.compact === "boolean" ? input.compact : undefined; - const depth = - typeof input.depth === "number" && Number.isFinite(input.depth) ? input.depth : undefined; - const selector = typeof input.selector === "string" ? input.selector.trim() : undefined; - const frame = typeof input.frame === "string" ? input.frame.trim() : undefined; - const resolvedMaxChars = - format === "ai" - ? hasMaxChars - ? maxChars - : mode === "efficient" - ? undefined - : DEFAULT_AI_SNAPSHOT_MAX_CHARS - : hasMaxChars - ? maxChars - : undefined; - const snapshotQuery = { - ...(format ? { format } : {}), - targetId, - limit, - ...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}), - refs, - interactive, - compact, - depth, - selector, - frame, - labels, - mode, - }; - const snapshot = proxyRequest - ? ((await proxyRequest({ - method: "GET", - path: "/snapshot", - profile, - query: snapshotQuery, - })) as Awaited>) - : await browserToolActionDeps.browserSnapshot(baseUrl, { - ...snapshotQuery, - profile, - }); - if (snapshot.format === "ai") { - const extractedText = snapshot.snapshot ?? ""; - const wrappedSnapshot = wrapExternalContent(extractedText, { - source: "browser", - includeWarning: true, - }); - const safeDetails = { - ok: true, - format: snapshot.format, - targetId: snapshot.targetId, - url: snapshot.url, - truncated: snapshot.truncated, - stats: snapshot.stats, - refs: snapshot.refs ? Object.keys(snapshot.refs).length : undefined, - labels: snapshot.labels, - labelsCount: snapshot.labelsCount, - labelsSkipped: snapshot.labelsSkipped, - imagePath: snapshot.imagePath, - imageType: snapshot.imageType, - externalContent: { - untrusted: true, - source: "browser", - kind: "snapshot", - format: "ai", - wrapped: true, - }, - }; - if (labels && snapshot.imagePath) { - return await browserToolActionDeps.imageResultFromFile({ - label: "browser:snapshot", - path: snapshot.imagePath, - extraText: wrappedSnapshot, - details: safeDetails, - }); - } - return { - content: [{ type: "text" as const, text: wrappedSnapshot }], - details: safeDetails, - }; - } - { - const wrapped = wrapBrowserExternalJson({ - kind: "snapshot", - payload: snapshot, - }); - return { - content: [{ type: "text" as const, text: wrapped.wrappedText }], - details: { - ...wrapped.safeDetails, - format: "aria", - targetId: snapshot.targetId, - url: snapshot.url, - nodeCount: snapshot.nodes.length, - externalContent: { - untrusted: true, - source: "browser", - kind: "snapshot", - format: "aria", - wrapped: true, - }, - }, - }; - } -} - -export async function executeConsoleAction(params: { - input: Record; - baseUrl?: string; - profile?: string; - proxyRequest: BrowserProxyRequest | null; -}): Promise> { - const { input, baseUrl, profile, proxyRequest } = params; - const level = typeof input.level === "string" ? input.level.trim() : undefined; - const targetId = typeof input.targetId === "string" ? input.targetId.trim() : undefined; - if (proxyRequest) { - const result = (await proxyRequest({ - method: "GET", - path: "/console", - profile, - query: { - level, - targetId, - }, - })) as { ok?: boolean; targetId?: string; messages?: unknown[] }; - return formatConsoleToolResult(result); - } - const result = await browserToolActionDeps.browserConsoleMessages(baseUrl, { - level, - targetId, - profile, - }); - return formatConsoleToolResult(result); -} - -export async function executeActAction(params: { - request: Parameters[1]; - baseUrl?: string; - profile?: string; - proxyRequest: BrowserProxyRequest | null; -}): Promise> { - const { request, baseUrl, profile, proxyRequest } = params; - try { - const result = proxyRequest - ? await proxyRequest({ - method: "POST", - path: "/act", - profile, - body: request, - }) - : await browserToolActionDeps.browserAct(baseUrl, request, { - profile, - }); - return jsonResult(result); - } catch (err) { - if (isChromeStaleTargetError(profile, err)) { - const retryRequest = stripTargetIdFromActRequest(request); - const tabs = proxyRequest - ? (( - (await proxyRequest({ - method: "GET", - path: "/tabs", - profile, - })) as { tabs?: unknown[] } - ).tabs ?? []) - : await browserToolActionDeps.browserTabs(baseUrl, { profile }).catch(() => []); - // Some user-browser targetIds can go stale between snapshots and actions. - // Only retry safe read-only actions, and only when exactly one tab remains attached. - if (retryRequest && canRetryChromeActWithoutTargetId(request) && tabs.length === 1) { - try { - const retryResult = proxyRequest - ? await proxyRequest({ - method: "POST", - path: "/act", - profile, - body: retryRequest, - }) - : await browserToolActionDeps.browserAct(baseUrl, retryRequest, { - profile, - }); - return jsonResult(retryResult); - } catch { - // Fall through to explicit stale-target guidance. - } - } - if (!tabs.length) { - throw new Error( - `No browser tabs found for profile="${profile}". Make sure the configured Chromium-based browser (v144+) is running and has open tabs, then retry.`, - { cause: err }, - ); - } - throw new Error( - `Chrome tab not found (stale targetId?). Run action=tabs profile="${profile}" and use one of the returned targetIds.`, - { cause: err }, - ); - } - throw err; - } -} +export * from "../../../extensions/browser/src/browser-tool.actions.js"; diff --git a/src/agents/tools/browser-tool.schema.ts b/src/agents/tools/browser-tool.schema.ts index aef51f6359d..f83d309d287 100644 --- a/src/agents/tools/browser-tool.schema.ts +++ b/src/agents/tools/browser-tool.schema.ts @@ -1,138 +1 @@ -import { Type } from "@sinclair/typebox"; -import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; - -const BROWSER_ACT_KINDS = [ - "click", - "type", - "press", - "hover", - "drag", - "select", - "fill", - "resize", - "wait", - "evaluate", - "close", -] as const; - -const BROWSER_TOOL_ACTIONS = [ - "status", - "start", - "stop", - "profiles", - "tabs", - "open", - "focus", - "close", - "snapshot", - "screenshot", - "navigate", - "console", - "pdf", - "upload", - "dialog", - "act", -] as const; - -const BROWSER_TARGETS = ["sandbox", "host", "node"] as const; - -const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const; -const BROWSER_SNAPSHOT_MODES = ["efficient"] as const; -const BROWSER_SNAPSHOT_REFS = ["role", "aria"] as const; - -const BROWSER_IMAGE_TYPES = ["png", "jpeg"] as const; - -// NOTE: Using a flattened object schema instead of Type.Union([Type.Object(...), ...]) -// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. -// The discriminator (kind) determines which properties are relevant; runtime validates. -const BrowserActSchema = Type.Object({ - kind: stringEnum(BROWSER_ACT_KINDS), - // Common fields - targetId: Type.Optional(Type.String()), - ref: Type.Optional(Type.String()), - // click - doubleClick: Type.Optional(Type.Boolean()), - button: Type.Optional(Type.String()), - modifiers: Type.Optional(Type.Array(Type.String())), - // type - text: Type.Optional(Type.String()), - submit: Type.Optional(Type.Boolean()), - slowly: Type.Optional(Type.Boolean()), - // press - key: Type.Optional(Type.String()), - delayMs: Type.Optional(Type.Number()), - // drag - startRef: Type.Optional(Type.String()), - endRef: Type.Optional(Type.String()), - // select - values: Type.Optional(Type.Array(Type.String())), - // fill - use permissive array of objects - fields: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }))), - // resize - width: Type.Optional(Type.Number()), - height: Type.Optional(Type.Number()), - // wait - timeMs: Type.Optional(Type.Number()), - selector: Type.Optional(Type.String()), - url: Type.Optional(Type.String()), - loadState: Type.Optional(Type.String()), - textGone: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - // evaluate - fn: Type.Optional(Type.String()), -}); - -// IMPORTANT: OpenAI function tool schemas must have a top-level `type: "object"`. -// A root-level `Type.Union([...])` compiles to `{ anyOf: [...] }` (no `type`), -// which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object. -export const BrowserToolSchema = Type.Object({ - action: stringEnum(BROWSER_TOOL_ACTIONS), - target: optionalStringEnum(BROWSER_TARGETS), - node: Type.Optional(Type.String()), - profile: Type.Optional(Type.String()), - targetUrl: Type.Optional(Type.String()), - url: Type.Optional(Type.String()), - targetId: Type.Optional(Type.String()), - limit: Type.Optional(Type.Number()), - maxChars: Type.Optional(Type.Number()), - mode: optionalStringEnum(BROWSER_SNAPSHOT_MODES), - snapshotFormat: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS), - refs: optionalStringEnum(BROWSER_SNAPSHOT_REFS), - interactive: Type.Optional(Type.Boolean()), - compact: Type.Optional(Type.Boolean()), - depth: Type.Optional(Type.Number()), - selector: Type.Optional(Type.String()), - frame: Type.Optional(Type.String()), - labels: Type.Optional(Type.Boolean()), - fullPage: Type.Optional(Type.Boolean()), - ref: Type.Optional(Type.String()), - element: Type.Optional(Type.String()), - type: optionalStringEnum(BROWSER_IMAGE_TYPES), - level: Type.Optional(Type.String()), - paths: Type.Optional(Type.Array(Type.String())), - inputRef: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - accept: Type.Optional(Type.Boolean()), - promptText: Type.Optional(Type.String()), - // Legacy flattened act params (preferred: request={...}) - kind: Type.Optional(stringEnum(BROWSER_ACT_KINDS)), - doubleClick: Type.Optional(Type.Boolean()), - button: Type.Optional(Type.String()), - modifiers: Type.Optional(Type.Array(Type.String())), - text: Type.Optional(Type.String()), - submit: Type.Optional(Type.Boolean()), - slowly: Type.Optional(Type.Boolean()), - key: Type.Optional(Type.String()), - delayMs: Type.Optional(Type.Number()), - startRef: Type.Optional(Type.String()), - endRef: Type.Optional(Type.String()), - values: Type.Optional(Type.Array(Type.String())), - fields: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }))), - width: Type.Optional(Type.Number()), - height: Type.Optional(Type.Number()), - timeMs: Type.Optional(Type.Number()), - textGone: Type.Optional(Type.String()), - loadState: Type.Optional(Type.String()), - fn: Type.Optional(Type.String()), - request: Type.Optional(BrowserActSchema), -}); +export * from "../../../extensions/browser/src/browser-tool.schema.js"; diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index d9819bf3e4b..02e8a6955c6 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -1,755 +1 @@ -import crypto from "node:crypto"; -import { - browserAct, - browserArmDialog, - browserArmFileChooser, - browserNavigate, - browserPdfSave, - browserScreenshotAction, -} from "../../browser/client-actions.js"; -import { - browserCloseTab, - browserFocusTab, - browserOpenTab, - browserProfiles, - browserStart, - browserStatus, - browserStop, -} from "../../browser/client.js"; -import { resolveBrowserConfig, resolveProfile } from "../../browser/config.js"; -import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "../../browser/paths.js"; -import { getBrowserProfileCapabilities } from "../../browser/profile-capabilities.js"; -import { applyBrowserProxyPaths, persistBrowserProxyFiles } from "../../browser/proxy-files.js"; -import { - trackSessionBrowserTab, - untrackSessionBrowserTab, -} from "../../browser/session-tab-registry.js"; -import { loadConfig } from "../../config/config.js"; -import { - executeActAction, - executeConsoleAction, - executeSnapshotAction, - executeTabsAction, -} from "./browser-tool.actions.js"; -import { BrowserToolSchema } from "./browser-tool.schema.js"; -import { type AnyAgentTool, imageResultFromFile, jsonResult, readStringParam } from "./common.js"; -import { callGatewayTool } from "./gateway.js"; -import { - listNodes, - resolveNodeIdFromList, - selectDefaultNodeFromList, - type NodeListNode, -} from "./nodes-utils.js"; - -const browserToolDeps = { - browserAct, - browserArmDialog, - browserArmFileChooser, - browserCloseTab, - browserFocusTab, - browserNavigate, - browserOpenTab, - browserPdfSave, - browserProfiles, - browserScreenshotAction, - browserStart, - browserStatus, - browserStop, - imageResultFromFile, - loadConfig, - listNodes, - callGatewayTool, - trackSessionBrowserTab, - untrackSessionBrowserTab, -}; - -export const __testing = { - setDepsForTest( - overrides: Partial<{ - browserAct: typeof browserAct; - browserArmDialog: typeof browserArmDialog; - browserArmFileChooser: typeof browserArmFileChooser; - browserCloseTab: typeof browserCloseTab; - browserFocusTab: typeof browserFocusTab; - browserNavigate: typeof browserNavigate; - browserOpenTab: typeof browserOpenTab; - browserPdfSave: typeof browserPdfSave; - browserProfiles: typeof browserProfiles; - browserScreenshotAction: typeof browserScreenshotAction; - browserStart: typeof browserStart; - browserStatus: typeof browserStatus; - browserStop: typeof browserStop; - imageResultFromFile: typeof imageResultFromFile; - loadConfig: typeof loadConfig; - listNodes: typeof listNodes; - callGatewayTool: typeof callGatewayTool; - trackSessionBrowserTab: typeof trackSessionBrowserTab; - untrackSessionBrowserTab: typeof untrackSessionBrowserTab; - }> | null, - ) { - browserToolDeps.browserAct = overrides?.browserAct ?? browserAct; - browserToolDeps.browserArmDialog = overrides?.browserArmDialog ?? browserArmDialog; - browserToolDeps.browserArmFileChooser = - overrides?.browserArmFileChooser ?? browserArmFileChooser; - browserToolDeps.browserCloseTab = overrides?.browserCloseTab ?? browserCloseTab; - browserToolDeps.browserFocusTab = overrides?.browserFocusTab ?? browserFocusTab; - browserToolDeps.browserNavigate = overrides?.browserNavigate ?? browserNavigate; - browserToolDeps.browserOpenTab = overrides?.browserOpenTab ?? browserOpenTab; - browserToolDeps.browserPdfSave = overrides?.browserPdfSave ?? browserPdfSave; - browserToolDeps.browserProfiles = overrides?.browserProfiles ?? browserProfiles; - browserToolDeps.browserScreenshotAction = - overrides?.browserScreenshotAction ?? browserScreenshotAction; - browserToolDeps.browserStart = overrides?.browserStart ?? browserStart; - browserToolDeps.browserStatus = overrides?.browserStatus ?? browserStatus; - browserToolDeps.browserStop = overrides?.browserStop ?? browserStop; - browserToolDeps.imageResultFromFile = overrides?.imageResultFromFile ?? imageResultFromFile; - browserToolDeps.loadConfig = overrides?.loadConfig ?? loadConfig; - browserToolDeps.listNodes = overrides?.listNodes ?? listNodes; - browserToolDeps.callGatewayTool = overrides?.callGatewayTool ?? callGatewayTool; - browserToolDeps.trackSessionBrowserTab = - overrides?.trackSessionBrowserTab ?? trackSessionBrowserTab; - browserToolDeps.untrackSessionBrowserTab = - overrides?.untrackSessionBrowserTab ?? untrackSessionBrowserTab; - }, -}; - -function readOptionalTargetAndTimeout(params: Record) { - const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined; - const timeoutMs = - typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) - ? params.timeoutMs - : undefined; - return { targetId, timeoutMs }; -} - -function readTargetUrlParam(params: Record) { - return ( - readStringParam(params, "targetUrl") ?? - readStringParam(params, "url", { required: true, label: "targetUrl" }) - ); -} - -const LEGACY_BROWSER_ACT_REQUEST_KEYS = [ - "targetId", - "ref", - "doubleClick", - "button", - "modifiers", - "text", - "submit", - "slowly", - "key", - "delayMs", - "startRef", - "endRef", - "values", - "fields", - "width", - "height", - "timeMs", - "textGone", - "selector", - "url", - "loadState", - "fn", - "timeoutMs", -] as const; - -function readActRequestParam(params: Record) { - const requestParam = params.request; - if (requestParam && typeof requestParam === "object") { - return requestParam as Parameters[1]; - } - - const kind = readStringParam(params, "kind"); - if (!kind) { - return undefined; - } - - const request: Record = { kind }; - for (const key of LEGACY_BROWSER_ACT_REQUEST_KEYS) { - if (!Object.hasOwn(params, key)) { - continue; - } - request[key] = params[key]; - } - return request as Parameters[1]; -} - -type BrowserProxyFile = { - path: string; - base64: string; - mimeType?: string; -}; - -type BrowserProxyResult = { - result: unknown; - files?: BrowserProxyFile[]; -}; - -const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000; -const BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS = 5_000; - -type BrowserNodeTarget = { - nodeId: string; - label?: string; -}; - -function isBrowserNode(node: NodeListNode) { - const caps = Array.isArray(node.caps) ? node.caps : []; - const commands = Array.isArray(node.commands) ? node.commands : []; - return caps.includes("browser") || commands.includes("browser.proxy"); -} - -async function resolveBrowserNodeTarget(params: { - requestedNode?: string; - target?: "sandbox" | "host" | "node"; - sandboxBridgeUrl?: string; -}): Promise { - const cfg = browserToolDeps.loadConfig(); - const policy = cfg.gateway?.nodes?.browser; - const mode = policy?.mode ?? "auto"; - if (mode === "off") { - if (params.target === "node" || params.requestedNode) { - throw new Error("Node browser proxy is disabled (gateway.nodes.browser.mode=off)."); - } - return null; - } - if (params.sandboxBridgeUrl?.trim() && params.target !== "node" && !params.requestedNode) { - return null; - } - if (params.target && params.target !== "node") { - return null; - } - if (mode === "manual" && params.target !== "node" && !params.requestedNode) { - return null; - } - - const nodes = await browserToolDeps.listNodes({}); - const browserNodes = nodes.filter((node) => node.connected && isBrowserNode(node)); - if (browserNodes.length === 0) { - if (params.target === "node" || params.requestedNode) { - throw new Error("No connected browser-capable nodes."); - } - return null; - } - - const requested = params.requestedNode?.trim() || policy?.node?.trim(); - if (requested) { - const nodeId = resolveNodeIdFromList(browserNodes, requested, false); - const node = browserNodes.find((entry) => entry.nodeId === nodeId); - return { nodeId, label: node?.displayName ?? node?.remoteIp ?? nodeId }; - } - - const selected = selectDefaultNodeFromList(browserNodes, { - preferLocalMac: false, - fallback: "none", - }); - - if (params.target === "node") { - if (selected) { - return { - nodeId: selected.nodeId, - label: selected.displayName ?? selected.remoteIp ?? selected.nodeId, - }; - } - throw new Error( - `Multiple browser-capable nodes connected (${browserNodes.length}). Set gateway.nodes.browser.node or pass node=.`, - ); - } - - if (mode === "manual") { - return null; - } - - if (selected) { - return { - nodeId: selected.nodeId, - label: selected.displayName ?? selected.remoteIp ?? selected.nodeId, - }; - } - return null; -} - -async function callBrowserProxy(params: { - nodeId: string; - method: string; - path: string; - query?: Record; - body?: unknown; - timeoutMs?: number; - profile?: string; -}): Promise { - const proxyTimeoutMs = - typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) - ? Math.max(1, Math.floor(params.timeoutMs)) - : DEFAULT_BROWSER_PROXY_TIMEOUT_MS; - const gatewayTimeoutMs = proxyTimeoutMs + BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS; - const payload = await browserToolDeps.callGatewayTool<{ payloadJSON?: string; payload?: string }>( - "node.invoke", - { timeoutMs: gatewayTimeoutMs }, - { - nodeId: params.nodeId, - command: "browser.proxy", - params: { - method: params.method, - path: params.path, - query: params.query, - body: params.body, - timeoutMs: proxyTimeoutMs, - profile: params.profile, - }, - idempotencyKey: crypto.randomUUID(), - }, - ); - const parsed = - payload?.payload ?? - (typeof payload?.payloadJSON === "string" && payload.payloadJSON - ? (JSON.parse(payload.payloadJSON) as BrowserProxyResult) - : null); - if (!parsed || typeof parsed !== "object" || !("result" in parsed)) { - throw new Error("browser proxy failed"); - } - return parsed; -} - -async function persistProxyFiles(files: BrowserProxyFile[] | undefined) { - return await persistBrowserProxyFiles(files); -} - -function applyProxyPaths(result: unknown, mapping: Map) { - applyBrowserProxyPaths(result, mapping); -} - -function resolveBrowserBaseUrl(params: { - target?: "sandbox" | "host"; - sandboxBridgeUrl?: string; - allowHostControl?: boolean; -}): string | undefined { - const cfg = loadConfig(); - const resolved = resolveBrowserConfig(cfg.browser, cfg); - const normalizedSandbox = params.sandboxBridgeUrl?.trim() ?? ""; - const target = params.target ?? (normalizedSandbox ? "sandbox" : "host"); - - if (target === "sandbox") { - if (!normalizedSandbox) { - throw new Error( - 'Sandbox browser is unavailable. Enable agents.defaults.sandbox.browser.enabled or use target="host" if allowed.', - ); - } - return normalizedSandbox.replace(/\/$/, ""); - } - - if (params.allowHostControl === false) { - throw new Error("Host browser control is disabled by sandbox policy."); - } - if (!resolved.enabled) { - throw new Error( - "Browser control is disabled. Set browser.enabled=true in ~/.openclaw/openclaw.json.", - ); - } - return undefined; -} - -function shouldPreferHostForProfile(profileName: string | undefined) { - if (!profileName) { - return false; - } - const cfg = browserToolDeps.loadConfig(); - const resolved = resolveBrowserConfig(cfg.browser, cfg); - const profile = resolveProfile(resolved, profileName); - if (!profile) { - return false; - } - const capabilities = getBrowserProfileCapabilities(profile); - return capabilities.usesChromeMcp; -} - -export function createBrowserTool(opts?: { - sandboxBridgeUrl?: string; - allowHostControl?: boolean; - agentSessionKey?: string; -}): AnyAgentTool { - const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host"; - const hostHint = - opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed."; - return { - label: "Browser", - name: "browser", - description: [ - "Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).", - "Browser choice: omit profile by default for the isolated OpenClaw-managed browser (`openclaw`).", - 'For the logged-in user browser on the local host, use profile="user". A supported Chromium-based browser (v144+) must be running. Use only when existing logins/cookies matter and the user is present.', - 'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node= or target="node".', - "When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).", - 'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.', - "Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.", - `target selects browser location (sandbox|host|node). Default: ${targetDefault}.`, - hostHint, - ].join(" "), - parameters: BrowserToolSchema, - execute: async (_toolCallId, args) => { - const params = args as Record; - const action = readStringParam(params, "action", { required: true }); - const profile = readStringParam(params, "profile"); - const requestedNode = readStringParam(params, "node"); - let target = readStringParam(params, "target") as "sandbox" | "host" | "node" | undefined; - - if (requestedNode && target && target !== "node") { - throw new Error('node is only supported with target="node".'); - } - // User-browser profiles (existing-session) are host-only. - const isUserBrowserProfile = shouldPreferHostForProfile(profile); - if (isUserBrowserProfile) { - if (requestedNode || target === "node") { - throw new Error(`profile="${profile}" only supports the local host browser.`); - } - if (target === "sandbox") { - throw new Error( - `profile="${profile}" cannot use the sandbox browser; use target="host" or omit target.`, - ); - } - if (!target && !requestedNode) { - target = "host"; - } - } - - const nodeTarget = await resolveBrowserNodeTarget({ - requestedNode: requestedNode ?? undefined, - target, - sandboxBridgeUrl: opts?.sandboxBridgeUrl, - }); - - const resolvedTarget = target === "node" ? undefined : target; - const baseUrl = nodeTarget - ? undefined - : resolveBrowserBaseUrl({ - target: resolvedTarget, - sandboxBridgeUrl: opts?.sandboxBridgeUrl, - allowHostControl: opts?.allowHostControl, - }); - - const proxyRequest = nodeTarget - ? async (opts: { - method: string; - path: string; - query?: Record; - body?: unknown; - timeoutMs?: number; - profile?: string; - }) => { - const proxy = await callBrowserProxy({ - nodeId: nodeTarget.nodeId, - method: opts.method, - path: opts.path, - query: opts.query, - body: opts.body, - timeoutMs: opts.timeoutMs, - profile: opts.profile, - }); - const mapping = await persistProxyFiles(proxy.files); - applyProxyPaths(proxy.result, mapping); - return proxy.result; - } - : null; - - switch (action) { - case "status": - if (proxyRequest) { - return jsonResult( - await proxyRequest({ - method: "GET", - path: "/", - profile, - }), - ); - } - return jsonResult(await browserToolDeps.browserStatus(baseUrl, { profile })); - case "start": - if (proxyRequest) { - await proxyRequest({ - method: "POST", - path: "/start", - profile, - }); - return jsonResult( - await proxyRequest({ - method: "GET", - path: "/", - profile, - }), - ); - } - await browserToolDeps.browserStart(baseUrl, { profile }); - return jsonResult(await browserToolDeps.browserStatus(baseUrl, { profile })); - case "stop": - if (proxyRequest) { - await proxyRequest({ - method: "POST", - path: "/stop", - profile, - }); - return jsonResult( - await proxyRequest({ - method: "GET", - path: "/", - profile, - }), - ); - } - await browserToolDeps.browserStop(baseUrl, { profile }); - return jsonResult(await browserToolDeps.browserStatus(baseUrl, { profile })); - case "profiles": - if (proxyRequest) { - const result = await proxyRequest({ - method: "GET", - path: "/profiles", - }); - return jsonResult(result); - } - return jsonResult({ profiles: await browserToolDeps.browserProfiles(baseUrl) }); - case "tabs": - return await executeTabsAction({ baseUrl, profile, proxyRequest }); - case "open": { - const targetUrl = readTargetUrlParam(params); - if (proxyRequest) { - const result = await proxyRequest({ - method: "POST", - path: "/tabs/open", - profile, - body: { url: targetUrl }, - }); - return jsonResult(result); - } - const opened = await browserToolDeps.browserOpenTab(baseUrl, targetUrl, { profile }); - browserToolDeps.trackSessionBrowserTab({ - sessionKey: opts?.agentSessionKey, - targetId: opened.targetId, - baseUrl, - profile, - }); - return jsonResult(opened); - } - case "focus": { - const targetId = readStringParam(params, "targetId", { - required: true, - }); - if (proxyRequest) { - const result = await proxyRequest({ - method: "POST", - path: "/tabs/focus", - profile, - body: { targetId }, - }); - return jsonResult(result); - } - await browserToolDeps.browserFocusTab(baseUrl, targetId, { profile }); - return jsonResult({ ok: true }); - } - case "close": { - const targetId = readStringParam(params, "targetId"); - if (proxyRequest) { - const result = targetId - ? await proxyRequest({ - method: "DELETE", - path: `/tabs/${encodeURIComponent(targetId)}`, - profile, - }) - : await proxyRequest({ - method: "POST", - path: "/act", - profile, - body: { kind: "close" }, - }); - return jsonResult(result); - } - if (targetId) { - await browserToolDeps.browserCloseTab(baseUrl, targetId, { profile }); - browserToolDeps.untrackSessionBrowserTab({ - sessionKey: opts?.agentSessionKey, - targetId, - baseUrl, - profile, - }); - } else { - await browserToolDeps.browserAct(baseUrl, { kind: "close" }, { profile }); - } - return jsonResult({ ok: true }); - } - case "snapshot": - return await executeSnapshotAction({ - input: params, - baseUrl, - profile, - proxyRequest, - }); - case "screenshot": { - const targetId = readStringParam(params, "targetId"); - const fullPage = Boolean(params.fullPage); - const ref = readStringParam(params, "ref"); - const element = readStringParam(params, "element"); - const type = params.type === "jpeg" ? "jpeg" : "png"; - const result = proxyRequest - ? ((await proxyRequest({ - method: "POST", - path: "/screenshot", - profile, - body: { - targetId, - fullPage, - ref, - element, - type, - }, - })) as Awaited>) - : await browserToolDeps.browserScreenshotAction(baseUrl, { - targetId, - fullPage, - ref, - element, - type, - profile, - }); - return await browserToolDeps.imageResultFromFile({ - label: "browser:screenshot", - path: result.path, - details: result, - }); - } - case "navigate": { - const targetUrl = readTargetUrlParam(params); - const targetId = readStringParam(params, "targetId"); - if (proxyRequest) { - const result = await proxyRequest({ - method: "POST", - path: "/navigate", - profile, - body: { - url: targetUrl, - targetId, - }, - }); - return jsonResult(result); - } - return jsonResult( - await browserToolDeps.browserNavigate(baseUrl, { - url: targetUrl, - targetId, - profile, - }), - ); - } - case "console": - return await executeConsoleAction({ - input: params, - baseUrl, - profile, - proxyRequest, - }); - case "pdf": { - const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined; - const result = proxyRequest - ? ((await proxyRequest({ - method: "POST", - path: "/pdf", - profile, - body: { targetId }, - })) as Awaited>) - : await browserToolDeps.browserPdfSave(baseUrl, { targetId, profile }); - return { - content: [{ type: "text" as const, text: `FILE:${result.path}` }], - details: result, - }; - } - case "upload": { - const paths = Array.isArray(params.paths) ? params.paths.map((p) => String(p)) : []; - if (paths.length === 0) { - throw new Error("paths required"); - } - const uploadPathsResult = await resolveExistingPathsWithinRoot({ - rootDir: DEFAULT_UPLOAD_DIR, - requestedPaths: paths, - scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`, - }); - if (!uploadPathsResult.ok) { - throw new Error(uploadPathsResult.error); - } - const normalizedPaths = uploadPathsResult.paths; - const ref = readStringParam(params, "ref"); - const inputRef = readStringParam(params, "inputRef"); - const element = readStringParam(params, "element"); - const { targetId, timeoutMs } = readOptionalTargetAndTimeout(params); - if (proxyRequest) { - const result = await proxyRequest({ - method: "POST", - path: "/hooks/file-chooser", - profile, - body: { - paths: normalizedPaths, - ref, - inputRef, - element, - targetId, - timeoutMs, - }, - }); - return jsonResult(result); - } - return jsonResult( - await browserToolDeps.browserArmFileChooser(baseUrl, { - paths: normalizedPaths, - ref, - inputRef, - element, - targetId, - timeoutMs, - profile, - }), - ); - } - case "dialog": { - const accept = Boolean(params.accept); - const promptText = typeof params.promptText === "string" ? params.promptText : undefined; - const { targetId, timeoutMs } = readOptionalTargetAndTimeout(params); - if (proxyRequest) { - const result = await proxyRequest({ - method: "POST", - path: "/hooks/dialog", - profile, - body: { - accept, - promptText, - targetId, - timeoutMs, - }, - }); - return jsonResult(result); - } - return jsonResult( - await browserToolDeps.browserArmDialog(baseUrl, { - accept, - promptText, - targetId, - timeoutMs, - profile, - }), - ); - } - case "act": { - const request = readActRequestParam(params); - if (!request) { - throw new Error("request required"); - } - return await executeActAction({ - request, - baseUrl, - profile, - proxyRequest, - }); - } - default: - throw new Error(`Unknown action: ${action}`); - } - }, - }; -} +export * from "../../../extensions/browser/src/browser-tool.js"; diff --git a/src/browser/bridge-auth-registry.ts b/src/browser/bridge-auth-registry.ts index ef9346bf340..d4c6e592349 100644 --- a/src/browser/bridge-auth-registry.ts +++ b/src/browser/bridge-auth-registry.ts @@ -1,34 +1 @@ -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(); - -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); -} +export * from "../../extensions/browser/src/browser/bridge-auth-registry.js"; diff --git a/src/browser/bridge-server.ts b/src/browser/bridge-server.ts index c1d0c082201..66d96ccf94e 100644 --- a/src/browser/bridge-server.ts +++ b/src/browser/bridge-server.ts @@ -1,146 +1 @@ -import type { Server } from "node:http"; -import type { AddressInfo } from "node:net"; -import express from "express"; -import { isLoopbackHost } from "../gateway/net.js"; -import { deleteBridgeAuthForPort, setBridgeAuthForPort } from "./bridge-auth-registry.js"; -import type { ResolvedBrowserConfig } from "./config.js"; -import { registerBrowserRoutes } from "./routes/index.js"; -import type { BrowserRouteRegistrar } from "./routes/types.js"; -import { - type BrowserServerState, - createBrowserRouteContext, - type ProfileContext, -} from "./server-context.js"; -import { - installBrowserAuthMiddleware, - installBrowserCommonMiddleware, -} from "./server-middleware.js"; - -export type BrowserBridge = { - server: Server; - port: number; - baseUrl: string; - state: BrowserServerState; -}; - -type ResolvedNoVncObserver = { - noVncPort: number; - password?: string; -}; - -function buildNoVncBootstrapHtml(params: ResolvedNoVncObserver): string { - const hash = new URLSearchParams({ - autoconnect: "1", - resize: "remote", - }); - if (params.password?.trim()) { - hash.set("password", params.password); - } - const targetUrl = `http://127.0.0.1:${params.noVncPort}/vnc.html#${hash.toString()}`; - const encodedTarget = JSON.stringify(targetUrl); - return ` - - - - - - OpenClaw noVNC Observer - - -

Opening sandbox observer...

- - -`; -} - -export async function startBrowserBridgeServer(params: { - resolved: ResolvedBrowserConfig; - host?: string; - port?: number; - authToken?: string; - authPassword?: string; - onEnsureAttachTarget?: (profile: ProfileContext["profile"]) => Promise; - resolveSandboxNoVncToken?: (token: string) => ResolvedNoVncObserver | null; -}): Promise { - const host = params.host ?? "127.0.0.1"; - if (!isLoopbackHost(host)) { - throw new Error(`bridge server must bind to loopback host (got ${host})`); - } - const port = params.port ?? 0; - - const app = express(); - installBrowserCommonMiddleware(app); - - if (params.resolveSandboxNoVncToken) { - app.get("/sandbox/novnc", (req, res) => { - res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate"); - res.setHeader("Pragma", "no-cache"); - res.setHeader("Expires", "0"); - res.setHeader("Referrer-Policy", "no-referrer"); - const rawToken = typeof req.query?.token === "string" ? req.query.token.trim() : ""; - if (!rawToken) { - res.status(400).send("Missing token"); - return; - } - const resolved = params.resolveSandboxNoVncToken?.(rawToken); - if (!resolved) { - res.status(404).send("Invalid or expired token"); - return; - } - res.type("html").status(200).send(buildNoVncBootstrapHtml(resolved)); - }); - } - - const authToken = params.authToken?.trim() || undefined; - const authPassword = params.authPassword?.trim() || undefined; - if (!authToken && !authPassword) { - throw new Error("bridge server requires auth (authToken/authPassword missing)"); - } - installBrowserAuthMiddleware(app, { token: authToken, password: authPassword }); - - const state: BrowserServerState = { - server: null as unknown as Server, - port, - resolved: params.resolved, - profiles: new Map(), - }; - - const ctx = createBrowserRouteContext({ - getState: () => state, - onEnsureAttachTarget: params.onEnsureAttachTarget, - }); - registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx); - - const server = await new Promise((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 { - try { - const address = server.address() as AddressInfo | null; - if (address?.port) { - deleteBridgeAuthForPort(address.port); - } - } catch { - // ignore - } - await new Promise((resolve) => { - server.close(() => resolve()); - }); -} +export * from "../../extensions/browser/src/browser/bridge-server.js"; diff --git a/src/browser/cdp-proxy-bypass.ts b/src/browser/cdp-proxy-bypass.ts index 8db5276fc51..a006e95cc3f 100644 --- a/src/browser/cdp-proxy-bypass.ts +++ b/src/browser/cdp-proxy-bypass.ts @@ -1,151 +1 @@ -/** - * Proxy bypass for CDP (Chrome DevTools Protocol) localhost connections. - * - * When HTTP_PROXY / HTTPS_PROXY / ALL_PROXY environment variables are set, - * CDP connections to localhost/127.0.0.1 can be incorrectly routed through - * the proxy, causing browser control to fail. - * - * @see https://github.com/nicepkg/openclaw/issues/31219 - */ -import http from "node:http"; -import https from "node:https"; -import { isLoopbackHost } from "../gateway/net.js"; -import { hasProxyEnvConfigured } from "../infra/net/proxy-env.js"; - -/** HTTP agent that never uses a proxy — for localhost CDP connections. */ -const directHttpAgent = new http.Agent(); -const directHttpsAgent = new https.Agent(); - -/** - * Returns a plain (non-proxy) agent for WebSocket or HTTP connections - * when the target is a loopback address. Returns `undefined` otherwise - * so callers fall through to their default behaviour. - */ -export function getDirectAgentForCdp(url: string): http.Agent | https.Agent | undefined { - try { - const parsed = new URL(url); - if (isLoopbackHost(parsed.hostname)) { - return parsed.protocol === "https:" || parsed.protocol === "wss:" - ? directHttpsAgent - : directHttpAgent; - } - } catch { - // not a valid URL — let caller handle it - } - return undefined; -} - -/** - * Returns `true` when any proxy-related env var is set that could - * interfere with loopback connections. - */ -export function hasProxyEnv(): boolean { - return hasProxyEnvConfigured(); -} - -const LOOPBACK_ENTRIES = "localhost,127.0.0.1,[::1]"; - -function noProxyAlreadyCoversLocalhost(): boolean { - const current = process.env.NO_PROXY || process.env.no_proxy || ""; - return ( - current.includes("localhost") && current.includes("127.0.0.1") && current.includes("[::1]") - ); -} - -export async function withNoProxyForLocalhost(fn: () => Promise): Promise { - return await withNoProxyForCdpUrl("http://127.0.0.1", fn); -} - -function isLoopbackCdpUrl(url: string): boolean { - try { - return isLoopbackHost(new URL(url).hostname); - } catch { - return false; - } -} - -type NoProxySnapshot = { - noProxy: string | undefined; - noProxyLower: string | undefined; - applied: string; -}; - -class NoProxyLeaseManager { - private leaseCount = 0; - private snapshot: NoProxySnapshot | null = null; - - acquire(url: string): (() => void) | null { - if (!isLoopbackCdpUrl(url) || !hasProxyEnv()) { - return null; - } - - if (this.leaseCount === 0 && !noProxyAlreadyCoversLocalhost()) { - const noProxy = process.env.NO_PROXY; - const noProxyLower = process.env.no_proxy; - const current = noProxy || noProxyLower || ""; - const applied = current ? `${current},${LOOPBACK_ENTRIES}` : LOOPBACK_ENTRIES; - process.env.NO_PROXY = applied; - process.env.no_proxy = applied; - this.snapshot = { noProxy, noProxyLower, applied }; - } - - this.leaseCount += 1; - let released = false; - return () => { - if (released) { - return; - } - released = true; - this.release(); - }; - } - - private release() { - if (this.leaseCount <= 0) { - return; - } - this.leaseCount -= 1; - if (this.leaseCount > 0 || !this.snapshot) { - return; - } - - const { noProxy, noProxyLower, applied } = this.snapshot; - const currentNoProxy = process.env.NO_PROXY; - const currentNoProxyLower = process.env.no_proxy; - const untouched = - currentNoProxy === applied && - (currentNoProxyLower === applied || currentNoProxyLower === undefined); - if (untouched) { - if (noProxy !== undefined) { - process.env.NO_PROXY = noProxy; - } else { - delete process.env.NO_PROXY; - } - if (noProxyLower !== undefined) { - process.env.no_proxy = noProxyLower; - } else { - delete process.env.no_proxy; - } - } - - this.snapshot = null; - } -} - -const noProxyLeaseManager = new NoProxyLeaseManager(); - -/** - * Scoped NO_PROXY bypass for loopback CDP URLs. - * - * This wrapper only mutates env vars for loopback destinations. On restore, - * it avoids clobbering external NO_PROXY changes that happened while calls - * were in-flight. - */ -export async function withNoProxyForCdpUrl(url: string, fn: () => Promise): Promise { - const release = noProxyLeaseManager.acquire(url); - try { - return await fn(); - } finally { - release?.(); - } -} +export * from "../../extensions/browser/src/browser/cdp-proxy-bypass.js"; diff --git a/src/browser/cdp-timeouts.ts b/src/browser/cdp-timeouts.ts index 1014972e42c..a793565fe67 100644 --- a/src/browser/cdp-timeouts.ts +++ b/src/browser/cdp-timeouts.ts @@ -1,56 +1 @@ -export const CDP_HTTP_REQUEST_TIMEOUT_MS = 1500; -export const CDP_WS_HANDSHAKE_TIMEOUT_MS = 5000; -export const CDP_JSON_NEW_TIMEOUT_MS = 1500; - -export const CHROME_REACHABILITY_TIMEOUT_MS = 500; -export const CHROME_WS_READY_TIMEOUT_MS = 800; -export const CHROME_BOOTSTRAP_PREFS_TIMEOUT_MS = 10_000; -export const CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS = 5000; -export const CHROME_LAUNCH_READY_WINDOW_MS = 15_000; -export const CHROME_LAUNCH_READY_POLL_MS = 200; -export const CHROME_STOP_TIMEOUT_MS = 2500; -export const CHROME_STOP_PROBE_TIMEOUT_MS = 200; -export const CHROME_STDERR_HINT_MAX_CHARS = 2000; - -export const PROFILE_HTTP_REACHABILITY_TIMEOUT_MS = 300; -export const PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS = 200; -export const PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS = 2000; -export const PROFILE_ATTACH_RETRY_TIMEOUT_MS = 1200; -export const PROFILE_POST_RESTART_WS_TIMEOUT_MS = 600; -export const CHROME_MCP_ATTACH_READY_WINDOW_MS = 8000; -export const CHROME_MCP_ATTACH_READY_POLL_MS = 200; - -function normalizeTimeoutMs(value: number | undefined): number | undefined { - if (typeof value !== "number" || !Number.isFinite(value)) { - return undefined; - } - return Math.max(1, Math.floor(value)); -} - -export function resolveCdpReachabilityTimeouts(params: { - profileIsLoopback: boolean; - timeoutMs?: number; - remoteHttpTimeoutMs: number; - remoteHandshakeTimeoutMs: number; -}): { httpTimeoutMs: number; wsTimeoutMs: number } { - const normalized = normalizeTimeoutMs(params.timeoutMs); - if (params.profileIsLoopback) { - const httpTimeoutMs = normalized ?? PROFILE_HTTP_REACHABILITY_TIMEOUT_MS; - const wsTimeoutMs = Math.max( - PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS, - Math.min(PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS, httpTimeoutMs * 2), - ); - return { httpTimeoutMs, wsTimeoutMs }; - } - - if (normalized !== undefined) { - return { - httpTimeoutMs: Math.max(normalized, params.remoteHttpTimeoutMs), - wsTimeoutMs: Math.max(normalized * 2, params.remoteHandshakeTimeoutMs), - }; - } - return { - httpTimeoutMs: params.remoteHttpTimeoutMs, - wsTimeoutMs: params.remoteHandshakeTimeoutMs, - }; -} +export * from "../../extensions/browser/src/browser/cdp-timeouts.js"; diff --git a/src/browser/cdp.helpers.ts b/src/browser/cdp.helpers.ts index 3bc02362b55..bc35820a6a7 100644 --- a/src/browser/cdp.helpers.ts +++ b/src/browser/cdp.helpers.ts @@ -1,280 +1 @@ -import WebSocket from "ws"; -import { isLoopbackHost } from "../gateway/net.js"; -import { type SsrFPolicy, resolvePinnedHostnameWithPolicy } from "../infra/net/ssrf.js"; -import { rawDataToString } from "../infra/ws.js"; -import { redactSensitiveText } from "../logging/redact.js"; -import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; -import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js"; -import { resolveBrowserRateLimitMessage } from "./client-fetch.js"; - -export { isLoopbackHost }; - -/** - * Returns true when the URL uses a WebSocket protocol (ws: or wss:). - * Used to distinguish direct-WebSocket CDP endpoints - * from HTTP(S) endpoints that require /json/version discovery. - */ -export function isWebSocketUrl(url: string): boolean { - try { - const parsed = new URL(url); - return parsed.protocol === "ws:" || parsed.protocol === "wss:"; - } catch { - return false; - } -} - -export async function assertCdpEndpointAllowed( - cdpUrl: string, - ssrfPolicy?: SsrFPolicy, -): Promise { - if (!ssrfPolicy) { - return; - } - const parsed = new URL(cdpUrl); - if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) { - throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`); - } - await resolvePinnedHostnameWithPolicy(parsed.hostname, { - policy: ssrfPolicy, - }); -} - -export function redactCdpUrl(cdpUrl: string | null | undefined): string | null | undefined { - if (typeof cdpUrl !== "string") { - return cdpUrl; - } - const trimmed = cdpUrl.trim(); - if (!trimmed) { - return trimmed; - } - try { - const parsed = new URL(trimmed); - parsed.username = ""; - parsed.password = ""; - return redactSensitiveText(parsed.toString().replace(/\/$/, "")); - } catch { - return redactSensitiveText(trimmed); - } -} - -type CdpResponse = { - id: number; - result?: unknown; - error?: { message?: string }; -}; - -type Pending = { - resolve: (value: unknown) => void; - reject: (err: Error) => void; -}; - -export type CdpSendFn = ( - method: string, - params?: Record, - sessionId?: string, -) => Promise; - -export function getHeadersWithAuth(url: string, headers: Record = {}) { - const mergedHeaders = { ...headers }; - try { - const parsed = new URL(url); - const hasAuthHeader = Object.keys(mergedHeaders).some( - (key) => key.toLowerCase() === "authorization", - ); - if (hasAuthHeader) { - return mergedHeaders; - } - if (parsed.username || parsed.password) { - const auth = Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64"); - return { ...mergedHeaders, Authorization: `Basic ${auth}` }; - } - } catch { - // ignore - } - return mergedHeaders; -} - -export function appendCdpPath(cdpUrl: string, path: string): string { - const url = new URL(cdpUrl); - const basePath = url.pathname.replace(/\/$/, ""); - const suffix = path.startsWith("/") ? path : `/${path}`; - url.pathname = `${basePath}${suffix}`; - return url.toString(); -} - -export function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string { - try { - const url = new URL(cdpUrl); - if (url.protocol === "ws:") { - url.protocol = "http:"; - } else if (url.protocol === "wss:") { - url.protocol = "https:"; - } - url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, ""); - url.pathname = url.pathname.replace(/\/cdp$/, ""); - return url.toString().replace(/\/$/, ""); - } catch { - // Best-effort fallback for non-URL-ish inputs. - return cdpUrl - .replace(/^ws:/, "http:") - .replace(/^wss:/, "https:") - .replace(/\/devtools\/browser\/.*$/, "") - .replace(/\/cdp$/, "") - .replace(/\/$/, ""); - } -} - -function createCdpSender(ws: WebSocket) { - let nextId = 1; - const pending = new Map(); - - const send: CdpSendFn = ( - method: string, - params?: Record, - sessionId?: string, - ) => { - const id = nextId++; - const msg = { id, method, params, sessionId }; - ws.send(JSON.stringify(msg)); - return new Promise((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( - url: string, - timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS, - init?: RequestInit, -): Promise { - const res = await fetchCdpChecked(url, timeoutMs, init); - return (await res.json()) as T; -} - -export async function fetchCdpChecked( - url: string, - timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS, - init?: RequestInit, -): Promise { - const ctrl = new AbortController(); - const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs); - try { - const headers = getHeadersWithAuth(url, (init?.headers as Record) || {}); - const res = await withNoProxyForCdpUrl(url, () => - fetch(url, { ...init, headers, signal: ctrl.signal }), - ); - if (!res.ok) { - if (res.status === 429) { - // Do not reflect upstream response text into the error surface (log/agent injection risk) - throw new Error(`${resolveBrowserRateLimitMessage(url)} Do NOT retry the browser tool.`); - } - throw new Error(`HTTP ${res.status}`); - } - return res; - } finally { - clearTimeout(t); - } -} - -export async function fetchOk( - url: string, - timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS, - init?: RequestInit, -): Promise { - await fetchCdpChecked(url, timeoutMs, init); -} - -export function openCdpWebSocket( - wsUrl: string, - opts?: { headers?: Record; handshakeTimeoutMs?: number }, -): WebSocket { - const headers = getHeadersWithAuth(wsUrl, opts?.headers ?? {}); - const handshakeTimeoutMs = - typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs) - ? Math.max(1, Math.floor(opts.handshakeTimeoutMs)) - : CDP_WS_HANDSHAKE_TIMEOUT_MS; - const agent = getDirectAgentForCdp(wsUrl); - return new WebSocket(wsUrl, { - handshakeTimeout: handshakeTimeoutMs, - ...(Object.keys(headers).length ? { headers } : {}), - ...(agent ? { agent } : {}), - }); -} - -export async function withCdpSocket( - wsUrl: string, - fn: (send: CdpSendFn) => Promise, - opts?: { headers?: Record; handshakeTimeoutMs?: number }, -): Promise { - const ws = openCdpWebSocket(wsUrl, opts); - const { send, closeWithError } = createCdpSender(ws); - - const openPromise = new Promise((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 - } - } -} +export * from "../../extensions/browser/src/browser/cdp.helpers.js"; diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index d8b9994089b..ea2a3e3a8d8 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -1,485 +1 @@ -import type { SsrFPolicy } from "../infra/net/ssrf.js"; -import { - appendCdpPath, - fetchJson, - isLoopbackHost, - isWebSocketUrl, - withCdpSocket, -} from "./cdp.helpers.js"; -import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js"; - -export { - appendCdpPath, - fetchJson, - fetchOk, - getHeadersWithAuth, - isWebSocketUrl, -} from "./cdp.helpers.js"; - -export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string { - const ws = new URL(wsUrl); - const cdp = new URL(cdpUrl); - // Treat 0.0.0.0 and :: as wildcard bind addresses that need rewriting. - // Containerized browsers (e.g. browserless) report ws://0.0.0.0: - // in /json/version — these must be rewritten to the external cdpUrl host:port. - const isWildcardBind = ws.hostname === "0.0.0.0" || ws.hostname === "[::]"; - if ((isLoopbackHost(ws.hostname) || isWildcardBind) && !isLoopbackHost(cdp.hostname)) { - ws.hostname = cdp.hostname; - const cdpPort = cdp.port || (cdp.protocol === "https:" ? "443" : "80"); - if (cdpPort) { - ws.port = cdpPort; - } - ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:"; - } - if (cdp.protocol === "https:" && ws.protocol === "ws:") { - ws.protocol = "wss:"; - } - if (!ws.username && !ws.password && (cdp.username || cdp.password)) { - ws.username = cdp.username; - ws.password = cdp.password; - } - for (const [key, value] of cdp.searchParams.entries()) { - if (!ws.searchParams.has(key)) { - ws.searchParams.append(key, value); - } - } - return ws.toString(); -} - -export async function captureScreenshotPng(opts: { - wsUrl: string; - fullPage?: boolean; -}): Promise { - 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 { - return await withCdpSocket(opts.wsUrl, async (send) => { - await send("Page.enable"); - - let clip: { x: number; y: number; width: number; height: number; scale: number } | undefined; - if (opts.fullPage) { - const metrics = (await send("Page.getLayoutMetrics")) as { - cssContentSize?: { width?: number; height?: number }; - contentSize?: { width?: number; height?: number }; - }; - const size = metrics?.cssContentSize ?? metrics?.contentSize; - const width = Number(size?.width ?? 0); - const height = Number(size?.height ?? 0); - if (width > 0 && height > 0) { - clip = { x: 0, y: 0, width, height, scale: 1 }; - } - } - - const format = opts.format ?? "png"; - const quality = - format === "jpeg" ? Math.max(0, Math.min(100, Math.round(opts.quality ?? 85))) : undefined; - - const result = (await send("Page.captureScreenshot", { - format, - ...(quality !== undefined ? { quality } : {}), - fromSurface: true, - captureBeyondViewport: true, - ...(clip ? { clip } : {}), - })) as { data?: string }; - - const base64 = result?.data; - if (!base64) { - throw new Error("Screenshot failed: missing data"); - } - return Buffer.from(base64, "base64"); - }); -} - -export async function createTargetViaCdp(opts: { - cdpUrl: string; - url: string; - ssrfPolicy?: SsrFPolicy; -}): Promise<{ targetId: string }> { - await assertBrowserNavigationAllowed({ - url: opts.url, - ...withBrowserNavigationPolicy(opts.ssrfPolicy), - }); - - let wsUrl: string; - if (isWebSocketUrl(opts.cdpUrl)) { - // Direct WebSocket URL — skip /json/version discovery. - wsUrl = opts.cdpUrl; - } else { - // Standard HTTP(S) CDP endpoint — discover WebSocket URL via /json/version. - const version = await fetchJson<{ webSocketDebuggerUrl?: string }>( - appendCdpPath(opts.cdpUrl, "/json/version"), - 1500, - ); - const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim(); - wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : ""; - if (!wsUrl) { - throw new Error("CDP /json/version missing webSocketDebuggerUrl"); - } - } - - return await withCdpSocket(wsUrl, async (send) => { - const created = (await send("Target.createTarget", { url: opts.url })) as { - targetId?: string; - }; - const targetId = String(created?.targetId ?? "").trim(); - if (!targetId) { - throw new Error("CDP Target.createTarget returned no targetId"); - } - return { targetId }; - }); -} - -export type CdpRemoteObject = { - type: string; - subtype?: string; - value?: unknown; - description?: string; - unserializableValue?: string; - preview?: unknown; -}; - -export type CdpExceptionDetails = { - text?: string; - lineNumber?: number; - columnNumber?: number; - exception?: CdpRemoteObject; - stackTrace?: unknown; -}; - -export async function evaluateJavaScript(opts: { - wsUrl: string; - expression: string; - awaitPromise?: boolean; - returnByValue?: boolean; -}): Promise<{ - result: CdpRemoteObject; - exceptionDetails?: CdpExceptionDetails; -}> { - return await withCdpSocket(opts.wsUrl, async (send) => { - await send("Runtime.enable").catch(() => {}); - const evaluated = (await send("Runtime.evaluate", { - expression: opts.expression, - awaitPromise: Boolean(opts.awaitPromise), - returnByValue: opts.returnByValue ?? true, - userGesture: true, - includeCommandLineAPI: true, - })) as { - result?: CdpRemoteObject; - exceptionDetails?: CdpExceptionDetails; - }; - - const result = evaluated?.result; - if (!result) { - throw new Error("CDP Runtime.evaluate returned no result"); - } - return { result, exceptionDetails: evaluated.exceptionDetails }; - }); -} - -export type AriaSnapshotNode = { - ref: string; - role: string; - name: string; - value?: string; - description?: string; - backendDOMNodeId?: number; - depth: number; -}; - -export type RawAXNode = { - nodeId?: string; - role?: { value?: string }; - name?: { value?: string }; - value?: { value?: string }; - description?: { value?: string }; - childIds?: string[]; - backendDOMNodeId?: number; -}; - -function axValue(v: unknown): string { - if (!v || typeof v !== "object") { - return ""; - } - const value = (v as { value?: unknown }).value; - if (typeof value === "string") { - return value; - } - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - return ""; -} - -export function formatAriaSnapshot(nodes: RawAXNode[], limit: number): AriaSnapshotNode[] { - const byId = new Map(); - 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(); - 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"; - 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; -}; +export * from "../../extensions/browser/src/browser/cdp.js"; diff --git a/src/browser/chrome-mcp.snapshot.ts b/src/browser/chrome-mcp.snapshot.ts index f0a1413736a..59e24e95383 100644 --- a/src/browser/chrome-mcp.snapshot.ts +++ b/src/browser/chrome-mcp.snapshot.ts @@ -1,193 +1 @@ -import type { SnapshotAriaNode } from "./client.js"; -import { - getRoleSnapshotStats, - type RoleRefMap, - type RoleSnapshotOptions, -} from "./pw-role-snapshot.js"; -import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js"; - -export type ChromeMcpSnapshotNode = { - id?: string; - role?: string; - name?: string; - value?: string | number | boolean; - description?: string; - children?: ChromeMcpSnapshotNode[]; -}; - -function normalizeRole(node: ChromeMcpSnapshotNode): string { - const role = typeof node.role === "string" ? node.role.trim().toLowerCase() : ""; - return role || "generic"; -} - -function normalizeString(value: unknown): string | undefined { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed || undefined; - } - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - return undefined; -} - -function escapeQuoted(value: string): string { - return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); -} - -function shouldIncludeNode(params: { - role: string; - name?: string; - options?: RoleSnapshotOptions; -}): boolean { - if (params.options?.interactive && !INTERACTIVE_ROLES.has(params.role)) { - return false; - } - if (params.options?.compact && STRUCTURAL_ROLES.has(params.role) && !params.name) { - return false; - } - return true; -} - -function shouldCreateRef(role: string, name?: string): boolean { - return INTERACTIVE_ROLES.has(role) || (CONTENT_ROLES.has(role) && Boolean(name)); -} - -type DuplicateTracker = { - counts: Map; - keysByRef: Map; - duplicates: Set; -}; - -function createDuplicateTracker(): DuplicateTracker { - return { - counts: new Map(), - keysByRef: new Map(), - duplicates: new Set(), - }; -} - -function registerRef( - tracker: DuplicateTracker, - ref: string, - role: string, - name?: string, -): number | undefined { - const key = `${role}:${name ?? ""}`; - const count = tracker.counts.get(key) ?? 0; - tracker.counts.set(key, count + 1); - tracker.keysByRef.set(ref, key); - if (count > 0) { - tracker.duplicates.add(key); - return count; - } - return undefined; -} - -export function flattenChromeMcpSnapshotToAriaNodes( - root: ChromeMcpSnapshotNode, - limit = 500, -): SnapshotAriaNode[] { - const boundedLimit = Math.max(1, Math.min(2000, Math.floor(limit))); - const out: SnapshotAriaNode[] = []; - - const visit = (node: ChromeMcpSnapshotNode, depth: number) => { - if (out.length >= boundedLimit) { - return; - } - const ref = normalizeString(node.id); - if (ref) { - out.push({ - ref, - role: normalizeRole(node), - name: normalizeString(node.name) ?? "", - value: normalizeString(node.value), - description: normalizeString(node.description), - depth, - }); - } - for (const child of node.children ?? []) { - visit(child, depth + 1); - if (out.length >= boundedLimit) { - return; - } - } - }; - - visit(root, 0); - return out; -} - -export function buildAiSnapshotFromChromeMcpSnapshot(params: { - root: ChromeMcpSnapshotNode; - options?: RoleSnapshotOptions; - maxChars?: number; -}): { - snapshot: string; - truncated?: boolean; - refs: RoleRefMap; - stats: { lines: number; chars: number; refs: number; interactive: number }; -} { - const refs: RoleRefMap = {}; - const tracker = createDuplicateTracker(); - const lines: string[] = []; - - const visit = (node: ChromeMcpSnapshotNode, depth: number) => { - const role = normalizeRole(node); - const name = normalizeString(node.name); - const value = normalizeString(node.value); - const description = normalizeString(node.description); - const maxDepth = params.options?.maxDepth; - if (maxDepth !== undefined && depth > maxDepth) { - return; - } - - const includeNode = shouldIncludeNode({ role, name, options: params.options }); - if (includeNode) { - let line = `${" ".repeat(depth)}- ${role}`; - if (name) { - line += ` "${escapeQuoted(name)}"`; - } - const ref = normalizeString(node.id); - if (ref && shouldCreateRef(role, name)) { - const nth = registerRef(tracker, ref, role, name); - refs[ref] = nth === undefined ? { role, name } : { role, name, nth }; - line += ` [ref=${ref}]`; - } - if (value) { - line += ` value="${escapeQuoted(value)}"`; - } - if (description) { - line += ` description="${escapeQuoted(description)}"`; - } - lines.push(line); - } - - for (const child of node.children ?? []) { - visit(child, depth + 1); - } - }; - - visit(params.root, 0); - - for (const [ref, data] of Object.entries(refs)) { - const key = tracker.keysByRef.get(ref); - if (key && !tracker.duplicates.has(key)) { - delete data.nth; - } - } - - let snapshot = lines.join("\n"); - let truncated = false; - const maxChars = - typeof params.maxChars === "number" && Number.isFinite(params.maxChars) && params.maxChars > 0 - ? Math.floor(params.maxChars) - : undefined; - if (maxChars && snapshot.length > maxChars) { - snapshot = `${snapshot.slice(0, maxChars)}\n\n[...TRUNCATED - page too large]`; - truncated = true; - } - - const stats = getRoleSnapshotStats(snapshot, refs); - return truncated ? { snapshot, truncated, refs, stats } : { snapshot, refs, stats }; -} +export * from "../../extensions/browser/src/browser/chrome-mcp.snapshot.js"; diff --git a/src/browser/chrome-mcp.ts b/src/browser/chrome-mcp.ts index bc724d2eaea..56766308577 100644 --- a/src/browser/chrome-mcp.ts +++ b/src/browser/chrome-mcp.ts @@ -1,650 +1 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js"; -import type { BrowserTab } from "./client.js"; -import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js"; - -type ChromeMcpStructuredPage = { - id: number; - url?: string; - selected?: boolean; -}; - -type ChromeMcpToolResult = { - structuredContent?: Record; - content?: Array>; - isError?: boolean; -}; - -type ChromeMcpSession = { - client: Client; - transport: StdioClientTransport; - ready: Promise; -}; - -type ChromeMcpSessionFactory = ( - profileName: string, - userDataDir?: string, -) => Promise; - -const DEFAULT_CHROME_MCP_COMMAND = "npx"; -const DEFAULT_CHROME_MCP_ARGS = [ - "-y", - "chrome-devtools-mcp@latest", - "--autoConnect", - // Direct chrome-devtools-mcp launches do not enable structuredContent by default. - "--experimentalStructuredContent", - "--experimental-page-id-routing", -]; - -const sessions = new Map(); -const pendingSessions = new Map>(); -let sessionFactory: ChromeMcpSessionFactory | null = null; - -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - -function asPages(value: unknown): ChromeMcpStructuredPage[] { - if (!Array.isArray(value)) { - return []; - } - const out: ChromeMcpStructuredPage[] = []; - for (const entry of value) { - const record = asRecord(entry); - if (!record || typeof record.id !== "number") { - continue; - } - out.push({ - id: record.id, - url: typeof record.url === "string" ? record.url : undefined, - selected: record.selected === true, - }); - } - return out; -} - -function parsePageId(targetId: string): number { - const parsed = Number.parseInt(targetId.trim(), 10); - if (!Number.isFinite(parsed)) { - throw new BrowserTabNotFoundError(); - } - return parsed; -} - -function toBrowserTabs(pages: ChromeMcpStructuredPage[]): BrowserTab[] { - return pages.map((page) => ({ - targetId: String(page.id), - title: "", - url: page.url ?? "", - type: "page", - })); -} - -function extractStructuredContent(result: ChromeMcpToolResult): Record { - return asRecord(result.structuredContent) ?? {}; -} - -function extractTextContent(result: ChromeMcpToolResult): string[] { - const content = Array.isArray(result.content) ? result.content : []; - return content - .map((entry) => { - const record = asRecord(entry); - return record && typeof record.text === "string" ? record.text : ""; - }) - .filter(Boolean); -} - -function extractTextPages(result: ChromeMcpToolResult): ChromeMcpStructuredPage[] { - const pages: ChromeMcpStructuredPage[] = []; - for (const block of extractTextContent(result)) { - for (const line of block.split(/\r?\n/)) { - const match = line.match(/^\s*(\d+):\s+(.+?)(?:\s+\[(selected)\])?\s*$/i); - if (!match) { - continue; - } - pages.push({ - id: Number.parseInt(match[1] ?? "", 10), - url: match[2]?.trim() || undefined, - selected: Boolean(match[3]), - }); - } - } - return pages; -} - -function extractStructuredPages(result: ChromeMcpToolResult): ChromeMcpStructuredPage[] { - const structured = asPages(extractStructuredContent(result).pages); - return structured.length > 0 ? structured : extractTextPages(result); -} - -function extractSnapshot(result: ChromeMcpToolResult): ChromeMcpSnapshotNode { - const structured = extractStructuredContent(result); - const snapshot = asRecord(structured.snapshot); - if (!snapshot) { - throw new Error("Chrome MCP snapshot response was missing structured snapshot data."); - } - return snapshot as unknown as ChromeMcpSnapshotNode; -} - -function extractJsonBlock(text: string): unknown { - const match = text.match(/```json\s*([\s\S]*?)\s*```/i); - const raw = match?.[1]?.trim() || text.trim(); - return raw ? JSON.parse(raw) : null; -} - -function extractMessageText(result: ChromeMcpToolResult): string { - const message = extractStructuredContent(result).message; - if (typeof message === "string" && message.trim()) { - return message; - } - const blocks = extractTextContent(result); - return blocks.find((block) => block.trim()) ?? ""; -} - -function extractToolErrorMessage(result: ChromeMcpToolResult, name: string): string { - const message = extractMessageText(result).trim(); - return message || `Chrome MCP tool "${name}" failed.`; -} - -function extractJsonMessage(result: ChromeMcpToolResult): unknown { - const candidates = [extractMessageText(result), ...extractTextContent(result)].filter((text) => - text.trim(), - ); - let lastError: unknown; - for (const candidate of candidates) { - try { - return extractJsonBlock(candidate); - } catch (err) { - lastError = err; - } - } - if (lastError) { - throw lastError; - } - return null; -} - -function normalizeChromeMcpUserDataDir(userDataDir?: string): string | undefined { - const trimmed = userDataDir?.trim(); - return trimmed ? trimmed : undefined; -} - -function buildChromeMcpSessionCacheKey(profileName: string, userDataDir?: string): string { - return JSON.stringify([profileName, normalizeChromeMcpUserDataDir(userDataDir) ?? ""]); -} - -function cacheKeyMatchesProfileName(cacheKey: string, profileName: string): boolean { - try { - const parsed = JSON.parse(cacheKey); - return Array.isArray(parsed) && parsed[0] === profileName; - } catch { - return false; - } -} - -async function closeChromeMcpSessionsForProfile( - profileName: string, - keepKey?: string, -): Promise { - let closed = false; - - for (const key of Array.from(pendingSessions.keys())) { - if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) { - pendingSessions.delete(key); - closed = true; - } - } - - for (const [key, session] of Array.from(sessions.entries())) { - if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) { - sessions.delete(key); - closed = true; - await session.client.close().catch(() => {}); - } - } - - return closed; -} - -export function buildChromeMcpArgs(userDataDir?: string): string[] { - const normalizedUserDataDir = normalizeChromeMcpUserDataDir(userDataDir); - return normalizedUserDataDir - ? [...DEFAULT_CHROME_MCP_ARGS, "--userDataDir", normalizedUserDataDir] - : [...DEFAULT_CHROME_MCP_ARGS]; -} - -async function createRealSession( - profileName: string, - userDataDir?: string, -): Promise { - const transport = new StdioClientTransport({ - command: DEFAULT_CHROME_MCP_COMMAND, - args: buildChromeMcpArgs(userDataDir), - stderr: "pipe", - }); - const client = new Client( - { - name: "openclaw-browser", - version: "0.0.0", - }, - {}, - ); - - const ready = (async () => { - try { - await client.connect(transport); - const tools = await client.listTools(); - if (!tools.tools.some((tool) => tool.name === "list_pages")) { - throw new Error("Chrome MCP server did not expose the expected navigation tools."); - } - } catch (err) { - await client.close().catch(() => {}); - const targetLabel = userDataDir - ? `the configured Chromium user data dir (${userDataDir})` - : "Google Chrome's default profile"; - throw new BrowserProfileUnavailableError( - `Chrome MCP existing-session attach failed for profile "${profileName}". ` + - `Make sure ${targetLabel} is running locally with remote debugging enabled. ` + - `Details: ${String(err)}`, - ); - } - })(); - - return { - client, - transport, - ready, - }; -} - -async function getSession(profileName: string, userDataDir?: string): Promise { - const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir); - await closeChromeMcpSessionsForProfile(profileName, cacheKey); - - let session = sessions.get(cacheKey); - if (session && session.transport.pid === null) { - sessions.delete(cacheKey); - session = undefined; - } - if (!session) { - let pending = pendingSessions.get(cacheKey); - if (!pending) { - pending = (async () => { - const created = await (sessionFactory ?? createRealSession)(profileName, userDataDir); - if (pendingSessions.get(cacheKey) === pending) { - sessions.set(cacheKey, created); - } else { - await created.client.close().catch(() => {}); - } - return created; - })(); - pendingSessions.set(cacheKey, pending); - } - try { - session = await pending; - } finally { - if (pendingSessions.get(cacheKey) === pending) { - pendingSessions.delete(cacheKey); - } - } - } - try { - await session.ready; - return session; - } catch (err) { - const current = sessions.get(cacheKey); - if (current?.transport === session.transport) { - sessions.delete(cacheKey); - } - throw err; - } -} - -async function callTool( - profileName: string, - userDataDir: string | undefined, - name: string, - args: Record = {}, -): Promise { - const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir); - const session = await getSession(profileName, userDataDir); - let result: ChromeMcpToolResult; - try { - result = (await session.client.callTool({ - name, - arguments: args, - })) as ChromeMcpToolResult; - } catch (err) { - // Transport/connection error — tear down session so it reconnects on next call - sessions.delete(cacheKey); - await session.client.close().catch(() => {}); - throw err; - } - // Tool-level errors (element not found, script error, etc.) don't indicate a - // broken connection — don't tear down the session for these. - if (result.isError) { - throw new Error(extractToolErrorMessage(result, name)); - } - return result; -} - -async function withTempFile(fn: (filePath: string) => Promise): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-mcp-")); - const filePath = path.join(dir, randomUUID()); - try { - return await fn(filePath); - } finally { - await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); - } -} - -async function findPageById( - profileName: string, - pageId: number, - userDataDir?: string, -): Promise { - const pages = await listChromeMcpPages(profileName, userDataDir); - const page = pages.find((entry) => entry.id === pageId); - if (!page) { - throw new BrowserTabNotFoundError(); - } - return page; -} - -export async function ensureChromeMcpAvailable( - profileName: string, - userDataDir?: string, -): Promise { - await getSession(profileName, userDataDir); -} - -export function getChromeMcpPid(profileName: string): number | null { - for (const [key, session] of sessions.entries()) { - if (cacheKeyMatchesProfileName(key, profileName)) { - return session.transport.pid ?? null; - } - } - return null; -} - -export async function closeChromeMcpSession(profileName: string): Promise { - return await closeChromeMcpSessionsForProfile(profileName); -} - -export async function stopAllChromeMcpSessions(): Promise { - const names = [...new Set([...sessions.keys()].map((key) => JSON.parse(key)[0] as string))]; - for (const name of names) { - await closeChromeMcpSession(name).catch(() => {}); - } -} - -export async function listChromeMcpPages( - profileName: string, - userDataDir?: string, -): Promise { - const result = await callTool(profileName, userDataDir, "list_pages"); - return extractStructuredPages(result); -} - -export async function listChromeMcpTabs( - profileName: string, - userDataDir?: string, -): Promise { - return toBrowserTabs(await listChromeMcpPages(profileName, userDataDir)); -} - -export async function openChromeMcpTab( - profileName: string, - url: string, - userDataDir?: string, -): Promise { - const result = await callTool(profileName, userDataDir, "new_page", { url }); - const pages = extractStructuredPages(result); - const chosen = pages.find((page) => page.selected) ?? pages.at(-1); - if (!chosen) { - throw new Error("Chrome MCP did not return the created page."); - } - return { - targetId: String(chosen.id), - title: "", - url: chosen.url ?? url, - type: "page", - }; -} - -export async function focusChromeMcpTab( - profileName: string, - targetId: string, - userDataDir?: string, -): Promise { - await callTool(profileName, userDataDir, "select_page", { - pageId: parsePageId(targetId), - bringToFront: true, - }); -} - -export async function closeChromeMcpTab( - profileName: string, - targetId: string, - userDataDir?: string, -): Promise { - await callTool(profileName, userDataDir, "close_page", { pageId: parsePageId(targetId) }); -} - -export async function navigateChromeMcpPage(params: { - profileName: string; - userDataDir?: string; - targetId: string; - url: string; - timeoutMs?: number; -}): Promise<{ url: string }> { - await callTool(params.profileName, params.userDataDir, "navigate_page", { - pageId: parsePageId(params.targetId), - type: "url", - url: params.url, - ...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}), - }); - const page = await findPageById( - params.profileName, - parsePageId(params.targetId), - params.userDataDir, - ); - return { url: page.url ?? params.url }; -} - -export async function takeChromeMcpSnapshot(params: { - profileName: string; - userDataDir?: string; - targetId: string; -}): Promise { - const result = await callTool(params.profileName, params.userDataDir, "take_snapshot", { - pageId: parsePageId(params.targetId), - }); - return extractSnapshot(result); -} - -export async function takeChromeMcpScreenshot(params: { - profileName: string; - userDataDir?: string; - targetId: string; - uid?: string; - fullPage?: boolean; - format?: "png" | "jpeg"; -}): Promise { - return await withTempFile(async (filePath) => { - await callTool(params.profileName, params.userDataDir, "take_screenshot", { - pageId: parsePageId(params.targetId), - filePath, - format: params.format ?? "png", - ...(params.uid ? { uid: params.uid } : {}), - ...(params.fullPage ? { fullPage: true } : {}), - }); - return await fs.readFile(filePath); - }); -} - -export async function clickChromeMcpElement(params: { - profileName: string; - userDataDir?: string; - targetId: string; - uid: string; - doubleClick?: boolean; -}): Promise { - await callTool(params.profileName, params.userDataDir, "click", { - pageId: parsePageId(params.targetId), - uid: params.uid, - ...(params.doubleClick ? { dblClick: true } : {}), - }); -} - -export async function fillChromeMcpElement(params: { - profileName: string; - userDataDir?: string; - targetId: string; - uid: string; - value: string; -}): Promise { - await callTool(params.profileName, params.userDataDir, "fill", { - pageId: parsePageId(params.targetId), - uid: params.uid, - value: params.value, - }); -} - -export async function fillChromeMcpForm(params: { - profileName: string; - userDataDir?: string; - targetId: string; - elements: Array<{ uid: string; value: string }>; -}): Promise { - await callTool(params.profileName, params.userDataDir, "fill_form", { - pageId: parsePageId(params.targetId), - elements: params.elements, - }); -} - -export async function hoverChromeMcpElement(params: { - profileName: string; - userDataDir?: string; - targetId: string; - uid: string; -}): Promise { - await callTool(params.profileName, params.userDataDir, "hover", { - pageId: parsePageId(params.targetId), - uid: params.uid, - }); -} - -export async function dragChromeMcpElement(params: { - profileName: string; - userDataDir?: string; - targetId: string; - fromUid: string; - toUid: string; -}): Promise { - await callTool(params.profileName, params.userDataDir, "drag", { - pageId: parsePageId(params.targetId), - from_uid: params.fromUid, - to_uid: params.toUid, - }); -} - -export async function uploadChromeMcpFile(params: { - profileName: string; - userDataDir?: string; - targetId: string; - uid: string; - filePath: string; -}): Promise { - await callTool(params.profileName, params.userDataDir, "upload_file", { - pageId: parsePageId(params.targetId), - uid: params.uid, - filePath: params.filePath, - }); -} - -export async function pressChromeMcpKey(params: { - profileName: string; - userDataDir?: string; - targetId: string; - key: string; -}): Promise { - await callTool(params.profileName, params.userDataDir, "press_key", { - pageId: parsePageId(params.targetId), - key: params.key, - }); -} - -export async function resizeChromeMcpPage(params: { - profileName: string; - userDataDir?: string; - targetId: string; - width: number; - height: number; -}): Promise { - await callTool(params.profileName, params.userDataDir, "resize_page", { - pageId: parsePageId(params.targetId), - width: params.width, - height: params.height, - }); -} - -export async function handleChromeMcpDialog(params: { - profileName: string; - userDataDir?: string; - targetId: string; - action: "accept" | "dismiss"; - promptText?: string; -}): Promise { - await callTool(params.profileName, params.userDataDir, "handle_dialog", { - pageId: parsePageId(params.targetId), - action: params.action, - ...(params.promptText ? { promptText: params.promptText } : {}), - }); -} - -export async function evaluateChromeMcpScript(params: { - profileName: string; - userDataDir?: string; - targetId: string; - fn: string; - args?: string[]; -}): Promise { - const result = await callTool(params.profileName, params.userDataDir, "evaluate_script", { - pageId: parsePageId(params.targetId), - function: params.fn, - ...(params.args?.length ? { args: params.args } : {}), - }); - return extractJsonMessage(result); -} - -export async function waitForChromeMcpText(params: { - profileName: string; - userDataDir?: string; - targetId: string; - text: string[]; - timeoutMs?: number; -}): Promise { - await callTool(params.profileName, params.userDataDir, "wait_for", { - pageId: parsePageId(params.targetId), - text: params.text, - ...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}), - }); -} - -export function setChromeMcpSessionFactoryForTest(factory: ChromeMcpSessionFactory | null): void { - sessionFactory = factory; -} - -export async function resetChromeMcpSessionsForTest(): Promise { - sessionFactory = null; - pendingSessions.clear(); - await stopAllChromeMcpSessions(); -} +export * from "../../extensions/browser/src/browser/chrome-mcp.js"; diff --git a/src/browser/chrome-user-data-dir.test-harness.ts b/src/browser/chrome-user-data-dir.test-harness.ts index e3edce48acd..281556ab526 100644 --- a/src/browser/chrome-user-data-dir.test-harness.ts +++ b/src/browser/chrome-user-data-dir.test-harness.ts @@ -1,18 +1 @@ -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 }); - }); -} +export * from "../../extensions/browser/src/browser/chrome-user-data-dir.test-harness.js"; diff --git a/src/browser/chrome.executables.ts b/src/browser/chrome.executables.ts index 9a45e92a0a9..56eddca29bf 100644 --- a/src/browser/chrome.executables.ts +++ b/src/browser/chrome.executables.ts @@ -1,721 +1 @@ -import { execFileSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import type { ResolvedBrowserConfig } from "./config.js"; - -export type BrowserExecutable = { - kind: "brave" | "canary" | "chromium" | "chrome" | "custom" | "edge"; - path: string; -}; - -const CHROME_VERSION_RE = /(\d+)(?:\.\d+){0,3}/; - -const CHROMIUM_BUNDLE_IDS = new Set([ - "com.google.Chrome", - "com.google.Chrome.beta", - "com.google.Chrome.canary", - "com.google.Chrome.dev", - "com.brave.Browser", - "com.brave.Browser.beta", - "com.brave.Browser.nightly", - "com.microsoft.Edge", - "com.microsoft.EdgeBeta", - "com.microsoft.EdgeDev", - "com.microsoft.EdgeCanary", - // Edge LaunchServices IDs (used in macOS default browser registration — - // these differ from CFBundleIdentifier and are what plutil returns) - "com.microsoft.edgemac", - "com.microsoft.edgemac.beta", - "com.microsoft.edgemac.dev", - "com.microsoft.edgemac.canary", - "org.chromium.Chromium", - "com.vivaldi.Vivaldi", - "com.operasoftware.Opera", - "com.operasoftware.OperaGX", - "com.yandex.desktop.yandex-browser", - "company.thebrowser.Browser", // Arc -]); - -const CHROMIUM_DESKTOP_IDS = new Set([ - "google-chrome.desktop", - "google-chrome-beta.desktop", - "google-chrome-unstable.desktop", - "brave-browser.desktop", - "microsoft-edge.desktop", - "microsoft-edge-beta.desktop", - "microsoft-edge-dev.desktop", - "microsoft-edge-canary.desktop", - "chromium.desktop", - "chromium-browser.desktop", - "vivaldi.desktop", - "vivaldi-stable.desktop", - "opera.desktop", - "opera-gx.desktop", - "yandex-browser.desktop", - "org.chromium.Chromium.desktop", -]); - -const CHROMIUM_EXE_NAMES = new Set([ - "chrome.exe", - "msedge.exe", - "brave.exe", - "brave-browser.exe", - "chromium.exe", - "vivaldi.exe", - "opera.exe", - "launcher.exe", - "yandex.exe", - "yandexbrowser.exe", - // mac/linux names - "google chrome", - "google chrome canary", - "brave browser", - "microsoft edge", - "chromium", - "chrome", - "brave", - "msedge", - "brave-browser", - "google-chrome", - "google-chrome-stable", - "google-chrome-beta", - "google-chrome-unstable", - "microsoft-edge", - "microsoft-edge-beta", - "microsoft-edge-dev", - "microsoft-edge-canary", - "chromium-browser", - "vivaldi", - "vivaldi-stable", - "opera", - "opera-stable", - "opera-gx", - "yandex-browser", -]); - -function exists(filePath: string) { - try { - return fs.existsSync(filePath); - } catch { - return false; - } -} - -function execText( - command: string, - args: string[], - timeoutMs = 1200, - maxBuffer = 1024 * 1024, -): string | null { - try { - const output = execFileSync(command, args, { - timeout: timeoutMs, - encoding: "utf8", - maxBuffer, - }); - return String(output ?? "").trim() || null; - } catch { - return null; - } -} - -function inferKindFromIdentifier(identifier: string): BrowserExecutable["kind"] { - const id = identifier.toLowerCase(); - if (id.includes("brave")) { - return "brave"; - } - if (id.includes("edge")) { - return "edge"; - } - if (id.includes("chromium")) { - return "chromium"; - } - if (id.includes("canary")) { - return "canary"; - } - if ( - id.includes("opera") || - id.includes("vivaldi") || - id.includes("yandex") || - id.includes("thebrowser") - ) { - return "chromium"; - } - return "chrome"; -} - -function inferKindFromExecutableName(name: string): BrowserExecutable["kind"] { - const lower = name.toLowerCase(); - if (lower.includes("brave")) { - return "brave"; - } - if (lower.includes("edge") || lower.includes("msedge")) { - return "edge"; - } - if (lower.includes("chromium")) { - return "chromium"; - } - if (lower.includes("canary") || lower.includes("sxs")) { - return "canary"; - } - if (lower.includes("opera") || lower.includes("vivaldi") || lower.includes("yandex")) { - return "chromium"; - } - return "chrome"; -} - -function detectDefaultChromiumExecutable(platform: NodeJS.Platform): BrowserExecutable | null { - if (platform === "darwin") { - return detectDefaultChromiumExecutableMac(); - } - if (platform === "linux") { - return detectDefaultChromiumExecutableLinux(); - } - if (platform === "win32") { - return detectDefaultChromiumExecutableWindows(); - } - return null; -} - -function detectDefaultChromiumExecutableMac(): BrowserExecutable | null { - const bundleId = detectDefaultBrowserBundleIdMac(); - if (!bundleId || !CHROMIUM_BUNDLE_IDS.has(bundleId)) { - return null; - } - - const appPathRaw = execText("/usr/bin/osascript", [ - "-e", - `POSIX path of (path to application id "${bundleId}")`, - ]); - if (!appPathRaw) { - return null; - } - const appPath = appPathRaw.trim().replace(/\/$/, ""); - const exeName = execText("/usr/bin/defaults", [ - "read", - path.join(appPath, "Contents", "Info"), - "CFBundleExecutable", - ]); - if (!exeName) { - return null; - } - const exePath = path.join(appPath, "Contents", "MacOS", exeName.trim()); - if (!exists(exePath)) { - return null; - } - return { kind: inferKindFromIdentifier(bundleId), path: exePath }; -} - -function detectDefaultBrowserBundleIdMac(): string | null { - const plistPath = path.join( - os.homedir(), - "Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist", - ); - if (!exists(plistPath)) { - return null; - } - const handlersRaw = execText( - "/usr/bin/plutil", - ["-extract", "LSHandlers", "json", "-o", "-", "--", plistPath], - 2000, - 5 * 1024 * 1024, - ); - if (!handlersRaw) { - return null; - } - let handlers: unknown; - try { - handlers = JSON.parse(handlersRaw); - } catch { - return null; - } - if (!Array.isArray(handlers)) { - return null; - } - - const resolveScheme = (scheme: string) => { - let candidate: string | null = null; - for (const entry of handlers) { - if (!entry || typeof entry !== "object") { - continue; - } - const record = entry as Record; - 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 | null { - for (const candidate of candidates) { - if (exists(candidate.path)) { - return candidate; - } - } - - return null; -} - -function findFirstChromeExecutable(candidates: string[]): BrowserExecutable | null { - for (const candidate of candidates) { - if (exists(candidate)) { - return { - kind: - candidate.toLowerCase().includes("sxs") || candidate.toLowerCase().includes("canary") - ? "canary" - : "chrome", - path: candidate, - }; - } - } - - return null; -} - -export function findChromeExecutableMac(): BrowserExecutable | null { - const candidates: Array = [ - { - kind: "chrome", - path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - }, - { - kind: "chrome", - path: path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), - }, - { - kind: "brave", - path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", - }, - { - kind: "brave", - path: path.join(os.homedir(), "Applications/Brave Browser.app/Contents/MacOS/Brave Browser"), - }, - { - kind: "edge", - path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", - }, - { - kind: "edge", - path: path.join( - os.homedir(), - "Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", - ), - }, - { - kind: "chromium", - path: "/Applications/Chromium.app/Contents/MacOS/Chromium", - }, - { - kind: "chromium", - path: path.join(os.homedir(), "Applications/Chromium.app/Contents/MacOS/Chromium"), - }, - { - kind: "canary", - path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", - }, - { - kind: "canary", - path: path.join( - os.homedir(), - "Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", - ), - }, - ]; - - return findFirstExecutable(candidates); -} - -export function findGoogleChromeExecutableMac(): BrowserExecutable | null { - return findFirstChromeExecutable([ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), - "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", - path.join( - os.homedir(), - "Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", - ), - ]); -} - -export function findChromeExecutableLinux(): BrowserExecutable | null { - const candidates: Array = [ - { kind: "chrome", path: "/usr/bin/google-chrome" }, - { kind: "chrome", path: "/usr/bin/google-chrome-stable" }, - { kind: "chrome", path: "/usr/bin/chrome" }, - { kind: "brave", path: "/usr/bin/brave-browser" }, - { kind: "brave", path: "/usr/bin/brave-browser-stable" }, - { kind: "brave", path: "/usr/bin/brave" }, - { kind: "brave", path: "/snap/bin/brave" }, - { kind: "edge", path: "/usr/bin/microsoft-edge" }, - { kind: "edge", path: "/usr/bin/microsoft-edge-stable" }, - { kind: "chromium", path: "/usr/bin/chromium" }, - { kind: "chromium", path: "/usr/bin/chromium-browser" }, - { kind: "chromium", path: "/snap/bin/chromium" }, - ]; - - return findFirstExecutable(candidates); -} - -export function findGoogleChromeExecutableLinux(): BrowserExecutable | null { - return findFirstChromeExecutable([ - "/usr/bin/google-chrome", - "/usr/bin/google-chrome-stable", - "/usr/bin/google-chrome-beta", - "/usr/bin/google-chrome-unstable", - "/snap/bin/google-chrome", - ]); -} - -export function findChromeExecutableWindows(): BrowserExecutable | null { - const localAppData = process.env.LOCALAPPDATA ?? ""; - const programFiles = process.env.ProgramFiles ?? "C:\\Program Files"; - // Must use bracket notation: variable name contains parentheses - const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"; - - const joinWin = path.win32.join; - const candidates: Array = []; - - if (localAppData) { - // Chrome (user install) - candidates.push({ - kind: "chrome", - path: joinWin(localAppData, "Google", "Chrome", "Application", "chrome.exe"), - }); - // Brave (user install) - candidates.push({ - kind: "brave", - path: joinWin(localAppData, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), - }); - // Edge (user install) - candidates.push({ - kind: "edge", - path: joinWin(localAppData, "Microsoft", "Edge", "Application", "msedge.exe"), - }); - // Chromium (user install) - candidates.push({ - kind: "chromium", - path: joinWin(localAppData, "Chromium", "Application", "chrome.exe"), - }); - // Chrome Canary (user install) - candidates.push({ - kind: "canary", - path: joinWin(localAppData, "Google", "Chrome SxS", "Application", "chrome.exe"), - }); - } - - // Chrome (system install, 64-bit) - candidates.push({ - kind: "chrome", - path: joinWin(programFiles, "Google", "Chrome", "Application", "chrome.exe"), - }); - // Chrome (system install, 32-bit on 64-bit Windows) - candidates.push({ - kind: "chrome", - path: joinWin(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"), - }); - // Brave (system install, 64-bit) - candidates.push({ - kind: "brave", - path: joinWin(programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), - }); - // Brave (system install, 32-bit on 64-bit Windows) - candidates.push({ - kind: "brave", - path: joinWin(programFilesX86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), - }); - // Edge (system install, 64-bit) - candidates.push({ - kind: "edge", - path: joinWin(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"), - }); - // Edge (system install, 32-bit on 64-bit Windows) - candidates.push({ - kind: "edge", - path: joinWin(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"), - }); - - return findFirstExecutable(candidates); -} - -export function findGoogleChromeExecutableWindows(): BrowserExecutable | null { - const localAppData = process.env.LOCALAPPDATA ?? ""; - const programFiles = process.env.ProgramFiles ?? "C:\\Program Files"; - const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"; - const joinWin = path.win32.join; - const candidates: string[] = []; - - if (localAppData) { - candidates.push(joinWin(localAppData, "Google", "Chrome", "Application", "chrome.exe")); - candidates.push(joinWin(localAppData, "Google", "Chrome SxS", "Application", "chrome.exe")); - } - - candidates.push(joinWin(programFiles, "Google", "Chrome", "Application", "chrome.exe")); - candidates.push(joinWin(programFilesX86, "Google", "Chrome", "Application", "chrome.exe")); - - return findFirstChromeExecutable(candidates); -} - -export function resolveGoogleChromeExecutableForPlatform( - platform: NodeJS.Platform, -): BrowserExecutable | null { - if (platform === "darwin") { - return findGoogleChromeExecutableMac(); - } - if (platform === "linux") { - return findGoogleChromeExecutableLinux(); - } - if (platform === "win32") { - return findGoogleChromeExecutableWindows(); - } - return null; -} - -export function readBrowserVersion(executablePath: string): string | null { - const output = execText(executablePath, ["--version"], 2000); - if (!output) { - return null; - } - return output.replace(/\s+/g, " ").trim(); -} - -export function parseBrowserMajorVersion(rawVersion: string | null | undefined): number | null { - const match = String(rawVersion ?? "").match(CHROME_VERSION_RE); - if (!match?.[1]) { - return null; - } - const major = Number.parseInt(match[1], 10); - return Number.isFinite(major) ? major : null; -} - -export function resolveBrowserExecutableForPlatform( - resolved: ResolvedBrowserConfig, - platform: NodeJS.Platform, -): BrowserExecutable | null { - if (resolved.executablePath) { - if (!exists(resolved.executablePath)) { - throw new Error(`browser.executablePath not found: ${resolved.executablePath}`); - } - return { kind: "custom", path: resolved.executablePath }; - } - - const detected = detectDefaultChromiumExecutable(platform); - if (detected) { - return detected; - } - - if (platform === "darwin") { - return findChromeExecutableMac(); - } - if (platform === "linux") { - return findChromeExecutableLinux(); - } - if (platform === "win32") { - return findChromeExecutableWindows(); - } - return null; -} +export * from "../../extensions/browser/src/browser/chrome.executables.js"; diff --git a/src/browser/chrome.profile-decoration.ts b/src/browser/chrome.profile-decoration.ts index 8739860e2a4..2c466529885 100644 --- a/src/browser/chrome.profile-decoration.ts +++ b/src/browser/chrome.profile-decoration.ts @@ -1,198 +1 @@ -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 | 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; - } catch { - return null; - } -} - -function safeWriteJson(filePath: string, data: Record) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); -} - -function setDeep(obj: Record, keys: string[], value: unknown) { - let node: Record = 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; - } - 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).info_cache - : null; - const info = - typeof infoCache === "object" && - infoCache !== null && - !Array.isArray(infoCache) && - typeof (infoCache as Record).Default === "object" && - (infoCache as Record).Default !== null && - !Array.isArray((infoCache as Record).Default) - ? ((infoCache as Record).Default as Record) - : null; - - const prefs = safeReadJson(preferencesPath); - const browserTheme = (() => { - const browser = prefs?.browser; - const theme = - typeof browser === "object" && browser !== null && !Array.isArray(browser) - ? (browser as Record).theme - : null; - return typeof theme === "object" && theme !== null && !Array.isArray(theme) - ? (theme as Record) - : null; - })(); - - const autogeneratedTheme = (() => { - const autogenerated = prefs?.autogenerated; - const theme = - typeof autogenerated === "object" && autogenerated !== null && !Array.isArray(autogenerated) - ? (autogenerated as Record).theme - : null; - return typeof theme === "object" && theme !== null && !Array.isArray(theme) - ? (theme as Record) - : 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); -} +export * from "../../extensions/browser/src/browser/chrome.profile-decoration.js"; diff --git a/src/browser/chrome.ts b/src/browser/chrome.ts index 47aef9a8e3e..a13e3859716 100644 --- a/src/browser/chrome.ts +++ b/src/browser/chrome.ts @@ -1,477 +1 @@ -import { type ChildProcess, type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import type { SsrFPolicy } from "../infra/net/ssrf.js"; -import { ensurePortAvailable } from "../infra/ports.js"; -import { rawDataToString } from "../infra/ws.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import { CONFIG_DIR } from "../utils.js"; -import { - CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS, - CHROME_BOOTSTRAP_PREFS_TIMEOUT_MS, - CHROME_LAUNCH_READY_POLL_MS, - CHROME_LAUNCH_READY_WINDOW_MS, - CHROME_REACHABILITY_TIMEOUT_MS, - CHROME_STDERR_HINT_MAX_CHARS, - CHROME_STOP_PROBE_TIMEOUT_MS, - CHROME_STOP_TIMEOUT_MS, - CHROME_WS_READY_TIMEOUT_MS, -} from "./cdp-timeouts.js"; -import { - appendCdpPath, - assertCdpEndpointAllowed, - fetchCdpChecked, - isWebSocketUrl, - openCdpWebSocket, -} from "./cdp.helpers.js"; -import { normalizeCdpWsUrl } from "./cdp.js"; -import { - type BrowserExecutable, - resolveBrowserExecutableForPlatform, -} from "./chrome.executables.js"; -import { - decorateOpenClawProfile, - ensureProfileCleanExit, - isProfileDecorated, -} from "./chrome.profile-decoration.js"; -import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js"; -import { - DEFAULT_OPENCLAW_BROWSER_COLOR, - DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, -} from "./constants.js"; - -const log = createSubsystemLogger("browser").child("chrome"); - -export type { BrowserExecutable } from "./chrome.executables.js"; -export { - findChromeExecutableLinux, - findChromeExecutableMac, - findChromeExecutableWindows, - resolveBrowserExecutableForPlatform, -} from "./chrome.executables.js"; -export { - decorateOpenClawProfile, - ensureProfileCleanExit, - isProfileDecorated, -} from "./chrome.profile-decoration.js"; - -function exists(filePath: string) { - try { - return fs.existsSync(filePath); - } catch { - return false; - } -} - -export type RunningChrome = { - pid: number; - exe: BrowserExecutable; - userDataDir: string; - cdpPort: number; - startedAt: number; - proc: ChildProcess; -}; - -function resolveBrowserExecutable(resolved: ResolvedBrowserConfig): BrowserExecutable | null { - return resolveBrowserExecutableForPlatform(resolved, process.platform); -} - -export function resolveOpenClawUserDataDir(profileName = DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME) { - return path.join(CONFIG_DIR, "browser", profileName, "user-data"); -} - -function cdpUrlForPort(cdpPort: number) { - return `http://127.0.0.1:${cdpPort}`; -} - -export function buildOpenClawChromeLaunchArgs(params: { - resolved: ResolvedBrowserConfig; - profile: ResolvedBrowserProfile; - userDataDir: string; -}): string[] { - const { resolved, profile, userDataDir } = params; - const args: string[] = [ - `--remote-debugging-port=${profile.cdpPort}`, - `--user-data-dir=${userDataDir}`, - "--no-first-run", - "--no-default-browser-check", - "--disable-sync", - "--disable-background-networking", - "--disable-component-update", - "--disable-features=Translate,MediaRouter", - "--disable-session-crashed-bubble", - "--hide-crash-restore-bubble", - "--password-store=basic", - ]; - - if (resolved.headless) { - args.push("--headless=new"); - args.push("--disable-gpu"); - } - if (resolved.noSandbox) { - args.push("--no-sandbox"); - args.push("--disable-setuid-sandbox"); - } - if (process.platform === "linux") { - args.push("--disable-dev-shm-usage"); - } - if (resolved.extraArgs.length > 0) { - args.push(...resolved.extraArgs); - } - - return args; -} - -async function canOpenWebSocket(url: string, timeoutMs: number): Promise { - return new Promise((resolve) => { - const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs }); - ws.once("open", () => { - try { - ws.close(); - } catch { - // ignore - } - resolve(true); - }); - ws.once("error", () => resolve(false)); - }); -} - -export async function isChromeReachable( - cdpUrl: string, - timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, - ssrfPolicy?: SsrFPolicy, -): Promise { - try { - await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); - if (isWebSocketUrl(cdpUrl)) { - // Direct WebSocket endpoint — probe via WS handshake. - return await canOpenWebSocket(cdpUrl, timeoutMs); - } - const version = await fetchChromeVersion(cdpUrl, timeoutMs, ssrfPolicy); - return Boolean(version); - } catch { - return false; - } -} - -type ChromeVersion = { - webSocketDebuggerUrl?: string; - Browser?: string; - "User-Agent"?: string; -}; - -async function fetchChromeVersion( - cdpUrl: string, - timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, - ssrfPolicy?: SsrFPolicy, -): Promise { - const ctrl = new AbortController(); - const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs); - try { - await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); - const versionUrl = appendCdpPath(cdpUrl, "/json/version"); - const res = await fetchCdpChecked(versionUrl, timeoutMs, { signal: ctrl.signal }); - const data = (await res.json()) as ChromeVersion; - if (!data || typeof data !== "object") { - return null; - } - return data; - } catch { - return null; - } finally { - clearTimeout(t); - } -} - -export async function getChromeWebSocketUrl( - cdpUrl: string, - timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, - ssrfPolicy?: SsrFPolicy, -): Promise { - await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); - if (isWebSocketUrl(cdpUrl)) { - // Direct WebSocket endpoint — the cdpUrl is already the WebSocket URL. - return cdpUrl; - } - const version = await fetchChromeVersion(cdpUrl, timeoutMs, ssrfPolicy); - const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim(); - if (!wsUrl) { - return null; - } - return normalizeCdpWsUrl(wsUrl, cdpUrl); -} - -async function canRunCdpHealthCommand( - wsUrl: string, - timeoutMs = CHROME_WS_READY_TIMEOUT_MS, -): Promise { - return await new Promise((resolve) => { - const ws = openCdpWebSocket(wsUrl, { - handshakeTimeoutMs: timeoutMs, - }); - let settled = false; - const onMessage = (raw: Parameters[0]) => { - if (settled) { - return; - } - let parsed: { id?: unknown; result?: unknown } | null = null; - try { - parsed = JSON.parse(rawDataToString(raw)) as { id?: unknown; result?: unknown }; - } catch { - return; - } - if (parsed?.id !== 1) { - return; - } - finish(Boolean(parsed.result && typeof parsed.result === "object")); - }; - - const finish = (value: boolean) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - ws.off("message", onMessage); - try { - ws.close(); - } catch { - // ignore - } - resolve(value); - }; - const timer = setTimeout( - () => { - try { - ws.terminate(); - } catch { - // ignore - } - finish(false); - }, - Math.max(50, timeoutMs + 25), - ); - - ws.once("open", () => { - try { - ws.send( - JSON.stringify({ - id: 1, - method: "Browser.getVersion", - }), - ); - } catch { - finish(false); - } - }); - - ws.on("message", onMessage); - - ws.once("error", () => { - finish(false); - }); - ws.once("close", () => { - finish(false); - }); - }); -} - -export async function isChromeCdpReady( - cdpUrl: string, - timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, - handshakeTimeoutMs = CHROME_WS_READY_TIMEOUT_MS, - ssrfPolicy?: SsrFPolicy, -): Promise { - const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs, ssrfPolicy).catch(() => null); - if (!wsUrl) { - return false; - } - return await canRunCdpHealthCommand(wsUrl, handshakeTimeoutMs); -} - -export async function launchOpenClawChrome( - resolved: ResolvedBrowserConfig, - profile: ResolvedBrowserProfile, -): Promise { - if (!profile.cdpIsLoopback) { - throw new Error(`Profile "${profile.name}" is remote; cannot launch local Chrome.`); - } - await ensurePortAvailable(profile.cdpPort); - - const exe = resolveBrowserExecutable(resolved); - if (!exe) { - throw new Error( - "No supported browser found (Chrome/Brave/Edge/Chromium on macOS, Linux, or Windows).", - ); - } - - const userDataDir = resolveOpenClawUserDataDir(profile.name); - fs.mkdirSync(userDataDir, { recursive: true }); - - const needsDecorate = !isProfileDecorated( - userDataDir, - profile.name, - (profile.color ?? DEFAULT_OPENCLAW_BROWSER_COLOR).toUpperCase(), - ); - - // First launch to create preference files if missing, then decorate and relaunch. - const spawnOnce = () => { - const args = buildOpenClawChromeLaunchArgs({ - resolved, - profile, - userDataDir, - }); - // stdio tuple: discard stdout to prevent buffer saturation in constrained - // environments (e.g. Docker), while keeping stderr piped for diagnostics. - // Cast to ChildProcessWithoutNullStreams so callers can use .stderr safely; - // the tuple overload resolution varies across @types/node versions. - return spawn(exe.path, args, { - stdio: ["ignore", "ignore", "pipe"], - env: { - ...process.env, - // Reduce accidental sharing with the user's env. - HOME: os.homedir(), - }, - }) as unknown as ChildProcessWithoutNullStreams; - }; - - const startedAt = Date.now(); - - const localStatePath = path.join(userDataDir, "Local State"); - const preferencesPath = path.join(userDataDir, "Default", "Preferences"); - const needsBootstrap = !exists(localStatePath) || !exists(preferencesPath); - - // If the profile doesn't exist yet, bootstrap it once so Chrome creates defaults. - // Then decorate (if needed) before the "real" run. - if (needsBootstrap) { - const bootstrap = spawnOnce(); - const deadline = Date.now() + CHROME_BOOTSTRAP_PREFS_TIMEOUT_MS; - while (Date.now() < deadline) { - if (exists(localStatePath) && exists(preferencesPath)) { - break; - } - await new Promise((r) => setTimeout(r, 100)); - } - try { - bootstrap.kill("SIGTERM"); - } catch { - // ignore - } - const exitDeadline = Date.now() + CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS; - while (Date.now() < exitDeadline) { - if (bootstrap.exitCode != null) { - break; - } - await new Promise((r) => setTimeout(r, 50)); - } - } - - if (needsDecorate) { - try { - decorateOpenClawProfile(userDataDir, { - name: profile.name, - color: profile.color, - }); - log.info(`🦞 openclaw browser profile decorated (${profile.color})`); - } catch (err) { - log.warn(`openclaw browser profile decoration failed: ${String(err)}`); - } - } - - try { - ensureProfileCleanExit(userDataDir); - } catch (err) { - log.warn(`openclaw browser clean-exit prefs failed: ${String(err)}`); - } - - const proc = spawnOnce(); - - // Collect stderr for diagnostics in case Chrome fails to start. - // The listener is removed on success to avoid unbounded memory growth - // from a long-lived Chrome process that emits periodic warnings. - const stderrChunks: Buffer[] = []; - const onStderr = (chunk: Buffer) => { - stderrChunks.push(chunk); - }; - proc.stderr?.on("data", onStderr); - - // Wait for CDP to come up. - const readyDeadline = Date.now() + CHROME_LAUNCH_READY_WINDOW_MS; - while (Date.now() < readyDeadline) { - if (await isChromeReachable(profile.cdpUrl)) { - break; - } - await new Promise((r) => setTimeout(r, CHROME_LAUNCH_READY_POLL_MS)); - } - - if (!(await isChromeReachable(profile.cdpUrl))) { - const stderrOutput = Buffer.concat(stderrChunks).toString("utf8").trim(); - const stderrHint = stderrOutput - ? `\nChrome stderr:\n${stderrOutput.slice(0, CHROME_STDERR_HINT_MAX_CHARS)}` - : ""; - const sandboxHint = - process.platform === "linux" && !resolved.noSandbox - ? "\nHint: If running in a container or as root, try setting browser.noSandbox: true in config." - : ""; - try { - proc.kill("SIGKILL"); - } catch { - // ignore - } - throw new Error( - `Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".${sandboxHint}${stderrHint}`, - ); - } - - // Chrome started successfully — detach the stderr listener and release the buffer. - proc.stderr?.off("data", onStderr); - stderrChunks.length = 0; - - const pid = proc.pid ?? -1; - log.info( - `🦞 openclaw browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`, - ); - - return { - pid, - exe, - userDataDir, - cdpPort: profile.cdpPort, - startedAt, - proc, - }; -} - -export async function stopOpenClawChrome( - running: RunningChrome, - timeoutMs = CHROME_STOP_TIMEOUT_MS, -) { - const proc = running.proc; - if (proc.killed) { - return; - } - try { - proc.kill("SIGTERM"); - } catch { - // ignore - } - - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (!proc.exitCode && proc.killed) { - break; - } - if (!(await isChromeReachable(cdpUrlForPort(running.cdpPort), CHROME_STOP_PROBE_TIMEOUT_MS))) { - return; - } - await new Promise((r) => setTimeout(r, 100)); - } - - try { - proc.kill("SIGKILL"); - } catch { - // ignore - } -} +export * from "../../extensions/browser/src/browser/chrome.js"; diff --git a/src/browser/client-actions-core.ts b/src/browser/client-actions-core.ts index 149ca54fadf..a0f78e7dbf7 100644 --- a/src/browser/client-actions-core.ts +++ b/src/browser/client-actions-core.ts @@ -1,279 +1 @@ -import type { - BrowserActionOk, - BrowserActionPathResult, - BrowserActionTabResult, -} from "./client-actions-types.js"; -import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js"; -import { fetchBrowserJson } from "./client-fetch.js"; - -export type BrowserFormField = { - ref: string; - type: string; - value?: string | number | boolean; -}; - -export type BrowserActRequest = - | { - kind: "click"; - ref?: string; - selector?: string; - targetId?: string; - doubleClick?: boolean; - button?: string; - modifiers?: string[]; - delayMs?: number; - timeoutMs?: number; - } - | { - kind: "type"; - ref?: string; - selector?: string; - text: string; - targetId?: string; - submit?: boolean; - slowly?: boolean; - timeoutMs?: number; - } - | { kind: "press"; key: string; targetId?: string; delayMs?: number } - | { - kind: "hover"; - ref?: string; - selector?: string; - targetId?: string; - timeoutMs?: number; - } - | { - kind: "scrollIntoView"; - ref?: string; - selector?: string; - targetId?: string; - timeoutMs?: number; - } - | { - kind: "drag"; - startRef?: string; - startSelector?: string; - endRef?: string; - endSelector?: string; - targetId?: string; - timeoutMs?: number; - } - | { - kind: "select"; - ref?: string; - selector?: string; - values: string[]; - targetId?: string; - timeoutMs?: number; - } - | { - kind: "fill"; - fields: BrowserFormField[]; - targetId?: string; - timeoutMs?: number; - } - | { kind: "resize"; width: number; height: number; targetId?: string } - | { - kind: "wait"; - timeMs?: number; - text?: string; - textGone?: string; - selector?: string; - url?: string; - loadState?: "load" | "domcontentloaded" | "networkidle"; - fn?: string; - targetId?: string; - timeoutMs?: number; - } - | { kind: "evaluate"; fn: string; ref?: string; targetId?: string; timeoutMs?: number } - | { kind: "close"; targetId?: string } - | { - kind: "batch"; - actions: BrowserActRequest[]; - targetId?: string; - stopOnError?: boolean; - }; - -export type BrowserActResponse = { - ok: true; - targetId: string; - url?: string; - result?: unknown; - results?: Array<{ ok: boolean; error?: string }>; -}; - -export type BrowserDownloadPayload = { - url: string; - suggestedFilename: string; - path: string; -}; - -type BrowserDownloadResult = { ok: true; targetId: string; download: BrowserDownloadPayload }; - -async function postDownloadRequest( - baseUrl: string | undefined, - route: "/wait/download" | "/download", - body: Record, - profile?: string, -): Promise { - const q = buildProfileQuery(profile); - return await fetchBrowserJson(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 { - const q = buildProfileQuery(opts.profile); - return await fetchBrowserJson(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 { - const q = buildProfileQuery(opts.profile); - return await fetchBrowserJson(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 { - const q = buildProfileQuery(opts.profile); - return await fetchBrowserJson(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 { - 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 { - 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 { - const q = buildProfileQuery(opts?.profile); - return await fetchBrowserJson(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 { - const q = buildProfileQuery(opts.profile); - return await fetchBrowserJson(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, - }); -} +export * from "../../extensions/browser/src/browser/client-actions-core.js"; diff --git a/src/browser/client-actions-observe.ts b/src/browser/client-actions-observe.ts index 7f7d8cd6926..8096514da13 100644 --- a/src/browser/client-actions-observe.ts +++ b/src/browser/client-actions-observe.ts @@ -1,184 +1 @@ -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 { - const q = buildProfileQuery(opts.profile); - return await fetchBrowserJson(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 { - const q = buildProfileQuery(opts.profile); - return await fetchBrowserJson(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 { - const q = buildProfileQuery(opts.profile); - return await fetchBrowserJson(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 { - const q = buildProfileQuery(opts.profile); - return await fetchBrowserJson(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; - body: string; - truncated?: boolean; - }; -}> { - const q = buildProfileQuery(opts.profile); - return await fetchBrowserJson<{ - ok: true; - targetId: string; - response: { - url: string; - status?: number; - headers?: Record; - 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, - }); -} +export * from "../../extensions/browser/src/browser/client-actions-observe.js"; diff --git a/src/browser/client-actions-state.ts b/src/browser/client-actions-state.ts index a5d87aaec2d..68db6b66030 100644 --- a/src/browser/client-actions-state.ts +++ b/src/browser/client-actions-state.ts @@ -1,278 +1 @@ -import type { BrowserActionOk, BrowserActionTargetOk } from "./client-actions-types.js"; -import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js"; -import { fetchBrowserJson } from "./client-fetch.js"; - -type TargetedProfileOptions = { - targetId?: string; - profile?: string; -}; - -type HttpCredentialsOptions = TargetedProfileOptions & { - username?: string; - password?: string; - clear?: boolean; -}; - -type GeolocationOptions = TargetedProfileOptions & { - latitude?: number; - longitude?: number; - accuracy?: number; - origin?: string; - clear?: boolean; -}; - -function buildStateQuery(params: { targetId?: string; key?: string; profile?: string }): string { - const query = new URLSearchParams(); - if (params.targetId) { - query.set("targetId", params.targetId); - } - if (params.key) { - query.set("key", params.key); - } - if (params.profile) { - query.set("profile", params.profile); - } - const suffix = query.toString(); - return suffix ? `?${suffix}` : ""; -} - -async function postProfileJson( - baseUrl: string | undefined, - params: { path: string; profile?: string; body: unknown }, -): Promise { - const query = buildProfileQuery(params.profile); - return await fetchBrowserJson(withBaseUrl(baseUrl, `${params.path}${query}`), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(params.body), - timeoutMs: 20000, - }); -} - -async function postTargetedProfileJson( - baseUrl: string | undefined, - params: { - path: string; - opts: { targetId?: string; profile?: string }; - body: Record; - }, -): Promise { - return await postProfileJson(baseUrl, { - path: params.path, - profile: params.opts.profile, - body: { - targetId: params.opts.targetId, - ...params.body, - }, - }); -} - -export async function browserCookies( - baseUrl: string | undefined, - opts: { targetId?: string; profile?: string } = {}, -): Promise<{ ok: true; targetId: string; cookies: unknown[] }> { - const suffix = buildStateQuery({ targetId: opts.targetId, profile: opts.profile }); - return await fetchBrowserJson<{ - ok: true; - targetId: string; - cookies: unknown[]; - }>(withBaseUrl(baseUrl, `/cookies${suffix}`), { timeoutMs: 20000 }); -} - -export async function browserCookiesSet( - baseUrl: string | undefined, - opts: { - cookie: Record; - targetId?: string; - profile?: string; - }, -): Promise { - return await postProfileJson(baseUrl, { - path: "/cookies/set", - profile: opts.profile, - body: { targetId: opts.targetId, cookie: opts.cookie }, - }); -} - -export async function browserCookiesClear( - baseUrl: string | undefined, - opts: { targetId?: string; profile?: string } = {}, -): Promise { - return await postProfileJson(baseUrl, { - path: "/cookies/clear", - profile: opts.profile, - body: { targetId: opts.targetId }, - }); -} - -export async function browserStorageGet( - baseUrl: string | undefined, - opts: { - kind: "local" | "session"; - key?: string; - targetId?: string; - profile?: string; - }, -): Promise<{ ok: true; targetId: string; values: Record }> { - const suffix = buildStateQuery({ targetId: opts.targetId, key: opts.key, profile: opts.profile }); - return await fetchBrowserJson<{ - ok: true; - targetId: string; - values: Record; - }>(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 { - return await postProfileJson(baseUrl, { - path: `/storage/${opts.kind}/set`, - profile: opts.profile, - body: { - targetId: opts.targetId, - key: opts.key, - value: opts.value, - }, - }); -} - -export async function browserStorageClear( - baseUrl: string | undefined, - opts: { kind: "local" | "session"; targetId?: string; profile?: string }, -): Promise { - return await postProfileJson(baseUrl, { - path: `/storage/${opts.kind}/clear`, - profile: opts.profile, - body: { targetId: opts.targetId }, - }); -} - -export async function browserSetOffline( - baseUrl: string | undefined, - opts: { offline: boolean; targetId?: string; profile?: string }, -): Promise { - return await postProfileJson(baseUrl, { - path: "/set/offline", - profile: opts.profile, - body: { targetId: opts.targetId, offline: opts.offline }, - }); -} - -export async function browserSetHeaders( - baseUrl: string | undefined, - opts: { - headers: Record; - targetId?: string; - profile?: string; - }, -): Promise { - return await postProfileJson(baseUrl, { - path: "/set/headers", - profile: opts.profile, - body: { targetId: opts.targetId, headers: opts.headers }, - }); -} - -export async function browserSetHttpCredentials( - baseUrl: string | undefined, - opts: HttpCredentialsOptions = {}, -): Promise { - return await postTargetedProfileJson(baseUrl, { - path: "/set/credentials", - opts, - body: { - username: opts.username, - password: opts.password, - clear: opts.clear, - }, - }); -} - -export async function browserSetGeolocation( - baseUrl: string | undefined, - opts: GeolocationOptions = {}, -): Promise { - return await postTargetedProfileJson(baseUrl, { - path: "/set/geolocation", - opts, - body: { - latitude: opts.latitude, - longitude: opts.longitude, - accuracy: opts.accuracy, - origin: opts.origin, - clear: opts.clear, - }, - }); -} - -export async function browserSetMedia( - baseUrl: string | undefined, - opts: { - colorScheme: "dark" | "light" | "no-preference" | "none"; - targetId?: string; - profile?: string; - }, -): Promise { - return await postProfileJson(baseUrl, { - path: "/set/media", - profile: opts.profile, - body: { - targetId: opts.targetId, - colorScheme: opts.colorScheme, - }, - }); -} - -export async function browserSetTimezone( - baseUrl: string | undefined, - opts: { timezoneId: string; targetId?: string; profile?: string }, -): Promise { - return await postProfileJson(baseUrl, { - path: "/set/timezone", - profile: opts.profile, - body: { - targetId: opts.targetId, - timezoneId: opts.timezoneId, - }, - }); -} - -export async function browserSetLocale( - baseUrl: string | undefined, - opts: { locale: string; targetId?: string; profile?: string }, -): Promise { - return await postProfileJson(baseUrl, { - path: "/set/locale", - profile: opts.profile, - body: { targetId: opts.targetId, locale: opts.locale }, - }); -} - -export async function browserSetDevice( - baseUrl: string | undefined, - opts: { name: string; targetId?: string; profile?: string }, -): Promise { - return await postProfileJson(baseUrl, { - path: "/set/device", - profile: opts.profile, - body: { targetId: opts.targetId, name: opts.name }, - }); -} - -export async function browserClearPermissions( - baseUrl: string | undefined, - opts: { targetId?: string; profile?: string } = {}, -): Promise { - return await postProfileJson(baseUrl, { - path: "/set/geolocation", - profile: opts.profile, - body: { targetId: opts.targetId, clear: true }, - }); -} +export * from "../../extensions/browser/src/browser/client-actions-state.js"; diff --git a/src/browser/client-actions-types.ts b/src/browser/client-actions-types.ts index 9ad0d820da2..36dc9a2ad57 100644 --- a/src/browser/client-actions-types.ts +++ b/src/browser/client-actions-types.ts @@ -1,16 +1 @@ -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 }; +export * from "../../extensions/browser/src/browser/client-actions-types.js"; diff --git a/src/browser/client-actions-url.ts b/src/browser/client-actions-url.ts index 25c47fa6dba..2f82ffde6ea 100644 --- a/src/browser/client-actions-url.ts +++ b/src/browser/client-actions-url.ts @@ -1,11 +1 @@ -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}`; -} +export * from "../../extensions/browser/src/browser/client-actions-url.js"; diff --git a/src/browser/client-actions.ts b/src/browser/client-actions.ts index c495f5d01c5..e082707335b 100644 --- a/src/browser/client-actions.ts +++ b/src/browser/client-actions.ts @@ -1,4 +1 @@ -export * from "./client-actions-core.js"; -export * from "./client-actions-observe.js"; -export * from "./client-actions-state.js"; -export * from "./client-actions-types.js"; +export * from "../../extensions/browser/src/browser/client-actions.js"; diff --git a/src/browser/client-fetch.ts b/src/browser/client-fetch.ts index e321c5a1e62..5805440b225 100644 --- a/src/browser/client-fetch.ts +++ b/src/browser/client-fetch.ts @@ -1,345 +1 @@ -import { formatCliCommand } from "../cli/command-format.js"; -import { loadConfig } from "../config/config.js"; -import { isLoopbackHost } from "../gateway/net.js"; -import { getBridgeAuthForPort } from "./bridge-auth-registry.js"; -import { resolveBrowserControlAuth } from "./control-auth.js"; -import { - createBrowserControlContext, - startBrowserControlServiceFromConfig, -} from "./control-service.js"; -import { createBrowserRouteDispatcher } from "./routes/dispatcher.js"; - -// Application-level error from the browser control service (service is reachable -// but returned an error response). Must NOT be wrapped with "Can't reach ..." messaging. -class BrowserServiceError extends Error { - constructor(message: string) { - super(message); - this.name = "BrowserServiceError"; - } -} - -type LoopbackBrowserAuthDeps = { - loadConfig: typeof loadConfig; - resolveBrowserControlAuth: typeof resolveBrowserControlAuth; - getBridgeAuthForPort: typeof getBridgeAuthForPort; -}; - -function isAbsoluteHttp(url: string): boolean { - return /^https?:\/\//i.test(url.trim()); -} - -function isLoopbackHttpUrl(url: string): boolean { - try { - return isLoopbackHost(new URL(url).hostname); - } catch { - return false; - } -} - -function withLoopbackBrowserAuthImpl( - url: string, - init: (RequestInit & { timeoutMs?: number }) | undefined, - deps: LoopbackBrowserAuthDeps, -): RequestInit & { timeoutMs?: number } { - const headers = new Headers(init?.headers ?? {}); - if (headers.has("authorization") || headers.has("x-openclaw-password")) { - return { ...init, headers }; - } - if (!isLoopbackHttpUrl(url)) { - return { ...init, headers }; - } - - try { - const cfg = deps.loadConfig(); - const auth = deps.resolveBrowserControlAuth(cfg); - if (auth.token) { - headers.set("Authorization", `Bearer ${auth.token}`); - return { ...init, headers }; - } - if (auth.password) { - headers.set("x-openclaw-password", auth.password); - return { ...init, headers }; - } - } catch { - // ignore config/auth lookup failures and continue without auth headers - } - - // Sandbox bridge servers can run with per-process ephemeral auth on dynamic ports. - // Fall back to the in-memory registry if config auth is not available. - try { - const parsed = new URL(url); - const port = - parsed.port && Number.parseInt(parsed.port, 10) > 0 - ? Number.parseInt(parsed.port, 10) - : parsed.protocol === "https:" - ? 443 - : 80; - const bridgeAuth = deps.getBridgeAuthForPort(port); - if (bridgeAuth?.token) { - headers.set("Authorization", `Bearer ${bridgeAuth.token}`); - } else if (bridgeAuth?.password) { - headers.set("x-openclaw-password", bridgeAuth.password); - } - } catch { - // ignore - } - - return { ...init, headers }; -} - -function withLoopbackBrowserAuth( - url: string, - init: (RequestInit & { timeoutMs?: number }) | undefined, -): RequestInit & { timeoutMs?: number } { - return withLoopbackBrowserAuthImpl(url, init, { - loadConfig, - resolveBrowserControlAuth, - getBridgeAuthForPort, - }); -} - -const BROWSER_TOOL_MODEL_HINT = - "Do NOT retry the browser tool — it will keep failing. " + - "Use an alternative approach or inform the user that the browser is currently unavailable."; - -const BROWSER_SERVICE_RATE_LIMIT_MESSAGE = - "Browser service rate limit reached. " + - "Wait for the current session to complete, or retry later."; - -const BROWSERBASE_RATE_LIMIT_MESSAGE = - "Browserbase rate limit reached (max concurrent sessions). " + - "Wait for the current session to complete, or upgrade your plan."; - -function isRateLimitStatus(status: number): boolean { - return status === 429; -} - -function isBrowserbaseUrl(url: string): boolean { - if (!isAbsoluteHttp(url)) { - return false; - } - try { - const host = new URL(url).hostname.toLowerCase(); - return host === "browserbase.com" || host.endsWith(".browserbase.com"); - } catch { - return false; - } -} - -export function resolveBrowserRateLimitMessage(url: string): string { - return isBrowserbaseUrl(url) - ? BROWSERBASE_RATE_LIMIT_MESSAGE - : BROWSER_SERVICE_RATE_LIMIT_MESSAGE; -} - -function resolveBrowserFetchOperatorHint(url: string): string { - const isLocal = !isAbsoluteHttp(url); - return isLocal - ? `Restart the OpenClaw gateway (OpenClaw.app menubar, or \`${formatCliCommand("openclaw gateway")}\`).` - : "If this is a sandboxed session, ensure the sandbox browser is running."; -} - -function normalizeErrorMessage(err: unknown): string { - if (err instanceof Error && err.message.trim().length > 0) { - return err.message.trim(); - } - return String(err); -} - -function appendBrowserToolModelHint(message: string): string { - if (message.includes(BROWSER_TOOL_MODEL_HINT)) { - return message; - } - return `${message} ${BROWSER_TOOL_MODEL_HINT}`; -} - -async function discardResponseBody(res: Response): Promise { - try { - await res.body?.cancel(); - } catch { - // Best effort only; we're already returning a stable error message. - } -} - -function enhanceDispatcherPathError(url: string, err: unknown): Error { - const msg = normalizeErrorMessage(err); - const suffix = `${resolveBrowserFetchOperatorHint(url)} ${BROWSER_TOOL_MODEL_HINT}`; - const normalized = msg.endsWith(".") ? msg : `${msg}.`; - return new Error(`${normalized} ${suffix}`, err instanceof Error ? { cause: err } : undefined); -} - -function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error { - const operatorHint = resolveBrowserFetchOperatorHint(url); - const msg = String(err); - const msgLower = msg.toLowerCase(); - const looksLikeTimeout = - msgLower.includes("timed out") || - msgLower.includes("timeout") || - msgLower.includes("aborted") || - msgLower.includes("abort") || - msgLower.includes("aborterror"); - if (looksLikeTimeout) { - return new Error( - appendBrowserToolModelHint( - `Can't reach the OpenClaw browser control service (timed out after ${timeoutMs}ms). ${operatorHint}`, - ), - ); - } - return new Error( - appendBrowserToolModelHint( - `Can't reach the OpenClaw browser control service. ${operatorHint} (${msg})`, - ), - ); -} - -async function fetchHttpJson( - url: string, - init: RequestInit & { timeoutMs?: number }, -): Promise { - const timeoutMs = init.timeoutMs ?? 5000; - const ctrl = new AbortController(); - const upstreamSignal = init.signal; - let upstreamAbortListener: (() => void) | undefined; - if (upstreamSignal) { - if (upstreamSignal.aborted) { - ctrl.abort(upstreamSignal.reason); - } else { - upstreamAbortListener = () => ctrl.abort(upstreamSignal.reason); - upstreamSignal.addEventListener("abort", upstreamAbortListener, { once: true }); - } - } - - const t = setTimeout(() => ctrl.abort(new Error("timed out")), timeoutMs); - try { - const res = await fetch(url, { ...init, signal: ctrl.signal }); - if (!res.ok) { - if (isRateLimitStatus(res.status)) { - // Do not reflect upstream response text into the error surface (log/agent injection risk) - await discardResponseBody(res); - throw new BrowserServiceError( - `${resolveBrowserRateLimitMessage(url)} ${BROWSER_TOOL_MODEL_HINT}`, - ); - } - const text = await res.text().catch(() => ""); - throw new BrowserServiceError(text || `HTTP ${res.status}`); - } - return (await res.json()) as T; - } finally { - clearTimeout(t); - if (upstreamSignal && upstreamAbortListener) { - upstreamSignal.removeEventListener("abort", upstreamAbortListener); - } - } -} - -export async function fetchBrowserJson( - url: string, - init?: RequestInit & { timeoutMs?: number }, -): Promise { - const timeoutMs = init?.timeoutMs ?? 5000; - let isDispatcherPath = false; - try { - if (isAbsoluteHttp(url)) { - const httpInit = withLoopbackBrowserAuth(url, init); - return await fetchHttpJson(url, { ...httpInit, timeoutMs }); - } - isDispatcherPath = true; - const started = await startBrowserControlServiceFromConfig(); - if (!started) { - throw new Error("browser control disabled"); - } - const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext()); - const parsed = new URL(url, "http://localhost"); - const query: Record = {}; - 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 = 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 | undefined; - if (timeoutMs) { - timer = setTimeout(() => abortCtrl.abort(new Error("timed out")), timeoutMs); - } - - const dispatchPromise = dispatcher.dispatch({ - method: - init?.method?.toUpperCase() === "DELETE" - ? "DELETE" - : init?.method?.toUpperCase() === "POST" - ? "POST" - : "GET", - path: parsed.pathname, - query, - body, - signal: abortCtrl.signal, - }); - - const result = await Promise.race([dispatchPromise, abortPromise]).finally(() => { - if (timer) { - clearTimeout(timer); - } - if (abortListener) { - abortCtrl.signal.removeEventListener("abort", abortListener); - } - if (upstreamSignal && upstreamAbortListener) { - upstreamSignal.removeEventListener("abort", upstreamAbortListener); - } - }); - - if (result.status >= 400) { - if (isRateLimitStatus(result.status)) { - // Do not reflect upstream response text into the error surface (log/agent injection risk) - throw new BrowserServiceError( - `${resolveBrowserRateLimitMessage(url)} ${BROWSER_TOOL_MODEL_HINT}`, - ); - } - const message = - result.body && typeof result.body === "object" && "error" in result.body - ? String((result.body as { error?: unknown }).error) - : `HTTP ${result.status}`; - throw new BrowserServiceError(message); - } - return result.body as T; - } catch (err) { - if (err instanceof BrowserServiceError) { - throw err; - } - // Dispatcher-path failures are service-operation failures, not network - // reachability failures. Keep the original context, but retain anti-retry hints. - if (isDispatcherPath) { - throw enhanceDispatcherPathError(url, err); - } - throw enhanceBrowserFetchError(url, err, timeoutMs); - } -} - -export const __test = { - withLoopbackBrowserAuth: withLoopbackBrowserAuthImpl, -}; +export * from "../../extensions/browser/src/browser/client-fetch.js"; diff --git a/src/browser/client.ts b/src/browser/client.ts index d7d8690147f..af83cbe166a 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -1,351 +1 @@ -import { fetchBrowserJson } from "./client-fetch.js"; - -export type BrowserTransport = "cdp" | "chrome-mcp"; - -export type BrowserStatus = { - enabled: boolean; - profile?: string; - driver?: "openclaw" | "existing-session"; - transport?: BrowserTransport; - running: boolean; - cdpReady?: boolean; - cdpHttp?: boolean; - pid: number | null; - cdpPort: number | null; - cdpUrl?: string | null; - chosenBrowser: string | null; - detectedBrowser?: string | null; - detectedExecutablePath?: string | null; - detectError?: string | null; - userDataDir: string | null; - color: string; - headless: boolean; - noSandbox?: boolean; - executablePath?: string | null; - attachOnly: boolean; -}; - -export type ProfileStatus = { - name: string; - transport?: BrowserTransport; - cdpPort: number | null; - cdpUrl: string | null; - color: string; - driver: "openclaw" | "existing-session"; - running: boolean; - tabCount: number; - isDefault: boolean; - isRemote: boolean; - missingFromConfig?: boolean; - reconcileReason?: string | null; -}; - -export type BrowserResetProfileResult = { - ok: true; - moved: boolean; - from: string; - to?: string; -}; - -export type BrowserTab = { - targetId: string; - title: string; - url: string; - wsUrl?: string; - type?: string; -}; - -export type SnapshotAriaNode = { - ref: string; - role: string; - name: string; - value?: string; - description?: string; - backendDOMNodeId?: number; - depth: number; -}; - -export type SnapshotResult = - | { - ok: true; - format: "aria"; - targetId: string; - url: string; - nodes: SnapshotAriaNode[]; - } - | { - ok: true; - format: "ai"; - targetId: string; - url: string; - snapshot: string; - truncated?: boolean; - refs?: Record; - 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 { - const q = buildProfileQuery(opts?.profile); - return await fetchBrowserJson(withBaseUrl(baseUrl, `/${q}`), { - timeoutMs: 1500, - }); -} - -export async function browserProfiles(baseUrl?: string): Promise { - const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>( - withBaseUrl(baseUrl, `/profiles`), - { - timeoutMs: 3000, - }, - ); - return res.profiles ?? []; -} - -export async function browserStart(baseUrl?: string, opts?: { profile?: string }): Promise { - 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 { - 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 { - const q = buildProfileQuery(opts?.profile); - return await fetchBrowserJson( - withBaseUrl(baseUrl, `/reset-profile${q}`), - { - method: "POST", - timeoutMs: 20000, - }, - ); -} - -export type BrowserCreateProfileResult = { - ok: true; - profile: string; - transport?: BrowserTransport; - cdpPort: number | null; - cdpUrl: string | null; - userDataDir: string | null; - color: string; - isRemote: boolean; -}; - -export async function browserCreateProfile( - baseUrl: string | undefined, - opts: { - name: string; - color?: string; - cdpUrl?: string; - userDataDir?: string; - driver?: "openclaw" | "existing-session"; - }, -): Promise { - return await fetchBrowserJson( - withBaseUrl(baseUrl, `/profiles/create`), - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: opts.name, - color: opts.color, - cdpUrl: opts.cdpUrl, - userDataDir: opts.userDataDir, - driver: opts.driver, - }), - timeoutMs: 10000, - }, - ); -} - -export type BrowserDeleteProfileResult = { - ok: true; - profile: string; - deleted: boolean; -}; - -export async function browserDeleteProfile( - baseUrl: string | undefined, - profile: string, -): Promise { - return await fetchBrowserJson( - withBaseUrl(baseUrl, `/profiles/${encodeURIComponent(profile)}`), - { - method: "DELETE", - timeoutMs: 20000, - }, - ); -} - -export async function browserTabs( - baseUrl?: string, - opts?: { profile?: string }, -): Promise { - 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 { - const q = buildProfileQuery(opts?.profile); - return await fetchBrowserJson(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 { - 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 { - 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 { - 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 { - const q = new URLSearchParams(); - if (opts.format) { - q.set("format", opts.format); - } - if (opts.targetId) { - q.set("targetId", opts.targetId); - } - if (typeof opts.limit === "number") { - q.set("limit", String(opts.limit)); - } - if (typeof opts.maxChars === "number" && Number.isFinite(opts.maxChars)) { - q.set("maxChars", String(opts.maxChars)); - } - if (opts.refs === "aria" || opts.refs === "role") { - q.set("refs", opts.refs); - } - if (typeof opts.interactive === "boolean") { - q.set("interactive", String(opts.interactive)); - } - if (typeof opts.compact === "boolean") { - q.set("compact", String(opts.compact)); - } - if (typeof opts.depth === "number" && Number.isFinite(opts.depth)) { - q.set("depth", String(opts.depth)); - } - if (opts.selector?.trim()) { - q.set("selector", opts.selector.trim()); - } - if (opts.frame?.trim()) { - q.set("frame", opts.frame.trim()); - } - if (opts.labels === true) { - q.set("labels", "1"); - } - if (opts.mode) { - q.set("mode", opts.mode); - } - if (opts.profile) { - q.set("profile", opts.profile); - } - return await fetchBrowserJson(withBaseUrl(baseUrl, `/snapshot?${q.toString()}`), { - timeoutMs: 20000, - }); -} - -// Actions beyond the basic read-only commands live in client-actions.ts. +export * from "../../extensions/browser/src/browser/client.js"; diff --git a/src/browser/config.ts b/src/browser/config.ts index a5bc131766a..f45150b8f39 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -1,365 +1 @@ -import type { BrowserConfig, BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; -import { resolveGatewayPort } from "../config/paths.js"; -import { - deriveDefaultBrowserCdpPortRange, - deriveDefaultBrowserControlPort, - DEFAULT_BROWSER_CONTROL_PORT, -} from "../config/port-defaults.js"; -import { isLoopbackHost } from "../gateway/net.js"; -import type { SsrFPolicy } from "../infra/net/ssrf.js"; -import { resolveUserPath } from "../utils.js"; -import { - DEFAULT_OPENCLAW_BROWSER_COLOR, - DEFAULT_OPENCLAW_BROWSER_ENABLED, - DEFAULT_BROWSER_EVALUATE_ENABLED, - DEFAULT_BROWSER_DEFAULT_PROFILE_NAME, - DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, -} from "./constants.js"; -import { CDP_PORT_RANGE_START } from "./profiles.js"; - -export type ResolvedBrowserConfig = { - enabled: boolean; - evaluateEnabled: boolean; - controlPort: number; - cdpPortRangeStart: number; - cdpPortRangeEnd: number; - cdpProtocol: "http" | "https"; - cdpHost: string; - cdpIsLoopback: boolean; - remoteCdpTimeoutMs: number; - remoteCdpHandshakeTimeoutMs: number; - color: string; - executablePath?: string; - headless: boolean; - noSandbox: boolean; - attachOnly: boolean; - defaultProfile: string; - profiles: Record; - ssrfPolicy?: SsrFPolicy; - extraArgs: string[]; -}; - -export type ResolvedBrowserProfile = { - name: string; - cdpPort: number; - cdpUrl: string; - cdpHost: string; - cdpIsLoopback: boolean; - userDataDir?: string; - color: string; - driver: "openclaw" | "existing-session"; - attachOnly: boolean; -}; - -function normalizeHexColor(raw: string | undefined) { - const value = (raw ?? "").trim(); - if (!value) { - return DEFAULT_OPENCLAW_BROWSER_COLOR; - } - const normalized = value.startsWith("#") ? value : `#${value}`; - if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) { - return DEFAULT_OPENCLAW_BROWSER_COLOR; - } - return normalized.toUpperCase(); -} - -function normalizeTimeoutMs(raw: number | undefined, fallback: number) { - const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback; - return value < 0 ? fallback : value; -} - -function resolveCdpPortRangeStart( - rawStart: number | undefined, - fallbackStart: number, - rangeSpan: number, -) { - const start = - typeof rawStart === "number" && Number.isFinite(rawStart) - ? Math.floor(rawStart) - : fallbackStart; - if (start < 1 || start > 65535) { - throw new Error(`browser.cdpPortRangeStart must be between 1 and 65535, got: ${start}`); - } - const maxStart = 65535 - rangeSpan; - if (start > maxStart) { - throw new Error( - `browser.cdpPortRangeStart (${start}) is too high for a ${rangeSpan + 1}-port range; max is ${maxStart}.`, - ); - } - return start; -} - -function normalizeStringList(raw: string[] | undefined): string[] | undefined { - if (!Array.isArray(raw) || raw.length === 0) { - return undefined; - } - const values = raw - .map((value) => value.trim()) - .filter((value): value is string => value.length > 0); - return values.length > 0 ? values : undefined; -} - -function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined { - const allowPrivateNetwork = cfg?.ssrfPolicy?.allowPrivateNetwork; - const dangerouslyAllowPrivateNetwork = cfg?.ssrfPolicy?.dangerouslyAllowPrivateNetwork; - const allowedHostnames = normalizeStringList(cfg?.ssrfPolicy?.allowedHostnames); - const hostnameAllowlist = normalizeStringList(cfg?.ssrfPolicy?.hostnameAllowlist); - const hasExplicitPrivateSetting = - allowPrivateNetwork !== undefined || dangerouslyAllowPrivateNetwork !== undefined; - // Browser defaults to trusted-network mode unless explicitly disabled by policy. - const resolvedAllowPrivateNetwork = - dangerouslyAllowPrivateNetwork === true || - allowPrivateNetwork === true || - !hasExplicitPrivateSetting; - - if ( - !resolvedAllowPrivateNetwork && - !hasExplicitPrivateSetting && - !allowedHostnames && - !hostnameAllowlist - ) { - return undefined; - } - - return { - ...(resolvedAllowPrivateNetwork ? { dangerouslyAllowPrivateNetwork: true } : {}), - ...(allowedHostnames ? { allowedHostnames } : {}), - ...(hostnameAllowlist ? { hostnameAllowlist } : {}), - }; -} - -export function parseHttpUrl(raw: string, label: string) { - const trimmed = raw.trim(); - const parsed = new URL(trimmed); - const allowed = ["http:", "https:", "ws:", "wss:"]; - if (!allowed.includes(parsed.protocol)) { - throw new Error(`${label} must be http(s) or ws(s), got: ${parsed.protocol.replace(":", "")}`); - } - - const isSecure = parsed.protocol === "https:" || parsed.protocol === "wss:"; - const port = - parsed.port && Number.parseInt(parsed.port, 10) > 0 - ? Number.parseInt(parsed.port, 10) - : isSecure - ? 443 - : 80; - - if (Number.isNaN(port) || port <= 0 || port > 65535) { - throw new Error(`${label} has invalid port: ${parsed.port}`); - } - - return { - parsed, - port, - normalized: parsed.toString().replace(/\/$/, ""), - }; -} - -/** - * Ensure the default "openclaw" profile exists in the profiles map. - * Auto-creates it with the legacy CDP port (from browser.cdpUrl) or first port if missing. - */ -function ensureDefaultProfile( - profiles: Record | undefined, - defaultColor: string, - legacyCdpPort?: number, - derivedDefaultCdpPort?: number, - legacyCdpUrl?: string, -): Record { - const result = { ...profiles }; - if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) { - result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = { - cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? CDP_PORT_RANGE_START, - color: defaultColor, - // Preserve the full cdpUrl for ws/wss endpoints so resolveProfile() - // doesn't reconstruct from cdpProtocol/cdpHost/cdpPort (which drops - // the WebSocket protocol and query params like API keys). - ...(legacyCdpUrl ? { cdpUrl: legacyCdpUrl } : {}), - }; - } - return result; -} - -/** - * Ensure a built-in "user" profile exists for Chrome's existing-session attach flow. - */ -function ensureDefaultUserBrowserProfile( - profiles: Record, -): Record { - const result = { ...profiles }; - if (result.user) { - return result; - } - result.user = { - driver: "existing-session", - attachOnly: true, - color: "#00AA00", - }; - return result; -} - -export function resolveBrowserConfig( - cfg: BrowserConfig | undefined, - rootConfig?: OpenClawConfig, -): ResolvedBrowserConfig { - const enabled = cfg?.enabled ?? DEFAULT_OPENCLAW_BROWSER_ENABLED; - const evaluateEnabled = cfg?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED; - const gatewayPort = resolveGatewayPort(rootConfig); - const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT); - const defaultColor = normalizeHexColor(cfg?.color); - const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500); - const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs( - cfg?.remoteCdpHandshakeTimeoutMs, - Math.max(2000, remoteCdpTimeoutMs * 2), - ); - - const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort); - const cdpRangeSpan = derivedCdpRange.end - derivedCdpRange.start; - const cdpPortRangeStart = resolveCdpPortRangeStart( - cfg?.cdpPortRangeStart, - derivedCdpRange.start, - cdpRangeSpan, - ); - const cdpPortRangeEnd = cdpPortRangeStart + cdpRangeSpan; - - const rawCdpUrl = (cfg?.cdpUrl ?? "").trim(); - let cdpInfo: - | { - parsed: URL; - port: number; - normalized: string; - } - | undefined; - if (rawCdpUrl) { - cdpInfo = parseHttpUrl(rawCdpUrl, "browser.cdpUrl"); - } else { - const derivedPort = controlPort + 1; - if (derivedPort > 65535) { - throw new Error( - `Derived CDP port (${derivedPort}) is too high; check gateway port configuration.`, - ); - } - const derived = new URL(`http://127.0.0.1:${derivedPort}`); - cdpInfo = { - parsed: derived, - port: derivedPort, - normalized: derived.toString().replace(/\/$/, ""), - }; - } - - const headless = cfg?.headless === true; - const noSandbox = cfg?.noSandbox === true; - const attachOnly = cfg?.attachOnly === true; - const executablePath = cfg?.executablePath?.trim() || undefined; - - const defaultProfileFromConfig = cfg?.defaultProfile?.trim() || undefined; - // Use legacy cdpUrl port for backward compatibility when no profiles configured - const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined; - const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:"; - const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined; - const profiles = ensureDefaultUserBrowserProfile( - ensureDefaultProfile( - cfg?.profiles, - defaultColor, - legacyCdpPort, - cdpPortRangeStart, - legacyCdpUrl, - ), - ); - const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http"; - - const defaultProfile = - defaultProfileFromConfig ?? - (profiles[DEFAULT_BROWSER_DEFAULT_PROFILE_NAME] - ? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME - : profiles[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] - ? DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME - : "user"); - - const extraArgs = Array.isArray(cfg?.extraArgs) - ? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0) - : []; - const ssrfPolicy = resolveBrowserSsrFPolicy(cfg); - return { - enabled, - evaluateEnabled, - controlPort, - cdpPortRangeStart, - cdpPortRangeEnd, - cdpProtocol, - cdpHost: cdpInfo.parsed.hostname, - cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname), - remoteCdpTimeoutMs, - remoteCdpHandshakeTimeoutMs, - color: defaultColor, - executablePath, - headless, - noSandbox, - attachOnly, - defaultProfile, - profiles, - ssrfPolicy, - extraArgs, - }; -} - -/** - * Resolve a profile by name from the config. - * Returns null if the profile doesn't exist. - */ -export function resolveProfile( - resolved: ResolvedBrowserConfig, - profileName: string, -): ResolvedBrowserProfile | null { - const profile = resolved.profiles[profileName]; - if (!profile) { - return null; - } - - const rawProfileUrl = profile.cdpUrl?.trim() ?? ""; - let cdpHost = resolved.cdpHost; - let cdpPort = profile.cdpPort ?? 0; - let cdpUrl = ""; - const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw"; - - if (driver === "existing-session") { - // existing-session uses Chrome MCP auto-connect; no CDP port/URL needed - return { - name: profileName, - cdpPort: 0, - cdpUrl: "", - cdpHost: "", - cdpIsLoopback: true, - userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined, - color: profile.color, - driver, - attachOnly: true, - }; - } - - if (rawProfileUrl) { - const parsed = parseHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`); - cdpHost = parsed.parsed.hostname; - cdpPort = parsed.port; - cdpUrl = parsed.normalized; - } else if (cdpPort) { - cdpUrl = `${resolved.cdpProtocol}://${resolved.cdpHost}:${cdpPort}`; - } else { - throw new Error(`Profile "${profileName}" must define cdpPort or cdpUrl.`); - } - - return { - name: profileName, - cdpPort, - cdpUrl, - cdpHost, - cdpIsLoopback: isLoopbackHost(cdpHost), - color: profile.color, - driver, - attachOnly: profile.attachOnly ?? resolved.attachOnly, - }; -} - -export function shouldStartLocalBrowserServer(_resolved: ResolvedBrowserConfig) { - return true; -} +export * from "../../extensions/browser/src/browser/config.js"; diff --git a/src/browser/constants.ts b/src/browser/constants.ts index 952bf9190a5..24392a23430 100644 --- a/src/browser/constants.ts +++ b/src/browser/constants.ts @@ -1,8 +1 @@ -export const DEFAULT_OPENCLAW_BROWSER_ENABLED = true; -export const DEFAULT_BROWSER_EVALUATE_ENABLED = true; -export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500"; -export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw"; -export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw"; -export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 80_000; -export const DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS = 10_000; -export const DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH = 6; +export * from "../../extensions/browser/src/browser/constants.js"; diff --git a/src/browser/control-auth.ts b/src/browser/control-auth.ts index be7c66ab498..bc1746e9c8c 100644 --- a/src/browser/control-auth.ts +++ b/src/browser/control-auth.ts @@ -1,98 +1 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { loadConfig } from "../config/config.js"; -import { resolveGatewayAuth } from "../gateway/auth.js"; -import { ensureGatewayStartupAuth } from "../gateway/startup-auth.js"; - -export type BrowserControlAuth = { - token?: string; - password?: string; -}; - -export function resolveBrowserControlAuth( - cfg: OpenClawConfig | undefined, - env: NodeJS.ProcessEnv = process.env, -): BrowserControlAuth { - const auth = resolveGatewayAuth({ - authConfig: cfg?.gateway?.auth, - env, - tailscaleMode: cfg?.gateway?.tailscale?.mode, - }); - const token = typeof auth.token === "string" ? auth.token.trim() : ""; - const password = typeof auth.password === "string" ? auth.password.trim() : ""; - return { - token: token || undefined, - password: password || undefined, - }; -} - -function shouldAutoGenerateBrowserAuth(env: NodeJS.ProcessEnv): boolean { - const nodeEnv = (env.NODE_ENV ?? "").trim().toLowerCase(); - if (nodeEnv === "test") { - return false; - } - const vitest = (env.VITEST ?? "").trim().toLowerCase(); - if (vitest && vitest !== "0" && vitest !== "false" && vitest !== "off") { - return false; - } - return true; -} - -export async function ensureBrowserControlAuth(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; -}): Promise<{ - auth: BrowserControlAuth; - generatedToken?: string; -}> { - const env = params.env ?? process.env; - const auth = resolveBrowserControlAuth(params.cfg, env); - if (auth.token || auth.password) { - return { auth }; - } - if (!shouldAutoGenerateBrowserAuth(env)) { - return { auth }; - } - - // Respect explicit password mode even if currently unset. - if (params.cfg.gateway?.auth?.mode === "password") { - return { auth }; - } - - if (params.cfg.gateway?.auth?.mode === "none") { - return { auth }; - } - - if (params.cfg.gateway?.auth?.mode === "trusted-proxy") { - return { auth }; - } - - // Re-read latest config to avoid racing with concurrent config writers. - const latestCfg = loadConfig(); - const latestAuth = resolveBrowserControlAuth(latestCfg, env); - if (latestAuth.token || latestAuth.password) { - return { auth: latestAuth }; - } - if (latestCfg.gateway?.auth?.mode === "password") { - return { auth: latestAuth }; - } - if (latestCfg.gateway?.auth?.mode === "none") { - return { auth: latestAuth }; - } - if (latestCfg.gateway?.auth?.mode === "trusted-proxy") { - return { auth: latestAuth }; - } - - const ensured = await ensureGatewayStartupAuth({ - cfg: latestCfg, - env, - persist: true, - }); - const ensuredAuth = { - token: ensured.auth.token, - password: ensured.auth.password, - }; - return { - auth: ensuredAuth, - generatedToken: ensured.generatedToken, - }; -} +export * from "../../extensions/browser/src/browser/control-auth.js"; diff --git a/src/browser/control-service.plugin-disabled.test.ts b/src/browser/control-service.plugin-disabled.test.ts new file mode 100644 index 00000000000..472c1b1a366 --- /dev/null +++ b/src/browser/control-service.plugin-disabled.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + ensureBrowserControlAuth: vi.fn(async () => ({ generatedToken: false })), + createBrowserRuntimeState: vi.fn(async () => ({ ok: true })), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => ({ + browser: { + enabled: true, + }, + plugins: { + entries: { + browser: { + enabled: false, + }, + }, + }, + }), + }; +}); + +vi.mock("./config.js", () => ({ + resolveBrowserConfig: vi.fn(() => ({ + enabled: true, + controlPort: 18791, + profiles: { openclaw: { cdpPort: 18800 } }, + })), +})); + +vi.mock("./control-auth.js", () => ({ + ensureBrowserControlAuth: mocks.ensureBrowserControlAuth, +})); + +vi.mock("./runtime-lifecycle.js", () => ({ + createBrowserRuntimeState: mocks.createBrowserRuntimeState, + stopBrowserRuntime: vi.fn(async () => {}), +})); + +let startBrowserControlServiceFromConfig: typeof import("./control-service.js").startBrowserControlServiceFromConfig; + +describe("startBrowserControlServiceFromConfig", () => { + beforeEach(async () => { + mocks.ensureBrowserControlAuth.mockClear(); + mocks.createBrowserRuntimeState.mockClear(); + vi.resetModules(); + ({ startBrowserControlServiceFromConfig } = await import("./control-service.js")); + }); + + it("does not start the default service when the browser plugin is disabled", async () => { + const started = await startBrowserControlServiceFromConfig(); + + expect(started).toBeNull(); + expect(mocks.ensureBrowserControlAuth).not.toHaveBeenCalled(); + expect(mocks.createBrowserRuntimeState).not.toHaveBeenCalled(); + }); +}); diff --git a/src/browser/control-service.ts b/src/browser/control-service.ts index 48dc08beb30..2397e6da39d 100644 --- a/src/browser/control-service.ts +++ b/src/browser/control-service.ts @@ -1,65 +1 @@ -import { loadConfig } from "../config/config.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import { resolveBrowserConfig } from "./config.js"; -import { ensureBrowserControlAuth } from "./control-auth.js"; -import { createBrowserRuntimeState, stopBrowserRuntime } from "./runtime-lifecycle.js"; -import { type BrowserServerState, createBrowserRouteContext } from "./server-context.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 { - 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 = await createBrowserRuntimeState({ - server: null, - port: resolved.controlPort, - 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 { - const current = state; - await stopBrowserRuntime({ - current, - getState: () => state, - clearState: () => { - state = null; - }, - onWarn: (message) => logService.warn(message), - }); -} +export * from "../../extensions/browser/src/browser/control-service.js"; diff --git a/src/browser/csrf.ts b/src/browser/csrf.ts index e743febcecf..b09767175a2 100644 --- a/src/browser/csrf.ts +++ b/src/browser/csrf.ts @@ -1,87 +1 @@ -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(); - }; -} +export * from "../../extensions/browser/src/browser/csrf.js"; diff --git a/src/browser/errors.ts b/src/browser/errors.ts index b363de4b06e..e3fab12bd2a 100644 --- a/src/browser/errors.ts +++ b/src/browser/errors.ts @@ -1,85 +1 @@ -import { SsrFBlockedError } from "../infra/net/ssrf.js"; -import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; - -export class BrowserError extends Error { - status: number; - - constructor(message: string, status = 500, options?: ErrorOptions) { - super(message, options); - this.name = new.target.name; - this.status = status; - } -} - -export class BrowserValidationError extends BrowserError { - constructor(message: string, options?: ErrorOptions) { - super(message, 400, options); - } -} - -export class BrowserConfigurationError extends BrowserError { - constructor(message: string, options?: ErrorOptions) { - super(message, 400, options); - } -} - -export class BrowserTargetAmbiguousError extends BrowserError { - constructor(message = "ambiguous target id prefix", options?: ErrorOptions) { - super(message, 409, options); - } -} - -export class BrowserTabNotFoundError extends BrowserError { - constructor(message = "tab not found", options?: ErrorOptions) { - super(message, 404, options); - } -} - -export class BrowserProfileNotFoundError extends BrowserError { - constructor(message: string, options?: ErrorOptions) { - super(message, 404, options); - } -} - -export class BrowserConflictError extends BrowserError { - constructor(message: string, options?: ErrorOptions) { - super(message, 409, options); - } -} - -export class BrowserResetUnsupportedError extends BrowserError { - constructor(message: string, options?: ErrorOptions) { - super(message, 400, options); - } -} - -export class BrowserProfileUnavailableError extends BrowserError { - constructor(message: string, options?: ErrorOptions) { - super(message, 409, options); - } -} - -export class BrowserResourceExhaustedError extends BrowserError { - constructor(message: string, options?: ErrorOptions) { - super(message, 507, options); - } -} - -export function toBrowserErrorResponse(err: unknown): { - status: number; - message: string; -} | null { - if (err instanceof BrowserError) { - return { status: err.status, message: err.message }; - } - if (err instanceof SsrFBlockedError) { - return { status: 400, message: err.message }; - } - if ( - err instanceof InvalidBrowserNavigationUrlError || - (err instanceof Error && err.name === "InvalidBrowserNavigationUrlError") - ) { - return { status: 400, message: err.message }; - } - return null; -} +export * from "../../extensions/browser/src/browser/errors.js"; diff --git a/src/browser/form-fields.ts b/src/browser/form-fields.ts index 9e0dac4ddd6..a7361d76c96 100644 --- a/src/browser/form-fields.ts +++ b/src/browser/form-fields.ts @@ -1,32 +1 @@ -import type { BrowserFormField } from "./client-actions-core.js"; - -export const DEFAULT_FILL_FIELD_TYPE = "text"; - -type BrowserFormFieldValue = NonNullable; - -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, -): 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 }; -} +export * from "../../extensions/browser/src/browser/form-fields.js"; diff --git a/src/browser/http-auth.ts b/src/browser/http-auth.ts index df0ab440dea..173d699234d 100644 --- a/src/browser/http-auth.ts +++ b/src/browser/http-auth.ts @@ -1,63 +1 @@ -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; -} +export * from "../../extensions/browser/src/browser/http-auth.js"; diff --git a/src/browser/navigation-guard.ts b/src/browser/navigation-guard.ts index 216140aba98..40a8d373eee 100644 --- a/src/browser/navigation-guard.ts +++ b/src/browser/navigation-guard.ts @@ -1,134 +1 @@ -import { hasProxyEnvConfigured } from "../infra/net/proxy-env.js"; -import { - isPrivateNetworkAllowedByPolicy, - resolvePinnedHostnameWithPolicy, - type LookupFn, - type SsrFPolicy, -} from "../infra/net/ssrf.js"; - -const NETWORK_NAVIGATION_PROTOCOLS = new Set(["http:", "https:"]); -const SAFE_NON_NETWORK_URLS = new Set(["about:blank"]); - -function isAllowedNonNetworkNavigationUrl(parsed: URL): boolean { - // Keep non-network navigation explicit; about:blank is the only allowed bootstrap URL. - return SAFE_NON_NETWORK_URLS.has(parsed.href); -} - -export class InvalidBrowserNavigationUrlError extends Error { - constructor(message: string) { - super(message); - this.name = "InvalidBrowserNavigationUrlError"; - } -} - -export type BrowserNavigationPolicyOptions = { - ssrfPolicy?: SsrFPolicy; -}; - -export type BrowserNavigationRequestLike = { - url(): string; - redirectedFrom(): BrowserNavigationRequestLike | null; -}; - -export function withBrowserNavigationPolicy( - ssrfPolicy?: SsrFPolicy, -): BrowserNavigationPolicyOptions { - return ssrfPolicy ? { ssrfPolicy } : {}; -} - -export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFPolicy): boolean { - return !isPrivateNetworkAllowedByPolicy(ssrfPolicy); -} - -export async function assertBrowserNavigationAllowed( - opts: { - url: string; - lookupFn?: LookupFn; - } & BrowserNavigationPolicyOptions, -): Promise { - const rawUrl = String(opts.url ?? "").trim(); - if (!rawUrl) { - throw new InvalidBrowserNavigationUrlError("url is required"); - } - - let parsed: URL; - try { - parsed = new URL(rawUrl); - } catch { - throw new InvalidBrowserNavigationUrlError(`Invalid URL: ${rawUrl}`); - } - - if (!NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol)) { - if (isAllowedNonNetworkNavigationUrl(parsed)) { - return; - } - throw new InvalidBrowserNavigationUrlError( - `Navigation blocked: unsupported protocol "${parsed.protocol}"`, - ); - } - - // Browser network stacks may apply env proxy routing at connect-time, which - // can bypass strict destination-binding intent from pre-navigation DNS checks. - // In strict mode, fail closed unless private-network navigation is explicitly - // enabled by policy. - if (hasProxyEnvConfigured() && !isPrivateNetworkAllowedByPolicy(opts.ssrfPolicy)) { - throw new InvalidBrowserNavigationUrlError( - "Navigation blocked: strict browser SSRF policy cannot be enforced while env proxy variables are set", - ); - } - - await resolvePinnedHostnameWithPolicy(parsed.hostname, { - lookupFn: opts.lookupFn, - policy: opts.ssrfPolicy, - }); -} - -/** - * Best-effort post-navigation guard for final page URLs. - * Only validates network URLs (http/https) and about:blank to avoid false - * positives on browser-internal error pages (e.g. chrome-error://). - */ -export async function assertBrowserNavigationResultAllowed( - opts: { - url: string; - lookupFn?: LookupFn; - } & BrowserNavigationPolicyOptions, -): Promise { - const rawUrl = String(opts.url ?? "").trim(); - if (!rawUrl) { - return; - } - let parsed: URL; - try { - parsed = new URL(rawUrl); - } catch { - return; - } - if ( - NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol) || - isAllowedNonNetworkNavigationUrl(parsed) - ) { - await assertBrowserNavigationAllowed(opts); - } -} - -export async function assertBrowserNavigationRedirectChainAllowed( - opts: { - request?: BrowserNavigationRequestLike | null; - lookupFn?: LookupFn; - } & BrowserNavigationPolicyOptions, -): Promise { - const chain: string[] = []; - let current = opts.request ?? null; - while (current) { - chain.push(current.url()); - current = current.redirectedFrom(); - } - for (const url of chain.toReversed()) { - await assertBrowserNavigationAllowed({ - url, - lookupFn: opts.lookupFn, - ssrfPolicy: opts.ssrfPolicy, - }); - } -} +export * from "../../extensions/browser/src/browser/navigation-guard.js"; diff --git a/src/browser/output-atomic.ts b/src/browser/output-atomic.ts index 541ad0901b6..8caa070a249 100644 --- a/src/browser/output-atomic.ts +++ b/src/browser/output-atomic.ts @@ -1,51 +1 @@ -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { writeFileFromPathWithinRoot } from "../infra/fs-safe.js"; -import { sanitizeUntrustedFileName } from "./safe-filename.js"; - -function buildSiblingTempPath(targetPath: string): string { - const id = crypto.randomUUID(); - const safeTail = sanitizeUntrustedFileName(path.basename(targetPath), "output.bin"); - return path.join(path.dirname(targetPath), `.openclaw-output-${id}-${safeTail}.part`); -} - -export async function writeViaSiblingTempPath(params: { - rootDir: string; - targetPath: string; - writeTemp: (tempPath: string) => Promise; -}): Promise { - const rootDir = await fs - .realpath(path.resolve(params.rootDir)) - .catch(() => path.resolve(params.rootDir)); - const requestedTargetPath = path.resolve(params.targetPath); - const targetPath = await fs - .realpath(path.dirname(requestedTargetPath)) - .then((realDir) => path.join(realDir, path.basename(requestedTargetPath))) - .catch(() => requestedTargetPath); - const relativeTargetPath = path.relative(rootDir, targetPath); - if ( - !relativeTargetPath || - relativeTargetPath === ".." || - relativeTargetPath.startsWith(`..${path.sep}`) || - path.isAbsolute(relativeTargetPath) - ) { - throw new Error("Target path is outside the allowed root"); - } - const tempPath = buildSiblingTempPath(targetPath); - let renameSucceeded = false; - try { - await params.writeTemp(tempPath); - await writeFileFromPathWithinRoot({ - rootDir, - relativePath: relativeTargetPath, - sourcePath: tempPath, - mkdir: false, - }); - renameSucceeded = true; - } finally { - if (!renameSucceeded) { - await fs.rm(tempPath, { force: true }).catch(() => {}); - } - } -} +export * from "../../extensions/browser/src/browser/output-atomic.js"; diff --git a/src/browser/paths.ts b/src/browser/paths.ts index 1506a2e2e91..bff9fec9ea9 100644 --- a/src/browser/paths.ts +++ b/src/browser/paths.ts @@ -1,256 +1 @@ -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 { - try { - return await fs.realpath(targetPath); - } catch { - return undefined; - } -} - -async function resolveTrustedRootRealPath(rootDir: string): Promise { - try { - const rootLstat = await fs.lstat(rootDir); - if (!rootLstat.isDirectory() || rootLstat.isSymbolicLink()) { - return undefined; - } - return await fs.realpath(rootDir); - } catch { - return undefined; - } -} - -async function validateCanonicalPathWithinRoot(params: { - rootRealPath: string; - candidatePath: string; - expect: "directory" | "file"; -}): Promise<"ok" | "not-found" | "invalid"> { - try { - const candidateLstat = await fs.lstat(params.candidatePath); - if (candidateLstat.isSymbolicLink()) { - return "invalid"; - } - if (params.expect === "directory" && !candidateLstat.isDirectory()) { - return "invalid"; - } - if (params.expect === "file" && !candidateLstat.isFile()) { - return "invalid"; - } - if (params.expect === "file" && candidateLstat.nlink > 1) { - return "invalid"; - } - const candidateRealPath = await fs.realpath(params.candidatePath); - return isPathInside(params.rootRealPath, candidateRealPath) ? "ok" : "invalid"; - } catch (err) { - return isNotFoundPathError(err) ? "not-found" : "invalid"; - } -} - -export function resolvePathWithinRoot(params: { - rootDir: string; - requestedPath: string; - scopeLabel: string; - defaultFileName?: string; -}): { ok: true; path: string } | { ok: false; error: string } { - const root = path.resolve(params.rootDir); - const raw = params.requestedPath.trim(); - if (!raw) { - if (!params.defaultFileName) { - return { ok: false, error: "path is required" }; - } - return { ok: true, path: path.join(root, params.defaultFileName) }; - } - const resolved = path.resolve(root, raw); - const rel = path.relative(root, resolved); - if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) { - return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel}` }; - } - return { ok: true, path: resolved }; -} - -export async function resolveWritablePathWithinRoot(params: { - rootDir: string; - requestedPath: string; - scopeLabel: string; - defaultFileName?: string; -}): Promise<{ ok: true; path: string } | { ok: false; error: string }> { - const lexical = resolvePathWithinRoot(params); - if (!lexical.ok) { - return lexical; - } - - const rootDir = path.resolve(params.rootDir); - const rootRealPath = await resolveTrustedRootRealPath(rootDir); - if (!rootRealPath) { - return invalidPath(params.scopeLabel); - } - - const requestedPath = lexical.path; - const parentDir = path.dirname(requestedPath); - const parentStatus = await validateCanonicalPathWithinRoot({ - rootRealPath, - candidatePath: parentDir, - expect: "directory", - }); - if (parentStatus !== "ok") { - return invalidPath(params.scopeLabel); - } - - const targetStatus = await validateCanonicalPathWithinRoot({ - rootRealPath, - candidatePath: requestedPath, - expect: "file", - }); - if (targetStatus === "invalid") { - return invalidPath(params.scopeLabel); - } - - return lexical; -} - -export function resolvePathsWithinRoot(params: { - rootDir: string; - requestedPaths: string[]; - scopeLabel: string; -}): { ok: true; paths: string[] } | { ok: false; error: string } { - const resolvedPaths: string[] = []; - for (const raw of params.requestedPaths) { - const pathResult = resolvePathWithinRoot({ - rootDir: params.rootDir, - requestedPath: raw, - scopeLabel: params.scopeLabel, - }); - if (!pathResult.ok) { - return { ok: false, error: pathResult.error }; - } - resolvedPaths.push(pathResult.path); - } - return { ok: true, paths: resolvedPaths }; -} - -export async function resolveExistingPathsWithinRoot(params: { - rootDir: string; - requestedPaths: string[]; - scopeLabel: string; -}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> { - return await resolveCheckedPathsWithinRoot({ - ...params, - allowMissingFallback: true, - }); -} - -export async function resolveStrictExistingPathsWithinRoot(params: { - rootDir: string; - requestedPaths: string[]; - scopeLabel: string; -}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> { - return await resolveCheckedPathsWithinRoot({ - ...params, - allowMissingFallback: false, - }); -} - -async function resolveCheckedPathsWithinRoot(params: { - rootDir: string; - requestedPaths: string[]; - scopeLabel: string; - allowMissingFallback: boolean; -}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> { - const rootDir = path.resolve(params.rootDir); - // Keep historical behavior for missing roots and rely on openFileWithinRoot for final checks. - const rootRealPath = await resolveRealPathIfExists(rootDir); - - const isInRoot = (relativePath: string) => - Boolean(relativePath) && !relativePath.startsWith("..") && !path.isAbsolute(relativePath); - - const resolveExistingRelativePath = async ( - requestedPath: string, - ): Promise< - { ok: true; relativePath: string; fallbackPath: string } | { ok: false; error: string } - > => { - const raw = requestedPath.trim(); - const lexicalPathResult = resolvePathWithinRoot({ - rootDir, - requestedPath, - scopeLabel: params.scopeLabel, - }); - if (lexicalPathResult.ok) { - return { - ok: true, - relativePath: path.relative(rootDir, lexicalPathResult.path), - fallbackPath: lexicalPathResult.path, - }; - } - if (!rootRealPath || !raw || !path.isAbsolute(raw)) { - return lexicalPathResult; - } - try { - const resolvedExistingPath = await fs.realpath(raw); - const relativePath = path.relative(rootRealPath, resolvedExistingPath); - if (!isInRoot(relativePath)) { - return lexicalPathResult; - } - return { - ok: true, - relativePath, - fallbackPath: resolvedExistingPath, - }; - } catch { - return lexicalPathResult; - } - }; - - const resolvedPaths: string[] = []; - for (const raw of params.requestedPaths) { - const pathResult = await resolveExistingRelativePath(raw); - if (!pathResult.ok) { - return { ok: false, error: pathResult.error }; - } - - let opened: Awaited> | undefined; - try { - opened = await openFileWithinRoot({ - rootDir, - relativePath: pathResult.relativePath, - }); - resolvedPaths.push(opened.realPath); - } catch (err) { - if (params.allowMissingFallback && err instanceof SafeOpenError && err.code === "not-found") { - // Preserve historical behavior for paths that do not exist yet. - resolvedPaths.push(pathResult.fallbackPath); - continue; - } - if (err instanceof SafeOpenError && err.code === "outside-workspace") { - return { - ok: false, - error: `File is outside ${params.scopeLabel}`, - }; - } - return { - ok: false, - error: `Invalid path: must stay within ${params.scopeLabel} and be a regular non-symlink file`, - }; - } finally { - await opened?.handle.close().catch(() => {}); - } - } - return { ok: true, paths: resolvedPaths }; -} +export * from "../../extensions/browser/src/browser/paths.js"; diff --git a/src/browser/plugin-enabled.test.ts b/src/browser/plugin-enabled.test.ts new file mode 100644 index 00000000000..999a0bcfdcc --- /dev/null +++ b/src/browser/plugin-enabled.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { isDefaultBrowserPluginEnabled } from "./plugin-enabled.js"; + +describe("isDefaultBrowserPluginEnabled", () => { + it("defaults to enabled", () => { + expect(isDefaultBrowserPluginEnabled({} as OpenClawConfig)).toBe(true); + }); + + it("respects explicit plugin disablement", () => { + expect( + isDefaultBrowserPluginEnabled({ + plugins: { + entries: { + browser: { + enabled: false, + }, + }, + }, + } as OpenClawConfig), + ).toBe(false); + }); +}); diff --git a/src/browser/plugin-enabled.ts b/src/browser/plugin-enabled.ts new file mode 100644 index 00000000000..3aee50235d0 --- /dev/null +++ b/src/browser/plugin-enabled.ts @@ -0,0 +1 @@ +export * from "../../extensions/browser/src/browser/plugin-enabled.js"; diff --git a/src/browser/plugin-service.ts b/src/browser/plugin-service.ts new file mode 100644 index 00000000000..af6a14f0d2d --- /dev/null +++ b/src/browser/plugin-service.ts @@ -0,0 +1 @@ +export * from "../../extensions/browser/src/browser/plugin-service.js"; diff --git a/src/browser/profile-capabilities.ts b/src/browser/profile-capabilities.ts index 994894239d1..f486ed4175a 100644 --- a/src/browser/profile-capabilities.ts +++ b/src/browser/profile-capabilities.ts @@ -1,93 +1 @@ -import type { ResolvedBrowserProfile } from "./config.js"; - -export type BrowserProfileMode = "local-managed" | "local-existing-session" | "remote-cdp"; - -export type BrowserProfileCapabilities = { - mode: BrowserProfileMode; - isRemote: boolean; - /** Profile uses the Chrome DevTools MCP server (existing-session driver). */ - usesChromeMcp: boolean; - usesPersistentPlaywright: boolean; - supportsPerTabWs: boolean; - supportsJsonTabEndpoints: boolean; - supportsReset: boolean; - supportsManagedTabLimit: boolean; -}; - -export function getBrowserProfileCapabilities( - profile: ResolvedBrowserProfile, -): BrowserProfileCapabilities { - if (profile.driver === "existing-session") { - return { - mode: "local-existing-session", - isRemote: false, - usesChromeMcp: true, - usesPersistentPlaywright: false, - supportsPerTabWs: false, - supportsJsonTabEndpoints: false, - supportsReset: false, - supportsManagedTabLimit: false, - }; - } - - if (!profile.cdpIsLoopback) { - return { - mode: "remote-cdp", - isRemote: true, - usesChromeMcp: false, - usesPersistentPlaywright: true, - supportsPerTabWs: false, - supportsJsonTabEndpoints: false, - supportsReset: false, - supportsManagedTabLimit: false, - }; - } - - return { - mode: "local-managed", - isRemote: false, - usesChromeMcp: false, - usesPersistentPlaywright: false, - supportsPerTabWs: true, - supportsJsonTabEndpoints: true, - supportsReset: true, - supportsManagedTabLimit: true, - }; -} - -export function resolveDefaultSnapshotFormat(params: { - profile: ResolvedBrowserProfile; - hasPlaywright: boolean; - explicitFormat?: "ai" | "aria"; - mode?: "efficient"; -}): "ai" | "aria" { - if (params.explicitFormat) { - return params.explicitFormat; - } - if (params.mode === "efficient") { - return "ai"; - } - - const capabilities = getBrowserProfileCapabilities(params.profile); - if (capabilities.mode === "local-existing-session") { - return "ai"; - } - - return params.hasPlaywright ? "ai" : "aria"; -} - -export function shouldUsePlaywrightForScreenshot(params: { - profile: ResolvedBrowserProfile; - wsUrl?: string; - ref?: string; - element?: string; -}): boolean { - return !params.wsUrl || Boolean(params.ref) || Boolean(params.element); -} - -export function shouldUsePlaywrightForAriaSnapshot(params: { - profile: ResolvedBrowserProfile; - wsUrl?: string; -}): boolean { - return !params.wsUrl; -} +export * from "../../extensions/browser/src/browser/profile-capabilities.js"; diff --git a/src/browser/profiles-service.ts b/src/browser/profiles-service.ts index ea1f3b674c6..0d5ac204e6d 100644 --- a/src/browser/profiles-service.ts +++ b/src/browser/profiles-service.ts @@ -1,258 +1 @@ -import fs from "node:fs"; -import path from "node:path"; -import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; -import { loadConfig, writeConfigFile } from "../config/config.js"; -import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js"; -import { resolveUserPath } from "../utils.js"; -import { resolveOpenClawUserDataDir } from "./chrome.js"; -import { parseHttpUrl, resolveProfile } from "./config.js"; -import { - BrowserConflictError, - BrowserProfileNotFoundError, - BrowserResourceExhaustedError, - BrowserValidationError, -} from "./errors.js"; -import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; -import { - allocateCdpPort, - allocateColor, - getUsedColors, - getUsedPorts, - isValidProfileName, -} from "./profiles.js"; -import type { BrowserRouteContext, ProfileStatus } from "./server-context.js"; -import { movePathToTrash } from "./trash.js"; - -export type CreateProfileParams = { - name: string; - color?: string; - cdpUrl?: string; - userDataDir?: string; - driver?: "openclaw" | "existing-session"; -}; - -export type CreateProfileResult = { - ok: true; - profile: string; - transport: "cdp" | "chrome-mcp"; - cdpPort: number | null; - cdpUrl: string | null; - userDataDir: string | null; - color: string; - isRemote: boolean; -}; - -export type DeleteProfileResult = { - ok: true; - profile: string; - deleted: boolean; -}; - -const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/; - -const cdpPortRange = (resolved: { - controlPort: number; - cdpPortRangeStart?: number; - cdpPortRangeEnd?: number; -}): { start: number; end: number } => { - const start = resolved.cdpPortRangeStart; - const end = resolved.cdpPortRangeEnd; - if ( - typeof start === "number" && - Number.isFinite(start) && - Number.isInteger(start) && - typeof end === "number" && - Number.isFinite(end) && - Number.isInteger(end) && - start > 0 && - end >= start && - end <= 65535 - ) { - return { start, end }; - } - - return deriveDefaultBrowserCdpPortRange(resolved.controlPort); -}; - -export function createBrowserProfilesService(ctx: BrowserRouteContext) { - const listProfiles = async (): Promise => { - return await ctx.listProfiles(); - }; - - const createProfile = async (params: CreateProfileParams): Promise => { - const name = params.name.trim(); - const rawCdpUrl = params.cdpUrl?.trim() || undefined; - const rawUserDataDir = params.userDataDir?.trim() || undefined; - const normalizedUserDataDir = rawUserDataDir ? resolveUserPath(rawUserDataDir) : undefined; - const driver = params.driver === "existing-session" ? "existing-session" : undefined; - - if (!isValidProfileName(name)) { - throw new BrowserValidationError( - "invalid profile name: use lowercase letters, numbers, and hyphens only", - ); - } - - const state = ctx.state(); - const resolvedProfiles = state.resolved.profiles; - if (name in resolvedProfiles) { - throw new BrowserConflictError(`profile "${name}" already exists`); - } - - const cfg = loadConfig(); - const rawProfiles = cfg.browser?.profiles ?? {}; - if (name in rawProfiles) { - throw new BrowserConflictError(`profile "${name}" already exists`); - } - - const usedColors = getUsedColors(resolvedProfiles); - const profileColor = - params.color && HEX_COLOR_RE.test(params.color) ? params.color : allocateColor(usedColors); - - let profileConfig: BrowserProfileConfig; - if (normalizedUserDataDir && driver !== "existing-session") { - throw new BrowserValidationError( - "driver=existing-session is required when userDataDir is provided", - ); - } - if (normalizedUserDataDir && !fs.existsSync(normalizedUserDataDir)) { - throw new BrowserValidationError( - `browser user data directory not found: ${normalizedUserDataDir}`, - ); - } - - if (rawCdpUrl) { - let parsed: ReturnType; - try { - parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl"); - } catch (err) { - throw new BrowserValidationError(String(err)); - } - if (driver === "existing-session") { - throw new BrowserValidationError( - "driver=existing-session does not accept cdpUrl; it attaches via the Chrome MCP auto-connect flow", - ); - } - profileConfig = { - cdpUrl: parsed.normalized, - ...(driver ? { driver } : {}), - color: profileColor, - }; - } else { - if (driver === "existing-session") { - // existing-session uses Chrome MCP auto-connect; no CDP port needed - profileConfig = { - driver, - attachOnly: true, - ...(normalizedUserDataDir ? { userDataDir: normalizedUserDataDir } : {}), - color: profileColor, - }; - } else { - const usedPorts = getUsedPorts(resolvedProfiles); - const range = cdpPortRange(state.resolved); - const cdpPort = allocateCdpPort(usedPorts, range); - if (cdpPort === null) { - throw new BrowserResourceExhaustedError("no available CDP ports in range"); - } - profileConfig = { - cdpPort, - ...(driver ? { driver } : {}), - color: profileColor, - }; - } - } - - const nextConfig: OpenClawConfig = { - ...cfg, - browser: { - ...cfg.browser, - profiles: { - ...rawProfiles, - [name]: profileConfig, - }, - }, - }; - - await writeConfigFile(nextConfig); - - state.resolved.profiles[name] = profileConfig; - const resolved = resolveProfile(state.resolved, name); - if (!resolved) { - throw new BrowserProfileNotFoundError(`profile "${name}" not found after creation`); - } - const capabilities = getBrowserProfileCapabilities(resolved); - - return { - ok: true, - profile: name, - transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp", - cdpPort: capabilities.usesChromeMcp ? null : resolved.cdpPort, - cdpUrl: capabilities.usesChromeMcp ? null : resolved.cdpUrl, - userDataDir: resolved.userDataDir ?? null, - color: resolved.color, - isRemote: !resolved.cdpIsLoopback, - }; - }; - - const deleteProfile = async (nameRaw: string): Promise => { - const name = nameRaw.trim(); - if (!name) { - throw new BrowserValidationError("profile name is required"); - } - if (!isValidProfileName(name)) { - throw new BrowserValidationError("invalid profile name"); - } - - const state = ctx.state(); - const cfg = loadConfig(); - const profiles = cfg.browser?.profiles ?? {}; - const defaultProfile = cfg.browser?.defaultProfile ?? state.resolved.defaultProfile; - if (name === defaultProfile) { - throw new BrowserValidationError( - `cannot delete the default profile "${name}"; change browser.defaultProfile first`, - ); - } - if (!(name in profiles)) { - throw new BrowserProfileNotFoundError(`profile "${name}" not found`); - } - - let deleted = false; - const resolved = resolveProfile(state.resolved, name); - - if (resolved?.cdpIsLoopback && resolved.driver === "openclaw") { - try { - await ctx.forProfile(name).stopRunningBrowser(); - } catch { - // ignore - } - - const userDataDir = resolveOpenClawUserDataDir(name); - const profileDir = path.dirname(userDataDir); - if (fs.existsSync(profileDir)) { - await movePathToTrash(profileDir); - deleted = true; - } - } - - const { [name]: _removed, ...remainingProfiles } = profiles; - const nextConfig: OpenClawConfig = { - ...cfg, - browser: { - ...cfg.browser, - profiles: remainingProfiles, - }, - }; - - await writeConfigFile(nextConfig); - - delete state.resolved.profiles[name]; - state.profiles.delete(name); - - return { ok: true, profile: name, deleted }; - }; - - return { - listProfiles, - createProfile, - deleteProfile, - }; -} +export * from "../../extensions/browser/src/browser/profiles-service.js"; diff --git a/src/browser/profiles.ts b/src/browser/profiles.ts index 73d561d7bdb..6676ffff054 100644 --- a/src/browser/profiles.ts +++ b/src/browser/profiles.ts @@ -1,113 +1 @@ -/** - * 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, - 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 | undefined, -): Set { - if (!profiles) { - return new Set(); - } - const used = new Set(); - 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 { - // 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 | undefined, -): Set { - if (!profiles) { - return new Set(); - } - return new Set(Object.values(profiles).map((p) => p.color.toUpperCase())); -} +export * from "../../extensions/browser/src/browser/profiles.js"; diff --git a/src/browser/proxy-files.ts b/src/browser/proxy-files.ts index 1d39d71a09e..839a4c5c30c 100644 --- a/src/browser/proxy-files.ts +++ b/src/browser/proxy-files.ts @@ -1,40 +1 @@ -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(); - } - const mapping = new Map(); - for (const file of files) { - const buffer = Buffer.from(file.base64, "base64"); - const saved = await saveMediaBuffer(buffer, file.mimeType, "browser"); - mapping.set(file.path, saved.path); - } - return mapping; -} - -export function applyBrowserProxyPaths(result: unknown, mapping: Map) { - if (!result || typeof result !== "object") { - return; - } - const obj = result as Record; - 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; - if (typeof d.path === "string" && mapping.has(d.path)) { - d.path = mapping.get(d.path); - } - } -} +export * from "../../extensions/browser/src/browser/proxy-files.js"; diff --git a/src/browser/pw-ai-module.ts b/src/browser/pw-ai-module.ts index e5062b8fe74..046248d0b3e 100644 --- a/src/browser/pw-ai-module.ts +++ b/src/browser/pw-ai-module.ts @@ -1,51 +1 @@ -import { extractErrorCode, formatErrorMessage } from "../infra/errors.js"; - -export type PwAiModule = typeof import("./pw-ai.js"); - -type PwAiLoadMode = "soft" | "strict"; - -let pwAiModuleSoft: Promise | null = null; -let pwAiModuleStrict: Promise | 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 { - 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 { - 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; -} +export * from "../../extensions/browser/src/browser/pw-ai-module.js"; diff --git a/src/browser/pw-ai-state.ts b/src/browser/pw-ai-state.ts index 58ce89f30d9..061fba4a40d 100644 --- a/src/browser/pw-ai-state.ts +++ b/src/browser/pw-ai-state.ts @@ -1,9 +1 @@ -let pwAiLoaded = false; - -export function markPwAiLoaded(): void { - pwAiLoaded = true; -} - -export function isPwAiLoaded(): boolean { - return pwAiLoaded; -} +export * from "../../extensions/browser/src/browser/pw-ai-state.js"; diff --git a/src/browser/pw-ai.ts b/src/browser/pw-ai.ts index f8d538b5394..f9b60d976d4 100644 --- a/src/browser/pw-ai.ts +++ b/src/browser/pw-ai.ts @@ -1,66 +1 @@ -import { markPwAiLoaded } from "./pw-ai-state.js"; - -markPwAiLoaded(); - -export { - type BrowserConsoleMessage, - closePageByTargetIdViaPlaywright, - closePlaywrightBrowserConnection, - createPageViaPlaywright, - ensurePageState, - forceDisconnectPlaywrightForTarget, - focusPageByTargetIdViaPlaywright, - getPageForTargetId, - listPagesViaPlaywright, - refLocator, - type WithSnapshotForAI, -} from "./pw-session.js"; - -export { - armDialogViaPlaywright, - armFileUploadViaPlaywright, - batchViaPlaywright, - clickViaPlaywright, - closePageViaPlaywright, - cookiesClearViaPlaywright, - cookiesGetViaPlaywright, - cookiesSetViaPlaywright, - downloadViaPlaywright, - dragViaPlaywright, - emulateMediaViaPlaywright, - evaluateViaPlaywright, - fillFormViaPlaywright, - getConsoleMessagesViaPlaywright, - getNetworkRequestsViaPlaywright, - getPageErrorsViaPlaywright, - highlightViaPlaywright, - hoverViaPlaywright, - navigateViaPlaywright, - pdfViaPlaywright, - pressKeyViaPlaywright, - resizeViewportViaPlaywright, - responseBodyViaPlaywright, - scrollIntoViewViaPlaywright, - selectOptionViaPlaywright, - setDeviceViaPlaywright, - setExtraHTTPHeadersViaPlaywright, - setGeolocationViaPlaywright, - setHttpCredentialsViaPlaywright, - setInputFilesViaPlaywright, - setLocaleViaPlaywright, - setOfflineViaPlaywright, - setTimezoneViaPlaywright, - snapshotAiViaPlaywright, - snapshotAriaViaPlaywright, - snapshotRoleViaPlaywright, - screenshotWithLabelsViaPlaywright, - storageClearViaPlaywright, - storageGetViaPlaywright, - storageSetViaPlaywright, - takeScreenshotViaPlaywright, - traceStartViaPlaywright, - traceStopViaPlaywright, - typeViaPlaywright, - waitForDownloadViaPlaywright, - waitForViaPlaywright, -} from "./pw-tools-core.js"; +export * from "../../extensions/browser/src/browser/pw-ai.js"; diff --git a/src/browser/pw-role-snapshot.ts b/src/browser/pw-role-snapshot.ts index 312abcf872f..6efcb09ba0e 100644 --- a/src/browser/pw-role-snapshot.ts +++ b/src/browser/pw-role-snapshot.ts @@ -1,402 +1 @@ -import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js"; - -export type RoleRef = { - role: string; - name?: string; - /** Index used only when role+name duplicates exist. */ - nth?: number; -}; - -export type RoleRefMap = Record; - -export type RoleSnapshotStats = { - lines: number; - chars: number; - refs: number; - interactive: number; -}; - -export type RoleSnapshotOptions = { - /** Only include interactive elements (buttons, links, inputs, etc.). */ - interactive?: boolean; - /** Maximum depth to include (0 = root only). */ - maxDepth?: number; - /** Remove unnamed structural elements and empty branches. */ - compact?: boolean; -}; - -export function getRoleSnapshotStats(snapshot: string, refs: RoleRefMap): RoleSnapshotStats { - const interactive = Object.values(refs).filter((r) => INTERACTIVE_ROLES.has(r.role)).length; - return { - lines: snapshot.split("\n").length, - chars: snapshot.length, - refs: Object.keys(refs).length, - interactive, - }; -} - -function getIndentLevel(line: string): number { - const match = line.match(/^(\s*)/); - return match ? Math.floor(match[1].length / 2) : 0; -} - -function matchInteractiveSnapshotLine( - line: string, - options: RoleSnapshotOptions, -): { roleRaw: string; role: string; name?: string; suffix: string } | null { - const depth = getIndentLevel(line); - if (options.maxDepth !== undefined && depth > options.maxDepth) { - return null; - } - const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/); - if (!match) { - return null; - } - const [, , roleRaw, name, suffix] = match; - if (roleRaw.startsWith("/")) { - return null; - } - const role = roleRaw.toLowerCase(); - return { - roleRaw, - role, - ...(name ? { name } : {}), - suffix, - }; -} - -type RoleNameTracker = { - counts: Map; - refsByKey: Map; - getKey: (role: string, name?: string) => string; - getNextIndex: (role: string, name?: string) => number; - trackRef: (role: string, name: string | undefined, ref: string) => void; - getDuplicateKeys: () => Set; -}; - -function createRoleNameTracker(): RoleNameTracker { - const counts = new Map(); - const refsByKey = new Map(); - 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(); - 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>; - -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, - }; -} +export * from "../../extensions/browser/src/browser/pw-role-snapshot.js"; diff --git a/src/browser/pw-session.mock-setup.ts b/src/browser/pw-session.mock-setup.ts index 0b176d536db..b7cc7071733 100644 --- a/src/browser/pw-session.mock-setup.ts +++ b/src/browser/pw-session.mock-setup.ts @@ -1,15 +1 @@ -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), -})); +export * from "../../extensions/browser/src/browser/pw-session.mock-setup.js"; diff --git a/src/browser/pw-session.page-cdp.ts b/src/browser/pw-session.page-cdp.ts index ccfc2ee7f34..629c0d06489 100644 --- a/src/browser/pw-session.page-cdp.ts +++ b/src/browser/pw-session.page-cdp.ts @@ -1,33 +1 @@ -import type { CDPSession, Page } from "playwright-core"; - -type PageCdpSend = (method: string, params?: Record) => Promise; - -async function withPlaywrightPageCdpSession( - page: Page, - fn: (session: CDPSession) => Promise, -): Promise { - const session = await page.context().newCDPSession(page); - try { - return await fn(session); - } finally { - await session.detach().catch(() => {}); - } -} - -export async function withPageScopedCdpClient(opts: { - cdpUrl: string; - page: Page; - targetId?: string; - fn: (send: PageCdpSend) => Promise; -}): Promise { - return await withPlaywrightPageCdpSession(opts.page, async (session) => { - return await opts.fn((method, params) => - ( - session.send as unknown as ( - method: string, - params?: Record, - ) => Promise - )(method, params), - ); - }); -} +export * from "../../extensions/browser/src/browser/pw-session.page-cdp.js"; diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index 97677557543..01bed483f79 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -1,845 +1 @@ -import type { - Browser, - BrowserContext, - ConsoleMessage, - Page, - Request, - Response, -} from "playwright-core"; -import { chromium } from "playwright-core"; -import { formatErrorMessage } from "../infra/errors.js"; -import type { SsrFPolicy } from "../infra/net/ssrf.js"; -import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; -import { - appendCdpPath, - fetchJson, - getHeadersWithAuth, - normalizeCdpHttpBaseForJsonEndpoints, - withCdpSocket, -} from "./cdp.helpers.js"; -import { normalizeCdpWsUrl } from "./cdp.js"; -import { getChromeWebSocketUrl } from "./chrome.js"; -import { BrowserTabNotFoundError } from "./errors.js"; -import { - assertBrowserNavigationAllowed, - assertBrowserNavigationRedirectChainAllowed, - assertBrowserNavigationResultAllowed, - withBrowserNavigationPolicy, -} from "./navigation-guard.js"; -import { withPageScopedCdpClient } from "./pw-session.page-cdp.js"; - -export type BrowserConsoleMessage = { - type: string; - text: string; - timestamp: string; - location?: { url?: string; lineNumber?: number; columnNumber?: number }; -}; - -export type BrowserPageError = { - message: string; - name?: string; - stack?: string; - timestamp: string; -}; - -export type BrowserNetworkRequest = { - id: string; - timestamp: string; - method: string; - url: string; - resourceType?: string; - status?: number; - ok?: boolean; - failureText?: string; -}; - -type SnapshotForAIResult = { full: string; incremental?: string }; -type SnapshotForAIOptions = { timeout?: number; track?: string }; - -export type WithSnapshotForAI = { - _snapshotForAI?: (options?: SnapshotForAIOptions) => Promise; -}; - -type TargetInfoResponse = { - targetInfo?: { - targetId?: string; - }; -}; - -type ConnectedBrowser = { - browser: Browser; - cdpUrl: string; - onDisconnected?: () => void; -}; - -type PageState = { - console: BrowserConsoleMessage[]; - errors: BrowserPageError[]; - requests: BrowserNetworkRequest[]; - requestIds: WeakMap; - 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; - roleRefsMode?: "role" | "aria"; - roleRefsFrameSelector?: string; -}; - -type RoleRefs = NonNullable; -type RoleRefsCacheEntry = { - refs: RoleRefs; - frameSelector?: string; - mode?: NonNullable; -}; - -type ContextState = { - traceActive: boolean; -}; - -const pageStates = new WeakMap(); -const contextStates = new WeakMap(); -const observedContexts = new WeakSet(); -const observedPages = new WeakSet(); - -// 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(); -const MAX_ROLE_REFS_CACHE = 50; - -const MAX_CONSOLE_MESSAGES = 500; -const MAX_PAGE_ERRORS = 200; -const MAX_NETWORK_REQUESTS = 500; - -const cachedByCdpUrl = new Map(); -const connectingByCdpUrl = new Map>(); - -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; -}): 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; -}): 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 { - const normalized = normalizeCdpUrl(cdpUrl); - const cached = cachedByCdpUrl.get(normalized); - if (cached) { - return cached; - } - const connecting = connectingByCdpUrl.get(normalized); - if (connecting) { - return await connecting; - } - - const connectWithRetry = async (): Promise => { - let lastErr: unknown; - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - const timeout = 5000 + attempt * 2000; - const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null); - const endpoint = wsUrl ?? normalized; - const headers = getHeadersWithAuth(endpoint); - // Bypass proxy for loopback CDP connections (#31219) - const browser = await withNoProxyForCdpUrl(endpoint, () => - chromium.connectOverCDP(endpoint, { timeout, headers }), - ); - const onDisconnected = () => { - const current = cachedByCdpUrl.get(normalized); - if (current?.browser === browser) { - cachedByCdpUrl.delete(normalized); - } - }; - const connected: ConnectedBrowser = { browser, cdpUrl: normalized, onDisconnected }; - cachedByCdpUrl.set(normalized, connected); - browser.on("disconnected", onDisconnected); - observeBrowser(browser); - return connected; - } catch (err) { - lastErr = err; - // Don't retry rate-limit errors; retrying worsens the 429. - const errMsg = err instanceof Error ? err.message : String(err); - if (errMsg.includes("rate limit")) { - break; - } - const delay = 250 + attempt * 250; - await new Promise((r) => setTimeout(r, delay)); - } - } - if (lastErr instanceof Error) { - throw lastErr; - } - const message = lastErr ? formatErrorMessage(lastErr) : "CDP connect failed"; - throw new Error(message); - }; - - const pending = connectWithRetry().finally(() => { - connectingByCdpUrl.delete(normalized); - }); - connectingByCdpUrl.set(normalized, pending); - - return await pending; -} - -async function getAllPages(browser: Browser): Promise { - const contexts = browser.contexts(); - const pages = contexts.flatMap((c) => c.pages()); - return pages; -} - -async function pageTargetId(page: Page): Promise { - const session = await page.context().newCDPSession(page); - try { - const info = (await session.send("Target.getTargetInfo")) as TargetInfoResponse; - const targetId = String(info?.targetInfo?.targetId ?? "").trim(); - return targetId || null; - } finally { - await session.detach().catch(() => {}); - } -} - -function matchPageByTargetList( - pages: Page[], - targets: Array<{ id: string; url: string; title?: string }>, - targetId: string, -): Page | null { - const target = targets.find((entry) => entry.id === targetId); - if (!target) { - return null; - } - - const urlMatch = pages.filter((page) => page.url() === target.url); - if (urlMatch.length === 1) { - return urlMatch[0] ?? null; - } - if (urlMatch.length > 1) { - const sameUrlTargets = targets.filter((entry) => entry.url === target.url); - if (sameUrlTargets.length === urlMatch.length) { - const idx = sameUrlTargets.findIndex((entry) => entry.id === targetId); - if (idx >= 0 && idx < urlMatch.length) { - return urlMatch[idx] ?? null; - } - } - } - return null; -} - -async function findPageByTargetIdViaTargetList( - pages: Page[], - targetId: string, - cdpUrl: string, -): Promise { - const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl); - const targets = await fetchJson< - Array<{ - id: string; - url: string; - title?: string; - }> - >(appendCdpPath(cdpHttpBase, "/json/list"), 2000); - return matchPageByTargetList(pages, targets, targetId); -} - -async function findPageByTargetId( - browser: Browser, - targetId: string, - cdpUrl?: string, -): Promise { - const pages = await getAllPages(browser); - let resolvedViaCdp = false; - for (const page of pages) { - let tid: string | null = null; - try { - tid = await pageTargetId(page); - resolvedViaCdp = true; - } catch { - tid = null; - } - if (tid && tid === targetId) { - return page; - } - } - if (cdpUrl) { - try { - return await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl); - } catch { - // Ignore fetch errors and fall through to return null. - } - } - if (!resolvedViaCdp && pages.length === 1) { - return pages[0] ?? null; - } - return null; -} - -async function resolvePageByTargetIdOrThrow(opts: { - cdpUrl: string; - targetId: string; -}): Promise { - const { browser } = await connectBrowser(opts.cdpUrl); - const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl); - if (!page) { - throw new BrowserTabNotFoundError(); - } - return page; -} - -export async function getPageForTargetId(opts: { - cdpUrl: string; - targetId?: string; -}): Promise { - const { browser } = await connectBrowser(opts.cdpUrl); - const pages = await getAllPages(browser); - if (!pages.length) { - throw new Error("No pages available in the connected browser."); - } - const first = pages[0]; - if (!opts.targetId) { - return first; - } - const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl); - if (!found) { - // If Playwright only exposes a single Page, use it as a best-effort fallback. - if (pages.length === 1) { - return first; - } - throw new BrowserTabNotFoundError(); - } - return found; -} - -export function refLocator(page: Page, ref: string) { - const normalized = ref.startsWith("@") - ? ref.slice(1) - : ref.startsWith("ref=") - ? ref.slice(4) - : ref; - - if (/^e\d+$/.test(normalized)) { - const state = pageStates.get(page); - if (state?.roleRefsMode === "aria") { - const scope = state.roleRefsFrameSelector - ? page.frameLocator(state.roleRefsFrameSelector) - : page; - return scope.locator(`aria-ref=${normalized}`); - } - const info = state?.roleRefs?.[normalized]; - if (!info) { - throw new Error( - `Unknown ref "${normalized}". Run a new snapshot and use a ref from that snapshot.`, - ); - } - const scope = state?.roleRefsFrameSelector - ? page.frameLocator(state.roleRefsFrameSelector) - : page; - const locAny = scope as unknown as { - getByRole: ( - role: never, - opts?: { name?: string; exact?: boolean }, - ) => ReturnType; - }; - const locator = info.name - ? locAny.getByRole(info.role as never, { name: info.name, exact: true }) - : locAny.getByRole(info.role as never); - return info.nth !== undefined ? locator.nth(info.nth) : locator; - } - - return page.locator(`aria-ref=${normalized}`); -} - -export async function closePlaywrightBrowserConnection(opts?: { cdpUrl?: string }): Promise { - const normalized = opts?.cdpUrl ? normalizeCdpUrl(opts.cdpUrl) : null; - - if (normalized) { - const cur = cachedByCdpUrl.get(normalized); - cachedByCdpUrl.delete(normalized); - connectingByCdpUrl.delete(normalized); - if (!cur) { - return; - } - if (cur.onDisconnected && typeof cur.browser.off === "function") { - cur.browser.off("disconnected", cur.onDisconnected); - } - await cur.browser.close().catch(() => {}); - return; - } - - const connections = Array.from(cachedByCdpUrl.values()); - cachedByCdpUrl.clear(); - connectingByCdpUrl.clear(); - for (const cur of connections) { - if (cur.onDisconnected && typeof cur.browser.off === "function") { - cur.browser.off("disconnected", cur.onDisconnected); - } - await cur.browser.close().catch(() => {}); - } -} - -function cdpSocketNeedsAttach(wsUrl: string): boolean { - try { - const pathname = new URL(wsUrl).pathname; - return ( - pathname === "/cdp" || pathname.endsWith("/cdp") || pathname.includes("/devtools/browser/") - ); - } catch { - return false; - } -} - -async function tryTerminateExecutionViaCdp(opts: { - cdpUrl: string; - targetId: string; -}): Promise { - 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 (work: Promise, ms: number): Promise => { - let timer: ReturnType | undefined; - const timeoutPromise = new Promise((_, 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 { - const normalized = normalizeCdpUrl(opts.cdpUrl); - const cur = cachedByCdpUrl.get(normalized); - if (!cur) { - return; - } - cachedByCdpUrl.delete(normalized); - // Also clear the per-url in-flight connect so the next call does a fresh connectOverCDP - // rather than awaiting a stale promise. - connectingByCdpUrl.delete(normalized); - // Remove the "disconnected" listener to prevent the old browser's teardown - // from racing with a fresh connection and nulling the new cached entry. - if (cur.onDisconnected && typeof cur.browser.off === "function") { - cur.browser.off("disconnected", cur.onDisconnected); - } - - // Best-effort: kill any stuck JS to unblock the target's execution context before we - // disconnect Playwright's CDP connection. - const targetId = opts.targetId?.trim() || ""; - if (targetId) { - await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId }).catch(() => {}); - } - - // Fire-and-forget: don't await because browser.close() may hang on the stuck CDP pipe. - cur.browser.close().catch(() => {}); -} - -/** - * List all pages/tabs from the persistent Playwright connection. - * Used for remote profiles where HTTP-based /json/list is ephemeral. - */ -export async function listPagesViaPlaywright(opts: { cdpUrl: string }): Promise< - Array<{ - targetId: string; - title: string; - url: string; - type: string; - }> -> { - const { browser } = await connectBrowser(opts.cdpUrl); - const pages = await getAllPages(browser); - const results: Array<{ - targetId: string; - title: string; - url: string; - type: string; - }> = []; - - for (const page of pages) { - const tid = await pageTargetId(page).catch(() => null); - if (tid) { - results.push({ - targetId: tid, - title: await page.title().catch(() => ""), - url: page.url(), - type: "page", - }); - } - } - return results; -} - -/** - * Create a new page/tab using the persistent Playwright connection. - * Used for remote profiles where HTTP-based /json/new is ephemeral. - * Returns the new page's targetId and metadata. - */ -export async function createPageViaPlaywright(opts: { - cdpUrl: string; - url: string; - ssrfPolicy?: SsrFPolicy; -}): Promise<{ - targetId: string; - title: string; - url: string; - type: string; -}> { - const { browser } = await connectBrowser(opts.cdpUrl); - const context = browser.contexts()[0] ?? (await browser.newContext()); - ensureContextState(context); - - const page = await context.newPage(); - ensurePageState(page); - - // Navigate to the URL - const targetUrl = opts.url.trim() || "about:blank"; - if (targetUrl !== "about:blank") { - const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy); - await assertBrowserNavigationAllowed({ - url: targetUrl, - ...navigationPolicy, - }); - const response = await page.goto(targetUrl, { timeout: 30_000 }).catch(() => { - // Navigation might fail for some URLs, but page is still created - return null; - }); - await assertBrowserNavigationRedirectChainAllowed({ - request: response?.request(), - ...navigationPolicy, - }); - await assertBrowserNavigationResultAllowed({ - url: page.url(), - ...navigationPolicy, - }); - } - - // Get the targetId for this page - const tid = await pageTargetId(page).catch(() => null); - if (!tid) { - throw new Error("Failed to get targetId for new page"); - } - - return { - targetId: tid, - title: await page.title().catch(() => ""), - url: page.url(), - type: "page", - }; -} - -/** - * Close a page/tab by targetId using the persistent Playwright connection. - * Used for remote profiles where HTTP-based /json/close is ephemeral. - */ -export async function closePageByTargetIdViaPlaywright(opts: { - cdpUrl: string; - targetId: string; -}): Promise { - const page = await resolvePageByTargetIdOrThrow(opts); - await page.close(); -} - -/** - * Focus a page/tab by targetId using the persistent Playwright connection. - * Used for remote profiles where HTTP-based /json/activate can be ephemeral. - */ -export async function focusPageByTargetIdViaPlaywright(opts: { - cdpUrl: string; - targetId: string; -}): Promise { - const page = await resolvePageByTargetIdOrThrow(opts); - try { - await page.bringToFront(); - } catch (err) { - try { - await withPageScopedCdpClient({ - cdpUrl: opts.cdpUrl, - page, - targetId: opts.targetId, - fn: async (send) => { - await send("Page.bringToFront"); - }, - }); - return; - } catch { - throw err; - } - } -} +export * from "../../extensions/browser/src/browser/pw-session.js"; diff --git a/src/browser/pw-tools-core.activity.ts b/src/browser/pw-tools-core.activity.ts index 33295060029..9ce708e70d4 100644 --- a/src/browser/pw-tools-core.activity.ts +++ b/src/browser/pw-tools-core.activity.ts @@ -1,68 +1 @@ -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 { - 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); -} +export * from "../../extensions/browser/src/browser/pw-tools-core.activity.js"; diff --git a/src/browser/pw-tools-core.downloads.ts b/src/browser/pw-tools-core.downloads.ts index 6024ee09f41..ea4701e7d4b 100644 --- a/src/browser/pw-tools-core.downloads.ts +++ b/src/browser/pw-tools-core.downloads.ts @@ -1,280 +1 @@ -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; -import type { Page } from "playwright-core"; -import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; -import { writeViaSiblingTempPath } from "./output-atomic.js"; -import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js"; -import { - ensurePageState, - getPageForTargetId, - refLocator, - restoreRoleRefsForTarget, -} from "./pw-session.js"; -import { - bumpDialogArmId, - bumpDownloadArmId, - bumpUploadArmId, - normalizeTimeoutMs, - requireRef, - toAIFriendlyError, -} from "./pw-tools-core.shared.js"; -import { sanitizeUntrustedFileName } from "./safe-filename.js"; - -function buildTempDownloadPath(fileName: string): string { - const id = crypto.randomUUID(); - const safeName = sanitizeUntrustedFileName(fileName, "download.bin"); - return path.join(resolvePreferredOpenClawTmpDir(), "downloads", `${id}-${safeName}`); -} - -function createPageDownloadWaiter(page: Page, timeoutMs: number) { - let done = false; - let timer: NodeJS.Timeout | undefined; - let handler: ((download: unknown) => void) | undefined; - - const cleanup = () => { - if (timer) { - clearTimeout(timer); - } - timer = undefined; - if (handler) { - page.off("download", handler as never); - handler = undefined; - } - }; - - const promise = new Promise((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; -}; - -async function saveDownloadPayload(download: DownloadPayload, outPath: string) { - const suggested = download.suggestedFilename?.() || "download.bin"; - const requestedPath = outPath?.trim(); - const resolvedOutPath = path.resolve(requestedPath || buildTempDownloadPath(suggested)); - await fs.mkdir(path.dirname(resolvedOutPath), { recursive: true }); - - if (!requestedPath) { - await download.saveAs?.(resolvedOutPath); - } else { - await writeViaSiblingTempPath({ - rootDir: path.dirname(resolvedOutPath), - targetPath: resolvedOutPath, - writeTemp: async (tempPath) => { - await download.saveAs?.(tempPath); - }, - }); - } - - return { - url: download.url?.() || "", - suggestedFilename: suggested, - path: resolvedOutPath, - }; -} - -async function awaitDownloadPayload(params: { - waiter: ReturnType; - state: ReturnType; - 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 { - 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 { - 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; - } -} +export * from "../../extensions/browser/src/browser/pw-tools-core.downloads.js"; diff --git a/src/browser/pw-tools-core.interactions.ts b/src/browser/pw-tools-core.interactions.ts index 01abc5338f0..819611cbe3f 100644 --- a/src/browser/pw-tools-core.interactions.ts +++ b/src/browser/pw-tools-core.interactions.ts @@ -1,891 +1 @@ -import type { BrowserActRequest, BrowserFormField } from "./client-actions-core.js"; -import { DEFAULT_FILL_FIELD_TYPE } from "./form-fields.js"; -import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js"; -import { - ensurePageState, - forceDisconnectPlaywrightForTarget, - getPageForTargetId, - refLocator, - restoreRoleRefsForTarget, -} from "./pw-session.js"; -import { - normalizeTimeoutMs, - requireRef, - requireRefOrSelector, - toAIFriendlyError, -} from "./pw-tools-core.shared.js"; -import { closePageViaPlaywright, resizeViewportViaPlaywright } from "./pw-tools-core.snapshot.js"; - -type TargetOpts = { - cdpUrl: string; - targetId?: string; -}; - -const MAX_CLICK_DELAY_MS = 5_000; -const MAX_WAIT_TIME_MS = 30_000; -const MAX_BATCH_ACTIONS = 100; - -function resolveBoundedDelayMs(value: number | undefined, label: string, maxMs: number): number { - const normalized = Math.floor(value ?? 0); - if (!Number.isFinite(normalized) || normalized < 0) { - throw new Error(`${label} must be >= 0`); - } - if (normalized > maxMs) { - throw new Error(`${label} exceeds maximum of ${maxMs}ms`); - } - return normalized; -} - -async function getRestoredPageForTarget(opts: TargetOpts) { - const page = await getPageForTargetId(opts); - ensurePageState(page); - restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page }); - return page; -} - -function resolveInteractionTimeoutMs(timeoutMs?: number): number { - return Math.max(500, Math.min(60_000, Math.floor(timeoutMs ?? 8000))); -} - -async function awaitEvalWithAbort( - evalPromise: Promise, - abortPromise?: Promise, -): Promise { - if (!abortPromise) { - return await evalPromise; - } - try { - return await Promise.race([evalPromise, abortPromise]); - } catch (err) { - // If abort wins the race, evaluate may reject later; avoid unhandled rejections. - void evalPromise.catch(() => {}); - throw err; - } -} - -export async function highlightViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - ref: string; -}): Promise { - const page = await getRestoredPageForTarget(opts); - const ref = requireRef(opts.ref); - try { - await refLocator(page, ref).highlight(); - } catch (err) { - throw toAIFriendlyError(err, ref); - } -} - -export async function clickViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - ref?: string; - selector?: string; - doubleClick?: boolean; - button?: "left" | "right" | "middle"; - modifiers?: Array<"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift">; - delayMs?: number; - timeoutMs?: number; -}): Promise { - const resolved = requireRefOrSelector(opts.ref, opts.selector); - const page = await getRestoredPageForTarget(opts); - const label = resolved.ref ?? resolved.selector!; - const locator = resolved.ref - ? refLocator(page, requireRef(resolved.ref)) - : page.locator(resolved.selector!); - const timeout = resolveInteractionTimeoutMs(opts.timeoutMs); - try { - const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS); - if (delayMs > 0) { - await locator.hover({ timeout }); - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - if (opts.doubleClick) { - await locator.dblclick({ - timeout, - button: opts.button, - modifiers: opts.modifiers, - }); - } else { - await locator.click({ - timeout, - button: opts.button, - modifiers: opts.modifiers, - }); - } - } catch (err) { - throw toAIFriendlyError(err, label); - } -} - -export async function hoverViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - ref?: string; - selector?: string; - timeoutMs?: number; -}): Promise { - const resolved = requireRefOrSelector(opts.ref, opts.selector); - const page = await getRestoredPageForTarget(opts); - const label = resolved.ref ?? resolved.selector!; - const locator = resolved.ref - ? refLocator(page, requireRef(resolved.ref)) - : page.locator(resolved.selector!); - try { - await locator.hover({ - timeout: resolveInteractionTimeoutMs(opts.timeoutMs), - }); - } catch (err) { - throw toAIFriendlyError(err, label); - } -} - -export async function dragViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - startRef?: string; - startSelector?: string; - endRef?: string; - endSelector?: string; - timeoutMs?: number; -}): Promise { - const resolvedStart = requireRefOrSelector(opts.startRef, opts.startSelector); - const resolvedEnd = requireRefOrSelector(opts.endRef, opts.endSelector); - const page = await getRestoredPageForTarget(opts); - const startLocator = resolvedStart.ref - ? refLocator(page, requireRef(resolvedStart.ref)) - : page.locator(resolvedStart.selector!); - const endLocator = resolvedEnd.ref - ? refLocator(page, requireRef(resolvedEnd.ref)) - : page.locator(resolvedEnd.selector!); - const startLabel = resolvedStart.ref ?? resolvedStart.selector!; - const endLabel = resolvedEnd.ref ?? resolvedEnd.selector!; - try { - await startLocator.dragTo(endLocator, { - timeout: resolveInteractionTimeoutMs(opts.timeoutMs), - }); - } catch (err) { - throw toAIFriendlyError(err, `${startLabel} -> ${endLabel}`); - } -} - -export async function selectOptionViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - ref?: string; - selector?: string; - values: string[]; - timeoutMs?: number; -}): Promise { - const resolved = requireRefOrSelector(opts.ref, opts.selector); - if (!opts.values?.length) { - throw new Error("values are required"); - } - const page = await getRestoredPageForTarget(opts); - const label = resolved.ref ?? resolved.selector!; - const locator = resolved.ref - ? refLocator(page, requireRef(resolved.ref)) - : page.locator(resolved.selector!); - try { - await locator.selectOption(opts.values, { - timeout: resolveInteractionTimeoutMs(opts.timeoutMs), - }); - } catch (err) { - throw toAIFriendlyError(err, label); - } -} - -export async function pressKeyViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - key: string; - delayMs?: number; -}): Promise { - const key = String(opts.key ?? "").trim(); - if (!key) { - throw new Error("key is required"); - } - const page = await getPageForTargetId(opts); - ensurePageState(page); - await page.keyboard.press(key, { - delay: Math.max(0, Math.floor(opts.delayMs ?? 0)), - }); -} - -export async function typeViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - ref?: string; - selector?: string; - text: string; - submit?: boolean; - slowly?: boolean; - timeoutMs?: number; -}): Promise { - const resolved = requireRefOrSelector(opts.ref, opts.selector); - const text = String(opts.text ?? ""); - const page = await getRestoredPageForTarget(opts); - const label = resolved.ref ?? resolved.selector!; - const locator = resolved.ref - ? refLocator(page, requireRef(resolved.ref)) - : page.locator(resolved.selector!); - const timeout = resolveInteractionTimeoutMs(opts.timeoutMs); - try { - if (opts.slowly) { - await locator.click({ timeout }); - await locator.type(text, { timeout, delay: 75 }); - } else { - await locator.fill(text, { timeout }); - } - if (opts.submit) { - await locator.press("Enter", { timeout }); - } - } catch (err) { - throw toAIFriendlyError(err, label); - } -} - -export async function fillFormViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - fields: BrowserFormField[]; - timeoutMs?: number; -}): Promise { - const page = await getRestoredPageForTarget(opts); - const timeout = resolveInteractionTimeoutMs(opts.timeoutMs); - for (const field of opts.fields) { - const ref = field.ref.trim(); - const type = (field.type || DEFAULT_FILL_FIELD_TYPE).trim() || DEFAULT_FILL_FIELD_TYPE; - const rawValue = field.value; - const value = - typeof rawValue === "string" - ? rawValue - : typeof rawValue === "number" || typeof rawValue === "boolean" - ? String(rawValue) - : ""; - if (!ref) { - continue; - } - const locator = refLocator(page, ref); - if (type === "checkbox" || type === "radio") { - const checked = - rawValue === true || rawValue === 1 || rawValue === "1" || rawValue === "true"; - try { - await locator.setChecked(checked, { timeout }); - } catch (err) { - throw toAIFriendlyError(err, ref); - } - continue; - } - try { - await locator.fill(value, { timeout }); - } catch (err) { - throw toAIFriendlyError(err, ref); - } - } -} - -export async function evaluateViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - fn: string; - ref?: string; - timeoutMs?: number; - signal?: AbortSignal; -}): Promise { - const fnText = String(opts.fn ?? "").trim(); - if (!fnText) { - throw new Error("function is required"); - } - const page = await getRestoredPageForTarget(opts); - // Clamp evaluate timeout to prevent permanently blocking Playwright's command queue. - // Without this, a long-running async evaluate blocks all subsequent page operations - // because Playwright serializes CDP commands per page. - // - // NOTE: Playwright's { timeout } on evaluate only applies to installing the function, - // NOT to its execution time. We must inject a Promise.race timeout into the browser - // context itself so async functions are bounded. - const outerTimeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); - // Leave headroom for routing/serialization overhead so the outer request timeout - // doesn't fire first and strand a long-running evaluate. - let evaluateTimeout = Math.max(1000, Math.min(120_000, outerTimeout - 500)); - evaluateTimeout = Math.min(evaluateTimeout, outerTimeout); - - const signal = opts.signal; - let abortListener: (() => void) | undefined; - let abortReject: ((reason: unknown) => void) | undefined; - let abortPromise: Promise | undefined; - if (signal) { - abortPromise = new Promise((_, reject) => { - abortReject = reject; - }); - // Ensure the abort promise never becomes an unhandled rejection if we throw early. - void abortPromise.catch(() => {}); - } - if (signal) { - const disconnect = () => { - void forceDisconnectPlaywrightForTarget({ - cdpUrl: opts.cdpUrl, - targetId: opts.targetId, - reason: "evaluate aborted", - }).catch(() => {}); - }; - if (signal.aborted) { - disconnect(); - throw signal.reason ?? new Error("aborted"); - } - abortListener = () => { - disconnect(); - abortReject?.(signal.reason ?? new Error("aborted")); - }; - signal.addEventListener("abort", abortListener, { once: true }); - // If the signal aborted between the initial check and listener registration, handle it. - if (signal.aborted) { - abortListener(); - throw signal.reason ?? new Error("aborted"); - } - } - - try { - if (opts.ref) { - const locator = refLocator(page, opts.ref); - // eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval - const elementEvaluator = new Function( - "el", - "args", - ` - "use strict"; - var fnBody = args.fnBody, timeoutMs = args.timeoutMs; - try { - var candidate = eval("(" + fnBody + ")"); - var result = typeof candidate === "function" ? candidate(el) : candidate; - if (result && typeof result.then === "function") { - return Promise.race([ - result, - new Promise(function(_, reject) { - setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs); - }) - ]); - } - return result; - } catch (err) { - throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err))); - } - `, - ) as (el: Element, args: { fnBody: string; timeoutMs: number }) => unknown; - const evalPromise = locator.evaluate(elementEvaluator, { - fnBody: fnText, - timeoutMs: evaluateTimeout, - }); - return await awaitEvalWithAbort(evalPromise, abortPromise); - } - - // eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval - const browserEvaluator = new Function( - "args", - ` - "use strict"; - var fnBody = args.fnBody, timeoutMs = args.timeoutMs; - try { - var candidate = eval("(" + fnBody + ")"); - var result = typeof candidate === "function" ? candidate() : candidate; - if (result && typeof result.then === "function") { - return Promise.race([ - result, - new Promise(function(_, reject) { - setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs); - }) - ]); - } - return result; - } catch (err) { - throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err))); - } - `, - ) as (args: { fnBody: string; timeoutMs: number }) => unknown; - const evalPromise = page.evaluate(browserEvaluator, { - fnBody: fnText, - timeoutMs: evaluateTimeout, - }); - return await awaitEvalWithAbort(evalPromise, abortPromise); - } finally { - if (signal && abortListener) { - signal.removeEventListener("abort", abortListener); - } - } -} - -export async function scrollIntoViewViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - ref?: string; - selector?: string; - timeoutMs?: number; -}): Promise { - const resolved = requireRefOrSelector(opts.ref, opts.selector); - const page = await getRestoredPageForTarget(opts); - const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); - - const label = resolved.ref ?? resolved.selector!; - const locator = resolved.ref - ? refLocator(page, requireRef(resolved.ref)) - : page.locator(resolved.selector!); - try { - await locator.scrollIntoViewIfNeeded({ timeout }); - } catch (err) { - throw toAIFriendlyError(err, label); - } -} - -export async function waitForViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - timeMs?: number; - text?: string; - textGone?: string; - selector?: string; - url?: string; - loadState?: "load" | "domcontentloaded" | "networkidle"; - fn?: string; - timeoutMs?: number; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); - - if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) { - await page.waitForTimeout(resolveBoundedDelayMs(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS)); - } - if (opts.text) { - await page.getByText(opts.text).first().waitFor({ - state: "visible", - timeout, - }); - } - if (opts.textGone) { - await page.getByText(opts.textGone).first().waitFor({ - state: "hidden", - timeout, - }); - } - if (opts.selector) { - const selector = String(opts.selector).trim(); - if (selector) { - await page.locator(selector).first().waitFor({ state: "visible", timeout }); - } - } - if (opts.url) { - const url = String(opts.url).trim(); - if (url) { - await page.waitForURL(url, { timeout }); - } - } - if (opts.loadState) { - await page.waitForLoadState(opts.loadState, { timeout }); - } - if (opts.fn) { - const fn = String(opts.fn).trim(); - if (fn) { - await page.waitForFunction(fn, { timeout }); - } - } -} - -export async function takeScreenshotViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - ref?: string; - element?: string; - fullPage?: boolean; - type?: "png" | "jpeg"; -}): Promise<{ buffer: Buffer }> { - const page = await getPageForTargetId(opts); - ensurePageState(page); - restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page }); - const type = opts.type ?? "png"; - if (opts.ref) { - if (opts.fullPage) { - throw new Error("fullPage is not supported for element screenshots"); - } - const locator = refLocator(page, opts.ref); - const buffer = await locator.screenshot({ type }); - return { buffer }; - } - if (opts.element) { - if (opts.fullPage) { - throw new Error("fullPage is not supported for element screenshots"); - } - const locator = page.locator(opts.element).first(); - const buffer = await locator.screenshot({ type }); - return { buffer }; - } - const buffer = await page.screenshot({ - type, - fullPage: Boolean(opts.fullPage), - }); - return { buffer }; -} - -export async function screenshotWithLabelsViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - refs: Record; - 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 { - const page = await getPageForTargetId(opts); - ensurePageState(page); - restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page }); - if (!opts.paths.length) { - throw new Error("paths are required"); - } - const inputRef = typeof opts.inputRef === "string" ? opts.inputRef.trim() : ""; - const element = typeof opts.element === "string" ? opts.element.trim() : ""; - if (inputRef && element) { - throw new Error("inputRef and element are mutually exclusive"); - } - if (!inputRef && !element) { - throw new Error("inputRef or element is required"); - } - - const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first(); - const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({ - rootDir: DEFAULT_UPLOAD_DIR, - requestedPaths: opts.paths, - scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`, - }); - if (!uploadPathsResult.ok) { - throw new Error(uploadPathsResult.error); - } - const resolvedPaths = uploadPathsResult.paths; - - try { - await locator.setInputFiles(resolvedPaths); - } catch (err) { - throw toAIFriendlyError(err, inputRef || element); - } - try { - const handle = await locator.elementHandle(); - if (handle) { - await handle.evaluate((el) => { - el.dispatchEvent(new Event("input", { bubbles: true })); - el.dispatchEvent(new Event("change", { bubbles: true })); - }); - } - } catch { - // Best-effort for sites that don't react to setInputFiles alone. - } -} - -const MAX_BATCH_DEPTH = 5; - -async function executeSingleAction( - action: BrowserActRequest, - cdpUrl: string, - targetId?: string, - evaluateEnabled?: boolean, - depth = 0, -): Promise { - if (depth > MAX_BATCH_DEPTH) { - throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`); - } - const effectiveTargetId = action.targetId ?? targetId; - switch (action.kind) { - case "click": - await clickViaPlaywright({ - cdpUrl, - targetId: effectiveTargetId, - ref: action.ref, - selector: action.selector, - doubleClick: action.doubleClick, - button: action.button as "left" | "right" | "middle" | undefined, - modifiers: action.modifiers as Array< - "Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift" - >, - delayMs: action.delayMs, - timeoutMs: action.timeoutMs, - }); - break; - case "type": - await typeViaPlaywright({ - cdpUrl, - targetId: effectiveTargetId, - ref: action.ref, - selector: action.selector, - text: action.text, - submit: action.submit, - slowly: action.slowly, - timeoutMs: action.timeoutMs, - }); - break; - case "press": - await pressKeyViaPlaywright({ - cdpUrl, - targetId: effectiveTargetId, - key: action.key, - delayMs: action.delayMs, - }); - break; - case "hover": - await hoverViaPlaywright({ - cdpUrl, - targetId: effectiveTargetId, - ref: action.ref, - selector: action.selector, - timeoutMs: action.timeoutMs, - }); - break; - case "scrollIntoView": - await scrollIntoViewViaPlaywright({ - cdpUrl, - targetId: effectiveTargetId, - ref: action.ref, - selector: action.selector, - timeoutMs: action.timeoutMs, - }); - break; - case "drag": - await dragViaPlaywright({ - cdpUrl, - targetId: effectiveTargetId, - startRef: action.startRef, - startSelector: action.startSelector, - endRef: action.endRef, - endSelector: action.endSelector, - timeoutMs: action.timeoutMs, - }); - break; - case "select": - await selectOptionViaPlaywright({ - cdpUrl, - targetId: effectiveTargetId, - ref: action.ref, - selector: action.selector, - values: action.values, - timeoutMs: action.timeoutMs, - }); - break; - case "fill": - await fillFormViaPlaywright({ - cdpUrl, - targetId: effectiveTargetId, - fields: action.fields, - timeoutMs: action.timeoutMs, - }); - break; - case "resize": - await resizeViewportViaPlaywright({ - cdpUrl, - targetId: effectiveTargetId, - width: action.width, - height: action.height, - }); - break; - case "wait": - if (action.fn && !evaluateEnabled) { - throw new Error("wait --fn is disabled by config (browser.evaluateEnabled=false)"); - } - await waitForViaPlaywright({ - cdpUrl, - targetId: effectiveTargetId, - timeMs: action.timeMs, - text: action.text, - textGone: action.textGone, - selector: action.selector, - url: action.url, - loadState: action.loadState, - fn: action.fn, - timeoutMs: action.timeoutMs, - }); - break; - case "evaluate": - if (!evaluateEnabled) { - throw new Error("act:evaluate is disabled by config (browser.evaluateEnabled=false)"); - } - await evaluateViaPlaywright({ - cdpUrl, - targetId: effectiveTargetId, - fn: action.fn, - ref: action.ref, - timeoutMs: action.timeoutMs, - }); - break; - case "close": - await closePageViaPlaywright({ - cdpUrl, - targetId: effectiveTargetId, - }); - break; - case "batch": - await batchViaPlaywright({ - cdpUrl, - targetId: effectiveTargetId, - actions: action.actions, - stopOnError: action.stopOnError, - evaluateEnabled, - depth: depth + 1, - }); - break; - default: - throw new Error(`Unsupported batch action kind: ${(action as { kind: string }).kind}`); - } -} - -export async function batchViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - actions: BrowserActRequest[]; - stopOnError?: boolean; - evaluateEnabled?: boolean; - depth?: number; -}): Promise<{ results: Array<{ ok: boolean; error?: string }> }> { - const depth = opts.depth ?? 0; - if (depth > MAX_BATCH_DEPTH) { - throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`); - } - if (opts.actions.length > MAX_BATCH_ACTIONS) { - throw new Error(`Batch exceeds maximum of ${MAX_BATCH_ACTIONS} actions`); - } - const results: Array<{ ok: boolean; error?: string }> = []; - for (const action of opts.actions) { - try { - await executeSingleAction(action, opts.cdpUrl, opts.targetId, opts.evaluateEnabled, depth); - results.push({ ok: true }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - results.push({ ok: false, error: message }); - if (opts.stopOnError !== false) { - break; - } - } - } - return { results }; -} +export * from "../../extensions/browser/src/browser/pw-tools-core.interactions.js"; diff --git a/src/browser/pw-tools-core.responses.ts b/src/browser/pw-tools-core.responses.ts index 4b153692a20..3f349280336 100644 --- a/src/browser/pw-tools-core.responses.ts +++ b/src/browser/pw-tools-core.responses.ts @@ -1,108 +1 @@ -import { formatCliCommand } from "../cli/command-format.js"; -import { ensurePageState, getPageForTargetId } from "./pw-session.js"; -import { normalizeTimeoutMs } from "./pw-tools-core.shared.js"; -import { matchBrowserUrlPattern } from "./url-pattern.js"; - -export async function responseBodyViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - url: string; - timeoutMs?: number; - maxChars?: number; -}): Promise<{ - url: string; - status?: number; - headers?: Record; - 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((resolve, reject) => { - let done = false; - let timer: NodeJS.Timeout | undefined; - let handler: ((resp: unknown) => void) | undefined; - - const cleanup = () => { - if (timer) { - clearTimeout(timer); - } - timer = undefined; - if (handler) { - page.off("response", handler as never); - } - }; - - handler = (resp: unknown) => { - if (done) { - return; - } - const r = resp as { url?: () => string }; - const u = r.url?.() || ""; - if (!matchBrowserUrlPattern(pattern, u)) { - return; - } - done = true; - cleanup(); - resolve(resp); - }; - - page.on("response", handler as never); - timer = setTimeout(() => { - if (done) { - return; - } - done = true; - cleanup(); - reject( - new Error( - `Response not found for url pattern "${pattern}". Run '${formatCliCommand("openclaw browser requests")}' to inspect recent network activity.`, - ), - ); - }, timeout); - }); - - const resp = (await promise) as { - url?: () => string; - status?: () => number; - headers?: () => Record; - body?: () => Promise; - text?: () => Promise; - }; - - 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, - }; -} +export * from "../../extensions/browser/src/browser/pw-tools-core.responses.js"; diff --git a/src/browser/pw-tools-core.shared.ts b/src/browser/pw-tools-core.shared.ts index b6132de92bf..7306ca7ed85 100644 --- a/src/browser/pw-tools-core.shared.ts +++ b/src/browser/pw-tools-core.shared.ts @@ -1,85 +1 @@ -import { parseRoleRef } from "./pw-role-snapshot.js"; - -let nextUploadArmId = 0; -let nextDialogArmId = 0; -let nextDownloadArmId = 0; - -export function bumpUploadArmId(): number { - nextUploadArmId += 1; - return nextUploadArmId; -} - -export function bumpDialogArmId(): number { - nextDialogArmId += 1; - return nextDialogArmId; -} - -export function bumpDownloadArmId(): number { - nextDownloadArmId += 1; - return nextDownloadArmId; -} - -export function requireRef(value: unknown): string { - const raw = typeof value === "string" ? value.trim() : ""; - const roleRef = raw ? parseRoleRef(raw) : null; - const ref = roleRef ?? (raw.startsWith("@") ? raw.slice(1) : raw); - if (!ref) { - throw new Error("ref is required"); - } - return ref; -} - -export function requireRefOrSelector( - ref: string | undefined, - selector: string | undefined, -): { ref?: string; selector?: string } { - const trimmedRef = typeof ref === "string" ? ref.trim() : ""; - const trimmedSelector = typeof selector === "string" ? selector.trim() : ""; - if (!trimmedRef && !trimmedSelector) { - throw new Error("ref or selector is required"); - } - return { - ref: trimmedRef || undefined, - selector: trimmedSelector || undefined, - }; -} - -export function normalizeTimeoutMs(timeoutMs: number | undefined, fallback: number) { - return Math.max(500, Math.min(120_000, timeoutMs ?? fallback)); -} - -export function toAIFriendlyError(error: unknown, selector: string): Error { - const message = error instanceof Error ? error.message : String(error); - - if (message.includes("strict mode violation")) { - const countMatch = message.match(/resolved to (\d+) elements/); - const count = countMatch ? countMatch[1] : "multiple"; - return new Error( - `Selector "${selector}" matched ${count} elements. ` + - `Run a new snapshot to get updated refs, or use a different ref.`, - ); - } - - if ( - (message.includes("Timeout") || message.includes("waiting for")) && - (message.includes("to be visible") || message.includes("not visible")) - ) { - return new Error( - `Element "${selector}" not found or not visible. ` + - `Run a new snapshot to see current page elements.`, - ); - } - - if ( - message.includes("intercepts pointer events") || - message.includes("not visible") || - message.includes("not receive pointer events") - ) { - return new Error( - `Element "${selector}" is not interactable (hidden or covered). ` + - `Try scrolling it into view, closing overlays, or re-snapshotting.`, - ); - } - - return error instanceof Error ? error : new Error(message); -} +export * from "../../extensions/browser/src/browser/pw-tools-core.shared.js"; diff --git a/src/browser/pw-tools-core.snapshot.ts b/src/browser/pw-tools-core.snapshot.ts index 09926626db1..fe09e86b8f9 100644 --- a/src/browser/pw-tools-core.snapshot.ts +++ b/src/browser/pw-tools-core.snapshot.ts @@ -1,262 +1 @@ -import type { SsrFPolicy } from "../infra/net/ssrf.js"; -import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js"; -import { - assertBrowserNavigationAllowed, - assertBrowserNavigationRedirectChainAllowed, - assertBrowserNavigationResultAllowed, - withBrowserNavigationPolicy, -} from "./navigation-guard.js"; -import { - buildRoleSnapshotFromAiSnapshot, - buildRoleSnapshotFromAriaSnapshot, - getRoleSnapshotStats, - type RoleSnapshotOptions, - type RoleRefMap, -} from "./pw-role-snapshot.js"; -import { - ensurePageState, - forceDisconnectPlaywrightForTarget, - getPageForTargetId, - storeRoleRefsForTarget, - type WithSnapshotForAI, -} from "./pw-session.js"; -import { withPageScopedCdpClient } from "./pw-session.page-cdp.js"; - -export async function snapshotAriaViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - limit?: number; -}): Promise<{ nodes: AriaSnapshotNode[] }> { - const limit = Math.max(1, Math.min(2000, Math.floor(opts.limit ?? 500))); - const page = await getPageForTargetId({ - cdpUrl: opts.cdpUrl, - targetId: opts.targetId, - }); - ensurePageState(page); - const res = (await withPageScopedCdpClient({ - cdpUrl: opts.cdpUrl, - page, - targetId: opts.targetId, - fn: async (send) => { - await send("Accessibility.enable").catch(() => {}); - return (await send("Accessibility.getFullAXTree")) as { - nodes?: RawAXNode[]; - }; - }, - })) as { - nodes?: RawAXNode[]; - }; - const nodes = Array.isArray(res?.nodes) ? res.nodes : []; - return { nodes: formatAriaSnapshot(nodes, limit) }; -} - -export async function snapshotAiViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - timeoutMs?: number; - maxChars?: number; -}): Promise<{ snapshot: string; truncated?: boolean; refs: RoleRefMap }> { - const page = await getPageForTargetId({ - cdpUrl: opts.cdpUrl, - targetId: opts.targetId, - }); - ensurePageState(page); - - const maybe = page as unknown as WithSnapshotForAI; - if (!maybe._snapshotForAI) { - throw new Error("Playwright _snapshotForAI is not available. Upgrade playwright-core."); - } - - const result = await maybe._snapshotForAI({ - timeout: Math.max(500, Math.min(60_000, Math.floor(opts.timeoutMs ?? 5000))), - track: "response", - }); - let snapshot = String(result?.full ?? ""); - const maxChars = opts.maxChars; - const limit = - typeof maxChars === "number" && Number.isFinite(maxChars) && maxChars > 0 - ? Math.floor(maxChars) - : undefined; - let truncated = false; - if (limit && snapshot.length > limit) { - snapshot = `${snapshot.slice(0, limit)}\n\n[...TRUNCATED - page too large]`; - truncated = true; - } - - const built = buildRoleSnapshotFromAiSnapshot(snapshot); - storeRoleRefsForTarget({ - page, - cdpUrl: opts.cdpUrl, - targetId: opts.targetId, - refs: built.refs, - mode: "aria", - }); - return truncated ? { snapshot, truncated, refs: built.refs } : { snapshot, refs: built.refs }; -} - -export async function snapshotRoleViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - selector?: string; - frameSelector?: string; - refsMode?: "role" | "aria"; - options?: RoleSnapshotOptions; -}): Promise<{ - snapshot: string; - refs: Record; - stats: { lines: number; chars: number; refs: number; interactive: number }; -}> { - const page = await getPageForTargetId({ - cdpUrl: opts.cdpUrl, - targetId: opts.targetId, - }); - ensurePageState(page); - - if (opts.refsMode === "aria") { - if (opts.selector?.trim() || opts.frameSelector?.trim()) { - throw new Error("refs=aria does not support selector/frame snapshots yet."); - } - const maybe = page as unknown as WithSnapshotForAI; - if (!maybe._snapshotForAI) { - throw new Error("refs=aria requires Playwright _snapshotForAI support."); - } - const result = await maybe._snapshotForAI({ - timeout: 5000, - track: "response", - }); - const built = buildRoleSnapshotFromAiSnapshot(String(result?.full ?? ""), opts.options); - storeRoleRefsForTarget({ - page, - cdpUrl: opts.cdpUrl, - targetId: opts.targetId, - refs: built.refs, - mode: "aria", - }); - return { - snapshot: built.snapshot, - refs: built.refs, - stats: getRoleSnapshotStats(built.snapshot, built.refs), - }; - } - - const frameSelector = opts.frameSelector?.trim() || ""; - const selector = opts.selector?.trim() || ""; - const locator = frameSelector - ? selector - ? page.frameLocator(frameSelector).locator(selector) - : page.frameLocator(frameSelector).locator(":root") - : selector - ? page.locator(selector) - : page.locator(":root"); - - const ariaSnapshot = await locator.ariaSnapshot(); - const built = buildRoleSnapshotFromAriaSnapshot(String(ariaSnapshot ?? ""), opts.options); - storeRoleRefsForTarget({ - page, - cdpUrl: opts.cdpUrl, - targetId: opts.targetId, - refs: built.refs, - frameSelector: frameSelector || undefined, - mode: "role", - }); - return { - snapshot: built.snapshot, - refs: built.refs, - stats: getRoleSnapshotStats(built.snapshot, built.refs), - }; -} - -export async function navigateViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - url: string; - timeoutMs?: number; - ssrfPolicy?: SsrFPolicy; -}): Promise<{ url: string }> { - const isRetryableNavigateError = (err: unknown): boolean => { - const msg = - typeof err === "string" - ? err.toLowerCase() - : err instanceof Error - ? err.message.toLowerCase() - : ""; - return ( - msg.includes("frame has been detached") || - msg.includes("target page, context or browser has been closed") - ); - }; - - const url = String(opts.url ?? "").trim(); - if (!url) { - throw new Error("url is required"); - } - await assertBrowserNavigationAllowed({ - url, - ...withBrowserNavigationPolicy(opts.ssrfPolicy), - }); - const timeout = Math.max(1000, Math.min(120_000, opts.timeoutMs ?? 20_000)); - let page = await getPageForTargetId(opts); - ensurePageState(page); - const navigate = async () => await page.goto(url, { timeout }); - let response; - try { - response = await navigate(); - } catch (err) { - if (!isRetryableNavigateError(err)) { - throw err; - } - // Extension relays can briefly drop CDP during renderer swaps/navigation. - // Force a clean reconnect, then retry once on the refreshed page handle. - await forceDisconnectPlaywrightForTarget({ - cdpUrl: opts.cdpUrl, - targetId: opts.targetId, - reason: "retry navigate after detached frame", - }).catch(() => {}); - page = await getPageForTargetId(opts); - ensurePageState(page); - response = await navigate(); - } - await assertBrowserNavigationRedirectChainAllowed({ - request: response?.request(), - ...withBrowserNavigationPolicy(opts.ssrfPolicy), - }); - const finalUrl = page.url(); - await assertBrowserNavigationResultAllowed({ - url: finalUrl, - ...withBrowserNavigationPolicy(opts.ssrfPolicy), - }); - return { url: finalUrl }; -} - -export async function resizeViewportViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - width: number; - height: number; -}): Promise { - 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 { - 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 }; -} +export * from "../../extensions/browser/src/browser/pw-tools-core.snapshot.js"; diff --git a/src/browser/pw-tools-core.state.ts b/src/browser/pw-tools-core.state.ts index 580fadba108..95b473719df 100644 --- a/src/browser/pw-tools-core.state.ts +++ b/src/browser/pw-tools-core.state.ts @@ -1,215 +1 @@ -import { devices as playwrightDevices } from "playwright-core"; -import { ensurePageState, getPageForTargetId } from "./pw-session.js"; -import { withPageScopedCdpClient } from "./pw-session.page-cdp.js"; - -export async function setOfflineViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - offline: boolean; -}): Promise { - 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; -}): Promise { - 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 { - 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 { - 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 { - 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 { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const locale = String(opts.locale ?? "").trim(); - if (!locale) { - throw new Error("locale is required"); - } - await withPageScopedCdpClient({ - cdpUrl: opts.cdpUrl, - page, - targetId: opts.targetId, - fn: async (send) => { - try { - await send("Emulation.setLocaleOverride", { locale }); - } catch (err) { - if (String(err).includes("Another locale override is already in effect")) { - return; - } - throw err; - } - }, - }); -} - -export async function setTimezoneViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - timezoneId: string; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const timezoneId = String(opts.timezoneId ?? "").trim(); - if (!timezoneId) { - throw new Error("timezoneId is required"); - } - await withPageScopedCdpClient({ - cdpUrl: opts.cdpUrl, - page, - targetId: opts.targetId, - fn: async (send) => { - try { - await send("Emulation.setTimezoneOverride", { timezoneId }); - } catch (err) { - const msg = String(err); - if (msg.includes("Timezone override is already in effect")) { - return; - } - if (msg.includes("Invalid timezone")) { - throw new Error(`Invalid timezone ID: ${timezoneId}`, { cause: err }); - } - throw err; - } - }, - }); -} - -export async function setDeviceViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - name: string; -}): Promise { - 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)[name] as - | { - userAgent?: string; - viewport?: { width: number; height: number }; - deviceScaleFactor?: number; - isMobile?: boolean; - hasTouch?: boolean; - locale?: string; - } - | undefined; - if (!descriptor) { - throw new Error(`Unknown device "${name}".`); - } - - if (descriptor.viewport) { - await page.setViewportSize({ - width: descriptor.viewport.width, - height: descriptor.viewport.height, - }); - } - - await withPageScopedCdpClient({ - cdpUrl: opts.cdpUrl, - page, - targetId: opts.targetId, - fn: async (send) => { - if (descriptor.userAgent || descriptor.locale) { - await send("Emulation.setUserAgentOverride", { - userAgent: descriptor.userAgent ?? "", - acceptLanguage: descriptor.locale ?? undefined, - }); - } - if (descriptor.viewport) { - await send("Emulation.setDeviceMetricsOverride", { - mobile: Boolean(descriptor.isMobile), - width: descriptor.viewport.width, - height: descriptor.viewport.height, - deviceScaleFactor: descriptor.deviceScaleFactor ?? 1, - screenWidth: descriptor.viewport.width, - screenHeight: descriptor.viewport.height, - }); - } - if (descriptor.hasTouch) { - await send("Emulation.setTouchEmulationEnabled", { - enabled: true, - }); - } - }, - }); -} +export * from "../../extensions/browser/src/browser/pw-tools-core.state.js"; diff --git a/src/browser/pw-tools-core.storage.ts b/src/browser/pw-tools-core.storage.ts index 8126d66fa71..96702ba01fe 100644 --- a/src/browser/pw-tools-core.storage.ts +++ b/src/browser/pw-tools-core.storage.ts @@ -1,128 +1 @@ -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 { - 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 { - 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 }> { - 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 = {}; - 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 { - 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 { - const page = await getPageForTargetId(opts); - ensurePageState(page); - await page.evaluate( - ({ kind }) => { - const store = kind === "session" ? window.sessionStorage : window.localStorage; - store.clear(); - }, - { kind: opts.kind }, - ); -} +export * from "../../extensions/browser/src/browser/pw-tools-core.storage.js"; diff --git a/src/browser/pw-tools-core.test-harness.ts b/src/browser/pw-tools-core.test-harness.ts index 6111fa89aef..9bccb724cea 100644 --- a/src/browser/pw-tools-core.test-harness.ts +++ b/src/browser/pw-tools-core.test-harness.ts @@ -1,66 +1 @@ -import { beforeEach, vi } from "vitest"; - -let currentPage: Record | null = null; -let currentRefLocator: Record | null = null; -let pageState: { - console: unknown[]; - armIdUpload: number; - armIdDialog: number; - armIdDownload: number; -} = { - console: [], - armIdUpload: 0, - armIdDialog: 0, - armIdDownload: 0, -}; - -const sessionMocks = vi.hoisted(() => ({ - getPageForTargetId: vi.fn(async () => { - if (!currentPage) { - throw new Error("missing page"); - } - return currentPage; - }), - ensurePageState: vi.fn(() => pageState), - forceDisconnectPlaywrightForTarget: vi.fn(async () => {}), - restoreRoleRefsForTarget: vi.fn(() => {}), - storeRoleRefsForTarget: vi.fn(() => {}), - refLocator: vi.fn(() => { - if (!currentRefLocator) { - throw new Error("missing locator"); - } - return currentRefLocator; - }), - rememberRoleRefsForTarget: vi.fn(() => {}), -})); - -vi.mock("./pw-session.js", () => sessionMocks); - -export function getPwToolsCoreSessionMocks() { - return sessionMocks; -} - -export function setPwToolsCoreCurrentPage(page: Record | null) { - currentPage = page; -} - -export function setPwToolsCoreCurrentRefLocator(locator: Record | 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(); - } - }); -} +export * from "../../extensions/browser/src/browser/pw-tools-core.test-harness.js"; diff --git a/src/browser/pw-tools-core.trace.ts b/src/browser/pw-tools-core.trace.ts index ce49eb77e07..feb3469bca7 100644 --- a/src/browser/pw-tools-core.trace.ts +++ b/src/browser/pw-tools-core.trace.ts @@ -1,45 +1 @@ -import { writeViaSiblingTempPath } from "./output-atomic.js"; -import { DEFAULT_TRACE_DIR } from "./paths.js"; -import { ensureContextState, getPageForTargetId } from "./pw-session.js"; - -export async function traceStartViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - screenshots?: boolean; - snapshots?: boolean; - sources?: boolean; -}): Promise { - 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 { - const page = await getPageForTargetId(opts); - const context = page.context(); - const ctxState = ensureContextState(context); - if (!ctxState.traceActive) { - throw new Error("No active trace. Start a trace before stopping it."); - } - await writeViaSiblingTempPath({ - rootDir: DEFAULT_TRACE_DIR, - targetPath: opts.path, - writeTemp: async (tempPath) => { - await context.tracing.stop({ path: tempPath }); - }, - }); - ctxState.traceActive = false; -} +export * from "../../extensions/browser/src/browser/pw-tools-core.trace.js"; diff --git a/src/browser/pw-tools-core.ts b/src/browser/pw-tools-core.ts index 55a596280c6..06163e7e570 100644 --- a/src/browser/pw-tools-core.ts +++ b/src/browser/pw-tools-core.ts @@ -1,8 +1 @@ -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"; +export * from "../../extensions/browser/src/browser/pw-tools-core.js"; diff --git a/src/browser/request-policy.ts b/src/browser/request-policy.ts index 6288c6b11a6..72553db9efc 100644 --- a/src/browser/request-policy.ts +++ b/src/browser/request-policy.ts @@ -1,49 +1 @@ -type BrowserRequestProfileParams = { - query?: Record; - body?: unknown; - profile?: string | null; -}; - -export function normalizeBrowserRequestPath(value: string): string { - const trimmed = value.trim(); - if (!trimmed) { - return trimmed; - } - const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; - if (withLeadingSlash.length <= 1) { - return withLeadingSlash; - } - return withLeadingSlash.replace(/\/+$/, ""); -} - -export function isPersistentBrowserProfileMutation(method: string, path: string): boolean { - const normalizedPath = normalizeBrowserRequestPath(path); - if ( - method === "POST" && - (normalizedPath === "/profiles/create" || normalizedPath === "/reset-profile") - ) { - return true; - } - return method === "DELETE" && /^\/profiles\/[^/]+$/.test(normalizedPath); -} - -export function resolveRequestedBrowserProfile( - params: BrowserRequestProfileParams, -): string | undefined { - const queryProfile = - typeof params.query?.profile === "string" ? params.query.profile.trim() : undefined; - if (queryProfile) { - return queryProfile; - } - if (params.body && typeof params.body === "object") { - const bodyProfile = - "profile" in params.body && typeof params.body.profile === "string" - ? params.body.profile.trim() - : undefined; - if (bodyProfile) { - return bodyProfile; - } - } - const explicitProfile = typeof params.profile === "string" ? params.profile.trim() : undefined; - return explicitProfile || undefined; -} +export * from "../../extensions/browser/src/browser/request-policy.js"; diff --git a/src/browser/resolved-config-refresh.ts b/src/browser/resolved-config-refresh.ts index 1d20eecec94..0fef8fffd33 100644 --- a/src/browser/resolved-config-refresh.ts +++ b/src/browser/resolved-config-refresh.ts @@ -1,107 +1 @@ -import { createConfigIO, getRuntimeConfigSnapshot } from "../config/config.js"; -import { resolveBrowserConfig, resolveProfile, type ResolvedBrowserProfile } from "./config.js"; -import type { BrowserServerState } from "./server-context.types.js"; - -function changedProfileInvariants( - current: ResolvedBrowserProfile, - next: ResolvedBrowserProfile, -): string[] { - const changed: string[] = []; - if (current.cdpUrl !== next.cdpUrl) { - changed.push("cdpUrl"); - } - if (current.cdpPort !== next.cdpPort) { - changed.push("cdpPort"); - } - if (current.driver !== next.driver) { - changed.push("driver"); - } - if (current.attachOnly !== next.attachOnly) { - changed.push("attachOnly"); - } - if (current.cdpIsLoopback !== next.cdpIsLoopback) { - changed.push("cdpIsLoopback"); - } - if ((current.userDataDir ?? "") !== (next.userDataDir ?? "")) { - changed.push("userDataDir"); - } - return changed; -} - -function applyResolvedConfig( - current: BrowserServerState, - freshResolved: BrowserServerState["resolved"], -) { - current.resolved = { - ...freshResolved, - // Keep the runtime evaluate gate stable across request-time profile refreshes. - // Security-sensitive behavior should only change via full runtime config reload, - // not as a side effect of resolving profiles/tabs during a request. - evaluateEnabled: current.resolved.evaluateEnabled, - }; - for (const [name, runtime] of current.profiles) { - const nextProfile = resolveProfile(freshResolved, name); - if (nextProfile) { - const changed = changedProfileInvariants(runtime.profile, nextProfile); - if (changed.length > 0) { - runtime.reconcile = { - previousProfile: runtime.profile, - reason: `profile invariants changed: ${changed.join(", ")}`, - }; - runtime.lastTargetId = null; - } - runtime.profile = nextProfile; - continue; - } - runtime.reconcile = { - previousProfile: runtime.profile, - reason: "profile removed from config", - }; - runtime.lastTargetId = null; - if (!runtime.running) { - current.profiles.delete(name); - } - } -} - -export function refreshResolvedBrowserConfigFromDisk(params: { - current: BrowserServerState; - refreshConfigFromDisk: boolean; - mode: "cached" | "fresh"; -}) { - if (!params.refreshConfigFromDisk) { - return; - } - - // Route-level browser config hot reload should observe on-disk changes immediately. - // The shared loadConfig() helper may return a cached snapshot for the configured TTL, - // which can leave request-time browser guards stale (for example evaluateEnabled). - const cfg = getRuntimeConfigSnapshot() ?? createConfigIO().loadConfig(); - const freshResolved = resolveBrowserConfig(cfg.browser, cfg); - applyResolvedConfig(params.current, freshResolved); -} - -export function resolveBrowserProfileWithHotReload(params: { - current: BrowserServerState; - refreshConfigFromDisk: boolean; - name: string; -}): ResolvedBrowserProfile | null { - refreshResolvedBrowserConfigFromDisk({ - current: params.current, - refreshConfigFromDisk: params.refreshConfigFromDisk, - mode: "cached", - }); - let profile = resolveProfile(params.current.resolved, params.name); - if (profile) { - return profile; - } - - // Hot-reload: profile missing; retry with a fresh disk read without flushing the global cache. - refreshResolvedBrowserConfigFromDisk({ - current: params.current, - refreshConfigFromDisk: params.refreshConfigFromDisk, - mode: "fresh", - }); - profile = resolveProfile(params.current.resolved, params.name); - return profile; -} +export * from "../../extensions/browser/src/browser/resolved-config-refresh.js"; diff --git a/src/browser/routes/agent.act.download.ts b/src/browser/routes/agent.act.download.ts index cfdf1362797..fd0e55cb7ba 100644 --- a/src/browser/routes/agent.act.download.ts +++ b/src/browser/routes/agent.act.download.ts @@ -1,123 +1 @@ -import { getBrowserProfileCapabilities } from "../profile-capabilities.js"; -import type { BrowserRouteContext } from "../server-context.js"; -import { - readBody, - requirePwAi, - resolveTargetIdFromBody, - withRouteTabContext, -} from "./agent.shared.js"; -import { ensureOutputRootDir, resolveWritableOutputPathOrRespond } from "./output-paths.js"; -import { DEFAULT_DOWNLOAD_DIR } from "./path-output.js"; -import type { BrowserRouteRegistrar } from "./types.js"; -import { jsonError, toNumber, toStringOrEmpty } from "./utils.js"; - -function buildDownloadRequestBase(cdpUrl: string, targetId: string, timeoutMs: number | undefined) { - return { - cdpUrl, - targetId, - timeoutMs: timeoutMs ?? undefined, - }; -} - -export function registerBrowserAgentActDownloadRoutes( - app: BrowserRouteRegistrar, - ctx: BrowserRouteContext, -) { - app.post("/wait/download", async (req, res) => { - const body = readBody(req); - const targetId = resolveTargetIdFromBody(body); - const out = toStringOrEmpty(body.path) || ""; - const timeoutMs = toNumber(body.timeoutMs); - - await withRouteTabContext({ - req, - res, - ctx, - targetId, - run: async ({ profileCtx, cdpUrl, tab }) => { - if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { - return jsonError( - res, - 501, - "download waiting is not supported for existing-session profiles yet.", - ); - } - const pw = await requirePwAi(res, "wait for download"); - if (!pw) { - return; - } - await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR); - let downloadPath: string | undefined; - if (out.trim()) { - const resolvedDownloadPath = await resolveWritableOutputPathOrRespond({ - res, - rootDir: DEFAULT_DOWNLOAD_DIR, - requestedPath: out, - scopeLabel: "downloads directory", - }); - if (!resolvedDownloadPath) { - return; - } - downloadPath = resolvedDownloadPath; - } - const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs); - const result = await pw.waitForDownloadViaPlaywright({ - ...requestBase, - path: downloadPath, - }); - res.json({ ok: true, targetId: tab.targetId, download: result }); - }, - }); - }); - - app.post("/download", async (req, res) => { - const body = readBody(req); - const targetId = resolveTargetIdFromBody(body); - const ref = toStringOrEmpty(body.ref); - const out = toStringOrEmpty(body.path); - const timeoutMs = toNumber(body.timeoutMs); - if (!ref) { - return jsonError(res, 400, "ref is required"); - } - if (!out) { - return jsonError(res, 400, "path is required"); - } - - await withRouteTabContext({ - req, - res, - ctx, - targetId, - run: async ({ profileCtx, cdpUrl, tab }) => { - if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { - return jsonError( - res, - 501, - "downloads are not supported for existing-session profiles yet.", - ); - } - const pw = await requirePwAi(res, "download"); - if (!pw) { - return; - } - await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR); - const downloadPath = await resolveWritableOutputPathOrRespond({ - res, - rootDir: DEFAULT_DOWNLOAD_DIR, - requestedPath: out, - scopeLabel: "downloads directory", - }); - if (!downloadPath) { - return; - } - const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs); - const result = await pw.downloadViaPlaywright({ - ...requestBase, - ref, - path: downloadPath, - }); - res.json({ ok: true, targetId: tab.targetId, download: result }); - }, - }); - }); -} +export * from "../../../extensions/browser/src/browser/routes/agent.act.download.js"; diff --git a/src/browser/routes/agent.act.hooks.ts b/src/browser/routes/agent.act.hooks.ts index a55e2f9b21e..0d6c7b3ace6 100644 --- a/src/browser/routes/agent.act.hooks.ts +++ b/src/browser/routes/agent.act.hooks.ts @@ -1,197 +1 @@ -import { evaluateChromeMcpScript, uploadChromeMcpFile } from "../chrome-mcp.js"; -import { getBrowserProfileCapabilities } from "../profile-capabilities.js"; -import type { BrowserRouteContext } from "../server-context.js"; -import { - readBody, - requirePwAi, - resolveTargetIdFromBody, - withRouteTabContext, -} from "./agent.shared.js"; -import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "./path-output.js"; -import type { BrowserRouteRegistrar } from "./types.js"; -import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js"; - -export function registerBrowserAgentActHookRoutes( - app: BrowserRouteRegistrar, - ctx: BrowserRouteContext, -) { - app.post("/hooks/file-chooser", async (req, res) => { - const body = readBody(req); - const targetId = resolveTargetIdFromBody(body); - const ref = toStringOrEmpty(body.ref) || undefined; - const inputRef = toStringOrEmpty(body.inputRef) || undefined; - const element = toStringOrEmpty(body.element) || undefined; - const paths = toStringArray(body.paths) ?? []; - const timeoutMs = toNumber(body.timeoutMs); - if (!paths.length) { - return jsonError(res, 400, "paths are required"); - } - - await withRouteTabContext({ - req, - res, - ctx, - targetId, - run: async ({ profileCtx, cdpUrl, tab }) => { - const uploadPathsResult = await resolveExistingPathsWithinRoot({ - rootDir: DEFAULT_UPLOAD_DIR, - requestedPaths: paths, - scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`, - }); - if (!uploadPathsResult.ok) { - res.status(400).json({ error: uploadPathsResult.error }); - return; - } - const resolvedPaths = uploadPathsResult.paths; - - if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { - if (element) { - return jsonError( - res, - 501, - "existing-session file uploads do not support element selectors; use ref/inputRef.", - ); - } - if (resolvedPaths.length !== 1) { - return jsonError( - res, - 501, - "existing-session file uploads currently support one file at a time.", - ); - } - const uid = inputRef || ref; - if (!uid) { - return jsonError(res, 501, "existing-session file uploads require ref or inputRef."); - } - await uploadChromeMcpFile({ - profileName: profileCtx.profile.name, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - uid, - filePath: resolvedPaths[0] ?? "", - }); - return res.json({ ok: true }); - } - - const pw = await requirePwAi(res, "file chooser hook"); - if (!pw) { - return; - } - - if (inputRef || element) { - if (ref) { - return jsonError(res, 400, "ref cannot be combined with inputRef/element"); - } - await pw.setInputFilesViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - inputRef, - element, - paths: resolvedPaths, - }); - } else { - await pw.armFileUploadViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - paths: resolvedPaths, - timeoutMs: timeoutMs ?? undefined, - }); - if (ref) { - await pw.clickViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - ref, - }); - } - } - res.json({ ok: true }); - }, - }); - }); - - app.post("/hooks/dialog", async (req, res) => { - const body = readBody(req); - const targetId = resolveTargetIdFromBody(body); - const accept = toBoolean(body.accept); - const promptText = toStringOrEmpty(body.promptText) || undefined; - const timeoutMs = toNumber(body.timeoutMs); - if (accept === undefined) { - return jsonError(res, 400, "accept is required"); - } - - await withRouteTabContext({ - req, - res, - ctx, - targetId, - run: async ({ profileCtx, cdpUrl, tab }) => { - if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { - if (timeoutMs) { - return jsonError( - res, - 501, - "existing-session dialog handling does not support timeoutMs.", - ); - } - await evaluateChromeMcpScript({ - profileName: profileCtx.profile.name, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - fn: `() => { - const state = (window.__openclawDialogHook ??= {}); - if (!state.originals) { - state.originals = { - alert: window.alert.bind(window), - confirm: window.confirm.bind(window), - prompt: window.prompt.bind(window), - }; - } - const originals = state.originals; - const restore = () => { - window.alert = originals.alert; - window.confirm = originals.confirm; - window.prompt = originals.prompt; - delete window.__openclawDialogHook; - }; - window.alert = (...args) => { - try { - return undefined; - } finally { - restore(); - } - }; - window.confirm = (...args) => { - try { - return ${accept ? "true" : "false"}; - } finally { - restore(); - } - }; - window.prompt = (...args) => { - try { - return ${accept ? JSON.stringify(promptText ?? "") : "null"}; - } finally { - restore(); - } - }; - return true; - }`, - }); - return res.json({ ok: true }); - } - const pw = await requirePwAi(res, "dialog hook"); - if (!pw) { - return; - } - await pw.armDialogViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - accept, - promptText, - timeoutMs: timeoutMs ?? undefined, - }); - res.json({ ok: true }); - }, - }); - }); -} +export * from "../../../extensions/browser/src/browser/routes/agent.act.hooks.js"; diff --git a/src/browser/routes/agent.act.shared.ts b/src/browser/routes/agent.act.shared.ts index b22f35e7ef2..4499756ef4a 100644 --- a/src/browser/routes/agent.act.shared.ts +++ b/src/browser/routes/agent.act.shared.ts @@ -1,53 +1 @@ -export const ACT_KINDS = [ - "batch", - "click", - "close", - "drag", - "evaluate", - "fill", - "hover", - "scrollIntoView", - "press", - "resize", - "select", - "type", - "wait", -] as const; - -export type ActKind = (typeof ACT_KINDS)[number]; - -export function isActKind(value: unknown): value is ActKind { - if (typeof value !== "string") { - return false; - } - return (ACT_KINDS as readonly string[]).includes(value); -} - -export type ClickButton = "left" | "right" | "middle"; -export type ClickModifier = "Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift"; - -const ALLOWED_CLICK_MODIFIERS = new Set([ - "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 }; -} +export * from "../../../extensions/browser/src/browser/routes/agent.act.shared.js"; diff --git a/src/browser/routes/agent.act.ts b/src/browser/routes/agent.act.ts index af0d8e40794..4a1aa85e941 100644 --- a/src/browser/routes/agent.act.ts +++ b/src/browser/routes/agent.act.ts @@ -1,1211 +1 @@ -import { - clickChromeMcpElement, - closeChromeMcpTab, - dragChromeMcpElement, - evaluateChromeMcpScript, - fillChromeMcpElement, - fillChromeMcpForm, - hoverChromeMcpElement, - pressChromeMcpKey, - resizeChromeMcpPage, -} from "../chrome-mcp.js"; -import type { BrowserActRequest, BrowserFormField } from "../client-actions-core.js"; -import { normalizeBrowserFormField } from "../form-fields.js"; -import { getBrowserProfileCapabilities } from "../profile-capabilities.js"; -import type { BrowserRouteContext } from "../server-context.js"; -import { matchBrowserUrlPattern } from "../url-pattern.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, - requirePwAi, - resolveTargetIdFromBody, - withRouteTabContext, - SELECTOR_UNSUPPORTED_MESSAGE, -} from "./agent.shared.js"; -import type { BrowserRouteRegistrar } from "./types.js"; -import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js"; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function browserEvaluateDisabledMessage(action: "wait" | "evaluate"): string { - return [ - action === "wait" - ? "wait --fn is disabled by config (browser.evaluateEnabled=false)." - : "act:evaluate is disabled by config (browser.evaluateEnabled=false).", - "Docs: /gateway/configuration#browser-openclaw-managed-browser", - ].join("\n"); -} - -function buildExistingSessionWaitPredicate(params: { - text?: string; - textGone?: string; - selector?: string; - loadState?: "load" | "domcontentloaded" | "networkidle"; - fn?: string; -}): string | null { - const checks: string[] = []; - if (params.text) { - checks.push(`Boolean(document.body?.innerText?.includes(${JSON.stringify(params.text)}))`); - } - if (params.textGone) { - checks.push(`!document.body?.innerText?.includes(${JSON.stringify(params.textGone)})`); - } - if (params.selector) { - checks.push(`Boolean(document.querySelector(${JSON.stringify(params.selector)}))`); - } - if (params.loadState === "domcontentloaded") { - checks.push(`document.readyState === "interactive" || document.readyState === "complete"`); - } else if (params.loadState === "load") { - checks.push(`document.readyState === "complete"`); - } - if (params.fn) { - checks.push(`Boolean(await (${params.fn})())`); - } - if (checks.length === 0) { - return null; - } - return checks.length === 1 ? checks[0] : checks.map((check) => `(${check})`).join(" && "); -} - -async function waitForExistingSessionCondition(params: { - profileName: string; - userDataDir?: string; - targetId: string; - timeMs?: number; - text?: string; - textGone?: string; - selector?: string; - url?: string; - loadState?: "load" | "domcontentloaded" | "networkidle"; - fn?: string; - timeoutMs?: number; -}): Promise { - if (params.timeMs && params.timeMs > 0) { - await sleep(params.timeMs); - } - const predicate = buildExistingSessionWaitPredicate(params); - if (!predicate && !params.url) { - return; - } - const timeoutMs = Math.max(250, params.timeoutMs ?? 10_000); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - let ready = true; - if (predicate) { - ready = Boolean( - await evaluateChromeMcpScript({ - profileName: params.profileName, - userDataDir: params.userDataDir, - targetId: params.targetId, - fn: `async () => ${predicate}`, - }), - ); - } - if (ready && params.url) { - const currentUrl = await evaluateChromeMcpScript({ - profileName: params.profileName, - userDataDir: params.userDataDir, - targetId: params.targetId, - fn: "() => window.location.href", - }); - ready = typeof currentUrl === "string" && matchBrowserUrlPattern(params.url, currentUrl); - } - if (ready) { - return; - } - await sleep(250); - } - throw new Error("Timed out waiting for condition"); -} - -const SELECTOR_ALLOWED_KINDS: ReadonlySet = new Set([ - "batch", - "click", - "drag", - "hover", - "scrollIntoView", - "select", - "type", - "wait", -]); -const MAX_BATCH_ACTIONS = 100; -const MAX_BATCH_CLICK_DELAY_MS = 5_000; -const MAX_BATCH_WAIT_TIME_MS = 30_000; - -function normalizeBoundedNonNegativeMs( - value: unknown, - fieldName: string, - maxMs: number, -): number | undefined { - const ms = toNumber(value); - if (ms === undefined) { - return undefined; - } - if (ms < 0) { - throw new Error(`${fieldName} must be >= 0`); - } - const normalized = Math.floor(ms); - if (normalized > maxMs) { - throw new Error(`${fieldName} exceeds maximum of ${maxMs}ms`); - } - return normalized; -} - -function countBatchActions(actions: BrowserActRequest[]): number { - let count = 0; - for (const action of actions) { - count += 1; - if (action.kind === "batch") { - count += countBatchActions(action.actions); - } - } - return count; -} - -function validateBatchTargetIds(actions: BrowserActRequest[], targetId: string): string | null { - for (const action of actions) { - if (action.targetId && action.targetId !== targetId) { - return "batched action targetId must match request targetId"; - } - if (action.kind === "batch") { - const nestedError = validateBatchTargetIds(action.actions, targetId); - if (nestedError) { - return nestedError; - } - } - } - return null; -} - -function normalizeBatchAction(value: unknown): BrowserActRequest { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("batch actions must be objects"); - } - const raw = value as Record; - const kind = toStringOrEmpty(raw.kind); - if (!isActKind(kind)) { - throw new Error("batch actions must use a supported kind"); - } - - switch (kind) { - case "click": { - const ref = toStringOrEmpty(raw.ref) || undefined; - const selector = toStringOrEmpty(raw.selector) || undefined; - if (!ref && !selector) { - throw new Error("click requires ref or selector"); - } - const buttonRaw = toStringOrEmpty(raw.button); - const button = buttonRaw ? parseClickButton(buttonRaw) : undefined; - if (buttonRaw && !button) { - throw new Error("click button must be left|right|middle"); - } - const modifiersRaw = toStringArray(raw.modifiers) ?? []; - const parsedModifiers = parseClickModifiers(modifiersRaw); - if (parsedModifiers.error) { - throw new Error(parsedModifiers.error); - } - const doubleClick = toBoolean(raw.doubleClick); - const delayMs = normalizeBoundedNonNegativeMs( - raw.delayMs, - "click delayMs", - MAX_BATCH_CLICK_DELAY_MS, - ); - const timeoutMs = toNumber(raw.timeoutMs); - const targetId = toStringOrEmpty(raw.targetId) || undefined; - return { - kind, - ...(ref ? { ref } : {}), - ...(selector ? { selector } : {}), - ...(targetId ? { targetId } : {}), - ...(doubleClick !== undefined ? { doubleClick } : {}), - ...(button ? { button } : {}), - ...(parsedModifiers.modifiers ? { modifiers: parsedModifiers.modifiers } : {}), - ...(delayMs !== undefined ? { delayMs } : {}), - ...(timeoutMs !== undefined ? { timeoutMs } : {}), - }; - } - case "type": { - const ref = toStringOrEmpty(raw.ref) || undefined; - const selector = toStringOrEmpty(raw.selector) || undefined; - const text = raw.text; - if (!ref && !selector) { - throw new Error("type requires ref or selector"); - } - if (typeof text !== "string") { - throw new Error("type requires text"); - } - const targetId = toStringOrEmpty(raw.targetId) || undefined; - const submit = toBoolean(raw.submit); - const slowly = toBoolean(raw.slowly); - const timeoutMs = toNumber(raw.timeoutMs); - return { - kind, - ...(ref ? { ref } : {}), - ...(selector ? { selector } : {}), - text, - ...(targetId ? { targetId } : {}), - ...(submit !== undefined ? { submit } : {}), - ...(slowly !== undefined ? { slowly } : {}), - ...(timeoutMs !== undefined ? { timeoutMs } : {}), - }; - } - case "press": { - const key = toStringOrEmpty(raw.key); - if (!key) { - throw new Error("press requires key"); - } - const targetId = toStringOrEmpty(raw.targetId) || undefined; - const delayMs = toNumber(raw.delayMs); - return { - kind, - key, - ...(targetId ? { targetId } : {}), - ...(delayMs !== undefined ? { delayMs } : {}), - }; - } - case "hover": - case "scrollIntoView": { - const ref = toStringOrEmpty(raw.ref) || undefined; - const selector = toStringOrEmpty(raw.selector) || undefined; - if (!ref && !selector) { - throw new Error(`${kind} requires ref or selector`); - } - const targetId = toStringOrEmpty(raw.targetId) || undefined; - const timeoutMs = toNumber(raw.timeoutMs); - return { - kind, - ...(ref ? { ref } : {}), - ...(selector ? { selector } : {}), - ...(targetId ? { targetId } : {}), - ...(timeoutMs !== undefined ? { timeoutMs } : {}), - }; - } - case "drag": { - const startRef = toStringOrEmpty(raw.startRef) || undefined; - const startSelector = toStringOrEmpty(raw.startSelector) || undefined; - const endRef = toStringOrEmpty(raw.endRef) || undefined; - const endSelector = toStringOrEmpty(raw.endSelector) || undefined; - if (!startRef && !startSelector) { - throw new Error("drag requires startRef or startSelector"); - } - if (!endRef && !endSelector) { - throw new Error("drag requires endRef or endSelector"); - } - const targetId = toStringOrEmpty(raw.targetId) || undefined; - const timeoutMs = toNumber(raw.timeoutMs); - return { - kind, - ...(startRef ? { startRef } : {}), - ...(startSelector ? { startSelector } : {}), - ...(endRef ? { endRef } : {}), - ...(endSelector ? { endSelector } : {}), - ...(targetId ? { targetId } : {}), - ...(timeoutMs !== undefined ? { timeoutMs } : {}), - }; - } - case "select": { - const ref = toStringOrEmpty(raw.ref) || undefined; - const selector = toStringOrEmpty(raw.selector) || undefined; - const values = toStringArray(raw.values); - if ((!ref && !selector) || !values?.length) { - throw new Error("select requires ref/selector and values"); - } - const targetId = toStringOrEmpty(raw.targetId) || undefined; - const timeoutMs = toNumber(raw.timeoutMs); - return { - kind, - ...(ref ? { ref } : {}), - ...(selector ? { selector } : {}), - values, - ...(targetId ? { targetId } : {}), - ...(timeoutMs !== undefined ? { timeoutMs } : {}), - }; - } - case "fill": { - const rawFields = Array.isArray(raw.fields) ? raw.fields : []; - const fields = rawFields - .map((field) => { - if (!field || typeof field !== "object") { - return null; - } - return normalizeBrowserFormField(field as Record); - }) - .filter((field): field is BrowserFormField => field !== null); - if (!fields.length) { - throw new Error("fill requires fields"); - } - const targetId = toStringOrEmpty(raw.targetId) || undefined; - const timeoutMs = toNumber(raw.timeoutMs); - return { - kind, - fields, - ...(targetId ? { targetId } : {}), - ...(timeoutMs !== undefined ? { timeoutMs } : {}), - }; - } - case "resize": { - const width = toNumber(raw.width); - const height = toNumber(raw.height); - if (width === undefined || height === undefined) { - throw new Error("resize requires width and height"); - } - const targetId = toStringOrEmpty(raw.targetId) || undefined; - return { - kind, - width, - height, - ...(targetId ? { targetId } : {}), - }; - } - case "wait": { - const loadStateRaw = toStringOrEmpty(raw.loadState); - const loadState = - loadStateRaw === "load" || - loadStateRaw === "domcontentloaded" || - loadStateRaw === "networkidle" - ? loadStateRaw - : undefined; - const timeMs = normalizeBoundedNonNegativeMs( - raw.timeMs, - "wait timeMs", - MAX_BATCH_WAIT_TIME_MS, - ); - const text = toStringOrEmpty(raw.text) || undefined; - const textGone = toStringOrEmpty(raw.textGone) || undefined; - const selector = toStringOrEmpty(raw.selector) || undefined; - const url = toStringOrEmpty(raw.url) || undefined; - const fn = toStringOrEmpty(raw.fn) || undefined; - if (timeMs === undefined && !text && !textGone && !selector && !url && !loadState && !fn) { - throw new Error( - "wait requires at least one of: timeMs, text, textGone, selector, url, loadState, fn", - ); - } - const targetId = toStringOrEmpty(raw.targetId) || undefined; - const timeoutMs = toNumber(raw.timeoutMs); - return { - kind, - ...(timeMs !== undefined ? { timeMs } : {}), - ...(text ? { text } : {}), - ...(textGone ? { textGone } : {}), - ...(selector ? { selector } : {}), - ...(url ? { url } : {}), - ...(loadState ? { loadState } : {}), - ...(fn ? { fn } : {}), - ...(targetId ? { targetId } : {}), - ...(timeoutMs !== undefined ? { timeoutMs } : {}), - }; - } - case "evaluate": { - const fn = toStringOrEmpty(raw.fn); - if (!fn) { - throw new Error("evaluate requires fn"); - } - const ref = toStringOrEmpty(raw.ref) || undefined; - const targetId = toStringOrEmpty(raw.targetId) || undefined; - const timeoutMs = toNumber(raw.timeoutMs); - return { - kind, - fn, - ...(ref ? { ref } : {}), - ...(targetId ? { targetId } : {}), - ...(timeoutMs !== undefined ? { timeoutMs } : {}), - }; - } - case "close": { - const targetId = toStringOrEmpty(raw.targetId) || undefined; - return { - kind, - ...(targetId ? { targetId } : {}), - }; - } - case "batch": { - const actions = Array.isArray(raw.actions) ? raw.actions.map(normalizeBatchAction) : []; - if (!actions.length) { - throw new Error("batch requires actions"); - } - if (countBatchActions(actions) > MAX_BATCH_ACTIONS) { - throw new Error(`batch exceeds maximum of ${MAX_BATCH_ACTIONS} actions`); - } - const targetId = toStringOrEmpty(raw.targetId) || undefined; - const stopOnError = toBoolean(raw.stopOnError); - return { - kind, - actions, - ...(targetId ? { targetId } : {}), - ...(stopOnError !== undefined ? { stopOnError } : {}), - }; - } - } -} - -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") && !SELECTOR_ALLOWED_KINDS.has(kind)) { - return jsonError(res, 400, SELECTOR_UNSUPPORTED_MESSAGE); - } - const earlyFn = kind === "wait" || kind === "evaluate" ? toStringOrEmpty(body.fn) : ""; - if ( - (kind === "evaluate" || (kind === "wait" && earlyFn)) && - !ctx.state().resolved.evaluateEnabled - ) { - return jsonError( - res, - 403, - browserEvaluateDisabledMessage(kind === "evaluate" ? "evaluate" : "wait"), - ); - } - - await withRouteTabContext({ - req, - res, - ctx, - targetId, - run: async ({ profileCtx, cdpUrl, tab }) => { - const evaluateEnabled = ctx.state().resolved.evaluateEnabled; - const isExistingSession = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp; - const profileName = profileCtx.profile.name; - - switch (kind) { - case "click": { - const ref = toStringOrEmpty(body.ref) || undefined; - const selector = toStringOrEmpty(body.selector) || undefined; - if (!ref && !selector) { - return jsonError(res, 400, "ref or selector is required"); - } - const doubleClick = toBoolean(body.doubleClick) ?? false; - const timeoutMs = toNumber(body.timeoutMs); - const delayMs = toNumber(body.delayMs); - 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; - if (isExistingSession) { - if (selector) { - return jsonError( - res, - 501, - "existing-session click does not support selector targeting yet; use ref.", - ); - } - if ((button && button !== "left") || (modifiers && modifiers.length > 0)) { - return jsonError( - res, - 501, - "existing-session click currently supports left-click only (no button overrides/modifiers).", - ); - } - await clickChromeMcpElement({ - profileName, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - uid: ref!, - doubleClick, - }); - return res.json({ ok: true, targetId: tab.targetId, url: tab.url }); - } - const pw = await requirePwAi(res, `act:${kind}`); - if (!pw) { - return; - } - const clickRequest: Parameters[0] = { - cdpUrl, - targetId: tab.targetId, - doubleClick, - }; - if (ref) { - clickRequest.ref = ref; - } - if (selector) { - clickRequest.selector = selector; - } - if (button) { - clickRequest.button = button; - } - if (modifiers) { - clickRequest.modifiers = modifiers; - } - if (delayMs) { - clickRequest.delayMs = delayMs; - } - 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) || undefined; - const selector = toStringOrEmpty(body.selector) || undefined; - if (!ref && !selector) { - return jsonError(res, 400, "ref or selector 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); - if (isExistingSession) { - if (selector) { - return jsonError( - res, - 501, - "existing-session type does not support selector targeting yet; use ref.", - ); - } - if (slowly) { - return jsonError( - res, - 501, - "existing-session type does not support slowly=true; use fill/press instead.", - ); - } - await fillChromeMcpElement({ - profileName, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - uid: ref!, - value: text, - }); - if (submit) { - await pressChromeMcpKey({ - profileName, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - key: "Enter", - }); - } - return res.json({ ok: true, targetId: tab.targetId }); - } - const pw = await requirePwAi(res, `act:${kind}`); - if (!pw) { - return; - } - const typeRequest: Parameters[0] = { - cdpUrl, - targetId: tab.targetId, - text, - submit, - slowly, - }; - if (ref) { - typeRequest.ref = ref; - } - if (selector) { - typeRequest.selector = selector; - } - 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); - if (isExistingSession) { - if (delayMs) { - return jsonError(res, 501, "existing-session press does not support delayMs."); - } - await pressChromeMcpKey({ - profileName, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - key, - }); - return res.json({ ok: true, targetId: tab.targetId }); - } - const pw = await requirePwAi(res, `act:${kind}`); - if (!pw) { - return; - } - 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) || undefined; - const selector = toStringOrEmpty(body.selector) || undefined; - if (!ref && !selector) { - return jsonError(res, 400, "ref or selector is required"); - } - const timeoutMs = toNumber(body.timeoutMs); - if (isExistingSession) { - if (selector) { - return jsonError( - res, - 501, - "existing-session hover does not support selector targeting yet; use ref.", - ); - } - if (timeoutMs) { - return jsonError( - res, - 501, - "existing-session hover does not support timeoutMs overrides.", - ); - } - await hoverChromeMcpElement({ - profileName, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - uid: ref!, - }); - return res.json({ ok: true, targetId: tab.targetId }); - } - const pw = await requirePwAi(res, `act:${kind}`); - if (!pw) { - return; - } - await pw.hoverViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - ref, - selector, - timeoutMs: timeoutMs ?? undefined, - }); - return res.json({ ok: true, targetId: tab.targetId }); - } - case "scrollIntoView": { - const ref = toStringOrEmpty(body.ref) || undefined; - const selector = toStringOrEmpty(body.selector) || undefined; - if (!ref && !selector) { - return jsonError(res, 400, "ref or selector is required"); - } - const timeoutMs = toNumber(body.timeoutMs); - if (isExistingSession) { - if (selector) { - return jsonError( - res, - 501, - "existing-session scrollIntoView does not support selector targeting yet; use ref.", - ); - } - if (timeoutMs) { - return jsonError( - res, - 501, - "existing-session scrollIntoView does not support timeoutMs overrides.", - ); - } - await evaluateChromeMcpScript({ - profileName, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`, - args: [ref!], - }); - return res.json({ ok: true, targetId: tab.targetId }); - } - const pw = await requirePwAi(res, `act:${kind}`); - if (!pw) { - return; - } - const scrollRequest: Parameters[0] = { - cdpUrl, - targetId: tab.targetId, - }; - if (ref) { - scrollRequest.ref = ref; - } - if (selector) { - scrollRequest.selector = selector; - } - if (timeoutMs) { - scrollRequest.timeoutMs = timeoutMs; - } - await pw.scrollIntoViewViaPlaywright(scrollRequest); - return res.json({ ok: true, targetId: tab.targetId }); - } - case "drag": { - const startRef = toStringOrEmpty(body.startRef) || undefined; - const startSelector = toStringOrEmpty(body.startSelector) || undefined; - const endRef = toStringOrEmpty(body.endRef) || undefined; - const endSelector = toStringOrEmpty(body.endSelector) || undefined; - if (!startRef && !startSelector) { - return jsonError(res, 400, "startRef or startSelector is required"); - } - if (!endRef && !endSelector) { - return jsonError(res, 400, "endRef or endSelector is required"); - } - const timeoutMs = toNumber(body.timeoutMs); - if (isExistingSession) { - if (startSelector || endSelector) { - return jsonError( - res, - 501, - "existing-session drag does not support selector targeting yet; use startRef/endRef.", - ); - } - if (timeoutMs) { - return jsonError( - res, - 501, - "existing-session drag does not support timeoutMs overrides.", - ); - } - await dragChromeMcpElement({ - profileName, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - fromUid: startRef!, - toUid: endRef!, - }); - return res.json({ ok: true, targetId: tab.targetId }); - } - const pw = await requirePwAi(res, `act:${kind}`); - if (!pw) { - return; - } - await pw.dragViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - startRef, - startSelector, - endRef, - endSelector, - timeoutMs: timeoutMs ?? undefined, - }); - return res.json({ ok: true, targetId: tab.targetId }); - } - case "select": { - const ref = toStringOrEmpty(body.ref) || undefined; - const selector = toStringOrEmpty(body.selector) || undefined; - const values = toStringArray(body.values); - if ((!ref && !selector) || !values?.length) { - return jsonError(res, 400, "ref/selector and values are required"); - } - const timeoutMs = toNumber(body.timeoutMs); - if (isExistingSession) { - if (selector) { - return jsonError( - res, - 501, - "existing-session select does not support selector targeting yet; use ref.", - ); - } - if (values.length !== 1) { - return jsonError( - res, - 501, - "existing-session select currently supports a single value only.", - ); - } - if (timeoutMs) { - return jsonError( - res, - 501, - "existing-session select does not support timeoutMs overrides.", - ); - } - await fillChromeMcpElement({ - profileName, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - uid: ref!, - value: values[0] ?? "", - }); - return res.json({ ok: true, targetId: tab.targetId }); - } - const pw = await requirePwAi(res, `act:${kind}`); - if (!pw) { - return; - } - await pw.selectOptionViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - ref, - selector, - 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); - }) - .filter((field): field is BrowserFormField => field !== null); - if (!fields.length) { - return jsonError(res, 400, "fields are required"); - } - const timeoutMs = toNumber(body.timeoutMs); - if (isExistingSession) { - if (timeoutMs) { - return jsonError( - res, - 501, - "existing-session fill does not support timeoutMs overrides.", - ); - } - await fillChromeMcpForm({ - profileName, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - elements: fields.map((field) => ({ - uid: field.ref, - value: String(field.value ?? ""), - })), - }); - return res.json({ ok: true, targetId: tab.targetId }); - } - const pw = await requirePwAi(res, `act:${kind}`); - if (!pw) { - return; - } - 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"); - } - if (isExistingSession) { - await resizeChromeMcpPage({ - profileName, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - width, - height, - }); - return res.json({ ok: true, targetId: tab.targetId, url: tab.url }); - } - const pw = await requirePwAi(res, `act:${kind}`); - if (!pw) { - return; - } - 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, browserEvaluateDisabledMessage("wait")); - } - 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", - ); - } - if (isExistingSession) { - if (loadState === "networkidle") { - return jsonError( - res, - 501, - "existing-session wait does not support loadState=networkidle yet.", - ); - } - await waitForExistingSessionCondition({ - profileName, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - timeMs, - text, - textGone, - selector, - url, - loadState, - fn, - timeoutMs, - }); - return res.json({ ok: true, targetId: tab.targetId }); - } - const pw = await requirePwAi(res, `act:${kind}`); - if (!pw) { - return; - } - 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, browserEvaluateDisabledMessage("evaluate")); - } - 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); - if (isExistingSession) { - if (evalTimeoutMs !== undefined) { - return jsonError( - res, - 501, - "existing-session evaluate does not support timeoutMs overrides.", - ); - } - const result = await evaluateChromeMcpScript({ - profileName, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - fn, - args: ref ? [ref] : undefined, - }); - return res.json({ - ok: true, - targetId: tab.targetId, - url: tab.url, - result, - }); - } - const pw = await requirePwAi(res, `act:${kind}`); - if (!pw) { - return; - } - const evalRequest: Parameters[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": { - if (isExistingSession) { - await closeChromeMcpTab(profileName, tab.targetId, profileCtx.profile.userDataDir); - return res.json({ ok: true, targetId: tab.targetId }); - } - const pw = await requirePwAi(res, `act:${kind}`); - if (!pw) { - return; - } - await pw.closePageViaPlaywright({ cdpUrl, targetId: tab.targetId }); - return res.json({ ok: true, targetId: tab.targetId }); - } - case "batch": { - if (isExistingSession) { - return jsonError( - res, - 501, - "existing-session batch is not supported yet; send actions individually.", - ); - } - const pw = await requirePwAi(res, `act:${kind}`); - if (!pw) { - return; - } - let actions: BrowserActRequest[]; - try { - actions = Array.isArray(body.actions) ? body.actions.map(normalizeBatchAction) : []; - } catch (err) { - return jsonError(res, 400, err instanceof Error ? err.message : String(err)); - } - if (!actions.length) { - return jsonError(res, 400, "actions are required"); - } - if (countBatchActions(actions) > MAX_BATCH_ACTIONS) { - return jsonError(res, 400, `batch exceeds maximum of ${MAX_BATCH_ACTIONS} actions`); - } - const targetIdError = validateBatchTargetIds(actions, tab.targetId); - if (targetIdError) { - return jsonError(res, 403, targetIdError); - } - const stopOnError = toBoolean(body.stopOnError) ?? true; - const result = await pw.batchViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - actions, - stopOnError, - evaluateEnabled, - }); - return res.json({ ok: true, targetId: tab.targetId, results: result.results }); - } - 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 withRouteTabContext({ - req, - res, - ctx, - targetId, - run: async ({ profileCtx, cdpUrl, tab }) => { - if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { - return jsonError( - res, - 501, - "response body is not supported for existing-session profiles yet.", - ); - } - const pw = await requirePwAi(res, "response body"); - if (!pw) { - return; - } - 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 withRouteTabContext({ - req, - res, - ctx, - targetId, - run: async ({ profileCtx, cdpUrl, tab }) => { - if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { - await evaluateChromeMcpScript({ - profileName: profileCtx.profile.name, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - args: [ref], - fn: `(el) => { - if (!(el instanceof Element)) { - return false; - } - el.scrollIntoView({ block: "center", inline: "center" }); - const previousOutline = el.style.outline; - const previousOffset = el.style.outlineOffset; - el.style.outline = "3px solid #FF4500"; - el.style.outlineOffset = "2px"; - setTimeout(() => { - el.style.outline = previousOutline; - el.style.outlineOffset = previousOffset; - }, 2000); - return true; - }`, - }); - return res.json({ ok: true, targetId: tab.targetId }); - } - const pw = await requirePwAi(res, "highlight"); - if (!pw) { - return; - } - await pw.highlightViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - ref, - }); - res.json({ ok: true, targetId: tab.targetId }); - }, - }); - }); -} +export * from "../../../extensions/browser/src/browser/routes/agent.act.js"; diff --git a/src/browser/routes/agent.debug.ts b/src/browser/routes/agent.debug.ts index f5c0d7b2030..e88ca7bd036 100644 --- a/src/browser/routes/agent.debug.ts +++ b/src/browser/routes/agent.debug.ts @@ -1,147 +1 @@ -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), - }); - }, - }); - }); -} +export * from "../../../extensions/browser/src/browser/routes/agent.debug.js"; diff --git a/src/browser/routes/agent.shared.ts b/src/browser/routes/agent.shared.ts index cc82e00d004..3435017ef10 100644 --- a/src/browser/routes/agent.shared.ts +++ b/src/browser/routes/agent.shared.ts @@ -1,148 +1 @@ -import { toBrowserErrorResponse } from "../errors.js"; -import type { PwAiModule } from "../pw-ai-module.js"; -import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js"; -import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; -import type { BrowserRequest, BrowserResponse } from "./types.js"; -import { getProfileContext, jsonError } from "./utils.js"; - -export const SELECTOR_UNSUPPORTED_MESSAGE = [ - "Error: 'selector' is not supported. Use 'ref' from snapshot instead.", - "", - "Example workflow:", - "1. snapshot action to get page state with refs", - '2. act with ref: "e123" to interact with element', - "", - "This is more reliable for modern SPAs.", -].join("\n"); - -export function readBody(req: BrowserRequest): Record { - const body = req.body as Record | undefined; - if (!body || typeof body !== "object" || Array.isArray(body)) { - return {}; - } - return body; -} - -export function resolveTargetIdFromBody(body: Record): string | undefined { - const targetId = typeof body.targetId === "string" ? body.targetId.trim() : ""; - return targetId || undefined; -} - -export function resolveTargetIdFromQuery(query: Record): string | undefined { - const targetId = typeof query.targetId === "string" ? query.targetId.trim() : ""; - return targetId || undefined; -} - -export function handleRouteError(ctx: BrowserRouteContext, res: BrowserResponse, err: unknown) { - const mapped = ctx.mapTabError(err); - if (mapped) { - return jsonError(res, mapped.status, mapped.message); - } - const browserMapped = toBrowserErrorResponse(err); - if (browserMapped) { - return jsonError(res, browserMapped.status, browserMapped.message); - } - jsonError(res, 500, String(err)); -} - -export function resolveProfileContext( - req: BrowserRequest, - res: BrowserResponse, - ctx: BrowserRouteContext, -): ProfileContext | null { - const profileCtx = getProfileContext(req, ctx); - if ("error" in profileCtx) { - jsonError(res, profileCtx.status, profileCtx.error); - return null; - } - return profileCtx; -} - -export async function getPwAiModule(): Promise { - return await getPwAiModuleBase({ mode: "soft" }); -} - -export async function requirePwAi( - res: BrowserResponse, - feature: string, -): Promise { - 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>; - cdpUrl: string; -}; - -type RouteTabPwContext = RouteTabContext & { - pw: PwAiModule; -}; - -type RouteWithTabParams = { - req: BrowserRequest; - res: BrowserResponse; - ctx: BrowserRouteContext; - targetId?: string; - run: (ctx: RouteTabContext) => Promise; -}; - -export async function withRouteTabContext( - params: RouteWithTabParams, -): Promise { - 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 = { - req: BrowserRequest; - res: BrowserResponse; - ctx: BrowserRouteContext; - targetId?: string; - feature: string; - run: (ctx: RouteTabPwContext) => Promise; -}; - -export async function withPlaywrightRouteContext( - params: RouteWithPwParams, -): Promise { - 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 }); - }, - }); -} +export * from "../../../extensions/browser/src/browser/routes/agent.shared.js"; diff --git a/src/browser/routes/agent.snapshot.plan.ts b/src/browser/routes/agent.snapshot.plan.ts index 6c913400d90..ff35196914f 100644 --- a/src/browser/routes/agent.snapshot.plan.ts +++ b/src/browser/routes/agent.snapshot.plan.ts @@ -1,97 +1 @@ -import type { ResolvedBrowserProfile } from "../config.js"; -import { - DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH, - DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS, - DEFAULT_AI_SNAPSHOT_MAX_CHARS, -} from "../constants.js"; -import { - resolveDefaultSnapshotFormat, - shouldUsePlaywrightForAriaSnapshot, - shouldUsePlaywrightForScreenshot, -} from "../profile-capabilities.js"; -import { toBoolean, toNumber, toStringOrEmpty } from "./utils.js"; - -export type BrowserSnapshotPlan = { - format: "ai" | "aria"; - mode?: "efficient"; - labels?: boolean; - limit?: number; - resolvedMaxChars?: number; - interactive?: boolean; - compact?: boolean; - depth?: number; - refsMode?: "aria" | "role"; - selectorValue?: string; - frameSelectorValue?: string; - wantsRoleSnapshot: boolean; -}; - -export function resolveSnapshotPlan(params: { - profile: ResolvedBrowserProfile; - query: Record; - hasPlaywright: boolean; -}): BrowserSnapshotPlan { - const mode = params.query.mode === "efficient" ? "efficient" : undefined; - const labels = toBoolean(params.query.labels) ?? undefined; - const explicitFormat = - params.query.format === "aria" ? "aria" : params.query.format === "ai" ? "ai" : undefined; - const format = resolveDefaultSnapshotFormat({ - profile: params.profile, - hasPlaywright: params.hasPlaywright, - explicitFormat, - mode, - }); - const limitRaw = typeof params.query.limit === "string" ? Number(params.query.limit) : undefined; - const hasMaxChars = Object.hasOwn(params.query, "maxChars"); - const maxCharsRaw = - typeof params.query.maxChars === "string" ? Number(params.query.maxChars) : undefined; - const limit = Number.isFinite(limitRaw) ? limitRaw : undefined; - const maxChars = - typeof maxCharsRaw === "number" && Number.isFinite(maxCharsRaw) && maxCharsRaw > 0 - ? Math.floor(maxCharsRaw) - : undefined; - const resolvedMaxChars = - format === "ai" - ? hasMaxChars - ? maxChars - : mode === "efficient" - ? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS - : DEFAULT_AI_SNAPSHOT_MAX_CHARS - : undefined; - const interactiveRaw = toBoolean(params.query.interactive); - const compactRaw = toBoolean(params.query.compact); - const depthRaw = toNumber(params.query.depth); - const refsModeRaw = toStringOrEmpty(params.query.refs).trim(); - const refsMode: "aria" | "role" | undefined = - refsModeRaw === "aria" ? "aria" : refsModeRaw === "role" ? "role" : undefined; - const interactive = interactiveRaw ?? (mode === "efficient" ? true : undefined); - const compact = compactRaw ?? (mode === "efficient" ? true : undefined); - const depth = - depthRaw ?? (mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH : undefined); - const selectorValue = toStringOrEmpty(params.query.selector).trim() || undefined; - const frameSelectorValue = toStringOrEmpty(params.query.frame).trim() || undefined; - - return { - format, - mode, - labels, - limit, - resolvedMaxChars, - interactive, - compact, - depth, - refsMode, - selectorValue, - frameSelectorValue, - wantsRoleSnapshot: - labels === true || - mode === "efficient" || - interactive === true || - compact === true || - depth !== undefined || - Boolean(selectorValue) || - Boolean(frameSelectorValue), - }; -} - -export { shouldUsePlaywrightForAriaSnapshot, shouldUsePlaywrightForScreenshot }; +export * from "../../../extensions/browser/src/browser/routes/agent.snapshot.plan.js"; diff --git a/src/browser/routes/agent.snapshot.ts b/src/browser/routes/agent.snapshot.ts index 7cb73049389..b683b55875c 100644 --- a/src/browser/routes/agent.snapshot.ts +++ b/src/browser/routes/agent.snapshot.ts @@ -1,602 +1 @@ -import path from "node:path"; -import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js"; -import { captureScreenshot, snapshotAria } from "../cdp.js"; -import { - evaluateChromeMcpScript, - navigateChromeMcpPage, - takeChromeMcpScreenshot, - takeChromeMcpSnapshot, -} from "../chrome-mcp.js"; -import { - buildAiSnapshotFromChromeMcpSnapshot, - flattenChromeMcpSnapshotToAriaNodes, -} from "../chrome-mcp.snapshot.js"; -import { - assertBrowserNavigationAllowed, - assertBrowserNavigationResultAllowed, -} from "../navigation-guard.js"; -import { withBrowserNavigationPolicy } from "../navigation-guard.js"; -import { getBrowserProfileCapabilities } from "../profile-capabilities.js"; -import { - DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, - DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, - normalizeBrowserScreenshot, -} from "../screenshot.js"; -import type { BrowserRouteContext } from "../server-context.js"; -import { - getPwAiModule, - handleRouteError, - readBody, - requirePwAi, - resolveProfileContext, - withPlaywrightRouteContext, - withRouteTabContext, -} from "./agent.shared.js"; -import { - resolveSnapshotPlan, - shouldUsePlaywrightForAriaSnapshot, - shouldUsePlaywrightForScreenshot, -} from "./agent.snapshot.plan.js"; -import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js"; -import { jsonError, toBoolean, toStringOrEmpty } from "./utils.js"; - -const CHROME_MCP_OVERLAY_ATTR = "data-openclaw-mcp-overlay"; - -async function clearChromeMcpOverlay(params: { - profileName: string; - userDataDir?: string; - targetId: string; -}): Promise { - await evaluateChromeMcpScript({ - profileName: params.profileName, - userDataDir: params.userDataDir, - targetId: params.targetId, - fn: `() => { - document.querySelectorAll("[${CHROME_MCP_OVERLAY_ATTR}]").forEach((node) => node.remove()); - return true; - }`, - }).catch(() => {}); -} - -async function renderChromeMcpLabels(params: { - profileName: string; - userDataDir?: string; - targetId: string; - refs: string[]; -}): Promise<{ labels: number; skipped: number }> { - const refList = JSON.stringify(params.refs); - const result = await evaluateChromeMcpScript({ - profileName: params.profileName, - userDataDir: params.userDataDir, - targetId: params.targetId, - args: params.refs, - fn: `(...elements) => { - const refs = ${refList}; - document.querySelectorAll("[${CHROME_MCP_OVERLAY_ATTR}]").forEach((node) => node.remove()); - const root = document.createElement("div"); - root.setAttribute("${CHROME_MCP_OVERLAY_ATTR}", "labels"); - root.style.position = "fixed"; - root.style.inset = "0"; - root.style.pointerEvents = "none"; - root.style.zIndex = "2147483647"; - let labels = 0; - let skipped = 0; - elements.forEach((el, index) => { - if (!(el instanceof Element)) { - skipped += 1; - return; - } - const rect = el.getBoundingClientRect(); - if (rect.width <= 0 && rect.height <= 0) { - skipped += 1; - return; - } - labels += 1; - const badge = document.createElement("div"); - badge.setAttribute("${CHROME_MCP_OVERLAY_ATTR}", "label"); - badge.textContent = refs[index] || String(labels); - badge.style.position = "fixed"; - badge.style.left = \`\${Math.max(0, rect.left)}px\`; - badge.style.top = \`\${Math.max(0, rect.top)}px\`; - badge.style.transform = "translateY(-100%)"; - badge.style.padding = "2px 6px"; - badge.style.borderRadius = "999px"; - badge.style.background = "#FF4500"; - badge.style.color = "#fff"; - badge.style.font = "600 12px ui-monospace, SFMono-Regular, Menlo, monospace"; - badge.style.boxShadow = "0 2px 6px rgba(0,0,0,0.35)"; - badge.style.whiteSpace = "nowrap"; - root.appendChild(badge); - }); - document.documentElement.appendChild(root); - return { labels, skipped }; - }`, - }); - const labels = - result && - typeof result === "object" && - typeof (result as { labels?: unknown }).labels === "number" - ? (result as { labels: number }).labels - : 0; - const skipped = - result && - typeof result === "object" && - typeof (result as { skipped?: unknown }).skipped === "number" - ? (result as { skipped: number }).skipped - : 0; - return { labels, skipped }; -} - -async function saveNormalizedScreenshotResponse(params: { - res: BrowserResponse; - buffer: Buffer; - type: "png" | "jpeg"; - targetId: string; - url: string; -}) { - const normalized = await normalizeBrowserScreenshot(params.buffer, { - maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, - maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, - }); - await saveBrowserMediaResponse({ - res: params.res, - buffer: normalized.buffer, - contentType: normalized.contentType ?? `image/${params.type}`, - maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, - targetId: params.targetId, - url: params.url, - }); -} - -async function saveBrowserMediaResponse(params: { - res: BrowserResponse; - buffer: Buffer; - contentType: string; - maxBytes: number; - targetId: string; - url: string; -}) { - await ensureMediaDir(); - const saved = await saveMediaBuffer( - params.buffer, - params.contentType, - "browser", - params.maxBytes, - ); - params.res.json({ - ok: true, - path: path.resolve(saved.path), - targetId: params.targetId, - url: params.url, - }); -} - -/** Resolve the correct targetId after a navigation that may trigger a renderer swap. */ -export async function resolveTargetIdAfterNavigate(opts: { - oldTargetId: string; - navigatedUrl: string; - listTabs: () => Promise>; -}): Promise { - let currentTargetId = opts.oldTargetId; - try { - const pickReplacement = ( - tabs: Array<{ targetId: string; url: string }>, - options?: { allowSingleTabFallback?: boolean }, - ) => { - if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) { - return opts.oldTargetId; - } - const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl); - if (byUrl.length === 1) { - return byUrl[0]?.targetId ?? opts.oldTargetId; - } - const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId); - if (uniqueReplacement.length === 1) { - return uniqueReplacement[0]?.targetId ?? opts.oldTargetId; - } - if (options?.allowSingleTabFallback && tabs.length === 1) { - return tabs[0]?.targetId ?? opts.oldTargetId; - } - return opts.oldTargetId; - }; - - currentTargetId = pickReplacement(await opts.listTabs()); - if (currentTargetId === opts.oldTargetId) { - await new Promise((r) => setTimeout(r, 800)); - currentTargetId = pickReplacement(await opts.listTabs(), { - allowSingleTabFallback: true, - }); - } - } catch { - // Best-effort: fall back to pre-navigation targetId - } - return currentTargetId; -} - -export function registerBrowserAgentSnapshotRoutes( - app: BrowserRouteRegistrar, - ctx: BrowserRouteContext, -) { - app.post("/navigate", async (req, res) => { - const body = readBody(req); - const url = toStringOrEmpty(body.url); - const targetId = toStringOrEmpty(body.targetId) || undefined; - if (!url) { - return jsonError(res, 400, "url is required"); - } - await withRouteTabContext({ - req, - res, - ctx, - targetId, - run: async ({ profileCtx, tab, cdpUrl }) => { - if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { - const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy); - await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); - const result = await navigateChromeMcpPage({ - profileName: profileCtx.profile.name, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - url, - }); - await assertBrowserNavigationResultAllowed({ url: result.url, ...ssrfPolicyOpts }); - return res.json({ ok: true, targetId: tab.targetId, ...result }); - } - const pw = await requirePwAi(res, "navigate"); - if (!pw) { - return; - } - const result = await pw.navigateViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - url, - ...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy), - }); - const currentTargetId = await resolveTargetIdAfterNavigate({ - oldTargetId: tab.targetId, - navigatedUrl: result.url, - listTabs: () => profileCtx.listTabs(), - }); - res.json({ ok: true, targetId: currentTargetId, ...result }); - }, - }); - }); - - app.post("/pdf", async (req, res) => { - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) { - return; - } - if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { - return jsonError( - res, - 501, - "pdf is not supported for existing-session profiles yet; use screenshot/snapshot instead.", - ); - } - await withPlaywrightRouteContext({ - req, - res, - ctx, - targetId, - feature: "pdf", - run: async ({ cdpUrl, tab, pw }) => { - const pdf = await pw.pdfViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - }); - await saveBrowserMediaResponse({ - res, - buffer: pdf.buffer, - contentType: "application/pdf", - maxBytes: pdf.buffer.byteLength, - targetId: tab.targetId, - url: tab.url, - }); - }, - }); - }); - - app.post("/screenshot", async (req, res) => { - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const fullPage = toBoolean(body.fullPage) ?? false; - const ref = toStringOrEmpty(body.ref) || undefined; - const element = toStringOrEmpty(body.element) || undefined; - const type = body.type === "jpeg" ? "jpeg" : "png"; - - if (fullPage && (ref || element)) { - return jsonError(res, 400, "fullPage is not supported for element screenshots"); - } - - await withRouteTabContext({ - req, - res, - ctx, - targetId, - run: async ({ profileCtx, tab, cdpUrl }) => { - if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { - if (element) { - return jsonError( - res, - 400, - "element screenshots are not supported for existing-session profiles; use ref from snapshot.", - ); - } - const buffer = await takeChromeMcpScreenshot({ - profileName: profileCtx.profile.name, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - uid: ref, - fullPage, - format: type, - }); - await saveNormalizedScreenshotResponse({ - res, - buffer, - type, - targetId: tab.targetId, - url: tab.url, - }); - return; - } - - let buffer: Buffer; - const shouldUsePlaywright = shouldUsePlaywrightForScreenshot({ - profile: profileCtx.profile, - wsUrl: tab.wsUrl, - ref, - element, - }); - if (shouldUsePlaywright) { - const pw = await requirePwAi(res, "screenshot"); - if (!pw) { - return; - } - const snap = await pw.takeScreenshotViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - ref, - element, - fullPage, - type, - }); - buffer = snap.buffer; - } else { - buffer = await captureScreenshot({ - wsUrl: tab.wsUrl ?? "", - fullPage, - format: type, - quality: type === "jpeg" ? 85 : undefined, - }); - } - - await saveNormalizedScreenshotResponse({ - res, - buffer, - type, - targetId: tab.targetId, - url: tab.url, - }); - }, - }); - }); - - app.get("/snapshot", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) { - return; - } - const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; - const hasPlaywright = Boolean(await getPwAiModule()); - const plan = resolveSnapshotPlan({ - profile: profileCtx.profile, - query: req.query, - hasPlaywright, - }); - - try { - const tab = await profileCtx.ensureTabAvailable(targetId || undefined); - if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") { - return jsonError(res, 400, "labels/mode=efficient require format=ai"); - } - if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { - if (plan.selectorValue || plan.frameSelectorValue) { - return jsonError( - res, - 400, - "selector/frame snapshots are not supported for existing-session profiles; snapshot the whole page and use refs.", - ); - } - const snapshot = await takeChromeMcpSnapshot({ - profileName: profileCtx.profile.name, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - }); - if (plan.format === "aria") { - return res.json({ - ok: true, - format: "aria", - targetId: tab.targetId, - url: tab.url, - nodes: flattenChromeMcpSnapshotToAriaNodes(snapshot, plan.limit), - }); - } - const built = buildAiSnapshotFromChromeMcpSnapshot({ - root: snapshot, - options: { - interactive: plan.interactive ?? undefined, - compact: plan.compact ?? undefined, - maxDepth: plan.depth ?? undefined, - }, - maxChars: plan.resolvedMaxChars, - }); - if (plan.labels) { - const refs = Object.keys(built.refs); - const labelResult = await renderChromeMcpLabels({ - profileName: profileCtx.profile.name, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - refs, - }); - try { - const labeled = await takeChromeMcpScreenshot({ - profileName: profileCtx.profile.name, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - format: "png", - }); - const normalized = await normalizeBrowserScreenshot(labeled, { - maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, - maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, - }); - await ensureMediaDir(); - const saved = await saveMediaBuffer( - normalized.buffer, - normalized.contentType ?? "image/png", - "browser", - DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, - ); - return res.json({ - ok: true, - format: "ai", - targetId: tab.targetId, - url: tab.url, - labels: true, - labelsCount: labelResult.labels, - labelsSkipped: labelResult.skipped, - imagePath: path.resolve(saved.path), - imageType: normalized.contentType?.includes("jpeg") ? "jpeg" : "png", - ...built, - }); - } finally { - await clearChromeMcpOverlay({ - profileName: profileCtx.profile.name, - userDataDir: profileCtx.profile.userDataDir, - targetId: tab.targetId, - }); - } - } - return res.json({ - ok: true, - format: "ai", - targetId: tab.targetId, - url: tab.url, - ...built, - }); - } - if (plan.format === "ai") { - const pw = await requirePwAi(res, "ai snapshot"); - if (!pw) { - return; - } - const roleSnapshotArgs = { - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - selector: plan.selectorValue, - frameSelector: plan.frameSelectorValue, - refsMode: plan.refsMode, - options: { - interactive: plan.interactive ?? undefined, - compact: plan.compact ?? undefined, - maxDepth: plan.depth ?? undefined, - }, - }; - - const snap = plan.wantsRoleSnapshot - ? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs) - : await pw - .snapshotAiViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - ...(typeof plan.resolvedMaxChars === "number" - ? { maxChars: plan.resolvedMaxChars } - : {}), - }) - .catch(async (err) => { - // Public-API fallback when Playwright's private _snapshotForAI is missing. - if (String(err).toLowerCase().includes("_snapshotforai")) { - return await pw.snapshotRoleViaPlaywright(roleSnapshotArgs); - } - throw err; - }); - if (plan.labels) { - const labeled = await pw.screenshotWithLabelsViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - refs: "refs" in snap ? snap.refs : {}, - type: "png", - }); - const normalized = await normalizeBrowserScreenshot(labeled.buffer, { - maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, - maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, - }); - await ensureMediaDir(); - const saved = await saveMediaBuffer( - normalized.buffer, - normalized.contentType ?? "image/png", - "browser", - DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, - ); - const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png"; - return res.json({ - ok: true, - format: plan.format, - targetId: tab.targetId, - url: tab.url, - labels: true, - labelsCount: labeled.labels, - labelsSkipped: labeled.skipped, - imagePath: path.resolve(saved.path), - imageType, - ...snap, - }); - } - - return res.json({ - ok: true, - format: plan.format, - targetId: tab.targetId, - url: tab.url, - ...snap, - }); - } - - const snap = shouldUsePlaywrightForAriaSnapshot({ - profile: profileCtx.profile, - wsUrl: tab.wsUrl, - }) - ? (() => { - // Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session. - // Also covers cases where wsUrl is missing/unusable. - return requirePwAi(res, "aria snapshot").then(async (pw) => { - if (!pw) { - return null; - } - return await pw.snapshotAriaViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - limit: plan.limit, - }); - }); - })() - : snapshotAria({ wsUrl: tab.wsUrl ?? "", limit: plan.limit }); - - const resolved = await Promise.resolve(snap); - if (!resolved) { - return; - } - return res.json({ - ok: true, - format: plan.format, - targetId: tab.targetId, - url: tab.url, - ...resolved, - }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); -} +export * from "../../../extensions/browser/src/browser/routes/agent.snapshot.js"; diff --git a/src/browser/routes/agent.storage.ts b/src/browser/routes/agent.storage.ts index 86830ab27ce..b75a0c8f494 100644 --- a/src/browser/routes/agent.storage.ts +++ b/src/browser/routes/agent.storage.ts @@ -1,451 +1 @@ -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, -): { kind: StorageKind | null; targetId: string | undefined } { - return { - kind: parseStorageKind(toStringOrEmpty(kindParam)), - targetId: resolveTargetIdFromBody(body), - }; -} - -export function parseRequiredStorageMutationRequest( - kindParam: unknown, - body: Record, -): { 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, -) { - 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) - : 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) - : null; - if (!headers) { - return jsonError(res, 400, "headers is required"); - } - - const parsed: Record = {}; - 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 }); - }, - }); - }); -} +export * from "../../../extensions/browser/src/browser/routes/agent.storage.js"; diff --git a/src/browser/routes/agent.ts b/src/browser/routes/agent.ts index dc5e65433ac..816ed3d7c39 100644 --- a/src/browser/routes/agent.ts +++ b/src/browser/routes/agent.ts @@ -1,13 +1 @@ -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); -} +export * from "../../../extensions/browser/src/browser/routes/agent.js"; diff --git a/src/browser/routes/basic.ts b/src/browser/routes/basic.ts index b781bc62694..211de1babf7 100644 --- a/src/browser/routes/basic.ts +++ b/src/browser/routes/basic.ts @@ -1,225 +1 @@ -import { getChromeMcpPid } from "../chrome-mcp.js"; -import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js"; -import { toBrowserErrorResponse } from "../errors.js"; -import { getBrowserProfileCapabilities } from "../profile-capabilities.js"; -import { createBrowserProfilesService } from "../profiles-service.js"; -import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; -import { resolveProfileContext } from "./agent.shared.js"; -import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js"; -import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js"; - -function handleBrowserRouteError(res: BrowserResponse, err: unknown) { - const mapped = toBrowserErrorResponse(err); - if (mapped) { - return jsonError(res, mapped.status, mapped.message); - } - jsonError(res, 500, String(err)); -} - -async function withBasicProfileRoute(params: { - req: BrowserRequest; - res: BrowserResponse; - ctx: BrowserRouteContext; - run: (profileCtx: ProfileContext) => Promise; -}) { - const profileCtx = resolveProfileContext(params.req, params.res, params.ctx); - if (!profileCtx) { - return; - } - try { - await params.run(profileCtx); - } catch (err) { - return handleBrowserRouteError(params.res, err); - } -} - -async function withProfilesServiceMutation(params: { - res: BrowserResponse; - ctx: BrowserRouteContext; - run: (service: ReturnType) => Promise; -}) { - try { - const service = createBrowserProfilesService(params.ctx); - const result = await params.run(service); - params.res.json(result); - } catch (err) { - return handleBrowserRouteError(params.res, err); - } -} - -export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) { - // List all profiles with their status - app.get("/profiles", async (_req, res) => { - try { - const service = createBrowserProfilesService(ctx); - const profiles = await service.listProfiles(); - res.json({ profiles }); - } catch (err) { - jsonError(res, 500, String(err)); - } - }); - - // Get status (profile-aware) - app.get("/", async (req, res) => { - let current: ReturnType; - try { - current = ctx.state(); - } catch { - return jsonError(res, 503, "browser server not started"); - } - - const profileCtx = getProfileContext(req, ctx); - if ("error" in profileCtx) { - return jsonError(res, profileCtx.status, profileCtx.error); - } - - try { - const [cdpHttp, cdpReady] = await Promise.all([ - profileCtx.isHttpReachable(300), - profileCtx.isReachable(600), - ]); - - const profileState = current.profiles.get(profileCtx.profile.name); - const capabilities = getBrowserProfileCapabilities(profileCtx.profile); - let detectedBrowser: string | null = null; - let detectedExecutablePath: string | null = null; - let detectError: string | null = null; - - try { - const detected = resolveBrowserExecutableForPlatform(current.resolved, process.platform); - if (detected) { - detectedBrowser = detected.kind; - detectedExecutablePath = detected.path; - } - } catch (err) { - detectError = String(err); - } - - res.json({ - enabled: current.resolved.enabled, - profile: profileCtx.profile.name, - driver: profileCtx.profile.driver, - transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp", - running: cdpReady, - cdpReady, - cdpHttp, - pid: capabilities.usesChromeMcp - ? getChromeMcpPid(profileCtx.profile.name) - : (profileState?.running?.pid ?? null), - cdpPort: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpPort, - cdpUrl: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpUrl, - chosenBrowser: profileState?.running?.exe.kind ?? null, - detectedBrowser, - detectedExecutablePath, - detectError, - userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null, - color: profileCtx.profile.color, - headless: current.resolved.headless, - noSandbox: current.resolved.noSandbox, - executablePath: current.resolved.executablePath ?? null, - attachOnly: profileCtx.profile.attachOnly, - }); - } catch (err) { - const mapped = toBrowserErrorResponse(err); - if (mapped) { - return jsonError(res, mapped.status, mapped.message); - } - jsonError(res, 500, String(err)); - } - }); - - // Start browser (profile-aware) - app.post("/start", async (req, res) => { - await withBasicProfileRoute({ - req, - res, - ctx, - run: async (profileCtx) => { - await profileCtx.ensureBrowserAvailable(); - res.json({ ok: true, profile: profileCtx.profile.name }); - }, - }); - }); - - // Stop browser (profile-aware) - app.post("/stop", async (req, res) => { - await withBasicProfileRoute({ - req, - res, - ctx, - run: async (profileCtx) => { - const result = await profileCtx.stopRunningBrowser(); - res.json({ - ok: true, - stopped: result.stopped, - profile: profileCtx.profile.name, - }); - }, - }); - }); - - // Reset profile (profile-aware) - app.post("/reset-profile", async (req, res) => { - await withBasicProfileRoute({ - req, - res, - ctx, - run: async (profileCtx) => { - const result = await profileCtx.resetProfile(); - res.json({ ok: true, profile: profileCtx.profile.name, ...result }); - }, - }); - }); - - // Create a new profile - app.post("/profiles/create", async (req, res) => { - const name = toStringOrEmpty((req.body as { name?: unknown })?.name); - const color = toStringOrEmpty((req.body as { color?: unknown })?.color); - const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl); - const userDataDir = toStringOrEmpty((req.body as { userDataDir?: unknown })?.userDataDir); - const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver); - - if (!name) { - return jsonError(res, 400, "name is required"); - } - if (driver && driver !== "openclaw" && driver !== "clawd" && driver !== "existing-session") { - return jsonError( - res, - 400, - `unsupported profile driver "${driver}"; use "openclaw", "clawd", or "existing-session"`, - ); - } - - await withProfilesServiceMutation({ - res, - ctx, - run: async (service) => - await service.createProfile({ - name, - color: color || undefined, - cdpUrl: cdpUrl || undefined, - userDataDir: userDataDir || undefined, - driver: - driver === "existing-session" - ? "existing-session" - : driver === "openclaw" || driver === "clawd" - ? "openclaw" - : undefined, - }), - }); - }); - - // Delete a profile - app.delete("/profiles/:name", async (req, res) => { - const name = toStringOrEmpty(req.params.name); - if (!name) { - return jsonError(res, 400, "profile name is required"); - } - - await withProfilesServiceMutation({ - res, - ctx, - run: async (service) => await service.deleteProfile(name), - }); - }); -} +export * from "../../../extensions/browser/src/browser/routes/basic.js"; diff --git a/src/browser/routes/dispatcher.ts b/src/browser/routes/dispatcher.ts index 3fe24e11041..46f6f6e9d29 100644 --- a/src/browser/routes/dispatcher.ts +++ b/src/browser/routes/dispatcher.ts @@ -1,133 +1 @@ -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; - 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; -}; - -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 => { - 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 = {}; - 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 }; +export * from "../../../extensions/browser/src/browser/routes/dispatcher.js"; diff --git a/src/browser/routes/index.ts b/src/browser/routes/index.ts index 3c20ef1c646..631fc5993d9 100644 --- a/src/browser/routes/index.ts +++ b/src/browser/routes/index.ts @@ -1,11 +1 @@ -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); -} +export * from "../../../extensions/browser/src/browser/routes/index.js"; diff --git a/src/browser/routes/output-paths.ts b/src/browser/routes/output-paths.ts index 4a11d3dc816..34b43eaae85 100644 --- a/src/browser/routes/output-paths.ts +++ b/src/browser/routes/output-paths.ts @@ -1,31 +1 @@ -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 { - await fs.mkdir(rootDir, { recursive: true }); -} - -export async function resolveWritableOutputPathOrRespond(params: { - res: BrowserResponse; - rootDir: string; - requestedPath: string; - scopeLabel: string; - defaultFileName?: string; - ensureRootDir?: boolean; -}): Promise { - 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; -} +export * from "../../../extensions/browser/src/browser/routes/output-paths.js"; diff --git a/src/browser/routes/path-output.ts b/src/browser/routes/path-output.ts index e23da97e1b2..db9c26a1f1d 100644 --- a/src/browser/routes/path-output.ts +++ b/src/browser/routes/path-output.ts @@ -1 +1 @@ -export * from "../paths.js"; +export * from "../../../extensions/browser/src/browser/routes/path-output.js"; diff --git a/src/browser/routes/tabs.ts b/src/browser/routes/tabs.ts index d15838787b3..a302625e27a 100644 --- a/src/browser/routes/tabs.ts +++ b/src/browser/routes/tabs.ts @@ -1,230 +1 @@ -import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "../errors.js"; -import { - assertBrowserNavigationAllowed, - withBrowserNavigationPolicy, -} from "../navigation-guard.js"; -import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; -import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js"; -import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js"; - -function resolveTabsProfileContext( - req: BrowserRequest, - res: BrowserResponse, - ctx: BrowserRouteContext, -) { - const profileCtx = getProfileContext(req, ctx); - if ("error" in profileCtx) { - jsonError(res, profileCtx.status, profileCtx.error); - return null; - } - return profileCtx; -} - -function handleTabsRouteError( - ctx: BrowserRouteContext, - res: BrowserResponse, - err: unknown, - opts?: { mapTabError?: boolean }, -) { - if (opts?.mapTabError) { - const mapped = ctx.mapTabError(err); - if (mapped) { - return jsonError(res, mapped.status, mapped.message); - } - } - return jsonError(res, 500, String(err)); -} - -async function withTabsProfileRoute(params: { - req: BrowserRequest; - res: BrowserResponse; - ctx: BrowserRouteContext; - mapTabError?: boolean; - run: (profileCtx: ProfileContext) => Promise; -}) { - const profileCtx = resolveTabsProfileContext(params.req, params.res, params.ctx); - if (!profileCtx) { - return; - } - try { - await params.run(profileCtx); - } catch (err) { - handleTabsRouteError(params.ctx, params.res, err, { mapTabError: params.mapTabError }); - } -} - -async function ensureBrowserRunning(profileCtx: ProfileContext, res: BrowserResponse) { - if (!(await profileCtx.isReachable(300))) { - jsonError( - res, - new BrowserProfileUnavailableError("browser not running").status, - "browser not running", - ); - return false; - } - return true; -} - -function resolveIndexedTab( - tabs: Awaited>, - 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; -}) { - await withTabsProfileRoute({ - req: params.req, - res: params.res, - ctx: params.ctx, - mapTabError: true, - run: async (profileCtx) => { - if (!(await ensureBrowserRunning(profileCtx, params.res))) { - return; - } - await params.mutate(profileCtx, params.targetId); - params.res.json({ ok: true }); - }, - }); -} - -export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) { - app.get("/tabs", async (req, res) => { - await withTabsProfileRoute({ - req, - res, - ctx, - run: async (profileCtx) => { - const reachable = await profileCtx.isReachable(300); - if (!reachable) { - return res.json({ running: false, tabs: [] as unknown[] }); - } - const tabs = await profileCtx.listTabs(); - res.json({ running: true, tabs }); - }, - }); - }); - - app.post("/tabs/open", async (req, res) => { - const url = toStringOrEmpty((req.body as { url?: unknown })?.url); - if (!url) { - return jsonError(res, 400, "url is required"); - } - - await withTabsProfileRoute({ - req, - res, - ctx, - mapTabError: true, - run: async (profileCtx) => { - await assertBrowserNavigationAllowed({ - url, - ...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy), - }); - await profileCtx.ensureBrowserAvailable(); - const tab = await profileCtx.openTab(url); - res.json(tab); - }, - }); - }); - - app.post("/tabs/focus", async (req, res) => { - const targetId = parseRequiredTargetId(res, (req.body as { targetId?: unknown })?.targetId); - if (!targetId) { - return; - } - await runTabTargetMutation({ - req, - res, - ctx, - targetId, - mutate: async (profileCtx, id) => { - await profileCtx.focusTab(id); - }, - }); - }); - - app.delete("/tabs/:targetId", async (req, res) => { - const targetId = parseRequiredTargetId(res, req.params.targetId); - if (!targetId) { - return; - } - await runTabTargetMutation({ - req, - res, - ctx, - targetId, - mutate: async (profileCtx, id) => { - await profileCtx.closeTab(id); - }, - }); - }); - - app.post("/tabs/action", async (req, res) => { - const action = toStringOrEmpty((req.body as { action?: unknown })?.action); - const index = toNumber((req.body as { index?: unknown })?.index); - - await withTabsProfileRoute({ - req, - res, - ctx, - mapTabError: true, - run: async (profileCtx) => { - if (action === "list") { - const reachable = await profileCtx.isReachable(300); - if (!reachable) { - return res.json({ ok: true, tabs: [] as unknown[] }); - } - const tabs = await profileCtx.listTabs(); - return res.json({ ok: true, tabs }); - } - - if (action === "new") { - await profileCtx.ensureBrowserAvailable(); - const tab = await profileCtx.openTab("about:blank"); - return res.json({ ok: true, tab }); - } - - if (action === "close") { - const tabs = await profileCtx.listTabs(); - const target = resolveIndexedTab(tabs, index); - if (!target) { - throw new BrowserTabNotFoundError(); - } - await profileCtx.closeTab(target.targetId); - return res.json({ ok: true, targetId: target.targetId }); - } - - if (action === "select") { - if (typeof index !== "number") { - return jsonError(res, 400, "index is required"); - } - const tabs = await profileCtx.listTabs(); - const target = tabs[index]; - if (!target) { - throw new BrowserTabNotFoundError(); - } - await profileCtx.focusTab(target.targetId); - return res.json({ ok: true, targetId: target.targetId }); - } - - return jsonError(res, 400, "unknown tab action"); - }, - }); - }); -} +export * from "../../../extensions/browser/src/browser/routes/tabs.js"; diff --git a/src/browser/routes/test-helpers.ts b/src/browser/routes/test-helpers.ts index e6b046a9878..fc27d64dfc5 100644 --- a/src/browser/routes/test-helpers.ts +++ b/src/browser/routes/test-helpers.ts @@ -1,36 +1 @@ -import type { BrowserResponse, BrowserRouteHandler, BrowserRouteRegistrar } from "./types.js"; - -export function createBrowserRouteApp() { - const getHandlers = new Map(); - const postHandlers = new Map(); - const deleteHandlers = new Map(); - const app: BrowserRouteRegistrar = { - get: (path, handler) => void getHandlers.set(path, handler), - post: (path, handler) => void postHandlers.set(path, handler), - delete: (path, handler) => void deleteHandlers.set(path, handler), - }; - return { app, getHandlers, postHandlers, deleteHandlers }; -} - -export function createBrowserRouteResponse() { - let statusCode = 200; - let jsonBody: unknown; - const res: BrowserResponse = { - status(code) { - statusCode = code; - return res; - }, - json(body) { - jsonBody = body; - }, - }; - return { - res, - get statusCode() { - return statusCode; - }, - get body() { - return jsonBody; - }, - }; -} +export * from "../../../extensions/browser/src/browser/routes/test-helpers.js"; diff --git a/src/browser/routes/types.ts b/src/browser/routes/types.ts index 97d5ff470a7..b8136a5ff60 100644 --- a/src/browser/routes/types.ts +++ b/src/browser/routes/types.ts @@ -1,26 +1 @@ -export type BrowserRequest = { - params: Record; - query: Record; - 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; - -export type BrowserRouteRegistrar = { - get: (path: string, handler: BrowserRouteHandler) => void; - post: (path: string, handler: BrowserRouteHandler) => void; - delete: (path: string, handler: BrowserRouteHandler) => void; -}; +export * from "../../../extensions/browser/src/browser/routes/types.js"; diff --git a/src/browser/routes/utils.ts b/src/browser/routes/utils.ts index 1c7eeb38c89..3f255a30c1d 100644 --- a/src/browser/routes/utils.ts +++ b/src/browser/routes/utils.ts @@ -1,73 +1 @@ -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; - 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; -} +export * from "../../../extensions/browser/src/browser/routes/utils.js"; diff --git a/src/browser/runtime-lifecycle.ts b/src/browser/runtime-lifecycle.ts index 7b181faea6e..82d629d3e72 100644 --- a/src/browser/runtime-lifecycle.ts +++ b/src/browser/runtime-lifecycle.ts @@ -1,60 +1 @@ -import type { Server } from "node:http"; -import { isPwAiLoaded } from "./pw-ai-state.js"; -import type { BrowserServerState } from "./server-context.js"; -import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js"; - -export async function createBrowserRuntimeState(params: { - resolved: BrowserServerState["resolved"]; - port: number; - server?: Server | null; - onWarn: (message: string) => void; -}): Promise { - const state: BrowserServerState = { - server: params.server ?? null, - port: params.port, - resolved: params.resolved, - profiles: new Map(), - }; - - await ensureExtensionRelayForProfiles({ - resolved: params.resolved, - onWarn: params.onWarn, - }); - - return state; -} - -export async function stopBrowserRuntime(params: { - current: BrowserServerState | null; - getState: () => BrowserServerState | null; - clearState: () => void; - closeServer?: boolean; - onWarn: (message: string) => void; -}): Promise { - if (!params.current) { - return; - } - - await stopKnownBrowserProfiles({ - getState: params.getState, - onWarn: params.onWarn, - }); - - if (params.closeServer && params.current.server) { - await new Promise((resolve) => { - params.current?.server?.close(() => resolve()); - }); - } - - params.clearState(); - - if (!isPwAiLoaded()) { - return; - } - try { - const mod = await import("./pw-ai.js"); - await mod.closePlaywrightBrowserConnection(); - } catch { - // ignore - } -} +export * from "../../extensions/browser/src/browser/runtime-lifecycle.js"; diff --git a/src/browser/safe-filename.ts b/src/browser/safe-filename.ts index 1508d528eaf..9f2a443e412 100644 --- a/src/browser/safe-filename.ts +++ b/src/browser/safe-filename.ts @@ -1,26 +1 @@ -import path from "node:path"; - -export function sanitizeUntrustedFileName(fileName: string, fallbackName: string): string { - const trimmed = String(fileName ?? "").trim(); - if (!trimmed) { - return fallbackName; - } - let base = path.posix.basename(trimmed); - base = path.win32.basename(base); - let cleaned = ""; - for (let i = 0; i < base.length; i++) { - const code = base.charCodeAt(i); - if (code < 0x20 || code === 0x7f) { - continue; - } - cleaned += base[i]; - } - base = cleaned.trim(); - if (!base || base === "." || base === "..") { - return fallbackName; - } - if (base.length > 200) { - base = base.slice(0, 200); - } - return base; -} +export * from "../../extensions/browser/src/browser/safe-filename.js"; diff --git a/src/browser/screenshot.ts b/src/browser/screenshot.ts index 35d39a354ed..8de8cfab46e 100644 --- a/src/browser/screenshot.ts +++ b/src/browser/screenshot.ts @@ -1,58 +1 @@ -import { - buildImageResizeSideGrid, - getImageMetadata, - IMAGE_REDUCE_QUALITY_STEPS, - resizeToJpeg, -} from "../media/image-ops.js"; - -export const DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE = 2000; -export const DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES = 5 * 1024 * 1024; - -export async function normalizeBrowserScreenshot( - buffer: Buffer, - opts?: { - maxSide?: number; - maxBytes?: number; - }, -): Promise<{ buffer: Buffer; contentType?: "image/jpeg" }> { - const maxSide = Math.max(1, Math.round(opts?.maxSide ?? DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE)); - const maxBytes = Math.max(1, Math.round(opts?.maxBytes ?? DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES)); - - const meta = await getImageMetadata(buffer); - const width = Number(meta?.width ?? 0); - const height = Number(meta?.height ?? 0); - const maxDim = Math.max(width, height); - - if (buffer.byteLength <= maxBytes && (maxDim === 0 || (width <= maxSide && height <= maxSide))) { - return { buffer }; - } - - const sideStart = maxDim > 0 ? Math.min(maxSide, maxDim) : maxSide; - const sideGrid = buildImageResizeSideGrid(maxSide, sideStart); - - let smallest: { buffer: Buffer; size: number } | null = null; - - for (const side of sideGrid) { - for (const quality of IMAGE_REDUCE_QUALITY_STEPS) { - const out = await resizeToJpeg({ - buffer, - maxSide: side, - quality, - withoutEnlargement: true, - }); - - if (!smallest || out.byteLength < smallest.size) { - smallest = { buffer: out, size: out.byteLength }; - } - - if (out.byteLength <= maxBytes) { - return { buffer: out, contentType: "image/jpeg" }; - } - } - } - - const best = smallest?.buffer ?? buffer; - throw new Error( - `Browser screenshot could not be reduced below ${(maxBytes / (1024 * 1024)).toFixed(0)}MB (got ${(best.byteLength / (1024 * 1024)).toFixed(2)}MB)`, - ); -} +export * from "../../extensions/browser/src/browser/screenshot.js"; diff --git a/src/browser/server-context.availability.ts b/src/browser/server-context.availability.ts index 783dbb9e782..123ff18fdca 100644 --- a/src/browser/server-context.availability.ts +++ b/src/browser/server-context.availability.ts @@ -1,293 +1 @@ -import fs from "node:fs"; -import { - CHROME_MCP_ATTACH_READY_POLL_MS, - CHROME_MCP_ATTACH_READY_WINDOW_MS, - PROFILE_ATTACH_RETRY_TIMEOUT_MS, - PROFILE_POST_RESTART_WS_TIMEOUT_MS, - resolveCdpReachabilityTimeouts, -} from "./cdp-timeouts.js"; -import { - closeChromeMcpSession, - ensureChromeMcpAvailable, - listChromeMcpTabs, -} from "./chrome-mcp.js"; -import { - isChromeCdpReady, - isChromeReachable, - launchOpenClawChrome, - stopOpenClawChrome, -} from "./chrome.js"; -import type { ResolvedBrowserProfile } from "./config.js"; -import { BrowserProfileUnavailableError } from "./errors.js"; -import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; -import { - CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS, - CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS, - CDP_READY_AFTER_LAUNCH_POLL_MS, - CDP_READY_AFTER_LAUNCH_WINDOW_MS, -} from "./server-context.constants.js"; -import type { - BrowserServerState, - ContextOptions, - ProfileRuntimeState, -} from "./server-context.types.js"; - -type AvailabilityDeps = { - opts: ContextOptions; - profile: ResolvedBrowserProfile; - state: () => BrowserServerState; - getProfileState: () => ProfileRuntimeState; - setProfileRunning: (running: ProfileRuntimeState["running"]) => void; -}; - -type AvailabilityOps = { - isHttpReachable: (timeoutMs?: number) => Promise; - isReachable: (timeoutMs?: number) => Promise; - ensureBrowserAvailable: () => Promise; - stopRunningBrowser: () => Promise<{ stopped: boolean }>; -}; - -export function createProfileAvailability({ - opts, - profile, - state, - getProfileState, - setProfileRunning, -}: AvailabilityDeps): AvailabilityOps { - const capabilities = getBrowserProfileCapabilities(profile); - const resolveTimeouts = (timeoutMs: number | undefined) => - resolveCdpReachabilityTimeouts({ - profileIsLoopback: profile.cdpIsLoopback, - timeoutMs, - remoteHttpTimeoutMs: state().resolved.remoteCdpTimeoutMs, - remoteHandshakeTimeoutMs: state().resolved.remoteCdpHandshakeTimeoutMs, - }); - - const isReachable = async (timeoutMs?: number) => { - if (capabilities.usesChromeMcp) { - // listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required - await listChromeMcpTabs(profile.name, profile.userDataDir); - return true; - } - const { httpTimeoutMs, wsTimeoutMs } = resolveTimeouts(timeoutMs); - return await isChromeCdpReady( - profile.cdpUrl, - httpTimeoutMs, - wsTimeoutMs, - state().resolved.ssrfPolicy, - ); - }; - - const isHttpReachable = async (timeoutMs?: number) => { - if (capabilities.usesChromeMcp) { - return await isReachable(timeoutMs); - } - const { httpTimeoutMs } = resolveTimeouts(timeoutMs); - return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, state().resolved.ssrfPolicy); - }; - - const attachRunning = (running: NonNullable) => { - setProfileRunning(running); - running.proc.on("exit", () => { - // Guard against server teardown (e.g., SIGUSR1 restart) - if (!opts.getState()) { - return; - } - const profileState = getProfileState(); - if (profileState.running?.pid === running.pid) { - setProfileRunning(null); - } - }); - }; - - const closePlaywrightBrowserConnectionForProfile = async (cdpUrl?: string): Promise => { - try { - const mod = await import("./pw-ai.js"); - await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined); - } catch { - // ignore - } - }; - - const reconcileProfileRuntime = async (): Promise => { - const profileState = getProfileState(); - const reconcile = profileState.reconcile; - if (!reconcile) { - return; - } - profileState.reconcile = null; - profileState.lastTargetId = null; - - const previousProfile = reconcile.previousProfile; - if (profileState.running) { - await stopOpenClawChrome(profileState.running).catch(() => {}); - setProfileRunning(null); - } - if (getBrowserProfileCapabilities(previousProfile).usesChromeMcp) { - await closeChromeMcpSession(previousProfile.name).catch(() => false); - } - await closePlaywrightBrowserConnectionForProfile(previousProfile.cdpUrl); - if (previousProfile.cdpUrl !== profile.cdpUrl) { - await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl); - } - }; - - const waitForCdpReadyAfterLaunch = async (): Promise => { - // launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS. - // If a follow-up call races ahead, we can hit PortInUseError trying to launch again on the same port. - const deadlineMs = Date.now() + CDP_READY_AFTER_LAUNCH_WINDOW_MS; - while (Date.now() < deadlineMs) { - const remainingMs = Math.max(0, deadlineMs - Date.now()); - // Keep each attempt short; loopback profiles derive a WS timeout from this value. - const attemptTimeoutMs = Math.max( - CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS, - Math.min(CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS, remainingMs), - ); - if (await isReachable(attemptTimeoutMs)) { - return; - } - await new Promise((r) => setTimeout(r, CDP_READY_AFTER_LAUNCH_POLL_MS)); - } - throw new Error( - `Chrome CDP websocket for profile "${profile.name}" is not reachable after start.`, - ); - }; - - const waitForChromeMcpReadyAfterAttach = async (): Promise => { - const deadlineMs = Date.now() + CHROME_MCP_ATTACH_READY_WINDOW_MS; - let lastError: unknown; - while (Date.now() < deadlineMs) { - try { - await listChromeMcpTabs(profile.name, profile.userDataDir); - return; - } catch (err) { - lastError = err; - } - await new Promise((r) => setTimeout(r, CHROME_MCP_ATTACH_READY_POLL_MS)); - } - const detail = lastError instanceof Error ? ` Last error: ${lastError.message}` : ""; - throw new BrowserProfileUnavailableError( - `Chrome MCP existing-session attach for profile "${profile.name}" timed out waiting for tabs to become available.` + - ` Approve the browser attach prompt, keep the browser open, and retry.${detail}`, - ); - }; - - const ensureBrowserAvailable = async (): Promise => { - await reconcileProfileRuntime(); - if (capabilities.usesChromeMcp) { - if (profile.userDataDir && !fs.existsSync(profile.userDataDir)) { - throw new BrowserProfileUnavailableError( - `Browser user data directory not found for profile "${profile.name}": ${profile.userDataDir}`, - ); - } - await ensureChromeMcpAvailable(profile.name, profile.userDataDir); - await waitForChromeMcpReadyAfterAttach(); - return; - } - const current = state(); - const remoteCdp = capabilities.isRemote; - const attachOnly = profile.attachOnly; - const profileState = getProfileState(); - const httpReachable = await isHttpReachable(); - - if (!httpReachable) { - if ((attachOnly || remoteCdp) && opts.onEnsureAttachTarget) { - await opts.onEnsureAttachTarget(profile); - if (await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS)) { - return; - } - } - // Browser control service can restart while a loopback OpenClaw browser is still - // alive. Give that pre-existing browser one longer probe window before falling - // back to local executable resolution. - if (!attachOnly && !remoteCdp && profile.cdpIsLoopback && !profileState.running) { - if ( - (await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS)) && - (await isReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS)) - ) { - return; - } - } - if (attachOnly || remoteCdp) { - throw new BrowserProfileUnavailableError( - remoteCdp - ? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.` - : `Browser attachOnly is enabled and profile "${profile.name}" is not running.`, - ); - } - const launched = await launchOpenClawChrome(current.resolved, profile); - attachRunning(launched); - try { - await waitForCdpReadyAfterLaunch(); - } catch (err) { - await stopOpenClawChrome(launched).catch(() => {}); - setProfileRunning(null); - throw err; - } - return; - } - - // Port is reachable - check if we own it. - if (await isReachable()) { - return; - } - - // HTTP responds but WebSocket fails. For attachOnly/remote profiles, never perform - // local ownership/restart handling; just run attach retries and surface attach errors. - if (attachOnly || remoteCdp) { - if (opts.onEnsureAttachTarget) { - await opts.onEnsureAttachTarget(profile); - if (await isReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS)) { - return; - } - } - throw new BrowserProfileUnavailableError( - remoteCdp - ? `Remote CDP websocket for profile "${profile.name}" is not reachable.` - : `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`, - ); - } - - // HTTP responds but WebSocket fails - port in use by something else. - if (!profileState.running) { - throw new BrowserProfileUnavailableError( - `Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. ` + - `Run action=reset-profile profile=${profile.name} to kill the process.`, - ); - } - - await stopOpenClawChrome(profileState.running); - setProfileRunning(null); - - const relaunched = await launchOpenClawChrome(current.resolved, profile); - attachRunning(relaunched); - - if (!(await isReachable(PROFILE_POST_RESTART_WS_TIMEOUT_MS))) { - throw new Error( - `Chrome CDP websocket for profile "${profile.name}" is not reachable after restart.`, - ); - } - }; - - const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => { - await reconcileProfileRuntime(); - if (capabilities.usesChromeMcp) { - const stopped = await closeChromeMcpSession(profile.name); - return { stopped }; - } - const profileState = getProfileState(); - if (!profileState.running) { - return { stopped: false }; - } - await stopOpenClawChrome(profileState.running); - setProfileRunning(null); - return { stopped: true }; - }; - - return { - isHttpReachable, - isReachable, - ensureBrowserAvailable, - stopRunningBrowser, - }; -} +export * from "../../extensions/browser/src/browser/server-context.availability.js"; diff --git a/src/browser/server-context.chrome-test-harness.ts b/src/browser/server-context.chrome-test-harness.ts index 95ebe8097e6..ee00500d4d3 100644 --- a/src/browser/server-context.chrome-test-harness.ts +++ b/src/browser/server-context.chrome-test-harness.ts @@ -1,15 +1 @@ -import { vi } from "vitest"; -import { installChromeUserDataDirHooks } from "./chrome-user-data-dir.test-harness.js"; - -const chromeUserDataDir = { dir: "/tmp/openclaw" }; -installChromeUserDataDirHooks(chromeUserDataDir); - -vi.mock("./chrome.js", () => ({ - isChromeCdpReady: vi.fn(async () => true), - isChromeReachable: vi.fn(async () => true), - launchOpenClawChrome: vi.fn(async () => { - throw new Error("unexpected launch"); - }), - resolveOpenClawUserDataDir: vi.fn(() => chromeUserDataDir.dir), - stopOpenClawChrome: vi.fn(async () => {}), -})); +export * from "../../extensions/browser/src/browser/server-context.chrome-test-harness.js"; diff --git a/src/browser/server-context.constants.ts b/src/browser/server-context.constants.ts index 9026aba537f..e90b8f525c9 100644 --- a/src/browser/server-context.constants.ts +++ b/src/browser/server-context.constants.ts @@ -1,9 +1 @@ -export const MANAGED_BROWSER_PAGE_TAB_LIMIT = 8; - -export const OPEN_TAB_DISCOVERY_WINDOW_MS = 2000; -export const OPEN_TAB_DISCOVERY_POLL_MS = 100; - -export const CDP_READY_AFTER_LAUNCH_WINDOW_MS = 8000; -export const CDP_READY_AFTER_LAUNCH_POLL_MS = 100; -export const CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS = 75; -export const CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS = 250; +export * from "../../extensions/browser/src/browser/server-context.constants.js"; diff --git a/src/browser/server-context.remote-tab-ops.harness.ts b/src/browser/server-context.remote-tab-ops.harness.ts index c5f65a4ce2a..3059f0b1a7c 100644 --- a/src/browser/server-context.remote-tab-ops.harness.ts +++ b/src/browser/server-context.remote-tab-ops.harness.ts @@ -1,107 +1 @@ -import { vi } from "vitest"; -import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; -import type { BrowserServerState } from "./server-context.js"; -import { createBrowserRouteContext } from "./server-context.js"; - -export const originalFetch = globalThis.fetch; - -export function makeState( - profile: "remote" | "openclaw", -): BrowserServerState & { profiles: Map } { - return { - // oxlint-disable-next-line typescript/no-explicit-any - server: null as any, - port: 0, - resolved: { - enabled: true, - controlPort: 18791, - cdpPortRangeStart: 18800, - cdpPortRangeEnd: 18899, - cdpProtocol: profile === "remote" ? "https" : "http", - cdpHost: profile === "remote" ? "browserless.example" : "127.0.0.1", - cdpIsLoopback: profile !== "remote", - remoteCdpTimeoutMs: 1500, - remoteCdpHandshakeTimeoutMs: 3000, - evaluateEnabled: false, - extraArgs: [], - color: "#FF4500", - headless: true, - noSandbox: false, - attachOnly: false, - ssrfPolicy: { allowPrivateNetwork: true }, - defaultProfile: profile, - profiles: { - remote: { - cdpUrl: "https://browserless.example/chrome?token=abc", - cdpPort: 443, - color: "#00AA00", - }, - openclaw: { cdpPort: 18800, color: "#FF4500" }, - }, - }, - profiles: new Map(), - }; -} - -export function makeUnexpectedFetchMock() { - return vi.fn(async () => { - throw new Error("unexpected fetch"); - }); -} - -export function createRemoteRouteHarness(fetchMock?: (url: unknown) => Promise) { - const activeFetchMock = fetchMock ?? makeUnexpectedFetchMock(); - global.fetch = withFetchPreconnect(activeFetchMock); - const state = makeState("remote"); - const ctx = createBrowserRouteContext({ getState: () => state }); - return { state, remote: ctx.forProfile("remote"), fetchMock: activeFetchMock }; -} - -export function createSequentialPageLister(responses: T[]) { - return async () => { - const next = responses.shift(); - if (!next) { - throw new Error("no more responses"); - } - return next; - }; -} - -type JsonListEntry = { - id: string; - title: string; - url: string; - webSocketDebuggerUrl: string; - type: "page"; -}; - -export function createJsonListFetchMock(entries: JsonListEntry[]) { - return async (url: unknown) => { - const u = String(url); - if (!u.includes("/json/list")) { - throw new Error(`unexpected fetch: ${u}`); - } - return { - ok: true, - json: async () => entries, - } as unknown as Response; - }; -} - -function makeManagedTab(id: string, ordinal: number): JsonListEntry { - return { - id, - title: String(ordinal), - url: `http://127.0.0.1:300${ordinal}`, - webSocketDebuggerUrl: `ws://127.0.0.1/devtools/page/${id}`, - type: "page", - }; -} - -export function makeManagedTabsWithNew(params?: { newFirst?: boolean }): JsonListEntry[] { - const oldTabs = Array.from({ length: 8 }, (_, index) => - makeManagedTab(`OLD${index + 1}`, index + 1), - ); - const newTab = makeManagedTab("NEW", 9); - return params?.newFirst ? [newTab, ...oldTabs] : [...oldTabs, newTab]; -} +export * from "../../extensions/browser/src/browser/server-context.remote-tab-ops.harness.js"; diff --git a/src/browser/server-context.reset.ts b/src/browser/server-context.reset.ts index ea478f56f31..c3217686b21 100644 --- a/src/browser/server-context.reset.ts +++ b/src/browser/server-context.reset.ts @@ -1,67 +1 @@ -import fs from "node:fs"; -import type { ResolvedBrowserProfile } from "./config.js"; -import { BrowserResetUnsupportedError } from "./errors.js"; -import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; -import type { ProfileRuntimeState } from "./server-context.types.js"; -import { movePathToTrash } from "./trash.js"; - -type ResetDeps = { - profile: ResolvedBrowserProfile; - getProfileState: () => ProfileRuntimeState; - stopRunningBrowser: () => Promise<{ stopped: boolean }>; - isHttpReachable: (timeoutMs?: number) => Promise; - resolveOpenClawUserDataDir: (profileName: string) => string; -}; - -type ResetOps = { - resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>; -}; - -async function closePlaywrightBrowserConnectionForProfile(cdpUrl?: string): Promise { - try { - const mod = await import("./pw-ai.js"); - await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined); - } catch { - // ignore - } -} - -export function createProfileResetOps({ - profile, - getProfileState, - stopRunningBrowser, - isHttpReachable, - resolveOpenClawUserDataDir, -}: ResetDeps): ResetOps { - const capabilities = getBrowserProfileCapabilities(profile); - const resetProfile = async () => { - if (!capabilities.supportsReset) { - throw new BrowserResetUnsupportedError( - `reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`, - ); - } - - const userDataDir = resolveOpenClawUserDataDir(profile.name); - const profileState = getProfileState(); - const httpReachable = await isHttpReachable(300); - if (httpReachable && !profileState.running) { - // Port in use but not by us - kill it. - await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl); - } - - if (profileState.running) { - await stopRunningBrowser(); - } - - await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl); - - if (!fs.existsSync(userDataDir)) { - return { moved: false, from: userDataDir }; - } - - const moved = await movePathToTrash(userDataDir); - return { moved: true, from: userDataDir, to: moved }; - }; - - return { resetProfile }; -} +export * from "../../extensions/browser/src/browser/server-context.reset.js"; diff --git a/src/browser/server-context.selection.ts b/src/browser/server-context.selection.ts index 24248cebfd8..a2bbceddd0d 100644 --- a/src/browser/server-context.selection.ts +++ b/src/browser/server-context.selection.ts @@ -1,153 +1 @@ -import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js"; -import { appendCdpPath } from "./cdp.js"; -import { closeChromeMcpTab, focusChromeMcpTab } from "./chrome-mcp.js"; -import type { ResolvedBrowserProfile } from "./config.js"; -import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.js"; -import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; -import type { PwAiModule } from "./pw-ai-module.js"; -import { getPwAiModule } from "./pw-ai-module.js"; -import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js"; -import { resolveTargetIdFromTabs } from "./target-id.js"; - -type SelectionDeps = { - profile: ResolvedBrowserProfile; - getProfileState: () => ProfileRuntimeState; - ensureBrowserAvailable: () => Promise; - listTabs: () => Promise; - openTab: (url: string) => Promise; -}; - -type SelectionOps = { - ensureTabAvailable: (targetId?: string) => Promise; - focusTab: (targetId: string) => Promise; - closeTab: (targetId: string) => Promise; -}; - -export function createProfileSelectionOps({ - profile, - getProfileState, - ensureBrowserAvailable, - listTabs, - openTab, -}: SelectionDeps): SelectionOps { - const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl); - const capabilities = getBrowserProfileCapabilities(profile); - - const ensureTabAvailable = async (targetId?: string): Promise => { - await ensureBrowserAvailable(); - const profileState = getProfileState(); - const tabs1 = await listTabs(); - if (tabs1.length === 0) { - await openTab("about:blank"); - } - - const tabs = await listTabs(); - const candidates = capabilities.supportsPerTabWs ? tabs.filter((t) => Boolean(t.wsUrl)) : tabs; - - const resolveById = (raw: string) => { - const resolved = resolveTargetIdFromTabs(raw, candidates); - if (!resolved.ok) { - if (resolved.reason === "ambiguous") { - return "AMBIGUOUS" as const; - } - return null; - } - return candidates.find((t) => t.targetId === resolved.targetId) ?? null; - }; - - const pickDefault = () => { - const last = profileState.lastTargetId?.trim() || ""; - const lastResolved = last ? resolveById(last) : null; - if (lastResolved && lastResolved !== "AMBIGUOUS") { - return lastResolved; - } - // Prefer a real page tab first (avoid service workers/background targets). - const page = candidates.find((t) => (t.type ?? "page") === "page"); - return page ?? candidates.at(0) ?? null; - }; - - const chosen = targetId ? resolveById(targetId) : pickDefault(); - - if (chosen === "AMBIGUOUS") { - throw new BrowserTargetAmbiguousError(); - } - if (!chosen) { - throw new BrowserTabNotFoundError(); - } - profileState.lastTargetId = chosen.targetId; - return chosen; - }; - - const resolveTargetIdOrThrow = async (targetId: string): Promise => { - const tabs = await listTabs(); - const resolved = resolveTargetIdFromTabs(targetId, tabs); - if (!resolved.ok) { - if (resolved.reason === "ambiguous") { - throw new BrowserTargetAmbiguousError(); - } - throw new BrowserTabNotFoundError(); - } - return resolved.targetId; - }; - - const focusTab = async (targetId: string): Promise => { - const resolvedTargetId = await resolveTargetIdOrThrow(targetId); - - if (capabilities.usesChromeMcp) { - await focusChromeMcpTab(profile.name, resolvedTargetId, profile.userDataDir); - const profileState = getProfileState(); - profileState.lastTargetId = resolvedTargetId; - return; - } - - if (capabilities.usesPersistentPlaywright) { - const mod = await getPwAiModule({ mode: "strict" }); - const focusPageByTargetIdViaPlaywright = (mod as Partial | null) - ?.focusPageByTargetIdViaPlaywright; - if (typeof focusPageByTargetIdViaPlaywright === "function") { - await focusPageByTargetIdViaPlaywright({ - cdpUrl: profile.cdpUrl, - targetId: resolvedTargetId, - }); - const profileState = getProfileState(); - profileState.lastTargetId = resolvedTargetId; - return; - } - } - - await fetchOk(appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`)); - const profileState = getProfileState(); - profileState.lastTargetId = resolvedTargetId; - }; - - const closeTab = async (targetId: string): Promise => { - const resolvedTargetId = await resolveTargetIdOrThrow(targetId); - - if (capabilities.usesChromeMcp) { - await closeChromeMcpTab(profile.name, resolvedTargetId, profile.userDataDir); - return; - } - - // For remote profiles, use Playwright's persistent connection to close tabs - if (capabilities.usesPersistentPlaywright) { - const mod = await getPwAiModule({ mode: "strict" }); - const closePageByTargetIdViaPlaywright = (mod as Partial | null) - ?.closePageByTargetIdViaPlaywright; - if (typeof closePageByTargetIdViaPlaywright === "function") { - await closePageByTargetIdViaPlaywright({ - cdpUrl: profile.cdpUrl, - targetId: resolvedTargetId, - }); - return; - } - } - - await fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`)); - }; - - return { - ensureTabAvailable, - focusTab, - closeTab, - }; -} +export * from "../../extensions/browser/src/browser/server-context.selection.js"; diff --git a/src/browser/server-context.tab-ops.ts b/src/browser/server-context.tab-ops.ts index 747082a7ff5..e3ae2f32be3 100644 --- a/src/browser/server-context.tab-ops.ts +++ b/src/browser/server-context.tab-ops.ts @@ -1,243 +1 @@ -import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js"; -import { fetchJson, fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js"; -import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js"; -import { listChromeMcpTabs, openChromeMcpTab } from "./chrome-mcp.js"; -import type { ResolvedBrowserProfile } from "./config.js"; -import { - assertBrowserNavigationAllowed, - assertBrowserNavigationResultAllowed, - InvalidBrowserNavigationUrlError, - requiresInspectableBrowserNavigationRedirects, - withBrowserNavigationPolicy, -} from "./navigation-guard.js"; -import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; -import type { PwAiModule } from "./pw-ai-module.js"; -import { getPwAiModule } from "./pw-ai-module.js"; -import { - MANAGED_BROWSER_PAGE_TAB_LIMIT, - OPEN_TAB_DISCOVERY_POLL_MS, - OPEN_TAB_DISCOVERY_WINDOW_MS, -} from "./server-context.constants.js"; -import type { - BrowserServerState, - BrowserTab, - ProfileRuntimeState, -} from "./server-context.types.js"; - -type TabOpsDeps = { - profile: ResolvedBrowserProfile; - state: () => BrowserServerState; - getProfileState: () => ProfileRuntimeState; -}; - -type ProfileTabOps = { - listTabs: () => Promise; - openTab: (url: string) => Promise; -}; - -/** - * Normalize a CDP WebSocket URL to use the correct base URL. - */ -function normalizeWsUrl(raw: string | undefined, cdpBaseUrl: string): string | undefined { - if (!raw) { - return undefined; - } - try { - return normalizeCdpWsUrl(raw, cdpBaseUrl); - } catch { - return raw; - } -} - -type CdpTarget = { - id?: string; - title?: string; - url?: string; - webSocketDebuggerUrl?: string; - type?: string; -}; - -export function createProfileTabOps({ - profile, - state, - getProfileState, -}: TabOpsDeps): ProfileTabOps { - const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl); - const capabilities = getBrowserProfileCapabilities(profile); - - const listTabs = async (): Promise => { - if (capabilities.usesChromeMcp) { - return await listChromeMcpTabs(profile.name, profile.userDataDir); - } - - if (capabilities.usesPersistentPlaywright) { - const mod = await getPwAiModule({ mode: "strict" }); - const listPagesViaPlaywright = (mod as Partial | null)?.listPagesViaPlaywright; - if (typeof listPagesViaPlaywright === "function") { - const pages = await listPagesViaPlaywright({ cdpUrl: profile.cdpUrl }); - return pages.map((p) => ({ - targetId: p.targetId, - title: p.title, - url: p.url, - type: p.type, - })); - } - } - - const raw = await fetchJson< - Array<{ - id?: string; - title?: string; - url?: string; - webSocketDebuggerUrl?: string; - type?: string; - }> - >(appendCdpPath(cdpHttpBase, "/json/list")); - return raw - .map((t) => ({ - targetId: t.id ?? "", - title: t.title ?? "", - url: t.url ?? "", - wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl, profile.cdpUrl), - type: t.type, - })) - .filter((t) => Boolean(t.targetId)); - }; - - const enforceManagedTabLimit = async (keepTargetId: string): Promise => { - const profileState = getProfileState(); - if ( - !capabilities.supportsManagedTabLimit || - state().resolved.attachOnly || - !profileState.running - ) { - return; - } - - const pageTabs = await listTabs() - .then((tabs) => tabs.filter((tab) => (tab.type ?? "page") === "page")) - .catch(() => [] as BrowserTab[]); - if (pageTabs.length <= MANAGED_BROWSER_PAGE_TAB_LIMIT) { - return; - } - - const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId); - const excessCount = pageTabs.length - MANAGED_BROWSER_PAGE_TAB_LIMIT; - for (const tab of candidates.slice(0, excessCount)) { - void fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${tab.targetId}`)).catch(() => { - // best-effort cleanup only - }); - } - }; - - const triggerManagedTabLimit = (keepTargetId: string): void => { - void enforceManagedTabLimit(keepTargetId).catch(() => { - // best-effort cleanup only - }); - }; - - const openTab = async (url: string): Promise => { - const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy); - - if (capabilities.usesChromeMcp) { - await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); - const page = await openChromeMcpTab(profile.name, url, profile.userDataDir); - const profileState = getProfileState(); - profileState.lastTargetId = page.targetId; - await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts }); - return page; - } - - if (capabilities.usesPersistentPlaywright) { - const mod = await getPwAiModule({ mode: "strict" }); - const createPageViaPlaywright = (mod as Partial | null)?.createPageViaPlaywright; - if (typeof createPageViaPlaywright === "function") { - const page = await createPageViaPlaywright({ - cdpUrl: profile.cdpUrl, - url, - ...ssrfPolicyOpts, - }); - const profileState = getProfileState(); - profileState.lastTargetId = page.targetId; - triggerManagedTabLimit(page.targetId); - return { - targetId: page.targetId, - title: page.title, - url: page.url, - type: page.type, - }; - } - } - - if (requiresInspectableBrowserNavigationRedirects(state().resolved.ssrfPolicy)) { - throw new InvalidBrowserNavigationUrlError( - "Navigation blocked: strict browser SSRF policy requires Playwright-backed redirect-hop inspection", - ); - } - - const createdViaCdp = await createTargetViaCdp({ - cdpUrl: profile.cdpUrl, - url, - ...ssrfPolicyOpts, - }) - .then((r) => r.targetId) - .catch(() => null); - - if (createdViaCdp) { - const profileState = getProfileState(); - profileState.lastTargetId = createdViaCdp; - const deadline = Date.now() + OPEN_TAB_DISCOVERY_WINDOW_MS; - while (Date.now() < deadline) { - const tabs = await listTabs().catch(() => [] as BrowserTab[]); - const found = tabs.find((t) => t.targetId === createdViaCdp); - if (found) { - await assertBrowserNavigationResultAllowed({ url: found.url, ...ssrfPolicyOpts }); - triggerManagedTabLimit(found.targetId); - return found; - } - await new Promise((r) => setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS)); - } - triggerManagedTabLimit(createdViaCdp); - return { targetId: createdViaCdp, title: "", url, type: "page" }; - } - - const encoded = encodeURIComponent(url); - const endpointUrl = new URL(appendCdpPath(cdpHttpBase, "/json/new")); - await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); - const endpoint = endpointUrl.search - ? (() => { - endpointUrl.searchParams.set("url", url); - return endpointUrl.toString(); - })() - : `${endpointUrl.toString()}?${encoded}`; - const created = await fetchJson(endpoint, CDP_JSON_NEW_TIMEOUT_MS, { - method: "PUT", - }).catch(async (err) => { - if (String(err).includes("HTTP 405")) { - return await fetchJson(endpoint, CDP_JSON_NEW_TIMEOUT_MS); - } - throw err; - }); - - if (!created.id) { - throw new Error("Failed to open tab (missing id)"); - } - const profileState = getProfileState(); - profileState.lastTargetId = created.id; - const resolvedUrl = created.url ?? url; - await assertBrowserNavigationResultAllowed({ url: resolvedUrl, ...ssrfPolicyOpts }); - triggerManagedTabLimit(created.id); - return { - targetId: created.id, - title: created.title ?? "", - url: resolvedUrl, - wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl), - type: created.type, - }; - }; - - return { - listTabs, - openTab, - }; -} +export * from "../../extensions/browser/src/browser/server-context.tab-ops.js"; diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index 5b06a49964e..92c04e017a2 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -1,258 +1 @@ -import { SsrFBlockedError } from "../infra/net/ssrf.js"; -import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js"; -import type { ResolvedBrowserProfile } from "./config.js"; -import { resolveProfile } from "./config.js"; -import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js"; -import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; -import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; -import { - refreshResolvedBrowserConfigFromDisk, - resolveBrowserProfileWithHotReload, -} from "./resolved-config-refresh.js"; -import { createProfileAvailability } from "./server-context.availability.js"; -import { createProfileResetOps } from "./server-context.reset.js"; -import { createProfileSelectionOps } from "./server-context.selection.js"; -import { createProfileTabOps } from "./server-context.tab-ops.js"; -import type { - BrowserServerState, - BrowserRouteContext, - BrowserTab, - ContextOptions, - ProfileContext, - ProfileRuntimeState, - ProfileStatus, -} from "./server-context.types.js"; - -export type { - BrowserRouteContext, - BrowserServerState, - BrowserTab, - ProfileContext, - ProfileRuntimeState, - ProfileStatus, -} from "./server-context.types.js"; - -export function listKnownProfileNames(state: BrowserServerState): string[] { - const names = new Set(Object.keys(state.resolved.profiles)); - for (const name of state.profiles.keys()) { - names.add(name); - } - return [...names]; -} - -/** - * Create a profile-scoped context for browser operations. - */ -function createProfileContext( - opts: ContextOptions, - profile: ResolvedBrowserProfile, -): ProfileContext { - const state = () => { - const current = opts.getState(); - if (!current) { - throw new Error("Browser server not started"); - } - return current; - }; - - const getProfileState = (): ProfileRuntimeState => { - const current = state(); - let profileState = current.profiles.get(profile.name); - if (!profileState) { - profileState = { profile, running: null, lastTargetId: null, reconcile: null }; - current.profiles.set(profile.name, profileState); - } - return profileState; - }; - - const setProfileRunning = (running: ProfileRuntimeState["running"]) => { - const profileState = getProfileState(); - profileState.running = running; - }; - - const { listTabs, openTab } = createProfileTabOps({ - profile, - state, - getProfileState, - }); - - const { ensureBrowserAvailable, isHttpReachable, isReachable, stopRunningBrowser } = - createProfileAvailability({ - opts, - profile, - state, - getProfileState, - setProfileRunning, - }); - - const { ensureTabAvailable, focusTab, closeTab } = createProfileSelectionOps({ - profile, - getProfileState, - ensureBrowserAvailable, - listTabs, - openTab, - }); - - const { resetProfile } = createProfileResetOps({ - profile, - getProfileState, - stopRunningBrowser, - isHttpReachable, - resolveOpenClawUserDataDir, - }); - - return { - profile, - ensureBrowserAvailable, - ensureTabAvailable, - isHttpReachable, - isReachable, - listTabs, - openTab, - focusTab, - closeTab, - stopRunningBrowser, - resetProfile, - }; -} - -export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteContext { - const refreshConfigFromDisk = opts.refreshConfigFromDisk === true; - - const state = () => { - const current = opts.getState(); - if (!current) { - throw new Error("Browser server not started"); - } - return current; - }; - - const forProfile = (profileName?: string): ProfileContext => { - const current = state(); - const name = profileName ?? current.resolved.defaultProfile; - const profile = resolveBrowserProfileWithHotReload({ - current, - refreshConfigFromDisk, - name, - }); - - if (!profile) { - const available = Object.keys(current.resolved.profiles).join(", "); - throw new BrowserProfileNotFoundError( - `Profile "${name}" not found. Available profiles: ${available || "(none)"}`, - ); - } - return createProfileContext(opts, profile); - }; - - const listProfiles = async (): Promise => { - const current = state(); - refreshResolvedBrowserConfigFromDisk({ - current, - refreshConfigFromDisk, - mode: "cached", - }); - const result: ProfileStatus[] = []; - - for (const name of listKnownProfileNames(current)) { - const profileState = current.profiles.get(name); - const profile = resolveProfile(current.resolved, name) ?? profileState?.profile; - if (!profile) { - continue; - } - const capabilities = getBrowserProfileCapabilities(profile); - - let tabCount = 0; - let running = false; - const profileCtx = createProfileContext(opts, profile); - - if (capabilities.usesChromeMcp) { - try { - running = await profileCtx.isReachable(300); - if (running) { - const tabs = await profileCtx.listTabs(); - tabCount = tabs.filter((t) => t.type === "page").length; - } - } catch { - // Chrome MCP not available - } - } else if (profileState?.running) { - running = true; - try { - const tabs = await profileCtx.listTabs(); - tabCount = tabs.filter((t) => t.type === "page").length; - } catch { - // Browser might not be responsive - } - } else { - // Check if something is listening on the port - try { - const reachable = await isChromeReachable( - profile.cdpUrl, - 200, - current.resolved.ssrfPolicy, - ); - if (reachable) { - running = true; - const tabs = await profileCtx.listTabs().catch(() => []); - tabCount = tabs.filter((t) => t.type === "page").length; - } - } catch { - // Not reachable - } - } - - result.push({ - name, - transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp", - cdpPort: capabilities.usesChromeMcp ? null : profile.cdpPort, - cdpUrl: capabilities.usesChromeMcp ? null : profile.cdpUrl, - color: profile.color, - driver: profile.driver, - running, - tabCount, - isDefault: name === current.resolved.defaultProfile, - isRemote: !profile.cdpIsLoopback, - missingFromConfig: !(name in current.resolved.profiles) || undefined, - reconcileReason: profileState?.reconcile?.reason ?? null, - }); - } - - return result; - }; - - // Create default profile context for backward compatibility - const getDefaultContext = () => forProfile(); - - const mapTabError = (err: unknown) => { - const browserMapped = toBrowserErrorResponse(err); - if (browserMapped) { - return browserMapped; - } - if (err instanceof SsrFBlockedError) { - return { status: 400, message: err.message }; - } - if (err instanceof InvalidBrowserNavigationUrlError) { - return { status: 400, message: err.message }; - } - return null; - }; - - return { - state, - forProfile, - listProfiles, - // Legacy methods delegate to default profile - ensureBrowserAvailable: () => getDefaultContext().ensureBrowserAvailable(), - ensureTabAvailable: (targetId) => getDefaultContext().ensureTabAvailable(targetId), - isHttpReachable: (timeoutMs) => getDefaultContext().isHttpReachable(timeoutMs), - isReachable: (timeoutMs) => getDefaultContext().isReachable(timeoutMs), - listTabs: () => getDefaultContext().listTabs(), - openTab: (url) => getDefaultContext().openTab(url), - focusTab: (targetId) => getDefaultContext().focusTab(targetId), - closeTab: (targetId) => getDefaultContext().closeTab(targetId), - stopRunningBrowser: () => getDefaultContext().stopRunningBrowser(), - resetProfile: () => getDefaultContext().resetProfile(), - mapTabError, - }; -} +export * from "../../extensions/browser/src/browser/server-context.js"; diff --git a/src/browser/server-context.types.ts b/src/browser/server-context.types.ts index b8ad7aa329d..1326b507b16 100644 --- a/src/browser/server-context.types.ts +++ b/src/browser/server-context.types.ts @@ -1,74 +1 @@ -import type { Server } from "node:http"; -import type { RunningChrome } from "./chrome.js"; -import type { BrowserTransport } from "./client.js"; -import type { BrowserTab } from "./client.js"; -import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js"; - -export type { BrowserTab }; - -/** - * Runtime state for a single profile's Chrome instance. - */ -export type ProfileRuntimeState = { - profile: ResolvedBrowserProfile; - running: RunningChrome | null; - /** Sticky tab selection when callers omit targetId (keeps snapshot+act consistent). */ - lastTargetId?: string | null; - reconcile?: { - previousProfile: ResolvedBrowserProfile; - reason: string; - } | null; -}; - -export type BrowserServerState = { - server?: Server | null; - port: number; - resolved: ResolvedBrowserConfig; - profiles: Map; -}; - -type BrowserProfileActions = { - ensureBrowserAvailable: () => Promise; - ensureTabAvailable: (targetId?: string) => Promise; - isHttpReachable: (timeoutMs?: number) => Promise; - isReachable: (timeoutMs?: number) => Promise; - listTabs: () => Promise; - openTab: (url: string) => Promise; - focusTab: (targetId: string) => Promise; - closeTab: (targetId: string) => Promise; - stopRunningBrowser: () => Promise<{ stopped: boolean }>; - resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>; -}; - -export type BrowserRouteContext = { - state: () => BrowserServerState; - forProfile: (profileName?: string) => ProfileContext; - listProfiles: () => Promise; - // Legacy methods delegate to default profile for backward compatibility - mapTabError: (err: unknown) => { status: number; message: string } | null; -} & BrowserProfileActions; - -export type ProfileContext = { - profile: ResolvedBrowserProfile; -} & BrowserProfileActions; - -export type ProfileStatus = { - name: string; - transport: BrowserTransport; - cdpPort: number | null; - cdpUrl: string | null; - color: string; - driver: ResolvedBrowserProfile["driver"]; - running: boolean; - tabCount: number; - isDefault: boolean; - isRemote: boolean; - missingFromConfig?: boolean; - reconcileReason?: string | null; -}; - -export type ContextOptions = { - getState: () => BrowserServerState | null; - onEnsureAttachTarget?: (profile: ResolvedBrowserProfile) => Promise; - refreshConfigFromDisk?: boolean; -}; +export * from "../../extensions/browser/src/browser/server-context.types.js"; diff --git a/src/browser/server-lifecycle.ts b/src/browser/server-lifecycle.ts index 1dd322f2bc9..635e92244e4 100644 --- a/src/browser/server-lifecycle.ts +++ b/src/browser/server-lifecycle.ts @@ -1,47 +1 @@ -import { stopOpenClawChrome } from "./chrome.js"; -import type { ResolvedBrowserConfig } from "./config.js"; -import { - type BrowserServerState, - createBrowserRouteContext, - listKnownProfileNames, -} from "./server-context.js"; - -export async function ensureExtensionRelayForProfiles(_params: { - resolved: ResolvedBrowserConfig; - onWarn: (message: string) => void; -}) { - // Intentional no-op: the Chrome extension relay path has been removed. - // runtime-lifecycle still calls this helper, so keep the stub until the next - // breaking cleanup rather than changing the call graph in a patch release. -} - -export async function stopKnownBrowserProfiles(params: { - getState: () => BrowserServerState | null; - onWarn: (message: string) => void; -}) { - const current = params.getState(); - if (!current) { - return; - } - const ctx = createBrowserRouteContext({ - getState: params.getState, - refreshConfigFromDisk: true, - }); - try { - for (const name of listKnownProfileNames(current)) { - try { - const runtime = current.profiles.get(name); - if (runtime?.running) { - await stopOpenClawChrome(runtime.running); - runtime.running = null; - continue; - } - await ctx.forProfile(name).stopRunningBrowser(); - } catch { - // ignore - } - } - } catch (err) { - params.onWarn(`openclaw browser stop failed: ${String(err)}`); - } -} +export * from "../../extensions/browser/src/browser/server-lifecycle.js"; diff --git a/src/browser/server-middleware.ts b/src/browser/server-middleware.ts index 99eeb9f2268..f6ad4cad1eb 100644 --- a/src/browser/server-middleware.ts +++ b/src/browser/server-middleware.ts @@ -1,37 +1 @@ -import type { Express } from "express"; -import express from "express"; -import { browserMutationGuardMiddleware } from "./csrf.js"; -import { isAuthorizedBrowserRequest } from "./http-auth.js"; - -export function installBrowserCommonMiddleware(app: Express) { - app.use((req, res, next) => { - const ctrl = new AbortController(); - const abort = () => ctrl.abort(new Error("request aborted")); - req.once("aborted", abort); - res.once("close", () => { - if (!res.writableEnded) { - abort(); - } - }); - // Make the signal available to browser route handlers (best-effort). - (req as unknown as { signal?: AbortSignal }).signal = ctrl.signal; - next(); - }); - app.use(express.json({ limit: "1mb" })); - app.use(browserMutationGuardMiddleware()); -} - -export function installBrowserAuthMiddleware( - app: Express, - auth: { token?: string; password?: string }, -) { - if (!auth.token && !auth.password) { - return; - } - app.use((req, res, next) => { - if (isAuthorizedBrowserRequest(req, auth)) { - return next(); - } - res.status(401).send("Unauthorized"); - }); -} +export * from "../../extensions/browser/src/browser/server-middleware.js"; diff --git a/src/browser/server.agent-contract.test-harness.ts b/src/browser/server.agent-contract.test-harness.ts index ea73714075f..2e7271552de 100644 --- a/src/browser/server.agent-contract.test-harness.ts +++ b/src/browser/server.agent-contract.test-harness.ts @@ -1,28 +1 @@ -import { - getBrowserControlServerBaseUrl, - installBrowserControlServerHooks, - startBrowserControlServerFromConfig, -} from "./server.control-server.test-harness.js"; -import { getBrowserTestFetch } from "./test-fetch.js"; - -export function installAgentContractHooks() { - installBrowserControlServerHooks(); -} - -export async function startServerAndBase(): Promise { - await startBrowserControlServerFromConfig(); - const base = getBrowserControlServerBaseUrl(); - const realFetch = getBrowserTestFetch(); - await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json()); - return base; -} - -export async function postJson(url: string, body?: unknown): Promise { - const realFetch = getBrowserTestFetch(); - const res = await realFetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - return (await res.json()) as T; -} +export * from "../../extensions/browser/src/browser/server.agent-contract.test-harness.js"; diff --git a/src/browser/server.control-server.test-harness.ts b/src/browser/server.control-server.test-harness.ts index 346708f7e9f..a329a73da8a 100644 --- a/src/browser/server.control-server.test-harness.ts +++ b/src/browser/server.control-server.test-harness.ts @@ -1,471 +1 @@ -import { afterEach, beforeEach, vi } from "vitest"; -import type { MockFn } from "../test-utils/vitest-mock-fn.js"; -import { installChromeUserDataDirHooks } from "./chrome-user-data-dir.test-harness.js"; -import { getFreePort } from "./test-port.js"; - -export { getFreePort } from "./test-port.js"; - -type HarnessState = { - testPort: number; - cdpBaseUrl: string; - reachable: boolean; - cfgAttachOnly: boolean; - cfgEvaluateEnabled: boolean; - cfgDefaultProfile: string; - cfgProfiles: Record< - string, - { - cdpPort?: number; - cdpUrl?: string; - color: string; - driver?: "openclaw" | "existing-session"; - attachOnly?: boolean; - } - >; - createTargetId: string | null; - prevGatewayPort: string | undefined; - prevGatewayToken: string | undefined; - prevGatewayPassword: string | undefined; -}; - -const state: HarnessState = { - testPort: 0, - cdpBaseUrl: "", - reachable: false, - cfgAttachOnly: false, - cfgEvaluateEnabled: true, - cfgDefaultProfile: "openclaw", - cfgProfiles: {}, - createTargetId: null, - prevGatewayPort: undefined, - prevGatewayToken: undefined, - prevGatewayPassword: undefined, -}; - -export function getBrowserControlServerTestState(): HarnessState { - return state; -} - -export function getBrowserControlServerBaseUrl(): string { - return `http://127.0.0.1:${state.testPort}`; -} - -export function restoreGatewayPortEnv(prevGatewayPort: string | undefined): void { - if (prevGatewayPort === undefined) { - delete process.env.OPENCLAW_GATEWAY_PORT; - return; - } - process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort; -} - -export function setBrowserControlServerCreateTargetId(targetId: string | null): void { - state.createTargetId = targetId; -} - -export function setBrowserControlServerAttachOnly(attachOnly: boolean): void { - state.cfgAttachOnly = attachOnly; -} - -export function setBrowserControlServerEvaluateEnabled(enabled: boolean): void { - state.cfgEvaluateEnabled = enabled; -} - -export function setBrowserControlServerReachable(reachable: boolean): void { - state.reachable = reachable; -} - -export function setBrowserControlServerProfiles( - profiles: HarnessState["cfgProfiles"], - defaultProfile = Object.keys(profiles)[0] ?? "openclaw", -): void { - state.cfgProfiles = profiles; - state.cfgDefaultProfile = defaultProfile; -} - -const cdpMocks = vi.hoisted(() => ({ - createTargetViaCdp: vi.fn<() => Promise<{ targetId: string }>>(async () => { - throw new Error("cdp disabled"); - }), - snapshotAria: vi.fn(async () => ({ - nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }], - })), -})); - -export function getCdpMocks(): { createTargetViaCdp: MockFn; snapshotAria: MockFn } { - return cdpMocks as unknown as { createTargetViaCdp: MockFn; snapshotAria: MockFn }; -} - -const pwMocks = vi.hoisted(() => ({ - armDialogViaPlaywright: vi.fn(async () => {}), - armFileUploadViaPlaywright: vi.fn(async () => {}), - batchViaPlaywright: vi.fn(async () => ({ results: [] })), - clickViaPlaywright: vi.fn(async () => {}), - closePageViaPlaywright: vi.fn(async () => {}), - closePlaywrightBrowserConnection: vi.fn(async () => {}), - downloadViaPlaywright: vi.fn(async () => ({ - url: "https://example.com/report.pdf", - suggestedFilename: "report.pdf", - path: "/tmp/report.pdf", - })), - dragViaPlaywright: vi.fn(async () => {}), - evaluateViaPlaywright: vi.fn(async () => "ok"), - fillFormViaPlaywright: vi.fn(async () => {}), - getConsoleMessagesViaPlaywright: vi.fn(async () => []), - hoverViaPlaywright: vi.fn(async () => {}), - scrollIntoViewViaPlaywright: vi.fn(async () => {}), - navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })), - pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })), - pressKeyViaPlaywright: vi.fn(async () => {}), - responseBodyViaPlaywright: vi.fn(async () => ({ - url: "https://example.com/api/data", - status: 200, - headers: { "content-type": "application/json" }, - body: '{"ok":true}', - })), - resizeViewportViaPlaywright: vi.fn(async () => {}), - selectOptionViaPlaywright: vi.fn(async () => {}), - setInputFilesViaPlaywright: vi.fn(async () => {}), - snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })), - traceStopViaPlaywright: vi.fn(async () => {}), - takeScreenshotViaPlaywright: vi.fn(async () => ({ - buffer: Buffer.from("png"), - })), - typeViaPlaywright: vi.fn(async () => {}), - waitForDownloadViaPlaywright: vi.fn(async () => ({ - url: "https://example.com/report.pdf", - suggestedFilename: "report.pdf", - path: "/tmp/report.pdf", - })), - waitForViaPlaywright: vi.fn(async () => {}), -})); - -export function getPwMocks(): Record { - return pwMocks as unknown as Record; -} - -const chromeMcpMocks = vi.hoisted(() => ({ - clickChromeMcpElement: vi.fn(async () => {}), - closeChromeMcpSession: vi.fn(async () => true), - closeChromeMcpTab: vi.fn(async () => {}), - dragChromeMcpElement: vi.fn(async () => {}), - ensureChromeMcpAvailable: vi.fn(async () => {}), - evaluateChromeMcpScript: vi.fn(async () => true), - fillChromeMcpElement: vi.fn(async () => {}), - fillChromeMcpForm: vi.fn(async () => {}), - focusChromeMcpTab: vi.fn(async () => {}), - getChromeMcpPid: vi.fn(() => 4321), - hoverChromeMcpElement: vi.fn(async () => {}), - listChromeMcpTabs: vi.fn(async () => [ - { targetId: "7", title: "", url: "https://example.com", type: "page" }, - ]), - navigateChromeMcpPage: vi.fn(async ({ url }: { url: string }) => ({ url })), - openChromeMcpTab: vi.fn(async (_profile: string, url: string) => ({ - targetId: "8", - title: "", - url, - type: "page", - })), - pressChromeMcpKey: vi.fn(async () => {}), - resizeChromeMcpPage: vi.fn(async () => {}), - takeChromeMcpScreenshot: vi.fn(async () => Buffer.from("png")), - takeChromeMcpSnapshot: vi.fn(async () => ({ - id: "root", - role: "document", - name: "Example", - children: [{ id: "btn-1", role: "button", name: "Continue" }], - })), - uploadChromeMcpFile: vi.fn(async () => {}), -})); - -export function getChromeMcpMocks(): Record { - return chromeMcpMocks as unknown as Record; -} - -const chromeUserDataDir = vi.hoisted(() => ({ dir: "/tmp/openclaw" })); -installChromeUserDataDirHooks(chromeUserDataDir); - -type BrowserServerModule = typeof import("./server.js"); -let browserServerModule: BrowserServerModule | null = null; - -async function loadBrowserServerModule(): Promise { - if (browserServerModule) { - return browserServerModule; - } - vi.resetModules(); - browserServerModule = await import("./server.js"); - return browserServerModule; -} - -function makeProc(pid = 123) { - const handlers = new Map void>>(); - return { - pid, - killed: false, - exitCode: null as number | null, - on: (event: string, cb: (...args: unknown[]) => void) => { - handlers.set(event, [...(handlers.get(event) ?? []), cb]); - return undefined; - }, - emitExit: () => { - for (const cb of handlers.get("exit") ?? []) { - cb(0); - } - }, - kill: () => { - return true; - }, - }; -} - -const proc = makeProc(); - -function defaultProfilesForState(testPort: number): HarnessState["cfgProfiles"] { - return { - openclaw: { cdpPort: testPort + 9, color: "#FF4500" }, - }; -} - -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - const loadConfig = () => { - return { - browser: { - enabled: true, - evaluateEnabled: state.cfgEvaluateEnabled, - color: "#FF4500", - attachOnly: state.cfgAttachOnly, - headless: true, - defaultProfile: state.cfgDefaultProfile, - profiles: - Object.keys(state.cfgProfiles).length > 0 - ? state.cfgProfiles - : defaultProfilesForState(state.testPort), - }, - }; - }; - const writeConfigFile = vi.fn(async () => {}); - return { - ...actual, - createConfigIO: vi.fn(() => ({ - loadConfig, - writeConfigFile, - })), - getRuntimeConfigSnapshot: vi.fn(() => null), - loadConfig, - writeConfigFile, - }; -}); - -const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>); - -export function getLaunchCalls() { - return launchCalls; -} - -vi.mock("./chrome.js", () => ({ - isChromeCdpReady: vi.fn(async () => state.reachable), - isChromeReachable: vi.fn(async () => state.reachable), - launchOpenClawChrome: vi.fn(async (_resolved: unknown, profile: { cdpPort: number }) => { - launchCalls.push({ port: profile.cdpPort }); - state.reachable = true; - return { - pid: 123, - exe: { kind: "chrome", path: "/fake/chrome" }, - userDataDir: chromeUserDataDir.dir, - cdpPort: profile.cdpPort, - startedAt: Date.now(), - proc, - }; - }), - resolveOpenClawUserDataDir: vi.fn(() => chromeUserDataDir.dir), - stopOpenClawChrome: vi.fn(async () => { - state.reachable = false; - }), -})); - -vi.mock("./cdp.js", () => ({ - createTargetViaCdp: cdpMocks.createTargetViaCdp, - normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl), - snapshotAria: cdpMocks.snapshotAria, - getHeadersWithAuth: vi.fn(() => ({})), - appendCdpPath: vi.fn((cdpUrl: string, cdpPath: string) => { - const base = cdpUrl.replace(/\/$/, ""); - const suffix = cdpPath.startsWith("/") ? cdpPath : `/${cdpPath}`; - return `${base}${suffix}`; - }), -})); - -vi.mock("./pw-ai.js", () => pwMocks); - -vi.mock("./chrome-mcp.js", () => chromeMcpMocks); - -vi.mock("../media/store.js", () => ({ - MEDIA_MAX_BYTES: 5 * 1024 * 1024, - ensureMediaDir: vi.fn(async () => {}), - getMediaDir: vi.fn(() => "/tmp"), - saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })), -})); - -vi.mock("./screenshot.js", () => ({ - DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128, - DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64, - normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({ - buffer: buf, - contentType: "image/png", - })), -})); - -export async function startBrowserControlServerFromConfig() { - const server = await loadBrowserServerModule(); - return await server.startBrowserControlServerFromConfig(); -} - -export async function stopBrowserControlServer(): Promise { - const server = browserServerModule; - browserServerModule = null; - if (!server) { - return; - } - await server.stopBrowserControlServer(); -} - -export function makeResponse( - body: unknown, - init?: { ok?: boolean; status?: number; text?: string }, -): Response { - const ok = init?.ok ?? true; - const status = init?.status ?? 200; - const text = init?.text ?? ""; - return { - ok, - status, - json: async () => body, - text: async () => text, - } as unknown as Response; -} - -function mockClearAll(obj: Record unknown }>) { - for (const fn of Object.values(obj)) { - fn.mockClear(); - } -} - -export async function resetBrowserControlServerTestContext(): Promise { - state.reachable = false; - state.cfgAttachOnly = false; - state.cfgEvaluateEnabled = true; - state.cfgDefaultProfile = "openclaw"; - state.cfgProfiles = defaultProfilesForState(state.testPort); - state.createTargetId = null; - - mockClearAll(pwMocks); - mockClearAll(cdpMocks); - mockClearAll(chromeMcpMocks); - - state.testPort = await getFreePort(); - state.cdpBaseUrl = `http://127.0.0.1:${state.testPort + 9}`; - state.cfgProfiles = defaultProfilesForState(state.testPort); - state.prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT; - process.env.OPENCLAW_GATEWAY_PORT = String(state.testPort - 2); - // Avoid flaky auth coupling: some suites temporarily set gateway env auth - // which would make the browser control server require auth. - state.prevGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN; - state.prevGatewayPassword = process.env.OPENCLAW_GATEWAY_PASSWORD; - delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_PASSWORD; -} - -export function restoreGatewayAuthEnv( - prevGatewayToken: string | undefined, - prevGatewayPassword: string | undefined, -): void { - if (prevGatewayToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevGatewayToken; - } - if (prevGatewayPassword === undefined) { - delete process.env.OPENCLAW_GATEWAY_PASSWORD; - } else { - process.env.OPENCLAW_GATEWAY_PASSWORD = prevGatewayPassword; - } -} - -export async function cleanupBrowserControlServerTestContext(): Promise { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - restoreGatewayPortEnv(state.prevGatewayPort); - restoreGatewayAuthEnv(state.prevGatewayToken, state.prevGatewayPassword); - await stopBrowserControlServer(); -} - -export function installBrowserControlServerHooks() { - beforeEach(async () => { - vi.useRealTimers(); - cdpMocks.createTargetViaCdp.mockImplementation(async () => { - if (state.createTargetId) { - return { targetId: state.createTargetId }; - } - throw new Error("cdp disabled"); - }); - - await resetBrowserControlServerTestContext(); - await loadBrowserServerModule(); - - // Minimal CDP JSON endpoints used by the server. - let putNewCalls = 0; - vi.stubGlobal( - "fetch", - vi.fn(async (url: string, init?: RequestInit) => { - const u = String(url); - if (u.includes("/json/list")) { - if (!state.reachable) { - return makeResponse([]); - } - return makeResponse([ - { - id: "abcd1234", - title: "Tab", - url: "https://example.com", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234", - type: "page", - }, - { - id: "abce9999", - title: "Other", - url: "https://other", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999", - type: "page", - }, - ]); - } - if (u.includes("/json/new?")) { - if (init?.method === "PUT") { - putNewCalls += 1; - if (putNewCalls === 1) { - return makeResponse({}, { ok: false, status: 405, text: "" }); - } - } - return makeResponse({ - id: "newtab1", - title: "", - url: "about:blank", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1", - type: "page", - }); - } - if (u.includes("/json/activate/")) { - return makeResponse("ok"); - } - if (u.includes("/json/close/")) { - return makeResponse("ok"); - } - return makeResponse({}, { ok: false, status: 500, text: "unexpected" }); - }), - ); - }); - - afterEach(async () => { - await cleanupBrowserControlServerTestContext(); - }); -} +export * from "../../extensions/browser/src/browser/server.control-server.test-harness.js"; diff --git a/src/browser/server.ts b/src/browser/server.ts index ce4a59419a4..ea38f1269ff 100644 --- a/src/browser/server.ts +++ b/src/browser/server.ts @@ -1,99 +1 @@ -import type { Server } from "node:http"; -import express from "express"; -import { loadConfig } from "../config/config.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import { resolveBrowserConfig } from "./config.js"; -import { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./control-auth.js"; -import { registerBrowserRoutes } from "./routes/index.js"; -import type { BrowserRouteRegistrar } from "./routes/types.js"; -import { createBrowserRuntimeState, stopBrowserRuntime } from "./runtime-lifecycle.js"; -import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js"; -import { - installBrowserAuthMiddleware, - installBrowserCommonMiddleware, -} from "./server-middleware.js"; - -let state: BrowserServerState | null = null; -const log = createSubsystemLogger("browser"); -const logServer = log.child("server"); - -export async function startBrowserControlServerFromConfig(): Promise { - if (state) { - return state; - } - - const cfg = loadConfig(); - const resolved = resolveBrowserConfig(cfg.browser, cfg); - if (!resolved.enabled) { - return null; - } - - let browserAuth = resolveBrowserControlAuth(cfg); - let browserAuthBootstrapFailed = false; - try { - const ensured = await ensureBrowserControlAuth({ cfg }); - browserAuth = ensured.auth; - if (ensured.generatedToken) { - logServer.info("No browser auth configured; generated gateway.auth.token automatically."); - } - } catch (err) { - logServer.warn(`failed to auto-configure browser auth: ${String(err)}`); - browserAuthBootstrapFailed = true; - } - - // Fail closed: if auth bootstrap failed and no explicit auth is available, - // do not start the browser control HTTP server. - if (browserAuthBootstrapFailed && !browserAuth.token && !browserAuth.password) { - logServer.error( - "browser control startup aborted: authentication bootstrap failed and no fallback auth is configured.", - ); - return null; - } - - const app = express(); - installBrowserCommonMiddleware(app); - installBrowserAuthMiddleware(app, browserAuth); - - const ctx = createBrowserRouteContext({ - getState: () => state, - refreshConfigFromDisk: true, - }); - registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx); - - const port = resolved.controlPort; - const server = await new Promise((resolve, reject) => { - const s = app.listen(port, "127.0.0.1", () => resolve(s)); - s.once("error", reject); - }).catch((err) => { - logServer.error(`openclaw browser server failed to bind 127.0.0.1:${port}: ${String(err)}`); - return null; - }); - - if (!server) { - return null; - } - - state = await createBrowserRuntimeState({ - server, - port, - resolved, - onWarn: (message) => logServer.warn(message), - }); - - const authMode = browserAuth.token ? "token" : browserAuth.password ? "password" : "off"; - logServer.info(`Browser control listening on http://127.0.0.1:${port}/ (auth=${authMode})`); - return state; -} - -export async function stopBrowserControlServer(): Promise { - const current = state; - await stopBrowserRuntime({ - current, - getState: () => state, - clearState: () => { - state = null; - }, - closeServer: true, - onWarn: (message) => logServer.warn(message), - }); -} +export * from "../../extensions/browser/src/browser/server.js"; diff --git a/src/browser/session-tab-registry.ts b/src/browser/session-tab-registry.ts index b81ceac3060..5a76ef56d4d 100644 --- a/src/browser/session-tab-registry.ts +++ b/src/browser/session-tab-registry.ts @@ -1,189 +1 @@ -import { browserCloseTab } from "./client.js"; - -export type TrackedSessionBrowserTab = { - sessionKey: string; - targetId: string; - baseUrl?: string; - profile?: string; - trackedAt: number; -}; - -const trackedTabsBySession = new Map>(); - -function normalizeSessionKey(raw: string): string { - return raw.trim().toLowerCase(); -} - -function normalizeTargetId(raw: string): string { - return raw.trim(); -} - -function normalizeProfile(raw?: string): string | undefined { - if (!raw) { - return undefined; - } - const trimmed = raw.trim(); - return trimmed ? trimmed.toLowerCase() : undefined; -} - -function normalizeBaseUrl(raw?: string): string | undefined { - if (!raw) { - return undefined; - } - const trimmed = raw.trim(); - return trimmed ? trimmed : undefined; -} - -function toTrackedTabId(params: { targetId: string; baseUrl?: string; profile?: string }): string { - return `${params.targetId}\u0000${params.baseUrl ?? ""}\u0000${params.profile ?? ""}`; -} - -function isIgnorableCloseError(err: unknown): boolean { - const message = String(err).toLowerCase(); - return ( - message.includes("tab not found") || - message.includes("target closed") || - message.includes("target not found") || - message.includes("no such target") - ); -} - -export function trackSessionBrowserTab(params: { - sessionKey?: string; - targetId?: string; - baseUrl?: string; - profile?: string; -}): void { - const sessionKeyRaw = params.sessionKey?.trim(); - const targetIdRaw = params.targetId?.trim(); - if (!sessionKeyRaw || !targetIdRaw) { - return; - } - const sessionKey = normalizeSessionKey(sessionKeyRaw); - const targetId = normalizeTargetId(targetIdRaw); - const baseUrl = normalizeBaseUrl(params.baseUrl); - const profile = normalizeProfile(params.profile); - const tracked: TrackedSessionBrowserTab = { - sessionKey, - targetId, - baseUrl, - profile, - trackedAt: Date.now(), - }; - const trackedId = toTrackedTabId(tracked); - let trackedForSession = trackedTabsBySession.get(sessionKey); - if (!trackedForSession) { - trackedForSession = new Map(); - trackedTabsBySession.set(sessionKey, trackedForSession); - } - trackedForSession.set(trackedId, tracked); -} - -export function untrackSessionBrowserTab(params: { - sessionKey?: string; - targetId?: string; - baseUrl?: string; - profile?: string; -}): void { - const sessionKeyRaw = params.sessionKey?.trim(); - const targetIdRaw = params.targetId?.trim(); - if (!sessionKeyRaw || !targetIdRaw) { - return; - } - const sessionKey = normalizeSessionKey(sessionKeyRaw); - const trackedForSession = trackedTabsBySession.get(sessionKey); - if (!trackedForSession) { - return; - } - const trackedId = toTrackedTabId({ - targetId: normalizeTargetId(targetIdRaw), - baseUrl: normalizeBaseUrl(params.baseUrl), - profile: normalizeProfile(params.profile), - }); - trackedForSession.delete(trackedId); - if (trackedForSession.size === 0) { - trackedTabsBySession.delete(sessionKey); - } -} - -function takeTrackedTabsForSessionKeys( - sessionKeys: Array, -): TrackedSessionBrowserTab[] { - const uniqueSessionKeys = new Set(); - for (const key of sessionKeys) { - if (!key?.trim()) { - continue; - } - uniqueSessionKeys.add(normalizeSessionKey(key)); - } - if (uniqueSessionKeys.size === 0) { - return []; - } - const seenTrackedIds = new Set(); - const tabs: TrackedSessionBrowserTab[] = []; - for (const sessionKey of uniqueSessionKeys) { - const trackedForSession = trackedTabsBySession.get(sessionKey); - if (!trackedForSession || trackedForSession.size === 0) { - continue; - } - trackedTabsBySession.delete(sessionKey); - for (const tracked of trackedForSession.values()) { - const trackedId = toTrackedTabId(tracked); - if (seenTrackedIds.has(trackedId)) { - continue; - } - seenTrackedIds.add(trackedId); - tabs.push(tracked); - } - } - return tabs; -} - -export async function closeTrackedBrowserTabsForSessions(params: { - sessionKeys: Array; - closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise; - onWarn?: (message: string) => void; -}): Promise { - const tabs = takeTrackedTabsForSessionKeys(params.sessionKeys); - if (tabs.length === 0) { - return 0; - } - const closeTab = - params.closeTab ?? - (async (tab: { targetId: string; baseUrl?: string; profile?: string }) => { - await browserCloseTab(tab.baseUrl, tab.targetId, { - profile: tab.profile, - }); - }); - let closed = 0; - for (const tab of tabs) { - try { - await closeTab({ - targetId: tab.targetId, - baseUrl: tab.baseUrl, - profile: tab.profile, - }); - closed += 1; - } catch (err) { - if (!isIgnorableCloseError(err)) { - params.onWarn?.(`failed to close tracked browser tab ${tab.targetId}: ${String(err)}`); - } - } - } - return closed; -} - -export function __resetTrackedSessionBrowserTabsForTests(): void { - trackedTabsBySession.clear(); -} - -export function __countTrackedSessionBrowserTabsForTests(sessionKey?: string): number { - if (typeof sessionKey === "string" && sessionKey.trim()) { - return trackedTabsBySession.get(normalizeSessionKey(sessionKey))?.size ?? 0; - } - let count = 0; - for (const tracked of trackedTabsBySession.values()) { - count += tracked.size; - } - return count; -} +export * from "../../extensions/browser/src/browser/session-tab-registry.js"; diff --git a/src/browser/snapshot-roles.ts b/src/browser/snapshot-roles.ts index 8e5d873e557..5c4b98c1039 100644 --- a/src/browser/snapshot-roles.ts +++ b/src/browser/snapshot-roles.ts @@ -1,63 +1 @@ -/** - * Shared ARIA role classification sets used by both the Playwright and Chrome MCP - * snapshot paths. Keep these in sync — divergence causes the two drivers to produce - * different snapshot output for the same page. - */ - -/** Roles that represent user-interactive elements and always get a ref. */ -export const INTERACTIVE_ROLES = new Set([ - "button", - "checkbox", - "combobox", - "link", - "listbox", - "menuitem", - "menuitemcheckbox", - "menuitemradio", - "option", - "radio", - "searchbox", - "slider", - "spinbutton", - "switch", - "tab", - "textbox", - "treeitem", -]); - -/** Roles that carry meaningful content and get a ref when named. */ -export const CONTENT_ROLES = new Set([ - "article", - "cell", - "columnheader", - "gridcell", - "heading", - "listitem", - "main", - "navigation", - "region", - "rowheader", -]); - -/** Structural/container roles — typically skipped in compact mode. */ -export const STRUCTURAL_ROLES = new Set([ - "application", - "directory", - "document", - "generic", - "grid", - "group", - "ignored", - "list", - "menu", - "menubar", - "none", - "presentation", - "row", - "rowgroup", - "table", - "tablist", - "toolbar", - "tree", - "treegrid", -]); +export * from "../../extensions/browser/src/browser/snapshot-roles.js"; diff --git a/src/browser/target-id.ts b/src/browser/target-id.ts index 6ae0f31bf08..4d716464d57 100644 --- a/src/browser/target-id.ts +++ b/src/browser/target-id.ts @@ -1,30 +1 @@ -export type TargetIdResolution = - | { ok: true; targetId: string } - | { ok: false; reason: "not_found" | "ambiguous"; matches?: string[] }; - -export function resolveTargetIdFromTabs( - input: string, - tabs: Array<{ targetId: string }>, -): TargetIdResolution { - const needle = input.trim(); - if (!needle) { - return { ok: false, reason: "not_found" }; - } - - const exact = tabs.find((t) => t.targetId === needle); - if (exact) { - return { ok: true, targetId: exact.targetId }; - } - - const lower = needle.toLowerCase(); - const matches = tabs.map((t) => t.targetId).filter((id) => id.toLowerCase().startsWith(lower)); - - const only = matches.length === 1 ? matches[0] : undefined; - if (only) { - return { ok: true, targetId: only }; - } - if (matches.length === 0) { - return { ok: false, reason: "not_found" }; - } - return { ok: false, reason: "ambiguous", matches }; -} +export * from "../../extensions/browser/src/browser/target-id.js"; diff --git a/src/browser/test-fetch.ts b/src/browser/test-fetch.ts index 310c7728afe..73daaa67d52 100644 --- a/src/browser/test-fetch.ts +++ b/src/browser/test-fetch.ts @@ -1,30 +1 @@ -import { createRequire } from "node:module"; - -type FetchLike = ((input: string | URL, init?: RequestInit) => Promise) & { - mock?: unknown; -}; - -export type BrowserTestFetch = (input: string | URL, init?: RequestInit) => Promise; - -function isUsableFetch(value: unknown): value is FetchLike { - return typeof value === "function" && !("mock" in (value as FetchLike)); -} - -export function getBrowserTestFetch(): BrowserTestFetch { - const require = createRequire(import.meta.url); - const vitest = (globalThis as { vi?: { doUnmock?: (id: string) => void } }).vi; - vitest?.doUnmock?.("undici"); - try { - delete require.cache[require.resolve("undici")]; - } catch { - // Best-effort cache bust for shared-thread test workers. - } - const { fetch } = require("undici") as typeof import("undici"); - if (isUsableFetch(fetch)) { - return (input, init) => fetch(input, init); - } - if (isUsableFetch(globalThis.fetch)) { - return (input, init) => globalThis.fetch(input, init); - } - throw new TypeError("fetch is not a function"); -} +export * from "../../extensions/browser/src/browser/test-fetch.js"; diff --git a/src/browser/test-port.ts b/src/browser/test-port.ts index 860968df9a7..a727492f6e2 100644 --- a/src/browser/test-port.ts +++ b/src/browser/test-port.ts @@ -1,18 +1 @@ -import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; - -export async function getFreePort(): Promise { - while (true) { - const port = await new Promise((resolve, reject) => { - const s = createServer(); - s.once("error", reject); - s.listen(0, "127.0.0.1", () => { - const assigned = (s.address() as AddressInfo).port; - s.close((err) => (err ? reject(err) : resolve(assigned))); - }); - }); - if (port < 65535) { - return port; - } - } -} +export * from "../../extensions/browser/src/browser/test-port.js"; diff --git a/src/browser/trash.ts b/src/browser/trash.ts index c0b1d6094d6..0c2240aed4e 100644 --- a/src/browser/trash.ts +++ b/src/browser/trash.ts @@ -1,22 +1 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { generateSecureToken } from "../infra/secure-random.js"; -import { runExec } from "../process/exec.js"; - -export async function movePathToTrash(targetPath: string): Promise { - try { - await runExec("trash", [targetPath], { timeoutMs: 10_000 }); - return targetPath; - } catch { - const trashDir = path.join(os.homedir(), ".Trash"); - fs.mkdirSync(trashDir, { recursive: true }); - const base = path.basename(targetPath); - let dest = path.join(trashDir, `${base}-${Date.now()}`); - if (fs.existsSync(dest)) { - dest = path.join(trashDir, `${base}-${Date.now()}-${generateSecureToken(6)}`); - } - fs.renameSync(targetPath, dest); - return dest; - } -} +export * from "../../extensions/browser/src/browser/trash.js"; diff --git a/src/browser/url-pattern.ts b/src/browser/url-pattern.ts index 2ff99657d26..fc781caac10 100644 --- a/src/browser/url-pattern.ts +++ b/src/browser/url-pattern.ts @@ -1,15 +1 @@ -export function matchBrowserUrlPattern(pattern: string, url: string): boolean { - const trimmedPattern = pattern.trim(); - if (!trimmedPattern) { - return false; - } - if (trimmedPattern === url) { - return true; - } - if (trimmedPattern.includes("*")) { - const escaped = trimmedPattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); - const regex = new RegExp(`^${escaped.replace(/\*\*/g, ".*").replace(/\*/g, ".*")}$`); - return regex.test(url); - } - return url.includes(trimmedPattern); -} +export * from "../../extensions/browser/src/browser/url-pattern.js"; diff --git a/src/cli/browser-cli-actions-input.ts b/src/cli/browser-cli-actions-input.ts index 7c852948ed0..6ca2162d9ce 100644 --- a/src/cli/browser-cli-actions-input.ts +++ b/src/cli/browser-cli-actions-input.ts @@ -1 +1 @@ -export { registerBrowserActionInputCommands } from "./browser-cli-actions-input/register.js"; +export * from "../../extensions/browser/src/cli/browser-cli-actions-input.js"; diff --git a/src/cli/browser-cli-actions-input/register.element.ts b/src/cli/browser-cli-actions-input/register.element.ts index 2b27c349f63..55d403be739 100644 --- a/src/cli/browser-cli-actions-input/register.element.ts +++ b/src/cli/browser-cli-actions-input/register.element.ts @@ -1,195 +1 @@ -import type { Command } from "commander"; -import { danger } from "../../globals.js"; -import { defaultRuntime } from "../../runtime.js"; -import type { BrowserParentOpts } from "../browser-cli-shared.js"; -import { - callBrowserAct, - logBrowserActionResult, - requireRef, - resolveBrowserActionContext, -} from "./shared.js"; - -export function registerBrowserElementCommands( - browser: Command, - parentOpts: (cmd: Command) => BrowserParentOpts, -) { - const runElementAction = async (params: { - cmd: Command; - body: Record; - successMessage: string | ((result: unknown) => string); - timeoutMs?: number; - }): Promise => { - const { parent, profile } = resolveBrowserActionContext(params.cmd, parentOpts); - try { - const result = await callBrowserAct({ - parent, - profile, - body: params.body, - timeoutMs: params.timeoutMs, - }); - const successMessage = - typeof params.successMessage === "function" - ? params.successMessage(result) - : params.successMessage; - logBrowserActionResult(parent, result, successMessage); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }; - - browser - .command("click") - .description("Click an element by ref from snapshot") - .argument("", "Ref id from snapshot") - .option("--target-id ", "CDP target id (or unique prefix)") - .option("--double", "Double click", false) - .option("--button ", "Mouse button to use") - .option("--modifiers ", "Comma-separated modifiers (Shift,Alt,Meta)") - .action(async (ref: string | undefined, opts, cmd) => { - const refValue = requireRef(ref); - if (!refValue) { - return; - } - const modifiers = opts.modifiers - ? String(opts.modifiers) - .split(",") - .map((v: string) => v.trim()) - .filter(Boolean) - : undefined; - await runElementAction({ - cmd, - body: { - kind: "click", - ref: refValue, - targetId: opts.targetId?.trim() || undefined, - doubleClick: Boolean(opts.double), - button: opts.button?.trim() || undefined, - modifiers, - }, - successMessage: (result) => { - const url = (result as { url?: unknown }).url; - const suffix = typeof url === "string" && url ? ` on ${url}` : ""; - return `clicked ref ${refValue}${suffix}`; - }, - }); - }); - - browser - .command("type") - .description("Type into an element by ref from snapshot") - .argument("", "Ref id from snapshot") - .argument("", "Text to type") - .option("--submit", "Press Enter after typing", false) - .option("--slowly", "Type slowly (human-like)", false) - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (ref: string | undefined, text: string, opts, cmd) => { - const refValue = requireRef(ref); - if (!refValue) { - return; - } - await runElementAction({ - cmd, - body: { - kind: "type", - ref: refValue, - text, - submit: Boolean(opts.submit), - slowly: Boolean(opts.slowly), - targetId: opts.targetId?.trim() || undefined, - }, - successMessage: `typed into ref ${refValue}`, - }); - }); - - browser - .command("press") - .description("Press a key") - .argument("", "Key to press (e.g. Enter)") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (key: string, opts, cmd) => { - await runElementAction({ - cmd, - body: { kind: "press", key, targetId: opts.targetId?.trim() || undefined }, - successMessage: `pressed ${key}`, - }); - }); - - browser - .command("hover") - .description("Hover an element by ai ref") - .argument("", "Ref id from snapshot") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (ref: string, opts, cmd) => { - await runElementAction({ - cmd, - body: { kind: "hover", ref, targetId: opts.targetId?.trim() || undefined }, - successMessage: `hovered ref ${ref}`, - }); - }); - - browser - .command("scrollintoview") - .description("Scroll an element into view by ref from snapshot") - .argument("", "Ref id from snapshot") - .option("--target-id ", "CDP target id (or unique prefix)") - .option("--timeout-ms ", "How long to wait for scroll (default: 20000)", (v: string) => - Number(v), - ) - .action(async (ref: string | undefined, opts, cmd) => { - const refValue = requireRef(ref); - if (!refValue) { - return; - } - const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined; - await runElementAction({ - cmd, - body: { - kind: "scrollIntoView", - ref: refValue, - targetId: opts.targetId?.trim() || undefined, - timeoutMs, - }, - timeoutMs, - successMessage: `scrolled into view: ${refValue}`, - }); - }); - - browser - .command("drag") - .description("Drag from one ref to another") - .argument("", "Start ref id") - .argument("", "End ref id") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (startRef: string, endRef: string, opts, cmd) => { - await runElementAction({ - cmd, - body: { - kind: "drag", - startRef, - endRef, - targetId: opts.targetId?.trim() || undefined, - }, - successMessage: `dragged ${startRef} → ${endRef}`, - }); - }); - - browser - .command("select") - .description("Select option(s) in a select element") - .argument("", "Ref id from snapshot") - .argument("", "Option values to select") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (ref: string, values: string[], opts, cmd) => { - await runElementAction({ - cmd, - body: { - kind: "select", - ref, - values, - targetId: opts.targetId?.trim() || undefined, - }, - successMessage: `selected ${values.join(", ")}`, - }); - }); -} +export * from "../../../extensions/browser/src/cli/browser-cli-actions-input/register.element.js"; diff --git a/src/cli/browser-cli-actions-input/register.files-downloads.ts b/src/cli/browser-cli-actions-input/register.files-downloads.ts index 6045da6ffaf..2d11e04ded7 100644 --- a/src/cli/browser-cli-actions-input/register.files-downloads.ts +++ b/src/cli/browser-cli-actions-input/register.files-downloads.ts @@ -1,201 +1 @@ -import type { Command } from "commander"; -import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "../../browser/paths.js"; -import { danger } from "../../globals.js"; -import { defaultRuntime } from "../../runtime.js"; -import { shortenHomePath } from "../../utils.js"; -import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js"; -import { resolveBrowserActionContext } from "./shared.js"; - -async function normalizeUploadPaths(paths: string[]): Promise { - const result = await resolveExistingPathsWithinRoot({ - rootDir: DEFAULT_UPLOAD_DIR, - requestedPaths: paths, - scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`, - }); - if (!result.ok) { - throw new Error(result.error); - } - return result.paths; -} - -async function runBrowserPostAction(params: { - parent: BrowserParentOpts; - profile: string | undefined; - path: string; - body: Record; - timeoutMs: number; - describeSuccess: (result: T) => string; -}): Promise { - try { - const result = await callBrowserRequest( - params.parent, - { - method: "POST", - path: params.path, - query: params.profile ? { profile: params.profile } : undefined, - body: params.body, - }, - { timeoutMs: params.timeoutMs }, - ); - if (params.parent?.json) { - defaultRuntime.writeJson(result); - return; - } - defaultRuntime.log(params.describeSuccess(result)); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } -} - -export function registerBrowserFilesAndDownloadsCommands( - browser: Command, - parentOpts: (cmd: Command) => BrowserParentOpts, -) { - const resolveTimeoutAndTarget = (opts: { timeoutMs?: unknown; targetId?: unknown }) => { - const timeoutMs = Number.isFinite(opts.timeoutMs) ? Number(opts.timeoutMs) : undefined; - const targetId = - typeof opts.targetId === "string" ? opts.targetId.trim() || undefined : undefined; - return { timeoutMs, targetId }; - }; - - const runDownloadCommand = async ( - cmd: Command, - opts: { timeoutMs?: unknown; targetId?: unknown }, - request: { path: string; body: Record }, - ) => { - const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts); - const { timeoutMs, targetId } = resolveTimeoutAndTarget(opts); - await runBrowserPostAction<{ download: { path: string } }>({ - parent, - profile, - path: request.path, - body: { - ...request.body, - targetId, - timeoutMs, - }, - timeoutMs: timeoutMs ?? 20000, - describeSuccess: (result) => `downloaded: ${shortenHomePath(result.download.path)}`, - }); - }; - - browser - .command("upload") - .description("Arm file upload for the next file chooser") - .argument( - "", - "File paths to upload (must be within OpenClaw temp uploads dir, e.g. /tmp/openclaw/uploads/file.pdf)", - ) - .option("--ref ", "Ref id from snapshot to click after arming") - .option("--input-ref ", "Ref id for to set directly") - .option("--element ", "CSS selector for ") - .option("--target-id ", "CDP target id (or unique prefix)") - .option( - "--timeout-ms ", - "How long to wait for the next file chooser (default: 120000)", - (v: string) => Number(v), - ) - .action(async (paths: string[], opts, cmd) => { - const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts); - const normalizedPaths = await normalizeUploadPaths(paths); - const { timeoutMs, targetId } = resolveTimeoutAndTarget(opts); - await runBrowserPostAction({ - parent, - profile, - path: "/hooks/file-chooser", - body: { - paths: normalizedPaths, - ref: opts.ref?.trim() || undefined, - inputRef: opts.inputRef?.trim() || undefined, - element: opts.element?.trim() || undefined, - targetId, - timeoutMs, - }, - timeoutMs: timeoutMs ?? 20000, - describeSuccess: () => `upload armed for ${paths.length} file(s)`, - }); - }); - - browser - .command("waitfordownload") - .description("Wait for the next download (and save it)") - .argument( - "[path]", - "Save path within openclaw temp downloads dir (default: /tmp/openclaw/downloads/...; fallback: os.tmpdir()/openclaw/downloads/...)", - ) - .option("--target-id ", "CDP target id (or unique prefix)") - .option( - "--timeout-ms ", - "How long to wait for the next download (default: 120000)", - (v: string) => Number(v), - ) - .action(async (outPath: string | undefined, opts, cmd) => { - await runDownloadCommand(cmd, opts, { - path: "/wait/download", - body: { - path: outPath?.trim() || undefined, - }, - }); - }); - - browser - .command("download") - .description("Click a ref and save the resulting download") - .argument("", "Ref id from snapshot to click") - .argument( - "", - "Save path within openclaw temp downloads dir (e.g. report.pdf or /tmp/openclaw/downloads/report.pdf)", - ) - .option("--target-id ", "CDP target id (or unique prefix)") - .option( - "--timeout-ms ", - "How long to wait for the download to start (default: 120000)", - (v: string) => Number(v), - ) - .action(async (ref: string, outPath: string, opts, cmd) => { - await runDownloadCommand(cmd, opts, { - path: "/download", - body: { - ref, - path: outPath, - }, - }); - }); - - browser - .command("dialog") - .description("Arm the next modal dialog (alert/confirm/prompt)") - .option("--accept", "Accept the dialog", false) - .option("--dismiss", "Dismiss the dialog", false) - .option("--prompt ", "Prompt response text") - .option("--target-id ", "CDP target id (or unique prefix)") - .option( - "--timeout-ms ", - "How long to wait for the next dialog (default: 120000)", - (v: string) => Number(v), - ) - .action(async (opts, cmd) => { - const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts); - const accept = opts.accept ? true : opts.dismiss ? false : undefined; - if (accept === undefined) { - defaultRuntime.error(danger("Specify --accept or --dismiss")); - defaultRuntime.exit(1); - return; - } - const { timeoutMs, targetId } = resolveTimeoutAndTarget(opts); - await runBrowserPostAction({ - parent, - profile, - path: "/hooks/dialog", - body: { - accept, - promptText: opts.prompt?.trim() || undefined, - targetId, - timeoutMs, - }, - timeoutMs: timeoutMs ?? 20000, - describeSuccess: () => "dialog armed", - }); - }); -} +export * from "../../../extensions/browser/src/cli/browser-cli-actions-input/register.files-downloads.js"; diff --git a/src/cli/browser-cli-actions-input/register.form-wait-eval.ts b/src/cli/browser-cli-actions-input/register.form-wait-eval.ts index 291e43c56e2..c7f103c7ec8 100644 --- a/src/cli/browser-cli-actions-input/register.form-wait-eval.ts +++ b/src/cli/browser-cli-actions-input/register.form-wait-eval.ts @@ -1,128 +1 @@ -import type { Command } from "commander"; -import { danger } from "../../globals.js"; -import { defaultRuntime } from "../../runtime.js"; -import type { BrowserParentOpts } from "../browser-cli-shared.js"; -import { - callBrowserAct, - logBrowserActionResult, - readFields, - resolveBrowserActionContext, -} from "./shared.js"; - -export function registerBrowserFormWaitEvalCommands( - browser: Command, - parentOpts: (cmd: Command) => BrowserParentOpts, -) { - browser - .command("fill") - .description("Fill a form with JSON field descriptors") - .option("--fields ", "JSON array of field objects") - .option("--fields-file ", "Read JSON array from a file") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (opts, cmd) => { - const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts); - try { - const fields = await readFields({ - fields: opts.fields, - fieldsFile: opts.fieldsFile, - }); - const result = await callBrowserAct<{ result?: unknown }>({ - parent, - profile, - body: { - kind: "fill", - fields, - targetId: opts.targetId?.trim() || undefined, - }, - }); - logBrowserActionResult(parent, result, `filled ${fields.length} field(s)`); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }); - - browser - .command("wait") - .description("Wait for time, selector, URL, load state, or JS conditions") - .argument("[selector]", "CSS selector to wait for (visible)") - .option("--time ", "Wait for N milliseconds", (v: string) => Number(v)) - .option("--text ", "Wait for text to appear") - .option("--text-gone ", "Wait for text to disappear") - .option("--url ", "Wait for URL (supports globs like **/dash)") - .option("--load ", "Wait for load state") - .option("--fn ", "Wait for JS condition (passed to waitForFunction)") - .option( - "--timeout-ms ", - "How long to wait for each condition (default: 20000)", - (v: string) => Number(v), - ) - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (selector: string | undefined, opts, cmd) => { - const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts); - try { - const sel = selector?.trim() || undefined; - const load = - opts.load === "load" || opts.load === "domcontentloaded" || opts.load === "networkidle" - ? (opts.load as "load" | "domcontentloaded" | "networkidle") - : undefined; - const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined; - const result = await callBrowserAct<{ result?: unknown }>({ - parent, - profile, - body: { - kind: "wait", - timeMs: Number.isFinite(opts.time) ? opts.time : undefined, - text: opts.text?.trim() || undefined, - textGone: opts.textGone?.trim() || undefined, - selector: sel, - url: opts.url?.trim() || undefined, - loadState: load, - fn: opts.fn?.trim() || undefined, - targetId: opts.targetId?.trim() || undefined, - timeoutMs, - }, - timeoutMs, - }); - logBrowserActionResult(parent, result, "wait complete"); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }); - - browser - .command("evaluate") - .description("Evaluate a function against the page or a ref") - .option("--fn ", "Function source, e.g. (el) => el.textContent") - .option("--ref ", "Ref from snapshot") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (opts, cmd) => { - const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts); - if (!opts.fn) { - defaultRuntime.error(danger("Missing --fn")); - defaultRuntime.exit(1); - return; - } - try { - const result = await callBrowserAct<{ result?: unknown }>({ - parent, - profile, - body: { - kind: "evaluate", - fn: opts.fn, - ref: opts.ref?.trim() || undefined, - targetId: opts.targetId?.trim() || undefined, - }, - }); - if (parent?.json) { - defaultRuntime.writeJson(result); - return; - } - defaultRuntime.writeJson(result.result ?? null); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }); -} +export * from "../../../extensions/browser/src/cli/browser-cli-actions-input/register.form-wait-eval.js"; diff --git a/src/cli/browser-cli-actions-input/register.navigation.ts b/src/cli/browser-cli-actions-input/register.navigation.ts index 9b15274c23e..17d2b345d85 100644 --- a/src/cli/browser-cli-actions-input/register.navigation.ts +++ b/src/cli/browser-cli-actions-input/register.navigation.ts @@ -1,70 +1 @@ -import type { Command } from "commander"; -import { danger } from "../../globals.js"; -import { defaultRuntime } from "../../runtime.js"; -import { runBrowserResizeWithOutput } from "../browser-cli-resize.js"; -import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js"; -import { requireRef, resolveBrowserActionContext } from "./shared.js"; - -export function registerBrowserNavigationCommands( - browser: Command, - parentOpts: (cmd: Command) => BrowserParentOpts, -) { - browser - .command("navigate") - .description("Navigate the current tab to a URL") - .argument("", "URL to navigate to") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (url: string, opts, cmd) => { - const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts); - try { - const result = await callBrowserRequest<{ url?: string }>( - parent, - { - method: "POST", - path: "/navigate", - query: profile ? { profile } : undefined, - body: { - url, - targetId: opts.targetId?.trim() || undefined, - }, - }, - { timeoutMs: 20000 }, - ); - if (parent?.json) { - defaultRuntime.writeJson(result); - return; - } - defaultRuntime.log(`navigated to ${result.url ?? url}`); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }); - - browser - .command("resize") - .description("Resize the viewport") - .argument("", "Viewport width", (v: string) => Number(v)) - .argument("", "Viewport height", (v: string) => Number(v)) - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (width: number, height: number, opts, cmd) => { - const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts); - try { - await runBrowserResizeWithOutput({ - parent, - profile, - width, - height, - targetId: opts.targetId, - timeoutMs: 20000, - successMessage: `resized to ${width}x${height}`, - }); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }); - - // Keep `requireRef` reachable; shared utilities are intended for other modules too. - void requireRef; -} +export * from "../../../extensions/browser/src/cli/browser-cli-actions-input/register.navigation.js"; diff --git a/src/cli/browser-cli-actions-input/register.ts b/src/cli/browser-cli-actions-input/register.ts index 973488a220e..7b3a2b70d6c 100644 --- a/src/cli/browser-cli-actions-input/register.ts +++ b/src/cli/browser-cli-actions-input/register.ts @@ -1,16 +1 @@ -import type { Command } from "commander"; -import type { BrowserParentOpts } from "../browser-cli-shared.js"; -import { registerBrowserElementCommands } from "./register.element.js"; -import { registerBrowserFilesAndDownloadsCommands } from "./register.files-downloads.js"; -import { registerBrowserFormWaitEvalCommands } from "./register.form-wait-eval.js"; -import { registerBrowserNavigationCommands } from "./register.navigation.js"; - -export function registerBrowserActionInputCommands( - browser: Command, - parentOpts: (cmd: Command) => BrowserParentOpts, -) { - registerBrowserNavigationCommands(browser, parentOpts); - registerBrowserElementCommands(browser, parentOpts); - registerBrowserFilesAndDownloadsCommands(browser, parentOpts); - registerBrowserFormWaitEvalCommands(browser, parentOpts); -} +export * from "../../../extensions/browser/src/cli/browser-cli-actions-input/register.js"; diff --git a/src/cli/browser-cli-actions-input/shared.ts b/src/cli/browser-cli-actions-input/shared.ts index 9bc3928dc5f..369888b4599 100644 --- a/src/cli/browser-cli-actions-input/shared.ts +++ b/src/cli/browser-cli-actions-input/shared.ts @@ -1,100 +1 @@ -import type { Command } from "commander"; -import type { BrowserFormField } from "../../browser/client-actions-core.js"; -import { - normalizeBrowserFormField, - normalizeBrowserFormFieldValue, -} from "../../browser/form-fields.js"; -import { danger } from "../../globals.js"; -import { defaultRuntime } from "../../runtime.js"; -import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js"; - -export type BrowserActionContext = { - parent: BrowserParentOpts; - profile: string | undefined; -}; - -export function resolveBrowserActionContext( - cmd: Command, - parentOpts: (cmd: Command) => BrowserParentOpts, -): BrowserActionContext { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - return { parent, profile }; -} - -export async function callBrowserAct(params: { - parent: BrowserParentOpts; - profile?: string; - body: Record; - timeoutMs?: number; -}): Promise { - return await callBrowserRequest( - params.parent, - { - method: "POST", - path: "/act", - query: params.profile ? { profile: params.profile } : undefined, - body: params.body, - }, - { timeoutMs: params.timeoutMs ?? 20000 }, - ); -} - -export function logBrowserActionResult( - parent: BrowserParentOpts, - result: unknown, - successMessage: string, -) { - if (parent?.json) { - defaultRuntime.writeJson(result); - return; - } - defaultRuntime.log(successMessage); -} - -export function requireRef(ref: string | undefined) { - const refValue = typeof ref === "string" ? ref.trim() : ""; - if (!refValue) { - defaultRuntime.error(danger("ref is required")); - defaultRuntime.exit(1); - return null; - } - return refValue; -} - -async function readFile(path: string): Promise { - const fs = await import("node:fs/promises"); - return await fs.readFile(path, "utf8"); -} - -export async function readFields(opts: { - fields?: string; - fieldsFile?: string; -}): Promise { - const payload = opts.fieldsFile ? await readFile(opts.fieldsFile) : (opts.fields ?? ""); - if (!payload.trim()) { - throw new Error("fields are required"); - } - const parsed = JSON.parse(payload) as unknown; - if (!Array.isArray(parsed)) { - throw new Error("fields must be an array"); - } - return parsed.map((entry, index) => { - if (!entry || typeof entry !== "object") { - throw new Error(`fields[${index}] must be an object`); - } - const rec = entry as Record; - const parsedField = normalizeBrowserFormField(rec); - if (!parsedField) { - throw new Error(`fields[${index}] must include ref`); - } - if ( - rec.value === undefined || - rec.value === null || - normalizeBrowserFormFieldValue(rec.value) !== undefined - ) { - return parsedField; - } - throw new Error(`fields[${index}].value must be string, number, boolean, or null`); - }); -} +export * from "../../../extensions/browser/src/cli/browser-cli-actions-input/shared.js"; diff --git a/src/cli/browser-cli-actions-observe.ts b/src/cli/browser-cli-actions-observe.ts index bea6ec58a27..a8d651515a2 100644 --- a/src/cli/browser-cli-actions-observe.ts +++ b/src/cli/browser-cli-actions-observe.ts @@ -1,116 +1 @@ -import type { Command } from "commander"; -import { danger } from "../globals.js"; -import { defaultRuntime } from "../runtime.js"; -import { shortenHomePath } from "../utils.js"; -import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js"; -import { runCommandWithRuntime } from "./cli-utils.js"; - -function runBrowserObserve(action: () => Promise) { - return runCommandWithRuntime(defaultRuntime, action, (err) => { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - }); -} - -export function registerBrowserActionObserveCommands( - browser: Command, - parentOpts: (cmd: Command) => BrowserParentOpts, -) { - browser - .command("console") - .description("Get recent console messages") - .option("--level ", "Filter by level (error, warn, info)") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - await runBrowserObserve(async () => { - const result = await callBrowserRequest<{ messages: unknown[] }>( - parent, - { - method: "GET", - path: "/console", - query: { - level: opts.level?.trim() || undefined, - targetId: opts.targetId?.trim() || undefined, - profile, - }, - }, - { timeoutMs: 20000 }, - ); - if (parent?.json) { - defaultRuntime.writeJson(result); - return; - } - defaultRuntime.writeJson(result.messages); - }); - }); - - browser - .command("pdf") - .description("Save page as PDF") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - await runBrowserObserve(async () => { - const result = await callBrowserRequest<{ path: string }>( - parent, - { - method: "POST", - path: "/pdf", - query: profile ? { profile } : undefined, - body: { targetId: opts.targetId?.trim() || undefined }, - }, - { timeoutMs: 20000 }, - ); - if (parent?.json) { - defaultRuntime.writeJson(result); - return; - } - defaultRuntime.log(`PDF: ${shortenHomePath(result.path)}`); - }); - }); - - browser - .command("responsebody") - .description("Wait for a network response and return its body") - .argument("", "URL (exact, substring, or glob like **/api)") - .option("--target-id ", "CDP target id (or unique prefix)") - .option( - "--timeout-ms ", - "How long to wait for the response (default: 20000)", - (v: string) => Number(v), - ) - .option("--max-chars ", "Max body chars to return (default: 200000)", (v: string) => - Number(v), - ) - .action(async (url: string, opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - await runBrowserObserve(async () => { - const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined; - const maxChars = Number.isFinite(opts.maxChars) ? opts.maxChars : undefined; - const result = await callBrowserRequest<{ response: { body: string } }>( - parent, - { - method: "POST", - path: "/response/body", - query: profile ? { profile } : undefined, - body: { - url, - targetId: opts.targetId?.trim() || undefined, - timeoutMs, - maxChars, - }, - }, - { timeoutMs: timeoutMs ?? 20000 }, - ); - if (parent?.json) { - defaultRuntime.writeJson(result); - return; - } - defaultRuntime.log(result.response.body); - }); - }); -} +export * from "../../extensions/browser/src/cli/browser-cli-actions-observe.js"; diff --git a/src/cli/browser-cli-debug.ts b/src/cli/browser-cli-debug.ts index fa6cc24c9ae..ad436b36766 100644 --- a/src/cli/browser-cli-debug.ts +++ b/src/cli/browser-cli-debug.ts @@ -1,232 +1 @@ -import type { Command } from "commander"; -import { danger } from "../globals.js"; -import { defaultRuntime } from "../runtime.js"; -import { shortenHomePath } from "../utils.js"; -import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js"; -import { runCommandWithRuntime } from "./cli-utils.js"; - -const BROWSER_DEBUG_TIMEOUT_MS = 20000; - -type BrowserRequestParams = Parameters[1]; - -type DebugContext = { - parent: BrowserParentOpts; - profile?: string; -}; - -function runBrowserDebug(action: () => Promise) { - return runCommandWithRuntime(defaultRuntime, action, (err) => { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - }); -} - -async function withDebugContext( - cmd: Command, - parentOpts: (cmd: Command) => BrowserParentOpts, - action: (context: DebugContext) => Promise, -) { - const parent = parentOpts(cmd); - await runBrowserDebug(() => - action({ - parent, - profile: parent.browserProfile, - }), - ); -} - -function printJsonResult(parent: BrowserParentOpts, result: unknown): boolean { - if (!parent.json) { - return false; - } - defaultRuntime.writeJson(result); - return true; -} - -async function callDebugRequest( - parent: BrowserParentOpts, - params: BrowserRequestParams, -): Promise { - return callBrowserRequest(parent, params, { timeoutMs: BROWSER_DEBUG_TIMEOUT_MS }); -} - -function resolveProfileQuery(profile?: string) { - return profile ? { profile } : undefined; -} - -function resolveDebugQuery(params: { - targetId?: unknown; - clear?: unknown; - profile?: string; - filter?: unknown; -}) { - return { - targetId: typeof params.targetId === "string" ? params.targetId.trim() || undefined : undefined, - filter: typeof params.filter === "string" ? params.filter.trim() || undefined : undefined, - clear: Boolean(params.clear), - profile: params.profile, - }; -} - -export function registerBrowserDebugCommands( - browser: Command, - parentOpts: (cmd: Command) => BrowserParentOpts, -) { - browser - .command("highlight") - .description("Highlight an element by ref") - .argument("", "Ref id from snapshot") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (ref: string, opts, cmd) => { - await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => { - const result = await callDebugRequest(parent, { - method: "POST", - path: "/highlight", - query: resolveProfileQuery(profile), - body: { - ref: ref.trim(), - targetId: opts.targetId?.trim() || undefined, - }, - }); - if (printJsonResult(parent, result)) { - return; - } - defaultRuntime.log(`highlighted ${ref.trim()}`); - }); - }); - - browser - .command("errors") - .description("Get recent page errors") - .option("--clear", "Clear stored errors after reading", false) - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (opts, cmd) => { - await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => { - const result = await callDebugRequest<{ - errors: Array<{ timestamp: string; name?: string; message: string }>; - }>(parent, { - method: "GET", - path: "/errors", - query: resolveDebugQuery({ - targetId: opts.targetId, - clear: opts.clear, - profile, - }), - }); - if (printJsonResult(parent, result)) { - return; - } - if (!result.errors.length) { - defaultRuntime.log("No page errors."); - return; - } - defaultRuntime.log( - result.errors - .map((e) => `${e.timestamp} ${e.name ? `${e.name}: ` : ""}${e.message}`) - .join("\n"), - ); - }); - }); - - browser - .command("requests") - .description("Get recent network requests (best-effort)") - .option("--filter ", "Only show URLs that contain this substring") - .option("--clear", "Clear stored requests after reading", false) - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (opts, cmd) => { - await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => { - const result = await callDebugRequest<{ - requests: Array<{ - timestamp: string; - method: string; - status?: number; - ok?: boolean; - url: string; - failureText?: string; - }>; - }>(parent, { - method: "GET", - path: "/requests", - query: resolveDebugQuery({ - targetId: opts.targetId, - filter: opts.filter, - clear: opts.clear, - profile, - }), - }); - if (printJsonResult(parent, result)) { - return; - } - if (!result.requests.length) { - defaultRuntime.log("No requests recorded."); - return; - } - defaultRuntime.log( - result.requests - .map((r) => { - const status = typeof r.status === "number" ? ` ${r.status}` : ""; - const ok = r.ok === true ? " ok" : r.ok === false ? " fail" : ""; - const fail = r.failureText ? ` (${r.failureText})` : ""; - return `${r.timestamp} ${r.method}${status}${ok} ${r.url}${fail}`; - }) - .join("\n"), - ); - }); - }); - - const trace = browser.command("trace").description("Record a Playwright trace"); - - trace - .command("start") - .description("Start trace recording") - .option("--target-id ", "CDP target id (or unique prefix)") - .option("--no-screenshots", "Disable screenshots") - .option("--no-snapshots", "Disable snapshots") - .option("--sources", "Include sources (bigger traces)", false) - .action(async (opts, cmd) => { - await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => { - const result = await callDebugRequest(parent, { - method: "POST", - path: "/trace/start", - query: resolveProfileQuery(profile), - body: { - targetId: opts.targetId?.trim() || undefined, - screenshots: Boolean(opts.screenshots), - snapshots: Boolean(opts.snapshots), - sources: Boolean(opts.sources), - }, - }); - if (printJsonResult(parent, result)) { - return; - } - defaultRuntime.log("trace started"); - }); - }); - - trace - .command("stop") - .description("Stop trace recording and write a .zip") - .option( - "--out ", - "Output path within openclaw temp dir (e.g. trace.zip or /tmp/openclaw/trace.zip)", - ) - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (opts, cmd) => { - await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => { - const result = await callDebugRequest<{ path: string }>(parent, { - method: "POST", - path: "/trace/stop", - query: resolveProfileQuery(profile), - body: { - targetId: opts.targetId?.trim() || undefined, - path: opts.out?.trim() || undefined, - }, - }); - if (printJsonResult(parent, result)) { - return; - } - defaultRuntime.log(`TRACE:${shortenHomePath(result.path)}`); - }); - }); -} +export * from "../../extensions/browser/src/cli/browser-cli-debug.js"; diff --git a/src/cli/browser-cli-examples.ts b/src/cli/browser-cli-examples.ts index 7e6df7cd6db..98c82ff5b2e 100644 --- a/src/cli/browser-cli-examples.ts +++ b/src/cli/browser-cli-examples.ts @@ -1,34 +1 @@ -export const browserCoreExamples = [ - "openclaw browser status", - "openclaw browser start", - "openclaw browser stop", - "openclaw browser tabs", - "openclaw browser open https://example.com", - "openclaw browser focus abcd1234", - "openclaw browser close abcd1234", - "openclaw browser screenshot", - "openclaw browser screenshot --full-page", - "openclaw browser screenshot --ref 12", - "openclaw browser snapshot", - "openclaw browser snapshot --format aria --limit 200", - "openclaw browser snapshot --efficient", - "openclaw browser snapshot --labels", -]; - -export const browserActionExamples = [ - "openclaw browser navigate https://example.com", - "openclaw browser resize 1280 720", - "openclaw browser click 12 --double", - 'openclaw browser type 23 "hello" --submit', - "openclaw browser press Enter", - "openclaw browser hover 44", - "openclaw browser drag 10 11", - "openclaw browser select 9 OptionA OptionB", - "openclaw browser upload /tmp/openclaw/uploads/file.pdf", - 'openclaw browser fill --fields \'[{"ref":"1","value":"Ada"}]\'', - "openclaw browser dialog --accept", - 'openclaw browser wait --text "Done"', - "openclaw browser evaluate --fn '(el) => el.textContent' --ref 7", - "openclaw browser console --level error", - "openclaw browser pdf", -]; +export * from "../../extensions/browser/src/cli/browser-cli-examples.js"; diff --git a/src/cli/browser-cli-inspect.test.ts b/src/cli/browser-cli-inspect.test.ts index 7a76b128c57..dad3055d8a5 100644 --- a/src/cli/browser-cli-inspect.test.ts +++ b/src/cli/browser-cli-inspect.test.ts @@ -46,15 +46,17 @@ const sharedMocks = vi.hoisted(() => ({ }, ), })); -vi.mock("./browser-cli-shared.js", () => ({ +vi.mock("../../extensions/browser/src/cli/browser-cli-shared.js", () => ({ callBrowserRequest: sharedMocks.callBrowserRequest, })); -vi.mock("../runtime.js", () => ({ +vi.mock("../../extensions/browser/src/core-api.js", async () => ({ + ...(await vi.importActual("../../extensions/browser/src/core-api.js")), defaultRuntime: runtime, + loadConfig: configMocks.loadConfig, })); -let registerBrowserInspectCommands: typeof import("./browser-cli-inspect.js").registerBrowserInspectCommands; +let registerBrowserInspectCommands: typeof import("../../extensions/browser/src/cli/browser-cli-inspect.js").registerBrowserInspectCommands; type SnapshotDefaultsCase = { label: string; @@ -78,7 +80,8 @@ describe("browser cli snapshot defaults", () => { const runSnapshot = async (args: string[]) => await runBrowserInspect(["snapshot", ...args]); beforeAll(async () => { - ({ registerBrowserInspectCommands } = await import("./browser-cli-inspect.js")); + ({ registerBrowserInspectCommands } = + await import("../../extensions/browser/src/cli/browser-cli-inspect.js")); }); afterEach(() => { diff --git a/src/cli/browser-cli-inspect.ts b/src/cli/browser-cli-inspect.ts index 021fa7ef02d..df7ac1ca434 100644 --- a/src/cli/browser-cli-inspect.ts +++ b/src/cli/browser-cli-inspect.ts @@ -1,154 +1 @@ -import type { Command } from "commander"; -import type { SnapshotResult } from "../browser/client.js"; -import { loadConfig } from "../config/config.js"; -import { danger } from "../globals.js"; -import { defaultRuntime } from "../runtime.js"; -import { shortenHomePath } from "../utils.js"; -import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js"; - -export function registerBrowserInspectCommands( - browser: Command, - parentOpts: (cmd: Command) => BrowserParentOpts, -) { - browser - .command("screenshot") - .description("Capture a screenshot (MEDIA:)") - .argument("[targetId]", "CDP target id (or unique prefix)") - .option("--full-page", "Capture full scrollable page", false) - .option("--ref ", "ARIA ref from ai snapshot") - .option("--element ", "CSS selector for element screenshot") - .option("--type ", "Output type (default: png)", "png") - .action(async (targetId: string | undefined, opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - try { - const result = await callBrowserRequest<{ path: string }>( - parent, - { - method: "POST", - path: "/screenshot", - query: profile ? { profile } : undefined, - body: { - targetId: targetId?.trim() || undefined, - fullPage: Boolean(opts.fullPage), - ref: opts.ref?.trim() || undefined, - element: opts.element?.trim() || undefined, - type: opts.type === "jpeg" ? "jpeg" : "png", - }, - }, - { timeoutMs: 20000 }, - ); - if (parent?.json) { - defaultRuntime.writeJson(result); - return; - } - defaultRuntime.log(`MEDIA:${shortenHomePath(result.path)}`); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }); - - browser - .command("snapshot") - .description("Capture a snapshot (default: ai; aria is the accessibility tree)") - .option("--format ", "Snapshot format (default: ai)", "ai") - .option("--target-id ", "CDP target id (or unique prefix)") - .option("--limit ", "Max nodes (default: 500/800)", (v: string) => Number(v)) - .option("--mode ", "Snapshot preset (efficient)") - .option("--efficient", "Use the efficient snapshot preset", false) - .option("--interactive", "Role snapshot: interactive elements only", false) - .option("--compact", "Role snapshot: compact output", false) - .option("--depth ", "Role snapshot: max depth", (v: string) => Number(v)) - .option("--selector ", "Role snapshot: scope to CSS selector") - .option("--frame ", "Role snapshot: scope to an iframe selector") - .option("--labels", "Include viewport label overlay screenshot", false) - .option("--out ", "Write snapshot to a file") - .action(async (opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - const format = opts.format === "aria" ? "aria" : "ai"; - const configMode = - format === "ai" && loadConfig().browser?.snapshotDefaults?.mode === "efficient" - ? "efficient" - : undefined; - const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : configMode; - try { - const query: Record = { - format, - targetId: opts.targetId?.trim() || undefined, - limit: Number.isFinite(opts.limit) ? opts.limit : undefined, - interactive: opts.interactive ? true : undefined, - compact: opts.compact ? true : undefined, - depth: Number.isFinite(opts.depth) ? opts.depth : undefined, - selector: opts.selector?.trim() || undefined, - frame: opts.frame?.trim() || undefined, - labels: opts.labels ? true : undefined, - mode, - profile, - }; - const result = await callBrowserRequest( - parent, - { - method: "GET", - path: "/snapshot", - query, - }, - { timeoutMs: 20000 }, - ); - - if (opts.out) { - const fs = await import("node:fs/promises"); - if (result.format === "ai") { - await fs.writeFile(opts.out, result.snapshot, "utf8"); - } else { - const payload = JSON.stringify(result, null, 2); - await fs.writeFile(opts.out, payload, "utf8"); - } - if (parent?.json) { - defaultRuntime.writeJson({ - ok: true, - out: opts.out, - ...(result.format === "ai" && result.imagePath - ? { imagePath: result.imagePath } - : {}), - }); - } else { - defaultRuntime.log(shortenHomePath(opts.out)); - if (result.format === "ai" && result.imagePath) { - defaultRuntime.log(`MEDIA:${shortenHomePath(result.imagePath)}`); - } - } - return; - } - - if (parent?.json) { - defaultRuntime.writeJson(result); - return; - } - - if (result.format === "ai") { - defaultRuntime.log(result.snapshot); - if (result.imagePath) { - defaultRuntime.log(`MEDIA:${shortenHomePath(result.imagePath)}`); - } - return; - } - - const nodes = "nodes" in result ? result.nodes : []; - defaultRuntime.log( - nodes - .map((n) => { - const indent = " ".repeat(Math.min(20, n.depth)); - const name = n.name ? ` "${n.name}"` : ""; - const value = n.value ? ` = "${n.value}"` : ""; - return `${indent}- ${n.role}${name}${value}`; - }) - .join("\n"), - ); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }); -} +export * from "../../extensions/browser/src/cli/browser-cli-inspect.js"; diff --git a/src/cli/browser-cli-manage.test-helpers.ts b/src/cli/browser-cli-manage.test-helpers.ts index 1d3f8547f9c..a5d985bfc00 100644 --- a/src/cli/browser-cli-manage.test-helpers.ts +++ b/src/cli/browser-cli-manage.test-helpers.ts @@ -1,5 +1,5 @@ import { vi } from "vitest"; -import { registerBrowserManageCommands } from "./browser-cli-manage.js"; +import { registerBrowserManageCommands } from "../../extensions/browser/src/cli/browser-cli-manage.js"; import { createBrowserProgram } from "./browser-cli-test-helpers.js"; type BrowserRequest = { path?: string }; @@ -31,20 +31,16 @@ const browserManageMocks = vi.hoisted(() => ({ ), })); -vi.mock("./browser-cli-shared.js", () => ({ +vi.mock("../../extensions/browser/src/cli/browser-cli-shared.js", () => ({ callBrowserRequest: browserManageMocks.callBrowserRequest, })); -vi.mock("./cli-utils.js", async () => ({ +vi.mock("../../extensions/browser/src/core-api.js", async () => ({ + ...(await vi.importActual("../../extensions/browser/src/core-api.js")), + ...(await (await import("./browser-cli-test-helpers.js")).createBrowserCliRuntimeMockModule()), ...(await (await import("./browser-cli-test-helpers.js")).createBrowserCliUtilsMockModule()), })); -vi.mock( - "../runtime.js", - async () => - await (await import("./browser-cli-test-helpers.js")).createBrowserCliRuntimeMockModule(), -); - export function createBrowserManageProgram(params?: { withParentTimeout?: boolean }) { const { program, browser, parentOpts } = createBrowserProgram(); if (params?.withParentTimeout) { diff --git a/src/cli/browser-cli-manage.ts b/src/cli/browser-cli-manage.ts index dea1111d3cc..d7f1b5a8478 100644 --- a/src/cli/browser-cli-manage.ts +++ b/src/cli/browser-cli-manage.ts @@ -1,533 +1 @@ -import type { Command } from "commander"; -import { redactCdpUrl } from "../browser/cdp.helpers.js"; -import type { - BrowserTransport, - BrowserCreateProfileResult, - BrowserDeleteProfileResult, - BrowserResetProfileResult, - BrowserStatus, - BrowserTab, - ProfileStatus, -} from "../browser/client.js"; -import { danger, info } from "../globals.js"; -import { defaultRuntime } from "../runtime.js"; -import { shortenHomePath } from "../utils.js"; -import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js"; -import { runCommandWithRuntime } from "./cli-utils.js"; - -const BROWSER_MANAGE_REQUEST_TIMEOUT_MS = 45_000; - -function resolveProfileQuery(profile?: string) { - return profile ? { profile } : undefined; -} - -function printJsonResult(parent: BrowserParentOpts, payload: unknown): boolean { - if (!parent?.json) { - return false; - } - defaultRuntime.writeJson(payload); - return true; -} - -async function callTabAction( - parent: BrowserParentOpts, - profile: string | undefined, - body: { action: "new" | "select" | "close"; index?: number }, -) { - return callBrowserRequest( - parent, - { - method: "POST", - path: "/tabs/action", - query: resolveProfileQuery(profile), - body, - }, - { timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS }, - ); -} - -async function fetchBrowserStatus( - parent: BrowserParentOpts, - profile?: string, -): Promise { - return await callBrowserRequest( - parent, - { - method: "GET", - path: "/", - query: resolveProfileQuery(profile), - }, - { - timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS, - }, - ); -} - -async function runBrowserToggle( - parent: BrowserParentOpts, - params: { profile?: string; path: string }, -) { - await callBrowserRequest(parent, { - method: "POST", - path: params.path, - query: resolveProfileQuery(params.profile), - }); - const status = await fetchBrowserStatus(parent, params.profile); - if (printJsonResult(parent, status)) { - return; - } - const name = status.profile ?? "openclaw"; - defaultRuntime.log(info(`🦞 browser [${name}] running: ${status.running}`)); -} - -function runBrowserCommand(action: () => Promise) { - return runCommandWithRuntime(defaultRuntime, action, (err) => { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - }); -} - -function logBrowserTabs(tabs: BrowserTab[], json?: boolean) { - if (json) { - defaultRuntime.writeJson({ tabs }); - return; - } - if (tabs.length === 0) { - defaultRuntime.log("No tabs (browser closed or no targets)."); - return; - } - defaultRuntime.log( - tabs - .map((t, i) => `${i + 1}. ${t.title || "(untitled)"}\n ${t.url}\n id: ${t.targetId}`) - .join("\n"), - ); -} - -function usesChromeMcpTransport(params: { - transport?: BrowserTransport; - driver?: "openclaw" | "existing-session"; -}): boolean { - return params.transport === "chrome-mcp" || params.driver === "existing-session"; -} - -function formatBrowserConnectionSummary(params: { - transport?: BrowserTransport; - driver?: "openclaw" | "existing-session"; - isRemote?: boolean; - cdpPort?: number | null; - cdpUrl?: string | null; - userDataDir?: string | null; -}): string { - if (usesChromeMcpTransport(params)) { - const userDataDir = params.userDataDir ? shortenHomePath(params.userDataDir) : null; - return userDataDir - ? `transport: chrome-mcp, userDataDir: ${userDataDir}` - : "transport: chrome-mcp"; - } - if (params.isRemote) { - return `cdpUrl: ${params.cdpUrl ?? "(unset)"}`; - } - return `port: ${params.cdpPort ?? "(unset)"}`; -} - -export function registerBrowserManageCommands( - browser: Command, - parentOpts: (cmd: Command) => BrowserParentOpts, -) { - browser - .command("status") - .description("Show browser status") - .action(async (_opts, cmd) => { - const parent = parentOpts(cmd); - await runBrowserCommand(async () => { - const status = await fetchBrowserStatus(parent, parent?.browserProfile); - if (printJsonResult(parent, status)) { - return; - } - const detectedPath = status.detectedExecutablePath ?? status.executablePath; - const detectedDisplay = detectedPath ? shortenHomePath(detectedPath) : "auto"; - defaultRuntime.log( - [ - `profile: ${status.profile ?? "openclaw"}`, - `enabled: ${status.enabled}`, - `running: ${status.running}`, - `transport: ${ - usesChromeMcpTransport(status) ? "chrome-mcp" : (status.transport ?? "cdp") - }`, - ...(!usesChromeMcpTransport(status) - ? [ - `cdpPort: ${status.cdpPort ?? "(unset)"}`, - `cdpUrl: ${redactCdpUrl(status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`)}`, - ] - : status.userDataDir - ? [`userDataDir: ${shortenHomePath(status.userDataDir)}`] - : []), - `browser: ${status.chosenBrowser ?? "unknown"}`, - `detectedBrowser: ${status.detectedBrowser ?? "unknown"}`, - `detectedPath: ${detectedDisplay}`, - `profileColor: ${status.color}`, - ...(status.detectError ? [`detectError: ${status.detectError}`] : []), - ].join("\n"), - ); - }); - }); - - browser - .command("start") - .description("Start the browser (no-op if already running)") - .action(async (_opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - await runBrowserCommand(async () => { - await runBrowserToggle(parent, { profile, path: "/start" }); - }); - }); - - browser - .command("stop") - .description("Stop the browser (best-effort)") - .action(async (_opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - await runBrowserCommand(async () => { - await runBrowserToggle(parent, { profile, path: "/stop" }); - }); - }); - - browser - .command("reset-profile") - .description("Reset browser profile (moves it to Trash)") - .action(async (_opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - await runBrowserCommand(async () => { - const result = await callBrowserRequest( - parent, - { - method: "POST", - path: "/reset-profile", - query: resolveProfileQuery(profile), - }, - { timeoutMs: 20000 }, - ); - if (printJsonResult(parent, result)) { - return; - } - if (!result.moved) { - defaultRuntime.log(info(`🦞 browser profile already missing.`)); - return; - } - const dest = result.to ?? result.from; - defaultRuntime.log(info(`🦞 browser profile moved to Trash (${dest})`)); - }); - }); - - browser - .command("tabs") - .description("List open tabs") - .action(async (_opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - await runBrowserCommand(async () => { - const result = await callBrowserRequest<{ running: boolean; tabs: BrowserTab[] }>( - parent, - { - method: "GET", - path: "/tabs", - query: resolveProfileQuery(profile), - }, - { timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS }, - ); - const tabs = result.tabs ?? []; - logBrowserTabs(tabs, parent?.json); - }); - }); - - const tab = browser - .command("tab") - .description("Tab shortcuts (index-based)") - .action(async (_opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - await runBrowserCommand(async () => { - const result = await callBrowserRequest<{ ok: true; tabs: BrowserTab[] }>( - parent, - { - method: "POST", - path: "/tabs/action", - query: resolveProfileQuery(profile), - body: { - action: "list", - }, - }, - { timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS }, - ); - const tabs = result.tabs ?? []; - logBrowserTabs(tabs, parent?.json); - }); - }); - - tab - .command("new") - .description("Open a new tab (about:blank)") - .action(async (_opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - await runBrowserCommand(async () => { - const result = await callTabAction(parent, profile, { action: "new" }); - if (printJsonResult(parent, result)) { - return; - } - defaultRuntime.log("opened new tab"); - }); - }); - - tab - .command("select") - .description("Focus tab by index (1-based)") - .argument("", "Tab index (1-based)", (v: string) => Number(v)) - .action(async (index: number, _opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - if (!Number.isFinite(index) || index < 1) { - defaultRuntime.error(danger("index must be a positive number")); - defaultRuntime.exit(1); - return; - } - await runBrowserCommand(async () => { - const result = await callTabAction(parent, profile, { - action: "select", - index: Math.floor(index) - 1, - }); - if (printJsonResult(parent, result)) { - return; - } - defaultRuntime.log(`selected tab ${Math.floor(index)}`); - }); - }); - - tab - .command("close") - .description("Close tab by index (1-based); default: first tab") - .argument("[index]", "Tab index (1-based)", (v: string) => Number(v)) - .action(async (index: number | undefined, _opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - const idx = - typeof index === "number" && Number.isFinite(index) ? Math.floor(index) - 1 : undefined; - if (typeof idx === "number" && idx < 0) { - defaultRuntime.error(danger("index must be >= 1")); - defaultRuntime.exit(1); - return; - } - await runBrowserCommand(async () => { - const result = await callTabAction(parent, profile, { action: "close", index: idx }); - if (printJsonResult(parent, result)) { - return; - } - defaultRuntime.log("closed tab"); - }); - }); - - browser - .command("open") - .description("Open a URL in a new tab") - .argument("", "URL to open") - .action(async (url: string, _opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - await runBrowserCommand(async () => { - const tab = await callBrowserRequest( - parent, - { - method: "POST", - path: "/tabs/open", - query: resolveProfileQuery(profile), - body: { url }, - }, - { timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS }, - ); - if (printJsonResult(parent, tab)) { - return; - } - defaultRuntime.log(`opened: ${tab.url}\nid: ${tab.targetId}`); - }); - }); - - browser - .command("focus") - .description("Focus a tab by target id (or unique prefix)") - .argument("", "Target id or unique prefix") - .action(async (targetId: string, _opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - await runBrowserCommand(async () => { - await callBrowserRequest( - parent, - { - method: "POST", - path: "/tabs/focus", - query: resolveProfileQuery(profile), - body: { targetId }, - }, - { timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS }, - ); - if (printJsonResult(parent, { ok: true })) { - return; - } - defaultRuntime.log(`focused tab ${targetId}`); - }); - }); - - browser - .command("close") - .description("Close a tab (target id optional)") - .argument("[targetId]", "Target id or unique prefix (optional)") - .action(async (targetId: string | undefined, _opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - await runBrowserCommand(async () => { - if (targetId?.trim()) { - await callBrowserRequest( - parent, - { - method: "DELETE", - path: `/tabs/${encodeURIComponent(targetId.trim())}`, - query: resolveProfileQuery(profile), - }, - { timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS }, - ); - } else { - await callBrowserRequest( - parent, - { - method: "POST", - path: "/act", - query: resolveProfileQuery(profile), - body: { kind: "close" }, - }, - { timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS }, - ); - } - if (printJsonResult(parent, { ok: true })) { - return; - } - defaultRuntime.log("closed tab"); - }); - }); - - // Profile management commands - browser - .command("profiles") - .description("List all browser profiles") - .action(async (_opts, cmd) => { - const parent = parentOpts(cmd); - await runBrowserCommand(async () => { - const result = await callBrowserRequest<{ profiles: ProfileStatus[] }>( - parent, - { - method: "GET", - path: "/profiles", - }, - { timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS }, - ); - const profiles = result.profiles ?? []; - if (printJsonResult(parent, { profiles })) { - return; - } - if (profiles.length === 0) { - defaultRuntime.log("No profiles configured."); - return; - } - defaultRuntime.log( - profiles - .map((p) => { - const status = p.running ? "running" : "stopped"; - const tabs = p.running ? ` (${p.tabCount} tabs)` : ""; - const def = p.isDefault ? " [default]" : ""; - const loc = formatBrowserConnectionSummary(p); - const remote = p.isRemote ? " [remote]" : ""; - const driver = p.driver !== "openclaw" ? ` [${p.driver}]` : ""; - return `${p.name}: ${status}${tabs}${def}${remote}${driver}\n ${loc}, color: ${p.color}`; - }) - .join("\n"), - ); - }); - }); - - browser - .command("create-profile") - .description("Create a new browser profile") - .requiredOption("--name ", "Profile name (lowercase, numbers, hyphens)") - .option("--color ", "Profile color (hex format, e.g. #0066CC)") - .option("--cdp-url ", "CDP URL for remote Chrome (http/https)") - .option("--user-data-dir ", "User data dir for existing-session Chromium attach") - .option("--driver ", "Profile driver (openclaw|existing-session). Default: openclaw") - .action( - async ( - opts: { - name: string; - color?: string; - cdpUrl?: string; - userDataDir?: string; - driver?: string; - }, - cmd, - ) => { - const parent = parentOpts(cmd); - await runBrowserCommand(async () => { - const result = await callBrowserRequest( - parent, - { - method: "POST", - path: "/profiles/create", - body: { - name: opts.name, - color: opts.color, - cdpUrl: opts.cdpUrl, - userDataDir: opts.userDataDir, - driver: opts.driver === "existing-session" ? "existing-session" : undefined, - }, - }, - { timeoutMs: 10_000 }, - ); - if (printJsonResult(parent, result)) { - return; - } - const loc = ` ${formatBrowserConnectionSummary(result)}`; - defaultRuntime.log( - info( - `🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}${ - result.userDataDir ? `\n userDataDir: ${shortenHomePath(result.userDataDir)}` : "" - }${opts.driver === "existing-session" ? "\n driver: existing-session" : ""}`, - ), - ); - }); - }, - ); - - browser - .command("delete-profile") - .description("Delete a browser profile") - .requiredOption("--name ", "Profile name to delete") - .action(async (opts: { name: string }, cmd) => { - const parent = parentOpts(cmd); - await runBrowserCommand(async () => { - const result = await callBrowserRequest( - parent, - { - method: "DELETE", - path: `/profiles/${encodeURIComponent(opts.name)}`, - }, - { timeoutMs: 20_000 }, - ); - if (printJsonResult(parent, result)) { - return; - } - const msg = result.deleted - ? `🦞 Deleted profile "${result.profile}" (user data removed)` - : `🦞 Deleted profile "${result.profile}" (no user data found)`; - defaultRuntime.log(info(msg)); - }); - }); -} +export * from "../../extensions/browser/src/cli/browser-cli-manage.js"; diff --git a/src/cli/browser-cli-resize.ts b/src/cli/browser-cli-resize.ts index 840cf17fd62..4aa40d1cf33 100644 --- a/src/cli/browser-cli-resize.ts +++ b/src/cli/browser-cli-resize.ts @@ -1,37 +1 @@ -import { danger } from "../globals.js"; -import { defaultRuntime } from "../runtime.js"; -import { callBrowserResize, type BrowserParentOpts } from "./browser-cli-shared.js"; - -export async function runBrowserResizeWithOutput(params: { - parent: BrowserParentOpts; - profile?: string; - width: number; - height: number; - targetId?: string; - timeoutMs?: number; - successMessage: string; -}): Promise { - const { width, height } = params; - if (!Number.isFinite(width) || !Number.isFinite(height)) { - defaultRuntime.error(danger("width and height must be numbers")); - defaultRuntime.exit(1); - return; - } - - const result = await callBrowserResize( - params.parent, - { - profile: params.profile, - width, - height, - targetId: params.targetId, - }, - { timeoutMs: params.timeoutMs ?? 20000 }, - ); - - if (params.parent?.json) { - defaultRuntime.writeJson(result); - return; - } - defaultRuntime.log(params.successMessage); -} +export * from "../../extensions/browser/src/cli/browser-cli-resize.js"; diff --git a/src/cli/browser-cli-shared.ts b/src/cli/browser-cli-shared.ts index 2f2e1436a93..4d0f347e3e9 100644 --- a/src/cli/browser-cli-shared.ts +++ b/src/cli/browser-cli-shared.ts @@ -1,84 +1 @@ -import type { GatewayRpcOpts } from "./gateway-rpc.js"; -import { callGatewayFromCli } from "./gateway-rpc.js"; - -export type BrowserParentOpts = GatewayRpcOpts & { - json?: boolean; - browserProfile?: string; -}; - -type BrowserRequestParams = { - method: "GET" | "POST" | "DELETE"; - path: string; - query?: Record; - body?: unknown; -}; - -function normalizeQuery(query: BrowserRequestParams["query"]): Record | undefined { - if (!query) { - return undefined; - } - const out: Record = {}; - for (const [key, value] of Object.entries(query)) { - if (value === undefined) { - continue; - } - out[key] = String(value); - } - return Object.keys(out).length ? out : undefined; -} - -export async function callBrowserRequest( - opts: BrowserParentOpts, - params: BrowserRequestParams, - extra?: { timeoutMs?: number; progress?: boolean }, -): Promise { - const resolvedTimeoutMs = - typeof extra?.timeoutMs === "number" && Number.isFinite(extra.timeoutMs) - ? Math.max(1, Math.floor(extra.timeoutMs)) - : typeof opts.timeout === "string" - ? Number.parseInt(opts.timeout, 10) - : undefined; - const resolvedTimeout = - typeof resolvedTimeoutMs === "number" && Number.isFinite(resolvedTimeoutMs) - ? resolvedTimeoutMs - : undefined; - const timeout = typeof resolvedTimeout === "number" ? String(resolvedTimeout) : opts.timeout; - const payload = await callGatewayFromCli( - "browser.request", - { ...opts, timeout }, - { - method: params.method, - path: params.path, - query: normalizeQuery(params.query), - body: params.body, - timeoutMs: resolvedTimeout, - }, - { progress: extra?.progress }, - ); - if (payload === undefined) { - throw new Error("Unexpected browser.request response"); - } - return payload as T; -} - -export async function callBrowserResize( - opts: BrowserParentOpts, - params: { profile?: string; width: number; height: number; targetId?: string }, - extra?: { timeoutMs?: number }, -): Promise { - return callBrowserRequest( - opts, - { - method: "POST", - path: "/act", - query: params.profile ? { profile: params.profile } : undefined, - body: { - kind: "resize", - width: params.width, - height: params.height, - targetId: params.targetId?.trim() || undefined, - }, - }, - extra, - ); -} +export * from "../../extensions/browser/src/cli/browser-cli-shared.js"; diff --git a/src/cli/browser-cli-state.cookies-storage.ts b/src/cli/browser-cli-state.cookies-storage.ts index d29c4c5f7d2..b0a79dd7fd5 100644 --- a/src/cli/browser-cli-state.cookies-storage.ts +++ b/src/cli/browser-cli-state.cookies-storage.ts @@ -1,229 +1 @@ -import type { Command } from "commander"; -import { danger } from "../globals.js"; -import { defaultRuntime } from "../runtime.js"; -import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js"; -import { inheritOptionFromParent } from "./command-options.js"; - -function resolveUrl(opts: { url?: string }, command: Command): string | undefined { - if (typeof opts.url === "string" && opts.url.trim()) { - return opts.url.trim(); - } - const inherited = inheritOptionFromParent(command, "url"); - if (typeof inherited === "string" && inherited.trim()) { - return inherited.trim(); - } - return undefined; -} - -function resolveTargetId(rawTargetId: unknown, command: Command): string | undefined { - const local = typeof rawTargetId === "string" ? rawTargetId.trim() : ""; - if (local) { - return local; - } - const inherited = inheritOptionFromParent(command, "targetId"); - if (typeof inherited !== "string") { - return undefined; - } - const trimmed = inherited.trim(); - return trimmed ? trimmed : undefined; -} - -async function runMutationRequest(params: { - parent: BrowserParentOpts; - request: Parameters[1]; - successMessage: string; -}) { - try { - const result = await callBrowserRequest(params.parent, params.request, { timeoutMs: 20000 }); - if (params.parent?.json) { - defaultRuntime.writeJson(result); - return; - } - defaultRuntime.log(params.successMessage); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } -} - -export function registerBrowserCookiesAndStorageCommands( - browser: Command, - parentOpts: (cmd: Command) => BrowserParentOpts, -) { - const cookies = browser.command("cookies").description("Read/write cookies"); - - cookies - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - const targetId = resolveTargetId(opts.targetId, cmd); - try { - const result = await callBrowserRequest<{ cookies?: unknown[] }>( - parent, - { - method: "GET", - path: "/cookies", - query: { - targetId, - profile, - }, - }, - { timeoutMs: 20000 }, - ); - if (parent?.json) { - defaultRuntime.writeJson(result); - return; - } - defaultRuntime.writeJson(result.cookies ?? []); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }); - - cookies - .command("set") - .description("Set a cookie (requires --url or domain+path)") - .argument("", "Cookie name") - .argument("", "Cookie value") - .option("--url ", "Cookie URL scope (recommended)") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (name: string, value: string, opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - const targetId = resolveTargetId(opts.targetId, cmd); - const url = resolveUrl(opts, cmd); - if (!url) { - defaultRuntime.error(danger("Missing required --url option for cookies set")); - defaultRuntime.exit(1); - return; - } - await runMutationRequest({ - parent, - request: { - method: "POST", - path: "/cookies/set", - query: profile ? { profile } : undefined, - body: { - targetId, - cookie: { name, value, url }, - }, - }, - successMessage: `cookie set: ${name}`, - }); - }); - - cookies - .command("clear") - .description("Clear all cookies") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - const targetId = resolveTargetId(opts.targetId, cmd); - await runMutationRequest({ - parent, - request: { - method: "POST", - path: "/cookies/clear", - query: profile ? { profile } : undefined, - body: { - targetId, - }, - }, - successMessage: "cookies cleared", - }); - }); - - const storage = browser.command("storage").description("Read/write localStorage/sessionStorage"); - - function registerStorageKind(kind: "local" | "session") { - const cmd = storage.command(kind).description(`${kind}Storage commands`); - - cmd - .command("get") - .description(`Get ${kind}Storage (all keys or one key)`) - .argument("[key]", "Key (optional)") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (key: string | undefined, opts, cmd2) => { - const parent = parentOpts(cmd2); - const profile = parent?.browserProfile; - const targetId = resolveTargetId(opts.targetId, cmd2); - try { - const result = await callBrowserRequest<{ values?: Record }>( - parent, - { - method: "GET", - path: `/storage/${kind}`, - query: { - key: key?.trim() || undefined, - targetId, - profile, - }, - }, - { timeoutMs: 20000 }, - ); - if (parent?.json) { - defaultRuntime.writeJson(result); - return; - } - defaultRuntime.writeJson(result.values ?? {}); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }); - - cmd - .command("set") - .description(`Set a ${kind}Storage key`) - .argument("", "Key") - .argument("", "Value") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (key: string, value: string, opts, cmd2) => { - const parent = parentOpts(cmd2); - const profile = parent?.browserProfile; - const targetId = resolveTargetId(opts.targetId, cmd2); - await runMutationRequest({ - parent, - request: { - method: "POST", - path: `/storage/${kind}/set`, - query: profile ? { profile } : undefined, - body: { - key, - value, - targetId, - }, - }, - successMessage: `${kind}Storage set: ${key}`, - }); - }); - - cmd - .command("clear") - .description(`Clear all ${kind}Storage keys`) - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (opts, cmd2) => { - const parent = parentOpts(cmd2); - const profile = parent?.browserProfile; - const targetId = resolveTargetId(opts.targetId, cmd2); - await runMutationRequest({ - parent, - request: { - method: "POST", - path: `/storage/${kind}/clear`, - query: profile ? { profile } : undefined, - body: { - targetId, - }, - }, - successMessage: `${kind}Storage cleared`, - }); - }); - } - - registerStorageKind("local"); - registerStorageKind("session"); -} +export * from "../../extensions/browser/src/cli/browser-cli-state.cookies-storage.js"; diff --git a/src/cli/browser-cli-state.option-collisions.test.ts b/src/cli/browser-cli-state.option-collisions.test.ts index 1421ca1bceb..7cd3f88fd92 100644 --- a/src/cli/browser-cli-state.option-collisions.test.ts +++ b/src/cli/browser-cli-state.option-collisions.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { registerBrowserStateCommands } from "./browser-cli-state.js"; +import { registerBrowserStateCommands } from "../../extensions/browser/src/cli/browser-cli-state.js"; import { createBrowserProgram as createBrowserProgramShared, getBrowserCliRuntime, @@ -11,19 +11,19 @@ const mocks = vi.hoisted(() => ({ runBrowserResizeWithOutput: vi.fn(async (_params: unknown) => {}), })); -vi.mock("./browser-cli-shared.js", () => ({ +vi.mock("../../extensions/browser/src/cli/browser-cli-shared.js", () => ({ callBrowserRequest: mocks.callBrowserRequest, })); -vi.mock("./browser-cli-resize.js", () => ({ +vi.mock("../../extensions/browser/src/cli/browser-cli-resize.js", () => ({ runBrowserResizeWithOutput: mocks.runBrowserResizeWithOutput, })); -vi.mock( - "../runtime.js", - async () => - await (await import("./browser-cli-test-helpers.js")).createBrowserCliRuntimeMockModule(), -); +vi.mock("../../extensions/browser/src/core-api.js", async () => ({ + ...(await vi.importActual("../../extensions/browser/src/core-api.js")), + ...(await (await import("./browser-cli-test-helpers.js")).createBrowserCliRuntimeMockModule()), + ...(await (await import("./browser-cli-test-helpers.js")).createBrowserCliUtilsMockModule()), +})); describe("browser state option collisions", () => { const createStateProgram = ({ withGatewayUrl = false } = {}) => { diff --git a/src/cli/browser-cli-state.ts b/src/cli/browser-cli-state.ts index 576e57723c8..3fa202ceb56 100644 --- a/src/cli/browser-cli-state.ts +++ b/src/cli/browser-cli-state.ts @@ -1,276 +1 @@ -import type { Command } from "commander"; -import { danger } from "../globals.js"; -import { defaultRuntime } from "../runtime.js"; -import { parseBooleanValue } from "../utils/boolean.js"; -import { runBrowserResizeWithOutput } from "./browser-cli-resize.js"; -import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js"; -import { registerBrowserCookiesAndStorageCommands } from "./browser-cli-state.cookies-storage.js"; -import { runCommandWithRuntime } from "./cli-utils.js"; - -function parseOnOff(raw: string): boolean | null { - const parsed = parseBooleanValue(raw); - return parsed === undefined ? null : parsed; -} - -function runBrowserCommand(action: () => Promise) { - return runCommandWithRuntime(defaultRuntime, action, (err) => { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - }); -} - -async function runBrowserSetRequest(params: { - parent: BrowserParentOpts; - path: string; - body: Record; - successMessage: string; -}) { - await runBrowserCommand(async () => { - const profile = params.parent?.browserProfile; - const result = await callBrowserRequest( - params.parent, - { - method: "POST", - path: params.path, - query: profile ? { profile } : undefined, - body: params.body, - }, - { timeoutMs: 20000 }, - ); - if (params.parent?.json) { - defaultRuntime.writeJson(result); - return; - } - defaultRuntime.log(params.successMessage); - }); -} - -export function registerBrowserStateCommands( - browser: Command, - parentOpts: (cmd: Command) => BrowserParentOpts, -) { - registerBrowserCookiesAndStorageCommands(browser, parentOpts); - - const set = browser.command("set").description("Browser environment settings"); - - set - .command("viewport") - .description("Set viewport size (alias for resize)") - .argument("", "Viewport width", (v: string) => Number(v)) - .argument("", "Viewport height", (v: string) => Number(v)) - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (width: number, height: number, opts, cmd) => { - const parent = parentOpts(cmd); - const profile = parent?.browserProfile; - await runBrowserCommand(async () => { - await runBrowserResizeWithOutput({ - parent, - profile, - width, - height, - targetId: opts.targetId, - timeoutMs: 20000, - successMessage: `viewport set: ${width}x${height}`, - }); - }); - }); - - set - .command("offline") - .description("Toggle offline mode") - .argument("", "on/off") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (value: string, opts, cmd) => { - const parent = parentOpts(cmd); - const offline = parseOnOff(value); - if (offline === null) { - defaultRuntime.error(danger("Expected on|off")); - defaultRuntime.exit(1); - return; - } - await runBrowserSetRequest({ - parent, - path: "/set/offline", - body: { - offline, - targetId: opts.targetId?.trim() || undefined, - }, - successMessage: `offline: ${offline}`, - }); - }); - - set - .command("headers") - .description("Set extra HTTP headers (JSON object)") - .argument("[headersJson]", "JSON object of headers (alternative to --headers-json)") - .option("--headers-json ", "JSON object of headers") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (headersJson: string | undefined, opts, cmd) => { - const parent = parentOpts(cmd); - await runBrowserCommand(async () => { - const headersJsonValue = - (typeof opts.headersJson === "string" && opts.headersJson.trim()) || - (headersJson?.trim() ? headersJson.trim() : undefined); - if (!headersJsonValue) { - throw new Error("Missing headers JSON (pass --headers-json or positional JSON argument)"); - } - const parsed = JSON.parse(String(headersJsonValue)) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error("Headers JSON must be a JSON object"); - } - const headers: Record = {}; - for (const [k, v] of Object.entries(parsed as Record)) { - if (typeof v === "string") { - headers[k] = v; - } - } - const profile = parent?.browserProfile; - const result = await callBrowserRequest( - parent, - { - method: "POST", - path: "/set/headers", - query: profile ? { profile } : undefined, - body: { - headers, - targetId: opts.targetId?.trim() || undefined, - }, - }, - { timeoutMs: 20000 }, - ); - if (parent?.json) { - defaultRuntime.writeJson(result); - return; - } - defaultRuntime.log("headers set"); - }); - }); - - set - .command("credentials") - .description("Set HTTP basic auth credentials") - .option("--clear", "Clear credentials", false) - .argument("[username]", "Username") - .argument("[password]", "Password") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (username: string | undefined, password: string | undefined, opts, cmd) => { - const parent = parentOpts(cmd); - await runBrowserSetRequest({ - parent, - path: "/set/credentials", - body: { - username: username?.trim() || undefined, - password, - clear: Boolean(opts.clear), - targetId: opts.targetId?.trim() || undefined, - }, - successMessage: opts.clear ? "credentials cleared" : "credentials set", - }); - }); - - set - .command("geo") - .description("Set geolocation (and grant permission)") - .option("--clear", "Clear geolocation + permissions", false) - .argument("[latitude]", "Latitude", (v: string) => Number(v)) - .argument("[longitude]", "Longitude", (v: string) => Number(v)) - .option("--accuracy ", "Accuracy in meters", (v: string) => Number(v)) - .option("--origin ", "Origin to grant permissions for") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (latitude: number | undefined, longitude: number | undefined, opts, cmd) => { - const parent = parentOpts(cmd); - await runBrowserSetRequest({ - parent, - path: "/set/geolocation", - body: { - latitude: Number.isFinite(latitude) ? latitude : undefined, - longitude: Number.isFinite(longitude) ? longitude : undefined, - accuracy: Number.isFinite(opts.accuracy) ? opts.accuracy : undefined, - origin: opts.origin?.trim() || undefined, - clear: Boolean(opts.clear), - targetId: opts.targetId?.trim() || undefined, - }, - successMessage: opts.clear ? "geolocation cleared" : "geolocation set", - }); - }); - - set - .command("media") - .description("Emulate prefers-color-scheme") - .argument("", "dark/light/none") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (value: string, opts, cmd) => { - const parent = parentOpts(cmd); - const v = value.trim().toLowerCase(); - const colorScheme = - v === "dark" ? "dark" : v === "light" ? "light" : v === "none" ? "none" : null; - if (!colorScheme) { - defaultRuntime.error(danger("Expected dark|light|none")); - defaultRuntime.exit(1); - return; - } - await runBrowserSetRequest({ - parent, - path: "/set/media", - body: { - colorScheme, - targetId: opts.targetId?.trim() || undefined, - }, - successMessage: `media colorScheme: ${colorScheme}`, - }); - }); - - set - .command("timezone") - .description("Override timezone (CDP)") - .argument("", "Timezone ID (e.g. America/New_York)") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (timezoneId: string, opts, cmd) => { - const parent = parentOpts(cmd); - await runBrowserSetRequest({ - parent, - path: "/set/timezone", - body: { - timezoneId, - targetId: opts.targetId?.trim() || undefined, - }, - successMessage: `timezone: ${timezoneId}`, - }); - }); - - set - .command("locale") - .description("Override locale (CDP)") - .argument("", "Locale (e.g. en-US)") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (locale: string, opts, cmd) => { - const parent = parentOpts(cmd); - await runBrowserSetRequest({ - parent, - path: "/set/locale", - body: { - locale, - targetId: opts.targetId?.trim() || undefined, - }, - successMessage: `locale: ${locale}`, - }); - }); - - set - .command("device") - .description('Apply a Playwright device descriptor (e.g. "iPhone 14")') - .argument("", "Device name (Playwright devices)") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (name: string, opts, cmd) => { - const parent = parentOpts(cmd); - await runBrowserSetRequest({ - parent, - path: "/set/device", - body: { - name, - targetId: opts.targetId?.trim() || undefined, - }, - successMessage: `device: ${name}`, - }); - }); -} +export * from "../../extensions/browser/src/cli/browser-cli-state.js"; diff --git a/src/cli/browser-cli.ts b/src/cli/browser-cli.ts index fd4c9a4a8b3..1f9675a6223 100644 --- a/src/cli/browser-cli.ts +++ b/src/cli/browser-cli.ts @@ -1,53 +1 @@ -import type { Command } from "commander"; -import { danger } from "../globals.js"; -import { defaultRuntime } from "../runtime.js"; -import { formatDocsLink } from "../terminal/links.js"; -import { theme } from "../terminal/theme.js"; -import { registerBrowserActionInputCommands } from "./browser-cli-actions-input.js"; -import { registerBrowserActionObserveCommands } from "./browser-cli-actions-observe.js"; -import { registerBrowserDebugCommands } from "./browser-cli-debug.js"; -import { browserActionExamples, browserCoreExamples } from "./browser-cli-examples.js"; -import { registerBrowserInspectCommands } from "./browser-cli-inspect.js"; -import { registerBrowserManageCommands } from "./browser-cli-manage.js"; -import type { BrowserParentOpts } from "./browser-cli-shared.js"; -import { registerBrowserStateCommands } from "./browser-cli-state.js"; -import { formatCliCommand } from "./command-format.js"; -import { addGatewayClientOptions } from "./gateway-rpc.js"; -import { formatHelpExamples } from "./help-format.js"; - -export function registerBrowserCli(program: Command) { - const browser = program - .command("browser") - .description("Manage OpenClaw's dedicated browser (Chrome/Chromium)") - .option("--browser-profile ", "Browser profile name (default from config)") - .option("--json", "Output machine-readable JSON", false) - .addHelpText( - "after", - () => - `\n${theme.heading("Examples:")}\n${formatHelpExamples( - [...browserCoreExamples, ...browserActionExamples].map((cmd) => [cmd, ""]), - true, - )}\n\n${theme.muted("Docs:")} ${formatDocsLink( - "/cli/browser", - "docs.openclaw.ai/cli/browser", - )}\n`, - ) - .action(() => { - browser.outputHelp(); - defaultRuntime.error( - danger(`Missing subcommand. Try: "${formatCliCommand("openclaw browser status")}"`), - ); - defaultRuntime.exit(1); - }); - - addGatewayClientOptions(browser); - - const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts; - - registerBrowserManageCommands(browser, parentOpts); - registerBrowserInspectCommands(browser, parentOpts); - registerBrowserActionInputCommands(browser, parentOpts); - registerBrowserActionObserveCommands(browser, parentOpts); - registerBrowserDebugCommands(browser, parentOpts); - registerBrowserStateCommands(browser, parentOpts); -} +export * from "../../extensions/browser/src/cli/browser-cli.js"; diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts index cbc235e41f9..f25cd63dcdf 100644 --- a/src/cli/completion-cli.ts +++ b/src/cli/completion-cli.ts @@ -13,7 +13,11 @@ import { } from "./completion-fish.js"; import { getCoreCliCommandNames, registerCoreCliByName } from "./program/command-registry.js"; import { getProgramContext } from "./program/program-context.js"; -import { getSubCliEntries, registerSubCliByName } from "./program/register.subclis.js"; +import { + getSubCliEntries, + loadValidatedConfigForPluginRegistration, + registerSubCliByName, +} from "./program/register.subclis.js"; const COMPLETION_SHELLS = ["zsh", "bash", "powershell", "fish"] as const; type CompletionShell = (typeof COMPLETION_SHELLS)[number]; @@ -273,6 +277,12 @@ export function registerCompletionCli(program: Command) { await registerSubCliByName(program, entry.name); } + const config = await loadValidatedConfigForPluginRegistration(); + if (config) { + const { registerPluginCliCommands } = await import("../plugins/cli.js"); + registerPluginCliCommands(program, config); + } + if (options.writeState) { const writeShells = options.shell ? [shell] : [...COMPLETION_SHELLS]; await writeCompletionCache({ diff --git a/src/cli/program/command-registry.test.ts b/src/cli/program/command-registry.test.ts index d91b274fdcf..3451ce64907 100644 --- a/src/cli/program/command-registry.test.ts +++ b/src/cli/program/command-registry.test.ts @@ -74,7 +74,6 @@ describe("command-registry", () => { expect(names).toContain("config"); expect(names).toContain("agents"); expect(names).toContain("backup"); - expect(names).toContain("browser"); expect(names).toContain("sessions"); expect(names).not.toContain("agent"); expect(names).not.toContain("status"); diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index 2f4aeec7fc7..43b57f638ce 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -203,19 +203,6 @@ const coreEntries: CoreCliEntry[] = [ mod.registerStatusHealthSessionsCommands(program); }, }, - { - commands: [ - { - name: "browser", - description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)", - hasSubcommands: true, - }, - ], - register: async ({ program }) => { - const mod = await import("../browser-cli.js"); - mod.registerBrowserCli(program); - }, - }, ]; export function getCoreCliCommandNames(): string[] { diff --git a/src/cli/program/core-command-descriptors.ts b/src/cli/program/core-command-descriptors.ts index 3d9568270cb..e36fca0ad24 100644 --- a/src/cli/program/core-command-descriptors.ts +++ b/src/cli/program/core-command-descriptors.ts @@ -81,11 +81,6 @@ export const CORE_CLI_COMMAND_DESCRIPTORS = [ description: "List stored conversation sessions", hasSubcommands: true, }, - { - name: "browser", - description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)", - hasSubcommands: true, - }, ] as const satisfies ReadonlyArray; export function getCoreCliCommandDescriptors(): ReadonlyArray { diff --git a/src/gateway/config-reload-plan.ts b/src/gateway/config-reload-plan.ts index 63eddd31c54..eaae31c2743 100644 --- a/src/gateway/config-reload-plan.ts +++ b/src/gateway/config-reload-plan.ts @@ -10,7 +10,6 @@ export type GatewayReloadPlan = { hotReasons: string[]; reloadHooks: boolean; restartGmailWatcher: boolean; - restartBrowserControl: boolean; restartCron: boolean; restartHeartbeat: boolean; restartHealthMonitor: boolean; @@ -27,7 +26,6 @@ type ReloadRule = { type ReloadAction = | "reload-hooks" | "restart-gmail-watcher" - | "restart-browser-control" | "restart-cron" | "restart-heartbeat" | "restart-health-monitor" @@ -77,11 +75,7 @@ const BASE_RELOAD_RULES: ReloadRule[] = [ }, { prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] }, { prefix: "cron", kind: "hot", actions: ["restart-cron"] }, - { - prefix: "browser", - kind: "hot", - actions: ["restart-browser-control"], - }, + { prefix: "browser", kind: "restart" }, ]; const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [ @@ -157,7 +151,6 @@ export function buildGatewayReloadPlan(changedPaths: string[]): GatewayReloadPla hotReasons: [], reloadHooks: false, restartGmailWatcher: false, - restartBrowserControl: false, restartCron: false, restartHeartbeat: false, restartHealthMonitor: false, @@ -178,9 +171,6 @@ export function buildGatewayReloadPlan(changedPaths: string[]): GatewayReloadPla case "restart-gmail-watcher": plan.restartGmailWatcher = true; break; - case "restart-browser-control": - plan.restartBrowserControl = true; - break; case "restart-cron": plan.restartCron = true; break; diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 9c4994541e9..eb33cb110f3 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -123,6 +123,13 @@ describe("buildGatewayReloadPlan", () => { expect(plan.restartReasons).toContain("gateway.port"); }); + it("restarts the gateway for browser plugin config changes", () => { + const plan = buildGatewayReloadPlan(["browser.enabled"]); + expect(plan.restartGateway).toBe(true); + expect(plan.restartReasons).toContain("browser.enabled"); + expect(plan.hotReasons).toEqual([]); + }); + it("restarts the Gmail watcher for hooks.gmail changes", () => { const plan = buildGatewayReloadPlan(["hooks.gmail.account"]); expect(plan.restartGateway).toBe(false); @@ -198,6 +205,11 @@ describe("buildGatewayReloadPlan", () => { }); it.each([ + { + path: "browser.enabled", + expectRestartGateway: true, + expectRestartReason: "browser.enabled", + }, { path: "gateway.channelHealthCheckMinutes", expectRestartGateway: false, diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 2edac06885f..6f6934855a5 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; import { authorizeOperatorScopesForMethod, isGatewayMethodClassified, @@ -7,6 +9,10 @@ import { import { listGatewayMethods } from "./server-methods-list.js"; import { coreGatewayHandlers } from "./server-methods.js"; +afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); +}); + describe("method scope resolution", () => { it.each([ ["sessions.resolve", ["operator.read"]], @@ -31,6 +37,18 @@ describe("method scope resolution", () => { it("returns empty scopes for unknown methods", () => { expect(resolveLeastPrivilegeOperatorScopesForMethod("totally.unknown.method")).toEqual([]); }); + + it("reads plugin-registered gateway method scopes from the active plugin registry", () => { + const registry = createEmptyPluginRegistry(); + registry.gatewayMethodScopes = { + "browser.request": "operator.write", + }; + setActivePluginRegistry(registry); + + expect(resolveLeastPrivilegeOperatorScopesForMethod("browser.request")).toEqual([ + "operator.write", + ]); + }); }); describe("operator scope authorization", () => { diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index e22023eeaf1..21dfbc7fdd1 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -1,3 +1,5 @@ +import { getActivePluginRegistry } from "../plugins/runtime.js"; + export const ADMIN_SCOPE = "operator.admin" as const; export const READ_SCOPE = "operator.read" as const; export const WRITE_SCOPE = "operator.write" as const; @@ -112,7 +114,6 @@ const METHOD_SCOPE_GROUPS: Record = { "sessions.send", "sessions.steer", "sessions.abort", - "browser.request", "push.test", "node.pending.enqueue", ], @@ -156,6 +157,10 @@ function resolveScopedMethod(method: string): OperatorScope | undefined { if (explicitScope) { return explicitScope; } + const pluginScope = getActivePluginRegistry()?.gatewayMethodScopes?.[method]; + if (pluginScope) { + return pluginScope; + } if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) { return ADMIN_SCOPE; } diff --git a/src/gateway/server-close.test.ts b/src/gateway/server-close.test.ts index a53944d775a..50596eddcc1 100644 --- a/src/gateway/server-close.test.ts +++ b/src/gateway/server-close.test.ts @@ -35,7 +35,6 @@ describe("createGatewayCloseHandler", () => { chatRunState: { clear: vi.fn() }, clients: new Set(), configReloader: { stop: vi.fn(async () => undefined) }, - browserControl: null, wss: { close: (cb: () => void) => cb() } as never, httpServer: { close: (cb: (err?: Error | null) => void) => cb(null), diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index 731029ddfaa..bcd257af253 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -30,7 +30,6 @@ export function createGatewayCloseHandler(params: { chatRunState: { clear: () => void }; clients: Set<{ socket: { close: (code: number, reason: string) => void } }>; configReloader: { stop: () => Promise }; - browserControl: { stop: () => Promise } | null; wss: WebSocketServer; httpServer: HttpServer; httpServers?: HttpServer[]; @@ -133,9 +132,6 @@ export function createGatewayCloseHandler(params: { } params.clients.clear(); await params.configReloader.stop().catch(() => {}); - if (params.browserControl) { - await params.browserControl.stop().catch(() => {}); - } await new Promise((resolve) => params.wss.close(() => resolve())); const servers = params.httpServers && params.httpServers.length > 0 diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index d9aa68635d5..c8b5a97b2c4 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -107,7 +107,6 @@ const BASE_METHODS = [ "agent", "agent.identity.get", "agent.wait", - "browser.request", // WebChat WebSocket-native chat methods "chat.history", "chat.abort", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 6276a5d4b31..295b190ab0a 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -6,7 +6,6 @@ import { ErrorCodes, errorShape } from "./protocol/index.js"; import { isRoleAuthorizedForMethod, parseGatewayRole } from "./role-policy.js"; import { agentHandlers } from "./server-methods/agent.js"; import { agentsHandlers } from "./server-methods/agents.js"; -import { browserHandlers } from "./server-methods/browser.js"; import { channelsHandlers } from "./server-methods/channels.js"; import { chatHandlers } from "./server-methods/chat.js"; import { configHandlers } from "./server-methods/config.js"; @@ -96,7 +95,6 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...usageHandlers, ...agentHandlers, ...agentsHandlers, - ...browserHandlers, }; export async function handleGatewayRequest( diff --git a/src/gateway/server-methods/browser.ts b/src/gateway/server-methods/browser.ts index 654ae6297b9..019d46b9031 100644 --- a/src/gateway/server-methods/browser.ts +++ b/src/gateway/server-methods/browser.ts @@ -1,262 +1 @@ -import crypto from "node:crypto"; -import { - createBrowserControlContext, - startBrowserControlServiceFromConfig, -} from "../../browser/control-service.js"; -import { applyBrowserProxyPaths, persistBrowserProxyFiles } from "../../browser/proxy-files.js"; -import { - isPersistentBrowserProfileMutation, - resolveRequestedBrowserProfile, -} from "../../browser/request-policy.js"; -import { createBrowserRouteDispatcher } from "../../browser/routes/dispatcher.js"; -import { loadConfig } from "../../config/config.js"; -import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js"; -import type { NodeSession } from "../node-registry.js"; -import { ErrorCodes, errorShape } from "../protocol/index.js"; -import { respondUnavailableOnNodeInvokeError, safeParseJson } from "./nodes.helpers.js"; -import type { GatewayRequestHandlers } from "./types.js"; - -type BrowserRequestParams = { - method?: string; - path?: string; - query?: Record; - body?: unknown; - timeoutMs?: number; -}; - -type BrowserProxyFile = { - path: string; - base64: string; - mimeType?: string; -}; - -type BrowserProxyResult = { - result: unknown; - files?: BrowserProxyFile[]; -}; - -function isBrowserNode(node: NodeSession) { - const caps = Array.isArray(node.caps) ? node.caps : []; - const commands = Array.isArray(node.commands) ? node.commands : []; - return caps.includes("browser") || commands.includes("browser.proxy"); -} - -function normalizeNodeKey(value: string) { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, ""); -} - -function resolveBrowserNode(nodes: NodeSession[], query: string): NodeSession | null { - const q = query.trim(); - if (!q) { - return null; - } - const qNorm = normalizeNodeKey(q); - const matches = nodes.filter((node) => { - if (node.nodeId === q) { - return true; - } - if (typeof node.remoteIp === "string" && node.remoteIp === q) { - return true; - } - const name = typeof node.displayName === "string" ? node.displayName : ""; - if (name && normalizeNodeKey(name) === qNorm) { - return true; - } - if (q.length >= 6 && node.nodeId.startsWith(q)) { - return true; - } - return false; - }); - if (matches.length === 1) { - return matches[0] ?? null; - } - if (matches.length === 0) { - return null; - } - throw new Error( - `ambiguous node: ${q} (matches: ${matches - .map((node) => node.displayName || node.remoteIp || node.nodeId) - .join(", ")})`, - ); -} - -function resolveBrowserNodeTarget(params: { - cfg: ReturnType; - nodes: NodeSession[]; -}): NodeSession | null { - const policy = params.cfg.gateway?.nodes?.browser; - const mode = policy?.mode ?? "auto"; - if (mode === "off") { - return null; - } - const browserNodes = params.nodes.filter((node) => isBrowserNode(node)); - if (browserNodes.length === 0) { - if (policy?.node?.trim()) { - throw new Error("No connected browser-capable nodes."); - } - return null; - } - const requested = policy?.node?.trim() || ""; - if (requested) { - const resolved = resolveBrowserNode(browserNodes, requested); - if (!resolved) { - throw new Error(`Configured browser node not connected: ${requested}`); - } - return resolved; - } - if (mode === "manual") { - return null; - } - if (browserNodes.length === 1) { - return browserNodes[0] ?? null; - } - return null; -} - -async function persistProxyFiles(files: BrowserProxyFile[] | undefined) { - return await persistBrowserProxyFiles(files); -} - -function applyProxyPaths(result: unknown, mapping: Map) { - applyBrowserProxyPaths(result, mapping); -} - -export const browserHandlers: GatewayRequestHandlers = { - "browser.request": async ({ params, respond, context }) => { - const typed = params as BrowserRequestParams; - const methodRaw = typeof typed.method === "string" ? typed.method.trim().toUpperCase() : ""; - const path = typeof typed.path === "string" ? typed.path.trim() : ""; - const query = typed.query && typeof typed.query === "object" ? typed.query : undefined; - const body = typed.body; - const timeoutMs = - typeof typed.timeoutMs === "number" && Number.isFinite(typed.timeoutMs) - ? Math.max(1, Math.floor(typed.timeoutMs)) - : undefined; - - if (!methodRaw || !path) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "method and path are required"), - ); - return; - } - if (methodRaw !== "GET" && methodRaw !== "POST" && methodRaw !== "DELETE") { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "method must be GET, POST, or DELETE"), - ); - return; - } - if (isPersistentBrowserProfileMutation(methodRaw, path)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "browser.request cannot mutate persistent browser profiles", - ), - ); - return; - } - - const cfg = loadConfig(); - let nodeTarget: NodeSession | null = null; - try { - nodeTarget = resolveBrowserNodeTarget({ - cfg, - nodes: context.nodeRegistry.listConnected(), - }); - } catch (err) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err))); - return; - } - - if (nodeTarget) { - const allowlist = resolveNodeCommandAllowlist(cfg, nodeTarget); - const allowed = isNodeCommandAllowed({ - command: "browser.proxy", - declaredCommands: nodeTarget.commands, - allowlist, - }); - if (!allowed.ok) { - const platform = nodeTarget.platform ?? "unknown"; - const hint = `node command not allowed: ${allowed.reason} (platform: ${platform}, command: browser.proxy)`; - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, hint, { - details: { reason: allowed.reason, command: "browser.proxy" }, - }), - ); - return; - } - - const proxyParams = { - method: methodRaw, - path, - query, - body, - timeoutMs, - profile: resolveRequestedBrowserProfile({ query, body }), - }; - const res = await context.nodeRegistry.invoke({ - nodeId: nodeTarget.nodeId, - command: "browser.proxy", - params: proxyParams, - timeoutMs, - idempotencyKey: crypto.randomUUID(), - }); - if (!respondUnavailableOnNodeInvokeError(respond, res)) { - return; - } - const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload; - const proxy = payload && typeof payload === "object" ? (payload as BrowserProxyResult) : null; - if (!proxy || !("result" in proxy)) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "browser proxy failed")); - return; - } - const mapping = await persistProxyFiles(proxy.files); - applyProxyPaths(proxy.result, mapping); - respond(true, proxy.result); - return; - } - - const ready = await startBrowserControlServiceFromConfig(); - if (!ready) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "browser control is disabled")); - return; - } - - let dispatcher; - try { - dispatcher = createBrowserRouteDispatcher(createBrowserControlContext()); - } catch (err) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err))); - return; - } - - const result = await dispatcher.dispatch({ - method: methodRaw, - path, - query, - body, - }); - - if (result.status >= 400) { - const message = - result.body && typeof result.body === "object" && "error" in result.body - ? String((result.body as { error?: unknown }).error) - : `browser request failed (${result.status})`; - const code = result.status >= 500 ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST; - respond(false, undefined, errorShape(code, message, { details: result.body })); - return; - } - - respond(true, result.body); - }, -}; +export * from "../../../extensions/browser/src/gateway/browser-request.js"; diff --git a/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts b/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts new file mode 100644 index 00000000000..3d1e67506ee --- /dev/null +++ b/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { clearPluginLoaderCache } from "../plugins/loader.js"; +import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; +import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js"; +import { listGatewayMethods } from "./server-methods-list.js"; +import { coreGatewayHandlers } from "./server-methods.js"; +import { loadGatewayStartupPlugins } from "./server-plugin-bootstrap.js"; + +function resetPluginState() { + clearPluginLoaderCache(); + clearPluginManifestRegistryCache(); + resetPluginRuntimeStateForTest(); +} + +function createTestLog() { + return { + info() {}, + warn() {}, + error() {}, + debug() {}, + }; +} + +describe("loadGatewayStartupPlugins browser plugin integration", () => { + beforeEach(() => { + resetPluginState(); + }); + + afterEach(() => { + resetPluginState(); + }); + + it("adds browser.request and the browser control service from the bundled plugin", () => { + const loaded = loadGatewayStartupPlugins({ + cfg: { + plugins: { + allow: ["browser"], + }, + } as OpenClawConfig, + workspaceDir: process.cwd(), + log: createTestLog(), + coreGatewayHandlers, + baseMethods: listGatewayMethods(), + logDiagnostics: false, + }); + + expect(loaded.gatewayMethods).toContain("browser.request"); + expect( + loaded.pluginRegistry.services.some( + (entry) => entry.pluginId === "browser" && entry.service.id === "browser-control", + ), + ).toBe(true); + }); + + it("omits browser gateway ownership when the bundled browser plugin is disabled", () => { + const loaded = loadGatewayStartupPlugins({ + cfg: { + plugins: { + allow: ["browser"], + entries: { + browser: { + enabled: false, + }, + }, + }, + } as OpenClawConfig, + workspaceDir: process.cwd(), + log: createTestLog(), + coreGatewayHandlers, + baseMethods: listGatewayMethods(), + logDiagnostics: false, + }); + + expect(loaded.gatewayMethods).not.toContain("browser.request"); + expect(loaded.pluginRegistry.services.some((entry) => entry.pluginId === "browser")).toBe( + false, + ); + }); +}); diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index b92d71889bc..5a9c6c3ccbb 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -20,7 +20,6 @@ import type { ChannelHealthMonitor } from "./channel-health-monitor.js"; import type { ChannelKind } from "./config-reload-plan.js"; import type { GatewayReloadPlan } from "./config-reload.js"; import { resolveHooksConfig } from "./hooks.js"; -import { startBrowserControlServerIfEnabled } from "./server-browser.js"; import { buildGatewayCronService, type GatewayCronState } from "./server-cron.js"; import type { HookClientIpConfig } from "./server-http.js"; import { resolveHookClientIpConfig } from "./server/hooks.js"; @@ -30,7 +29,6 @@ type GatewayHotReloadState = { hookClientIpConfig: HookClientIpConfig; heartbeatRunner: HeartbeatRunner; cronState: GatewayCronState; - browserControl: Awaited> | null; channelHealthMonitor: ChannelHealthMonitor | null; }; @@ -46,7 +44,6 @@ export function createGatewayReloadHandlers(params: { warn: (msg: string) => void; error: (msg: string) => void; }; - logBrowser: { error: (msg: string) => void }; logChannels: { info: (msg: string) => void; error: (msg: string) => void }; logCron: { error: (msg: string) => void }; logReload: { info: (msg: string) => void; warn: (msg: string) => void }; @@ -91,17 +88,6 @@ export function createGatewayReloadHandlers(params: { .catch((err) => params.logCron.error(`failed to start: ${String(err)}`)); } - if (plan.restartBrowserControl) { - if (state.browserControl) { - await state.browserControl.stop().catch(() => {}); - } - try { - nextState.browserControl = await startBrowserControlServerIfEnabled(); - } catch (err) { - params.logBrowser.error(`server failed to start: ${String(err)}`); - } - } - if (plan.restartHealthMonitor) { state.channelHealthMonitor?.stop(); const minutes = nextConfig.gateway?.channelHealthCheckMinutes; diff --git a/src/gateway/server-startup.ts b/src/gateway/server-startup.ts index 69b6d3f2810..b504173cb3d 100644 --- a/src/gateway/server-startup.ts +++ b/src/gateway/server-startup.ts @@ -26,7 +26,6 @@ import { loadInternalHooks } from "../hooks/loader.js"; import { isTruthyEnvValue } from "../infra/env.js"; import type { loadOpenClawPlugins } from "../plugins/loader.js"; import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js"; -import { startBrowserControlServerIfEnabled } from "./server-browser.js"; import { scheduleRestartSentinelWake, shouldWakeFromRestartSentinel, @@ -75,7 +74,6 @@ export async function startGatewaySidecars(params: { error: (msg: string) => void; }; logChannels: { info: (msg: string) => void; error: (msg: string) => void }; - logBrowser: { error: (msg: string) => void }; }) { try { const stateDir = resolveStateDir(process.env); @@ -92,14 +90,6 @@ export async function startGatewaySidecars(params: { params.log.warn(`session lock cleanup failed on startup: ${String(err)}`); } - // Start OpenClaw browser control server (unless disabled via config). - let browserControl: Awaited> = null; - try { - browserControl = await startBrowserControlServerIfEnabled(); - } catch (err) { - params.logBrowser.error(`server failed to start: ${String(err)}`); - } - // Start Gmail watcher if configured (hooks.gmail.account). await startGmailWatcherWithLogs({ cfg: params.cfg, @@ -222,7 +212,7 @@ export async function startGatewaySidecars(params: { }, 750); } - return { browserControl, pluginServices }; + return { pluginServices }; } export const __testing = { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index b113e214514..3a844059f94 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -84,7 +84,6 @@ import { import { ExecApprovalManager } from "./exec-approval-manager.js"; import { startGatewayModelPricingRefresh } from "./model-pricing-cache.js"; import { NodeRegistry } from "./node-registry.js"; -import type { startBrowserControlServerIfEnabled } from "./server-browser.js"; import { createChannelManager } from "./server-channels.js"; import { createAgentEventHandler, @@ -163,7 +162,6 @@ const logCanvas = log.child("canvas"); const logDiscovery = log.child("discovery"); const logTailscale = log.child("tailscale"); const logChannels = log.child("channels"); -const logBrowser = log.child("browser"); let cachedChannelRuntime: ReturnType["channel"] | null = null; @@ -742,7 +740,6 @@ export async function startGatewayServer( }; let stopGatewayUpdateCheck = () => {}; let tailscaleCleanup: (() => Promise) | null = null; - let browserControl: Awaited> = null; let skillsRefreshTimer: ReturnType | null = null; const skillsRefreshDelayMs = 30_000; let skillsChangeUnsub = () => {}; @@ -787,7 +784,6 @@ export async function startGatewayServer( chatRunState, clients, configReloader, - browserControl, wss, httpServer, httpServers, @@ -1262,7 +1258,7 @@ export async function startGatewayServer( logDiagnostics: false, })); } - ({ browserControl, pluginServices } = await startGatewaySidecars({ + ({ pluginServices } = await startGatewaySidecars({ cfg: cfgAtStart, pluginRegistry, defaultWorkspaceDir, @@ -1271,7 +1267,6 @@ export async function startGatewayServer( log, logHooks, logChannels, - logBrowser, })); } @@ -1296,7 +1291,6 @@ export async function startGatewayServer( hookClientIpConfig, heartbeatRunner, cronState, - browserControl, channelHealthMonitor, }), setState: (nextState) => { @@ -1306,13 +1300,11 @@ export async function startGatewayServer( cronState = nextState.cronState; cron = cronState.cron; cronStorePath = cronState.storePath; - browserControl = nextState.browserControl; channelHealthMonitor = nextState.channelHealthMonitor; }, startChannel, stopChannel, logHooks, - logBrowser, logChannels, logCron, logReload, @@ -1397,7 +1389,6 @@ export async function startGatewayServer( chatRunState, clients, configReloader, - browserControl, wss, httpServer, httpServers, diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index 8a6d392af7f..8eee3b6c145 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -26,11 +26,6 @@ const hoisted = vi.hoisted(() => { } } - const browserStop = vi.fn(async () => {}); - const startBrowserControlServerIfEnabled = vi.fn(async () => ({ - stop: browserStop, - })); - const heartbeatStop = vi.fn(); const heartbeatUpdateConfig = vi.fn(); const startHeartbeatRunner = vi.fn(() => ({ @@ -131,8 +126,6 @@ const hoisted = vi.hoisted(() => { return { CronService: CronServiceMock, cronInstances, - browserStop, - startBrowserControlServerIfEnabled, heartbeatStop, heartbeatUpdateConfig, startHeartbeatRunner, @@ -151,10 +144,6 @@ vi.mock("../cron/service.js", () => ({ CronService: hoisted.CronService, })); -vi.mock("./server-browser.js", () => ({ - startBrowserControlServerIfEnabled: hoisted.startBrowserControlServerIfEnabled, -})); - vi.mock("../infra/heartbeat-runner.js", () => ({ startHeartbeatRunner: hoisted.startHeartbeatRunner, })); @@ -463,7 +452,6 @@ describe("gateway hot reload", () => { }, cron: { enabled: true, store: "/tmp/cron.json" }, agents: { defaults: { heartbeat: { every: "1m" }, maxConcurrent: 2 } }, - browser: { enabled: true }, web: { enabled: true }, channels: { telegram: { botToken: "token" }, @@ -479,7 +467,6 @@ describe("gateway hot reload", () => { "hooks.gmail.account", "cron.enabled", "agents.defaults.heartbeat.every", - "browser.enabled", "web.enabled", "channels.telegram.botToken", "channels.discord.token", @@ -491,7 +478,6 @@ describe("gateway hot reload", () => { hotReasons: ["web.enabled"], reloadHooks: true, restartGmailWatcher: true, - restartBrowserControl: true, restartCron: true, restartHeartbeat: true, restartChannels: new Set(["whatsapp", "telegram", "discord", "signal", "imessage"]), @@ -503,9 +489,6 @@ describe("gateway hot reload", () => { expect(hoisted.stopGmailWatcher).toHaveBeenCalled(); expect(hoisted.startGmailWatcher).toHaveBeenCalledWith(expect.objectContaining(nextConfig)); - expect(hoisted.browserStop).toHaveBeenCalledTimes(1); - expect(hoisted.startBrowserControlServerIfEnabled).toHaveBeenCalledTimes(2); - expect(hoisted.startHeartbeatRunner).toHaveBeenCalledTimes(1); expect(hoisted.heartbeatUpdateConfig).toHaveBeenCalledTimes(1); expect(hoisted.heartbeatUpdateConfig).toHaveBeenCalledWith( @@ -543,7 +526,6 @@ describe("gateway hot reload", () => { hotReasons: [], reloadHooks: false, restartGmailWatcher: false, - restartBrowserControl: false, restartCron: false, restartHeartbeat: false, restartChannels: new Set(), @@ -634,7 +616,6 @@ describe("gateway hot reload", () => { hotReasons: ["models.providers.openai.apiKey"], reloadHooks: false, restartGmailWatcher: false, - restartBrowserControl: false, restartCron: false, restartHeartbeat: false, restartChannels: new Set(), @@ -682,7 +663,6 @@ describe("gateway hot reload", () => { hotReasons: ["tools.web.search.gemini.apiKey"], reloadHooks: false, restartGmailWatcher: false, - restartBrowserControl: false, restartCron: false, restartHeartbeat: false, restartChannels: new Set(), diff --git a/src/node-host/invoke-browser.test.ts b/src/node-host/invoke-browser.test.ts index f92889468b4..d102bd4a822 100644 --- a/src/node-host/invoke-browser.test.ts +++ b/src/node-host/invoke-browser.test.ts @@ -26,15 +26,17 @@ const browserConfigMocks = vi.hoisted(() => ({ })), })); -vi.mock("../browser/control-service.js", () => controlServiceMocks); -vi.mock("../browser/routes/dispatcher.js", () => dispatcherMocks); -vi.mock("../config/config.js", () => configMocks); -vi.mock("../browser/config.js", () => browserConfigMocks); -vi.mock("../media/mime.js", () => ({ +vi.mock("../../extensions/browser/src/core-api.js", async () => ({ + ...(await vi.importActual("../../extensions/browser/src/core-api.js")), + createBrowserControlContext: controlServiceMocks.createBrowserControlContext, + createBrowserRouteDispatcher: dispatcherMocks.createBrowserRouteDispatcher, detectMime: vi.fn(async () => "image/png"), + loadConfig: configMocks.loadConfig, + resolveBrowserConfig: browserConfigMocks.resolveBrowserConfig, + startBrowserControlServiceFromConfig: controlServiceMocks.startBrowserControlServiceFromConfig, })); -let runBrowserProxyCommand: typeof import("./invoke-browser.js").runBrowserProxyCommand; +let runBrowserProxyCommand: typeof import("../../extensions/browser/src/node-host/invoke-browser.js").runBrowserProxyCommand; describe("runBrowserProxyCommand", () => { beforeEach(async () => { @@ -56,7 +58,8 @@ describe("runBrowserProxyCommand", () => { enabled: true, defaultProfile: "openclaw", }); - ({ runBrowserProxyCommand } = await import("./invoke-browser.js")); + ({ runBrowserProxyCommand } = + await import("../../extensions/browser/src/node-host/invoke-browser.js")); configMocks.loadConfig.mockReturnValue({ browser: {}, nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } }, diff --git a/src/node-host/invoke-browser.ts b/src/node-host/invoke-browser.ts index dc41220f61c..f621a57f895 100644 --- a/src/node-host/invoke-browser.ts +++ b/src/node-host/invoke-browser.ts @@ -1,353 +1 @@ -import fsPromises from "node:fs/promises"; -import { redactCdpUrl } from "../browser/cdp.helpers.js"; -import { resolveBrowserConfig } from "../browser/config.js"; -import { - createBrowserControlContext, - startBrowserControlServiceFromConfig, -} from "../browser/control-service.js"; -import { - isPersistentBrowserProfileMutation, - normalizeBrowserRequestPath, - resolveRequestedBrowserProfile, -} from "../browser/request-policy.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; - 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 | null = null; - -async function ensureBrowserControlService(): Promise { - 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(); - const obj = - typeof payload === "object" && payload !== null ? (payload as Record) : 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).path; - if (typeof dlPath === "string" && dlPath.trim()) { - paths.add(dlPath.trim()); - } - } - return [...paths]; -} - -async function readBrowserProxyFile(filePath: string): Promise { - 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(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; - profile?: string; -}): Promise | 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; - return { - running: body.running, - transport: body.transport, - 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 | 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.transport === "string" && params.status.transport.trim()) { - statusParts.push(`transport=${params.status.transport}`); - } - if (typeof params.status.cdpUrl === "string" && params.status.cdpUrl.trim()) { - statusParts.push(`cdpUrl=${redactCdpUrl(params.status.cdpUrl)}`); - } - parts.push(`status(${statusParts.join(", ")})`); - } - return parts.join("; "); -} - -export async function runBrowserProxyCommand(paramsJSON?: string | null): Promise { - const params = decodeParams(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 method = typeof params.method === "string" ? params.method.toUpperCase() : "GET"; - const path = normalizeBrowserRequestPath(pathValue); - const body = params.body; - const requestedProfile = - resolveRequestedBrowserProfile({ - query: params.query, - body, - profile: params.profile, - }) ?? ""; - const allowedProfiles = proxyConfig.allowProfiles; - if (allowedProfiles.length > 0) { - if (isPersistentBrowserProfileMutation(method, path)) { - throw new Error( - "INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles when allowProfiles is configured", - ); - } - if (path !== "/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 timeoutMs = resolveBrowserProxyTimeout(params.timeoutMs); - const query: Record = {}; - 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); - } - if (requestedProfile) { - query.profile = requestedProfile; - } - - 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) : {}; - 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).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); -} +export * from "../../extensions/browser/src/node-host/invoke-browser.js"; diff --git a/src/plugins/bundled-plugin-metadata.generated.ts b/src/plugins/bundled-plugin-metadata.generated.ts index 317f9b767b2..ea68157c475 100644 --- a/src/plugins/bundled-plugin-metadata.generated.ts +++ b/src/plugins/bundled-plugin-metadata.generated.ts @@ -298,6 +298,29 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ }, }, }, + { + dirName: "browser", + idHint: "browser-plugin", + source: { + source: "./index.ts", + built: "index.js", + }, + packageName: "@openclaw/browser-plugin", + packageVersion: "2026.3.25", + packageDescription: "OpenClaw browser tool plugin", + packageManifest: { + extensions: ["./index.ts"], + }, + manifest: { + id: "browser", + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + enabledByDefault: true, + }, + }, { dirName: "byteplus", idHint: "byteplus", diff --git a/src/plugins/cli.browser-plugin.integration.test.ts b/src/plugins/cli.browser-plugin.integration.test.ts new file mode 100644 index 00000000000..9d6a4897a08 --- /dev/null +++ b/src/plugins/cli.browser-plugin.integration.test.ts @@ -0,0 +1,50 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { registerPluginCliCommands } from "./cli.js"; +import { clearPluginLoaderCache } from "./loader.js"; +import { clearPluginManifestRegistryCache } from "./manifest-registry.js"; +import { resetPluginRuntimeStateForTest } from "./runtime.js"; + +function resetPluginState() { + clearPluginLoaderCache(); + clearPluginManifestRegistryCache(); + resetPluginRuntimeStateForTest(); +} + +describe("registerPluginCliCommands browser plugin integration", () => { + beforeEach(() => { + resetPluginState(); + }); + + afterEach(() => { + resetPluginState(); + }); + + it("registers the browser command from the bundled browser plugin", () => { + const program = new Command(); + registerPluginCliCommands(program, { + plugins: { + allow: ["browser"], + }, + } as OpenClawConfig); + + expect(program.commands.map((command) => command.name())).toContain("browser"); + }); + + it("omits the browser command when the bundled browser plugin is disabled", () => { + const program = new Command(); + registerPluginCliCommands(program, { + plugins: { + allow: ["browser"], + entries: { + browser: { + enabled: false, + }, + }, + }, + } as OpenClawConfig); + + expect(program.commands.map((command) => command.name())).not.toContain("browser"); + }); +});