openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
3,070 lines • 115 kB
JavaScript
import { F as resolveTimerTimeoutMs, N as resolveOptionalIntegerOption, a as addTimerTimeoutGraceMs, m as clampTimerTimeoutMs, n as MAX_TIMER_TIMEOUT_MS, p as clampPositiveTimerTimeoutMs } from "./number-coercion-CLj0HTDM.js";
import { r as asNullableRecord } from "./record-coerce-DItp3I4t.js";
import { g as readStringValue, l as normalizeOptionalString } from "./string-coerce-CIXf7egm.js";
import { d as normalizeStringEntries } from "./string-normalization-DsCfAx8q.js";
import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.js";
import { n as isTruthyEnvValue } from "./env-M3R40TOb.js";
import { n as getRuntimeConfig } from "./io.runtime-B9iJRs3w.js";
import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js";
import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js";
import { t as ErrorCodes } from "./gateway-error-details-w0nAGBBp.js";
import { d as errorShape } from "./error-codes-Bo8q2D1o.js";
import { n as detectMime } from "./mime-CVpcq9ju.js";
import { f as saveMediaBuffer } from "./store-Ph57PZ9P.js";
import { n as estimateBase64DecodedBytes, t as canonicalizeBase64 } from "./base64-Vw7DZYSc.js";
import { t as jsonResult } from "./tool-results-BCM3fdVS.js";
import { d as readPositiveIntegerParam, h as readToolStringParam, i as imageResultFromFile, l as readNonNegativeIntegerParam } from "./common-Bm6UTDDA.js";
import { t as DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS } from "./tool-result-limits-CFk9DkYv.js";
import { a as wrapExternalContent, i as truncateSanitizedExternalContent } from "./external-content-CpqslxXH.js";
import { t as callGatewayTool } from "./gateway-CB9m4Gcq.js";
import { i as resolveNodeIdFromList, t as listNodes } from "./nodes-utils-CL3pooh5.js";
import { l as resolveNodeCommandAllowlist, o as isNodeCommandAllowed } from "./node-command-policy-H4FpS0aA.js";
import { C as hasGatewayToolRoutingContext } from "./sessions-helpers-B2gzzr60.js";
import "./runtime-env-0Q-gCyUb.js";
import "./number-runtime-Cy4drVnh.js";
import "./string-coerce-runtime-GQa0ehRA.js";
import "./media-runtime--XhyE_WP.js";
import "./media-store-4cRisQA1.js";
import { t as startLazyPluginServiceModule } from "./plugin-runtime-Cl_BX05Z.js";
import "./security-runtime-Ckf0kc0h.js";
import { t as parseGatewayPayload } from "./server-json-CUC0gj3q.js";
import { t as respondUnavailableOnNodeInvokeError } from "./nodes.helpers-7uACMTmw.js";
import { n as redactCdpUrl } from "./browser-cdp-DfRHhv02.js";
import { t as describeImageFile } from "./runtime-D2rkI188.js";
import "./param-readers-BElxWJzG.js";
import "./text-utility-runtime-BjzvUG99.js";
import { a as applyBrowserTabToolBinding, c as BROWSER_PROXY_UPLOAD_COMMAND, i as describeBrowserTool, l as browserProxyUploadUnavailableMessage, n as createBrowserToolSchema, o as parseBrowserTabToolBinding, r as resolveBrowserToolCapabilities, s as BROWSER_PROXY_COMMAND, t as BrowserToolOutputSchema } from "./browser-tool.schema-CFr5Al89.js";
import { i as DEFAULT_BROWSER_ACTION_TIMEOUT_MS, r as DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants-DKjTvIBP.js";
import { c as resolveBrowserActRequestTimeoutMs, i as BROWSER_ACTION_TRANSPORT_SLACK_MS, l as resolveBrowserNavigationTimeoutMs } from "./act-policy-DHamxe2c.js";
import { a as resolveProfile, r as resolveBrowserConfig } from "./config-BosO4Zbt.js";
import { t as getBrowserProfileCapabilities } from "./profile-capabilities-B30ig3n7.js";
import { E as parseBrowserSessionTabCloseResult } from "./session-tab-store-dEfDhf6J.js";
import "./server-middleware-CwDqkWQc.js";
import "./sdk-security-runtime-De1urFYo.js";
import { O as finalizeRoleSnapshot, k as findRoleSnapshotLineRef, z as parseBrowserNavigationUrl } from "./chrome-CtUCaDWQ.js";
import { r as closeTrackedCdpTarget } from "./cdp.helpers-CLv_y5y4.js";
import "./sdk-config-DCcWkGeD.js";
import { i as resolveExistingUploadPaths } from "./paths-CA_QMzCv.js";
import "./errors-CesLEJB8.js";
import "./runtime-lifecycle-DhKurfgJ.js";
import { a as untrackSessionBrowserTab, i as trackSessionBrowserTab, r as touchSessionBrowserTab } from "./session-tab-registry-7B39oGZ2.js";
import "./bridge-server-BAaCS-Bc.js";
import { i as withTimeout } from "./control-auth-DxPMmxz-.js";
import { n as resolveCdpControlPolicy } from "./cdp-reachability-policy-2SFcQJH2.js";
import { a as loadBrowserConfigForRuntimeRefresh } from "./server-context-CSIC6Xps.js";
import "./trash-BWuUMhs8.js";
import "./sdk-setup-tools-CklxTWqk.js";
import { a as stageBrowserProxyUploadRequest, c as BROWSER_PROXY_OWNED_TAB_CLOSE_PATH, d as createBrowserProxyFailure, f as parseBrowserProxyFailure, i as prepareBrowserProxyUploadRequest, l as assertBrowserProxyFileBytesWithinLimits, m as visitBrowserProxyFilePaths, n as ensureBrowserProxyUploadCleanup, o as BROWSER_PROXY_ERROR_ENVELOPE, p as parseBrowserProxyRoute, r as isBrowserProxyUploadRequest, s as BROWSER_PROXY_MAX_FILE_BYTES, t as discardStagedBrowserProxyUpload, u as assertBrowserProxyFileCountWithinLimit } from "./browser-proxy-upload-B56yPZQ0.js";
import { S as withBaseUrl, _ as browserTabs, a as browserDoctor, b as fetchBrowserJson, 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, v as normalizeBrowserTabsResult, x as buildProfileQuery, y as BrowserServiceError } from "./client-x-IUPjVX.js";
import { i as normalizeBrowserScreenshot, n as DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES } from "./routes-g8jdcRKn.js";
import { t as parseSystemProfileDomains } from "./system-profile-domains-BQKUrgj5.js";
import { a as resolveRequestedBrowserProfile, i as normalizeBrowserRequestPath, n as isBrowserHostLocalRoute, r as isPersistentBrowserProfileMutation, t as createBrowserRouteDispatcher } from "./dispatcher-wCUy7gxn.js";
import "./form-fields-Deu-DeE_.js";
import { r as getBrowserControlState, t as createBrowserControlContext } from "./browser-control-state-BwkjlfAE.js";
import { t as startBrowserControlServiceFromConfig } from "./control-service-DCNNOWdd.js";
import path from "node:path";
import fs, { readFile } from "node:fs/promises";
import crypto from "node:crypto";
//#region extensions/browser/src/browser-node-fallback.ts
/**
* Browser-node fallback classification.
*
* Only the node host's explicit pre-dispatch reachability failure is safe to
* retry on the Gateway host. Other failures may follow a mutating action.
*/
const BROWSER_CONTROL_HOST_UNREACHABLE = /\bbrowser control host is not reachable\b/i;
function isBrowserControlHostUnavailableError(value) {
const seen = /* @__PURE__ */ new Set();
const visit = (candidate, depth) => {
if (typeof candidate === "string") return BROWSER_CONTROL_HOST_UNREACHABLE.test(candidate);
if (!candidate || typeof candidate !== "object" || depth > 3 || seen.has(candidate)) return false;
seen.add(candidate);
const record = candidate;
if (typeof record.message === "string" && BROWSER_CONTROL_HOST_UNREACHABLE.test(record.message)) return true;
return [
record.error,
record.cause,
record.details,
record.nodeError
].some((entry) => visit(entry, depth + 1));
};
return visit(value, 0);
}
//#endregion
//#region extensions/browser/src/browser/client-actions-core.ts
/**
* Browser client action helpers.
*
* Wraps browser-control action endpoints for navigation, dialog/file hooks,
* screenshots, and element actions used by the Browser agent tool.
*/
function normalizePositiveTimeoutMs$1(value) {
return clampPositiveTimerTimeoutMs(value);
}
function resolveBrowserOperationRequestTimeoutMs(timeoutMs) {
const operationTimeoutMs = normalizePositiveTimeoutMs$1(timeoutMs) ?? 12e4;
return addTimerTimeoutGraceMs(operationTimeoutMs, 5e3) ?? 1;
}
async function postDownloadRequest(baseUrl, route, body, profile, timeoutMs, signal) {
const q = buildProfileQuery(profile);
return await fetchBrowserJson(withBaseUrl(baseUrl, `${route}${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
timeoutMs: resolveBrowserOperationRequestTimeoutMs(timeoutMs),
signal
});
}
/** Navigate a browser tab through the control server. */
async function browserNavigate(baseUrl, opts) {
const q = buildProfileQuery(opts.profile);
const timeoutMs = resolveBrowserNavigationTimeoutMs(opts.timeoutMs);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/navigate${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: opts.url,
targetId: opts.targetId,
timeoutMs
}),
timeoutMs: resolveBrowserOperationRequestTimeoutMs(timeoutMs),
signal: opts.signal
});
}
/** Arm a one-shot browser dialog handler. */
async function browserArmDialog(baseUrl, opts) {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/hooks/dialog${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
accept: opts.accept,
promptText: opts.promptText,
dialogId: opts.dialogId,
targetId: opts.targetId,
timeoutMs: opts.timeoutMs
}),
timeoutMs: resolveBrowserOperationRequestTimeoutMs(opts.timeoutMs),
signal: opts.signal
});
}
/** Arm or execute a browser file chooser upload. */
async function browserArmFileChooser(baseUrl, opts) {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/hooks/file-chooser${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
paths: opts.paths,
ref: opts.ref,
inputRef: opts.inputRef,
element: opts.element,
targetId: opts.targetId,
timeoutMs: opts.timeoutMs
}),
timeoutMs: resolveBrowserOperationRequestTimeoutMs(opts.timeoutMs),
signal: opts.signal
});
}
/** Wait for the next managed browser download and save it under the guarded download root. */
async function browserWaitForDownload(baseUrl, opts) {
return await postDownloadRequest(baseUrl, "/wait/download", {
targetId: opts.targetId,
path: opts.path,
timeoutMs: opts.timeoutMs
}, opts.profile, opts.timeoutMs, opts.signal);
}
/** Click a snapshot ref and save its download under the guarded download root. */
async function browserDownload(baseUrl, opts) {
return await postDownloadRequest(baseUrl, "/download", {
targetId: opts.targetId,
ref: opts.ref,
path: opts.path,
timeoutMs: opts.timeoutMs
}, opts.profile, opts.timeoutMs, opts.signal);
}
/** Execute one normalized browser action request. */
async function browserAct(baseUrl, req, opts) {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/act${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
timeoutMs: resolveTimerTimeoutMs(opts?.timeoutMs, resolveBrowserActRequestTimeoutMs(req)),
signal: opts?.signal
});
}
/** Capture a screenshot through the browser control server. */
async function browserScreenshotAction(baseUrl, opts) {
const q = buildProfileQuery(opts.profile);
const effectiveTimeoutMs = clampPositiveTimerTimeoutMs(opts.timeoutMs) ?? 2e4;
return await fetchBrowserJson(withBaseUrl(baseUrl, `/screenshot${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
fullPage: opts.fullPage,
ref: opts.ref,
element: opts.element,
type: opts.type,
labels: opts.labels,
timeoutMs: effectiveTimeoutMs
}),
timeoutMs: effectiveTimeoutMs,
signal: opts.signal
});
}
//#endregion
//#region extensions/browser/src/browser/client-actions-observe.ts
function buildQuerySuffix(params) {
const query = new URLSearchParams();
for (const [key, value] of params) {
if (typeof value === "boolean") {
query.set(key, String(value));
continue;
}
if (typeof value === "string" && value.length > 0) query.set(key, value);
}
const encoded = query.toString();
return encoded.length > 0 ? `?${encoded}` : "";
}
/** Read browser console messages for a tab. */
async function browserConsoleMessages(baseUrl, opts = {}) {
const suffix = buildQuerySuffix([
["level", opts.level],
["targetId", opts.targetId],
["profile", opts.profile]
]);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/console${suffix}`), {
timeoutMs: 2e4,
signal: opts.signal
});
}
/** Read the collected network request log for a tab. */
async function browserRequests(baseUrl, opts = {}) {
const suffix = buildQuerySuffix([
["filter", opts.filter],
["clear", opts.clear],
["targetId", opts.targetId],
["profile", opts.profile]
]);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/requests${suffix}`), {
timeoutMs: 2e4,
signal: opts.signal
});
}
/** Read the collected page error log for a tab. */
async function browserErrors(baseUrl, opts = {}) {
const suffix = buildQuerySuffix([
["clear", opts.clear],
["targetId", opts.targetId],
["profile", opts.profile]
]);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/errors${suffix}`), {
timeoutMs: 2e4,
signal: opts.signal
});
}
/** Read bounded visible text without executing page-supplied code. */
async function browserPageText(baseUrl, opts) {
const suffix = buildQuerySuffix([
["targetId", opts.targetId],
["selector", opts.selector],
["maxChars", String(opts.maxChars)],
["profile", opts.profile]
]);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/text${suffix}`), {
timeoutMs: 2e4,
signal: opts.signal
});
}
/** Apply one of the browser control service's existing emulation settings. */
async function browserEmulateSetting(baseUrl, opts) {
return await fetchBrowserJson(withBaseUrl(baseUrl, `/set/${opts.setting}${buildProfileQuery(opts.profile)}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts.body),
timeoutMs: 2e4,
signal: opts.signal
});
}
/** Save the current page as PDF through browser control. */
async function browserPdfSave(baseUrl, opts = {}) {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/pdf${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
timeoutMs: 2e4,
signal: opts.signal
});
}
//#endregion
//#region extensions/browser/src/browser/proxy-files.ts
/**
* Browser proxy file helpers.
*
* Persists files returned by node-hosted browser proxy calls and rewrites
* proxied result paths to local saved media paths.
*/
const INVALID_BROWSER_PROXY_FILE_ENVELOPE = "browser proxy returned an invalid file envelope";
function invalidBrowserProxyFileEnvelope() {
throw new Error(INVALID_BROWSER_PROXY_FILE_ENVELOPE);
}
function collectBrowserProxyResultPaths(result) {
const paths = /* @__PURE__ */ new Set();
visitBrowserProxyFilePaths(result, (filePath) => {
paths.add(filePath);
assertBrowserProxyFileCountWithinLimit(paths.size);
});
return paths;
}
function validateBrowserProxyFiles(result, files) {
const referencedPaths = collectBrowserProxyResultPaths(result);
const candidates = files === void 0 ? [] : files;
if (!Array.isArray(candidates)) return invalidBrowserProxyFileEnvelope();
assertBrowserProxyFileCountWithinLimit(candidates.length);
const validated = [];
for (const value of candidates) {
const file = asNullableRecord(value);
if (!file || typeof file.path !== "string" || !file.path.trim() || typeof file.base64 !== "string" || file.mimeType !== void 0 && typeof file.mimeType !== "string" || !referencedPaths.delete(file.path)) return invalidBrowserProxyFileEnvelope();
validated.push({
path: file.path,
base64: file.base64,
...file.mimeType === void 0 ? {} : { mimeType: file.mimeType }
});
}
if (referencedPaths.size > 0) return invalidBrowserProxyFileEnvelope();
return validated;
}
function decodeBrowserProxyFileBase64(file, totalBytes) {
const estimatedBytes = estimateBase64DecodedBytes(file.base64);
assertBrowserProxyFileBytesWithinLimits(estimatedBytes, totalBytes + estimatedBytes);
const canonicalBase64 = file.base64 === "" ? "" : canonicalizeBase64(file.base64);
if (canonicalBase64 === void 0) throw new Error("browser proxy file contains malformed base64 data");
const buffer = Buffer.from(canonicalBase64, "base64");
assertBrowserProxyFileBytesWithinLimits(buffer.byteLength, totalBytes + buffer.byteLength);
return buffer;
}
/** Validate, persist, and rewrite every route-owned file in a node result. */
async function persistBrowserProxyResultFiles(result, files) {
const validatedFiles = validateBrowserProxyFiles(result, files);
if (validatedFiles.length === 0) return result;
const decoded = [];
let totalBytes = 0;
for (const file of validatedFiles) {
const buffer = decodeBrowserProxyFileBase64(file, totalBytes);
totalBytes += buffer.byteLength;
decoded.push({
file,
buffer
});
}
const mapping = /* @__PURE__ */ new Map();
for (const { file, buffer } of decoded) {
const saved = await saveMediaBuffer(buffer, file.mimeType, "browser", BROWSER_PROXY_MAX_FILE_BYTES);
mapping.set(file.path, saved.path);
}
visitBrowserProxyFilePaths(result, (filePath) => mapping.get(filePath));
return result;
}
//#endregion
//#region extensions/browser/src/browser/screenshot-sharing.ts
/** Stages a bounded screenshot copy in the sandbox-authorized outbound store. */
async function stageBrowserScreenshotForSharing(filePath, maxDimensionPx) {
const source = await readFile(filePath);
const normalized = await normalizeBrowserScreenshot(source, {
maxSide: maxDimensionPx ?? 2e3,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES
});
return (await saveMediaBuffer(normalized.buffer, normalized.contentType, "outbound", DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, path.basename(filePath))).path;
}
//#endregion
//#region extensions/browser/src/browser-tool.runtime.ts
/**
* Runtime dependency barrel for the Browser agent tool.
*
* Kept separate from browser-tool.ts so tests can mock the tool boundary while
* production still imports SDK helpers and browser client actions lazily.
*/
/** Resolve global image downscaling for screenshots returned to agent tools. */
function resolveRuntimeImageSanitization() {
const maxDimensionPx = resolveOptionalIntegerOption(getRuntimeConfig().agents?.defaults?.imageMaxDimensionPx, { min: 1 });
if (maxDimensionPx === void 0) return;
return { maxDimensionPx };
}
//#endregion
//#region extensions/browser/src/browser-node-proxy.ts
const logger$1 = createSubsystemLogger("browser");
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS$1 = 2e4;
const BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS = 5e3;
var BrowserNodeSafeFallbackError = class extends Error {
constructor(message, cause) {
super(message, cause === void 0 ? void 0 : { cause });
this.name = "BrowserNodeSafeFallbackError";
}
};
function unwrapBrowserProxyPayload(payload) {
if (payload?.payload !== void 0) return payload.payload;
if (typeof payload?.payloadJSON !== "string" || !payload.payloadJSON.trim()) return null;
try {
return JSON.parse(payload.payloadJSON);
} catch {
return null;
}
}
async function callBrowserProxy(params) {
const proxyTimeoutMs = Math.min(resolveTimerTimeoutMs(params.timeoutMs, DEFAULT_BROWSER_PROXY_TIMEOUT_MS$1), MAX_TIMER_TIMEOUT_MS - 2 * BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS);
const nodeInvokeTimeoutMs = addTimerTimeoutGraceMs(proxyTimeoutMs, BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS) ?? proxyTimeoutMs;
const gatewayTimeoutMs = addTimerTimeoutGraceMs(nodeInvokeTimeoutMs, BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS) ?? nodeInvokeTimeoutMs;
if (isBrowserProxyUploadRequest(params) && !params.declaredCommands.includes("browser.proxy.upload.v1")) throw new BrowserNodeSafeFallbackError(browserProxyUploadUnavailableMessage(params.pendingDeclaredCommands));
const preparedUpload = await prepareBrowserProxyUploadRequest({
method: params.method,
path: params.path,
body: params.body,
signal: params.signal
});
const command = preparedUpload.upload ? BROWSER_PROXY_UPLOAD_COMMAND : BROWSER_PROXY_COMMAND;
let payload;
try {
payload = await callGatewayTool("node.invoke", { timeoutMs: gatewayTimeoutMs }, {
nodeId: params.nodeId,
command,
timeoutMs: nodeInvokeTimeoutMs,
params: {
method: params.method,
path: params.path,
query: params.query,
body: preparedUpload.body,
upload: preparedUpload.upload,
timeoutMs: proxyTimeoutMs,
profile: params.profile,
errorEnvelope: BROWSER_PROXY_ERROR_ENVELOPE
},
idempotencyKey: crypto.randomUUID()
}, {
scopes: ["operator.admin"],
...params.signal ? { signal: params.signal } : {}
});
} catch (error) {
if (params.allowAutomaticHostFallback && isBrowserControlHostUnavailableError(error)) throw new BrowserNodeSafeFallbackError("browser node control host unavailable", error);
throw error;
}
const parsed = unwrapBrowserProxyPayload(payload);
if (!parsed || typeof parsed !== "object" || !("result" in parsed) && !parseBrowserProxyFailure(parsed)) {
const selectedNode = truncateUtf16Safe(params.nodeLabel?.trim() || params.nodeId, 256);
throw new Error(`Browser proxy returned an invalid response from node ${JSON.stringify(selectedNode)}. Retry with action=status target="host" to check Gateway host browser control.`);
}
return parsed;
}
async function callLocalBrowserControl(params) {
const url = new URL(params.path, "http://localhost");
for (const [key, value] of Object.entries(params.query ?? {})) if (value !== void 0) url.searchParams.set(key, String(value));
if (params.profile) url.searchParams.set("profile", params.profile);
return await fetchBrowserJson(`${url.pathname}${url.search}`, {
method: params.method,
body: params.body === void 0 ? void 0 : JSON.stringify(params.body),
timeoutMs: params.timeoutMs,
signal: params.signal
});
}
function createBrowserNodeProxyRequest(params) {
let target = params.allowAutomaticHostFallback ? "auto" : "node";
let route;
const dispatch = async (request) => {
const requestWithSignal = request.signal || params.signal ? {
...request,
signal: request.signal ?? params.signal
} : request;
if (target === "host") return await callLocalBrowserControl(requestWithSignal);
try {
const proxy = await callBrowserProxy({
nodeId: params.nodeTarget.nodeId,
nodeLabel: params.nodeTarget.label,
declaredCommands: params.nodeTarget.commands ?? [],
pendingDeclaredCommands: params.nodeTarget.pendingDeclaredCommands ?? [],
allowAutomaticHostFallback: target === "auto",
...requestWithSignal
});
target = "node";
route = parseBrowserProxyRoute(proxy);
const failure = parseBrowserProxyFailure(proxy);
if (failure) {
const { status, body } = failure.error;
throw new BrowserServiceError(body.error, body, status);
}
if (!("result" in proxy)) throw new Error("Browser proxy returned a failure without an error payload.");
return await persistBrowserProxyResultFiles(proxy.result, proxy.files);
} catch (error) {
if (target !== "auto" || !(error instanceof BrowserNodeSafeFallbackError)) throw error;
target = "host";
route = void 0;
logger$1.warn(`browser node ${params.nodeTarget.label ?? params.nodeTarget.nodeId} unavailable before dispatch (${error.message}); falling back to Gateway host`);
return await callLocalBrowserControl(requestWithSignal);
}
};
return Object.assign(dispatch, {
isHostFallbackActive: () => target === "host",
route: () => route
});
}
function createBrowserNodeSessionTabRoute(nodeTarget) {
return {
kind: "node-proxy",
nodeId: nodeTarget.nodeId,
closeTarget: async (tab) => {
const cleanupProxy = createBrowserNodeProxyRequest({
nodeTarget,
allowAutomaticHostFallback: false
});
if (tab.ownership?.status === "durable") return parseBrowserSessionTabCloseResult(await cleanupProxy({
method: "POST",
path: BROWSER_PROXY_OWNED_TAB_CLOSE_PATH,
body: { ownership: tab.ownership },
profile: tab.profile
}));
await cleanupProxy({
method: "DELETE",
path: `/tabs/${encodeURIComponent(tab.targetId)}`,
query: { targetIdMode: "raw" },
profile: tab.profile
});
return { status: "closed" };
}
};
}
//#endregion
//#region extensions/browser/src/browser-tool-session-tabs.ts
/**
* Session tracking for tabs created through the browser tool.
*/
function readOpenedTab(result) {
if (!result || typeof result !== "object" || Array.isArray(result)) return { aliases: [] };
const opened = result;
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) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const { ownership: _ownership, resolvedProfile: _resolvedProfile, ...agentVisible } = value;
return agentVisible;
}
async function trackOpenedBrowserTab(params) {
const opened = readOpenedTab(params.result);
const profile = opened.profile ?? params.fallbackProfile;
try {
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
});
} 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)
};
};
return {
touch: (targetId) => {
if (targetId) params.registry.touchSessionBrowserTab(identity(targetId));
},
untrack: (targetId) => {
if (targetId) 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,
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;
const lines = text.split("\n");
let changed = false;
for (const [i, line] of lines.entries()) {
const leading = line.length - line.trimStart().length;
const rest = line.slice(leading);
if (/^MEDIA:/i.test(rest)) {
lines[i] = `${line.slice(0, leading)}[neutralized] ${rest}`;
changed = true;
}
}
return changed ? lines.join("\n") : text;
}
//#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;
const 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();
while (wrapped.truncated && records.length > 0) {
records.shift();
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");
}
function withRoleRefsFallback(snapshotQuery) {
return {
...snapshotQuery,
refs: "role"
};
}
/** 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" ? "efficient" : !formatExplicit && format !== "aria" && 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 = format === "ai" ? hasMaxChars ? maxChars : mode === "efficient" ? void 0 : DEFAULT_AI_SNAPSHOT_MAX_CHARS : hasMaxChars ? maxChars : 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) => proxyRequest ? await proxyRequest({
method: "GET",
path: "/snapshot",
profile,
query,
timeoutMs: snapshotTimeoutMs
}) : await browserSnapshot(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(withRoleRefsFallback(snapshotQuery));
}
params.onTabActivity?.(readStringValue(snapshot.targetId) ?? targetId);
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;
const result = {
content: [{
type: "text",
text: wrapped.text
}],
details: {
ok: snapshot.ok,
format: snapshot.format,
targetId: snapshot.targetId,
url: snapshot.url,
matchCount,
stats: filtered.stats,
refs: filtered.stats.refs,
...snapshot.format === "ai" && snapshot.newElements !== void 0 ? { newElements } : {},
truncated: snapshot.truncated || wrapped.truncated || void 0,
...snapshot.browserState !== void 0 ? { browserState: snapshot.browserState } : {},
...snapshot.format === "ai" ? {
labels: snapshot.labels,
labelsCount: snapshot.labelsCount,
labelsSkipped: snapshot.labelsSkipped,
annotations: snapshot.annotations,
imagePath: snapshot.imagePath,
imageType: snapshot.imageType,
refsFallback
} : {},
externalContent: {
untrusted: true,
source: "browser",
kind: "snapshot",
format: snapshot.format,
wrapped: true
}
}
};
if (labels && snapshot.format === "ai" && snapshot.imagePath) return await imageResultFromFile({
label: "browser:snapshot",
path: snapshot.imagePath,
extraText: result.content.filter((item) => item.type === "text").map((item) => item.text).join("\n"),
details: {
...result.details,
media: { outbound: false }
},
imageSanitization: resolveRuntimeImageSanitization()
});
return result;
}
if (snapshot.format === "ai") {
const dialogStateFields = {
...snapshot.blockedByDialog ? { blockedByDialog: true } : {},
...snapshot.browserState !== void 0 ? { browserState: snapshot.browserState } : {}
};
if (snapshot.blockedByDialog) {
const wrapped = wrapBrowserExternalJson({
kind: "snapshot",
payload: {
format: snapshot.format,
targetId: snapshot.targetId,
url: snapshot.url,
...dialogStateFields
}
});
return {
content: [{
type: "text",
text: wrapped.wrappedText
}],
details: {
...wrapped.safeDetails,
format: snapshot.format,
targetId: snapshot.targetId,
url: snapshot.url,
...dialogStateFields
}
};
}
const boundedSnapshot = wrapBrowserExternalText({
value: snapshot.snapshot ?? "",
marker: BROWSER_EXTERNAL_JSON_TRUNCATION_MARKERS.snapshot,
includeWarning: true
});
const safeDetails = {
ok: true,
format: snapshot.format,
targetId: snapshot.targetId,
url: snapshot.url,
truncated: snapshot.truncated || boundedSnapshot.truncated ? true : void 0,
newElements: snapshot.newElements,
stats: snapshot.stats,
refs: snapshot.refs ? Object.keys(snapshot.refs).length : void 0,
labels: snapshot.labels,
labelsCount: snapshot.labelsCount,
labelsSkipped: snapshot.labelsSkipped,
annotations: snapshot.annotations,
imagePath: snapshot.imagePath,
imageType: snapshot.imageType,
refsFallback,
...dialogStateFields,
externalContent: {
untrusted: true,
source: "browser",
kind: "snapshot",
format: "ai",
wrapped: true
}
};
if (labels && snapshot.imagePath) return await imageResultFromFile({
label: "browser:snapshot",
path: snapshot.imagePath,
extraText: boundedSnapshot.text,
details: {
...safeDetails,
media: { outbound: false }
},
imageSanitization: resolveRuntimeImageSanitization()
});
return {
content: [{
type: "text",
text: boundedSnapshot.text
}],
details: safeDetails
};
}
{
const wrapped = wrapBrowserExternalJson({
kind: "snapshot",
payload: snapshot
});
return {
content: [{
type: "text",
text: wrapped.wrappedText
}],
details: {
...wrapped.safeDetails,
format: "aria",
targetId: snapshot.targetId,
url: snapshot.url,
nodeCount: snapshot.nodes.length,
...snapshot.blockedByDialog ? { blockedByDialog: true } : {},
...snapshot.browserState !== void 0 ? { browserState: snapshot.browserState } : {},
externalContent: {
untrusted: true,
source: "browser",
kind: "snapshot",
format: "aria",
wrapped: true
}
}
};
}
}
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
const browserToolActionDeps = {
browserAct,
browserConsoleMessages,
browserRequests,
browserErrors,
browserPageText,
browserEmulateSetting,
browserDownload,
browserTabs,
browserWaitForDownload
};
const BROWSER_DOWNLOAD_REQUEST_TIMEOUT_SLACK_MS = 5e3;
const ACT_TIMEOUT_KINDS = /* @__PURE__ */ new Set([
"click",
"type",
"hover",
"scrollIntoView",
"drag",
"select",
"fill",
"evaluate",
"wait"
]);
const EXISTING_SESSION_TIMEOUT_REJECTED_KINDS = /* @__PURE__ */ new Set([
"type",
"hover",
"scrollIntoView",
"drag",
"select",
"fill"
]);
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) {
const typedRequest = request;
if (normalizePositiveTimeoutMs(typedRequest.timeoutMs) !== void 0 || !ACT_TIMEOUT_KINDS.has(request.kind) || usesChromeMcp && EXISTING_SESSION_TIMEOUT_REJECTED_KINDS.has(request.kind)) return request;
return {
...typedRequest,
timeoutMs: DEFAULT_BROWSER_ACTION_TIMEOUT_MS
};
}
function resolveActProxyTimeoutMs(request) {
return resolveBrowserActRequestTimeoutMs(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 label = readStringValue(source.label);
const suggestedTargetId = readStringValue(source.suggestedTargetId) ?? label ?? tabId ?? targetId;
return {
...suggestedTargetId ? { suggestedTargetId } : {},
...tabId ? { tabId } : {},
...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((tab) => formatAgentTab(tab));
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) {
const result = jsonResult(params.payload);
const wrapped = wrapBrowserExternalJson({
kind: params.kind,
payload: params.payload,
includeWarning: false
});
return {
...result,
content: [{
type: "text",
text: wrapped.wrappedText
}]
};
}
function formatConsoleToolResult(result) {
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
}
};
}
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 replaceStaleTargetIdInActRequest(request, targetId) {
if (!normalizeOptionalString(request.targetId) || !targetId) return null;
return {
...request,
targetId
};
}
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;
if (proxyRequest) {
const result = normalizeBrowserTabsResult(await proxyRequest({
method: "GET",
path: "/tabs",
profile,
timeoutMs
}));
const tabs = result.tabs.filter((tab) => !params.targetId || readStringValue(tab.targetId) === params.targetId);
return formatTabsToolResult({
running: result.running,
tabs
});
}
const result = await browserToolActionDeps.browserTabs(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
};
}
/** True when an /act response reports a cross-document navigation. */
function actObservedNavigation(result, aborted) {
if (aborted?.reason === "navigation") return true;
const results = result?.results;
return Array.isArray(results) && results.some((entry) => entry?.navigated === true);
}
/** Execute browser console retrieval and wrap page-controlled messages. */
async function executeConsoleAction(params) {
const { input, baseUrl, profile, proxyRequest } = params;
const level = normalizeOptionalString(input.level);
const targetId = normalizeOptionalString(input.targetId);
if (proxyRequest) return formatConsoleToolResult(await proxyRequest({
method: "GET",
path: "/console",
profile,
query: {
level,
targetId
}
}));
return formatConsoleToolResult(await browserToolActionDeps.browserConsoleMessages(baseUrl, {
level,
targetId,
profile,
signal: params.signal
}));
}
/** Read recent network requests, keeping counts aligned with the bounded payload. */
async function executeRequestsAction(params) {
const { input, baseUrl, profile, proxyRequest, signal } = params;
const targetId = normalizeOptionalString(input.targetId);
const filter = normalizeOptionalString(input.filter);
const clear = typeof input.clear === "boolean" ? input.clear : void 0;
const limit = readPositiveIntegerParam(input, "limit", { message: "limit must be a positive integer." }) ?? 50;
const result = proxyRequest ? await proxyRequest({
method: "GET",
path: "/requests",
profile,
query: {
targetId,
filter,
clear
}
}) : await browserToolActionDeps.browserRequests(baseUrl, {
targetId,
filter,
clear,
profile,
signal
});
return formatBrowserDebugLogResult("requests", result, result.requests, limit);
}
/** Read recent page errors, keeping counts aligned with the bounded payload. */
async function executeErrorsAction(params) {
const { input, baseUrl, profile, proxyRequest, signal } = params;
const targetId = normalizeOptionalString(input.targetId);
const clear = typeof input.clear === "boolean" ? input.clear : void 0;
const limit = readPositiveIntegerParam(input, "limit", { message: "limit must be a positive integer." }) ?? 50;
const result = proxyRequest ? await proxyRequest({
method: "GET",
path: "/errors",
profile,
query: {
targetId,
clear
}
}) : await browserToolActionDeps.browserErrors(baseUrl, {
targetId,
clear,
profile,
signal
});
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 = proxyRequest ? await proxyRequest({
method: "GET",
path: "/text",
profile,
query: {
targetId,
selector,
maxChars
}
}) : await browserToolActionDeps.browserPageText(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) {
const body = {
targetId,
[key]: value
};
targetId = (proxyRequest ? await proxyRequest({
method: "POST",
path: `/set/${setting}`,
profile,
body
}) : await browserToolActionDeps.browserEmulateSetting(baseUrl, {
setting,
body,
profile,
signal
})).targetId ?? targetId;
applied.push(field);
}
return jsonResult({
ok: true,
targetId,
applied
});
}
function resolveDownloadProxyTimeoutMs(timeoutMs) {
return (timeoutMs ?? 12e4) + BROWSER_DOWNLOAD_REQUEST_TIMEOUT_SLACK_MS;
}
function readBrowserDownloadRequest(action, input) {
if (action === "download") return {
action,
route: "/download",
ref: readToolStringParam(input, "ref", { required: true }),
path: readToolStringParam(input, "path", { required: true })
};
return {
action,
route: "/wait/download",
path: readToolStringParam(input, "path")
};
}
/** 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 timeoutMs = normalizePositiveTimeoutMs(input.timeoutMs);
const request = readBrowserDownloadRequest(action, input);
const result = proxyRequest ? await proxyRequest({
method: "POST",
path: request.route,
profile,
timeoutMs: resolveDownloadProxyTimeoutMs(timeoutMs),
body: request.action === "download" ? {
ref: request.ref,
path: request.path,
targetId,
timeoutMs
} : {
path: request.path,
targetId,
timeoutMs
}
}) : request.action === "download" ? await browserToolActionDeps.browserDownload(baseUrl, {
ref: request.ref,
path: request.path,
targetId,
timeoutMs,
profile,
signal: params.signal
}) : await browserToolActionDeps.browserWaitForDownload(baseUrl, {
path: request.path,
targetId,
timeoutMs,
profile,
signal: params.signal
});
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);
(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
});
};
try {
const result = proxyRequest ? await proxyRequest({
method: "POST",
path: "/act",
profile,
body: request,
timeoutMs: resolveActProxyTimeoutMs(request)
}) : await browserToolActionDeps.browserAct(baseUrl, effectiveRequest, {
profile,
signal: params.signal
});
return await finishActResult(result, readStringValue(result.targetId) ?? readStringValue(effectiveRequest.targetId));
} 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 = proxyRequest ? await proxyRequest({
method: "GET",
path: "/tabs",
profile
}).then(normalizeBrowserTabsResult).catch((refreshError) => {
params.signal?.throwIfAborted();
tabRefreshError = refreshError;
return {
running: false,
tabs: []
};
}) : await browserToolActionDeps.browserTabs(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 ? replaceStaleTargetIdInActRequest(effectiveRequest, freshTargetId) : null;
if (retryRequest && canRetryChromeActAfterSoleTargetRefresh(effectiveRequest) && tabs.length === 1) {
const retryResult = proxyRequest ? await proxyRequest({
method: "POST",
path: "/act",
profile,
body: retryRequest,
timeoutMs: resolveActProxyTimeoutMs(retryRequest)
}) : await browserToolActionDeps.browserAct(baseUrl, retryRequest, {
profile,
signal: params.signal
});
return await finishActResult(retryResult, readStringValue(retryResult.targetId) ?? readStringValue(retryRequest.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;
}
}
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 fullPage = Boolean(params.fullPage);
const ref = readToolStringParam(params, "ref");
const element = readToolStringParam(params, "element");
const labels = typeof params.labels === "boolean" ? params.labels : void 0;
const type = params.type === "jpeg" ? "jpeg" : "png";
const effectiveTimeoutMs = requestedTimeoutMs ?? 2e4;
const result = proxyRequest ? await proxyRequest({
method: "POST",
path: "/screenshot",
profile,
timeoutMs: effectiveTimeoutMs,
body: {
targetId,
fullPage,
ref,
element,
type,
labels,
timeoutMs: effectiveTimeoutMs
}
}) : await browserScreenshotAction(baseUrl, {
targetId,
fullPage,
ref,
element,
type,
labels,
timeoutMs: effectiveTimeoutMs,
profile,
signal
});
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 resultRecord = result;
const resultTargetId = readStringValue(resultRecord.targetId) ?? targetId;
const resultUrl = readStringValue(resultRecord.url);
return jsonResult({
ok: resultRecord.ok === true,
path: artifactPath,
...resultTargetId ? { targetId: truncateUtf16Safe(resultTargetId, 256) } : {},
...resultUrl ? { url: truncateUtf16Safe(resultUrl, 2048) } : {},
...Array.isArray(resultRecord.annotations) ? { annotationCount: resultRecord.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 }
};
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) {
const headerLines = [`[analyzed by ${described.provider && described.model ? `${described.provider}/${described.model}` : "media image understanding"}]`];
const wrappedDescription = wrapExternalContent(neutralizeMediaDirectives(described.text.trim()), {
source: "browser",
includeWarning: true
});
return {
content: [{
type: "text",
text: `${headerLines.join("\n")}\n${wrappedDescription}\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);
const 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
});
}
return await imageResultFromFile({
label: "browser:screenshot",
path: screenshotPath,
extraText: shareHint,
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 = (targetId) => {
sessionTabs.touch(targetId);
context.onTabActivity(targetId);
};
const executeTrackedTabRequest = async (path, body, runLocal) => {
const result = proxyRequest ? await proxyRequest({
method: "POST",
path,
profile,
body
}) : await runLocal();
touchTab(readStringValue(asNullableRecord(result)?.targetId) ?? readStringValue(body.targetId));
return jsonResult(result);
};
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 = proxyRequest ? await proxyRequest({
method: "POST",
path: "/tabs/open",
profile,
body: {
url: targetUrl,
...label ? { label } : {}
},
timeoutMs: toolTimeoutMs
}) : await browserOpenTab(baseUrl, targetUrl, {
profile,
label,
timeoutMs: toolTimeoutMs,
signal
});
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);
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 });
const result = proxyRequest ? await proxyRequest({
method: "POST",
path: "/tabs/focus",
profile,
body: { targetId },
timeoutMs: toolTimeoutMs
}) : await browserFocusTab(baseUrl, targetId, {
profile,
timeoutMs: toolTimeoutMs,
signal
});
touchTab(readStringValue(asNullableRecord(result)?.targetId) ?? targetId);
return jsonResult(result);
}
case "close": {
const targetId = readToolStringParam(params, "targetId");
if (proxyRequest) {
const result = targetId ? await proxyRequest({
method: "DELETE",
path: `/tabs/${encodeURIComponent(targetId)}`,
profile,
timeoutMs: toolTimeoutMs
}) : await proxyRequest({
method: "POST",
path: "/act",
profile,
body: { kind: "close" },
timeoutMs: toolTimeoutMs
});
sessionTabs.untrack(readStringValue(asNullableRecord(result)?.targetId) ?? targetId);
return jsonResult(result);
}
const result = targetId ? await browserCloseTab(baseUrl, targetId, {
profile,
timeoutMs: toolTimeoutMs,
signal
}) : await browserAct(baseUrl, { kind: "close" }, {
profile,
timeoutMs: toolTimeoutMs,
signal
});
sessionTabs.untrack(readStringValue(result.targetId) ?? targetId);
return jsonResult(result);
}
case "snapshot": return await executeSnapshotAction({
input: params,
baseUrl,
profile,
proxyRequest,
signal,
onTabActivity: touchTab
});
case "screenshot": return await executeScreenshotAction({
input: params,
baseUrl,
profile,
requestedTimeoutMs,
proxyRequest,
signal,
onTabActivity: touchTab,
opts
});
case "navigate": {
const targetUrl = readTargetUrlParam(params);
const targetId = readToolStringParam(params, "targetId");
const timeoutMs = resolveBrowserNavigationTimeoutMs(requestedTimeoutMs);
const result = proxyRequest ? await proxyRequest({
method: "POST",
path: "/navigate",
profile,
body: {
url: targetUrl,
targetId,
timeoutMs
},
timeoutMs: timeoutMs + BROWSER_ACTION_TRANSPORT_SLACK_MS
}) : await browserNavigate(baseUrl, {
url: targetUrl,
targetId,
timeoutMs,
profile,
signal
});
const navigatedTargetId = readStringValue(asNullableRecord(result)?.targetId) ?? targetId;
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({
input: params,
baseUrl,
profile,
proxyRequest,
signal
});
const targetId = readToolStringParam(params, "targetId");
touchTab(readStringValue(asNullableRecord(result.details)?.targetId) ?? targetId);
return result;
}
case "requests":
case "errors":
case "text":
case "emulate": {
const execute = {
requests: executeRequestsAction,
errors: executeErrorsAction,
text: executeTextAction,
emulate: executeEmulateAction
}[action];
const result = await execute({
input: params,
baseUrl,
profile,
proxyRequest,
signal
});
touchTab(readStringValue(asNullableRecord(result.details)?.targetId) ?? readStringValue(params.targetId));
return result;
}
case "pdf": {
const targetId = normalizeOptionalString(params.targetId);
const result = proxyRequest ? await proxyRequest({
method: "POST",
path: "/pdf",
profile,
body: { targetId }
}) : await browserPdfSave(baseUrl, {
targetId,
profile,
signal
});
touchTab(readStringValue(result.targetId) ?? targetId);
return {
content: [{
type: "text",
text: `FILE:${result.path}`
}],
details: result
};
}
case "download":
case "waitfordownload": return await executeDownloadAction({
action,
input: params,
baseUrl,
profile,
proxyRequest,
signal,
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 normalizedPaths = resolvedResult.paths;
const ref = readToolStringParam(params, "ref");
const inputRef = readToolStringParam(params, "inputRef");
const element = readToolStringParam(params, "element");
const { targetId, timeoutMs } = readOptionalTargetAndTimeout(params);
const request = {
paths: normalizedPaths,
ref,
inputRef,
element,
targetId,
timeoutMs
};
return await executeTrackedTabRequest("/hooks/file-chooser", request, async () => await browserArmFileChooser(baseUrl, {
...request,
profile,
signal
}));
}
case "dialog": {
const accept = Boolean(params.accept);
const promptText = readStringValue(params.promptText);
const dialogId = readStringValue(params.dialogId);
const { targetId, timeoutMs } = readOptionalTargetAndTimeout(params);
const request = {
accept,
promptText,
dialogId,
targetId,
timeoutMs
};
return await executeTrackedTabRequest("/hooks/dialog", request, async () => await browserArmDialog(baseUrl, {
...request,
profile,
signal
}));
}
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
/** Shared browser-node selection for agent tools and Gateway requests. */
/** Select the same authorized browser-capable node on every request surface. */
function resolveBrowserNodeTarget(params) {
const mode = params.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() || params.policy?.node?.trim();
if (mode === "manual" && !explicit && !requested) return null;
const browserNodes = 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
/** Browser tool host, sandbox, and node target resolution. */
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") {
resolveBrowserNodeTarget({
nodes: [],
policy,
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 = resolveBrowserNodeTarget({
nodes: await listNodes({}, params.signal),
policy,
requestedNode,
explicitTarget,
requireConnected: true
});
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 () => proxyRequest ? await proxyRequest({
method: "GET",
path: "/",
profile,
timeoutMs: toolTimeoutMs
}) : await browserStatus(baseUrl, {
profile,
timeoutMs: toolTimeoutMs,
signal
});
switch (action) {
case "doctor": return jsonResult(proxyRequest ? await proxyRequest({
method: "GET",
path: "/doctor",
profile
}) : await browserDoctor(baseUrl, {
profile,
signal
}));
case "status": return jsonResult(await readBrowserStatus());
case "start":
case "stop":
if (proxyRequest) await proxyRequest({
method: "POST",
path: `/${action}`,
profile,
timeoutMs: toolTimeoutMs
});
else await (action === "start" ? browserStart : browserStop)(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 title = readStringValue(details.title);
return {
...result,
details: {
...details,
browserTab: {
...identity,
...url ? { 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"
];
const LEGACY_BROWSER_ACT_SHARED_REQUEST_KEYS = /* @__PURE__ */ new Set(["targetId"]);
function readActRequestParam(params) {
const requestParam = params.request;
if (requestParam && typeof requestParam === "object") {
const request = { ...requestParam };
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 && !LEGACY_BROWSER_ACT_SHARED_REQUEST_KEYS.has(key)) continue;
request[key] = params[key];
}
return request;
}
if (!readToolStringParam(params, "kind")) return;
const request = {};
for (const key of LEGACY_BROWSER_ACT_REQUEST_KEYS) {
if (!Object.hasOwn(params, key)) continue;
request[key] = params[key];
}
return request;
}
function readToolTimeoutMs(params) {
return readPositiveIntegerParam(params, "timeoutMs", { message: "timeoutMs must be a positive integer." });
}
/** Create the Browser tool exposed to agents. */
function createBrowserTool(opts) {
const bindingResult = opts?.runToolBinding === void 0 ? void 0 : parseBrowserTabToolBinding(opts.runToolBinding);
if (bindingResult && !bindingResult.ok) throw new Error(`invalid browser run binding: ${bindingResult.error}`);
const capabilities = opts?.toolCapabilities ?? (() => {
const config = getRuntimeConfig();
const boundProfile = bindingResult?.ok && bindingResult.binding.target === "host" ? resolveProfile(resolveBrowserConfig(config.browser, config), bindingResult.binding.profile) : void 0;
return resolveBrowserToolCapabilities({
tabBound: bindingResult?.ok,
evaluateEnabled: config.browser?.evaluateEnabled !== false,
...boundProfile ? { profileCapabilities: getBrowserProfileCapabilities(boundProfile) } : {}
});
})();
const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host";
const hostHint = opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed.";
return {
label: "Browser",
name: "browser",
resultContentSource: "network",
description: describeBrowserTool({
targetDefault,
hostHint,
capabilities
}),
parameters: createBrowserToolSchema(capabilities),
outputSchema: BrowserToolOutputSchema,
execute: async (_toolCallId, args, signal) => {
const params = bindingResult?.ok ? applyBrowserTabToolBinding(args, bindingResult.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 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,
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;
const result = await executeBrowserTabAction({
action,
actRequest: action === "act" ? readActRequestParam(params) : void 0,
params,
baseUrl,
profile,
proxyRequest,
nodeRoute,
sessionTabs,
capabilities,
isUserBrowserProfile,
toolTimeoutMs,
requestedTimeoutMs,
signal,
opts,
boundTargetId: bindingResult?.ok ? bindingResult.binding.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
});
}
});
return [
"open",
"focus",
"navigate",
"screenshot",
"snapshot",
"text",
"requests",
"errors",
"console",
"emulate",
"act"
].includes(action) ? withBrowserTabDetails(result, tabIdentity) : result;
}
};
}
//#endregion
//#region extensions/browser/src/node-host/invoke-browser.ts
/**
* Node-host browser.proxy command implementation for delegated Browser control
* requests.
*/
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 DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 2e4;
const BROWSER_PROXY_STATUS_TIMEOUT_MS = 750;
const BROWSER_PROXY_MAX_ENCODED_PAYLOAD_BYTES = 25165824;
function normalizeProfileAllowlist(raw) {
return Array.isArray(raw) ? normalizeStringEntries(raw) : [];
}
function resolveBrowserProxyConfig() {
const proxy = loadBrowserConfigForRuntimeRefresh().nodeHost?.browserProxy;
const allowProfiles = normalizeProfileAllowlist(proxy?.allowProfiles);
return {
enabled: proxy?.enabled !== false,
allowProfiles
};
}
let browserControlReady = null;
let admittedBrowserControlState = null;
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("browser control disabled");
const started = await startBrowserControlServiceFromConfig();
if (!started) throw new Error("browser control disabled");
admittedBrowserControlState = started;
})().finally(() => {
if (browserControlReady === sharedStartup) browserControlReady = null;
});
browserControlReady = sharedStartup;
return sharedStartup;
}
function isProfileAllowed(params) {
const { allowProfiles, profile } = params;
if (!allowProfiles.length) return true;
if (!profile) return false;
return allowProfiles.includes(profile.trim());
}
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 decodeParams(raw) {
if (!raw) throw new Error("INVALID_REQUEST: paramsJSON required");
return JSON.parse(raw);
}
function resolveBrowserProxyTimeout(timeoutMs) {
return resolveTimerTimeoutMs(timeoutMs, DEFAULT_BROWSER_PROXY_TIMEOUT_MS);
}
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("; ");
}
/** Executes a serialized browser.proxy command and returns a serialized result payload. */
async function runBrowserProxyCommand(paramsJSON, command = BROWSER_PROXY_COMMAND, invocationSignal) {
invocationSignal?.throwIfAborted();
ensureBrowserProxyUploadCleanup();
const params = decodeParams(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");
const proxyConfig = resolveBrowserProxyConfig();
if (!proxyConfig.enabled) throw new Error("UNAVAILABLE: node browser proxy disabled");
await ensureBrowserControlService();
invocationSignal?.throwIfAborted();
const cfg = loadBrowserConfigForRuntimeRefresh();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
const path = normalizeBrowserRequestPath(pathValue);
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 = proxyConfig.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");
if (allowedProfiles.length > 0) {
if (path !== "/profiles") {
if (!isProfileAllowed({
allowProfiles: allowedProfiles,
profile: requestedProfile || resolved.defaultProfile
})) throw new Error("INVALID_REQUEST: browser profile not allowed");
} else if (requestedProfile) {
if (!isProfileAllowed({
allowProfiles: allowedProfiles,
profile: requestedProfile
})) throw new Error("INVALID_REQUEST: browser profile not allowed");
}
}
const timeoutMs = resolveBrowserProxyTimeout(params.timeoutMs);
const deadlineAt = Date.now() + timeoutMs;
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 (requestedProfile) query.profile = requestedProfile;
if (path === "/__openclaw/session-tab/close-owned") {
const request = readOwnedTabCloseRequest(body);
const liveResolved = getBrowserControlState()?.resolved ?? resolved;
const profile = resolveProfile(liveResolved, effectiveProfile);
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
}) : { 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({
method,
path,
profile: requestedProfile || resolved.defaultProfile || void 0,
timeoutMs,
wsBacked: isWsBackedBrowserProxyPath(path),
status: null
}), { cause: err });
}
body = stagedUpload.body;
const remainingTimeoutMs = deadlineAt - Date.now();
if (remainingTimeoutMs <= 0) {
await discardStagedBrowserProxyUpload(stagedUpload);
throw new Error(formatBrowserProxyTimeoutMessage({
method,
path,
profile: requestedProfile || resolved.defaultProfile || void 0,
timeoutMs,
wsBacked: isWsBackedBrowserProxyPath(path),
status: null
}));
}
let response;
try {
response = await withTimeout((timeoutSignal) => dispatcher.dispatch({
method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET",
path,
query,
body,
signal: combineBrowserProxySignals(timeoutSignal, invocationSignal)
}), 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({
method,
path,
profile: path === "/profiles" ? void 0 : profileForStatus || void 0,
timeoutMs,
wsBacked: isWsBackedBrowserProxyPath(path),
status
}), { cause: err });
}
if (response.status >= 400) await discardStagedBrowserProxyUpload(stagedUpload);
if (response.status >= 400) {
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 = files ? {
result,
files,
...includeRoute ? { route } : {}
} : {
result,
...includeRoute ? { route } : {}
};
const serialized = JSON.stringify(payload);
if (Buffer.byteLength(JSON.stringify(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/gateway/browser-request.ts
/**
* Gateway handler for browser.request, including optional node-host proxy
* dispatch and local Browser control route dispatch.
*/
const logger = createSubsystemLogger("browser");
/** Handles one browser.request gateway call and streams a success/error response. */
async function handleBrowserGatewayRequest({ params, respond, context }) {
const typed = params;
const methodRaw = (normalizeOptionalString(typed.method) ?? "").toUpperCase();
const path = normalizeOptionalString(typed.path) ?? "";
const query = typed.query && typeof typed.query === "object" ? typed.query : void 0;
const body = typed.body;
const timeoutMs = clampTimerTimeoutMs(typed.timeoutMs);
const explicitNode = typed.target === "node";
const requestedNode = normalizeOptionalString(typed.node);
if (typed.target !== void 0 && typed.target !== "host" && !explicitNode || typed.node !== void 0 && (!explicitNode || !requestedNode || typeof typed.node !== "string" || typed.node.length > 256)) {
respond(false, void 0, errorShape(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) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "method and path are required"));
return;
}
if (methodRaw !== "GET" && methodRaw !== "POST" && methodRaw !== "DELETE") {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "method must be GET, POST, or DELETE"));
return;
}
const cfg = getRuntimeConfig();
const configuredNode = normalizeOptionalString(cfg.gateway?.nodes?.browser?.node);
const forceHostLocal = isBrowserHostLocalRoute(methodRaw, path);
if (forceHostLocal && explicitNode) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "this browser route must run on the Gateway host"));
return;
}
let nodeTarget = null;
if (!forceHostLocal && typed.target !== "host") try {
nodeTarget = resolveBrowserNodeTarget({
nodes: context.nodeRegistry.listConnected(),
policy: cfg.gateway?.nodes?.browser,
explicitTarget: explicitNode,
requestedNode
});
} catch (err) {
respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
return;
}
if (nodeTarget && isPersistentBrowserProfileMutation(methodRaw, path)) {
respond(false, void 0, errorShape(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) {
respond(false, void 0, errorShape(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 {
preparedUpload = await prepareBrowserProxyUploadRequest({
method: methodRaw,
path,
body
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, message));
return;
}
if (preparedUpload.upload) proxyCommand = BROWSER_PROXY_UPLOAD_COMMAND;
}
if (nodeTarget && preparedUpload) {
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})`;
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, hint, { details: {
reason: allowed.reason,
command: proxyCommand
} }));
return;
}
const proxyParams = {
method: methodRaw,
path,
query,
body: preparedUpload.body,
upload: preparedUpload.upload,
timeoutMs,
profile: resolveRequestedBrowserProfile({
query,
body
}),
errorEnvelope: BROWSER_PROXY_ERROR_ENVELOPE
};
const res = await context.nodeRegistry.invoke({
nodeId: nodeTarget.nodeId,
command: proxyCommand,
params: proxyParams,
timeoutMs,
idempotencyKey: crypto.randomUUID()
});
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;
const code = status >= 500 ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST;
respond(false, void 0, errorShape(code, errorBody.error, { details: errorBody }));
return;
}
const proxy = payload && typeof payload === "object" ? payload : null;
if (!proxy || !("result" in proxy)) {
respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, "browser proxy failed"));
return;
}
const success = proxy;
try {
respond(true, await persistBrowserProxyResultFiles(success.result, success.files));
} catch {
respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, "browser proxy file transfer failed"));
}
return;
}
}
if (!await startBrowserControlServiceFromConfig()) {
respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, "browser control is disabled"));
return;
}
let dispatcher;
try {
dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
} catch (err) {
respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
return;
}
let result;
try {
result = timeoutMs ? await withTimeout((signal) => dispatcher.dispatch({
method: methodRaw,
path,
query,
body,
signal
}), timeoutMs, "browser request") : await dispatcher.dispatch({
method: methodRaw,
path,
query,
body
});
} catch (err) {
respond(false, void 0, errorShape(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})`;
const code = result.status >= 500 ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST;
respond(false, void 0, errorShape(code, message, { details: result.body }));
return;
}
respond(true, result.body);
}
/** Gateway request handler map contributed by the Browser plugin. */
const browserHandlers = { "browser.request": handleBrowserGatewayRequest };
//#endregion
//#region extensions/browser/src/plugin-service.ts
/**
* Browser plugin service factory that lazily starts the control server.
*/
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;
}
/** Creates the Browser plugin service registered by the plugin entrypoint. */
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-CXHAhKSm.js"),
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 { createBrowserTool as a, browserAct as c, browserNavigate as d, browserScreenshotAction as f, runBrowserProxyCommand as i, browserArmDialog as l, browserHandlers as n, browserConsoleMessages as o, handleBrowserGatewayRequest as r, browserPdfSave as s, createBrowserPluginService as t, browserArmFileChooser as u };