UNPKG

@gguf/claw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,399 lines (1,381 loc) 75.7 kB
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