@gguf/claw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,399 lines (1,381 loc) • 75.7 kB
JavaScript
import { r as STATE_DIR } from "./paths-B4BZAPZh.js";
import { y as resolveUserPath } from "./utils-CP9YLh6M.js";
import { d as resolveAgentIdFromSessionKey, l as normalizeAgentId } from "./session-key-CZ6OwgSB.js";
import { t as CHANNEL_IDS } from "./registry-B-j4DRfe.js";
import { f as defaultRuntime } from "./subsystem-BCQGGxdd.js";
import { C as ensureAgentWorkspace, S as DEFAULT_USER_FILENAME, _ as DEFAULT_IDENTITY_FILENAME, b as DEFAULT_SOUL_FILENAME, d as resolveSessionAgentId, g as DEFAULT_HEARTBEAT_FILENAME, h as DEFAULT_BOOTSTRAP_FILENAME, m as DEFAULT_AGENT_WORKSPACE_DIR, p as DEFAULT_AGENTS_FILENAME, r as resolveAgentConfig, x as DEFAULT_TOOLS_FILENAME } from "./agent-scope-BnZW9Gh2.js";
import { t as formatCliCommand } from "./command-format-DEKzLnLg.js";
import { i as loadConfig } from "./config-PQiujvsf.js";
import { f as wrapOwnerOnlyToolExecution } from "./common-a25M2Kvi.js";
import { A as DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, D as DEFAULT_BROWSER_EVALUATE_ENABLED, O as DEFAULT_OPENCLAW_BROWSER_COLOR } from "./chrome-Dd5zBIFu.js";
import { t as safeEqualSecret } from "./secret-equal-CJzes24Z.js";
import { r as isLoopbackHost } from "./ws-CV6gEox3.js";
import { a as resolveProfile, m as resolveBrowserControlAuth, p as ensureBrowserControlAuth, t as createBrowserRouteContext } from "./server-context-DAWsUNUs.js";
import { d as resolveSandboxInputPath, f as resolveSandboxPath, g as sanitizeEnvVars, o as syncSkillsToWorkspace } from "./skills-CLmzWt48.js";
import { t as registerBrowserRoutes } from "./routes-DIQa_0pt.js";
import { R as canonicalizeMainSessionAlias, q as acquireSessionWriteLock, z as resolveAgentMainSessionKey } from "./sessions-Z1BZU3xh.js";
import { existsSync, realpathSync } from "node:fs";
import path, { posix } from "node:path";
import fs$1 from "node:fs/promises";
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import express from "express";
//#region src/agents/sandbox/constants.ts
const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join(STATE_DIR, "sandboxes");
const DEFAULT_SANDBOX_IMAGE = "openclaw-sandbox:bookworm-slim";
const DEFAULT_SANDBOX_CONTAINER_PREFIX = "openclaw-sbx-";
const DEFAULT_SANDBOX_WORKDIR = "/workspace";
const DEFAULT_SANDBOX_IDLE_HOURS = 24;
const DEFAULT_SANDBOX_MAX_AGE_DAYS = 7;
const DEFAULT_TOOL_ALLOW = [
"exec",
"process",
"read",
"write",
"edit",
"apply_patch",
"image",
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"subagents",
"session_status"
];
const DEFAULT_TOOL_DENY = [
"browser",
"canvas",
"nodes",
"cron",
"gateway",
...CHANNEL_IDS
];
const DEFAULT_SANDBOX_BROWSER_IMAGE = "openclaw-sandbox-browser:bookworm-slim";
const DEFAULT_SANDBOX_COMMON_IMAGE = "openclaw-sandbox-common:bookworm-slim";
const DEFAULT_SANDBOX_BROWSER_PREFIX = "openclaw-sbx-browser-";
const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222;
const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900;
const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080;
const DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS = 12e3;
const SANDBOX_AGENT_WORKSPACE_MOUNT = "/agent";
const SANDBOX_STATE_DIR = path.join(STATE_DIR, "sandbox");
const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json");
const SANDBOX_BROWSER_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "browsers.json");
//#endregion
//#region src/agents/glob-pattern.ts
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function compileGlobPattern(params) {
const normalized = params.normalize(params.raw);
if (!normalized) return {
kind: "exact",
value: ""
};
if (normalized === "*") return { kind: "all" };
if (!normalized.includes("*")) return {
kind: "exact",
value: normalized
};
return {
kind: "regex",
value: new RegExp(`^${escapeRegex(normalized).replaceAll("\\*", ".*")}$`)
};
}
function compileGlobPatterns(params) {
if (!Array.isArray(params.raw)) return [];
return params.raw.map((raw) => compileGlobPattern({
raw,
normalize: params.normalize
})).filter((pattern) => pattern.kind !== "exact" || pattern.value);
}
function matchesAnyGlobPattern(value, patterns) {
for (const pattern of patterns) {
if (pattern.kind === "all") return true;
if (pattern.kind === "exact" && value === pattern.value) return true;
if (pattern.kind === "regex" && pattern.value.test(value)) return true;
}
return false;
}
//#endregion
//#region src/agents/tool-policy.ts
const TOOL_NAME_ALIASES = {
bash: "exec",
"apply-patch": "apply_patch"
};
const TOOL_GROUPS = {
"group:memory": ["memory_search", "memory_get"],
"group:web": ["web_search", "web_fetch"],
"group:fs": [
"read",
"write",
"edit",
"apply_patch"
],
"group:runtime": ["exec", "process"],
"group:sessions": [
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"subagents",
"session_status"
],
"group:ui": ["browser", "canvas"],
"group:automation": ["cron", "gateway"],
"group:messaging": ["message"],
"group:nodes": ["nodes"],
"group:openclaw": [
"browser",
"canvas",
"nodes",
"cron",
"message",
"gateway",
"agents_list",
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"subagents",
"session_status",
"memory_search",
"memory_get",
"web_search",
"web_fetch",
"image"
]
};
const OWNER_ONLY_TOOL_NAME_FALLBACKS = new Set([
"whatsapp_login",
"cron",
"gateway"
]);
const TOOL_PROFILES = {
minimal: { allow: ["session_status"] },
coding: { allow: [
"group:fs",
"group:runtime",
"group:sessions",
"group:memory",
"image"
] },
messaging: { allow: [
"group:messaging",
"sessions_list",
"sessions_history",
"sessions_send",
"session_status"
] },
full: {}
};
function normalizeToolName(name) {
const normalized = name.trim().toLowerCase();
return TOOL_NAME_ALIASES[normalized] ?? normalized;
}
function isOwnerOnlyToolName(name) {
return OWNER_ONLY_TOOL_NAME_FALLBACKS.has(normalizeToolName(name));
}
function isOwnerOnlyTool(tool) {
return tool.ownerOnly === true || isOwnerOnlyToolName(tool.name);
}
function applyOwnerOnlyToolPolicy(tools, senderIsOwner) {
const withGuard = tools.map((tool) => {
if (!isOwnerOnlyTool(tool)) return tool;
return wrapOwnerOnlyToolExecution(tool, senderIsOwner);
});
if (senderIsOwner) return withGuard;
return withGuard.filter((tool) => !isOwnerOnlyTool(tool));
}
function normalizeToolList(list) {
if (!list) return [];
return list.map(normalizeToolName).filter(Boolean);
}
function expandToolGroups(list) {
const normalized = normalizeToolList(list);
const expanded = [];
for (const value of normalized) {
const group = TOOL_GROUPS[value];
if (group) {
expanded.push(...group);
continue;
}
expanded.push(value);
}
return Array.from(new Set(expanded));
}
function collectExplicitAllowlist(policies) {
const entries = [];
for (const policy of policies) {
if (!policy?.allow) continue;
for (const value of policy.allow) {
if (typeof value !== "string") continue;
const trimmed = value.trim();
if (trimmed) entries.push(trimmed);
}
}
return entries;
}
function buildPluginToolGroups(params) {
const all = [];
const byPlugin = /* @__PURE__ */ new Map();
for (const tool of params.tools) {
const meta = params.toolMeta(tool);
if (!meta) continue;
const name = normalizeToolName(tool.name);
all.push(name);
const pluginId = meta.pluginId.toLowerCase();
const list = byPlugin.get(pluginId) ?? [];
list.push(name);
byPlugin.set(pluginId, list);
}
return {
all,
byPlugin
};
}
function expandPluginGroups(list, groups) {
if (!list || list.length === 0) return list;
const expanded = [];
for (const entry of list) {
const normalized = normalizeToolName(entry);
if (normalized === "group:plugins") {
if (groups.all.length > 0) expanded.push(...groups.all);
else expanded.push(normalized);
continue;
}
const tools = groups.byPlugin.get(normalized);
if (tools && tools.length > 0) {
expanded.push(...tools);
continue;
}
expanded.push(normalized);
}
return Array.from(new Set(expanded));
}
function expandPolicyWithPluginGroups(policy, groups) {
if (!policy) return;
return {
allow: expandPluginGroups(policy.allow, groups),
deny: expandPluginGroups(policy.deny, groups)
};
}
function stripPluginOnlyAllowlist(policy, groups, coreTools) {
if (!policy?.allow || policy.allow.length === 0) return {
policy,
unknownAllowlist: [],
strippedAllowlist: false
};
const normalized = normalizeToolList(policy.allow);
if (normalized.length === 0) return {
policy,
unknownAllowlist: [],
strippedAllowlist: false
};
const pluginIds = new Set(groups.byPlugin.keys());
const pluginTools = new Set(groups.all);
const unknownAllowlist = [];
let hasCoreEntry = false;
for (const entry of normalized) {
if (entry === "*") {
hasCoreEntry = true;
continue;
}
const isPluginEntry = entry === "group:plugins" || pluginIds.has(entry) || pluginTools.has(entry);
const isCoreEntry = expandToolGroups([entry]).some((tool) => coreTools.has(tool));
if (isCoreEntry) hasCoreEntry = true;
if (!isCoreEntry && !isPluginEntry) unknownAllowlist.push(entry);
}
const strippedAllowlist = !hasCoreEntry;
if (strippedAllowlist) {}
return {
policy: strippedAllowlist ? {
...policy,
allow: void 0
} : policy,
unknownAllowlist: Array.from(new Set(unknownAllowlist)),
strippedAllowlist
};
}
function resolveToolProfilePolicy(profile) {
if (!profile) return;
const resolved = TOOL_PROFILES[profile];
if (!resolved) return;
if (!resolved.allow && !resolved.deny) return;
return {
allow: resolved.allow ? [...resolved.allow] : void 0,
deny: resolved.deny ? [...resolved.deny] : void 0
};
}
function mergeAlsoAllowPolicy(policy, alsoAllow) {
if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) return policy;
return {
...policy,
allow: Array.from(new Set([...policy.allow, ...alsoAllow]))
};
}
//#endregion
//#region src/agents/sandbox/tool-policy.ts
function normalizeGlob(value) {
return value.trim().toLowerCase();
}
function isToolAllowed(policy, name) {
const normalized = normalizeGlob(name);
if (matchesAnyGlobPattern(normalized, compileGlobPatterns({
raw: expandToolGroups(policy.deny ?? []),
normalize: normalizeGlob
}))) return false;
const allow = compileGlobPatterns({
raw: expandToolGroups(policy.allow ?? []),
normalize: normalizeGlob
});
if (allow.length === 0) return true;
return matchesAnyGlobPattern(normalized, allow);
}
function resolveSandboxToolPolicyForAgent(cfg, agentId) {
const agentConfig = cfg && agentId ? resolveAgentConfig(cfg, agentId) : void 0;
const agentAllow = agentConfig?.tools?.sandbox?.tools?.allow;
const agentDeny = agentConfig?.tools?.sandbox?.tools?.deny;
const globalAllow = cfg?.tools?.sandbox?.tools?.allow;
const globalDeny = cfg?.tools?.sandbox?.tools?.deny;
const allowSource = Array.isArray(agentAllow) ? {
source: "agent",
key: "agents.list[].tools.sandbox.tools.allow"
} : Array.isArray(globalAllow) ? {
source: "global",
key: "tools.sandbox.tools.allow"
} : {
source: "default",
key: "tools.sandbox.tools.allow"
};
const denySource = Array.isArray(agentDeny) ? {
source: "agent",
key: "agents.list[].tools.sandbox.tools.deny"
} : Array.isArray(globalDeny) ? {
source: "global",
key: "tools.sandbox.tools.deny"
} : {
source: "default",
key: "tools.sandbox.tools.deny"
};
const deny = Array.isArray(agentDeny) ? agentDeny : Array.isArray(globalDeny) ? globalDeny : [...DEFAULT_TOOL_DENY];
const allow = Array.isArray(agentAllow) ? agentAllow : Array.isArray(globalAllow) ? globalAllow : [...DEFAULT_TOOL_ALLOW];
const expandedDeny = expandToolGroups(deny);
let expandedAllow = expandToolGroups(allow);
if (expandedAllow.length > 0 && !expandedDeny.map((v) => v.toLowerCase()).includes("image") && !expandedAllow.map((v) => v.toLowerCase()).includes("image")) expandedAllow = [...expandedAllow, "image"];
return {
allow: expandedAllow,
deny: expandedDeny,
sources: {
allow: allowSource,
deny: denySource
}
};
}
//#endregion
//#region src/agents/sandbox/config.ts
function resolveSandboxBrowserDockerCreateConfig(params) {
const base = {
...params.docker,
network: "bridge",
image: params.browser.image
};
return params.browser.binds !== void 0 ? {
...base,
binds: params.browser.binds
} : base;
}
function resolveSandboxScope(params) {
if (params.scope) return params.scope;
if (typeof params.perSession === "boolean") return params.perSession ? "session" : "shared";
return "agent";
}
function resolveSandboxDockerConfig(params) {
const agentDocker = params.scope === "shared" ? void 0 : params.agentDocker;
const globalDocker = params.globalDocker;
const env = agentDocker?.env ? {
...globalDocker?.env ?? { LANG: "C.UTF-8" },
...agentDocker.env
} : globalDocker?.env ?? { LANG: "C.UTF-8" };
const ulimits = agentDocker?.ulimits ? {
...globalDocker?.ulimits,
...agentDocker.ulimits
} : globalDocker?.ulimits;
const binds = [...globalDocker?.binds ?? [], ...agentDocker?.binds ?? []];
return {
image: agentDocker?.image ?? globalDocker?.image ?? DEFAULT_SANDBOX_IMAGE,
containerPrefix: agentDocker?.containerPrefix ?? globalDocker?.containerPrefix ?? DEFAULT_SANDBOX_CONTAINER_PREFIX,
workdir: agentDocker?.workdir ?? globalDocker?.workdir ?? DEFAULT_SANDBOX_WORKDIR,
readOnlyRoot: agentDocker?.readOnlyRoot ?? globalDocker?.readOnlyRoot ?? true,
tmpfs: agentDocker?.tmpfs ?? globalDocker?.tmpfs ?? [
"/tmp",
"/var/tmp",
"/run"
],
network: agentDocker?.network ?? globalDocker?.network ?? "none",
user: agentDocker?.user ?? globalDocker?.user,
capDrop: agentDocker?.capDrop ?? globalDocker?.capDrop ?? ["ALL"],
env,
setupCommand: agentDocker?.setupCommand ?? globalDocker?.setupCommand,
pidsLimit: agentDocker?.pidsLimit ?? globalDocker?.pidsLimit,
memory: agentDocker?.memory ?? globalDocker?.memory,
memorySwap: agentDocker?.memorySwap ?? globalDocker?.memorySwap,
cpus: agentDocker?.cpus ?? globalDocker?.cpus,
ulimits,
seccompProfile: agentDocker?.seccompProfile ?? globalDocker?.seccompProfile,
apparmorProfile: agentDocker?.apparmorProfile ?? globalDocker?.apparmorProfile,
dns: agentDocker?.dns ?? globalDocker?.dns,
extraHosts: agentDocker?.extraHosts ?? globalDocker?.extraHosts,
binds: binds.length ? binds : void 0
};
}
function resolveSandboxBrowserConfig(params) {
const agentBrowser = params.scope === "shared" ? void 0 : params.agentBrowser;
const globalBrowser = params.globalBrowser;
const binds = [...globalBrowser?.binds ?? [], ...agentBrowser?.binds ?? []];
const bindsConfigured = globalBrowser?.binds !== void 0 || agentBrowser?.binds !== void 0;
return {
enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false,
image: agentBrowser?.image ?? globalBrowser?.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE,
containerPrefix: agentBrowser?.containerPrefix ?? globalBrowser?.containerPrefix ?? DEFAULT_SANDBOX_BROWSER_PREFIX,
cdpPort: agentBrowser?.cdpPort ?? globalBrowser?.cdpPort ?? DEFAULT_SANDBOX_BROWSER_CDP_PORT,
vncPort: agentBrowser?.vncPort ?? globalBrowser?.vncPort ?? DEFAULT_SANDBOX_BROWSER_VNC_PORT,
noVncPort: agentBrowser?.noVncPort ?? globalBrowser?.noVncPort ?? DEFAULT_SANDBOX_BROWSER_NOVNC_PORT,
headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false,
enableNoVnc: agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true,
allowHostControl: agentBrowser?.allowHostControl ?? globalBrowser?.allowHostControl ?? false,
autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true,
autoStartTimeoutMs: agentBrowser?.autoStartTimeoutMs ?? globalBrowser?.autoStartTimeoutMs ?? DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS,
binds: bindsConfigured ? binds : void 0
};
}
function resolveSandboxPruneConfig(params) {
const agentPrune = params.scope === "shared" ? void 0 : params.agentPrune;
const globalPrune = params.globalPrune;
return {
idleHours: agentPrune?.idleHours ?? globalPrune?.idleHours ?? DEFAULT_SANDBOX_IDLE_HOURS,
maxAgeDays: agentPrune?.maxAgeDays ?? globalPrune?.maxAgeDays ?? DEFAULT_SANDBOX_MAX_AGE_DAYS
};
}
function resolveSandboxConfigForAgent(cfg, agentId) {
const agent = cfg?.agents?.defaults?.sandbox;
let agentSandbox;
const agentConfig = cfg && agentId ? resolveAgentConfig(cfg, agentId) : void 0;
if (agentConfig?.sandbox) agentSandbox = agentConfig.sandbox;
const scope = resolveSandboxScope({
scope: agentSandbox?.scope ?? agent?.scope,
perSession: agentSandbox?.perSession ?? agent?.perSession
});
const toolPolicy = resolveSandboxToolPolicyForAgent(cfg, agentId);
return {
mode: agentSandbox?.mode ?? agent?.mode ?? "off",
scope,
workspaceAccess: agentSandbox?.workspaceAccess ?? agent?.workspaceAccess ?? "none",
workspaceRoot: agentSandbox?.workspaceRoot ?? agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT,
docker: resolveSandboxDockerConfig({
scope,
globalDocker: agent?.docker,
agentDocker: agentSandbox?.docker
}),
browser: resolveSandboxBrowserConfig({
scope,
globalBrowser: agent?.browser,
agentBrowser: agentSandbox?.browser
}),
tools: {
allow: toolPolicy.allow,
deny: toolPolicy.deny
},
prune: resolveSandboxPruneConfig({
scope,
globalPrune: agent?.prune,
agentPrune: agentSandbox?.prune
})
};
}
//#endregion
//#region src/browser/bridge-auth-registry.ts
const authByPort = /* @__PURE__ */ new Map();
function setBridgeAuthForPort(port, auth) {
if (!Number.isFinite(port) || port <= 0) return;
const token = typeof auth.token === "string" ? auth.token.trim() : "";
const password = typeof auth.password === "string" ? auth.password.trim() : "";
authByPort.set(port, {
token: token || void 0,
password: password || void 0
});
}
function getBridgeAuthForPort(port) {
if (!Number.isFinite(port) || port <= 0) return;
return authByPort.get(port);
}
function deleteBridgeAuthForPort(port) {
if (!Number.isFinite(port) || port <= 0) return;
authByPort.delete(port);
}
//#endregion
//#region src/browser/csrf.ts
function firstHeader(value) {
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
}
function isMutatingMethod(method) {
const m = (method || "").trim().toUpperCase();
return m === "POST" || m === "PUT" || m === "PATCH" || m === "DELETE";
}
function isLoopbackUrl(value) {
const v = value.trim();
if (!v || v === "null") return false;
try {
return isLoopbackHost(new URL(v).hostname);
} catch {
return false;
}
}
function shouldRejectBrowserMutation(params) {
if (!isMutatingMethod(params.method)) return false;
if ((params.secFetchSite ?? "").trim().toLowerCase() === "cross-site") return true;
const origin = (params.origin ?? "").trim();
if (origin) return !isLoopbackUrl(origin);
const referer = (params.referer ?? "").trim();
if (referer) return !isLoopbackUrl(referer);
return false;
}
function browserMutationGuardMiddleware() {
return (req, res, next) => {
const method = (req.method || "").trim().toUpperCase();
if (method === "OPTIONS") return next();
if (shouldRejectBrowserMutation({
method,
origin: firstHeader(req.headers.origin),
referer: firstHeader(req.headers.referer),
secFetchSite: firstHeader(req.headers["sec-fetch-site"])
})) {
res.status(403).send("Forbidden");
return;
}
next();
};
}
//#endregion
//#region src/browser/http-auth.ts
function firstHeaderValue(value) {
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
}
function parseBearerToken(authorization) {
if (!authorization || !authorization.toLowerCase().startsWith("bearer ")) return;
return authorization.slice(7).trim() || void 0;
}
function parseBasicPassword(authorization) {
if (!authorization || !authorization.toLowerCase().startsWith("basic ")) return;
const encoded = authorization.slice(6).trim();
if (!encoded) return;
try {
const decoded = Buffer.from(encoded, "base64").toString("utf8");
const sep = decoded.indexOf(":");
if (sep < 0) return;
return decoded.slice(sep + 1).trim() || void 0;
} catch {
return;
}
}
function isAuthorizedBrowserRequest(req, auth) {
const authorization = firstHeaderValue(req.headers.authorization).trim();
if (auth.token) {
const bearer = parseBearerToken(authorization);
if (bearer && safeEqualSecret(bearer, auth.token)) return true;
}
if (auth.password) {
const passwordHeader = firstHeaderValue(req.headers["x-openclaw-password"]).trim();
if (passwordHeader && safeEqualSecret(passwordHeader, auth.password)) return true;
const basicPassword = parseBasicPassword(authorization);
if (basicPassword && safeEqualSecret(basicPassword, auth.password)) return true;
}
return false;
}
//#endregion
//#region src/browser/server-middleware.ts
function installBrowserCommonMiddleware(app) {
app.use((req, res, next) => {
const ctrl = new AbortController();
const abort = () => ctrl.abort(/* @__PURE__ */ new Error("request aborted"));
req.once("aborted", abort);
res.once("close", () => {
if (!res.writableEnded) abort();
});
req.signal = ctrl.signal;
next();
});
app.use(express.json({ limit: "1mb" }));
app.use(browserMutationGuardMiddleware());
}
function installBrowserAuthMiddleware(app, auth) {
if (!auth.token && !auth.password) return;
app.use((req, res, next) => {
if (isAuthorizedBrowserRequest(req, auth)) return next();
res.status(401).send("Unauthorized");
});
}
//#endregion
//#region src/browser/bridge-server.ts
async function startBrowserBridgeServer(params) {
const host = params.host ?? "127.0.0.1";
if (!isLoopbackHost(host)) throw new Error(`bridge server must bind to loopback host (got ${host})`);
const port = params.port ?? 0;
const app = express();
installBrowserCommonMiddleware(app);
const authToken = params.authToken?.trim() || void 0;
const authPassword = params.authPassword?.trim() || void 0;
if (!authToken && !authPassword) throw new Error("bridge server requires auth (authToken/authPassword missing)");
installBrowserAuthMiddleware(app, {
token: authToken,
password: authPassword
});
const state = {
server: null,
port,
resolved: params.resolved,
profiles: /* @__PURE__ */ new Map()
};
registerBrowserRoutes(app, createBrowserRouteContext({
getState: () => state,
onEnsureAttachTarget: params.onEnsureAttachTarget
}));
const server = await new Promise((resolve, reject) => {
const s = app.listen(port, host, () => resolve(s));
s.once("error", reject);
});
const resolvedPort = server.address()?.port ?? port;
state.server = server;
state.port = resolvedPort;
state.resolved.controlPort = resolvedPort;
setBridgeAuthForPort(resolvedPort, {
token: authToken,
password: authPassword
});
return {
server,
port: resolvedPort,
baseUrl: `http://${host}:${resolvedPort}`,
state
};
}
async function stopBrowserBridgeServer(server) {
try {
const address = server.address();
if (address?.port) deleteBridgeAuthForPort(address.port);
} catch {}
await new Promise((resolve) => {
server.close(() => resolve());
});
}
//#endregion
//#region src/agents/sandbox/browser-bridges.ts
const BROWSER_BRIDGES = /* @__PURE__ */ new Map();
//#endregion
//#region src/agents/sandbox/hash.ts
function hashTextSha256(value) {
return crypto.createHash("sha256").update(value).digest("hex");
}
//#endregion
//#region src/agents/sandbox/config-hash.ts
function normalizeForHash(value) {
if (value === void 0) return;
if (Array.isArray(value)) return value.map(normalizeForHash).filter((item) => item !== void 0);
if (value && typeof value === "object") {
const entries = Object.entries(value).toSorted(([a], [b]) => a.localeCompare(b));
const normalized = {};
for (const [key, entryValue] of entries) {
const next = normalizeForHash(entryValue);
if (next !== void 0) normalized[key] = next;
}
return normalized;
}
return value;
}
function computeSandboxConfigHash(input) {
return computeHash(input);
}
function computeSandboxBrowserConfigHash(input) {
return computeHash(input);
}
function computeHash(input) {
const payload = normalizeForHash(input);
return hashTextSha256(JSON.stringify(payload));
}
//#endregion
//#region src/agents/sandbox/registry.ts
function isRecord(value) {
return Boolean(value) && typeof value === "object";
}
function isRegistryEntry(value) {
return isRecord(value) && typeof value.containerName === "string";
}
function isRegistryFile(value) {
if (!isRecord(value)) return false;
const maybeEntries = value.entries;
return Array.isArray(maybeEntries) && maybeEntries.every(isRegistryEntry);
}
async function withRegistryLock(registryPath, fn) {
const lock = await acquireSessionWriteLock({
sessionFile: registryPath,
allowReentrant: false
});
try {
return await fn();
} finally {
await lock.release();
}
}
async function readRegistryFromFile(registryPath, mode) {
try {
const raw = await fs$1.readFile(registryPath, "utf-8");
const parsed = JSON.parse(raw);
if (isRegistryFile(parsed)) return parsed;
if (mode === "fallback") return { entries: [] };
throw new Error(`Invalid sandbox registry format: ${registryPath}`);
} catch (error) {
if (error?.code === "ENOENT") return { entries: [] };
if (mode === "fallback") return { entries: [] };
if (error instanceof Error) throw error;
throw new Error(`Failed to read sandbox registry file: ${registryPath}`, { cause: error });
}
}
async function writeRegistryFile(registryPath, registry) {
await fs$1.mkdir(SANDBOX_STATE_DIR, { recursive: true });
const payload = `${JSON.stringify(registry, null, 2)}\n`;
const registryDir = path.dirname(registryPath);
const tempPath = path.join(registryDir, `${path.basename(registryPath)}.${crypto.randomUUID()}.tmp`);
await fs$1.writeFile(tempPath, payload, "utf-8");
try {
await fs$1.rename(tempPath, registryPath);
} catch (error) {
await fs$1.rm(tempPath, { force: true });
throw error;
}
}
async function readRegistry() {
return await readRegistryFromFile(SANDBOX_REGISTRY_PATH, "fallback");
}
function upsertEntry(entries, entry) {
const existing = entries.find((item) => item.containerName === entry.containerName);
const next = entries.filter((item) => item.containerName !== entry.containerName);
next.push({
...entry,
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
image: existing?.image ?? entry.image,
configHash: entry.configHash ?? existing?.configHash
});
return next;
}
function removeEntry(entries, containerName) {
return entries.filter((entry) => entry.containerName !== containerName);
}
async function withRegistryMutation(registryPath, mutate) {
await withRegistryLock(registryPath, async () => {
const next = mutate((await readRegistryFromFile(registryPath, "strict")).entries);
if (next === null) return;
await writeRegistryFile(registryPath, { entries: next });
});
}
async function updateRegistry(entry) {
await withRegistryMutation(SANDBOX_REGISTRY_PATH, (entries) => upsertEntry(entries, entry));
}
async function removeRegistryEntry(containerName) {
await withRegistryMutation(SANDBOX_REGISTRY_PATH, (entries) => {
const next = removeEntry(entries, containerName);
if (next.length === entries.length) return null;
return next;
});
}
async function readBrowserRegistry() {
return await readRegistryFromFile(SANDBOX_BROWSER_REGISTRY_PATH, "fallback");
}
async function updateBrowserRegistry(entry) {
await withRegistryMutation(SANDBOX_BROWSER_REGISTRY_PATH, (entries) => upsertEntry(entries, entry));
}
async function removeBrowserRegistryEntry(containerName) {
await withRegistryMutation(SANDBOX_BROWSER_REGISTRY_PATH, (entries) => {
const next = removeEntry(entries, containerName);
if (next.length === entries.length) return null;
return next;
});
}
//#endregion
//#region src/agents/sandbox/shared.ts
function slugifySessionKey(value) {
const trimmed = value.trim() || "session";
const hash = hashTextSha256(trimmed).slice(0, 8);
return `${trimmed.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "session"}-${hash}`;
}
function resolveSandboxWorkspaceDir(root, sessionKey) {
const resolvedRoot = resolveUserPath(root);
const slug = slugifySessionKey(sessionKey);
return path.join(resolvedRoot, slug);
}
function resolveSandboxScopeKey(scope, sessionKey) {
const trimmed = sessionKey.trim() || "main";
if (scope === "shared") return "shared";
if (scope === "session") return trimmed;
return `agent:${resolveAgentIdFromSessionKey(trimmed)}`;
}
function resolveSandboxAgentId(scopeKey) {
const trimmed = scopeKey.trim();
if (!trimmed || trimmed === "shared") return;
const parts = trimmed.split(":").filter(Boolean);
if (parts[0] === "agent" && parts[1]) return normalizeAgentId(parts[1]);
return resolveAgentIdFromSessionKey(trimmed);
}
//#endregion
//#region src/agents/sandbox/validate-sandbox-security.ts
/**
* Sandbox security validation — blocks dangerous Docker configurations.
*
* Threat model: local-trusted config, but protect against foot-guns and config injection.
* Enforced at runtime when creating sandbox containers.
*/
const BLOCKED_HOST_PATHS = [
"/etc",
"/private/etc",
"/proc",
"/sys",
"/dev",
"/root",
"/boot",
"/run",
"/var/run",
"/private/var/run",
"/var/run/docker.sock",
"/private/var/run/docker.sock",
"/run/docker.sock"
];
const BLOCKED_NETWORK_MODES = new Set(["host"]);
const BLOCKED_SECCOMP_PROFILES = new Set(["unconfined"]);
const BLOCKED_APPARMOR_PROFILES = new Set(["unconfined"]);
/**
* Parse the host/source path from a Docker bind mount string.
* Format: `source:target[:mode]`
*/
function parseBindSourcePath(bind) {
const trimmed = bind.trim();
const firstColon = trimmed.indexOf(":");
if (firstColon <= 0) return trimmed;
return trimmed.slice(0, firstColon);
}
/**
* Normalize a POSIX path: resolve `.`, `..`, collapse `//`, strip trailing `/`.
*/
function normalizeHostPath(raw) {
const trimmed = raw.trim();
return posix.normalize(trimmed).replace(/\/+$/, "") || "/";
}
/**
* String-only blocked-path check (no filesystem I/O).
* Blocks:
* - binds that target blocked paths (equal or under)
* - binds that cover the system root (mounting "/" is never safe)
* - non-absolute source paths (relative / volume names) because they are hard to validate safely
*/
function getBlockedBindReason(bind) {
const sourceRaw = parseBindSourcePath(bind);
if (!sourceRaw.startsWith("/")) return {
kind: "non_absolute",
sourcePath: sourceRaw
};
return getBlockedReasonForSourcePath(normalizeHostPath(sourceRaw));
}
function getBlockedReasonForSourcePath(sourceNormalized) {
if (sourceNormalized === "/") return {
kind: "covers",
blockedPath: "/"
};
for (const blocked of BLOCKED_HOST_PATHS) if (sourceNormalized === blocked || sourceNormalized.startsWith(blocked + "/")) return {
kind: "targets",
blockedPath: blocked
};
return null;
}
function tryRealpathAbsolute(path) {
if (!path.startsWith("/")) return path;
if (!existsSync(path)) return path;
try {
return normalizeHostPath(realpathSync.native(path));
} catch {
return path;
}
}
function formatBindBlockedError(params) {
if (params.reason.kind === "non_absolute") return /* @__PURE__ */ new Error(`Sandbox security: bind mount "${params.bind}" uses a non-absolute source path "${params.reason.sourcePath}". Only absolute POSIX paths are supported for sandbox binds.`);
const verb = params.reason.kind === "covers" ? "covers" : "targets";
return /* @__PURE__ */ new Error(`Sandbox security: bind mount "${params.bind}" ${verb} blocked path "${params.reason.blockedPath}". Mounting system directories (or Docker socket paths) into sandbox containers is not allowed. Use project-specific paths instead (e.g. /home/user/myproject).`);
}
/**
* Validate bind mounts — throws if any source path is dangerous.
* Includes a symlink/realpath pass when the source path exists.
*/
function validateBindMounts(binds) {
if (!binds?.length) return;
for (const rawBind of binds) {
const bind = rawBind.trim();
if (!bind) continue;
const blocked = getBlockedBindReason(bind);
if (blocked) throw formatBindBlockedError({
bind,
reason: blocked
});
const sourceNormalized = normalizeHostPath(parseBindSourcePath(bind));
const sourceReal = tryRealpathAbsolute(sourceNormalized);
if (sourceReal !== sourceNormalized) {
const reason = getBlockedReasonForSourcePath(sourceReal);
if (reason) throw formatBindBlockedError({
bind,
reason
});
}
}
}
function validateNetworkMode(network) {
if (network && BLOCKED_NETWORK_MODES.has(network.trim().toLowerCase())) throw new Error(`Sandbox security: network mode "${network}" is blocked. Network "host" mode bypasses container network isolation. Use "bridge" or "none" instead.`);
}
function validateSeccompProfile(profile) {
if (profile && BLOCKED_SECCOMP_PROFILES.has(profile.trim().toLowerCase())) throw new Error(`Sandbox security: seccomp profile "${profile}" is blocked. Disabling seccomp removes syscall filtering and weakens sandbox isolation. Use a custom seccomp profile file or omit this setting.`);
}
function validateApparmorProfile(profile) {
if (profile && BLOCKED_APPARMOR_PROFILES.has(profile.trim().toLowerCase())) throw new Error(`Sandbox security: apparmor profile "${profile}" is blocked. Disabling AppArmor removes mandatory access controls and weakens sandbox isolation. Use a named AppArmor profile or omit this setting.`);
}
function validateSandboxSecurity(cfg) {
validateBindMounts(cfg.binds);
validateNetworkMode(cfg.network);
validateSeccompProfile(cfg.seccompProfile);
validateApparmorProfile(cfg.apparmorProfile);
}
//#endregion
//#region src/agents/sandbox/docker.ts
function createAbortError() {
const err = /* @__PURE__ */ new Error("Aborted");
err.name = "AbortError";
return err;
}
function execDockerRaw(args, opts) {
return new Promise((resolve, reject) => {
const child = spawn("docker", args, { stdio: [
"pipe",
"pipe",
"pipe"
] });
const stdoutChunks = [];
const stderrChunks = [];
let aborted = false;
const signal = opts?.signal;
const handleAbort = () => {
if (aborted) return;
aborted = true;
child.kill("SIGTERM");
};
if (signal) if (signal.aborted) handleAbort();
else signal.addEventListener("abort", handleAbort);
child.stdout?.on("data", (chunk) => {
stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
child.stderr?.on("data", (chunk) => {
stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
child.on("error", (error) => {
if (signal) signal.removeEventListener("abort", handleAbort);
reject(error);
});
child.on("close", (code) => {
if (signal) signal.removeEventListener("abort", handleAbort);
const stdout = Buffer.concat(stdoutChunks);
const stderr = Buffer.concat(stderrChunks);
if (aborted || signal?.aborted) {
reject(createAbortError());
return;
}
const exitCode = code ?? 0;
if (exitCode !== 0 && !opts?.allowFailure) {
const message = stderr.length > 0 ? stderr.toString("utf8").trim() : "";
reject(Object.assign(new Error(message || `docker ${args.join(" ")} failed`), {
code: exitCode,
stdout,
stderr
}));
return;
}
resolve({
stdout,
stderr,
code: exitCode
});
});
const stdin = child.stdin;
if (stdin) if (opts?.input !== void 0) stdin.end(opts.input);
else stdin.end();
});
}
const HOT_CONTAINER_WINDOW_MS = 300 * 1e3;
async function execDocker(args, opts) {
const result = await execDockerRaw(args, opts);
return {
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
code: result.code
};
}
async function readDockerContainerLabel(containerName, label) {
const result = await execDocker([
"inspect",
"-f",
`{{ index .Config.Labels "${label}" }}`,
containerName
], { allowFailure: true });
if (result.code !== 0) return null;
const raw = result.stdout.trim();
if (!raw || raw === "<no value>") return null;
return raw;
}
async function readDockerPort(containerName, port) {
const result = await execDocker([
"port",
containerName,
`${port}/tcp`
], { allowFailure: true });
if (result.code !== 0) return null;
const match = (result.stdout.trim().split(/\r?\n/)[0] ?? "").match(/:(\d+)\s*$/);
if (!match) return null;
const mapped = Number.parseInt(match[1] ?? "", 10);
return Number.isFinite(mapped) ? mapped : null;
}
async function dockerImageExists(image) {
const result = await execDocker([
"image",
"inspect",
image
], { allowFailure: true });
if (result.code === 0) return true;
const stderr = result.stderr.trim();
if (stderr.includes("No such image")) return false;
throw new Error(`Failed to inspect sandbox image: ${stderr}`);
}
async function ensureDockerImage(image) {
if (await dockerImageExists(image)) return;
if (image === DEFAULT_SANDBOX_IMAGE) {
await execDocker(["pull", "debian:bookworm-slim"]);
await execDocker([
"tag",
"debian:bookworm-slim",
DEFAULT_SANDBOX_IMAGE
]);
return;
}
throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`);
}
async function dockerContainerState(name) {
const result = await execDocker([
"inspect",
"-f",
"{{.State.Running}}",
name
], { allowFailure: true });
if (result.code !== 0) return {
exists: false,
running: false
};
return {
exists: true,
running: result.stdout.trim() === "true"
};
}
function normalizeDockerLimit(value) {
if (value === void 0 || value === null) return;
if (typeof value === "number") return Number.isFinite(value) ? String(value) : void 0;
const trimmed = value.trim();
return trimmed ? trimmed : void 0;
}
function formatUlimitValue(name, value) {
if (!name.trim()) return null;
if (typeof value === "number" || typeof value === "string") {
const raw = String(value).trim();
return raw ? `${name}=${raw}` : null;
}
const soft = typeof value.soft === "number" ? Math.max(0, value.soft) : void 0;
const hard = typeof value.hard === "number" ? Math.max(0, value.hard) : void 0;
if (soft === void 0 && hard === void 0) return null;
if (soft === void 0) return `${name}=${hard}`;
if (hard === void 0) return `${name}=${soft}`;
return `${name}=${soft}:${hard}`;
}
function buildSandboxCreateArgs(params) {
validateSandboxSecurity(params.cfg);
const createdAtMs = params.createdAtMs ?? Date.now();
const args = [
"create",
"--name",
params.name
];
args.push("--label", "openclaw.sandbox=1");
args.push("--label", `openclaw.sessionKey=${params.scopeKey}`);
args.push("--label", `openclaw.createdAtMs=${createdAtMs}`);
if (params.configHash) args.push("--label", `openclaw.configHash=${params.configHash}`);
for (const [key, value] of Object.entries(params.labels ?? {})) if (key && value) args.push("--label", `${key}=${value}`);
if (params.cfg.readOnlyRoot) args.push("--read-only");
for (const entry of params.cfg.tmpfs) args.push("--tmpfs", entry);
if (params.cfg.network) args.push("--network", params.cfg.network);
if (params.cfg.user) args.push("--user", params.cfg.user);
const envSanitization = sanitizeEnvVars(params.cfg.env ?? {});
if (envSanitization.blocked.length > 0) console.warn("[Security] Blocked sensitive environment variables:", envSanitization.blocked.join(", "));
if (envSanitization.warnings.length > 0) console.warn("[Security] Suspicious environment variables:", envSanitization.warnings);
for (const [key, value] of Object.entries(envSanitization.allowed)) args.push("--env", `${key}=${value}`);
for (const cap of params.cfg.capDrop) args.push("--cap-drop", cap);
args.push("--security-opt", "no-new-privileges");
if (params.cfg.seccompProfile) args.push("--security-opt", `seccomp=${params.cfg.seccompProfile}`);
if (params.cfg.apparmorProfile) args.push("--security-opt", `apparmor=${params.cfg.apparmorProfile}`);
for (const entry of params.cfg.dns ?? []) if (entry.trim()) args.push("--dns", entry);
for (const entry of params.cfg.extraHosts ?? []) if (entry.trim()) args.push("--add-host", entry);
if (typeof params.cfg.pidsLimit === "number" && params.cfg.pidsLimit > 0) args.push("--pids-limit", String(params.cfg.pidsLimit));
const memory = normalizeDockerLimit(params.cfg.memory);
if (memory) args.push("--memory", memory);
const memorySwap = normalizeDockerLimit(params.cfg.memorySwap);
if (memorySwap) args.push("--memory-swap", memorySwap);
if (typeof params.cfg.cpus === "number" && params.cfg.cpus > 0) args.push("--cpus", String(params.cfg.cpus));
for (const [name, value] of Object.entries(params.cfg.ulimits ?? {})) {
const formatted = formatUlimitValue(name, value);
if (formatted) args.push("--ulimit", formatted);
}
if (params.cfg.binds?.length) for (const bind of params.cfg.binds) args.push("-v", bind);
return args;
}
async function createSandboxContainer(params) {
const { name, cfg, workspaceDir, scopeKey } = params;
await ensureDockerImage(cfg.image);
const args = buildSandboxCreateArgs({
name,
cfg,
scopeKey,
configHash: params.configHash
});
args.push("--workdir", cfg.workdir);
const mainMountSuffix = params.workspaceAccess === "ro" && workspaceDir === params.agentWorkspaceDir ? ":ro" : "";
args.push("-v", `${workspaceDir}:${cfg.workdir}${mainMountSuffix}`);
if (params.workspaceAccess !== "none" && workspaceDir !== params.agentWorkspaceDir) {
const agentMountSuffix = params.workspaceAccess === "ro" ? ":ro" : "";
args.push("-v", `${params.agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`);
}
args.push(cfg.image, "sleep", "infinity");
await execDocker(args);
await execDocker(["start", name]);
if (cfg.setupCommand?.trim()) await execDocker([
"exec",
"-i",
name,
"sh",
"-lc",
cfg.setupCommand
]);
}
async function readContainerConfigHash(containerName) {
return await readDockerContainerLabel(containerName, "openclaw.configHash");
}
function formatSandboxRecreateHint(params) {
if (params.scope === "session") return formatCliCommand(`openclaw sandbox recreate --session ${params.sessionKey}`);
if (params.scope === "agent") return formatCliCommand(`openclaw sandbox recreate --agent ${resolveSandboxAgentId(params.sessionKey) ?? "main"}`);
return formatCliCommand("openclaw sandbox recreate --all");
}
async function ensureSandboxContainer(params) {
const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey);
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey);
const containerName = `${params.cfg.docker.containerPrefix}${slug}`.slice(0, 63);
const expectedHash = computeSandboxConfigHash({
docker: params.cfg.docker,
workspaceAccess: params.cfg.workspaceAccess,
workspaceDir: params.workspaceDir,
agentWorkspaceDir: params.agentWorkspaceDir
});
const now = Date.now();
const state = await dockerContainerState(containerName);
let hasContainer = state.exists;
let running = state.running;
let currentHash = null;
let hashMismatch = false;
let registryEntry;
if (hasContainer) {
registryEntry = (await readRegistry()).entries.find((entry) => entry.containerName === containerName);
currentHash = await readContainerConfigHash(containerName);
if (!currentHash) currentHash = registryEntry?.configHash ?? null;
hashMismatch = !currentHash || currentHash !== expectedHash;
if (hashMismatch) {
const lastUsedAtMs = registryEntry?.lastUsedAtMs;
if (running && (typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS)) {
const hint = formatSandboxRecreateHint({
scope: params.cfg.scope,
sessionKey: scopeKey
});
defaultRuntime.log(`Sandbox config changed for ${containerName} (recently used). Recreate to apply: ${hint}`);
} else {
await execDocker([
"rm",
"-f",
containerName
], { allowFailure: true });
hasContainer = false;
running = false;
}
}
}
if (!hasContainer) await createSandboxContainer({
name: containerName,
cfg: params.cfg.docker,
workspaceDir: params.workspaceDir,
workspaceAccess: params.cfg.workspaceAccess,
agentWorkspaceDir: params.agentWorkspaceDir,
scopeKey,
configHash: expectedHash
});
else if (!running) await execDocker(["start", containerName]);
await updateRegistry({
containerName,
sessionKey: scopeKey,
createdAtMs: now,
lastUsedAtMs: now,
image: params.cfg.docker.image,
configHash: hashMismatch && running ? currentHash ?? void 0 : expectedHash
});
return containerName;
}
//#endregion
//#region src/agents/sandbox/browser.ts
const HOT_BROWSER_WINDOW_MS = 300 * 1e3;
async function waitForSandboxCdp(params) {
const deadline = Date.now() + Math.max(0, params.timeoutMs);
const url = `http://127.0.0.1:${params.cdpPort}/json/version`;
while (Date.now() < deadline) {
try {
const ctrl = new AbortController();
const t = setTimeout(ctrl.abort.bind(ctrl), 1e3);
try {
if ((await fetch(url, { signal: ctrl.signal })).ok) return true;
} finally {
clearTimeout(t);
}
} catch {}
await new Promise((r) => setTimeout(r, 150));
}
return false;
}
function buildSandboxBrowserResolvedConfig(params) {
return {
enabled: true,
evaluateEnabled: params.evaluateEnabled,
controlPort: params.controlPort,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3e3,
color: DEFAULT_OPENCLAW_BROWSER_COLOR,
executablePath: void 0,
headless: params.headless,
noSandbox: false,
attachOnly: true,
defaultProfile: DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
extraArgs: [],
profiles: { [DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]: {
cdpPort: params.cdpPort,
color: DEFAULT_OPENCLAW_BROWSER_COLOR
} }
};
}
async function ensureSandboxBrowserImage(image) {
if ((await execDocker([
"image",
"inspect",
image
], { allowFailure: true })).code === 0) return;
throw new Error(`Sandbox browser image not found: ${image}. Build it with scripts/sandbox-browser-setup.sh.`);
}
async function ensureSandboxBrowser(params) {
if (!params.cfg.browser.enabled) return null;
if (!isToolAllowed(params.cfg.tools, "browser")) return null;
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey);
const containerName = `${params.cfg.browser.containerPrefix}${slug}`.slice(0, 63);
const state = await dockerContainerState(containerName);
const browserImage = params.cfg.browser.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE;
const browserDockerCfg = resolveSandboxBrowserDockerCreateConfig({
docker: params.cfg.docker,
browser: {
...params.cfg.browser,
image: browserImage
}
});
const expectedHash = computeSandboxBrowserConfigHash({
docker: browserDockerCfg,
browser: {
cdpPort: params.cfg.browser.cdpPort,
vncPort: params.cfg.browser.vncPort,
noVncPort: params.cfg.browser.noVncPort,
headless: params.cfg.browser.headless,
enableNoVnc: params.cfg.browser.enableNoVnc
},
workspaceAccess: params.cfg.workspaceAccess,
workspaceDir: params.workspaceDir,
agentWorkspaceDir: params.agentWorkspaceDir
});
const now = Date.now();
let hasContainer = state.exists;
let running = state.running;
let currentHash = null;
let hashMismatch = false;
if (hasContainer) {
const registryEntry = (await readBrowserRegistry()).entries.find((entry) => entry.containerName === containerName);
currentHash = await readDockerContainerLabel(containerName, "openclaw.configHash");
hashMismatch = !currentHash || currentHash !== expectedHash;
if (!currentHash) {
currentHash = registryEntry?.configHash ?? null;
hashMismatch = !currentHash || currentHash !== expectedHash;
}
if (hashMismatch) {
const lastUsedAtMs = registryEntry?.lastUsedAtMs;
if (running && (typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_BROWSER_WINDOW_MS)) {
const hint = (() => {
if (params.cfg.scope === "session") return `openclaw sandbox recreate --browser --session ${params.scopeKey}`;
if (params.cfg.scope === "agent") return `openclaw sandbox recreate --browser --agent ${resolveSandboxAgentId(params.scopeKey) ?? "main"}`;
return "openclaw sandbox recreate --browser --all";
})();
defaultRuntime.log(`Sandbox browser config changed for ${containerName} (recently used). Recreate to apply: ${hint}`);
} else {
await execDocker([
"rm",
"-f",
containerName
], { allowFailure: true });
hasContainer = false;
running = false;
}
}
}
if (!hasContainer) {
await ensureSandboxBrowserImage(browserImage);
const args = buildSandboxCreateArgs({
name: containerName,
cfg: browserDockerCfg,
scopeKey: params.scopeKey,
labels: { "openclaw.sandboxBrowser": "1" },
configHash: expectedHash
});
const mainMountSuffix = params.cfg.workspaceAccess === "ro" && params.workspaceDir === params.agentWorkspaceDir ? ":ro" : "";
args.push("-v", `${params.workspaceDir}:${params.cfg.docker.workdir}${mainMountSuffix}`);
if (params.cfg.workspaceAccess !== "none" && params.workspaceDir !== params.agentWorkspaceDir) {
const agentMountSuffix = params.cfg.workspaceAccess === "ro" ? ":ro" : "";
args.push("-v", `${params.agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`);
}
args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`);
if (params.cfg.browser.enableNoVnc && !params.cfg.browser.headless) args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`);
args.push("-e", `OPENCLAW_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`);
args.push("-e", `OPENCLAW_BROWSER_ENABLE_NOVNC=${params.cfg.browser.enableNoVnc ? "1" : "0"}`);
args.push("-e", `OPENCLAW_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`);
args.push("-e", `OPENCLAW_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`);
args.push("-e", `OPENCLAW_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`);
args.push(browserImage);
await execDocker(args);
await execDocker(["start", containerName]);
} else if (!running) await execDocker(["start", containerName]);
co