();
+
+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