openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
2,638 lines • 105 kB
JavaScript
import { c as normalizeOptionalLowercaseString, g as readStringValue, l as normalizeOptionalString } from "./string-coerce-6kR-86XC.mjs";
import { m as clampTimerTimeoutMs } from "./number-coercion-Dq1l5muL.mjs";
import { r as asNullableRecord } from "./record-coerce-TIsquoVR.mjs";
import { d as normalizeStringEntries } from "./string-normalization-YMwDjufr.mjs";
import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.mjs";
import { n as isTruthyEnvValue } from "./env-Bw1K_ti_.mjs";
import { t as formatErrorMessage } from "./errors-BSdPMFao.mjs";
import { t as createSubsystemLogger } from "./subsystem-BlrgtxyM.mjs";
import { Et as _enum, Fn as object, Jn as string, Lt as boolean, Nt as array, qn as strictObject } from "./schemas-BOYIvvln.mjs";
import { r as getRuntimeConfig } from "./io.runtime-FunTy0y1.mjs";
import { t as ErrorCodes } from "./gateway-error-details-DIefe7Zy.mjs";
import { f as errorShape } from "./error-codes-DI20vDEB.mjs";
import { n as detectMime } from "./mime-CGDYrRzL.mjs";
import { t as DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS } from "./tool-result-limits-B-fhY8wF.mjs";
import { a as wrapExternalContent, i as truncateSanitizedExternalContent } from "./external-content-CmQTCbqd.mjs";
import "./string-coerce-runtime-B1b8UNzb.mjs";
import "./text-utility-runtime-UZWYbiiX.mjs";
import { f as saveMediaBuffer } from "./store-B9-NomZN.mjs";
import { t as jsonResult } from "./tool-results-BCM3fdVS.mjs";
import { d as readPositiveIntegerParam, h as readToolStringParam, i as imageResultFromFile, l as readNonNegativeIntegerParam } from "./common-CwfR3iO6.mjs";
import { r as readGatewayToolOperatorScopes, t as callGatewayTool } from "./gateway-b_lf--A8.mjs";
import "./runtime-env-DMdB1vhM.mjs";
import "./number-runtime-DyNDaYIV.mjs";
import { o as hasGatewayToolRoutingContext } from "./in-process-gateway-6fGE5b69.mjs";
import { l as resolveNodeCommandAllowlist, o as isNodeCommandAllowed } from "./node-command-policy-B8V85z2C.mjs";
import { a as resolveNodeIdFromList, n as listNodes } from "./nodes-utils-DEiJdJlh.mjs";
import "./runtime-config-snapshot-DPEZoubb.mjs";
import "./media-mime-DLFR6fqC.mjs";
import { t as startLazyPluginServiceModule } from "./plugin-runtime-Dcm9HLSL.mjs";
import "./security-runtime-9ynw5sNI.mjs";
import { r as parseGatewayPayload, t as respondUnavailableOnNodeInvokeError } from "./nodes.helpers-D-xrp4Rq.mjs";
import "./gateway-runtime-C4L8u4H3.mjs";
import "./agent-harness-runtime-ADe6s8a3.mjs";
import { n as redactCdpUrl } from "./browser-cdp-DnffQlpt.mjs";
import { a as createDeferred } from "./extension-shared-DrsO57mB.mjs";
import { t as describeImageFile } from "./runtime-CKW0oAyi.mjs";
import "./param-readers-PfUsCMZz.mjs";
import { $ as getOptionalBrowserStateRuntime } from "./session-tab-store-CkLpaG2I.mjs";
import { n as BROWSER_PROXY_UPLOAD_COMMAND, r as browserProxyUploadUnavailableMessage, t as BROWSER_PROXY_COMMAND } from "./browser-node-commands-CIbUPKdY.mjs";
import { n as applyBrowserTabToolBinding, t as createBrowserToolDefinition } from "./browser-tool-description-CkCLR4cg.mjs";
import { i as DEFAULT_BROWSER_ACTION_TIMEOUT_MS, r as DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants-C-i9f6FL.mjs";
import { o as EXISTING_SESSION_TIMEOUT_OVERRIDE_KINDS } from "./act-policy-tIEm1pjf.mjs";
import { i as resolveBrowserConfig, s as resolveProfile } from "./config-HztiUSlV.mjs";
import { t as getBrowserProfileCapabilities } from "./profile-capabilities-B5_4l3yO.mjs";
import { n as resolveCdpControlPolicy } from "./cdp-reachability-policy-TqFnMsZb.mjs";
import { r as closeTrackedCdpTarget } from "./cdp.helpers-B9Hc2ly9.mjs";
import { I as parseBrowserNavigationUrl, T as findRoleSnapshotLineRef, w as finalizeRoleSnapshot } from "./chrome-DbIHmS_R.mjs";
import { i as resolveExistingUploadPaths } from "./paths-DlZwlk-a.mjs";
import { a as loadBrowserConfigForRuntimeRefresh } from "./server-context-DsDmQZY8.mjs";
import { a as normalizeBrowserRequestPath, i as isPersistentBrowserProfileMutation, o as resolveRequestedBrowserProfile, r as isBrowserHostLocalRoute } from "./routes-CgYEBrMq.mjs";
import { r as normalizeBrowserScreenshot } from "./screenshot-CW7JCtd6.mjs";
import { t as parseSystemProfileDomains } from "./system-profile-domains-BoDfTlq2.mjs";
import { t as createBrowserRouteDispatcher } from "./dispatcher-CSQP5jpe.mjs";
import { a as touchSessionBrowserTab, o as trackSessionBrowserTab, s as untrackSessionBrowserTab } from "./session-tab-registry-CcyLHMRN.mjs";
import { a as stopBrowserDashboard, i as requestBrowserDashboard, n as inspectBrowserDashboard, t as assertBrowserDashboardTargetCurrent } from "./browser-dashboard-D8RXhvvz.mjs";
import { C as withBrowserRequestScope, _ as browserTabs, a as browserDoctor, c as browserOpenTab, d as browserSnapshot, f as browserStart, h as browserSystemProfiles, l as browserProfiles, m as browserStop, o as browserFocusTab, p as browserStatus, s as browserImportProfile, t as browserCloseTab } from "./client-DFx0FGiJ.mjs";
import { _ as browserScreenshotAction, a as persistBrowserProxyResultFiles, c as browserArmFileChooser, d as browserEmulateSetting, f as browserErrors, g as browserRequests, h as browserPdfSave, i as stageBrowserScreenshotForSharing, l as browserConsoleMessages, m as browserPageText, n as createBrowserNodeSessionTabRoute, o as browserAct, p as browserNavigate, r as resolveRuntimeImageSanitization, s as browserArmDialog, t as createBrowserNodeProxyRequest, u as browserDownload, v as browserWaitForDownload, y as isBrowserControlHostUnavailableError } from "./browser-node-proxy-BUV9XL1t.mjs";
import { a as prepareBrowserProxyUploadRequest, d as assertBrowserProxyFileCountWithinLimit, f as createBrowserProxyFailure, h as visitBrowserProxyFilePaths, i as isBrowserProxyUploadRequest, m as parseBrowserProxyRoute, n as ensureBrowserProxyUploadCleanup, o as stageBrowserProxyUploadRequest, p as parseBrowserProxyFailure, r as hasBrowserProxyUploadWork, s as BROWSER_PROXY_ERROR_ENVELOPE, t as discardStagedBrowserProxyUpload, u as assertBrowserProxyFileBytesWithinLimits } from "./browser-proxy-upload-BUN5FWhk.mjs";
import { n as resolveBrowserProxyTimeouts, t as resolveBrowserProxyTimeoutMs } from "./browser-proxy-timeouts-BkSGd7p-.mjs";
import { i as hasBrowserControlWork, r as getBrowserControlState, t as createBrowserControlContext } from "./browser-control-state-B-F0BJmc.mjs";
import { t as describeBrowserControlUnavailable } from "./plugin-enabled-211d5JAm.mjs";
import { t as startBrowserControlServiceFromConfig } from "./control-service-B_pDtqPN.mjs";
import fs, { readFile } from "node:fs/promises";
import { toUSVString } from "node:util";
import crypto from "node:crypto";
//#region extensions/browser/src/browser-tool-session-tabs.ts
function readOpenedTab(result) {
const opened = asNullableRecord(result);
if (!opened) return { aliases: [] };
const targetId = normalizeOptionalString(opened.targetId);
const aliases = [
targetId,
normalizeOptionalString(opened.tabId),
normalizeOptionalString(opened.label),
normalizeOptionalString(opened.suggestedTargetId)
].filter((alias) => Boolean(alias));
const profile = normalizeOptionalString(opened.resolvedProfile);
const rawOwnership = opened.ownership && typeof opened.ownership === "object" ? opened.ownership : void 0;
const ownership = rawOwnership?.status === "durable" && !profile ? void 0 : rawOwnership;
return {
targetId,
aliases: [...new Set(aliases)],
profile,
ownership
};
}
function stripBrowserOpenInternalMetadata(value) {
const record = asNullableRecord(value);
if (!record) return value;
const { ownership: _ownership, resolvedProfile: _resolvedProfile, ...agentVisible } = record;
return agentVisible;
}
async function trackOpenedBrowserTab(params) {
const opened = readOpenedTab(params.result);
const profile = opened.profile ?? params.fallbackProfile;
try {
await params.track({
sessionKey: params.sessionKey,
targetId: opened.targetId,
route: params.route,
profile,
...params.fallbackProfile && opened.profile && opened.profile !== params.fallbackProfile ? { profileAliases: [params.fallbackProfile] } : {},
ownership: params.route.kind === "browser-control" && params.route.baseUrl ? void 0 : opened.ownership,
aliases: opened.aliases,
...params.authority ? { authority: params.authority } : {}
});
} catch (trackingError) {
if (!opened.targetId) throw trackingError;
try {
await params.closeTab(opened.targetId, profile);
} catch (closeError) {
throw Object.assign(new Error("Failed to register browser tab cleanup and close the newly opened tab", { cause: closeError }), {
name: "BrowserTabTrackingCompensationError",
errors: [trackingError, closeError]
});
}
throw trackingError;
}
}
function createBrowserToolSessionTabs(params) {
const trackedRoute = () => params.nodeRoute && !params.isHostFallbackActive?.() ? params.nodeRoute : {
kind: "browser-control",
...params.baseUrl ? { baseUrl: params.baseUrl } : {}
};
const trackedProfile = (route) => route.kind === "node-proxy" ? params.routeProfile?.() ?? params.requestedProfile : route.baseUrl && !params.requestedProfile ? void 0 : params.requestedProfile ?? params.defaultProfile;
const identity = (targetId) => {
const route = trackedRoute();
return {
sessionKey: params.sessionKey,
targetId,
route,
profile: trackedProfile(route),
...params.authority ? { authority: params.authority } : {}
};
};
return {
touch: async (targetId) => {
if (targetId) await params.registry.touchSessionBrowserTab(identity(targetId));
},
untrack: async (targetId) => {
if (targetId) await params.registry.untrackSessionBrowserTab(identity(targetId));
},
trackOpened: async (result, closeTab) => {
const route = trackedRoute();
const profile = trackedProfile(route);
await trackOpenedBrowserTab({
result,
sessionKey: params.sessionKey,
fallbackProfile: profile,
route,
track: params.registry.trackSessionBrowserTab,
authority: params.authority,
closeTab
});
}
};
}
//#endregion
//#region extensions/browser/src/browser/vision.ts
/**
* Browser screenshot description helpers built on the shared media image
* understanding contract. No browser-specific model registry lives here.
*/
/** Default prompt for turning browser screenshots into text-only page context. */
const DEFAULT_BROWSER_SCREENSHOT_DESCRIPTION_PROMPT = "Describe what is visible in this browser screenshot. Capture page layout, headings, primary content blocks, visible text, and notable interactive elements so a text-only assistant can reason about the page.";
function normalizeActiveModel(activeModel) {
const provider = activeModel?.provider?.trim();
if (!provider) return;
const model = activeModel?.model?.trim();
return model ? {
provider,
model
} : { provider };
}
async function resolveImageUnderstandingFilePath(ctx, deps) {
const maxDimensionPx = ctx.imageSanitization?.maxDimensionPx;
if (typeof maxDimensionPx !== "number" || !Number.isFinite(maxDimensionPx)) return ctx.filePath;
const source = await readFile(ctx.filePath);
const normalized = await deps.normalizeBrowserScreenshot(source, { maxSide: Math.max(1, Math.floor(maxDimensionPx)) });
if (normalized.buffer === source) return ctx.filePath;
return (await deps.saveMediaBuffer(normalized.buffer, normalized.contentType ?? "image/jpeg", "browser")).path;
}
/** Produces a text description for a browser screenshot, or null when no text was produced. */
async function describeBrowserScreenshot(ctx, deps) {
const filePath = await resolveImageUnderstandingFilePath(ctx, deps);
const agentId = ctx.agentDir ? void 0 : (await import("./plugin-sdk/agent-scope-runtime.js")).resolveSessionAgentIdStrict({
agentId: ctx.agentId,
sessionKey: ctx.mediaScope?.sessionKey,
config: ctx.cfg
});
const described = await deps.describeImageFile({
filePath,
cfg: ctx.cfg,
prompt: DEFAULT_BROWSER_SCREENSHOT_DESCRIPTION_PROMPT,
...agentId ? { agentId } : {},
agentDir: ctx.agentDir,
workspaceDir: ctx.workspaceDir,
activeModel: normalizeActiveModel(ctx.activeModel),
scopeContext: ctx.mediaScope
});
const text = described.text?.trim();
if (!text) return null;
return {
text,
provider: described.provider,
model: described.model,
decision: described.decision
};
}
/** Neutralizes model-generated MEDIA directives before feeding text back to tools. */
function neutralizeMediaDirectives(text) {
if (!text || !/media:/i.test(text)) return text;
return text.replace(/(^|\n)([^\S\n]*)(MEDIA:)/gi, "$1$2[neutralized] $3");
}
//#endregion
//#region extensions/browser/src/browser-tool.snapshot.ts
const BROWSER_EXTERNAL_JSON_TRUNCATION_MARKERS = {
snapshot: "\n[truncated — retry with a smaller maxChars or limit]",
console: "\n[truncated — retry with a stricter level or targetId]",
requests: "\n[truncated — retry with a narrower filter or smaller limit]",
errors: "\n[truncated — retry with a smaller limit]",
tabs: "\n[truncated — retry with action=snapshot and a specific targetId]",
act: "\n[truncated — inspect the affected targetId with action=snapshot]",
download: "\n[truncated — retry with a specific targetId and download ref]"
};
function truncateBrowserToolText(value, marker, maxChars) {
const bounded = truncateSanitizedExternalContent(value, maxChars);
if (!bounded.truncated) return bounded;
if (marker.length > maxChars) return bounded;
return {
text: `${truncateSanitizedExternalContent(value, Math.max(0, maxChars - marker.length)).text}${marker}`,
truncated: true
};
}
/** Bound and wrap browser-originated text before it reaches the model. */
function wrapBrowserExternalText(params) {
const wrap = (value) => wrapExternalContent(value, {
source: "browser",
includeWarning: params.includeWarning
});
const prefix = params.prefix ? `${params.prefix}\n` : "";
const wrapperOverhead = prefix.length + wrap("").length;
let maxInnerChars = Math.max(0, Math.min(params.maxChars ?? Infinity, DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS - wrapperOverhead));
const value = neutralizeMediaDirectives(params.value);
let bounded = truncateBrowserToolText(value, params.marker, maxInnerChars);
let wrappedText = prefix + wrap(bounded.text);
if (wrappedText.length > 16e3) {
maxInnerChars = Math.max(0, maxInnerChars - (wrappedText.length - DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS));
bounded = truncateBrowserToolText(value, params.marker, maxInnerChars);
wrappedText = prefix + wrap(bounded.text);
}
return {
text: wrappedText,
boundedText: bounded.text,
truncated: bounded.truncated
};
}
/** Wrap page-controlled JSON payloads as untrusted browser content. */
function wrapBrowserExternalJson(params) {
const wrapped = wrapBrowserExternalText({
value: JSON.stringify(params.payload, (_key, value) => typeof value === "string" ? neutralizeMediaDirectives(value) : value, 2) ?? "null",
marker: BROWSER_EXTERNAL_JSON_TRUNCATION_MARKERS[params.kind],
includeWarning: params.includeWarning ?? true
});
return {
wrappedText: wrapped.text,
truncated: wrapped.truncated,
safeDetails: {
ok: true,
externalContent: {
untrusted: true,
source: "browser",
kind: params.kind,
wrapped: true
}
}
};
}
/** Keep debug log counts aligned with complete records inside the output budget. */
function formatBrowserDebugLogResult(kind, result, entries, limit) {
const total = entries.length;
let records = entries.slice(-limit);
const details = () => ({
ok: result.ok,
targetId: result.targetId,
url: result.url,
total,
returned: records.length,
truncated: records.length < total
});
const wrap = () => wrapBrowserExternalJson({
kind,
payload: {
...details(),
[kind]: records
},
includeWarning: false
});
let wrapped = wrap();
if (wrapped.truncated && records.length > 0) {
const initialRecords = records;
let lower = 1;
let upper = initialRecords.length;
while (lower < upper) {
const middle = Math.floor((lower + upper) / 2);
records = initialRecords.slice(middle);
wrapped = wrap();
if (wrapped.truncated) lower = middle + 1;
else upper = middle;
}
if (records.length !== initialRecords.length - lower) {
records = initialRecords.slice(lower);
wrapped = wrap();
}
}
return {
content: [{
type: "text",
text: wrapped.wrappedText
}],
details: {
...wrapped.safeDetails,
...details(),
truncated: records.length < total || wrapped.truncated
}
};
}
function isAriaRefsUnsupportedError(err) {
const msg = String(err).toLowerCase();
return msg.includes("refs=aria") && msg.includes("not support");
}
/** Execute and format browser snapshots for agent consumption. */
async function executeSnapshotAction(params) {
const { input, baseUrl, profile, proxyRequest } = params;
const snapshotDefaults = getRuntimeConfig().browser?.snapshotDefaults;
const format = input.snapshotFormat === "ai" ? "ai" : input.snapshotFormat === "aria" ? "aria" : void 0;
const formatExplicit = format !== void 0;
const mode = input.mode === "efficient" || !formatExplicit && snapshotDefaults?.mode === "efficient" ? "efficient" : void 0;
const labels = typeof input.labels === "boolean" ? input.labels : void 0;
const urls = typeof input.urls === "boolean" ? input.urls : void 0;
const refs = input.refs === "aria" || input.refs === "role" ? input.refs : void 0;
const hasMaxChars = Object.hasOwn(input, "maxChars");
const targetId = normalizeOptionalString(input.targetId);
const limit = readPositiveIntegerParam(input, "limit", { message: "limit must be a positive integer." });
const maxCharsRaw = readNonNegativeIntegerParam(input, "maxChars", { message: "maxChars must be a non-negative integer." });
const maxChars = maxCharsRaw !== void 0 && maxCharsRaw > 0 ? maxCharsRaw : void 0;
const interactive = typeof input.interactive === "boolean" ? input.interactive : void 0;
const compact = typeof input.compact === "boolean" ? input.compact : void 0;
const depth = readNonNegativeIntegerParam(input, "depth", { message: "depth must be a non-negative integer." });
const selector = normalizeOptionalString(input.selector);
const frame = normalizeOptionalString(input.frame);
const resolvedMaxChars = hasMaxChars ? maxChars : format === "ai" && mode !== "efficient" ? DEFAULT_AI_SNAPSHOT_MAX_CHARS : void 0;
const snapshotTimeoutMs = readPositiveIntegerParam(input, "timeoutMs", { message: "timeoutMs must be a positive integer." }) ?? 2e4;
const snapshotQuery = {
...format ? { format } : {},
targetId,
limit,
...typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {},
refs,
interactive,
compact,
depth,
selector,
frame,
labels,
urls,
mode,
timeoutMs: snapshotTimeoutMs
};
let refsFallback;
const readSnapshot = async (query) => await browserSnapshot(proxyRequest ?? baseUrl, {
...query,
profile,
signal: params.signal
});
let snapshot;
try {
snapshot = await readSnapshot(snapshotQuery);
} catch (err) {
if (refs !== "aria" || !isAriaRefsUnsupportedError(err)) throw err;
refsFallback = "role";
snapshot = await readSnapshot({
...snapshotQuery,
refs: "role"
});
}
await params.onTabActivity?.(readStringValue(snapshot.targetId) ?? targetId);
const identity = {
format: snapshot.format,
targetId: snapshot.targetId,
url: snapshot.url
};
const dialogState = {
...snapshot.blockedByDialog ? { blockedByDialog: true } : {},
...snapshot.browserState !== void 0 ? { browserState: snapshot.browserState } : {}
};
const aiMetadata = snapshot.format === "ai" ? {
labels: snapshot.labels,
labelsCount: snapshot.labelsCount,
labelsSkipped: snapshot.labelsSkipped,
annotations: snapshot.annotations,
imagePath: snapshot.imagePath,
imageType: snapshot.imageType,
refsFallback
} : {};
const externalContent = {
untrusted: true,
source: "browser",
kind: "snapshot",
format: snapshot.format,
wrapped: true
};
const finishSnapshot = async (text, details) => {
if (labels && snapshot.format === "ai" && snapshot.imagePath) return await imageResultFromFile({
label: "browser:snapshot",
path: snapshot.imagePath,
extraText: text,
details: {
...details,
media: { outbound: false }
},
imageSanitization: resolveRuntimeImageSanitization()
});
return {
content: [{
type: "text",
text
}],
details
};
};
const query = normalizeOptionalString(input.query);
if (query && !snapshot.blockedByDialog) {
const tokens = query.toLowerCase().split(/\s+/);
const matches = (snapshot.format === "ai" ? snapshot.snapshot : snapshot.nodes.map((node) => `- ${node.role} ${JSON.stringify(node.name)} [ref=${node.ref}]${node.value ? ` value=${JSON.stringify(node.value)}` : ""}${node.description ? ` description=${JSON.stringify(node.description)}` : ""}`).join("\n")).split("\n").filter((line) => {
const lower = line.toLowerCase();
return tokens.every((token) => lower.includes(token));
});
const matchedText = matches.join("\n");
const matchCount = matches.length;
const summary = matchCount ? `${matchCount} matching line(s) in the returned snapshot${snapshot.truncated ? " (source truncated)" : ""}.` : "No matching lines in the returned snapshot. Refine the query or take a broader snapshot.";
const wrapped = wrapBrowserExternalText({
value: matchedText,
marker: BROWSER_EXTERNAL_JSON_TRUNCATION_MARKERS.snapshot,
maxChars: maxChars ?? 4e4,
includeWarning: true,
prefix: summary
});
const snapshotRefs = snapshot.format === "ai" ? snapshot.refs ?? {} : Object.fromEntries(snapshot.nodes.map((node) => [node.ref, {
role: node.role,
name: node.name
}]));
const filtered = finalizeRoleSnapshot({
snapshot: wrapped.boundedText,
refs: snapshotRefs
});
const newElements = filtered.snapshot.split("\n").filter((line) => line.endsWith(" [new]") && findRoleSnapshotLineRef(line)).length;
return await finishSnapshot(wrapped.text, {
ok: snapshot.ok,
...identity,
matchCount,
stats: filtered.stats,
refs: filtered.stats.refs,
...snapshot.format === "ai" && snapshot.newElements !== void 0 ? { newElements } : {},
truncated: snapshot.truncated || wrapped.truncated || void 0,
...dialogState,
...aiMetadata,
externalContent
});
}
if (snapshot.format === "ai") {
if (snapshot.blockedByDialog) {
const wrapped = wrapBrowserExternalJson({
kind: "snapshot",
payload: {
...identity,
...dialogState
}
});
return {
content: [{
type: "text",
text: wrapped.wrappedText
}],
details: {
...wrapped.safeDetails,
...identity,
...dialogState
}
};
}
const boundedSnapshot = wrapBrowserExternalText({
value: snapshot.snapshot ?? "",
marker: BROWSER_EXTERNAL_JSON_TRUNCATION_MARKERS.snapshot,
includeWarning: true
});
return await finishSnapshot(boundedSnapshot.text, {
ok: true,
...identity,
truncated: snapshot.truncated || boundedSnapshot.truncated ? true : void 0,
newElements: snapshot.newElements,
stats: snapshot.stats,
refs: snapshot.refs ? Object.keys(snapshot.refs).length : void 0,
...aiMetadata,
...dialogState,
externalContent
});
}
{
const wrapped = wrapBrowserExternalJson({
kind: "snapshot",
payload: snapshot
});
return {
content: [{
type: "text",
text: wrapped.wrappedText
}],
details: {
...wrapped.safeDetails,
...identity,
nodeCount: snapshot.nodes.length,
...dialogState,
externalContent
}
};
}
}
function withPageStateUnavailableHint(result, reason) {
return {
...result,
content: [...result.content, {
type: "text",
text: `[page snapshot unavailable: ${reason}. Use action=snapshot to read the page.]`
}]
};
}
/**
* Attach fresh page state to the result of an action that changed the page
* document (navigate, act that navigated). The model can act on the new page
* without a follow-up snapshot call. The inline state uses the efficient
* interactive tier so the unsolicited payload stays bounded on every profile
* (mode=efficient forces the capped ai format even where the profile default
* would be an uncapped aria tree); a full snapshot stays one explicit call away.
*/
async function appendNavigatedPageState(params) {
let snapshot;
try {
snapshot = await executeSnapshotAction({
input: {
targetId: params.targetId,
mode: "efficient"
},
baseUrl: params.baseUrl,
profile: params.profile,
proxyRequest: params.proxyRequest,
signal: params.signal
});
} catch (err) {
params.signal?.throwIfAborted();
if (err instanceof Error && err.name === "AbortError") throw err;
return withPageStateUnavailableHint(params.result, wrapExternalContent(neutralizeMediaDirectives(formatErrorMessage(err)), {
source: "browser",
includeWarning: false
}));
}
const baseDetails = params.result.details && typeof params.result.details === "object" ? params.result.details : {};
return {
content: [...params.result.content, ...snapshot.content],
details: {
...baseDetails,
pageState: snapshot.details
}
};
}
//#endregion
//#region extensions/browser/src/browser-tool.actions.ts
function normalizePositiveTimeoutMs(value) {
return readPositiveIntegerParam({ value }, "value", { message: "timeoutMs must be a positive integer." });
}
function normalizeNonNegativeDurationMs(value) {
return readNonNegativeIntegerParam({ value }, "value", { message: "timeMs must be a non-negative integer." });
}
function withLocalActTimeout(request, usesChromeMcp) {
if (normalizePositiveTimeoutMs("timeoutMs" in request ? request.timeoutMs : void 0) !== void 0 || usesChromeMcp && !EXISTING_SESSION_TIMEOUT_OVERRIDE_KINDS.has(request.kind)) return request;
switch (request.kind) {
case "click":
case "type":
case "hover":
case "scrollIntoView":
case "drag":
case "select":
case "fill":
case "evaluate":
case "wait": return {
...request,
timeoutMs: DEFAULT_BROWSER_ACTION_TIMEOUT_MS
};
default: return request;
}
}
function formatAgentTab(tab) {
if (!tab || typeof tab !== "object") return { value: tab };
const source = tab;
const targetId = readStringValue(source.targetId);
const tabId = readStringValue(source.tabId);
const webExtensionTabId = typeof source.webExtensionTabId === "number" && Number.isSafeInteger(source.webExtensionTabId) && source.webExtensionTabId >= 0 ? source.webExtensionTabId : void 0;
const label = readStringValue(source.label);
const suggestedTargetId = readStringValue(source.suggestedTargetId) ?? label ?? tabId ?? targetId;
return {
...suggestedTargetId ? { suggestedTargetId } : {},
...tabId ? { tabId } : {},
...webExtensionTabId !== void 0 ? { webExtensionTabId } : {},
...label ? { label } : {},
title: source.title,
url: source.url,
...source.urlUnavailableReason === "navigation_blocked" || source.urlUnavailableReason === "navigation_check_failed" ? { urlUnavailableReason: source.urlUnavailableReason } : {},
type: source.type,
...targetId ? { targetId } : {},
...source.wsUrl ? { wsUrl: source.wsUrl } : {}
};
}
function formatTabsToolResult(result) {
const formattedTabs = result.tabs.map(formatAgentTab);
const wrapped = wrapBrowserExternalJson({
kind: "tabs",
payload: {
running: result.running,
tabs: formattedTabs
},
includeWarning: false
});
return {
content: [{
type: "text",
text: wrapped.wrappedText
}],
details: {
...wrapped.safeDetails,
running: result.running,
tabCount: formattedTabs.length,
tabs: formattedTabs
}
};
}
/** Protect page-controlled model text while preserving the shipped structured result contract. */
function formatBrowserExternalToolResult(params) {
return {
content: [{
type: "text",
text: wrapBrowserExternalJson({
kind: params.kind,
payload: params.payload,
includeWarning: false
}).wrappedText
}],
details: params.payload
};
}
function isChromeStaleTargetError(usesChromeMcp, err) {
const status = err && typeof err === "object" && "status" in err ? err.status : null;
const msg = String(err);
const isTabNotFound = (status === 404 || msg.includes("404:")) && msg.includes("tab not found");
return usesChromeMcp && isTabNotFound;
}
function canRetryChromeActAfterSoleTargetRefresh(request) {
if (request.kind !== "wait" || normalizeNonNegativeDurationMs(request.timeMs) === void 0) return false;
return [
request.fn,
request.text,
request.textGone,
request.selector,
request.url,
request.loadState
].every((value) => !normalizeOptionalString(value));
}
async function executeTabsAction(params) {
const { baseUrl, profile, timeoutMs, proxyRequest } = params;
const result = await browserTabs(proxyRequest ?? baseUrl, {
profile,
timeoutMs,
signal: params.signal
});
const tabs = result.running ? result.tabs.filter((tab) => !params.targetId || readStringValue(tab.targetId) === params.targetId) : [];
return formatTabsToolResult({
running: result.running,
tabs
});
}
/** Validate the /act wire payload's abort summary once for note and page-state decisions. */
function readBrowserBatchAbort(result) {
if (!result || typeof result !== "object") return null;
const aborted = result.aborted;
if (!aborted || typeof aborted !== "object") return null;
const { reason, afterAction, url, skipped } = aborted;
if (reason !== "navigation" && reason !== "closed" || typeof afterAction !== "number" || typeof url !== "string" || typeof skipped !== "number") return null;
return {
reason,
afterAction,
url,
skipped
};
}
function actObservedNavigation(result, aborted) {
if (aborted?.reason === "navigation") return true;
const results = result?.results;
return Array.isArray(results) && results.some((entry) => entry?.navigated === true);
}
async function executeConsoleAction(params) {
const { input, baseUrl, profile, proxyRequest } = params;
const result = await browserConsoleMessages(proxyRequest ?? baseUrl, {
level: normalizeOptionalString(input.level),
targetId: normalizeOptionalString(input.targetId),
profile,
signal: params.signal
});
const wrapped = wrapBrowserExternalJson({
kind: "console",
payload: result,
includeWarning: false
});
return {
content: [{
type: "text",
text: wrapped.wrappedText
}],
details: {
...wrapped.safeDetails,
targetId: readStringValue(result.targetId),
url: readStringValue(result.url),
messageCount: Array.isArray(result.messages) ? result.messages.length : void 0
}
};
}
/** Read browser debug logs, keeping counts aligned with the bounded payload. */
async function executeDebugLogAction(kind, params) {
const { input, baseUrl, profile, proxyRequest, signal } = params;
const limit = readPositiveIntegerParam(input, "limit", { message: "limit must be a positive integer." }) ?? 50;
const options = {
targetId: normalizeOptionalString(input.targetId),
clear: typeof input.clear === "boolean" ? input.clear : void 0,
profile,
signal
};
if (kind === "requests") {
const result = await browserRequests(proxyRequest ?? baseUrl, {
...options,
filter: normalizeOptionalString(input.filter)
});
return formatBrowserDebugLogResult(kind, result, result.requests, limit);
}
const result = await browserErrors(proxyRequest ?? baseUrl, options);
return formatBrowserDebugLogResult("errors", result, result.errors, limit);
}
/** Extract visible page prose with the same trust boundary as snapshots. */
async function executeTextAction(params) {
const { input, baseUrl, profile, proxyRequest, signal } = params;
const targetId = normalizeOptionalString(input.targetId);
const selector = normalizeOptionalString(input.selector);
const maxChars = Math.min(readPositiveIntegerParam(input, "maxChars", { message: "maxChars must be a positive integer." }) ?? 4e4, DEFAULT_AI_SNAPSHOT_MAX_CHARS);
const result = await browserPageText(proxyRequest ?? baseUrl, {
targetId,
selector,
maxChars,
profile,
signal
});
const wrapped = wrapBrowserExternalText({
value: result.text,
marker: "\n[truncated — retry with a narrower selector]",
includeWarning: true,
maxChars,
prefix: result.truncated ? "Page text was truncated. Retry with a narrower selector." : void 0
});
return {
content: [{
type: "text",
text: wrapped.text
}],
details: {
ok: result.ok,
targetId: result.targetId,
url: result.url,
truncated: result.truncated || wrapped.truncated,
externalContent: {
untrusted: true,
source: "browser",
kind: "text",
wrapped: true
}
}
};
}
/** Apply settings in order and pin later changes to the first resolved tab. */
async function executeEmulateAction(params) {
const { input, baseUrl, profile, proxyRequest, signal } = params;
const requested = [
[
"device",
"device",
"name"
],
[
"colorScheme",
"media",
"colorScheme"
],
[
"timezoneId",
"timezone",
"timezoneId"
],
[
"locale",
"locale",
"locale"
]
].flatMap(([field, setting, key]) => {
const value = normalizeOptionalString(input[field]);
return value ? [{
field,
setting,
key,
value
}] : [];
});
if (requested.length === 0) throw new Error("emulate requires at least one of device, colorScheme, timezoneId, or locale.");
const colorScheme = requested.find(({ field }) => field === "colorScheme")?.value;
if (colorScheme && ![
"dark",
"light",
"no-preference",
"none"
].includes(colorScheme)) throw new Error("colorScheme must be dark|light|no-preference|none.");
let targetId = normalizeOptionalString(input.targetId);
const applied = [];
for (const { field, setting, key, value } of requested) {
targetId = (await browserEmulateSetting(proxyRequest ?? baseUrl, {
setting,
body: {
targetId,
[key]: value
},
profile,
signal
})).targetId ?? targetId;
applied.push(field);
}
return jsonResult({
ok: true,
targetId,
applied
});
}
/** Execute explicit Browser download operations through the local or node-host path. */
async function executeDownloadAction(params) {
const { action, input, baseUrl, profile, proxyRequest } = params;
const targetId = normalizeOptionalString(input.targetId);
const options = {
targetId,
timeoutMs: normalizePositiveTimeoutMs(input.timeoutMs),
profile,
signal: params.signal
};
const result = action === "download" ? await browserDownload(proxyRequest ?? baseUrl, {
...options,
ref: readToolStringParam(input, "ref", { required: true }),
path: readToolStringParam(input, "path", { required: true })
}) : await browserWaitForDownload(proxyRequest ?? baseUrl, {
...options,
path: readToolStringParam(input, "path")
});
await params.onTabActivity?.(readStringValue(result.targetId) ?? targetId);
return formatBrowserExternalToolResult({
kind: "download",
payload: result
});
}
/** Execute browser actions with route-owned timeout semantics and stale-tab recovery. */
async function executeActAction(params) {
const { request, baseUrl, profile, proxyRequest } = params;
if ("timeoutMs" in request && request.timeoutMs !== void 0) normalizePositiveTimeoutMs(request.timeoutMs);
const effectiveRequest = proxyRequest ? request : withLocalActTimeout(request, params.usesChromeMcp);
const finishActResult = async (result, resolvedTargetId) => {
const aborted = readBrowserBatchAbort(result);
await (effectiveRequest.kind === "close" || aborted?.reason === "closed" ? params.onTabClose : params.onTabActivity)?.(resolvedTargetId);
const formatted = formatActToolResult(result, aborted);
if (!actObservedNavigation(result, aborted)) return formatted;
return await appendNavigatedPageState({
result: formatted,
targetId: resolvedTargetId,
baseUrl,
profile,
proxyRequest,
signal: params.signal
});
};
const dispatchAct = async (actionRequest) => {
const result = await browserAct(proxyRequest ?? baseUrl, actionRequest, {
profile,
signal: params.signal
});
return {
result,
targetId: readStringValue(result.targetId) ?? readStringValue(actionRequest.targetId)
};
};
let dispatched;
try {
dispatched = await dispatchAct(effectiveRequest);
} catch (err) {
const proxyRoute = proxyRequest?.route();
const usesChromeMcp = proxyRequest ? proxyRoute?.status === "resolved" && proxyRoute.driver === "existing-session" : params.usesChromeMcp;
const recoveryProfile = proxyRoute?.status === "resolved" ? proxyRoute.profile : profile ?? "default";
if (isChromeStaleTargetError(usesChromeMcp, err)) {
let tabRefreshError;
const availability = await browserTabs(proxyRequest ?? baseUrl, {
profile,
signal: params.signal
}).catch((refreshError) => {
params.signal?.throwIfAborted();
tabRefreshError = refreshError;
return {
running: false,
tabs: []
};
});
const tabs = availability.tabs;
const freshTargetId = tabs.length === 1 ? readStringValue(tabs[0]?.targetId) : void 0;
const retryRequest = freshTargetId && normalizeOptionalString(effectiveRequest.targetId) ? {
...effectiveRequest,
targetId: freshTargetId
} : null;
if (retryRequest && canRetryChromeActAfterSoleTargetRefresh(effectiveRequest)) {
const retried = await dispatchAct(retryRequest);
return await finishActResult(retried.result, retried.targetId);
}
if (tabRefreshError) throw new Error(`Chrome tab not found for profile="${recoveryProfile}", and refreshing tabs failed: ${formatErrorMessage(tabRefreshError)}. Run action=tabs profile="${recoveryProfile}" and retry with a returned targetId.`, { cause: err });
if (!availability.running) throw new Error(`Browser tabs are unavailable for profile="${recoveryProfile}". Reconnect or start that browser profile, then run action=tabs and retry.`, { cause: err });
if (!tabs.length) throw new Error(`No browser tabs found for profile="${recoveryProfile}". Make sure the configured Chromium-based browser (v144+) is running and has open tabs, then retry.`, { cause: err });
throw new Error(`Chrome tab not found (stale targetId?). Run action=tabs profile="${recoveryProfile}" and use one of the returned targetIds.`, { cause: err });
}
throw err;
}
return await finishActResult(dispatched.result, dispatched.targetId);
}
function formatActToolResult(result, aborted) {
const formatted = formatBrowserExternalToolResult({
kind: "act",
payload: result
});
if (!aborted) return formatted;
const note = aborted.reason === "navigation" ? `Batch aborted after action ${aborted.afterAction} because the page navigated; ${aborted.skipped} remaining action(s) skipped. Earlier refs are stale.` : `Batch aborted after action ${aborted.afterAction} because the page or browser context closed; ${aborted.skipped} remaining action(s) skipped. Take a new snapshot before continuing.`;
return {
...formatted,
content: [...formatted.content, {
type: "text",
text: note
}]
};
}
//#endregion
//#region extensions/browser/src/browser-tool.screenshot.ts
function formatScreenshotShareHint(filePath) {
return `[Screenshot saved to ${JSON.stringify(filePath)}. A sanitized outbound copy is ready at this path for explicit sharing.]`;
}
const SCREENSHOT_SHARE_UNAVAILABLE = "[Screenshot sharing is unavailable because an outbound copy could not be prepared.]";
async function executeScreenshotAction({ input: params, baseUrl, profile, requestedTimeoutMs, proxyRequest, signal, onTabActivity, opts }) {
const targetId = readToolStringParam(params, "targetId");
const type = params.type === "jpeg" ? "jpeg" : "png";
const result = await browserScreenshotAction(proxyRequest ?? baseUrl, {
targetId,
fullPage: Boolean(params.fullPage),
ref: readToolStringParam(params, "ref"),
element: readToolStringParam(params, "element"),
type,
labels: typeof params.labels === "boolean" ? params.labels : void 0,
timeoutMs: requestedTimeoutMs ?? 2e4,
profile,
signal
});
await onTabActivity(readStringValue(result.targetId) ?? targetId);
if (opts?.screenshotResultMode === "path") {
const artifactPath = opts.persistScreenshot ? await opts.persistScreenshot({
sourcePath: result.path,
type,
targetId: readStringValue(result.targetId) ?? targetId
}) : result.path;
if (artifactPath.length > 4096) throw new Error("Browser screenshot artifact path exceeds 4096 characters");
const resultTargetId = readStringValue(result.targetId) ?? targetId;
const resultUrl = readStringValue(result.url);
return jsonResult({
ok: result.ok,
path: artifactPath,
...resultTargetId ? { targetId: truncateUtf16Safe(resultTargetId, 256) } : {},
...resultUrl ? { url: truncateUtf16Safe(resultUrl, 2048) } : {},
...Array.isArray(result.annotations) ? { annotationCount: result.annotations.length } : {},
media: { outbound: false }
});
}
const screenshotPath = result.path;
const screenshotCfg = getRuntimeConfig();
const imageSanitization = resolveRuntimeImageSanitization();
let shareHint = SCREENSHOT_SHARE_UNAVAILABLE;
try {
shareHint = formatScreenshotShareHint(await stageBrowserScreenshotForSharing(screenshotPath, imageSanitization?.maxDimensionPx));
} catch {}
const screenshotDetails = {
...result,
media: { outbound: false }
};
let extraText = shareHint;
try {
const described = await describeBrowserScreenshot({
cfg: screenshotCfg,
filePath: screenshotPath,
agentDir: opts?.agentDir,
agentId: opts?.agentId,
workspaceDir: opts?.workspaceDir,
activeModel: opts?.activeModel,
mediaScope: opts?.mediaScope,
imageSanitization
}, {
describeImageFile,
normalizeBrowserScreenshot,
saveMediaBuffer
});
if (described) return {
content: [{
type: "text",
text: `[analyzed by ${described.provider && described.model ? `${described.provider}/${described.model}` : "media image understanding"}]\n${wrapExternalContent(neutralizeMediaDirectives(described.text.trim()), {
source: "browser",
includeWarning: true
})}\n${shareHint}`
}],
details: {
...result,
vision: {
provider: described.provider,
model: described.model,
decision: described.decision
}
}
};
} catch (err) {
const rawReason = err instanceof Error ? err.message : String(err);
extraText = `[browser screenshot vision failed: ${wrapExternalContent(neutralizeMediaDirectives(rawReason), {
source: "browser",
includeWarning: false
})}]\n${shareHint}`;
}
return await imageResultFromFile({
label: "browser:screenshot",
path: screenshotPath,
extraText,
details: screenshotDetails,
imageSanitization
});
}
//#endregion
//#region extensions/browser/src/browser-tool-dispatch.ts
function readOptionalTargetAndTimeout(params) {
return {
targetId: normalizeOptionalString(params.targetId),
timeoutMs: readPositiveIntegerParam(params, "timeoutMs", { message: "timeoutMs must be a positive integer." })
};
}
function readTargetUrlParam(params) {
const targetUrl = readToolStringParam(params, "targetUrl") ?? readToolStringParam(params, "url", {
required: true,
label: "targetUrl"
});
parseBrowserNavigationUrl(targetUrl);
return targetUrl;
}
/** Run tab actions against the prepared host, node, or sandbox route. */
async function executeBrowserTabAction(context) {
const { action, params, baseUrl, profile, proxyRequest, nodeRoute, sessionTabs, capabilities, isUserBrowserProfile, toolTimeoutMs, requestedTimeoutMs, signal, opts } = context;
const touchTab = async (targetId) => {
await sessionTabs.touch(targetId);
await context.onTabActivity(targetId);
};
const trackedTabResult = async (result, targetId) => {
await touchTab(readStringValue(asNullableRecord(result)?.targetId) ?? targetId);
return jsonResult(result);
};
const actionOptions = {
input: params,
baseUrl,
profile,
proxyRequest,
signal
};
const clientOptions = {
profile,
timeoutMs: toolTimeoutMs,
signal
};
switch (action) {
case "tabs": return await executeTabsAction({
baseUrl,
profile,
timeoutMs: toolTimeoutMs,
proxyRequest,
targetId: context.boundTargetId,
signal
});
case "open": {
const targetUrl = readTargetUrlParam(params);
const label = normalizeOptionalString(params.label);
const opened = await browserOpenTab(proxyRequest ?? baseUrl, targetUrl, {
...clientOptions,
label
});
const closeOpenedTab = async (targetId, openedProfile) => {
if (nodeRoute && !proxyRequest?.isHostFallbackActive()) {
await nodeRoute.closeTarget({
targetId,
profile: openedProfile
});
return;
}
await browserCloseTab(baseUrl, targetId, {
profile: openedProfile,
timeoutMs: toolTimeoutMs
});
};
await sessionTabs.trackOpened(opened, closeOpenedTab);
await context.onTabActivity(readStringValue(asNullableRecord(opened)?.targetId), readStringValue(asNullableRecord(opened)?.resolvedProfile));
return formatBrowserExternalToolResult({
kind: "tabs",
payload: stripBrowserOpenInternalMetadata(opened)
});
}
case "focus": {
const targetId = readToolStringParam(params, "targetId", { required: true });
return await trackedTabResult(await browserFocusTab(proxyRequest ?? baseUrl, targetId, clientOptions), targetId);
}
case "close": {
const targetId = readToolStringParam(params, "targetId");
const result = targetId ? await browserCloseTab(proxyRequest ?? baseUrl, targetId, clientOptions) : await browserAct(proxyRequest ?? baseUrl, { kind: "close" }, clientOptions);
await sessionTabs.untrack(readStringValue(asNullableRecord(result)?.targetId) ?? targetId);
return jsonResult(result);
}
case "snapshot": return await executeSnapshotAction({
...actionOptions,
onTabActivity: touchTab
});
case "screenshot": return await executeScreenshotAction({
...actionOptions,
requestedTimeoutMs,
onTabActivity: touchTab,
opts
});
case "navigate": {
const targetUrl = readTargetUrlParam(params);
const targetId = readToolStringParam(params, "targetId");
const result = await browserNavigate(proxyRequest ?? baseUrl, {
url: targetUrl,
targetId,
timeoutMs: requestedTimeoutMs,
profile,
signal
});
const navigatedTargetId = readStringValue(asNullableRecord(result)?.targetId) ?? targetId;
await touchTab(navigatedTargetId);
const formatted = formatBrowserExternalToolResult({
kind: asNullableRecord(result)?.download ? "download" : "act",
payload: result
});
if (asNullableRecord(result)?.download) return formatted;
return await appendNavigatedPageState({
result: formatted,
targetId: navigatedTargetId,
baseUrl,
profile,
proxyRequest,
signal
});
}
case "console": {
const result = await executeConsoleAction(actionOptions);
const targetId = readToolStringParam(params, "targetId");
await touchTab(readStringValue(asNullableRecord(result.details)?.targetId) ?? targetId);
return result;
}
case "requests":
case "errors":
case "text":
case "emulate": {
const result = action === "requests" || action === "errors" ? await executeDebugLogAction(action, actionOptions) : await (action === "text" ? executeTextAction : executeEmulateAction)(actionOptions);
await touchTab(readStringValue(asNullableRecord(result.details)?.targetId) ?? readStringValue(params.targetId));
return result;
}
case "pdf": {
const targetId = normalizeOptionalString(params.targetId);
const result = await browserPdfSave(proxyRequest ?? baseUrl, {
targetId,
profile,
signal
});
await touchTab(readStringValue(result.targetId) ?? targetId);
return {
content: [{
type: "text",
text: `FILE:${result.path}`
}],
details: result
};
}
case "download":
case "waitfordownload": return await executeDownloadAction({
...actionOptions,
action,
onTabActivity: touchTab
});
case "upload": {
const paths = Array.isArray(params.paths) ? params.paths.map((p) => String(p)) : [];
if (paths.length === 0) throw new Error("paths required");
const resolvedResult = await resolveExistingUploadPaths({ requestedPaths: paths });
if (!resolvedResult.ok) throw new Error(resolvedResult.error);
const ref = readToolStringParam(params, "ref");
const inputRef = readToolStringParam(params, "inputRef");
const element = readToolStringParam(params, "element");
const { targetId, timeoutMs } = readOptionalTargetAndTimeout(params);
return await trackedTabResult(await browserArmFileChooser(proxyRequest ?? baseUrl, {
paths: resolvedResult.paths,
ref,
inputRef,
element,
targetId,
timeoutMs,
profile,
signal
}), targetId);
}
case "dialog": {
const accept = Boolean(params.accept);
const promptText = readStringValue(params.promptText);
const dialogId = readStringValue(params.dialogId);
const { targetId, timeoutMs } = readOptionalTargetAndTimeout(params);
return await trackedTabResult(await browserArmDialog(proxyRequest ?? baseUrl, {
accept,
promptText,
dialogId,
targetId,
timeoutMs,
profile,
signal
}), targetId);
}
case "act": {
const request = context.actRequest;
if (!request) throw new Error("request required");
if (!capabilities.actKinds.some((kind) => kind === request.kind)) throw new Error(`browser act kind ${JSON.stringify(request.kind)} is unavailable for this run`);
return await executeActAction({
request,
baseUrl,
profile,
usesChromeMcp: isUserBrowserProfile,
proxyRequest,
signal,
onTabActivity: touchTab,
onTabClose: sessionTabs.untrack
});
}
default: throw new Error(`Unknown action: ${action}`);
}
}
//#endregion
//#region extensions/browser/src/browser-node-routing.ts
/** Select the same authorized browser-capable node on every request surface. */
async function resolveBrowserNodeTarget(params) {
const policy = params.config.gateway?.nodes?.browser;
const mode = policy?.mode ?? "auto";
const explicit = params.explicitTarget || Boolean(params.requestedNode?.trim());
if (mode === "off") {
if (explicit) throw new Error("Node browser proxy is disabled (gateway.nodes.browser.mode=off).");
return null;
}
const requested = params.requestedNode?.trim() || policy?.node?.trim();
if (mode === "manual" && !explicit && !requested) return null;
if (!explicit && !requested) {
const { isBrowserHostAvailable } = await import("./browser-host-availability-DAjL-4jU.mjs");
if (await isBrowserHostAvailable(params.config, params.profile)) return null;
}
const browserNodes = (await params.nodes()).filter((node) => {
if (params.requireConnected && !node.connected) return false;
return node.caps?.includes("browser") || node.commands?.includes("browser.proxy");
});
if (browserNodes.length === 0) {
if (explicit || requested) throw new Error("No connected browser-capable nodes.");
return null;
}
if (requested) {
const nodeId = resolveNodeIdFromList(browserNodes, requested, false, { allowCompactDisplayName: true });
return browserNodes.find((node) => node.nodeId === nodeId) ?? null;
}
if (browserNodes.length === 1) return browserNodes[0] ?? null;
if (explicit) throw new Error(`Multiple browser-capable nodes connected (${browserNodes.length}). Set gateway.nodes.browser.node or pass node=<id>.`);
return null;
}
//#endregion
//#region extensions/browser/src/browser-tool.routing.ts
async function resolveBrowserToolNodeTarget(params) {
if (params.allowHostControl === false) {
if (params.target === "node" || params.requestedNode) throw new Error("Node browser control is disabled by sandbox policy.");
return null;
}
const cfg = getRuntimeConfig();
const policy = cfg.gateway?.nodes?.browser;
const explicitTarget = params.target === "node";
const requestedNode = params.requestedNode?.trim();
if (policy?.mode === "off") {
await resolveBrowserNodeTarget({
nodes: () => [],
config: cfg,
requestedNode,
explicitTarget
});
return null;
}
if (params.sandboxBridgeUrl?.trim() && !explicitTarget && !requestedNode) return null;
if (params.target && !explicitTarget) return null;
if (!explicitTarget && !requestedNode && !policy?.node?.trim() && (policy?.mode === "manual" || policy?.mode !== "auto" && !hasGatewayToolRoutingContext() && cfg.gateway?.mode !== "remote" && !cfg.gateway?.remote?.url?.trim() && !process.env.OPENCLAW_GATEWAY_URL?.trim())) return null;
const node = await resolveBrowserNodeTarget({
nodes: () => listNodes({}, params.signal),
config: cfg,
profile: params.profile,
requestedNode,
explicitTarget,
requireConnected: true
});
params.signal?.throwIfAborted();
return node ? {
nodeId: node.nodeId,
label: node.displayName ?? node.remoteIp ?? node.nodeId,
commands: node.commands ?? [],
pendingDeclaredCommands: node.pendingDeclaredCommands ?? []
} : null;
}
function resolveBrowserBaseUrl(params) {
const cfg = getRuntimeConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const normalizedSandbox = params.sandboxBridgeUrl?.trim() ?? "";
if ((params.target ?? (normalizedSandbox ? "sandbox" : "host")) === "sandbox") {
if (!normalizedSandbox) throw new Error("Sandbox browser is unavailable. Enable agents.defaults.sandbox.browser.enabled or use target=\"host\" if allowed.");
return normalizedSandbox.replace(/\/$/, "");
}
if (params.allowHostControl === false) throw new Error("Host browser control is disabled by sandbox policy.");
if (!resolved.enabled) throw new Error("Browser control is disabled. Set browser.enabled=true in ~/.openclaw/openclaw.json.");
}
const DEFAULT_EXISTING_SESSION_MANAGE_TIMEOUT_MS = 45e3;
const EXISTING_SESSION_MANAGE_ACTIONS = /* @__PURE__ */ new Set([
"status",
"start",
"stop",
"profiles",
"tabs",
"open",
"focus",
"close"
]);
const PERSISTENT_TAB_ACTIONS = /* @__PURE__ */ new Set([
"profiles",
"tabs",
"open",
"focus",
"close"
]);
function hasExistingSessionProfile(resolved) {
return Object.keys(resolved.profiles).some((name) => {
const candidate = resolveProfile(resolved, name);
return candidate ? getBrowserProfileCapabilities(candidate).usesChromeMcp : false;
});
}
function resolveBrowserToolTimeoutMs({ requestedTimeoutMs, action, isUserBrowserProfile, usesPersistentPlaywright, isNodeProxy, resolvedBrowser }) {
if (requestedTimeoutMs !== void 0) return requestedTimeoutMs;
if (EXISTING_SESSION_MANAGE_ACTIONS.has(action) && (isUserBrowserProfile || action === "profiles" && hasExistingSessionProfile(resolvedBrowser))) return DEFAULT_EXISTING_SESSION_MANAGE_TIMEOUT_MS;
if (PERSISTENT_TAB_ACTIONS.has(action) && (usesPersistentPlaywright || isNodeProxy)) return resolvedBrowser.actionTimeoutMs;
}
//#endregion
//#region extensions/browser/src/browser-tool.lifecycle.ts
const unavailableSystemProfiles = (unavailableReason) => ({
profiles: [],
unavailableReason
});
/**
* Read importable system profiles from the host control server. Discovery must
* match where import runs (host-local), so it never uses a node proxy or the
* sandbox base URL. Other profile sources remain useful when host discovery
* is unavailable, so failures become an explicit degradation fact.
*/
async function readHostSystemProfiles(params) {
if (params.allowHostControl === false) return unavailableSystemProfiles("Host system profile discovery is disabled by sandbox policy; enable host control to discover importable system profiles.");
let hostBaseUrl;
try {
hostBaseUrl = resolveBrowserBaseUrl({
target: "host",
sandboxBridgeUrl: params.sandboxBridgeUrl,
allowHostControl: params.allowHostControl
});
} catch {
return unavailableSystemProfiles("Host browser control is unavailable; enable it and retry action=profiles target=\"host\".");
}
try {
return {
profiles: await browserSystemProfiles(hostBaseUrl, {
timeoutMs: params.timeoutMs,
signal: params.signal
}),
unavailableReason: void 0
};
} catch {
params.signal?.throwIfAborted();
return unavailableSystemProfiles("Host system profile discovery failed; retry action=profiles target=\"host\" after host browser control is available.");
}
}
async function executeBrowserLifecycleAction({ action, input: params, baseUrl, profile, timeoutMs: toolTimeoutMs, proxyRequest, allowHostControl, sandboxBridgeUrl, signal }) {
const readBrowserStatus = async () => await browserStatus(proxyRequest ?? baseUrl, {
profile,
timeoutMs: toolTimeoutMs,
signal
});
switch (action) {
case "doctor": return jsonResult(await browserDoctor(proxyRequest ?? baseUrl, {
profile,
signal
}));
case "status": return jsonResult(await readBrowserStatus());
case "start":
case "stop":
await (action === "start" ? browserStart : browserStop)(proxyRequest ?? baseUrl, {
profile,
timeoutMs: toolTimeoutMs,
signal
});
return jsonResult(await readBrowserStatus());
case "profiles": {
const { profiles: systemProfiles, unavailableReason: systemProfilesUnavailable } = await readHostSystemProfiles({
allowHostControl,
sandboxBridgeUrl,
timeoutMs: toolTimeoutMs,
signal
});
if (proxyRequest) {
const result = await proxyRequest({
method: "GET",
path: "/profiles",
timeoutMs: toolTimeoutMs
});
return jsonResult({
...result && typeof result === "object" ? result : { profiles: result },
systemProfiles,
...systemProfilesUnavailable ? { systemProfilesUnavailable } : {}
});
}
return jsonResult({
profiles: await browserProfiles(baseUrl, {
timeoutMs: toolTimeoutMs,
signal
}),
systemProfiles,
...systemProfilesUnavailable ? { systemProfilesUnavailable } : {}
});
}
case "importprofile": {
if (proxyRequest) throw new Error("system profile import must run on the browser host");
const domains = parseSystemProfileDomains(params.domains);
return jsonResult(await browserImportProfile(baseUrl, {
browser: normalizeOptionalString(params.browser) ?? "chrome",
systemProfile: normalizeOptionalString(params.systemProfile) ?? "Default",
into: normalizeOptionalString(params.into) ?? "imported",
domains,
signal
}));
}
default: throw new Error(`Unknown action: ${String(action)}`);
}
}
//#endregion
//#region extensions/browser/src/browser-tool.ts
function isBrowserRouteIdentifier(value, maxChars) {
return typeof value === "string" && value.length > 0 && value.length <= maxChars && value.trim() === value;
}
function resolveBrowserTabIdentity(params) {
if (params.baseUrl || !isBrowserRouteIdentifier(params.targetId, 128) || !isBrowserRouteIdentifier(params.profile, 128)) return;
if (params.target === "node") return isBrowserRouteIdentifier(params.node, 256) ? {
targetId: params.targetId,
profile: params.profile,
target: "node",
node: params.node
} : void 0;
return {
targetId: params.targetId,
profile: params.profile,
target: "host"
};
}
function withBrowserTabDetails(result, identity) {
const details = asNullableRecord(result.details);
if (!identity || !details || details.ok === false || details.isError === true || Array.isArray(details.results) && details.results.some((entry) => asNullableRecord(entry)?.ok === false) || asNullableRecord(details.aborted)?.reason === "closed") return result;
const url = readStringValue(details.url);
const protocol = url ? URL.parse(url)?.protocol : void 0;
const title = readStringValue(details.title);
return {
...result,
details: {
...details,
browserTab: {
...identity,
...url && (protocol === "http:" || protocol === "https:") ? { url: truncateUtf16Safe(url, 2048) } : {},
...title ? { title: truncateUtf16Safe(title, 512) } : {}
}
}
};
}
const LEGACY_BROWSER_ACT_REQUEST_KEYS = [
"kind",
"actions",
"stopOnError",
"targetId",
"ref",
"doubleClick",
"button",
"modifiers",
"x",
"y",
"text",
"submit",
"slowly",
"key",
"delayMs",
"startRef",
"endRef",
"values",
"fields",
"width",
"height",
"timeMs",
"textGone",
"selector",
"url",
"loadState",
"fn",
"timeoutMs"
];
function readActRequestParam(params) {
const requestParam = params.request;
const nestedRequest = requestParam && typeof requestParam === "object" ? { ...requestParam } : void 0;
if (!nestedRequest && !readToolStringParam(params, "kind")) return;
const request = nestedRequest ?? {};
const hasMismatchedKind = typeof request.kind === "string" && typeof params.kind === "string" && request.kind !== params.kind;
for (const key of LEGACY_BROWSER_ACT_REQUEST_KEYS) {
if (Object.hasOwn(request, key) || !Object.hasOwn(params, key)) continue;
if (hasMismatchedKind && key !== "targetId") continue;
request[key] = params[key];
}
return request;
}
function readToolTimeoutMs(params) {
return readPositiveIntegerParam(params, "timeoutMs", { message: "timeoutMs must be a positive integer." });
}
function createBrowserTool(opts) {
const { binding, capabilities, metadata } = createBrowserToolDefinition(opts, getRuntimeConfig);
return {
...metadata,
execute: async (_toolCallId, args, signal) => {
let params = binding ? applyBrowserTabToolBinding(args, binding) : args;
const action = readToolStringParam(params, "action", { required: true });
if (!capabilities.actions.some((candidate) => candidate === action)) throw new Error(`browser action ${JSON.stringify(action)} is unavailable for this run; use an available action such as snapshot, or select a managed browser profile in an unbound run.`);
const dashboardName = readToolStringParam(params, "dashboard");
let browserDashboard;
if (dashboardName) {
const operatorScopes = readGatewayToolOperatorScopes();
const sessionScoped = operatorScopes !== void 0 && !operatorScopes.includes("operator.admin");
const dashboardScopes = sessionScoped ? operatorScopes.includes("operator.write") ? ["operator.write"] : ["operator.sessions.write"] : ["operator.admin"];
if (binding || !opts?.agentSessionKey || opts.allowHostControl === false || params.target && params.target !== "host" || params.node) throw new Error("Browser dashboard requires this session's unbound host-browser capability");
const request = {
sessionKey: opts.agentSessionKey,
agentId: opts.agentId,
name: dashboardName
};
if (params.profile !== void 0 || params.targetId !== void 0) throw new Error("A dashboard selector owns its browser profile and tab. Omit profile and targetId.");
const callDashboard = async (method, resume = false) => {
signal?.throwIfAborted();
const dashboard = await callGatewayTool(sessionScoped ? "browser.dashboard.request" : "browser.request", { timeoutMs: readToolTimeoutMs(params) }, {
...sessionScoped ? {
sessionKey: request.sessionKey,
agentId: request.agentId
} : { target: "host" },
method,
path: "/dashboard",
body: {
...request,
...resume ? { resume: true } : {}
}
}, {
scopes: dashboardScopes,
signal
});
signal?.throwIfAborted();
return dashboard;
};
if (action === "close") return jsonResult({ browserDashboard: await callDashboard("DELETE") });
if (action === "open") {
if (params.targetUrl !== void 0 || params.url !== void 0) throw new Error("Update the dashboard widget URL with dashboard widget_put before opening it");
return jsonResult({ browserDashboard: await callDashboard("POST", true) });
}
if ([
"doctor",
"status",
"start",
"stop",
"profiles",
"importprofile"
].includes(action)) throw new Error("Use browser tab actions with a dashboard selector; open resumes it and close pauses it");
const dashboard = await callDashboard("POST");
browserDashboard = dashboard;
if (!dashboard.browserTab) throw new Error(`Dashboard ${dashboardName} is paused. Use action=open with dashboard=${dashboardName} to resume it.`);
params = applyBrowserTabToolBinding(params, {
kind: "tab",
tabId: 0,
...dashboard.browserTab
});
if (sessionScoped) {
if (![
"tabs",
"focus",
"navigate",
"snapshot",
"screenshot",
"act"
].includes(action)) throw new Error("Session dashboards support tabs, focus, navigate, snapshot, screenshot and act; open resumes and close pauses them.");
const stripSelectors = (input) => {
if (!input) return;
const { targetId: _targetId, profile: _profile, target: _target, node: _node, ...rest } = input;
return rest;
};
const proxyRequest = Object.assign(async (call) => {
return callGatewayTool("browser.dashboard.request", { timeoutMs: call.timeoutMs }, {
sessionKey: request.sessionKey,
agentId: request.agentId,
dashboard: {
name: dashboardName,
instanceId: dashboard.instanceId
},
method: call.method,
path: call.path,
query: stripSelectors(call.query),
body: stripSelectors(asNullableRecord(call.body) ?? void 0)
}, {
scopes: dashboardScopes,
signal: call.signal ?? signal
});
}, {
isHostFallbackActive: () => false,
route: () => void 0
});
const result = await executeBrowserTabAction({
action,
params,
actRequest: action === "act" ? readActRequestParam(params) : void 0,
profile: dashboard.browserTab.profile,
proxyRequest,
capabilities,
isUserBrowserProfile: false,
boundTargetId: dashboard.browserTab.targetId,
requestedTimeoutMs: readToolTimeoutMs(params),
signal,
opts,
sessionTabs: {
touch: async () => {},
untrack: async () => {},
trackOpened: async () => {
throw new Error("Dashboard owns its context.");
}
},
onTabActivity: () => {}
});
return {
...result,
details: {
...asNullableRecord(result.details),
browserDashboard: dashboard
}
};
}
}
const requestedProfile = readToolStringParam(params, "profile");
const requestedNode = readToolStringParam(params, "node");
const requestedTimeoutMs = readToolTimeoutMs(params);
let target = readToolStringParam(params, "target");
const runtimeConfig = getRuntimeConfig();
const resolvedBrowser = resolveBrowserConfig(runtimeConfig.browser, runtimeConfig);
const effectiveProfile = requestedProfile ?? resolvedBrowser.defaultProfile;
const resolvedProfile = resolveProfile(resolvedBrowser, effectiveProfile);
const profileCapabilities = resolvedProfile ? getBrowserProfileCapabilities(resolvedProfile) : void 0;
let profile = profileCapabilities?.usesChromeMcp ? effectiveProfile : requestedProfile;
const configuredNode = runtimeConfig.gateway?.nodes?.browser?.node?.trim();
if (requestedNode && target && target !== "node") throw new Error("node is only supported with target=\"node\".");
if (action === "importprofile") {
if (target === "sandbox" || target === "node" || requestedNode) throw new Error("system profile import must run on the host; omit target or use target=\"host\".");
target = "host";
}
const isUserBrowserProfile = profileCapabilities?.usesChromeMcp === true;
if (isUserBrowserProfile) {
if (target === "sandbox") throw new Error(`profile="${profile}" cannot use the sandbox browser; use target="host" or omit target.`);
}
let nodeTarget = null;
try {
nodeTarget = await resolveBrowserToolNodeTarget({
requestedNode: requestedNode ?? void 0,
profile: requestedProfile,
target,
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
allowHostControl: opts?.allowHostControl,
signal
});
} catch (error) {
signal?.throwIfAborted();
if (!(isUserBrowserProfile && !target && !requestedNode && !configuredNode)) throw error;
}
if (isUserBrowserProfile && !target && !requestedNode && !nodeTarget) target = "host";
const baseUrl = nodeTarget ? void 0 : resolveBrowserBaseUrl({
target: target === "node" ? void 0 : target,
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
allowHostControl: opts?.allowHostControl
});
const allowAutomaticHostFallback = Boolean(nodeTarget && !target && !requestedNode && !configuredNode && opts?.allowHostControl !== false);
const proxyRequest = nodeTarget ? createBrowserNodeProxyRequest({
nodeTarget,
allowAutomaticHostFallback,
signal
}) : null;
if (proxyRequest) profile = requestedProfile;
if (!proxyRequest && isUserBrowserProfile && [
"requests",
"errors",
"text",
"emulate"
].includes(action)) throw new Error(`action=${action} is not supported for existing-session profiles; use action=snapshot to inspect this page, or select a managed browser profile for ${action}.`);
const nodeRoute = nodeTarget ? createBrowserNodeSessionTabRoute(nodeTarget) : void 0;
const toolTimeoutMs = resolveBrowserToolTimeoutMs({
requestedTimeoutMs,
action,
isUserBrowserProfile,
usesPersistentPlaywright: profileCapabilities?.usesPersistentPlaywright === true,
isNodeProxy: proxyRequest !== null,
resolvedBrowser
});
const sessionTabs = createBrowserToolSessionTabs({
sessionKey: opts?.agentSessionKey,
requestedProfile: profile,
defaultProfile: resolvedBrowser.defaultProfile,
baseUrl,
nodeRoute,
routeProfile: () => {
const route = proxyRequest?.route();
return route?.status === "resolved" ? route.profile : void 0;
},
isHostFallbackActive: proxyRequest?.isHostFallbackActive,
registry: {
touchSessionBrowserTab,
trackSessionBrowserTab,
untrackSessionBrowserTab
}
});
switch (action) {
case "doctor":
case "status":
case "start":
case "stop":
case "profiles":
case "importprofile": return await executeBrowserLifecycleAction({
action,
input: params,
baseUrl,
profile,
timeoutMs: toolTimeoutMs,
proxyRequest,
allowHostControl: opts?.allowHostControl,
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
signal
});
}
let tabIdentity;
if (browserDashboard) await assertBrowserDashboardTargetCurrent(browserDashboard, opts?.agentId, { signal });
const dispatchTabAction = () => executeBrowserTabAction({
action,
actRequest: action === "act" ? readActRequestParam(params) : void 0,
params,
baseUrl,
profile,
proxyRequest,
nodeRoute,
sessionTabs,
capabilities,
isUserBrowserProfile,
toolTimeoutMs,
requestedTimeoutMs,
signal,
opts,
boundTargetId: binding ? binding.targetId : dashboardName ? readToolStringParam(params, "targetId") : void 0,
onTabActivity: (targetId, openedProfile) => {
const route = proxyRequest?.route();
const onNode = proxyRequest && !proxyRequest.isHostFallbackActive();
const routeProfile = onNode ? route?.status === "resolved" ? route.profile : void 0 : profile ?? resolvedBrowser.defaultProfile;
tabIdentity = resolveBrowserTabIdentity({
targetId,
baseUrl,
profile: openedProfile ?? routeProfile,
target: onNode ? "node" : "host",
node: onNode && route?.status === "resolved" ? nodeTarget?.nodeId : void 0
});
}
});
const dashboardTarget = browserDashboard;
const result = dashboardTarget ? await withBrowserRequestScope({
managedOnly: true,
assertCurrent: (admittedProfile) => assertBrowserDashboardTargetCurrent(dashboardTarget, opts?.agentId, { signal }, admittedProfile)
}, dispatchTabAction) : await dispatchTabAction();
if (browserDashboard) return {
...result,
details: {
...asNullableRecord(result.details),
browserDashboard
}
};
return [
"open",
"focus",
"navigate",
"screenshot",
"snapshot",
"text",
"requests",
"errors",
"console",
"emulate",
"act"
].includes(action) ? withBrowserTabDetails(result, tabIdentity) : result;
}
};
}
//#endregion
//#region extensions/browser/src/sdk-node-runtime.ts
/** Runs async work with an optional aborting timeout signal. */
async function withTimeout(work, timeoutMs, label) {
const resolved = clampTimerTimeoutMs(timeoutMs);
if (!resolved) return await work(void 0);
const controller = new AbortController();
const error = /* @__PURE__ */ new Error(`${label ?? "request"} timed out`);
const timeout = createDeferred();
const timer = setTimeout(() => {
timeout.reject(error);
controller.abort(error);
}, resolved);
timer.unref?.();
try {
return await Promise.race([work(controller.signal), timeout.promise]);
} finally {
clearTimeout(timer);
}
}
//#endregion
//#region extensions/browser/src/gateway/browser-request-tab-scope.ts
const browserTabScopeSchema = strictObject({
sessionKey: string().trim().min(1).max(512),
referencedTabs: array(strictObject({
target: _enum(["host", "node"]),
node: string().trim().min(1).max(256).optional(),
profile: string().trim().min(1).optional(),
targetId: string().trim().min(1)
}).refine((tab) => tab.target === "node" || tab.node === void 0)).max(64).optional()
});
/** Apply session membership only after the execution owner has completed the route. */
async function applyBrowserRequestTabScope(params) {
params.assertCurrent();
const listTabs = params.method === "GET" && params.path === "/tabs";
const openTab = params.method === "POST" && params.path === "/tabs/open";
const closeTab = params.method === "DELETE" && /^\/tabs\/[^/]+$/.test(params.path);
const touchTab = params.method === "POST" && (params.path === "/navigate" || params.path === "/tabs/focus");
if (!listTabs && !openTab && !closeTab && !touchTab) return params.result;
const authority = {
runtime: getOptionalBrowserStateRuntime() ?? void 0,
assertCurrent: params.assertCurrent
};
const registry = await import("./session-tab-registry-9DubijLW.mjs");
const nodeRoute = params.nodeTarget ? (await import("./browser-node-proxy-BFm-b05Q.mjs")).createBrowserNodeSessionTabRoute(params.nodeTarget) : void 0;
params.assertCurrent();
const route = nodeRoute ?? { kind: "browser-control" };
const profile = normalizeOptionalLowercaseString(params.profile);
if (listTabs) {
const result = asNullableRecord(params.result);
if (!Array.isArray(result?.tabs)) return params.result;
const tabs = result.tabs.flatMap((value) => {
const tab = asNullableRecord(value);
const targetId = normalizeOptionalString(tab?.targetId);
return targetId ? [{
value,
targetId,
tabId: normalizeOptionalString(tab?.tabId)
}] : [];
});
const tracked = new Set(await registry.filterTrackedSessionBrowserTabs({
sessionKey: params.scope.sessionKey,
route,
profile,
tabs,
authority
}));
params.assertCurrent();
const referenced = new Set((params.scope.referencedTabs ?? []).filter((tab) => {
if (nodeRoute ? tab.target !== "node" || tab.node !== nodeRoute.nodeId : tab.target !== "host") return false;
return normalizeOptionalLowercaseString(tab.profile ?? (nodeRoute ? !params.requestedProfile ? profile : void 0 : params.defaultProfile)) === profile;
}).map((tab) => tab.targetId));
return {
...result,
tabs: tabs.filter((tab) => tracked.has(tab) || referenced.has(tab.targetId) || Boolean(tab.tabId && referenced.has(tab.tabId))).map((tab) => tab.value)
};
}
const sessionTabs = createBrowserToolSessionTabs({
sessionKey: params.scope.sessionKey,
requestedProfile: params.requestedProfile,
defaultProfile: params.defaultProfile ?? "",
nodeRoute,
routeProfile: () => params.profile,
registry,
authority
});
if (openTab) await sessionTabs.trackOpened(params.result, params.closeTab);
else if (closeTab) await sessionTabs.untrack(decodeURIComponent(params.path.slice(6)));
else if (touchTab) await sessionTabs.touch(normalizeOptionalString(asNullableRecord(params.body)?.targetId));
params.assertCurrent();
return params.result;
}
//#endregion
//#region extensions/browser/src/gateway/browser-request.ts
const logger = createSubsystemLogger("browser");
const dashboardRequestSchema = object({
sessionKey: string().trim().min(1),
agentId: string().trim().min(1).optional(),
name: string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/),
instanceId: string().min(1).optional()
});
async function handleBrowserGatewayRequest({ params, respond, context, client, signal: invocationSignal, hasCurrentClientAuthority }) {
const reject = (...error) => respond(false, void 0, errorShape(...error));
const typed = params;
const methodRaw = (normalizeOptionalString(typed.method) ?? "").toUpperCase();
const path = normalizeBrowserRequestPath(normalizeOptionalString(typed.path) ?? "");
let query = typed.query && typeof typed.query === "object" ? typed.query : void 0;
let body = typed.body;
const timeoutMs = clampTimerTimeoutMs(typed.timeoutMs);
const explicitNode = typed.target === "node";
const requestedNode = normalizeOptionalString(typed.node);
const connectionSignal = client?.connectionSignal;
const requestSignal = invocationSignal && connectionSignal && invocationSignal !== connectionSignal ? AbortSignal.any([invocationSignal, connectionSignal]) : invocationSignal ?? connectionSignal;
const isRequesterCurrent = () => !requestSignal?.aborted && !client?.invalidated && !client?.connectionSignal?.aborted && hasCurrentClientAuthority?.() !== false;
const assertRequesterCurrent = () => {
requestSignal?.throwIfAborted();
if (!isRequesterCurrent()) throw new Error("Browser requester is no longer active");
};
const parsedTabScope = typed.tabScope === void 0 ? void 0 : browserTabScopeSchema.safeParse(typed.tabScope);
if (parsedTabScope && (!parsedTabScope.success || typed.dashboard !== void 0 || path === "/dashboard")) {
reject(ErrorCodes.INVALID_REQUEST, "tabScope requires a valid session tab scope and cannot be combined with dashboard");
return;
}
const tabScope = parsedTabScope?.data;
if (typed.target !== void 0 && typed.target !== "host" && !explicitNode || typed.node !== void 0 && (!explicitNode || !requestedNode || typeof typed.node !== "string" || typed.node.length > 256)) {
reject(ErrorCodes.INVALID_REQUEST, "target must be \"host\" or \"node\"; node requires target=\"node\" and a nonempty selector of at most 256 characters");
return;
}
if (!methodRaw || !path) {
reject(ErrorCodes.INVALID_REQUEST, "method and path are required");
return;
}
if (methodRaw !== "GET" && methodRaw !== "POST" && methodRaw !== "DELETE") {
reject(ErrorCodes.INVALID_REQUEST, "method must be GET, POST, or DELETE");
return;
}
if (path === "/dashboard") {
if (typed.target === "node" || requestedNode) {
reject(ErrorCodes.INVALID_REQUEST, "Browser dashboards use a local managed browser on the Gateway host");
return;
}
const request = dashboardRequestSchema.extend({ resume: boolean().optional() }).safeParse(methodRaw === "GET" ? query : body);
if (!request.success) {
reject(ErrorCodes.INVALID_REQUEST, "Browser dashboard requires sessionKey and a stable widget name");
return;
}
try {
respond(true, await (methodRaw === "DELETE" ? stopBrowserDashboard : methodRaw === "GET" ? inspectBrowserDashboard : requestBrowserDashboard)(request.data, {
signal: requestSignal,
assertCurrent: assertRequesterCurrent
}));
} catch (error) {
reject(ErrorCodes.INVALID_REQUEST, String(error));
}
return;
}
let assertDashboardCurrent;
if (typed.dashboard !== void 0) {
const scope = dashboardRequestSchema.safeParse(typed.dashboard);
if (!scope.success || explicitNode || requestedNode) {
reject(ErrorCodes.INVALID_REQUEST, "Dashboard requests require a valid local dashboard identity");
return;
}
try {
const authority = {
signal: requestSignal,
assertCurrent: assertRequesterCurrent
};
const dashboard = await inspectBrowserDashboard(scope.data, authority);
const tab = dashboard.browserTab;
if (!tab || dashboard.paused) throw new Error("Dashboard browser is paused or unavailable. Resume the dashboard first.");
if (path === "/tabs/open" || path === "/tabs/action" || path === "/start" || path === "/stop" || path === "/reset-profile" || path.startsWith("/profiles") || path.startsWith("/system-")) throw new Error("Use the dashboard controls to open or stop its retained tab");
if (methodRaw === "DELETE" && path.startsWith("/tabs/") && decodeURIComponent(path.slice(6)) !== tab.targetId) throw new Error("Dashboard request cannot address another browser tab");
const binding = {
kind: "tab",
tabId: 0,
...tab
};
query = {
...applyBrowserTabToolBinding(query ?? {}, binding),
managedOnly: true
};
const bodyRecord = asNullableRecord(body);
if (bodyRecord || methodRaw === "POST") body = applyBrowserTabToolBinding(bodyRecord ?? {}, binding);
assertDashboardCurrent = (profile) => assertBrowserDashboardTargetCurrent(dashboard, scope.data.agentId, authority, profile);
await assertDashboardCurrent();
} catch (error) {
reject(ErrorCodes.INVALID_REQUEST, String(error));
return;
}
}
const cfg = getRuntimeConfig();
const configuredNode = normalizeOptionalString(cfg.gateway?.nodes?.browser?.node);
const forceHostLocal = Boolean(assertDashboardCurrent) || isBrowserHostLocalRoute(methodRaw, path);
if (forceHostLocal && explicitNode) {
reject(ErrorCodes.INVALID_REQUEST, "this browser route must run on the Gateway host");
return;
}
let nodeTarget = null;
if (!forceHostLocal && typed.target !== "host") try {
nodeTarget = await resolveBrowserNodeTarget({
nodes: () => context.nodeRegistry.listConnected(),
config: cfg,
profile: resolveRequestedBrowserProfile({
query,
body
}),
explicitTarget: explicitNode,
requestedNode
});
assertRequesterCurrent();
} catch (err) {
reject(ErrorCodes.UNAVAILABLE, String(err));
return;
}
if (nodeTarget && path === "/screencast") {
reject(ErrorCodes.INVALID_REQUEST, "browser screencast is not available over a node proxy", { details: {
code: "SCREENCAST_UNSUPPORTED",
reason: "node"
} });
return;
}
if (nodeTarget && isPersistentBrowserProfileMutation(methodRaw, path)) {
reject(ErrorCodes.INVALID_REQUEST, "browser.request cannot mutate persistent browser profiles over a node proxy");
return;
}
let preparedUpload = null;
let proxyCommand = BROWSER_PROXY_COMMAND;
if (nodeTarget) {
if (isBrowserProxyUploadRequest({
method: methodRaw,
path,
body
}) && !nodeTarget.commands?.includes("browser.proxy.upload.v1")) {
const message = browserProxyUploadUnavailableMessage(nodeTarget.declaredCommands);
if (explicitNode || configuredNode) {
reject(ErrorCodes.UNAVAILABLE, message);
return;
}
logger.warn(`browser node ${nodeTarget.displayName ?? nodeTarget.nodeId} lacks ${BROWSER_PROXY_UPLOAD_COMMAND}; falling back to Gateway host`);
nodeTarget = null;
}
}
if (nodeTarget) {
try {
assertRequesterCurrent();
preparedUpload = await prepareBrowserProxyUploadRequest({
method: methodRaw,
path,
body,
signal: requestSignal
});
assertRequesterCurrent();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
reject(ErrorCodes.INVALID_REQUEST, message);
return;
}
if (preparedUpload.upload) proxyCommand = BROWSER_PROXY_UPLOAD_COMMAND;
}
if (nodeTarget && preparedUpload) {
const resolvedNodeTarget = nodeTarget;
const allowlist = resolveNodeCommandAllowlist(cfg, nodeTarget);
const allowed = isNodeCommandAllowed({
command: proxyCommand,
declaredCommands: nodeTarget.commands,
allowlist
});
if (!allowed.ok) {
const platform = nodeTarget.platform ?? "unknown";
const hint = `node command not allowed: ${allowed.reason} (platform: ${platform}, command: ${proxyCommand})`;
reject(ErrorCodes.INVALID_REQUEST, hint, { details: {
reason: allowed.reason,
command: proxyCommand
} });
return;
}
const { proxyTimeoutMs, nodeInvokeTimeoutMs } = resolveBrowserProxyTimeouts(timeoutMs);
const invokeProxy = async (proxyRequest, command = BROWSER_PROXY_COMMAND) => {
assertRequesterCurrent();
const invoked = await context.nodeRegistry.invoke({
nodeId: resolvedNodeTarget.nodeId,
command,
params: {
...proxyRequest,
timeoutMs: proxyTimeoutMs,
errorEnvelope: BROWSER_PROXY_ERROR_ENVELOPE
},
timeoutMs: nodeInvokeTimeoutMs,
signal: requestSignal,
isDispatchAuthorized: isRequesterCurrent,
idempotencyKey: crypto.randomUUID()
});
assertRequesterCurrent();
return invoked;
};
const proxyParams = {
method: methodRaw,
path,
query,
body: preparedUpload.body,
upload: preparedUpload.upload,
profile: resolveRequestedBrowserProfile({
query,
body
})
};
let res;
try {
res = await invokeProxy(proxyParams, proxyCommand);
} catch (error) {
reject(ErrorCodes.UNAVAILABLE, String(error));
return;
}
if (!explicitNode && !configuredNode && isBrowserControlHostUnavailableError(res.error) && !res.ok) logger.warn(`browser node ${nodeTarget.displayName ?? nodeTarget.nodeId} control host unavailable; falling back to Gateway host`);
else {
if (!respondUnavailableOnNodeInvokeError(respond, res)) return;
const payload = res.payloadJSON ? parseGatewayPayload(res.payloadJSON) : res.payload;
const failure = parseBrowserProxyFailure(payload);
if (failure) {
const { status, body: errorBody } = failure.error;
reject(status >= 500 ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST, errorBody.error, { details: errorBody });
return;
}
const proxy = payload && typeof payload === "object" ? payload : null;
if (!proxy || !("result" in proxy)) {
reject(ErrorCodes.UNAVAILABLE, "browser proxy failed");
return;
}
try {
const result = await persistBrowserProxyResultFiles(proxy.result, proxy.files);
assertRequesterCurrent();
const resolvedRoute = parseBrowserProxyRoute(proxy);
const scopedResult = tabScope ? await applyBrowserRequestTabScope({
scope: tabScope,
method: methodRaw,
path,
body,
result,
nodeTarget,
profile: resolvedRoute?.status === "resolved" ? resolvedRoute.profile : resolveRequestedBrowserProfile({
query,
body
}),
requestedProfile: resolveRequestedBrowserProfile({
query,
body
}),
assertCurrent: assertRequesterCurrent,
closeTab: async (targetId, profile) => {
const closed = await invokeProxy({
method: "DELETE",
path: `/tabs/${encodeURIComponent(targetId)}`,
query: { targetIdMode: "raw" },
profile
});
const closePayload = closed.payloadJSON ? parseGatewayPayload(closed.payloadJSON) : closed.payload;
if (!closed.ok || parseBrowserProxyFailure(closePayload) || !asNullableRecord(closePayload)?.result) throw new Error("Failed to close newly opened browser node tab");
}
}) : result;
assertRequesterCurrent();
respond(true, scopedResult);
} catch (error) {
reject(ErrorCodes.UNAVAILABLE, tabScope ? String(error) : "browser proxy file transfer failed");
}
return;
}
}
const ready = await startBrowserControlServiceFromConfig();
if (!ready) {
reject(ErrorCodes.UNAVAILABLE, await describeBrowserControlUnavailable());
return;
}
let dispatcher;
try {
dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
} catch (err) {
reject(ErrorCodes.UNAVAILABLE, String(err));
return;
}
const requesterSignal = connectionSignal;
const requester = client && requesterSignal ? {
connId: client.connId,
signal: requesterSignal,
isCurrent: () => client.invalidated !== true && !requesterSignal.aborted && hasCurrentClientAuthority?.() !== false
} : void 0;
let resolvedProfile;
const assertCurrent = async (profile) => {
assertRequesterCurrent();
if (profile) resolvedProfile = profile.name;
await assertDashboardCurrent?.(profile);
assertRequesterCurrent();
};
const dispatch = (timeoutSignal) => dispatcher.dispatch({
method: methodRaw,
path,
query,
body,
signal: timeoutSignal && requestSignal ? AbortSignal.any([timeoutSignal, requestSignal]) : timeoutSignal ?? requestSignal,
...requester ? { requester } : {},
assertCurrent
});
let result;
try {
await assertCurrent();
result = timeoutMs ? await withTimeout(dispatch, timeoutMs, "browser request") : await dispatch();
} catch (err) {
reject(ErrorCodes.UNAVAILABLE, String(err));
return;
}
if (result.status >= 400) {
const message = result.body && typeof result.body === "object" && "error" in result.body ? String(result.body.error) : `browser request failed (${result.status})`;
reject(result.status >= 500 ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST, message, { details: result.body });
return;
}
try {
respond(true, tabScope ? await applyBrowserRequestTabScope({
scope: tabScope,
method: methodRaw,
path,
body,
result: result.body,
profile: resolvedProfile,
requestedProfile: resolveRequestedBrowserProfile({
query,
body
}),
defaultProfile: ready.resolved.defaultProfile,
assertCurrent: assertRequesterCurrent,
closeTab: async (targetId, profile) => {
assertRequesterCurrent();
const closed = await dispatcher.dispatch({
method: "DELETE",
path: `/tabs/${encodeURIComponent(targetId)}`,
query: {
profile,
targetIdMode: "raw"
},
signal: requestSignal,
...requester ? { requester } : {},
assertCurrent
});
assertRequesterCurrent();
if (closed.status >= 400) throw new Error(`Failed to close newly opened browser tab (${closed.status})`);
}
}) : result.body);
} catch (error) {
reject(ErrorCodes.UNAVAILABLE, String(error));
}
}
const browserHandlers = { "browser.request": handleBrowserGatewayRequest };
//#endregion
//#region extensions/browser/src/node-host/invoke-browser.ts
function readOwnedTabCloseRequest(value) {
const record = asNullableRecord(value);
const ownership = asNullableRecord(record?.ownership);
if (ownership?.status !== "durable" || typeof ownership.nativeTargetId !== "string" || !ownership.nativeTargetId.trim() || typeof ownership.profileFingerprint !== "string" || !ownership.profileFingerprint.trim() || typeof ownership.browserInstanceFingerprint !== "string" || !ownership.browserInstanceFingerprint.trim()) throw new Error("INVALID_REQUEST: valid durable tab ownership required");
return { ownership: {
status: "durable",
nativeTargetId: ownership.nativeTargetId.trim(),
profileFingerprint: ownership.profileFingerprint.trim(),
browserInstanceFingerprint: ownership.browserInstanceFingerprint.trim()
} };
}
const BROWSER_PROXY_STATUS_TIMEOUT_MS = 750;
const BROWSER_PROXY_MAX_ENCODED_PAYLOAD_BYTES = 25165824;
function countBrowserProxyEncodedPayloadBytes(serialized) {
let bytes = Buffer.byteLength(serialized, "utf8") + 2;
for (const character of "\"\\") {
const code = character.charCodeAt(0);
let index = serialized.indexOf(character);
while (index !== -1) {
const end = Math.min(index + 128, serialized.length);
for (; index < end; index++) if (serialized.charCodeAt(index) === code) bytes++;
index = serialized.indexOf(character, index);
}
}
const wellFormed = toUSVString(serialized);
if (wellFormed !== serialized) {
for (let index = wellFormed.indexOf("�"); index !== -1; index = wellFormed.indexOf("�", index + 1)) if (serialized.charCodeAt(index) !== 65533) bytes += 3;
}
return bytes;
}
function resolveBrowserProxyConfig(cfg = loadBrowserConfigForRuntimeRefresh()) {
const proxy = cfg.nodeHost?.browserProxy;
if (proxy?.enabled === false) throw new Error("UNAVAILABLE: node browser proxy disabled");
return { allowProfiles: Array.isArray(proxy?.allowProfiles) ? normalizeStringEntries(proxy.allowProfiles) : [] };
}
let browserControlReady = null;
let admittedBrowserControlState = null;
function hasBrowserNodeHostWork() {
return hasBrowserControlWork() || hasBrowserProxyUploadWork();
}
async function ensureBrowserControlService() {
const current = getBrowserControlState();
if (current && current === admittedBrowserControlState) return;
if (browserControlReady) return browserControlReady;
const sharedStartup = (async () => {
const cfg = loadBrowserConfigForRuntimeRefresh();
if (!resolveBrowserConfig(cfg.browser, cfg).enabled) throw new Error(await describeBrowserControlUnavailable(cfg));
const started = await startBrowserControlServiceFromConfig();
if (!started) throw new Error(await describeBrowserControlUnavailable(cfg));
admittedBrowserControlState = started;
})().finally(() => {
if (browserControlReady === sharedStartup) browserControlReady = null;
});
browserControlReady = sharedStartup;
return sharedStartup;
}
function collectBrowserProxyPaths(payload) {
const paths = /* @__PURE__ */ new Set();
visitBrowserProxyFilePaths(payload, (filePath) => {
paths.add(filePath.trim());
assertBrowserProxyFileCountWithinLimit(paths.size);
});
return [...paths];
}
async function readBrowserProxyFiles(filePaths) {
const files = [];
let totalBytes = 0;
for (const filePath of filePaths) try {
const stat = await fs.stat(filePath).catch(() => null);
if (!stat || !stat.isFile()) throw new Error("file not found");
assertBrowserProxyFileBytesWithinLimits(stat.size, totalBytes + stat.size);
const buffer = await fs.readFile(filePath);
assertBrowserProxyFileBytesWithinLimits(buffer.byteLength, totalBytes + buffer.byteLength);
totalBytes += buffer.byteLength;
const mimeType = await detectMime({
buffer,
filePath
});
files.push({
path: filePath,
base64: buffer.toString("base64"),
mimeType
});
} catch (err) {
throw new Error(`browser proxy file read failed for ${filePath}: ${String(err)}`, { cause: err });
}
return files;
}
function isBrowserProxyTimeoutError(err) {
return String(err).includes("browser proxy request timed out");
}
function combineBrowserProxySignals(timeoutSignal, invocationSignal) {
if (timeoutSignal && invocationSignal) return AbortSignal.any([timeoutSignal, invocationSignal]);
return timeoutSignal ?? invocationSignal;
}
function isWsBackedBrowserProxyPath(path) {
return path === "/act" || path === "/download" || path === "/navigate" || path === "/pdf" || path === "/screenshot" || path === "/snapshot" || path === "/wait/download";
}
async function readBrowserProxyStatus(params) {
const query = params.profile ? { profile: params.profile } : {};
try {
const response = await withTimeout((signal) => params.dispatcher.dispatch({
method: "GET",
path: "/",
query,
signal
}), BROWSER_PROXY_STATUS_TIMEOUT_MS, "browser proxy status");
if (response.status >= 400 || !response.body || typeof response.body !== "object") return null;
const body = response.body;
return {
running: body.running,
transport: body.transport,
cdpHttp: body.cdpHttp,
cdpReady: body.cdpReady,
cdpUrl: body.cdpUrl
};
} catch {
return null;
}
}
function formatBrowserProxyTimeoutMessage(params) {
const parts = [`browser proxy timed out for ${params.method} ${params.path} after ${params.timeoutMs}ms`, params.wsBacked ? "ws-backed browser action" : "browser action"];
if (params.profile) parts.push(`profile=${params.profile}`);
if (params.status) {
const statusParts = [
`running=${String(params.status.running)}`,
`cdpHttp=${String(params.status.cdpHttp)}`,
`cdpReady=${String(params.status.cdpReady)}`
];
if (typeof params.status.transport === "string" && params.status.transport.trim()) statusParts.push(`transport=${params.status.transport}`);
if (typeof params.status.cdpUrl === "string" && params.status.cdpUrl.trim()) statusParts.push(`cdpUrl=${redactCdpUrl(params.status.cdpUrl)}`);
parts.push(`status(${statusParts.join(", ")})`);
}
return parts.join("; ");
}
async function runBrowserProxyCommand(paramsJSON, command = BROWSER_PROXY_COMMAND, invocationSignal) {
invocationSignal?.throwIfAborted();
ensureBrowserProxyUploadCleanup();
if (!paramsJSON) throw new Error("INVALID_REQUEST: paramsJSON required");
const params = JSON.parse(paramsJSON);
if (command === "browser.proxy" && params.upload !== void 0) throw new Error("INVALID_REQUEST: browser.proxy does not accept upload envelopes");
if (command === "browser.proxy.upload.v1" && !params.upload) throw new Error("INVALID_REQUEST: browser.proxy.upload.v1 requires an upload envelope");
if (command !== "browser.proxy" && command !== "browser.proxy.upload.v1") throw new Error(`INVALID_REQUEST: unsupported browser proxy command: ${command}`);
const pathValue = typeof params.path === "string" ? params.path.trim() : "";
if (!pathValue) throw new Error("INVALID_REQUEST: path required");
resolveBrowserProxyConfig();
const method = typeof params.method === "string" ? params.method.trim().toUpperCase() : "GET";
const path = normalizeBrowserRequestPath(pathValue);
if (method !== "GET" && method !== "POST" && method !== "DELETE") throw new Error("INVALID_REQUEST: method must be GET, POST, or DELETE");
if (path === "/__openclaw/session-tab/close-owned" && method !== "POST") throw new Error("INVALID_REQUEST: owned tab close requires POST");
await ensureBrowserControlService();
invocationSignal?.throwIfAborted();
const cfg = loadBrowserConfigForRuntimeRefresh();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
let body = params.body;
const requestedProfile = resolveRequestedBrowserProfile({
query: params.query,
body,
profile: params.profile
}) ?? "";
const effectiveProfile = path === "/profiles" ? "" : requestedProfile || resolved.defaultProfile;
const effectiveResolvedProfile = effectiveProfile ? resolveProfile(resolved, effectiveProfile) : null;
const route = effectiveResolvedProfile ? {
status: "resolved",
profile: effectiveProfile,
driver: effectiveResolvedProfile.driver
} : { status: "unavailable" };
const includeRoute = params.errorEnvelope === BROWSER_PROXY_ERROR_ENVELOPE;
const allowedProfiles = resolveBrowserProxyConfig(cfg).allowProfiles;
if (isPersistentBrowserProfileMutation(method, path)) throw new Error("INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles");
if (isBrowserHostLocalRoute(method, path)) throw new Error("INVALID_REQUEST: browser.proxy cannot run host-local browser routes");
const assertCurrent = (profile) => {
invocationSignal?.throwIfAborted();
const current = resolveBrowserProxyConfig();
const selected = profile?.name || effectiveProfile || requestedProfile;
if ((path !== "/profiles" || selected) && current.allowProfiles.length > 0 && (!selected || !current.allowProfiles.includes(selected.trim()))) throw new Error("INVALID_REQUEST: browser profile not allowed");
};
assertCurrent();
const timeoutMs = resolveBrowserProxyTimeoutMs(params.timeoutMs);
const deadlineAt = Date.now() + timeoutMs;
const timeoutDetails = {
method,
path,
profile: requestedProfile || resolved.defaultProfile || void 0,
timeoutMs,
wsBacked: isWsBackedBrowserProxyPath(path)
};
const query = {};
const rawQuery = params.query ?? {};
for (const [key, value] of Object.entries(rawQuery)) {
if (value === void 0 || value === null) continue;
query[key] = typeof value === "string" ? value : String(value);
}
if (effectiveProfile || requestedProfile) query.profile = effectiveProfile || requestedProfile;
if (path === "/__openclaw/session-tab/close-owned") {
const request = readOwnedTabCloseRequest(body);
const liveResolved = getBrowserControlState()?.resolved ?? resolved;
const profile = resolveProfile(liveResolved, effectiveProfile);
assertCurrent(profile ?? void 0);
const result = profile?.cdpUrl && effectiveProfile ? await closeTrackedCdpTarget({
profileName: effectiveProfile,
cdpUrl: profile.cdpUrl,
nativeTargetId: request.ownership.nativeTargetId,
expectedProfileFingerprint: request.ownership.profileFingerprint,
expectedBrowserInstanceFingerprint: request.ownership.browserInstanceFingerprint,
timeoutMs: liveResolved.remoteCdpTimeoutMs,
ssrfPolicy: resolveCdpControlPolicy(profile, liveResolved.ssrfPolicy),
signal: invocationSignal,
closeIfCurrent: async (dispatch) => {
assertCurrent(profile);
return await dispatch();
}
}) : { status: "ownership-mismatch" };
return JSON.stringify({
result,
...includeRoute ? { route } : {}
});
}
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
let stagedUpload;
try {
stagedUpload = await withTimeout((timeoutSignal) => stageBrowserProxyUploadRequest({
method,
path,
body,
upload: params.upload,
signal: combineBrowserProxySignals(timeoutSignal, invocationSignal)
}), timeoutMs, "browser proxy request");
} catch (err) {
if (!isBrowserProxyTimeoutError(err)) throw err;
throw new Error(formatBrowserProxyTimeoutMessage({
...timeoutDetails,
status: null
}), { cause: err });
}
body = stagedUpload.body;
try {
assertCurrent();
} catch (error) {
await discardStagedBrowserProxyUpload(stagedUpload);
throw error;
}
const remainingTimeoutMs = deadlineAt - Date.now();
if (remainingTimeoutMs <= 0) {
await discardStagedBrowserProxyUpload(stagedUpload);
throw new Error(formatBrowserProxyTimeoutMessage({
...timeoutDetails,
status: null
}));
}
let response;
try {
response = await withTimeout((timeoutSignal) => dispatcher.dispatch({
method,
path,
query,
body,
signal: combineBrowserProxySignals(timeoutSignal, invocationSignal),
assertCurrent: async (profile) => {
assertCurrent(profile);
}
}), remainingTimeoutMs, "browser proxy request");
} catch (err) {
if (!isBrowserProxyTimeoutError(err)) throw err;
const profileForStatus = requestedProfile || resolved.defaultProfile;
const status = await readBrowserProxyStatus({
dispatcher,
profile: path === "/profiles" ? void 0 : profileForStatus
});
throw new Error(formatBrowserProxyTimeoutMessage({
...timeoutDetails,
profile: path === "/profiles" ? void 0 : profileForStatus || void 0,
status
}), { cause: err });
}
if (response.status >= 400) {
await discardStagedBrowserProxyUpload(stagedUpload);
if (params.errorEnvelope === "browser-v1") return JSON.stringify(createBrowserProxyFailure(response.status, response.body, route));
const detail = response.body && typeof response.body === "object" && "error" in response.body ? String(response.body.error).trim() : "";
throw new Error(detail ? `${response.status}: ${detail}` : `HTTP ${response.status}`);
}
const result = response.body;
if (allowedProfiles.length > 0 && path === "/profiles") {
const obj = typeof result === "object" && result !== null ? result : {};
obj.profiles = (Array.isArray(obj.profiles) ? obj.profiles : []).filter((entry) => {
if (!entry || typeof entry !== "object") return false;
const name = entry.name;
return typeof name === "string" && allowedProfiles.includes(name);
});
}
const paths = collectBrowserProxyPaths(result);
const files = paths.length > 0 ? await readBrowserProxyFiles(paths) : void 0;
const payload = {
result,
...files ? { files } : {},
...includeRoute ? { route } : {}
};
const serialized = JSON.stringify(payload);
if (countBrowserProxyEncodedPayloadBytes(serialized) > BROWSER_PROXY_MAX_ENCODED_PAYLOAD_BYTES) throw new Error("browser proxy payload exceeds 24 MiB encoded limit");
return serialized;
}
//#endregion
//#region extensions/browser/src/plugin-service.ts
const EAGER_BROWSER_CONTROL_SERVICE_ENV = "OPENCLAW_EAGER_BROWSER_CONTROL_SERVER";
const UNSAFE_BROWSER_CONTROL_OVERRIDE_SPECIFIER = /^(?:data|http|https|node):/i;
function validateBrowserControlOverrideSpecifier(specifier) {
const trimmed = specifier.trim();
if (UNSAFE_BROWSER_CONTROL_OVERRIDE_SPECIFIER.test(trimmed)) throw new Error(`Refusing unsafe browser control override specifier: ${trimmed}`);
return trimmed;
}
function createBrowserPluginService(params) {
let handle = null;
return {
id: "browser-control",
start: async () => {
if (!isTruthyEnvValue(process.env[EAGER_BROWSER_CONTROL_SERVICE_ENV])) return;
if (handle) return;
handle = await startLazyPluginServiceModule({
skipEnvVar: "OPENCLAW_SKIP_BROWSER_CONTROL_SERVER",
overrideEnvVar: "OPENCLAW_BROWSER_CONTROL_MODULE",
validateOverrideSpecifier: validateBrowserControlOverrideSpecifier,
loadDefaultModule: async () => await import("./server-BmAty9i9.mjs"),
startExportNames: ["startBrowserControlServiceFromConfig", "startBrowserControlServerFromConfig"],
stopExportNames: ["stopBrowserControlService", "stopBrowserControlServer"]
});
},
stop: async () => {
const current = handle;
if (current) {
await current.stop();
if (handle === current) handle = null;
return;
}
await params.stopOnDemand();
}
};
}
//#endregion
export { handleBrowserGatewayRequest as a, browserHandlers as i, hasBrowserNodeHostWork as n, withTimeout as o, runBrowserProxyCommand as r, createBrowserTool as s, createBrowserPluginService as t };