UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

590 lines (589 loc) 22.5 kB
import { r as resolveOpenClawPackageRootsSync } from "./openclaw-root-CfYY-fyD.js"; import { Et as array, Rn as string, Tn as object } from "./schemas-zxit8y5H.js"; import { m as shortenHomePath } from "./utils-P__uGsPB.js"; import { t as formatCliCommand } from "./command-format-C7YfyMTd.js"; import { i as listAgentEntries } from "./agent-scope-config-DcbEhP0R.js"; import { t as safeParseJsonWithSchema } from "./zod-parse-Bip-sZi_.js"; import { n as resolveSandboxScope } from "./config-contract-CFOz6uqW.js"; import { s as withFileLock } from "./file-lock-B0wiaenm.js"; import "./file-lock-DPooFrLa.js"; import { n as runExec, r as runCommandWithTimeout } from "./exec-BIE-3oLG.js"; import { S as SANDBOX_CONTAINERS_DIR, _ as SANDBOX_BROWSERS_DIR, r as DEFAULT_SANDBOX_BROWSER_IMAGE, u as DEFAULT_SANDBOX_IMAGE, w as SANDBOX_REGISTRY_PATH, y as SANDBOX_BROWSER_REGISTRY_PATH } from "./constants-DE2EIeEP.js"; import { _ as validateSandboxContainerEngineTarget, u as isDockerDaemonUnavailable, v as DOCKER_SANDBOX_ENGINE, y as PODMAN_SANDBOX_ENGINE } from "./docker-BzxWmWPm.js"; import { n as insertSandboxRegistryEntryIfMissing, t as insertSandboxBrowserRegistryEntryIfMissing } from "./registry-Blki4jt_.js"; import "./sandbox-A2KOznlu.js"; import { t as note } from "./note-DqHk3fA1.js"; import fs from "node:fs"; import path from "node:path"; import fs$1 from "node:fs/promises"; //#region src/commands/doctor-sandbox-legacy-registry.ts /** Doctor-only inspection and migration for legacy sandbox registry files. */ const RegistryEntrySchema = object({ containerName: string() }).passthrough(); const RegistryFileSchema = object({ entries: array(RegistryEntrySchema) }); async function readLegacyRegistryFile(registryPath) { try { const raw = await fs$1.readFile(registryPath, "utf-8"); return safeParseJsonWithSchema(RegistryFileSchema, raw); } catch (error) { if (error?.code === "ENOENT") return { entries: [] }; if (error instanceof Error) throw error; throw new Error(`Failed to read sandbox registry file: ${registryPath}`, { cause: error }); } } async function readShardedEntriesDetailed(dir) { let files; try { files = await fs$1.readdir(dir); } catch (error) { if (error?.code === "ENOENT") return { entries: [], invalidFiles: [] }; throw error; } const invalidFiles = []; return { entries: (await Promise.all(files.filter((name) => name.endsWith(".json")).toSorted().map(async (name) => { const filePath = path.join(dir, name); try { const raw = await fs$1.readFile(filePath, "utf-8"); const entry = safeParseJsonWithSchema(RegistryEntrySchema, raw); if (!entry) invalidFiles.push(filePath); return entry; } catch { invalidFiles.push(filePath); return null; } }))).filter((entry) => entry !== null).toSorted((left, right) => left.containerName.localeCompare(right.containerName)), invalidFiles: invalidFiles.toSorted() }; } async function quarantineLegacyRegistry(registryPath) { const quarantinePath = `${registryPath}.invalid-${Date.now()}`; await fs$1.rename(registryPath, quarantinePath).catch(async (error) => { if (error?.code !== "ENOENT") await fs$1.rm(registryPath, { force: true }); }); return quarantinePath; } async function quarantineInvalidShards(dir, invalidFiles) { const quarantineDir = `${dir}.invalid-${Date.now()}`; await fs$1.mkdir(quarantineDir, { recursive: true }); for (const invalidFile of invalidFiles) await fs$1.rename(invalidFile, path.join(quarantineDir, path.basename(invalidFile))).catch(async (error) => { if (error?.code !== "ENOENT") throw error; }); return quarantineDir; } function writeLegacyEntryIfMissing(kind, entry) { if (kind === "containers") { insertSandboxRegistryEntryIfMissing({ ...entry, containerName: entry.containerName, sessionKey: typeof entry.sessionKey === "string" ? entry.sessionKey : "", createdAtMs: typeof entry.createdAtMs === "number" ? entry.createdAtMs : 0, lastUsedAtMs: typeof entry.lastUsedAtMs === "number" ? entry.lastUsedAtMs : 0, image: typeof entry.image === "string" ? entry.image : "" }); return; } insertSandboxBrowserRegistryEntryIfMissing({ ...entry, containerName: entry.containerName, sessionKey: typeof entry.sessionKey === "string" ? entry.sessionKey : "", createdAtMs: typeof entry.createdAtMs === "number" ? entry.createdAtMs : 0, lastUsedAtMs: typeof entry.lastUsedAtMs === "number" ? entry.lastUsedAtMs : 0, image: typeof entry.image === "string" ? entry.image : "", cdpPort: typeof entry.cdpPort === "number" ? entry.cdpPort : 0 }); } async function migrateMonolithicIfNeeded(target) { const { registryPath } = target; try { await fs$1.access(registryPath); } catch (error) { if (error?.code === "ENOENT") return { kind: target.kind, status: "missing" }; throw error; } return await withFileLock(registryPath, { stale: 3e4, retries: { retries: 59, factor: 1, minTimeout: 1e3, maxTimeout: 1e3 } }, async () => { const registry = await readLegacyRegistryFile(registryPath); if (!registry) { const quarantinePath = await quarantineLegacyRegistry(registryPath); return { kind: target.kind, status: "quarantined-invalid", path: registryPath, quarantinePath }; } if (registry.entries.length === 0) { await fs$1.rm(registryPath, { force: true }); return { kind: target.kind, status: "removed-empty" }; } for (const entry of registry.entries) writeLegacyEntryIfMissing(target.kind, entry); await fs$1.rm(registryPath, { force: true }); return { kind: target.kind, status: "migrated", entries: registry.entries.length }; }); } async function migrateShardedIfNeeded(target) { let dirExists = false; try { dirExists = (await fs$1.stat(target.shardedDir)).isDirectory(); } catch (error) { if (error?.code !== "ENOENT") throw error; } if (!dirExists) return { kind: target.kind, status: "missing" }; const { entries, invalidFiles } = await readShardedEntriesDetailed(target.shardedDir); if (invalidFiles.length > 0) { for (const entry of entries) writeLegacyEntryIfMissing(target.kind, entry); const quarantinePath = await quarantineInvalidShards(target.shardedDir, invalidFiles); await fs$1.rm(target.shardedDir, { recursive: true, force: true }); return { kind: target.kind, status: "quarantined-invalid", path: target.shardedDir, quarantinePath }; } if (entries.length === 0) { await fs$1.rm(target.shardedDir, { recursive: true, force: true }); return { kind: target.kind, status: "removed-empty" }; } for (const entry of entries) writeLegacyEntryIfMissing(target.kind, entry); await fs$1.rm(target.shardedDir, { recursive: true, force: true }); return { kind: target.kind, status: "migrated", entries: entries.length }; } function combineMigrationResults(target, monolithic, sharded) { if (monolithic.status === "quarantined-invalid") return monolithic; if (sharded.status === "quarantined-invalid") return sharded; const entries = (monolithic.status === "migrated" ? monolithic.entries : 0) + (sharded.status === "migrated" ? sharded.entries : 0); if (entries > 0) return { kind: target.kind, status: "migrated", entries }; if (monolithic.status === "removed-empty" || sharded.status === "removed-empty") return { kind: target.kind, status: "removed-empty" }; return { kind: target.kind, status: "missing" }; } function legacyRegistryTargets() { return [{ kind: "containers", registryPath: SANDBOX_REGISTRY_PATH, shardedDir: SANDBOX_CONTAINERS_DIR }, { kind: "browsers", registryPath: SANDBOX_BROWSER_REGISTRY_PATH, shardedDir: SANDBOX_BROWSERS_DIR }]; } /** Inspects old registry files without mutating them. */ async function inspectLegacySandboxRegistryFiles() { const inspections = []; for (const target of legacyRegistryTargets()) { try { await fs$1.access(target.registryPath); } catch (error) { if (error?.code === "ENOENT") inspections.push({ kind: target.kind, path: target.registryPath, source: "monolithic", exists: false, valid: true, entries: 0 }); else throw error; } if (!inspections.some((entry) => entry.kind === target.kind && entry.source === "monolithic")) { const registry = await readLegacyRegistryFile(target.registryPath); inspections.push({ kind: target.kind, path: target.registryPath, source: "monolithic", exists: true, valid: Boolean(registry), entries: registry?.entries.length ?? 0 }); } const sharded = await readShardedEntriesDetailed(target.shardedDir); let shardedExists = false; try { shardedExists = (await fs$1.stat(target.shardedDir)).isDirectory(); } catch (error) { if (error?.code !== "ENOENT") throw error; } inspections.push({ kind: target.kind, path: target.shardedDir, source: "sharded", exists: shardedExists, valid: sharded.invalidFiles.length === 0, entries: sharded.entries.length }); } return inspections; } /** Migrates old registry files into SQLite when present. */ async function migrateLegacySandboxRegistryFiles() { const results = []; for (const target of legacyRegistryTargets()) { const sharded = await migrateShardedIfNeeded(target); const monolithic = await migrateMonolithicIfNeeded(target); results.push(combineMigrationResults(target, monolithic, sharded)); } return results; } //#endregion //#region src/commands/doctor-sandbox.ts /** Doctor checks and repairs for Docker sandbox images, namespaces, and registry state. */ const SANDBOX_REGISTRY_FILES_CHECK_ID = "core/doctor/sandbox/registry-files"; function resolveSandboxScript(scriptRel, options = {}) { const roots = resolveOpenClawPackageRootsSync({ cwd: options.cwd ?? process.cwd(), argv1: options.argv1 ?? process.argv[1] }); for (const root of roots) { const scriptPath = path.join(root, scriptRel); if (fs.existsSync(scriptPath)) return { scriptPath, cwd: root }; } return null; } if (process.env.VITEST || false) globalThis[Symbol.for("openclaw.doctorSandboxTestApi")] = { resolveSandboxScript }; async function runSandboxScript(scriptRel, runtime) { const script = resolveSandboxScript(scriptRel); if (!script) { note(`Unable to locate ${scriptRel}. Run it from the repo root.`, "Sandbox"); return false; } runtime.log(`Running ${scriptRel}...`); const result = await runCommandWithTimeout(["bash", script.scriptPath], { timeoutMs: 12e5, cwd: script.cwd }); if (result.code !== 0) { runtime.error(`Failed running ${scriptRel}: ${result.stderr.trim() || result.stdout.trim() || "unknown error"}`); return false; } runtime.log(`Completed ${scriptRel}.`); return true; } async function isContainerEngineAvailable(command) { try { await runExec(command, command === "docker" ? [ "version", "--format", "{{.Server.Version}}" ] : ["info"], { timeoutMs: 5e3 }); return true; } catch { return false; } } function formatNamespaceProbeCommand(args) { return ["unshare", ...args].join(" "); } async function runCodexBwrapNamespaceProbe(kind, args) { try { await runExec("unshare", args, { timeoutMs: 5e3 }); return { ok: true }; } catch (error) { const reason = error?.stderr?.trim() || error?.stdout?.trim() || (error instanceof Error ? error.message : String(error)); return { ok: false, kind, command: formatNamespaceProbeCommand(args), reason }; } } function codexBwrapNeedsNetworkNamespaceProbe(cfg) { const network = cfg.agents?.defaults?.sandbox?.docker?.network?.trim().toLowerCase(); return network === void 0 || network === "" || network === "none"; } async function probeCodexBwrapNamespaces(cfg) { if (process.platform !== "linux") return { ok: true }; const userProbe = await runCodexBwrapNamespaceProbe("user", [ "--user", "--map-root-user", "true" ]); if (!userProbe.ok || !codexBwrapNeedsNetworkNamespaceProbe(cfg)) return userProbe; return await runCodexBwrapNamespaceProbe("network", [ "--user", "--map-root-user", "--net", "true" ]); } async function noteCodexBwrapNamespaceWarning(cfg, engineName) { const probe = await probeCodexBwrapNamespaces(cfg); if (probe.ok) return; const symptom = probe.kind === "user" ? " bwrap: setting up uid map: Permission denied" : " bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted"; const networkSentence = codexBwrapNeedsNetworkNamespaceProbe(cfg) ? `With ${engineName} sandbox network egress disabled, it also needs an unprivileged network namespace.` : `${engineName} sandbox network egress is enabled, so doctor only checked the user namespace.`; const lines = [ `Codex bwrap ${probe.kind} namespace probe failed while ${engineName} sandbox mode is enabled.`, `Codex app-server \`workspace-write\` shell execution needs unprivileged user namespaces. ${networkSentence}`, "On Ubuntu/AppArmor hosts this usually appears as:", symptom, `Probe command: ${probe.command}`, `Probe result: ${probe.reason}`, "", "Fix the host namespace policy for the OpenClaw service user, then restart the gateway.", "Prefer an AppArmor profile that grants the required namespaces to the OpenClaw service process.", "`kernel.apparmor_restrict_unprivileged_userns=0` is a host-wide fallback with security tradeoffs; use it only when that host posture is acceptable.", "Do not add broad Docker container privileges just to satisfy nested bwrap; that weakens the outer sandbox." ]; note(lines.join("\n"), "Sandbox"); } async function containerImageExists(command, image) { try { await runExec(command, [ "image", "inspect", image ], { timeoutMs: 5e3 }); return true; } catch (error) { const stderr = error?.stderr || error?.message || ""; if (command === "docker" ? stderr.includes("No such image") : /No such image|image not known|image .* not found/iu.test(stderr)) return false; if (command === "docker" && isDockerDaemonUnavailable(stderr)) return false; throw error; } } function resolveSandboxDockerImage(cfg) { const image = cfg.agents?.defaults?.sandbox?.docker?.image?.trim(); return image ? image : DEFAULT_SANDBOX_IMAGE; } function resolveSandboxBackend(cfg) { return (cfg.agents?.defaults?.sandbox?.backend?.trim() || "docker").toLowerCase(); } function resolveSandboxBrowserImage(cfg) { const image = cfg.agents?.defaults?.sandbox?.browser?.image?.trim(); return image ? image : DEFAULT_SANDBOX_BROWSER_IMAGE; } function updateSandboxDockerImage(cfg, image) { return { ...cfg, agents: { ...cfg.agents, defaults: { ...cfg.agents?.defaults, sandbox: { ...cfg.agents?.defaults?.sandbox, docker: { ...cfg.agents?.defaults?.sandbox?.docker, image } } } } }; } function updateSandboxBrowserImage(cfg, image) { return { ...cfg, agents: { ...cfg.agents, defaults: { ...cfg.agents?.defaults, sandbox: { ...cfg.agents?.defaults?.sandbox, browser: { ...cfg.agents?.defaults?.sandbox?.browser, image } } } } }; } async function handleMissingSandboxImage(params, runtime, prompter) { if (await containerImageExists(params.engineCommand, params.image)) return; const buildHint = params.buildScript ? `Build it with ${params.buildScript}.` : "Build or pull it first."; note(`Sandbox ${params.kind} image missing: ${params.image}. ${buildHint}`, "Sandbox"); if (params.buildScript) { if (await prompter.confirmRuntimeRepair({ message: `Build ${params.kind} sandbox image now?`, initialValue: true })) await runSandboxScript(params.buildScript, runtime); } } /** * Checks configured sandbox images and optionally runs repo build scripts for missing defaults. * * Non-container backends skip image checks; local container mode also probes Codex bwrap namespace * support because nested app-server shells rely on host user/network namespace policy. */ async function maybeRepairSandboxImages(cfg, runtime, prompter) { const sandbox = cfg.agents?.defaults?.sandbox; const mode = sandbox?.mode ?? "off"; if (!sandbox || mode === "off") return cfg; const backend = resolveSandboxBackend(cfg); if (backend !== "docker" && backend !== "podman") { if (sandbox.browser?.enabled) note(`Sandbox backend "${backend}" selected. Docker browser health checks are skipped; browser sandbox currently requires the docker backend.`, "Sandbox"); return cfg; } const containerEngine = backend === "podman" ? PODMAN_SANDBOX_ENGINE : DOCKER_SANDBOX_ENGINE; if (!await isContainerEngineAvailable(containerEngine.command)) { const lines = containerEngine.id === "docker" ? [ `Sandbox mode is enabled (mode: "${mode}") but Docker is not available.`, "Docker is required for sandbox mode to function.", "Isolated sessions (automations, sub-agents) will fail without Docker.", "", "Options:", "- Install Docker and restart the gateway", "- Disable sandbox mode: openclaw config set agents.defaults.sandbox.mode off" ] : [ `Sandbox mode is enabled (mode: "${mode}") but Podman is not available.`, "Podman is required by the selected sandbox backend.", "Isolated sessions (automations, sub-agents) will fail without Podman.", "", "Options:", "- Install Podman and restart the gateway", "- Disable sandbox mode: openclaw config set agents.defaults.sandbox.mode off" ]; note(lines.join("\n"), "Sandbox"); return cfg; } await validateSandboxContainerEngineTarget(containerEngine); await noteCodexBwrapNamespaceWarning(cfg, containerEngine.displayName); let next = cfg; const changes = []; const dockerImage = resolveSandboxDockerImage(cfg); await handleMissingSandboxImage({ engineCommand: containerEngine.command, kind: "base", image: dockerImage, buildScript: containerEngine.id !== "docker" ? void 0 : dockerImage === "openclaw-sandbox-common:bookworm-slim" ? "scripts/sandbox-common-setup.sh" : dockerImage === "openclaw-sandbox:bookworm-slim" ? "scripts/sandbox-setup.sh" : void 0, updateConfig: (image) => { next = updateSandboxDockerImage(next, image); changes.push(`Updated agents.defaults.sandbox.docker.image → ${image}`); } }, runtime, prompter); if (sandbox.browser?.enabled && containerEngine.id === "docker") await handleMissingSandboxImage({ engineCommand: containerEngine.command, kind: "browser", image: resolveSandboxBrowserImage(cfg), buildScript: "scripts/sandbox-browser-setup.sh", updateConfig: (image) => { next = updateSandboxBrowserImage(next, image); changes.push(`Updated agents.defaults.sandbox.browser.image → ${image}`); } }, runtime, prompter); else if (sandbox.browser?.enabled) note("Podman sandbox selected. Browser sandbox health checks are skipped because browser sandboxing requires the Docker engine.", "Sandbox"); if (changes.length > 0) note(changes.join("\n"), "Doctor changes"); return next; } function formatLegacyRegistryInspectionLine(file) { const status = file.valid ? `${file.entries} entr${file.entries === 1 ? "y" : "ies"}` : "invalid"; return `- ${file.kind} ${file.source}: ${shortenHomePath(file.path)} (${status})`; } function formatLegacyRegistryMigrationLine(result) { if (result.status === "migrated") return `- Migrated ${result.kind} registry into ${result.entries} SQLite row${result.entries === 1 ? "" : "s"}.`; if (result.status === "removed-empty") return `- Removed empty legacy ${result.kind} registry files.`; if (result.status === "quarantined-invalid") { const file = shortenHomePath(result.path); const quarantine = ` to ${shortenHomePath(result.quarantinePath)}`; return `- Quarantined invalid legacy ${result.kind} registry ${file}${quarantine}.`; } return ""; } async function detectLegacySandboxRegistryFileIssues() { return (await inspectLegacySandboxRegistryFiles()).filter((file) => file.exists); } function legacySandboxRegistryInspectionToHealthFinding(file) { return { checkId: SANDBOX_REGISTRY_FILES_CHECK_ID, severity: "warning", message: `Legacy sandbox registry file detected. ${formatLegacyRegistryInspectionLine(file)}`, path: file.path, fixHint: `Run ${formatCliCommand("openclaw doctor --fix")} to migrate valid entries to SQLite.` }; } function legacySandboxRegistryInspectionToRepairEffect(file) { return { kind: "state", action: !file.valid ? "would-quarantine-legacy-sandbox-registry" : file.entries === 0 ? "would-remove-empty-legacy-sandbox-registry" : "would-migrate-legacy-sandbox-registry", target: file.path, dryRunSafe: false }; } /** Migrates legacy sandbox registry files and directories. */ async function maybeRepairSandboxRegistryFiles(prompter) { const legacyFiles = await detectLegacySandboxRegistryFileIssues(); if (legacyFiles.length === 0) return; if (!prompter.shouldRepair) { note([ "Legacy sandbox registry files detected.", ...legacyFiles.map(formatLegacyRegistryInspectionLine), `Run ${formatCliCommand("openclaw doctor --fix")} to migrate them to SQLite.` ].join("\n"), "Sandbox"); return; } const results = (await migrateLegacySandboxRegistryFiles()).filter((result) => result.status !== "missing").map(formatLegacyRegistryMigrationLine).filter((line) => line.length > 0); if (results.length > 0) note(results.join("\n"), "Doctor changes"); } /** Warns when agent sandbox overrides are ignored because sandbox scope resolves to shared. */ function noteSandboxScopeWarnings(cfg) { const globalSandbox = cfg.agents?.defaults?.sandbox; const warnings = []; for (const agent of listAgentEntries(cfg)) { const agentId = agent.id; const agentSandbox = agent.sandbox; if (!agentSandbox) continue; if (resolveSandboxScope({ scope: agentSandbox.scope ?? globalSandbox?.scope }) !== "shared") continue; const overrides = []; if (agentSandbox.docker && Object.keys(agentSandbox.docker).length > 0) overrides.push("docker"); if (agentSandbox.browser && Object.keys(agentSandbox.browser).length > 0) overrides.push("browser"); if (agentSandbox.prune && Object.keys(agentSandbox.prune).length > 0) overrides.push("prune"); if (overrides.length === 0) continue; warnings.push([`- agents.entries.${agentId} sandbox ${overrides.join("/")} overrides ignored.`, ` scope resolves to "shared".`].join("\n")); } if (warnings.length > 0) note(warnings.join("\n"), "Sandbox"); } //#endregion export { detectLegacySandboxRegistryFileIssues, legacySandboxRegistryInspectionToHealthFinding, legacySandboxRegistryInspectionToRepairEffect, maybeRepairSandboxImages, maybeRepairSandboxRegistryFiles, noteSandboxScopeWarnings };