openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
3,898 lines • 141 kB
JavaScript
import { a as normalizeLowercaseStringOrEmpty, c as normalizeOptionalString$1, p as readStringValue } from "./string-coerce-mnp54Vah.js";
import { _ as parseStrictFiniteNumber, b as parseStrictPositiveInteger, d as clampPositiveTimerTimeoutMs, v as parseStrictInteger, y as parseStrictNonNegativeInteger } from "./number-coercion-CJQ8TR--.js";
import { i as formatErrorMessage } from "./errors-BXgSefBE.js";
import { _ as uniqueStrings } from "./string-normalization-WNUDCpXX.js";
import { u as pathScope } from "./fs-safe-aqmM_n6V.js";
import { p as resolveUserPath } from "./utils-CCC-BEJH.js";
import { i as getRuntimeConfig } from "./io-Gi7-pyU-.js";
import { a as ensureMediaDir, u as saveMediaBuffer } from "./store-C9M_0A1p.js";
import { i as buildImageResizeSideGrid, l as isImageProcessorUnavailableError, m as resizeToJpeg, s as getImageMetadata, t as IMAGE_REDUCE_QUALITY_STEPS } from "./image-ops-PfTOpg74.js";
import "./number-runtime-DBLVDypr.js";
import "./string-coerce-runtime-CEGJWkQ_.js";
import { n as redactCdpUrl, t as parseBrowserHttpUrl } from "./browser-config-CB1dJM4Y.js";
import { i as normalizeActBoundedNonNegativeMs, n as ACT_MAX_VIEWPORT_DIMENSION, r as ACT_MAX_WAIT_TIME_MS, t as ACT_MAX_CLICK_DELAY_MS } from "./act-policy-ChvnirnB.js";
import { C as BrowserTargetAmbiguousError, I as DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS, L as DEFAULT_AI_SNAPSHOT_MAX_CHARS, S as BrowserTabNotFoundError, T as toBrowserErrorResponse, U as DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS, _ as BrowserError, d as withCdpSocket, f as normalizeBrowserTimerDelayMs, g as BrowserConflictError, n as assertCdpEndpointAllowed, v as BrowserProfileNotFoundError, w as BrowserValidationError, y as BrowserProfileUnavailableError } from "./cdp.helpers-CTQ-1blZ.js";
import { a as DEFAULT_DOWNLOAD_DIR, c as resolveExistingUploadPaths, i as resolveProfile, o as DEFAULT_TRACE_DIR, r as resolveManagedBrowserHeadlessMode } from "./config-C2rsUqn7.js";
import "./config-DGptl9kI.js";
import "./errors-Bxt8NtDz.js";
import { i as isValidProfileName, n as deleteBrowserProfileConfig, t as createBrowserProfileConfig } from "./config-mutations-CvFyMZmA.js";
import { r as resolveBrowserExecutableForPlatform } from "./chrome.executables-BGN3WFpZ.js";
import { t as movePathToTrash } from "./trash-UCE9Bn4F.js";
import { n as normalizeString } from "./record-shared-BdOR6CcJ.js";
import "./sdk-setup-tools-Bm-WxrMN.js";
import { a as shouldUsePlaywrightForAriaSnapshot, i as resolveDefaultSnapshotFormat, n as getPwAiModule$1, o as shouldUsePlaywrightForScreenshot, r as getBrowserProfileCapabilities, t as resolveTargetIdFromTabs } from "./target-id-CwSK7UJx.js";
import { D as takeChromeMcpScreenshot, O as takeChromeMcpSnapshot, S as resizeChromeMcpPage, a as closeChromeMcpTab, b as pressChromeMcpKey, d as fillChromeMcpForm, h as hoverChromeMcpElement, k as uploadChromeMcpFile, l as evaluateChromeMcpScript, n as clickChromeMcpCoords, p as getChromeMcpPid, r as clickChromeMcpElement, s as dragChromeMcpElement, u as fillChromeMcpElement, v as navigateChromeMcpPage } from "./chrome-mcp-CS-5Xve0.js";
import { i as getRoleSnapshotStats, l as matchBrowserUrlPattern, s as normalizeBrowserFormField, t as appendSnapshotUrls, u as normalizeBrowserEvaluateFunctionSource } from "./snapshot-urls-Pv7A2j4E.js";
import { E as withBrowserNavigationPolicy, S as assertBrowserNavigationAllowed, _ as INTERACTIVE_ROLES, a as resolveOpenClawUserDataDir, b as resolveBrowserNavigationProxyMode, g as CONTENT_ROLES, h as snapshotRoleViaCdp, m as snapshotAria, t as getChromeWebSocketUrl, u as captureScreenshot, v as STRUCTURAL_ROLES, w as assertBrowserNavigationResultAllowed, y as ensureOutputDirectory } from "./chrome-BWowRFBm.js";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
//#region extensions/browser/src/browser/screenshot.ts
/**
* Browser screenshot normalization helpers that bound screenshots for media
* transport and model input.
*/
const DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE = 2e3;
const DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES = 5 * 1024 * 1024;
/** Downscales/re-encodes screenshots to fit Browser plugin byte and dimension caps. */
async function normalizeBrowserScreenshot(buffer, opts) {
const maxSide = Math.max(1, Math.round(opts?.maxSide ?? 2e3));
const maxBytes = Math.max(1, Math.round(opts?.maxBytes ?? 5242880));
const meta = await getImageMetadata(buffer);
const width = meta?.width ?? 0;
const height = meta?.height ?? 0;
const maxDim = Math.max(width, height);
if (buffer.byteLength <= maxBytes && (maxDim === 0 || width <= maxSide && height <= maxSide)) return { buffer };
const sideGrid = buildImageResizeSideGrid(maxSide, maxDim > 0 ? Math.min(maxSide, maxDim) : maxSide);
let smallest = null;
let processorUnavailableError;
for (const side of sideGrid) {
for (const quality of IMAGE_REDUCE_QUALITY_STEPS) {
let out;
try {
out = await resizeToJpeg({
buffer,
maxSide: side,
quality,
withoutEnlargement: true
});
} catch (err) {
if (isImageProcessorUnavailableError(err)) {
processorUnavailableError = err;
break;
}
throw err;
}
if (!smallest || out.byteLength < smallest.size) smallest = {
buffer: out,
size: out.byteLength
};
if (out.byteLength <= maxBytes) return {
buffer: out,
contentType: "image/jpeg"
};
}
if (processorUnavailableError) break;
}
if (processorUnavailableError) throw toLintErrorObject$1(processorUnavailableError, "Non-Error thrown");
const best = smallest?.buffer ?? buffer;
throw new Error(`Browser screenshot could not be reduced below ${(maxBytes / (1024 * 1024)).toFixed(0)}MB (got ${(best.byteLength / (1024 * 1024)).toFixed(2)}MB)`);
}
function toLintErrorObject$1(value, fallbackMessage) {
if (value instanceof Error) return value;
if (typeof value === "string") return new Error(value);
const error = new Error(fallbackMessage, { cause: value });
if (typeof value === "object" && value !== null || typeof value === "function") Object.assign(error, value);
return error;
}
//#endregion
//#region extensions/browser/src/browser/routes/utils.ts
function normalizeOptionalString(value) {
return value.trim() || void 0;
}
/** Convert thrown async route errors into next(error) calls for the HTTP layer. */
function asyncBrowserRoute(handler) {
return (req, res) => handler(req, res);
}
/**
* Extract profile name from query string or body and get profile context.
* Query string takes precedence over body for consistency with GET routes.
*/
/** Resolve the profile context requested by query/profile parameters. */
function getProfileContext(req, ctx) {
let profileName;
if (typeof req.query.profile === "string") profileName = normalizeOptionalString(req.query.profile);
if (!profileName && req.body && typeof req.body === "object") {
const body = req.body;
if (typeof body.profile === "string") profileName = normalizeOptionalString(body.profile);
}
try {
return ctx.forProfile(profileName);
} catch (err) {
return {
error: String(err),
status: 404
};
}
}
/** Send a simple JSON error response. */
function jsonError(res, status, message) {
res.status(status).json({ error: message });
}
/** Coerce route values to strings while treating nullish values as empty. */
function toStringOrEmpty(value) {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return normalizeOptionalString(String(value)) ?? "";
return "";
}
/** Coerce route numeric values from numbers or decimal strings. */
function toNumber(value) {
if (typeof value === "number" && Number.isFinite(value)) return value;
const normalized = typeof value === "string" ? normalizeOptionalString(value) : void 0;
if (normalized) {
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : void 0;
}
}
/** Coerce route boolean values from booleans or common string forms. */
function toBoolean(value) {
if (typeof value === "boolean") return value;
if (typeof value !== "string" && typeof value !== "number") return;
const normalized = String(value).trim().toLowerCase();
if (normalized === "true" || normalized === "1" || normalized === "yes") return true;
if (normalized === "false" || normalized === "0" || normalized === "no") return false;
}
/** Coerce a route value to a string array when every entry is a string. */
function toStringArray(value) {
if (!Array.isArray(value)) return;
const strings = value.map((v) => toStringOrEmpty(v)).filter(Boolean);
return strings.length ? strings : void 0;
}
//#endregion
//#region extensions/browser/src/browser/routes/agent.shared.ts
/**
* Shared browser route helpers.
*
* Centralizes body/query parsing, profile resolution, error mapping, Playwright
* availability checks, and tab-context guards for route modules.
*/
const SELECTOR_UNSUPPORTED_MESSAGE = [
"Error: 'selector' is not supported. Use 'ref' from snapshot instead.",
"",
"Example workflow:",
"1. snapshot action to get page state with refs",
"2. act with ref: \"e123\" to interact with element",
"",
"This is more reliable for modern SPAs."
].join("\n");
/** Return a safe object body for routes that accept JSON payloads. */
function readBody(req) {
const body = req.body;
if (!body || typeof body !== "object" || Array.isArray(body)) return {};
return body;
}
/** Read an optional targetId from a request body. */
function resolveTargetIdFromBody(body) {
return (normalizeOptionalString$1(body.targetId) ?? "") || void 0;
}
/** Read an optional targetId from a query object. */
function resolveTargetIdFromQuery(query) {
return (normalizeOptionalString$1(query.targetId) ?? "") || void 0;
}
/** Map route-level browser errors to HTTP JSON responses. */
function handleRouteError(ctx, res, err) {
const mapped = ctx.mapTabError(err);
if (mapped) return jsonError(res, mapped.status, mapped.message);
const browserMapped = toBrowserErrorResponse(err);
if (browserMapped) return jsonError(res, browserMapped.status, browserMapped.message);
jsonError(res, 500, String(err));
}
/** Resolve the requested browser profile and respond with JSON on failure. */
function resolveProfileContext(req, res, ctx) {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
jsonError(res, profileCtx.status, profileCtx.error);
return null;
}
return profileCtx;
}
/** Build navigation guard policy for a profile and current resolved config. */
function browserNavigationPolicyForProfile(ctx, profileCtx) {
return withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy, { browserProxyMode: resolveBrowserNavigationProxyMode({
resolved: ctx.state().resolved,
profile: profileCtx.profile
}) });
}
/** Load the optional Playwright bridge module in soft-fail mode. */
async function getPwAiModule() {
return await getPwAiModule$1({ mode: "soft" });
}
/** Require Playwright support for a route feature, returning a 501 when absent. */
async function requirePwAi(res, feature) {
const mod = await getPwAiModule();
if (mod) return mod;
jsonError(res, 501, [
`Playwright is not available in this gateway build; '${feature}' is unsupported.`,
"Reinstall or update OpenClaw so the core browser runtime dependency is present, then restart the gateway. In Docker, also install Chromium with the bundled playwright-core CLI.",
"Docs: /tools/browser#playwright-requirement"
].join("\n"));
return null;
}
/** Resolve profile and tab context, optionally enforcing current URL policy. */
async function withRouteTabContext(params) {
const profileCtx = resolveProfileContext(params.req, params.res, params.ctx);
if (!profileCtx) return;
try {
const tab = await profileCtx.ensureTabAvailable(params.targetId);
if (params.enforceCurrentUrlAllowed) await assertBrowserNavigationResultAllowed({
url: tab.url,
...browserNavigationPolicyForProfile(params.ctx, profileCtx)
});
return await params.run({
profileCtx,
tab,
cdpUrl: profileCtx.profile.cdpUrl,
resolveTabUrl: (fallbackUrl) => resolveSafeRouteTabUrl({
ctx: params.ctx,
profileCtx,
targetId: tab.targetId,
fallbackUrl
})
});
} catch (err) {
handleRouteError(params.ctx, params.res, err);
return;
}
}
/**
* Response-only URL redaction. This swallows policy failures and must not be used as
* an execution gate; use enforceCurrentUrlAllowed on the route helper instead.
*/
async function resolveSafeRouteTabUrl(params) {
const candidateUrl = (await params.profileCtx.listTabs().catch(() => [])).find((tab) => tab.targetId === params.targetId)?.url ?? params.fallbackUrl;
if (!candidateUrl) return;
try {
await assertBrowserNavigationResultAllowed({
url: candidateUrl,
...browserNavigationPolicyForProfile(params.ctx, params.profileCtx)
});
return candidateUrl;
} catch {
return;
}
}
/** Resolve profile, tab, and Playwright context for Playwright-only routes. */
async function withPlaywrightRouteContext(params) {
return await withRouteTabContext({
req: params.req,
res: params.res,
ctx: params.ctx,
targetId: params.targetId,
enforceCurrentUrlAllowed: params.enforceCurrentUrlAllowed,
run: async ({ profileCtx, tab, cdpUrl, resolveTabUrl }) => {
const pw = await requirePwAi(params.res, params.feature);
if (!pw) return;
return await params.run({
profileCtx,
tab,
cdpUrl,
resolveTabUrl,
pw
});
}
});
}
//#endregion
//#region extensions/browser/src/browser/routes/existing-session-limits.ts
/**
* Existing-session browser capability-limit messages.
*
* Centralizes unsupported-operation text so route responses and tests stay
* stable while Chrome MCP support grows incrementally.
*/
/** User-facing messages for existing-session route limitations. */
const EXISTING_SESSION_LIMITS = {
act: {
clickSelector: "existing-session click does not support selector targeting yet; use ref.",
clickButtonOrModifiers: "existing-session click currently supports left-click only (no button overrides/modifiers).",
typeSelector: "existing-session type does not support selector targeting yet; use ref.",
typeSlowly: "existing-session type does not support slowly=true; use fill/press instead.",
typeTimeout: "existing-session type does not support timeoutMs overrides.",
pressDelay: "existing-session press does not support delayMs.",
hoverSelector: "existing-session hover does not support selector targeting yet; use ref.",
hoverTimeout: "existing-session hover does not support timeoutMs overrides.",
scrollSelector: "existing-session scrollIntoView does not support selector targeting yet; use ref.",
scrollTimeout: "existing-session scrollIntoView does not support timeoutMs overrides.",
dragSelector: "existing-session drag does not support selector targeting yet; use startRef/endRef.",
dragTimeout: "existing-session drag does not support timeoutMs overrides.",
selectSelector: "existing-session select does not support selector targeting yet; use ref.",
selectSingleValue: "existing-session select currently supports a single value only.",
selectTimeout: "existing-session select does not support timeoutMs overrides.",
fillTimeout: "existing-session fill does not support timeoutMs overrides.",
waitNetworkIdle: "existing-session wait does not support loadState=networkidle yet.",
evaluateTimeout: "existing-session evaluate does not support timeoutMs overrides.",
batch: "existing-session batch is not supported yet; send actions individually."
},
hooks: {
uploadElement: "existing-session file uploads do not support element selectors; use ref/inputRef.",
uploadSingleFile: "existing-session file uploads currently support one file at a time.",
uploadRefRequired: "existing-session file uploads require ref or inputRef.",
dialogId: "existing-session dialog handling does not support dialogId.",
dialogTimeout: "existing-session dialog handling does not support timeoutMs."
},
download: {
waitUnsupported: "download waiting is not supported for existing-session profiles yet.",
downloadUnsupported: "downloads are not supported for existing-session profiles yet."
},
snapshot: {
pdfUnsupported: "pdf is not supported for existing-session profiles yet; use screenshot/snapshot instead.",
screenshotElement: "element screenshots are not supported for existing-session profiles; use ref from snapshot.",
snapshotSelector: "selector/frame snapshots are not supported for existing-session profiles; snapshot the whole page and use refs."
},
responseBody: "response body is not supported for existing-session profiles yet."
};
//#endregion
//#region extensions/browser/src/browser/routes/output-paths.ts
/**
* Browser route output-path helpers.
*
* Validates writable output paths against a route-specific root before any
* screenshot, trace, or download route writes to disk.
*/
/** Ensure a browser output root exists before resolving child write paths. */
async function ensureOutputRootDir(rootDir) {
await ensureOutputDirectory(rootDir);
}
/** Resolve a writable output path or send a 400 JSON response on scope errors. */
async function resolveWritableOutputPathOrRespond(params) {
if (params.ensureRootDir) await ensureOutputRootDir(params.rootDir);
const pathResult = await pathScope(params.rootDir, { label: params.scopeLabel }).writable(params.requestedPath, { defaultName: params.defaultFileName });
if (!pathResult.ok) {
params.res.status(400).json({ error: pathResult.error });
return null;
}
return pathResult.path;
}
//#endregion
//#region extensions/browser/src/browser/routes/route-numeric.ts
/**
* Strict numeric parsers for browser route input.
*
* Converts query/body values into finite integer/timeout numbers while
* preserving route-specific error messages for JSON responses.
*/
function hasRouteInputValue(value) {
return value != null;
}
/** Read an optional finite number route field. */
function readRouteFiniteNumber(value, fieldName) {
const parsed = parseStrictFiniteNumber(value);
if (parsed === void 0 && hasRouteInputValue(value)) throw new Error(`${fieldName} must be a finite number.`);
return parsed;
}
/** Read an optional finite number, treating blank strings as absent. */
function readOptionalRouteFiniteNumber(value, fieldName) {
if (typeof value === "string" && value.trim() === "") return;
return readRouteFiniteNumber(value, fieldName);
}
/** Read an optional integer route field. */
function readRouteInteger(value, fieldName, options) {
const parsed = parseStrictInteger(value);
if (parsed === void 0 && hasRouteInputValue(value)) throw new Error(options?.invalidMessage ?? `${fieldName} must be an integer.`);
return parsed;
}
/** Read an optional positive integer route field. */
function readRoutePositiveInteger(value, fieldName, options) {
const parsed = parseStrictPositiveInteger(value);
if (parsed === void 0 && hasRouteInputValue(value)) throw new Error(options?.invalidMessage ?? `${fieldName} must be a positive integer.`);
return parsed;
}
/** Read and normalize an optional positive timeout value. */
function readRouteTimerTimeoutMs(value, fieldName = "timeoutMs", opts) {
const parsed = readRoutePositiveInteger(value, fieldName, opts);
return parsed === void 0 ? void 0 : normalizeBrowserTimerDelayMs(parsed, opts);
}
/** Read an optional non-negative integer route field. */
function readRouteNonNegativeInteger(value, fieldName, options) {
const parsed = parseStrictNonNegativeInteger(value);
if (parsed === void 0 && hasRouteInputValue(value)) throw new Error(options?.invalidMessage ?? `${fieldName} must be a non-negative integer.`);
return parsed;
}
//#endregion
//#region extensions/browser/src/browser/routes/agent.act.download.ts
/**
* Browser agent action routes for download handling.
*
* Registers endpoints that wait for a pending download or trigger a referenced
* page download while keeping files scoped to the configured downloads root.
*/
function buildDownloadRequestBase(cdpUrl, targetId, timeoutMs) {
return {
cdpUrl,
targetId,
timeoutMs: timeoutMs ?? void 0
};
}
/** Register download action endpoints on the browser control server. */
function registerBrowserAgentActDownloadRoutes(app, ctx) {
app.post("/wait/download", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const out = toStringOrEmpty(body.path) || "";
let timeoutMs;
try {
timeoutMs = readRouteTimerTimeoutMs(body.timeoutMs);
} catch (err) {
return jsonError(res, 400, formatErrorMessage(err));
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) return jsonError(res, 501, EXISTING_SESSION_LIMITS.download.waitUnsupported);
const pw = await requirePwAi(res, "wait for download");
if (!pw) return;
await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR);
let downloadPath;
if (out.trim()) {
const resolvedDownloadPath = await resolveWritableOutputPathOrRespond({
res,
rootDir: DEFAULT_DOWNLOAD_DIR,
requestedPath: out,
scopeLabel: "downloads directory"
});
if (!resolvedDownloadPath) return;
downloadPath = resolvedDownloadPath;
}
const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
const result = await pw.waitForDownloadViaPlaywright({
...requestBase,
path: downloadPath,
rootDir: DEFAULT_DOWNLOAD_DIR
});
res.json({
ok: true,
targetId: tab.targetId,
download: result
});
}
});
}));
app.post("/download", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref);
const out = toStringOrEmpty(body.path);
let timeoutMs;
try {
timeoutMs = readRouteTimerTimeoutMs(body.timeoutMs);
} catch (err) {
return jsonError(res, 400, formatErrorMessage(err));
}
if (!ref) return jsonError(res, 400, "ref is required");
if (!out) return jsonError(res, 400, "path is required");
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) return jsonError(res, 501, EXISTING_SESSION_LIMITS.download.downloadUnsupported);
const pw = await requirePwAi(res, "download");
if (!pw) return;
await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR);
const downloadPath = await resolveWritableOutputPathOrRespond({
res,
rootDir: DEFAULT_DOWNLOAD_DIR,
requestedPath: out,
scopeLabel: "downloads directory"
});
if (!downloadPath) return;
const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
const result = await pw.downloadViaPlaywright({
...requestBase,
ref,
path: downloadPath,
rootDir: DEFAULT_DOWNLOAD_DIR
});
res.json({
ok: true,
targetId: tab.targetId,
download: result
});
}
});
}));
}
//#endregion
//#region extensions/browser/src/browser/routes/agent.act.errors.ts
/** Stable machine-readable codes returned by browser action routes. */
const ACT_ERROR_CODES = {
kindRequired: "ACT_KIND_REQUIRED",
invalidRequest: "ACT_INVALID_REQUEST",
selectorUnsupported: "ACT_SELECTOR_UNSUPPORTED",
evaluateDisabled: "ACT_EVALUATE_DISABLED",
unsupportedForExistingSession: "ACT_EXISTING_SESSION_UNSUPPORTED",
targetIdMismatch: "ACT_TARGET_ID_MISMATCH"
};
/** Send a browser action JSON error with a stable action error code. */
function jsonActError(res, status, code, message) {
res.status(status).json({
error: message,
code
});
}
/** Build the config-disabled message for JavaScript evaluation actions. */
function browserEvaluateDisabledMessage(action) {
return [action === "wait" ? "wait --fn is disabled by config (browser.evaluateEnabled=false)." : "act:evaluate is disabled by config (browser.evaluateEnabled=false).", "Docs: /gateway/configuration#browser-openclaw-managed-browser"].join("\n");
}
//#endregion
//#region extensions/browser/src/browser/routes/agent.act.hooks.ts
/**
* Browser agent action hook routes.
*
* Handles file chooser and dialog interception for both Playwright-backed
* OpenClaw profiles and Chrome MCP existing-session profiles.
*/
/** Register file chooser and dialog hook endpoints on the browser control server. */
function registerBrowserAgentActHookRoutes(app, ctx) {
app.post("/hooks/file-chooser", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref) || void 0;
const inputRef = toStringOrEmpty(body.inputRef) || void 0;
const element = toStringOrEmpty(body.element) || void 0;
const paths = toStringArray(body.paths) ?? [];
let timeoutMs;
try {
timeoutMs = readRouteTimerTimeoutMs(body.timeoutMs);
} catch (err) {
return jsonError(res, 400, formatErrorMessage(err));
}
if (!paths.length) return jsonError(res, 400, "paths are required");
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
const resolvedResult = await resolveExistingUploadPaths({ requestedPaths: paths });
if (!resolvedResult.ok) {
res.status(400).json({ error: resolvedResult.error });
return;
}
const resolvedPaths = resolvedResult.paths;
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (element) return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadElement);
if (resolvedPaths.length !== 1) return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadSingleFile);
const uid = inputRef || ref;
if (!uid) return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadRefRequired);
await uploadChromeMcpFile({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,
targetId: tab.targetId,
uid,
filePath: resolvedPaths[0] ?? ""
});
return res.json({ ok: true });
}
const pw = await requirePwAi(res, "file chooser hook");
if (!pw) return;
if (inputRef || element) {
if (ref) return jsonError(res, 400, "ref cannot be combined with inputRef/element");
await pw.setInputFilesViaPlaywright({
cdpUrl,
targetId: tab.targetId,
inputRef,
element,
paths: resolvedPaths
});
} else {
await pw.armFileUploadViaPlaywright({
cdpUrl,
targetId: tab.targetId,
paths: resolvedPaths,
timeoutMs: timeoutMs ?? void 0
});
if (ref) await pw.clickViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
ref
});
}
res.json({ ok: true });
}
});
}));
app.post("/hooks/dialog", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const accept = toBoolean(body.accept);
const promptText = toStringOrEmpty(body.promptText) || void 0;
let timeoutMs;
try {
timeoutMs = readRouteTimerTimeoutMs(body.timeoutMs);
} catch (err) {
return jsonError(res, 400, formatErrorMessage(err));
}
const dialogId = toStringOrEmpty(body.dialogId) || void 0;
if (accept === void 0) return jsonError(res, 400, "accept is required");
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (dialogId) return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.dialogId);
if (timeoutMs) return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.dialogTimeout);
await evaluateChromeMcpScript({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,
targetId: tab.targetId,
fn: `() => {
const state = (window.__openclawDialogHook ??= {});
if (!state.originals) {
state.originals = {
alert: window.alert.bind(window),
confirm: window.confirm.bind(window),
prompt: window.prompt.bind(window),
};
}
const originals = state.originals;
const restore = () => {
window.alert = originals.alert;
window.confirm = originals.confirm;
window.prompt = originals.prompt;
delete window.__openclawDialogHook;
};
window.alert = (...args) => {
try {
return undefined;
} finally {
restore();
}
};
window.confirm = (...args) => {
try {
return ${accept ? "true" : "false"};
} finally {
restore();
}
};
window.prompt = (...args) => {
try {
return ${accept ? JSON.stringify(promptText ?? "") : "null"};
} finally {
restore();
}
};
return true;
}`
});
return res.json({ ok: true });
}
const pw = await requirePwAi(res, "dialog hook");
if (!pw) return;
await pw.armDialogViaPlaywright({
cdpUrl,
targetId: tab.targetId,
dialogId,
accept,
promptText,
timeoutMs: timeoutMs ?? void 0
});
res.json({ ok: true });
}
});
}));
}
//#endregion
//#region extensions/browser/src/browser/routes/agent.act.shared.ts
/**
* Shared browser action enums and parsers.
*
* Keeps route normalization, schema tests, and action dispatch using the same
* action names, mouse buttons, and keyboard modifier vocabulary.
*/
const ACT_KINDS = [
"batch",
"click",
"clickCoords",
"close",
"drag",
"evaluate",
"fill",
"hover",
"scrollIntoView",
"press",
"resize",
"select",
"type",
"wait"
];
/** Return true when a raw value names a supported browser action kind. */
function isActKind(value) {
if (typeof value !== "string") return false;
return ACT_KINDS.includes(value);
}
const ALLOWED_CLICK_MODIFIERS = new Set([
"Alt",
"Control",
"ControlOrMeta",
"Meta",
"Shift"
]);
/** Parse a model/client mouse button string into the supported click button set. */
function parseClickButton(raw) {
if (raw === "left" || raw === "right" || raw === "middle") return raw;
}
/** Parse and validate click modifier names accepted by Playwright actions. */
function parseClickModifiers(raw) {
if (raw.filter((m) => !ALLOWED_CLICK_MODIFIERS.has(m)).length) return { error: "modifiers must be Alt|Control|ControlOrMeta|Meta|Shift" };
return { modifiers: raw.length ? raw : void 0 };
}
//#endregion
//#region extensions/browser/src/browser/routes/agent.act.normalize.ts
/**
* Browser action request normalization.
*
* Converts loosely typed route bodies into the closed BrowserActRequest union
* used by Playwright and Chrome MCP action executors.
*/
function normalizeActKind(raw) {
const kind = toStringOrEmpty(raw);
if (!isActKind(kind)) throw new Error("kind is required");
return kind;
}
function countBatchActions(actions) {
let count = 0;
for (const action of actions) {
count += 1;
if (action.kind === "batch") count += countBatchActions(action.actions);
}
return count;
}
/** Validate that nested batch actions cannot drift to a different target tab. */
function validateBatchTargetIds(actions, targetId) {
for (const action of actions) {
if (action.targetId && action.targetId !== targetId) return "batched action targetId must match request targetId";
if (action.kind === "batch") {
const nestedError = validateBatchTargetIds(action.actions, targetId);
if (nestedError) return nestedError;
}
}
return null;
}
function normalizeFields(rawFields) {
return (Array.isArray(rawFields) ? rawFields : []).map((field) => {
if (!field || typeof field !== "object") return null;
return normalizeBrowserFormField(field);
}).filter((field) => field !== null);
}
function normalizeBatchAction(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("batch actions must be objects");
return normalizeActRequest(value, { source: "batch" });
}
function readActionNonNegativeInteger(body, key) {
return readRouteNonNegativeInteger(body[key], key);
}
function readActionTimeoutMs(body) {
return readRouteTimerTimeoutMs(body.timeoutMs);
}
function readBoundedActionDurationMs(body, key, fieldName, maxMs) {
return normalizeActBoundedNonNegativeMs(readActionNonNegativeInteger(body, key), fieldName, maxMs);
}
function readResizeDimension(body, key) {
const value = readRouteInteger(body[key], key, { invalidMessage: "resize requires positive width and height" });
if (value === void 0 && Object.hasOwn(body, key)) throw new Error("resize requires positive width and height");
return value;
}
/** Normalize one model/client action payload into a BrowserActRequest. */
function normalizeActRequest(body, options) {
const source = options?.source ?? "request";
const kind = normalizeActKind(body.kind);
switch (kind) {
case "click": {
const ref = toStringOrEmpty(body.ref) || void 0;
const selector = toStringOrEmpty(body.selector) || void 0;
if (!ref && !selector) throw new Error("click requires ref or selector");
const buttonRaw = toStringOrEmpty(body.button);
const button = buttonRaw ? parseClickButton(buttonRaw) : void 0;
if (buttonRaw && !button) throw new Error("click button must be left|right|middle");
const parsedModifiers = parseClickModifiers(toStringArray(body.modifiers) ?? []);
if (parsedModifiers.error) throw new Error(parsedModifiers.error);
const doubleClick = toBoolean(body.doubleClick);
const delayMs = readBoundedActionDurationMs(body, "delayMs", "click delayMs", ACT_MAX_CLICK_DELAY_MS);
const timeoutMs = readActionTimeoutMs(body);
const targetId = toStringOrEmpty(body.targetId) || void 0;
return {
kind,
...ref ? { ref } : {},
...selector ? { selector } : {},
...targetId ? { targetId } : {},
...doubleClick !== void 0 ? { doubleClick } : {},
...button ? { button } : {},
...parsedModifiers.modifiers ? { modifiers: parsedModifiers.modifiers } : {},
...delayMs !== void 0 ? { delayMs } : {},
...timeoutMs !== void 0 ? { timeoutMs } : {}
};
}
case "clickCoords": {
const x = toNumber(body.x);
const y = toNumber(body.y);
if (x === void 0 || y === void 0 || x < 0 || y < 0) throw new Error("clickCoords requires non-negative x and y");
const buttonRaw = toStringOrEmpty(body.button);
const button = buttonRaw ? parseClickButton(buttonRaw) : void 0;
if (buttonRaw && !button) throw new Error("clickCoords button must be left|right|middle");
const doubleClick = toBoolean(body.doubleClick);
const delayMs = readBoundedActionDurationMs(body, "delayMs", "clickCoords delayMs", ACT_MAX_CLICK_DELAY_MS);
const timeoutMs = readActionTimeoutMs(body);
const targetId = toStringOrEmpty(body.targetId) || void 0;
return {
kind,
x,
y,
...targetId ? { targetId } : {},
...doubleClick !== void 0 ? { doubleClick } : {},
...button ? { button } : {},
...delayMs !== void 0 ? { delayMs } : {},
...timeoutMs !== void 0 ? { timeoutMs } : {}
};
}
case "type": {
const ref = toStringOrEmpty(body.ref) || void 0;
const selector = toStringOrEmpty(body.selector) || void 0;
const text = body.text;
if (!ref && !selector) throw new Error("type requires ref or selector");
if (typeof text !== "string") throw new Error("type requires text");
const targetId = toStringOrEmpty(body.targetId) || void 0;
const submit = toBoolean(body.submit);
const slowly = toBoolean(body.slowly);
const timeoutMs = readActionTimeoutMs(body);
return {
kind,
...ref ? { ref } : {},
...selector ? { selector } : {},
text,
...targetId ? { targetId } : {},
...submit !== void 0 ? { submit } : {},
...slowly !== void 0 ? { slowly } : {},
...timeoutMs !== void 0 ? { timeoutMs } : {}
};
}
case "press": {
const key = toStringOrEmpty(body.key);
if (!key) throw new Error("press requires key");
const targetId = toStringOrEmpty(body.targetId) || void 0;
const delayMs = readActionNonNegativeInteger(body, "delayMs");
return {
kind,
key,
...targetId ? { targetId } : {},
...delayMs !== void 0 ? { delayMs } : {}
};
}
case "hover":
case "scrollIntoView": {
const ref = toStringOrEmpty(body.ref) || void 0;
const selector = toStringOrEmpty(body.selector) || void 0;
if (!ref && !selector) throw new Error(`${kind} requires ref or selector`);
const targetId = toStringOrEmpty(body.targetId) || void 0;
const timeoutMs = readActionTimeoutMs(body);
return {
kind,
...ref ? { ref } : {},
...selector ? { selector } : {},
...targetId ? { targetId } : {},
...timeoutMs !== void 0 ? { timeoutMs } : {}
};
}
case "drag": {
const startRef = toStringOrEmpty(body.startRef) || void 0;
const startSelector = toStringOrEmpty(body.startSelector) || void 0;
const endRef = toStringOrEmpty(body.endRef) || void 0;
const endSelector = toStringOrEmpty(body.endSelector) || void 0;
if (!startRef && !startSelector) throw new Error("drag requires startRef or startSelector");
if (!endRef && !endSelector) throw new Error("drag requires endRef or endSelector");
const targetId = toStringOrEmpty(body.targetId) || void 0;
const timeoutMs = readActionTimeoutMs(body);
return {
kind,
...startRef ? { startRef } : {},
...startSelector ? { startSelector } : {},
...endRef ? { endRef } : {},
...endSelector ? { endSelector } : {},
...targetId ? { targetId } : {},
...timeoutMs !== void 0 ? { timeoutMs } : {}
};
}
case "select": {
const ref = toStringOrEmpty(body.ref) || void 0;
const selector = toStringOrEmpty(body.selector) || void 0;
const values = toStringArray(body.values);
if (!ref && !selector || !values?.length) throw new Error("select requires ref/selector and values");
const targetId = toStringOrEmpty(body.targetId) || void 0;
const timeoutMs = readActionTimeoutMs(body);
return {
kind,
...ref ? { ref } : {},
...selector ? { selector } : {},
values,
...targetId ? { targetId } : {},
...timeoutMs !== void 0 ? { timeoutMs } : {}
};
}
case "fill": {
const fields = normalizeFields(body.fields);
if (!fields.length) throw new Error("fill requires fields");
const targetId = toStringOrEmpty(body.targetId) || void 0;
const timeoutMs = readActionTimeoutMs(body);
return {
kind,
fields,
...targetId ? { targetId } : {},
...timeoutMs !== void 0 ? { timeoutMs } : {}
};
}
case "resize": {
const width = readResizeDimension(body, "width");
const height = readResizeDimension(body, "height");
if (width === void 0 || height === void 0 || width <= 0 || height <= 0) throw new Error("resize requires positive width and height");
if (width > 8192 || height > 8192) throw new Error(`resize width and height must not exceed ${ACT_MAX_VIEWPORT_DIMENSION}`);
const targetId = toStringOrEmpty(body.targetId) || void 0;
return {
kind,
width,
height,
...targetId ? { targetId } : {}
};
}
case "wait": {
const loadStateRaw = toStringOrEmpty(body.loadState);
const loadState = loadStateRaw === "load" || loadStateRaw === "domcontentloaded" || loadStateRaw === "networkidle" ? loadStateRaw : void 0;
const timeMs = readBoundedActionDurationMs(body, "timeMs", "wait timeMs", ACT_MAX_WAIT_TIME_MS);
const text = toStringOrEmpty(body.text) || void 0;
const textGone = toStringOrEmpty(body.textGone) || void 0;
const selector = toStringOrEmpty(body.selector) || void 0;
const url = toStringOrEmpty(body.url) || void 0;
const fn = toStringOrEmpty(body.fn) || void 0;
if (timeMs === void 0 && !text && !textGone && !selector && !url && !loadState && !fn) throw new Error("wait requires at least one of: timeMs, text, textGone, selector, url, loadState, fn");
const targetId = toStringOrEmpty(body.targetId) || void 0;
const timeoutMs = readActionTimeoutMs(body);
return {
kind,
...timeMs !== void 0 ? { timeMs } : {},
...text ? { text } : {},
...textGone ? { textGone } : {},
...selector ? { selector } : {},
...url ? { url } : {},
...loadState ? { loadState } : {},
...fn ? { fn } : {},
...targetId ? { targetId } : {},
...timeoutMs !== void 0 ? { timeoutMs } : {}
};
}
case "evaluate": {
const fn = toStringOrEmpty(body.fn);
if (!fn) throw new Error("evaluate requires fn");
const ref = toStringOrEmpty(body.ref) || void 0;
const targetId = toStringOrEmpty(body.targetId) || void 0;
const timeoutMs = readActionTimeoutMs(body);
return {
kind,
fn,
...ref ? { ref } : {},
...targetId ? { targetId } : {},
...timeoutMs !== void 0 ? { timeoutMs } : {}
};
}
case "close": {
const targetId = toStringOrEmpty(body.targetId) || void 0;
return {
kind,
...targetId ? { targetId } : {}
};
}
case "batch": {
const actions = Array.isArray(body.actions) ? body.actions.map(normalizeBatchAction) : [];
if (!actions.length) throw new Error(source === "batch" ? "batch requires actions" : "actions are required");
if (countBatchActions(actions) > 100) throw new Error(`batch exceeds maximum of 100 actions`);
const targetId = toStringOrEmpty(body.targetId) || void 0;
const stopOnError = toBoolean(body.stopOnError);
return {
kind,
actions,
...targetId ? { targetId } : {},
...stopOnError !== void 0 ? { stopOnError } : {}
};
}
}
throw new Error("Unsupported browser act kind");
}
//#endregion
//#region extensions/browser/src/browser/routes/agent.snapshot-target.ts
/** Resolve the correct targetId after a navigation that may trigger a renderer swap. */
async function resolveTargetIdAfterNavigate(opts) {
let currentTargetId = opts.oldTargetId;
try {
const pickReplacement = (tabs, options) => {
if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) return {
targetId: opts.oldTargetId,
shouldRetry: false
};
const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl);
if (byUrl.length === 1) return {
targetId: byUrl[0]?.targetId ?? opts.oldTargetId,
shouldRetry: false
};
const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId);
if (uniqueReplacement.length === 1) return {
targetId: uniqueReplacement[0]?.targetId ?? opts.oldTargetId,
shouldRetry: false
};
if (options?.allowSingleTabFallback && tabs.length === 1) return {
targetId: tabs[0]?.targetId ?? opts.oldTargetId,
shouldRetry: false
};
return {
targetId: opts.oldTargetId,
shouldRetry: true
};
};
const first = pickReplacement(await opts.listTabs());
currentTargetId = first.targetId;
if (first.shouldRetry) {
await new Promise((r) => {
setTimeout(r, opts.retryDelayMs ?? 800);
});
currentTargetId = pickReplacement(await opts.listTabs(), { allowSingleTabFallback: true }).targetId;
}
} catch {}
return currentTargetId;
}
//#endregion
//#region extensions/browser/src/browser/routes/agent.act.ts
/**
* Browser agent action route registration and existing-session execution.
*
* Dispatches normalized actions to either Playwright-backed OpenClaw browser
* control or Chrome MCP existing-session operations with navigation guards.
*/
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
const EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS = [
0,
250,
500
];
async function readExistingSessionLocationHref(params) {
const currentUrl = await evaluateChromeMcpScript({
profileName: params.profileName,
profile: params.profile,
userDataDir: params.userDataDir,
targetId: params.targetId,
fn: "() => window.location.href"
});
if (typeof currentUrl !== "string") throw new Error("Location probe returned a non-string result");
const normalizedUrl = currentUrl.trim();
if (!normalizedUrl) throw new Error("Location probe returned an empty URL");
return normalizedUrl;
}
async function assertExistingSessionPostInteractionNavigationAllowed(params) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(params.ssrfPolicy);
if (!ssrfPolicyOpts.ssrfPolicy) return;
const listTabs = params.listTabs;
const initialTabTargetIds = params.initialTabTargetIds;
const assertNewTabsAllowed = async () => {
const tabs = await listTabs();
for (const tab of tabs) {
if (initialTabTargetIds.has(tab.targetId)) continue;
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts
});
}
};
let lastObservedUrl;
let sawStableAllowedUrl = false;
for (const delayMs of EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS) {
if (delayMs > 0) await sleep(delayMs);
let currentUrl;
try {
currentUrl = await readExistingSessionLocationHref(params);
} catch {
sawStableAllowedUrl = false;
continue;
}
await assertBrowserNavigationResultAllowed({
url: currentUrl,
...ssrfPolicyOpts
});
if (currentUrl === lastObservedUrl) sawStableAllowedUrl = true;
else sawStableAllowedUrl = false;
lastObservedUrl = currentUrl;
}
if (sawStableAllowedUrl) {
await assertNewTabsAllowed();
return;
}
if (lastObservedUrl) {
const lastDelay = EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS[EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS.length - 1];
await sleep(lastDelay);
try {
const followUpUrl = await readExistingSessionLocationHref(params);
await assertBrowserNavigationResultAllowed({
url: followUpUrl,
...ssrfPolicyOpts
});
if (followUpUrl === lastObservedUrl) {
await assertNewTabsAllowed();
return;
}
} catch {}
}
throw new Error("Unable to verify stable post-interaction navigation");
}
async function runExistingSessionActionWithNavigationGuard(params) {
let actionError;
let result;
try {
result = await params.execute();
} catch (error) {
actionError = error;
}
if (params.guard) await assertExistingSessionPostInteractionNavigationAllowed(params.guard);
if (actionError) throw toLintErrorObject(actionError, "Non-Error thrown");
return result;
}
function buildExistingSessionWaitPredicate(params) {
const checks = [];
if (params.text) checks.push(`Boolean(document.body?.innerText?.includes(${JSON.stringify(params.text)}))`);
if (params.textGone) checks.push(`!document.body?.innerText?.includes(${JSON.stringify(params.textGone)})`);
if (params.selector) checks.push(`Boolean(document.querySelector(${JSON.stringify(params.selector)}))`);
if (params.loadState === "domcontentloaded") checks.push(`document.readyState === "interactive" || document.readyState === "complete"`);
else if (params.loadState === "load") checks.push(`document.readyState === "complete"`);
if (params.fn) checks.push(`Boolean(await (${params.fn})())`);
if (checks.length === 0) return null;
return checks.length === 1 ? checks[0] : checks.map((check) => `(${check})`).join(" && ");
}
async function waitForExistingSessionCondition(params) {
if (params.timeMs && params.timeMs > 0) await sleep(params.timeMs);
const predicate = buildExistingSessionWaitPredicate(params);
if (!predicate && !params.url) return;
const timeoutMs = Math.max(250, params.timeoutMs ?? 1e4);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
let ready = true;
if (predicate) ready = Boolean(await evaluateChromeMcpScript({
profileName: params.profileName,
profile: params.profile,
userDataDir: params.userDataDir,
targetId: params.targetId,
fn: `async () => ${predicate}`
}));
if (ready && params.url) {
const currentUrl = await evaluateChromeMcpScript({
profileName: params.profileName,
profile: params.profile,
userDataDir: params.userDataDir,
targetId: params.targetId,
fn: "() => window.location.href"
});
ready = typeof currentUrl === "string" && matchBrowserUrlPattern(params.url, currentUrl);
}
if (ready) return;
await sleep(250);
}
throw new Error("Timed out waiting for condition");
}
const SELECTOR_ALLOWED_KINDS = new Set([
"batch",
"click",
"drag",
"hover",
"scrollIntoView",
"select",
"type",
"wait"
]);
function shouldEnforceCurrentUrlForAct(action) {
return action.kind !== "resize" && action.kind !== "close";
}
function getExistingSessionUnsupportedMessage(action) {
switch (action.kind) {
case "click":
if (action.selector) return EXISTING_SESSION_LIMITS.act.clickSelector;
if (action.button && action.button !== "left" || Array.isArray(action.modifiers) && action.modifiers.length > 0) return EXISTING_SESSION_LIMITS.act.clickButtonOrModifiers;
return null;
case "clickCoords": return null;
case "type":
if (action.selector) return EXISTING_SESSION_LIMITS.act.typeSelector;
if (action.slowly) return EXISTING_SESSION_LIMITS.act.typeSlowly;
return action.timeoutMs ? EXISTING_SESSION_LIMITS.act.typeTimeout : null;
case "press": return action.delayMs ? EXISTING_SESSION_LIMITS.act.pressDelay : null;
case "hover":
if (action.selector) return EXISTING_SESSION_LIMITS.act.hoverSelector;
return action.timeoutMs ? EXISTING_SESSION_LIMITS.act.hoverTimeout : null;
case "scrollIntoView":
if (action.selector) return EXISTING_SESSION_LIMITS.act.scrollSelector;
return action.timeoutMs ? EXISTING_SESSION_LIMITS.act.scrollTimeout : null;
case "drag":
if (action.startSelector || action.endSelector) return EXISTING_SESSION_LIMITS.act.dragSelector;
return action.timeoutMs ? EXISTING_SESSION_LIMITS.act.dragTimeout : null;
case "select":
if (action.selector) return EXISTING_SESSION_LIMITS.act.selectSelector;
if (action.values.length !== 1) return EXISTING_SESSION_LIMITS.act.selectSingleValue;
return action.timeoutMs ? EXISTING_SESSION_LIMITS.act.selectTimeout : null;
case "fill": return action.timeoutMs ? EXISTING_SESSION_LIMITS.act.fillTimeout : null;
case "wait": return action.loadState === "networkidle" ? EXISTING_SESSION_LIMITS.act.waitNetworkIdle : null;
case "evaluate": return action.timeoutMs !== void 0 ? EXISTING_SESSION_LIMITS.act.evaluateTimeout : null;
case "batch": return EXISTING_SESSION_LIMITS.act.batch;
case "resize":
case "close": return null;
}
throw new Error("Unsupported browser act kind");
}
/** Register browser action endpoints, including hook and download subroutes. */
function registerBrowserAgentActRoutes(app, ctx) {
app.post("/act", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const kindRaw = toStringOrEmpty(body.kind);
if (!isActKind(kindRaw)) return jsonActError(res, 400, ACT_ERROR_CODES.kindRequired, "kind is required");
const kind = kindRaw;
let action;
try {
action = normalizeActRequest(body);
} catch (err) {
return jsonActError(res, 400, ACT_ERROR_CODES.invalidRequest, formatErrorMessage(err));
}
const targetId = resolveTargetIdFromBody(body);
if (Object.hasOwn(body, "selector") && !SELECTOR_ALLOWED_KINDS.has(kind)) return jsonActError(res, 400, ACT_ERROR_CODES.selectorUnsupported, SELECTOR_UNSUPPORTED_MESSAGE);
const earlyFn = action.kind === "wait" || action.kind === "evaluate" ? action.fn : "";
if ((action.kind === "evaluate" || action.kind === "wait" && earlyFn) && !ctx.state().resolved.evaluateEnabled) return jsonActError(res, 403, ACT_ERROR_CODES.evaluateDisabled, browserEvaluateDisabledMessage(action.kind === "evaluate" ? "evaluate" : "wait"));
await withRouteTabContext({
req,
res,
ctx,
targetId,
enforceCurrentUrlAllowed: shouldEnforceCurrentUrlForAct(action),
run: async ({ profileCtx, cdpUrl, tab, resolveTabUrl }) => {
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
const ssrfPolicy = ctx.state().resolved.ssrfPolicy;
const isExistingSession = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
const hasNavigationResultPolicy = Boolean(withBrowserNavigationPolicy(ssrfPolicy).ssrfPolicy);
const jsonOk = async (extra, options) => {
const responseTargetId = options?.resolveCurrentTarget && (!isExistingSession || hasNavigationResultPolicy) ? await resolveTargetIdAfterNavigate({
oldTargetId: tab.targetId,
navigatedUrl: tab.url,
listTabs: () => profileCtx.listTabs()
}) : tab.targetId;
const url = responseTargetId === tab.targetId ? await resolveTabUrl(tab.url) : await resolveSafeRouteTabUrl({
ctx,
profileCtx,
targetId: responseTargetId,
fallbackUrl: tab.url
});
return res.json({
ok: true,
targetId: responseTargetId,
...url ? { url } : {},
...extra
});
};
if (action.targetId && action.targetId !== tab.targetId) return jsonActError(res, 403, ACT_ERROR_CODES.targetIdMismatch, "action targetId must match request targetId");
const profileName = profileCtx.profile.name;
if (isExistingSession) {
const initialTabTargetIds = hasNavigationResultPolicy ? new Set((await profileCtx.listTabs()).map((currentTab) => currentTab.targetId)) : /* @__PURE__ */ new Set();
const existingSessionNavigationGuard = {
profileName,
profile: profileCtx.profile,
targetId: tab.targetId,
ssrfPolicy,
listTabs: () => profileCtx.listTabs(),
initialTabTargetIds
};
const unsupportedMessage = getExistingSessionUnsupportedMessage(action);
if (unsupportedMessage) return jsonActError(res, 501, ACT_ERROR_CODES.unsupportedForExistingSession, unsupportedMessage);
switch (action.kind) {
case "click":
await runExistingSessionActionWithNavigationGuard({
execute: () => clickChromeMcpElement({
profileName,
profile: profileCtx.profile,
targetId: tab.targetId,
uid: action.ref,
doubleClick: action.doubleClick ?? false,
timeoutMs: action.timeoutMs,
signal: req.signal
}),
guard: existingSessionNavigationGuard
});
return await jsonOk(void 0, { resolveCurrentTarget: true });
case "clickCoords":
await runExistingSessionActionWithNavigationGuard({
execute: () => clickChromeMcpCoords({
profileName,
profile: profileCtx.profile,
targetId: tab.targetId,
x: action.x,
y: action.y,
doubleClick: action.doubleClick ?? false,
button: action.button,
delayMs: action.delayMs
}),
guard: existingSessionNavigationGuard
});
return await jsonOk(void 0, { resolveCurrentTarget: true });
case "type":
await runExistingSessionActionWithNavigationGuard({
execute: async () => {
await fillChromeMcpElement({
profileName,
profile: profileCtx.profile,
targetId: tab.targetId,
uid: action.ref,
value: action.text
});
if (action.submit) await pressChromeMcpKey({
profileName,
profile: profileCtx.profile,
targetId: tab.targetId,
key: "Enter"
});
},
guard: existingSessionNavigationGuard
});
return await jsonOk(void 0, { resolveCurrentTarget: true });
case "press":
await runExistingSessionActionWithNavigationGuard({
execute: () => pressChromeMcpKey({
profileName,
profile: profileCtx.profile,
targetId: tab.targetId,
key: action.key
}),
guard: existingSessionNavigationGuard
});
return await jsonOk(void 0, { resolveCurrentTarget: true });
case "hover":
await runExistingSessionActionWithNavigationGuard({
execute: () => hoverChromeMcpElement({
profileName,
profile: profileCtx.profile,
targetId: tab.targetId,
uid: action.ref
}),
guard: existingSessionNavigationGuard
});
return await jsonOk();
case "scrollIntoView":
await runExistingSessionActionWithNavigationGuard({
execute: () => evaluateChromeMcpScript({
profileName,
profile: profileCtx.profile,
targetId: tab.targetId,
fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`,
args: [action.ref]
}),
guard: existingSessionNavigationGuard
});
return await jsonOk();
case "drag":
await runExistingSessionActionWithNavigationGuard({
execute: () => dragChromeMcpElement({
profileName,
profile: profileCtx.profile,
targetId: tab.targetId,
fromUid: action.startRef,
toUid: action.endRef
}),
guard: existingSessionNavigationGuard
});
return await jsonOk();
case "select":
await runExistingSessionActionWithNavigationGuard({
execute: () => fillChromeMcpElement({
profileName,
profile: profileCtx.profile,
targetId: tab.targetId,
uid: action.ref,
value: action.values[0] ?? ""
}),
guard: existingSessionNavigationGuard
});
return await jsonOk();
case "fill":
await runExistingSessionActionWithNavigationGuard({
execute: () => fillChromeMcpForm({
profileName,
profile: profileCtx.profile,
targetId: tab.targetId,
elements: action.fields.map((field) => ({
uid: field.ref,
value: String(field.value ?? "")
}))
}),
guard: existingSessionNavigationGuard
});
return await jsonOk();
case "resize":
await resizeChromeMcpPage({
profileName,
profile: profileCtx.profile,
targetId: tab.targetId,
width: action.width,
height: action.height
});
return await jsonOk();
case "wait":
await waitForExistingSessionCondition({
profileName,
profile: profileCtx.profile,
targetId: tab.targetId,
timeMs: action.timeMs,
text: action.text,
textGone: action.textGone,
selector: action.selector,
url: action.url,
loadState: action.loadState,
fn: action.fn,
timeoutMs: action.timeoutMs
});
return await jsonOk();
case "evaluate": return await jsonOk({ result: await runExistingSessionActionWithNavigationGuard({
execute: () => evaluateChromeMcpScript({
profileName,
profile: profileCtx.profile,
targetId: tab.targetId,
fn: normalizeBrowserEvaluateFunctionSource(action.fn, action.ref ? { argumentName: "el" } : void 0),
args: action.ref ? [action.ref] : void 0
}),
guard: existingSessionNavigationGuard
}) });
case "close":
await closeChromeMcpTab(profileName, tab.targetId, profileCtx.profile);
return await jsonOk();
case "batch": return jsonActError(res, 501, ACT_ERROR_CODES.unsupportedForExistingSession, EXISTING_SESSION_LIMITS.act.batch);
}
}
const pw = await requirePwAi(res, `act:${kind}`);
if (!pw) return;
if (action.kind === "batch") {
const targetIdError = validateBatchTargetIds(action.actions, tab.targetId);
if (targetIdError) return jsonActError(res, 403, ACT_ERROR_CODES.targetIdMismatch, targetIdError);
}
const result = await pw.executeActViaPlaywright({
cdpUrl,
action,
targetId: tab.targetId,
evaluateEnabled,
ssrfPolicy,
signal: req.signal
});
if (result.blockedByDialog) return await jsonOk({
blockedByDialog: true,
browserState: result.browserState
});
switch (action.kind) {
case "batch": return await jsonOk({ results: result.results ?? [] }, { resolveCurrentTarget: true });
case "evaluate": return await jsonOk({ result: result.result }, { resolveCurrentTarget: true });
case "click":
case "clickCoords": return await jsonOk(void 0, { resolveCurrentTarget: true });
case "resize": return await jsonOk();
default: return await jsonOk(void 0, { resolveCurrentTarget: true });
}
}
});
}));
registerBrowserAgentActHookRoutes(app, ctx);
registerBrowserAgentActDownloadRoutes(app, ctx);
app.post("/response/body", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const url = toStringOrEmpty(body.url);
let timeoutMs;
let maxChars;
try {
timeoutMs = readRouteTimerTimeoutMs(body.timeoutMs);
maxChars = readRoutePositiveInteger(body.maxChars, "maxChars");
} catch (err) {
return jsonError(res, 400, formatErrorMessage(err));
}
if (!url) return jsonError(res, 400, "url is required");
await withRouteTabContext({
req,
res,
ctx,
targetId,
enforceCurrentUrlAllowed: true,
run: async ({ profileCtx, cdpUrl, tab, resolveTabUrl }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) return jsonError(res, 501, EXISTING_SESSION_LIMITS.responseBody);
const pw = await requirePwAi(res, "response body");
if (!pw) return;
const result = await pw.responseBodyViaPlaywright({
cdpUrl,
targetId: tab.targetId,
url,
timeoutMs: timeoutMs ?? void 0,
maxChars: maxChars ?? void 0
});
const currentUrl = await resolveTabUrl(tab.url);
res.json({
ok: true,
targetId: tab.targetId,
...currentUrl ? { url: currentUrl } : {},
response: result
});
}
});
}));
app.post("/highlight", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref);
if (!ref) return jsonError(res, 400, "ref is required");
await withRouteTabContext({
req,
res,
ctx,
targetId,
enforceCurrentUrlAllowed: true,
run: async ({ profileCtx, cdpUrl, tab, resolveTabUrl }) => {
const jsonOk = async () => {
const currentUrl = await resolveTabUrl(tab.url);
return res.json({
ok: true,
targetId: tab.targetId,
...currentUrl ? { url: currentUrl } : {}
});
};
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
await evaluateChromeMcpScript({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,
targetId: tab.targetId,
args: [ref],
fn: `(el) => {
if (!(el instanceof Element)) {
return false;
}
el.scrollIntoView({ block: "center", inline: "center" });
const previousOutline = el.style.outline;
const previousOffset = el.style.outlineOffset;
el.style.outline = "3px solid #FF4500";
el.style.outlineOffset = "2px";
setTimeout(() => {
el.style.outline = previousOutline;
el.style.outlineOffset = previousOffset;
}, 2000);
return true;
}`
});
return await jsonOk();
}
const pw = await requirePwAi(res, "highlight");
if (!pw) return;
await pw.highlightViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref
});
await jsonOk();
}
});
}));
}
function toLintErrorObject(value, fallbackMessage) {
if (value instanceof Error) return value;
if (typeof value === "string") return new Error(value);
const error = new Error(fallbackMessage, { cause: value });
if (typeof value === "object" && value !== null || typeof value === "function") Object.assign(error, value);
return error;
}
//#endregion
//#region extensions/browser/src/browser/routes/agent.debug.ts
/**
* Browser debug and trace routes.
*
* Exposes console messages, page errors, network requests, dialog state, and
* Playwright tracing scoped to the selected browser tab.
*/
function browserDebugTargetPayload(targetId, url) {
return {
ok: true,
targetId,
...url ? { url } : {}
};
}
async function sendPlaywrightDebugCollection(params) {
await withPlaywrightRouteContext({
req: params.req,
res: params.res,
ctx: params.ctx,
targetId: params.targetId,
feature: params.feature,
enforceCurrentUrlAllowed: true,
run: async ({ cdpUrl, tab, pw, resolveTabUrl }) => {
const result = await params.collect({
cdpUrl,
targetId: tab.targetId,
pw
});
const url = await resolveTabUrl(tab.url);
params.res.json({
...browserDebugTargetPayload(tab.targetId, url),
...result
});
}
});
}
/** Register browser debug endpoints on the control server. */
function registerBrowserAgentDebugRoutes(app, ctx) {
app.get("/console", asyncBrowserRoute(async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const level = typeof req.query.level === "string" ? req.query.level : "";
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "console messages",
enforceCurrentUrlAllowed: true,
run: async ({ cdpUrl, tab, pw, resolveTabUrl }) => {
const messages = await pw.getConsoleMessagesViaPlaywright({
cdpUrl,
targetId: tab.targetId,
level: normalizeOptionalString$1(level)
});
const url = await resolveTabUrl(tab.url);
res.json({
...browserDebugTargetPayload(tab.targetId, url),
messages
});
}
});
}));
app.get("/errors", asyncBrowserRoute(async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const clear = toBoolean(req.query.clear) ?? false;
await sendPlaywrightDebugCollection({
req,
res,
ctx,
targetId,
feature: "page errors",
collect: async ({ cdpUrl, targetId: targetIdValue, pw }) => await pw.getPageErrorsViaPlaywright({
cdpUrl,
targetId: targetIdValue,
clear
})
});
}));
app.get("/requests", asyncBrowserRoute(async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const filter = typeof req.query.filter === "string" ? req.query.filter : "";
const clear = toBoolean(req.query.clear) ?? false;
await sendPlaywrightDebugCollection({
req,
res,
ctx,
targetId,
feature: "network requests",
collect: async ({ cdpUrl, targetId: targetIdLocal, pw }) => await pw.getNetworkRequestsViaPlaywright({
cdpUrl,
targetId: targetIdLocal,
filter: normalizeOptionalString$1(filter),
clear
})
});
}));
app.get("/dialogs", asyncBrowserRoute(async (req, res) => {
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId: resolveTargetIdFromQuery(req.query),
feature: "dialog state",
enforceCurrentUrlAllowed: true,
run: async ({ cdpUrl, tab, pw, resolveTabUrl }) => {
const browserState = await pw.getObservedBrowserStateViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy
});
const url = await resolveTabUrl(tab.url);
res.json({
...browserDebugTargetPayload(tab.targetId, url),
browserState
});
}
});
}));
app.post("/trace/start", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const screenshots = toBoolean(body.screenshots) ?? void 0;
const snapshots = toBoolean(body.snapshots) ?? void 0;
const sources = toBoolean(body.sources) ?? void 0;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "trace start",
enforceCurrentUrlAllowed: true,
run: async ({ cdpUrl, tab, pw, resolveTabUrl }) => {
await pw.traceStartViaPlaywright({
cdpUrl,
targetId: tab.targetId,
screenshots,
snapshots,
sources
});
const url = await resolveTabUrl(tab.url);
res.json(browserDebugTargetPayload(tab.targetId, url));
}
});
}));
app.post("/trace/stop", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const out = toStringOrEmpty(body.path) || "";
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "trace stop",
enforceCurrentUrlAllowed: true,
run: async ({ cdpUrl, tab, pw, resolveTabUrl }) => {
const tracePath = await resolveWritableOutputPathOrRespond({
res,
rootDir: DEFAULT_TRACE_DIR,
requestedPath: out,
scopeLabel: "trace directory",
defaultFileName: `browser-trace-${crypto.randomUUID()}.zip`,
ensureRootDir: true
});
if (!tracePath) return;
await pw.traceStopViaPlaywright({
cdpUrl,
targetId: tab.targetId,
path: tracePath
});
const url = await resolveTabUrl(tab.url);
res.json({
...browserDebugTargetPayload(tab.targetId, url),
path: path.resolve(tracePath)
});
}
});
}));
}
//#endregion
//#region extensions/browser/src/browser/chrome-mcp.snapshot.ts
/**
* Chrome MCP snapshot conversion helpers.
*
* Converts chrome-devtools-mcp structured snapshots into OpenClaw ARIA nodes
* and compact AI snapshots with stable refs and duplicate tracking.
*/
function normalizeRole(node) {
return normalizeLowercaseStringOrEmpty(node.role) || "generic";
}
function escapeQuoted(value) {
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
}
function shouldIncludeNode(params) {
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, name) {
return INTERACTIVE_ROLES.has(role) || CONTENT_ROLES.has(role) && Boolean(name);
}
function createDuplicateTracker() {
return {
counts: /* @__PURE__ */ new Map(),
keysByRef: /* @__PURE__ */ new Map(),
duplicates: /* @__PURE__ */ new Set()
};
}
function registerRef(tracker, ref, role, name) {
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;
}
}
/** Flatten a Chrome MCP snapshot tree into OpenClaw ARIA-style nodes. */
function flattenChromeMcpSnapshotToAriaNodes(root, limit = 500) {
const boundedLimit = Math.max(1, Math.min(2e3, Math.floor(limit)));
const out = [];
const visit = (node, depth) => {
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;
}
/** Build a compact text snapshot and ref map from a Chrome MCP snapshot tree. */
function buildAiSnapshotFromChromeMcpSnapshot(params) {
const refs = {};
const tracker = createDuplicateTracker();
const lines = [];
const visit = (node, depth) => {
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 !== void 0 && depth > maxDepth) return;
if (shouldIncludeNode({
role,
name,
options: params.options
})) {
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 === void 0 ? {
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) : void 0;
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
};
}
//#endregion
//#region extensions/browser/src/browser/routes/agent.snapshot.plan.ts
/**
* Snapshot planning for browser route handlers.
*
* Resolves requested snapshot mode, format, limits, refs, labels, and driver
* choice before the route talks to Playwright or Chrome MCP.
*/
/** Resolve a normalized snapshot plan from query parameters and profile caps. */
function resolveSnapshotPlan(params) {
const mode = params.query.mode === "efficient" ? "efficient" : void 0;
const labels = toBoolean(params.query.labels) ?? void 0;
const urls = toBoolean(params.query.urls) ?? void 0;
const explicitFormat = params.query.format === "aria" ? "aria" : params.query.format === "ai" ? "ai" : void 0;
const format = resolveDefaultSnapshotFormat({
profile: params.profile,
hasPlaywright: params.hasPlaywright,
explicitFormat,
mode
});
const limit = parseStrictPositiveInteger(params.query.limit);
const hasMaxChars = Object.hasOwn(params.query, "maxChars");
const maxCharsRaw = parseStrictNonNegativeInteger(params.query.maxChars);
const resolvedMaxChars = format === "ai" ? hasMaxChars ? maxCharsRaw === void 0 ? mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS : DEFAULT_AI_SNAPSHOT_MAX_CHARS : maxCharsRaw !== void 0 && maxCharsRaw > 0 ? maxCharsRaw : void 0 : mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS : DEFAULT_AI_SNAPSHOT_MAX_CHARS : void 0;
const interactiveRaw = toBoolean(params.query.interactive);
const compactRaw = toBoolean(params.query.compact);
const depthRaw = parseStrictNonNegativeInteger(params.query.depth);
const refsModeRaw = toStringOrEmpty(params.query.refs).trim();
const refsMode = refsModeRaw === "aria" ? "aria" : refsModeRaw === "role" ? "role" : void 0;
const interactive = interactiveRaw ?? (mode === "efficient" ? true : void 0);
const compact = compactRaw ?? (mode === "efficient" ? true : void 0);
const depth = depthRaw ?? (mode === "efficient" ? 6 : void 0);
const selectorValue = normalizeOptionalString$1(toStringOrEmpty(params.query.selector));
const frameSelectorValue = normalizeOptionalString$1(toStringOrEmpty(params.query.frame));
const timeoutMsRaw = parseStrictPositiveInteger(params.query.timeoutMs);
return {
format,
mode,
labels,
urls,
limit,
resolvedMaxChars,
interactive,
compact,
depth,
refsMode,
selectorValue,
frameSelectorValue,
timeoutMs: timeoutMsRaw !== void 0 ? normalizeBrowserTimerDelayMs(timeoutMsRaw) : void 0,
wantsRoleSnapshot: labels === true || urls === true || mode === "efficient" || interactive === true || compact === true || depth !== void 0 || Boolean(selectorValue) || Boolean(frameSelectorValue)
};
}
//#endregion
//#region extensions/browser/src/browser/routes/agent.snapshot.ts
/**
* Browser snapshot, navigation, and screenshot routes.
*
* Handles profile-aware snapshot generation across Playwright and Chrome MCP,
* navigation policy checks, media storage, and screenshot normalization.
*/
const CHROME_MCP_OVERLAY_ATTR = "data-openclaw-mcp-overlay";
async function collectChromeMcpSnapshotUrls(params) {
const result = await evaluateChromeMcpScript({
profileName: params.profileName,
profile: params.profile,
userDataDir: params.userDataDir,
targetId: params.targetId,
fn: `() => {
const seen = new Set();
const out = [];
for (const anchor of Array.from(document.querySelectorAll("a[href]"))) {
const href = anchor.href || "";
if (!href || seen.has(href)) continue;
const text = (anchor.innerText || anchor.textContent || anchor.getAttribute("aria-label") || "")
.replace(/\\s+/g, " ")
.trim()
.slice(0, 120) || href;
seen.add(href);
out.push({ text, url: href });
if (out.length >= 100) break;
}
return out;
}`
}).catch(() => []);
return Array.isArray(result) ? result.filter((entry) => entry && typeof entry === "object" && typeof entry.text === "string" && typeof entry.url === "string") : [];
}
async function clearChromeMcpOverlay(params) {
await evaluateChromeMcpScript({
profileName: params.profileName,
profile: params.profile,
userDataDir: params.userDataDir,
targetId: params.targetId,
fn: `() => {
document.querySelectorAll("[${CHROME_MCP_OVERLAY_ATTR}]").forEach((node) => node.remove());
return true;
}`
}).catch(() => {});
}
async function renderChromeMcpLabels(params) {
const refList = JSON.stringify(params.refs);
const result = await evaluateChromeMcpScript({
profileName: params.profileName,
profile: params.profile,
userDataDir: params.userDataDir,
targetId: params.targetId,
args: params.refs,
fn: `(...elements) => {
const refs = ${refList};
document.querySelectorAll("[${CHROME_MCP_OVERLAY_ATTR}]").forEach((node) => node.remove());
const root = document.createElement("div");
root.setAttribute("${CHROME_MCP_OVERLAY_ATTR}", "labels");
root.style.position = "fixed";
root.style.inset = "0";
root.style.pointerEvents = "none";
root.style.zIndex = "2147483647";
let labels = 0;
let skipped = 0;
elements.forEach((el, index) => {
if (!(el instanceof Element)) {
skipped += 1;
return;
}
const rect = el.getBoundingClientRect();
if (rect.width <= 0 && rect.height <= 0) {
skipped += 1;
return;
}
labels += 1;
const badge = document.createElement("div");
badge.setAttribute("${CHROME_MCP_OVERLAY_ATTR}", "label");
badge.textContent = refs[index] || String(labels);
badge.style.position = "fixed";
badge.style.left = \`\${Math.max(0, rect.left)}px\`;
badge.style.top = \`\${Math.max(0, rect.top)}px\`;
badge.style.transform = "translateY(-100%)";
badge.style.padding = "2px 6px";
badge.style.borderRadius = "999px";
badge.style.background = "#FF4500";
badge.style.color = "#fff";
badge.style.font = "600 12px ui-monospace, SFMono-Regular, Menlo, monospace";
badge.style.boxShadow = "0 2px 6px rgba(0,0,0,0.35)";
badge.style.whiteSpace = "nowrap";
root.appendChild(badge);
});
document.documentElement.appendChild(root);
return { labels, skipped };
}`
});
return {
labels: result && typeof result === "object" && typeof result.labels === "number" ? result.labels : 0,
skipped: result && typeof result === "object" && typeof result.skipped === "number" ? result.skipped : 0
};
}
async function saveNormalizedScreenshotResponse(params) {
const normalized = await normalizeBrowserScreenshot(params.buffer, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES
});
await saveBrowserMediaResponse({
res: params.res,
buffer: normalized.buffer,
contentType: normalized.contentType ?? `image/${params.type}`,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
targetId: params.targetId,
url: params.url,
labels: params.labels,
labelsCount: params.labelsCount,
labelsSkipped: params.labelsSkipped
});
}
async function saveBrowserMediaResponse(params) {
await ensureMediaDir();
const saved = await saveMediaBuffer(params.buffer, params.contentType, "browser", params.maxBytes);
params.res.json({
ok: true,
path: path.resolve(saved.path),
targetId: params.targetId,
url: params.url,
...params.labels ? { labels: true } : {},
...typeof params.labelsCount === "number" ? { labelsCount: params.labelsCount } : {},
...typeof params.labelsSkipped === "number" ? { labelsSkipped: params.labelsSkipped } : {}
});
}
function hasObservableBrowserState(state) {
if (!state || typeof state !== "object") return false;
const dialogs = state.dialogs;
return Boolean(dialogs?.pending?.length || dialogs?.recent?.length);
}
function hasPendingDialogs(state) {
if (!state || typeof state !== "object") return false;
const dialogs = state.dialogs;
return Boolean(dialogs?.pending?.length);
}
function browserStateResponseFields(state) {
return hasObservableBrowserState(state) ? { browserState: state } : {};
}
/** Register snapshot, screenshot, and navigation endpoints. */
function registerBrowserAgentSnapshotRoutes(app, ctx) {
app.post("/navigate", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const url = toStringOrEmpty(body.url);
const targetId = toStringOrEmpty(body.targetId) || void 0;
if (!url) return jsonError(res, 400, "url is required");
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx);
await assertBrowserNavigationAllowed({
url,
...ssrfPolicyOpts
});
const result = await navigateChromeMcpPage({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,
targetId: tab.targetId,
url
});
await assertBrowserNavigationResultAllowed({
url: result.url,
...ssrfPolicyOpts
});
return res.json({
ok: true,
targetId: tab.targetId,
...result
});
}
const pw = await requirePwAi(res, "navigate");
if (!pw) return;
const result = await pw.navigateViaPlaywright({
cdpUrl,
targetId: tab.targetId,
url,
...browserNavigationPolicyForProfile(ctx, profileCtx)
});
const currentTargetId = await resolveTargetIdAfterNavigate({
oldTargetId: tab.targetId,
navigatedUrl: result.url,
listTabs: () => profileCtx.listTabs()
});
res.json({
ok: true,
targetId: currentTargetId,
...result
});
}
});
}));
app.post("/pdf", asyncBrowserRoute(async (req, res) => {
const targetId = toStringOrEmpty(readBody(req).targetId) || void 0;
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) return jsonError(res, 501, EXISTING_SESSION_LIMITS.snapshot.pdfUnsupported);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "pdf",
enforceCurrentUrlAllowed: true,
run: async ({ cdpUrl, tab, pw }) => {
const pdf = await pw.pdfViaPlaywright({
cdpUrl,
targetId: tab.targetId
});
await saveBrowserMediaResponse({
res,
buffer: pdf.buffer,
contentType: "application/pdf",
maxBytes: pdf.buffer.byteLength,
targetId: tab.targetId,
url: tab.url
});
}
});
}));
app.post("/screenshot", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || void 0;
const fullPage = toBoolean(body.fullPage) ?? false;
const ref = toStringOrEmpty(body.ref) || void 0;
const element = toStringOrEmpty(body.element) || void 0;
const labels = toBoolean(body.labels) ?? false;
const type = body.type === "jpeg" ? "jpeg" : "png";
let timeoutMs;
try {
const timeoutMsRaw = readRoutePositiveInteger(body.timeoutMs, "timeoutMs");
timeoutMs = timeoutMsRaw !== void 0 ? normalizeBrowserTimerDelayMs(timeoutMsRaw) : DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS;
} catch (err) {
return jsonError(res, 400, String(err instanceof Error ? err.message : err));
}
if (fullPage && (ref || element)) return jsonError(res, 400, "fullPage is not supported for element screenshots");
await withRouteTabContext({
req,
res,
ctx,
targetId,
enforceCurrentUrlAllowed: true,
run: async ({ profileCtx, tab, cdpUrl }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx);
if (ssrfPolicyOpts.ssrfPolicy) await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts
});
if (element) return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.screenshotElement);
if (labels) {
const built = buildAiSnapshotFromChromeMcpSnapshot({ root: await takeChromeMcpSnapshot({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,
targetId: tab.targetId
}) });
const labelResult = await renderChromeMcpLabels({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,
targetId: tab.targetId,
refs: Object.keys(built.refs)
});
try {
await saveNormalizedScreenshotResponse({
res,
buffer: await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,
targetId: tab.targetId,
fullPage,
format: type,
timeoutMs
}),
type,
targetId: tab.targetId,
url: tab.url,
labels: true,
labelsCount: labelResult.labels,
labelsSkipped: labelResult.skipped
});
} finally {
await clearChromeMcpOverlay({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,
targetId: tab.targetId
});
}
return;
}
await saveNormalizedScreenshotResponse({
res,
buffer: await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,
targetId: tab.targetId,
uid: ref,
fullPage,
format: type,
timeoutMs
}),
type,
targetId: tab.targetId,
url: tab.url
});
return;
}
let buffer;
if (labels || shouldUsePlaywrightForScreenshot({
profile: profileCtx.profile,
wsUrl: tab.wsUrl,
ref,
element
})) {
const pw = await requirePwAi(res, "screenshot");
if (!pw) return;
if (labels) {
const snap = await pw.snapshotRoleViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy
});
const labeled = await pw.screenshotWithLabelsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
refs: snap.refs,
type,
timeoutMs
});
await saveNormalizedScreenshotResponse({
res,
buffer: labeled.buffer,
type,
targetId: tab.targetId,
url: tab.url,
labels: true,
labelsCount: labeled.labels,
labelsSkipped: labeled.skipped
});
return;
}
buffer = (await pw.takeScreenshotViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref,
element,
fullPage,
type,
timeoutMs
})).buffer;
} else buffer = await captureScreenshot({
wsUrl: tab.wsUrl ?? "",
fullPage,
format: type,
quality: type === "jpeg" ? 85 : void 0,
timeoutMs
});
await saveNormalizedScreenshotResponse({
res,
buffer,
type,
targetId: tab.targetId,
url: tab.url
});
}
});
}));
app.get("/snapshot", asyncBrowserRoute(async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const pwModule = await getPwAiModule();
const hasPlaywright = Boolean(pwModule);
const plan = resolveSnapshotPlan({
profile: profileCtx.profile,
query: req.query,
hasPlaywright
});
try {
const tab = await profileCtx.ensureTabAvailable(targetId || void 0);
const usesChromeMcp = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx);
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") return jsonError(res, 400, "labels/mode=efficient require format=ai");
if (usesChromeMcp && (plan.selectorValue || plan.frameSelectorValue)) return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.snapshotSelector);
if (ssrfPolicyOpts.ssrfPolicy) await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts
});
let observedBrowserState;
if (!usesChromeMcp && pwModule) observedBrowserState = await pwModule.getObservedBrowserStateViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy
}).catch(() => void 0);
if (usesChromeMcp) {
const snapshot = await takeChromeMcpSnapshot({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,
targetId: tab.targetId,
timeoutMs: plan.timeoutMs
});
if (plan.format === "aria") return res.json({
ok: true,
format: "aria",
targetId: tab.targetId,
url: tab.url,
nodes: flattenChromeMcpSnapshotToAriaNodes(snapshot, plan.limit)
});
const built = buildAiSnapshotFromChromeMcpSnapshot({
root: snapshot,
options: {
interactive: plan.interactive ?? void 0,
compact: plan.compact ?? void 0,
maxDepth: plan.depth ?? void 0
},
maxChars: plan.resolvedMaxChars
});
const builtWithUrls = plan.urls ? {
...built,
snapshot: appendSnapshotUrls(built.snapshot, await collectChromeMcpSnapshotUrls({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,
targetId: tab.targetId
}))
} : built;
if (plan.labels) {
const refs = Object.keys(builtWithUrls.refs);
const labelResult = await renderChromeMcpLabels({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,
targetId: tab.targetId,
refs
});
try {
const normalized = await normalizeBrowserScreenshot(await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,
targetId: tab.targetId,
format: "png",
timeoutMs: plan.timeoutMs
}), {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES
});
await ensureMediaDir();
const saved = await saveMediaBuffer(normalized.buffer, normalized.contentType ?? "image/png", "browser", DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES);
return res.json({
ok: true,
format: "ai",
targetId: tab.targetId,
url: tab.url,
labels: true,
labelsCount: labelResult.labels,
labelsSkipped: labelResult.skipped,
imagePath: path.resolve(saved.path),
imageType: normalized.contentType?.includes("jpeg") ? "jpeg" : "png",
...builtWithUrls
});
} finally {
await clearChromeMcpOverlay({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,
targetId: tab.targetId
});
}
}
return res.json({
ok: true,
format: "ai",
targetId: tab.targetId,
url: tab.url,
...builtWithUrls
});
}
if (hasPendingDialogs(observedBrowserState)) return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
blockedByDialog: true,
...browserStateResponseFields(observedBrowserState),
...plan.format === "aria" ? { nodes: [] } : {
snapshot: "",
refs: {}
}
});
if (plan.format === "ai") {
const roleSnapshotArgs = {
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
selector: plan.selectorValue,
frameSelector: plan.frameSelectorValue,
refsMode: plan.refsMode,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
urls: plan.urls,
timeoutMs: plan.timeoutMs,
options: {
interactive: plan.interactive ?? void 0,
compact: plan.compact ?? void 0,
maxDepth: plan.depth ?? void 0
}
};
const cdpRoleSnapshot = async () => {
if (!tab.wsUrl) return null;
if (plan.selectorValue || plan.frameSelectorValue) return null;
return await snapshotRoleViaCdp({
wsUrl: tab.wsUrl,
urls: plan.urls,
timeoutMs: plan.timeoutMs,
options: {
interactive: plan.interactive ?? void 0,
compact: plan.compact ?? void 0,
maxDepth: plan.depth ?? void 0
}
});
};
const pw = await getPwAiModule();
const snap = plan.wantsRoleSnapshot ? pw ? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs).catch(async (err) => {
const fallback = await cdpRoleSnapshot();
if (fallback) return fallback;
throw err;
}) : await cdpRoleSnapshot() : pw ? await pw.snapshotAiViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
urls: plan.urls,
timeoutMs: plan.timeoutMs,
...typeof plan.resolvedMaxChars === "number" ? { maxChars: plan.resolvedMaxChars } : {}
}) : await cdpRoleSnapshot();
if (!snap) {
await requirePwAi(res, "ai snapshot");
return;
}
if (plan.labels) {
if (!pw) return jsonError(res, 501, "Snapshot labels require Playwright.");
const labeled = await pw.screenshotWithLabelsViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
refs: "refs" in snap ? snap.refs : {},
type: "png",
timeoutMs: plan.timeoutMs
});
const normalized = await normalizeBrowserScreenshot(labeled.buffer, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES
});
await ensureMediaDir();
const saved = await saveMediaBuffer(normalized.buffer, normalized.contentType ?? "image/png", "browser", DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES);
const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png";
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...browserStateResponseFields(observedBrowserState),
labels: true,
labelsCount: labeled.labels,
labelsSkipped: labeled.skipped,
imagePath: path.resolve(saved.path),
imageType,
...snap
});
}
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...browserStateResponseFields(observedBrowserState),
...snap
});
}
const usePlaywrightAriaSnapshot = shouldUsePlaywrightForAriaSnapshot({
profile: profileCtx.profile,
wsUrl: tab.wsUrl
});
const snap = usePlaywrightAriaSnapshot ? (() => {
return requirePwAi(res, "aria snapshot").then(async (pw) => {
if (!pw) return null;
return await pw.snapshotAriaViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
limit: plan.limit,
timeoutMs: plan.timeoutMs,
ssrfPolicy: ctx.state().resolved.ssrfPolicy
});
});
})() : snapshotAria({
wsUrl: tab.wsUrl ?? "",
limit: plan.limit,
timeoutMs: plan.timeoutMs
});
const resolved = await Promise.resolve(snap);
if (!resolved) return;
if (!usePlaywrightAriaSnapshot) await pwModule?.storeAriaSnapshotRefsViaPlaywright?.({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
nodes: resolved.nodes
});
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...browserStateResponseFields(observedBrowserState),
...resolved
});
} catch (err) {
handleRouteError(ctx, res, err);
}
}));
}
//#endregion
//#region extensions/browser/src/browser/routes/agent.storage.ts
/**
* Browser storage and context mutation routes.
*
* Parses and applies cookies, local/session storage, geolocation, permissions,
* and related browser-context mutations for the selected profile/tab.
*/
/** Parse the supported browser storage bucket names. */
function parseStorageKind(raw) {
if (raw === "local" || raw === "session") return raw;
return null;
}
/** Parse an optional storage mutation request from a route body. */
function parseStorageMutationRequest(kindParam, body) {
return {
kind: parseStorageKind(toStringOrEmpty(kindParam)),
targetId: resolveTargetIdFromBody(body)
};
}
/** Parse a required storage mutation request and throw on invalid input. */
function parseRequiredStorageMutationRequest(kindParam, body) {
const parsed = parseStorageMutationRequest(kindParam, body);
if (!parsed.kind) return null;
return {
kind: parsed.kind,
targetId: parsed.targetId
};
}
function parseStorageMutationOrRespond(res, kindParam, body) {
const parsed = parseRequiredStorageMutationRequest(kindParam, body);
if (!parsed) {
jsonError(res, 400, "kind must be local|session");
return null;
}
return parsed;
}
function parseStorageMutationFromRequest(req, res) {
const body = readBody(req);
const parsed = parseStorageMutationOrRespond(res, req.params.kind, body);
if (!parsed) return null;
return {
body,
parsed
};
}
function assertRange(value, fieldName, min, max) {
if (value === void 0) return;
if (value < min || value > max) throw new Error(`${fieldName} must be between ${min} and ${max}.`);
return value;
}
/** Parse cookie options accepted by browser storage mutation routes. */
function parseCookieSetOptions(cookie) {
return {
name: toStringOrEmpty(cookie.name),
value: toStringOrEmpty(cookie.value),
url: toStringOrEmpty(cookie.url) || void 0,
domain: toStringOrEmpty(cookie.domain) || void 0,
path: toStringOrEmpty(cookie.path) || void 0,
expires: readOptionalRouteFiniteNumber(cookie.expires, "cookie.expires"),
httpOnly: toBoolean(cookie.httpOnly) ?? void 0,
secure: toBoolean(cookie.secure) ?? void 0,
sameSite: cookie.sameSite === "Lax" || cookie.sameSite === "None" || cookie.sameSite === "Strict" ? cookie.sameSite : void 0
};
}
/** Parse geolocation override options accepted by context mutation routes. */
function parseGeolocationOptions(body) {
const clear = toBoolean(body.clear) ?? false;
const origin = toStringOrEmpty(body.origin) || void 0;
if (clear) return {
clear,
origin
};
const latitude = assertRange(readRouteFiniteNumber(body.latitude, "latitude"), "latitude", -90, 90);
const longitude = assertRange(readRouteFiniteNumber(body.longitude, "longitude"), "longitude", -180, 180);
const accuracy = readRouteFiniteNumber(body.accuracy, "accuracy");
if (accuracy !== void 0 && accuracy < 0) throw new Error("accuracy must be non-negative.");
if (!clear && (latitude === void 0 || longitude === void 0)) throw new Error("latitude and longitude are required (or set clear=true)");
return {
clear,
latitude,
longitude,
accuracy,
origin
};
}
/** Register storage and browser-context mutation endpoints. */
function registerBrowserAgentStorageRoutes(app, ctx) {
app.get("/cookies", asyncBrowserRoute(async (req, res) => {
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId: resolveTargetIdFromQuery(req.query),
feature: "cookies",
enforceCurrentUrlAllowed: true,
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.cookiesGetViaPlaywright({
cdpUrl,
targetId: tab.targetId
});
res.json({
ok: true,
targetId: tab.targetId,
...result
});
}
});
}));
app.post("/cookies/set", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const cookie = body.cookie && typeof body.cookie === "object" && !Array.isArray(body.cookie) ? body.cookie : null;
if (!cookie) return jsonError(res, 400, "cookie is required");
let parsedCookie;
try {
parsedCookie = parseCookieSetOptions(cookie);
} catch (err) {
return jsonError(res, 400, formatErrorMessage(err));
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "cookies set",
run: async ({ cdpUrl, tab, pw }) => {
await pw.cookiesSetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
cookie: parsedCookie
});
res.json({
ok: true,
targetId: tab.targetId
});
}
});
}));
app.post("/cookies/clear", asyncBrowserRoute(async (req, res) => {
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId: resolveTargetIdFromBody(readBody(req)),
feature: "cookies clear",
run: async ({ cdpUrl, tab, pw }) => {
await pw.cookiesClearViaPlaywright({
cdpUrl,
targetId: tab.targetId
});
res.json({
ok: true,
targetId: tab.targetId
});
}
});
}));
app.get("/storage/:kind", asyncBrowserRoute(async (req, res) => {
const kind = parseStorageKind(toStringOrEmpty(req.params.kind));
if (!kind) return jsonError(res, 400, "kind must be local|session");
const targetId = resolveTargetIdFromQuery(req.query);
const key = toStringOrEmpty(req.query.key);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "storage get",
enforceCurrentUrlAllowed: true,
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.storageGetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind,
key: normalizeOptionalString$1(key)
});
res.json({
ok: true,
targetId: tab.targetId,
...result
});
}
});
}));
app.post("/storage/:kind/set", asyncBrowserRoute(async (req, res) => {
const mutation = parseStorageMutationFromRequest(req, res);
if (!mutation) return;
const key = toStringOrEmpty(mutation.body.key);
if (!key) return jsonError(res, 400, "key is required");
const value = typeof mutation.body.value === "string" ? mutation.body.value : "";
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId: mutation.parsed.targetId,
feature: "storage set",
run: async ({ cdpUrl, tab, pw }) => {
await pw.storageSetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind: mutation.parsed.kind,
key,
value
});
res.json({
ok: true,
targetId: tab.targetId
});
}
});
}));
app.post("/storage/:kind/clear", asyncBrowserRoute(async (req, res) => {
const mutation = parseStorageMutationFromRequest(req, res);
if (!mutation) return;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId: mutation.parsed.targetId,
feature: "storage clear",
run: async ({ cdpUrl, tab, pw }) => {
await pw.storageClearViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind: mutation.parsed.kind
});
res.json({
ok: true,
targetId: tab.targetId
});
}
});
}));
app.post("/set/offline", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const offline = toBoolean(body.offline);
if (offline === void 0) return jsonError(res, 400, "offline is required");
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "offline",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setOfflineViaPlaywright({
cdpUrl,
targetId: tab.targetId,
offline
});
res.json({
ok: true,
targetId: tab.targetId
});
}
});
}));
app.post("/set/headers", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const headers = body.headers && typeof body.headers === "object" && !Array.isArray(body.headers) ? body.headers : null;
if (!headers) return jsonError(res, 400, "headers is required");
const parsed = {};
for (const [k, v] of Object.entries(headers)) if (typeof v === "string") parsed[k] = v;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "headers",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setExtraHTTPHeadersViaPlaywright({
cdpUrl,
targetId: tab.targetId,
headers: parsed
});
res.json({
ok: true,
targetId: tab.targetId
});
}
});
}));
app.post("/set/credentials", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const clear = toBoolean(body.clear) ?? false;
const username = toStringOrEmpty(body.username) || void 0;
const password = readStringValue(body.password);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "http credentials",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setHttpCredentialsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
username,
password,
clear
});
res.json({
ok: true,
targetId: tab.targetId
});
}
});
}));
app.post("/set/geolocation", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
let geolocation;
try {
geolocation = parseGeolocationOptions(body);
} catch (err) {
return jsonError(res, 400, formatErrorMessage(err));
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "geolocation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setGeolocationViaPlaywright({
cdpUrl,
targetId: tab.targetId,
...geolocation
});
res.json({
ok: true,
targetId: tab.targetId
});
}
});
}));
app.post("/set/media", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const schemeRaw = toStringOrEmpty(body.colorScheme);
const colorScheme = schemeRaw === "dark" || schemeRaw === "light" || schemeRaw === "no-preference" ? schemeRaw : schemeRaw === "none" ? null : void 0;
if (colorScheme === void 0) return jsonError(res, 400, "colorScheme must be dark|light|no-preference|none");
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "media emulation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.emulateMediaViaPlaywright({
cdpUrl,
targetId: tab.targetId,
colorScheme
});
res.json({
ok: true,
targetId: tab.targetId
});
}
});
}));
app.post("/set/timezone", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const timezoneId = toStringOrEmpty(body.timezoneId);
if (!timezoneId) return jsonError(res, 400, "timezoneId is required");
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "timezone",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setTimezoneViaPlaywright({
cdpUrl,
targetId: tab.targetId,
timezoneId
});
res.json({
ok: true,
targetId: tab.targetId
});
}
});
}));
app.post("/set/locale", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const locale = toStringOrEmpty(body.locale);
if (!locale) return jsonError(res, 400, "locale is required");
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "locale",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setLocaleViaPlaywright({
cdpUrl,
targetId: tab.targetId,
locale
});
res.json({
ok: true,
targetId: tab.targetId
});
}
});
}));
app.post("/set/device", asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const name = toStringOrEmpty(body.name);
if (!name) return jsonError(res, 400, "name is required");
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "device emulation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setDeviceViaPlaywright({
cdpUrl,
targetId: tab.targetId,
name
});
res.json({
ok: true,
targetId: tab.targetId
});
}
});
}));
}
//#endregion
//#region extensions/browser/src/browser/routes/agent.ts
/** Register all agent-facing browser route groups. */
function registerBrowserAgentRoutes(app, ctx) {
registerBrowserAgentSnapshotRoutes(app, ctx);
registerBrowserAgentActRoutes(app, ctx);
registerBrowserAgentDebugRoutes(app, ctx);
registerBrowserAgentStorageRoutes(app, ctx);
}
//#endregion
//#region extensions/browser/src/browser/doctor.ts
/** Build a browser doctor report from a status response and environment facts. */
function buildBrowserDoctorReport(params) {
const status = params.status;
const checks = [];
const transport = status.transport === "chrome-mcp" ? "chrome-mcp" : "cdp";
checks.push({
id: "plugin-enabled",
label: "Browser plugin",
status: status.enabled ? "pass" : "fail",
summary: status.enabled ? "enabled" : "disabled",
...status.enabled ? {} : { fixHint: "Enable the browser plugin and restart the Gateway." }
});
checks.push({
id: "profile",
label: "Profile",
status: "pass",
summary: `${status.profile ?? "openclaw"} via ${transport}`
});
if (transport === "chrome-mcp") checks.push({
id: "attach-target",
label: "Existing browser attach",
status: status.running ? "pass" : "fail",
summary: status.running ? "Chrome MCP target is reachable" : "Chrome MCP target is not reachable",
...status.running ? {} : { fixHint: "Keep the matching Chromium browser running, enable remote debugging in chrome://inspect, and accept the attach prompt." }
});
else {
checks.push({
id: "managed-executable",
label: "Chromium executable",
status: status.detectError ? "fail" : status.detectedExecutablePath ? "pass" : "warn",
summary: status.detectError ? status.detectError : status.detectedExecutablePath ? `${status.detectedBrowser ?? "chromium"} at ${status.detectedExecutablePath}` : "No Chromium executable detected",
...status.detectedExecutablePath || status.detectError ? {} : { fixHint: "Install Chrome/Chromium/Brave/Edge or set browser.executablePath." }
});
const platform = params.platform ?? process.platform;
const env = params.env ?? process.env;
const uid = params.uid ?? process.getuid?.();
const missingDisplay = platform === "linux" && !status.headless && !env.DISPLAY && !env.WAYLAND_DISPLAY;
if (status.headlessSource === "linux-display-fallback") checks.push({
id: "headless-mode",
label: "Headless mode",
status: "pass",
summary: "Linux no-display fallback selected headless mode"
});
if (missingDisplay) checks.push({
id: "display",
label: "Display",
status: "warn",
summary: `No DISPLAY or WAYLAND_DISPLAY is set while headed mode is selected (${status.headlessSource ?? "unknown"})`,
fixHint: "Use a desktop session, Xvfb, set OPENCLAW_BROWSER_HEADLESS=1, or remove the headed override."
});
if (platform === "linux" && uid === 0 && !status.noSandbox) checks.push({
id: "linux-sandbox",
label: "Linux sandbox",
status: "warn",
summary: "Gateway is running as root while browser.noSandbox is false",
fixHint: "Set browser.noSandbox: true for container/root Chromium runtimes."
});
checks.push({
id: "cdp-http",
label: "CDP HTTP",
status: status.cdpHttp ? "pass" : status.running ? "fail" : "info",
summary: status.cdpHttp ? "CDP HTTP endpoint is reachable" : status.running ? "CDP HTTP endpoint is not reachable" : "Browser is not currently running",
...status.cdpHttp || !status.running ? {} : { fixHint: "Run openclaw browser start or inspect browser.cdpUrl/CDP port reachability." }
});
checks.push({
id: "cdp-websocket",
label: "CDP WebSocket",
status: status.cdpReady ? "pass" : status.running ? "fail" : "info",
summary: status.cdpReady ? "CDP WebSocket is reachable" : status.running ? "CDP WebSocket is not reachable" : "Browser is launchable but not running",
...status.cdpReady || !status.running ? {} : { fixHint: "Check Chrome launch logs, stale locks, proxy env, and port conflicts." }
});
}
return {
ok: checks.every((check) => check.status !== "fail"),
profile: status.profile ?? "openclaw",
transport,
checks,
status
};
}
//#endregion
//#region extensions/browser/src/browser/profiles-service.ts
/**
* Browser profile service.
*
* Implements profile listing, creation, and deletion using browser config
* mutation helpers and route context runtime state.
*/
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
/** Create a profile service bound to one browser route context. */
function createBrowserProfilesService(ctx) {
const listProfiles = async () => {
return await ctx.listProfiles();
};
const createProfile = async (params) => {
const name = params.name.trim();
const rawCdpUrl = normalizeOptionalString$1(params.cdpUrl);
const rawUserDataDir = normalizeOptionalString$1(params.userDataDir);
const normalizedUserDataDir = rawUserDataDir ? resolveUserPath(rawUserDataDir) : void 0;
const driver = params.driver === "existing-session" ? "existing-session" : void 0;
if (!isValidProfileName(name)) throw new BrowserValidationError("invalid profile name: use lowercase letters, numbers, and hyphens only");
const state = ctx.state();
if (name in state.resolved.profiles) throw new BrowserConflictError(`profile "${name}" already exists`);
if (name in (getRuntimeConfig().browser?.profiles ?? {})) throw new BrowserConflictError(`profile "${name}" already exists`);
const explicitProfileColor = params.color && HEX_COLOR_RE.test(params.color) ? params.color : void 0;
let parsedCdpUrl;
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;
try {
parsed = parseBrowserHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
await assertCdpEndpointAllowed(parsed.normalized, state.resolved.ssrfPolicy);
} catch (err) {
throw new BrowserValidationError(formatErrorMessage(err));
}
parsedCdpUrl = parsed.normalized;
}
const profileConfig = await createBrowserProfileConfig({
name,
resolved: state.resolved,
...explicitProfileColor ? { color: explicitProfileColor } : {},
...parsedCdpUrl ? { parsedCdpUrl } : {},
...normalizedUserDataDir ? { userDataDir: normalizedUserDataDir } : {},
...driver ? { driver } : {}
});
if (!profileConfig) throw new BrowserProfileNotFoundError(`profile "${name}" not found after creation`);
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: resolved.cdpUrl || null,
userDataDir: resolved.userDataDir ?? null,
color: resolved.color,
isRemote: !resolved.cdpIsLoopback
};
};
const deleteProfile = async (nameRaw) => {
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 = getRuntimeConfig();
const profiles = cfg.browser?.profiles ?? {};
if (name === (cfg.browser?.defaultProfile ?? state.resolved.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 {}
const userDataDir = resolveOpenClawUserDataDir(name);
const profileDir = path.dirname(userDataDir);
if (fs.existsSync(profileDir)) {
await movePathToTrash(profileDir);
deleted = true;
}
}
await deleteBrowserProfileConfig(name);
delete state.resolved.profiles[name];
state.profiles.delete(name);
return {
ok: true,
profile: name,
deleted
};
};
return {
listProfiles,
createProfile,
deleteProfile
};
}
//#endregion
//#region extensions/browser/src/browser/routes/basic.ts
/**
* Basic browser control routes.
*
* Serves status, doctor, start/stop, profile management, and simple health
* endpoints for the browser control server.
*/
const STATUS_CDP_HTTP_TIMEOUT_MS = 300;
const STATUS_CDP_TRANSPORT_TIMEOUT_MS = 600;
const STATUS_CHROME_MCP_TOTAL_TIMEOUT_MS = 7e3;
const STATUS_CHROME_MCP_TRANSPORT_TIMEOUT_MS = 5e3;
function remainingChromeMcpStatusTimeoutMs(startedAtMs) {
return Math.max(1, STATUS_CHROME_MCP_TOTAL_TIMEOUT_MS - (Date.now() - startedAtMs));
}
async function probeChromeMcpPageReady(profileCtx, timeoutMs) {
const abort = new AbortController();
const timer = setTimeout(() => {
abort.abort(/* @__PURE__ */ new Error(`Chrome MCP page-readiness probe timed out after ${timeoutMs}ms.`));
}, timeoutMs);
try {
return await profileCtx.isReachable(timeoutMs, {
ephemeral: true,
signal: abort.signal
});
} catch {
return false;
} finally {
clearTimeout(timer);
}
}
function handleBrowserRouteError(res, err) {
const mapped = toBrowserErrorResponse(err);
if (mapped) return jsonError(res, mapped.status, mapped.message);
jsonError(res, 500, String(err));
}
async function sendBasicJsonResponse(params) {
try {
params.res.json(await params.run());
} catch (err) {
return handleBrowserRouteError(params.res, err);
}
}
async function withBasicProfileRoute(params) {
const profileCtx = resolveProfileContext(params.req, params.res, params.ctx);
if (!profileCtx) return;
try {
await params.run(profileCtx);
} catch (err) {
return handleBrowserRouteError(params.res, err);
}
}
function registerBasicProfilePost(app, ctx, path, run) {
app.post(path, asyncBrowserRoute(async (req, res) => {
await withBasicProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => await run({
req,
res,
profileCtx
})
});
}));
}
async function withProfilesServiceMutation(params) {
try {
const service = createBrowserProfilesService(params.ctx);
const result = await params.run(service);
params.res.json(result);
} catch (err) {
return handleBrowserRouteError(params.res, err);
}
}
async function buildBrowserStatus(req, ctx) {
let current;
try {
current = ctx.state();
} catch {
throw new BrowserError("browser server not started", 503);
}
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) throw new BrowserError(profileCtx.error, profileCtx.status);
const capabilities = getBrowserProfileCapabilities(profileCtx.profile);
const [cdpHttp, cdpReady, pageReady] = capabilities.usesChromeMcp ? await (async () => {
const statusStartedAtMs = Date.now();
const transportReady = await profileCtx.isTransportAvailable(STATUS_CHROME_MCP_TRANSPORT_TIMEOUT_MS);
if (!transportReady) return [
false,
false,
false
];
return [
transportReady,
transportReady,
await probeChromeMcpPageReady(profileCtx, remainingChromeMcpStatusTimeoutMs(statusStartedAtMs))
];
})() : await (async () => {
const [http, ready] = await Promise.all([profileCtx.isHttpReachable(STATUS_CDP_HTTP_TIMEOUT_MS), profileCtx.isTransportAvailable(STATUS_CDP_TRANSPORT_TIMEOUT_MS)]);
return [
http,
ready,
ready
];
})();
const profileState = current.profiles.get(profileCtx.profile.name);
let detectedBrowser = null;
let detectedExecutablePath = null;
let detectError = null;
try {
const detected = resolveBrowserExecutableForPlatform(current.resolved, process.platform);
if (detected) {
detectedBrowser = detected.kind;
detectedExecutablePath = detected.path;
}
} catch (err) {
detectError = String(err);
}
const configuredHeadlessMode = resolveManagedBrowserHeadlessMode(current.resolved, profileCtx.profile);
const headlessMode = typeof profileState?.running?.headless === "boolean" ? {
headless: profileState.running.headless,
source: profileState.running.headlessSource ?? configuredHeadlessMode.source
} : configuredHeadlessMode;
return {
enabled: current.resolved.enabled,
profile: profileCtx.profile.name,
driver: profileCtx.profile.driver,
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
running: cdpReady,
cdpReady,
cdpHttp,
pageReady,
pid: capabilities.usesChromeMcp ? getChromeMcpPid(profileCtx.profile.name) : profileState?.running?.pid ?? null,
cdpPort: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpPort,
cdpUrl: profileCtx.profile.cdpUrl ? redactCdpUrl(profileCtx.profile.cdpUrl) ?? null : null,
chosenBrowser: profileState?.running?.exe.kind ?? null,
detectedBrowser,
detectedExecutablePath,
detectError,
userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null,
color: profileCtx.profile.color,
headless: headlessMode.headless,
headlessSource: headlessMode.source,
noSandbox: current.resolved.noSandbox,
executablePath: profileCtx.profile.executablePath ?? null,
attachOnly: profileCtx.profile.attachOnly
};
}
async function runBrowserLiveProbe(req, ctx) {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) return {
id: "live-snapshot",
label: "Live snapshot",
status: "fail",
summary: profileCtx.error
};
const capabilities = getBrowserProfileCapabilities(profileCtx.profile);
try {
const tab = await profileCtx.ensureTabAvailable();
if (capabilities.usesChromeMcp) {
await takeChromeMcpSnapshot({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,
targetId: tab.targetId
});
return {
id: "live-snapshot",
label: "Live snapshot",
status: "pass",
summary: `Chrome MCP snapshot succeeded on ${tab.suggestedTargetId ?? tab.targetId}`
};
}
if (!tab.wsUrl) return {
id: "live-snapshot",
label: "Live snapshot",
status: "warn",
summary: "No per-tab CDP WebSocket available for the lightweight live snapshot probe"
};
const snap = await snapshotAria({
wsUrl: tab.wsUrl,
limit: 25
});
return {
id: "live-snapshot",
label: "Live snapshot",
status: snap.nodes.length > 0 ? "pass" : "warn",
summary: snap.nodes.length > 0 ? `CDP accessibility snapshot returned ${snap.nodes.length} nodes on ${tab.suggestedTargetId ?? tab.targetId}` : `CDP accessibility snapshot returned no nodes on ${tab.suggestedTargetId ?? tab.targetId}`
};
} catch (err) {
return {
id: "live-snapshot",
label: "Live snapshot",
status: "fail",
summary: String(err),
fixHint: "Run openclaw browser start, then retry with openclaw browser doctor --deep."
};
}
}
function hasQueryKey(query, key) {
return Object.hasOwn(query ?? {}, key);
}
function parseHeadlessStartOverride(params) {
if (!hasQueryKey(params.req.query, "headless")) return { ok: true };
const headless = toBoolean(params.req.query.headless);
if (typeof headless !== "boolean") {
jsonError(params.res, 400, "Invalid headless value. Use \"true\" or \"false\".");
return { ok: false };
}
const capabilities = getBrowserProfileCapabilities(params.profileCtx.profile);
if (params.profileCtx.profile.driver !== "openclaw" || params.profileCtx.profile.attachOnly || capabilities.isRemote) {
jsonError(params.res, 400, `Headless start override is only supported for locally launched openclaw profiles. Profile "${params.profileCtx.profile.name}" is attach-only, remote, or existing-session.`);
return { ok: false };
}
return {
ok: true,
headless
};
}
/** Register basic browser lifecycle, status, doctor, and profile endpoints. */
function registerBrowserBasicRoutes(app, ctx) {
app.get("/profiles", asyncBrowserRoute(async (_req, res) => {
try {
const profiles = await createBrowserProfilesService(ctx).listProfiles();
res.json({ profiles });
} catch (err) {
jsonError(res, 500, String(err));
}
}));
app.get("/", asyncBrowserRoute(async (req, res) => {
await sendBasicJsonResponse({
res,
run: async () => await buildBrowserStatus(req, ctx)
});
}));
app.get("/doctor", asyncBrowserRoute(async (req, res) => {
await sendBasicJsonResponse({
res,
run: async () => {
const report = buildBrowserDoctorReport({ status: await buildBrowserStatus(req, ctx) });
if (toBoolean(req.query.deep) === true || toBoolean(req.query.live) === true) {
report.checks.push(await runBrowserLiveProbe(req, ctx));
report.ok = report.checks.every((check) => check.status !== "fail");
}
return report;
}
});
}));
registerBasicProfilePost(app, ctx, "/start", async ({ req, res, profileCtx }) => {
const headlessOverride = parseHeadlessStartOverride({
req,
res,
profileCtx
});
if (!headlessOverride.ok) return;
await profileCtx.ensureBrowserAvailable({ headless: headlessOverride.headless });
res.json({
ok: true,
profile: profileCtx.profile.name
});
});
registerBasicProfilePost(app, ctx, "/stop", async ({ res, profileCtx }) => {
const result = await profileCtx.stopRunningBrowser();
res.json({
ok: true,
stopped: result.stopped,
profile: profileCtx.profile.name
});
});
registerBasicProfilePost(app, ctx, "/reset-profile", async ({ res, profileCtx }) => {
const result = await profileCtx.resetProfile();
res.json({
ok: true,
profile: profileCtx.profile.name,
...result
});
});
app.post("/profiles/create", asyncBrowserRoute(async (req, res) => {
const name = toStringOrEmpty(req.body?.name);
const color = toStringOrEmpty(req.body?.color);
const cdpUrl = toStringOrEmpty(req.body?.cdpUrl);
const userDataDir = toStringOrEmpty(req.body?.userDataDir);
const driver = toStringOrEmpty(req.body?.driver);
if (!name) return jsonError(res, 400, "name is required");
if (driver && driver !== "openclaw" && driver !== "clawd" && driver !== "existing-session") return jsonError(res, 400, `unsupported profile driver "${driver}"; use "openclaw", "clawd", or "existing-session"`);
await withProfilesServiceMutation({
res,
ctx,
run: async (service) => await service.createProfile({
name,
color: color || void 0,
cdpUrl: cdpUrl || void 0,
userDataDir: userDataDir || void 0,
driver: driver === "existing-session" ? "existing-session" : driver === "openclaw" || driver === "clawd" ? "openclaw" : void 0
})
});
}));
app.delete("/profiles/:name", asyncBrowserRoute(async (req, res) => {
const name = toStringOrEmpty(req.params.name);
if (!name) return jsonError(res, 400, "profile name is required");
await withProfilesServiceMutation({
res,
ctx,
run: async (service) => await service.deleteProfile(name)
});
}));
}
//#endregion
//#region extensions/browser/src/browser/routes/permissions.ts
/**
* Browser permission routes.
*
* Grants required and optional browser permissions for an origin, preferring
* Playwright context APIs when available and falling back to raw CDP.
*/
const permissionRouteDeps = { getPwAiModule: getPwAiModule$1 };
function readOrigin(raw) {
const value = toStringOrEmpty(raw);
if (!value) return null;
try {
const parsed = new URL(value);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
return parsed.origin;
} catch {
return null;
}
}
function readPermissions(raw) {
if (!Array.isArray(raw)) return null;
const permissions = raw.map((value) => typeof value === "string" ? value.trim() : "").filter(Boolean);
if (permissions.length !== raw.length) return null;
return uniqueStrings(permissions);
}
async function grantPermissions(params) {
const allPermissions = [...new Set([...params.requiredPermissions, ...params.optionalPermissions])];
const playwrightRequiredPermissions = params.requiredPermissions.map(toPlaywrightPermission);
if (playwrightRequiredPermissions.every((value) => Boolean(value)) && params.requiredPermissions.length > 0) {
const pw = await permissionRouteDeps.getPwAiModule({ mode: "soft" });
if (pw) try {
await (await pw.getPageForTargetId({
cdpUrl: params.profileCtx.profile.cdpUrl,
targetId: params.targetId,
ssrfPolicy: params.ssrfPolicy
})).context().grantPermissions(playwrightRequiredPermissions, { origin: params.origin });
return {
grantedPermissions: params.requiredPermissions,
unsupportedPermissions: params.optionalPermissions,
grantMethod: "playwright"
};
} catch {}
}
let unsupportedPermissions = [];
await withCdpSocket(params.wsUrl, async (send) => {
try {
await send("Browser.grantPermissions", {
origin: params.origin,
permissions: allPermissions
});
return;
} catch (error) {
if (params.optionalPermissions.length === 0) throw error;
}
await send("Browser.grantPermissions", {
origin: params.origin,
permissions: params.requiredPermissions
});
unsupportedPermissions = params.optionalPermissions;
}, { commandTimeoutMs: params.timeoutMs });
return {
grantedPermissions: allPermissions.filter((value) => !unsupportedPermissions.includes(value)),
unsupportedPermissions,
grantMethod: "cdp"
};
}
function toPlaywrightPermission(permission) {
switch (permission) {
case "audioCapture": return "microphone";
case "videoCapture": return "camera";
default: return;
}
}
/** Register permission grant endpoints on the browser control server. */
function registerBrowserPermissionRoutes(app, ctx) {
app.post("/permissions/grant", asyncBrowserRoute(async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
const body = req.body ?? {};
const origin = readOrigin(body.origin);
if (!origin) return jsonError(res, 400, "origin must be an http(s) origin");
const requiredPermissions = readPermissions(body.permissions);
if (!requiredPermissions || requiredPermissions.length === 0) return jsonError(res, 400, "permissions must be a non-empty string array");
const optionalPermissions = readPermissions(body.optionalPermissions ?? []) ?? [];
const targetId = toStringOrEmpty(body.targetId) || void 0;
let timeoutMs;
try {
timeoutMs = readRouteTimerTimeoutMs(body.timeoutMs, "timeoutMs", { minMs: 1e3 }) ?? 5e3;
} catch (err) {
return jsonError(res, 400, formatErrorMessage(err));
}
try {
await profileCtx.ensureBrowserAvailable();
const wsUrl = await getChromeWebSocketUrl(profileCtx.profile.cdpUrl, timeoutMs, ctx.state().resolved.ssrfPolicy);
if (!wsUrl) return jsonError(res, 409, "browser CDP WebSocket unavailable");
const granted = await grantPermissions({
profileCtx,
targetId,
wsUrl,
origin,
requiredPermissions,
optionalPermissions,
timeoutMs,
ssrfPolicy: ctx.state().resolved.ssrfPolicy
});
return res.json({
ok: true,
origin,
...granted
});
} catch (error) {
return jsonError(res, 500, error instanceof Error ? error.message : String(error));
}
}));
}
//#endregion
//#region extensions/browser/src/browser/routes/tabs.ts
/**
* Browser tab management routes.
*
* Lists, opens, focuses, closes, and mutates tabs while applying navigation
* policy checks and profile reachability probes.
*/
const DEFAULT_TAB_REACHABILITY_TIMEOUT_MS = 300;
function handleTabsRouteError(ctx, res, err, opts) {
if (opts?.mapTabError) {
const mapped = ctx.mapTabError(err);
if (mapped) return jsonError(res, mapped.status, mapped.message);
}
return jsonError(res, 500, String(err));
}
async function withTabsProfileRoute(params) {
const profileCtx = resolveProfileContext(params.req, params.res, params.ctx);
if (!profileCtx) return;
try {
await params.run(profileCtx);
} catch (err) {
handleTabsRouteError(params.ctx, params.res, err, { mapTabError: params.mapTabError });
}
}
function resolveTabReachabilityTimeoutMs(ctx, profileCtx) {
if (!getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) return DEFAULT_TAB_REACHABILITY_TIMEOUT_MS;
return clampPositiveTimerTimeoutMs(ctx.state().resolved.actionTimeoutMs) ?? DEFAULT_TAB_REACHABILITY_TIMEOUT_MS;
}
async function checkTabReachability(ctx, profileCtx, signal) {
const timeoutMs = resolveTabReachabilityTimeoutMs(ctx, profileCtx);
return signal ? await profileCtx.isReachable(timeoutMs, { signal }) : await profileCtx.isReachable(timeoutMs);
}
async function ensureBrowserRunning(ctx, profileCtx, res, signal) {
if (!await checkTabReachability(ctx, profileCtx, signal)) {
jsonError(res, new BrowserProfileUnavailableError("browser not running").status, "browser not running");
return false;
}
return true;
}
async function redactBlockedTabUrls(params) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(params.ssrfPolicy);
if (!ssrfPolicyOpts.ssrfPolicy) return params.tabs;
const redactedTabs = [];
for (const tab of params.tabs) try {
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts
});
redactedTabs.push(tab);
} catch {
redactedTabs.push({
...tab,
url: ""
});
}
return redactedTabs;
}
function resolveIndexedTab(tabs, index) {
return typeof index === "number" ? tabs[index] : tabs.at(0);
}
function parseRequiredTargetId(res, rawTargetId) {
const targetId = toStringOrEmpty(rawTargetId);
if (!targetId) {
jsonError(res, 400, "targetId is required");
return null;
}
return targetId;
}
function readOptionalTabLabel(body) {
return toStringOrEmpty(body?.label) || void 0;
}
function readTabIndex(res, body, opts) {
const record = body && typeof body === "object" ? body : {};
if (!Object.hasOwn(record, "index")) {
if (opts?.required) {
jsonError(res, 400, "index is required");
return null;
}
return;
}
if (record.index == null) {
jsonError(res, 400, "index must be a non-negative integer");
return null;
}
try {
return readRouteNonNegativeInteger(record.index, "index", { invalidMessage: "index must be a non-negative integer" });
} catch (error) {
jsonError(res, 400, error instanceof Error ? error.message : String(error));
return null;
}
}
async function runTabTargetMutation(params) {
await withTabsProfileRoute({
req: params.req,
res: params.res,
ctx: params.ctx,
mapTabError: true,
run: async (profileCtx) => {
if (!await ensureBrowserRunning(params.ctx, profileCtx, params.res, params.req.signal)) return;
await params.mutate(profileCtx, params.targetId);
params.res.json({ ok: true });
}
});
}
/** Register tab listing and mutation endpoints on the browser control server. */
function registerBrowserTabRoutes(app, ctx) {
app.get("/tabs", asyncBrowserRoute(async (req, res) => {
await withTabsProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
if (!await checkTabReachability(ctx, profileCtx, req.signal)) return res.json({
running: false,
tabs: []
});
const tabs = await redactBlockedTabUrls({
tabs: await profileCtx.listTabs(),
ssrfPolicy: ctx.state().resolved.ssrfPolicy
});
res.json({
running: true,
tabs
});
}
});
}));
app.post("/tabs/open", asyncBrowserRoute(async (req, res) => {
const url = toStringOrEmpty(req.body?.url);
const label = readOptionalTabLabel(req.body);
if (!url) return jsonError(res, 400, "url is required");
await withTabsProfileRoute({
req,
res,
ctx,
mapTabError: true,
run: async (profileCtx) => {
await assertBrowserNavigationAllowed({
url,
...browserNavigationPolicyForProfile(ctx, profileCtx)
});
await profileCtx.ensureBrowserAvailable();
const tab = await profileCtx.openTab(url, { label });
res.json(tab);
}
});
}));
app.post("/tabs/focus", asyncBrowserRoute(async (req, res) => {
const targetId = parseRequiredTargetId(res, req.body?.targetId);
if (!targetId) return;
await runTabTargetMutation({
req,
res,
ctx,
targetId,
mutate: async (profileCtx, id) => {
const tabs = await profileCtx.listTabs();
const resolved = resolveTargetIdFromTabs(id, tabs);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") throw new BrowserTargetAmbiguousError();
throw new BrowserTabNotFoundError({ input: id });
}
const tab = tabs.find((currentTab) => currentTab.targetId === resolved.targetId);
if (!tab) throw new BrowserTabNotFoundError({ input: id });
const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx);
if (ssrfPolicyOpts.ssrfPolicy) await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts
});
await profileCtx.focusTab(resolved.targetId);
}
});
}));
app.delete("/tabs/:targetId", asyncBrowserRoute(async (req, res) => {
const targetId = parseRequiredTargetId(res, req.params.targetId);
if (!targetId) return;
await runTabTargetMutation({
req,
res,
ctx,
targetId,
mutate: async (profileCtx, id) => {
await profileCtx.closeTab(id);
}
});
}));
app.post("/tabs/action", asyncBrowserRoute(async (req, res) => {
const action = toStringOrEmpty(req.body?.action);
await withTabsProfileRoute({
req,
res,
ctx,
mapTabError: true,
run: async (profileCtx) => {
if (action === "list") {
if (!await checkTabReachability(ctx, profileCtx, req.signal)) return res.json({
ok: true,
tabs: []
});
const tabs = await redactBlockedTabUrls({
tabs: await profileCtx.listTabs(),
ssrfPolicy: ctx.state().resolved.ssrfPolicy
});
return res.json({
ok: true,
tabs
});
}
if (action === "new") {
await profileCtx.ensureBrowserAvailable();
const tab = await profileCtx.openTab("about:blank", { label: readOptionalTabLabel(req.body) });
return res.json({
ok: true,
tab
});
}
if (action === "label") {
if (!await ensureBrowserRunning(ctx, profileCtx, res, req.signal)) return;
const targetId = parseRequiredTargetId(res, req.body?.targetId);
if (!targetId) return;
const label = readOptionalTabLabel(req.body);
if (!label) return jsonError(res, 400, "label is required");
const tab = await profileCtx.labelTab(targetId, label);
return res.json({
ok: true,
tab
});
}
if (action === "close") {
if (!await ensureBrowserRunning(ctx, profileCtx, res, req.signal)) return;
const index = readTabIndex(res, req.body);
if (index === null) return;
const target = resolveIndexedTab(await profileCtx.listTabs(), index);
if (!target) throw new BrowserTabNotFoundError();
await profileCtx.closeTab(target.targetId);
return res.json({
ok: true,
targetId: target.targetId
});
}
if (action === "select") {
const index = readTabIndex(res, req.body, { required: true });
if (index === null || index === void 0) return;
if (!await ensureBrowserRunning(ctx, profileCtx, res, req.signal)) return;
const target = (await profileCtx.listTabs())[index];
if (!target) throw new BrowserTabNotFoundError();
const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx);
if (ssrfPolicyOpts.ssrfPolicy) await assertBrowserNavigationResultAllowed({
url: target.url,
...ssrfPolicyOpts
});
await profileCtx.focusTab(target.targetId);
return res.json({
ok: true,
targetId: target.targetId
});
}
return jsonError(res, 400, "unknown tab action");
}
});
}));
}
//#endregion
//#region extensions/browser/src/browser/routes/index.ts
/** Register every browser control route group. */
function registerBrowserRoutes(app, ctx) {
registerBrowserBasicRoutes(app, ctx);
registerBrowserTabRoutes(app, ctx);
registerBrowserPermissionRoutes(app, ctx);
registerBrowserAgentRoutes(app, ctx);
}
//#endregion
export { normalizeBrowserScreenshot as n, registerBrowserRoutes as t };