style: apply oxfmt formatting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Muhsinun Chowdhury 2026-01-29 18:49:13 -05:00
parent 0a2a7fbcd1
commit dc41f74e72
2 changed files with 369 additions and 284 deletions

View File

@ -1,23 +1,32 @@
import dns from "node:dns/promises"; import dns from "node:dns/promises"
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js"; import {
import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js"; startBrowserBridgeServer,
stopBrowserBridgeServer,
} from "../../browser/bridge-server.js"
import {
type ResolvedBrowserConfig,
resolveProfile,
} from "../../browser/config.js"
import { import {
DEFAULT_BROWSER_EVALUATE_ENABLED, DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_COLOR,
} from "../../browser/constants.js"; } from "../../browser/constants.js"
import { BROWSER_BRIDGES } from "./browser-bridges.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"
import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; import {
DEFAULT_SANDBOX_BROWSER_IMAGE,
SANDBOX_AGENT_WORKSPACE_MOUNT,
} from "./constants.js"
import { import {
buildSandboxCreateArgs, buildSandboxCreateArgs,
dockerContainerState, dockerContainerState,
execDocker, execDocker,
readDockerPort, readDockerPort,
remapPathForDinD, remapPathForDinD,
} from "./docker.js"; } from "./docker.js"
import { updateBrowserRegistry } from "./registry.js"; import { updateBrowserRegistry } from "./registry.js"
import { slugifySessionKey } from "./shared.js"; import { slugifySessionKey } from "./shared.js"
import { isToolAllowed } from "./tool-policy.js"; import { isToolAllowed } from "./tool-policy.js"
import type { SandboxBrowserContext, SandboxConfig } from "./types.js"; import type { SandboxBrowserContext, SandboxConfig } from "./types.js"
/** /**
* Resolve a hostname to an IPv4 address for CDP connections. * Resolve a hostname to an IPv4 address for CDP connections.
@ -27,57 +36,59 @@ import type { SandboxBrowserContext, SandboxConfig } from "./types.js";
async function resolveHostToIp(host: string): Promise<string> { async function resolveHostToIp(host: string): Promise<string> {
// If already an IP address (v4 or v6), return as-is // If already an IP address (v4 or v6), return as-is
if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(host) || host.includes(":")) { if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(host) || host.includes(":")) {
return host; return host
} }
// localhost is special-cased by Chrome // localhost is special-cased by Chrome
if (host === "localhost") { if (host === "localhost") {
return host; return host
} }
try { try {
const result = await dns.lookup(host, { family: 4 }); const result = await dns.lookup(host, { family: 4 })
return result.address; return result.address
} catch { } catch {
// If DNS resolution fails, return original host and let caller handle the error // If DNS resolution fails, return original host and let caller handle the error
return host; return host
} }
} }
async function waitForSandboxCdp(params: { async function waitForSandboxCdp(params: {
cdpHost: string; cdpHost: string
cdpPort: number; cdpPort: number
timeoutMs: number; timeoutMs: number
}): Promise<boolean> { }): Promise<boolean> {
const deadline = Date.now() + Math.max(0, params.timeoutMs); const deadline = Date.now() + Math.max(0, params.timeoutMs)
// Resolve hostname to IP for Chrome CDP compatibility // Resolve hostname to IP for Chrome CDP compatibility
const resolvedHost = await resolveHostToIp(params.cdpHost); const resolvedHost = await resolveHostToIp(params.cdpHost)
const url = `http://${resolvedHost}:${params.cdpPort}/json/version`; const url = `http://${resolvedHost}:${params.cdpPort}/json/version`
while (Date.now() < deadline) { while (Date.now() < deadline) {
try { try {
const ctrl = new AbortController(); const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), 1000); const t = setTimeout(() => ctrl.abort(), 1000)
try { try {
const res = await fetch(url, { signal: ctrl.signal }); const res = await fetch(url, { signal: ctrl.signal })
if (res.ok) return true; if (res.ok) return true
} finally { } finally {
clearTimeout(t); clearTimeout(t)
} }
} catch { } catch {
// ignore // ignore
} }
await new Promise((r) => setTimeout(r, 150)); await new Promise((r) => setTimeout(r, 150))
} }
return false; return false
} }
function buildSandboxBrowserResolvedConfig(params: { function buildSandboxBrowserResolvedConfig(params: {
controlPort: number; controlPort: number
cdpHost: string; cdpHost: string
cdpPort: number; cdpPort: number
headless: boolean; headless: boolean
evaluateEnabled: boolean; evaluateEnabled: boolean
}): ResolvedBrowserConfig { }): ResolvedBrowserConfig {
const isLoopback = const isLoopback =
params.cdpHost === "127.0.0.1" || params.cdpHost === "localhost" || params.cdpHost === "::1"; params.cdpHost === "127.0.0.1" ||
params.cdpHost === "localhost" ||
params.cdpHost === "::1"
return { return {
enabled: true, enabled: true,
evaluateEnabled: params.evaluateEnabled, evaluateEnabled: params.evaluateEnabled,
@ -96,120 +107,150 @@ function buildSandboxBrowserResolvedConfig(params: {
profiles: { profiles: {
clawd: { cdpPort: params.cdpPort, color: DEFAULT_CLAWD_BROWSER_COLOR }, clawd: { cdpPort: params.cdpPort, color: DEFAULT_CLAWD_BROWSER_COLOR },
}, },
}; }
} }
async function ensureSandboxBrowserImage(image: string) { async function ensureSandboxBrowserImage(image: string) {
const result = await execDocker(["image", "inspect", image], { const result = await execDocker(["image", "inspect", image], {
allowFailure: true, allowFailure: true,
}); })
if (result.code === 0) return; if (result.code === 0) return
throw new Error( throw new Error(
`Sandbox browser image not found: ${image}. Build it with scripts/sandbox-browser-setup.sh.`, `Sandbox browser image not found: ${image}. Build it with scripts/sandbox-browser-setup.sh.`,
); )
} }
export async function ensureSandboxBrowser(params: { export async function ensureSandboxBrowser(params: {
scopeKey: string; scopeKey: string
workspaceDir: string; workspaceDir: string
agentWorkspaceDir: string; agentWorkspaceDir: string
cfg: SandboxConfig; cfg: SandboxConfig
evaluateEnabled?: boolean; evaluateEnabled?: boolean
}): Promise<SandboxBrowserContext | null> { }): Promise<SandboxBrowserContext | null> {
if (!params.cfg.browser.enabled) return null; if (!params.cfg.browser.enabled) return null
if (!isToolAllowed(params.cfg.tools, "browser")) return null; if (!isToolAllowed(params.cfg.tools, "browser")) return null
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey); const slug =
const name = `${params.cfg.browser.containerPrefix}${slug}`; params.cfg.scope === "shared"
const containerName = name.slice(0, 63); ? "shared"
const state = await dockerContainerState(containerName); : slugifySessionKey(params.scopeKey)
const name = `${params.cfg.browser.containerPrefix}${slug}`
const containerName = name.slice(0, 63)
const state = await dockerContainerState(containerName)
if (!state.exists) { if (!state.exists) {
await ensureSandboxBrowserImage(params.cfg.browser.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE); await ensureSandboxBrowserImage(
params.cfg.browser.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE,
)
const args = buildSandboxCreateArgs({ const args = buildSandboxCreateArgs({
name: containerName, name: containerName,
cfg: params.cfg.docker, cfg: params.cfg.docker,
scopeKey: params.scopeKey, scopeKey: params.scopeKey,
labels: { "moltbot.sandboxBrowser": "1" }, labels: { "moltbot.sandboxBrowser": "1" },
}); })
const mainMountSuffix = const mainMountSuffix =
params.cfg.workspaceAccess === "ro" && params.workspaceDir === params.agentWorkspaceDir params.cfg.workspaceAccess === "ro" &&
params.workspaceDir === params.agentWorkspaceDir
? ":ro" ? ":ro"
: ""; : ""
// Remap paths for Docker-in-Docker scenarios // Remap paths for Docker-in-Docker scenarios
const hostWorkspaceDir = remapPathForDinD(params.workspaceDir); const hostWorkspaceDir = remapPathForDinD(params.workspaceDir)
args.push("-v", `${hostWorkspaceDir}:${params.cfg.docker.workdir}${mainMountSuffix}`); args.push(
if (params.cfg.workspaceAccess !== "none" && params.workspaceDir !== params.agentWorkspaceDir) { "-v",
const agentMountSuffix = params.cfg.workspaceAccess === "ro" ? ":ro" : ""; `${hostWorkspaceDir}:${params.cfg.docker.workdir}${mainMountSuffix}`,
const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir); )
if (
params.cfg.workspaceAccess !== "none" &&
params.workspaceDir !== params.agentWorkspaceDir
) {
const agentMountSuffix = params.cfg.workspaceAccess === "ro" ? ":ro" : ""
const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir)
args.push( args.push(
"-v", "-v",
`${hostAgentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`, `${hostAgentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`,
); )
} }
args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`); args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`)
if (params.cfg.browser.enableNoVnc && !params.cfg.browser.headless) { if (params.cfg.browser.enableNoVnc && !params.cfg.browser.headless) {
args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`); args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`)
} }
args.push("-e", `CLAWDBOT_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`); args.push(
args.push("-e", `CLAWDBOT_BROWSER_ENABLE_NOVNC=${params.cfg.browser.enableNoVnc ? "1" : "0"}`); "-e",
args.push("-e", `CLAWDBOT_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`); `CLAWDBOT_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`,
args.push("-e", `CLAWDBOT_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`); )
args.push("-e", `CLAWDBOT_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`); args.push(
args.push(params.cfg.browser.image); "-e",
await execDocker(args); `CLAWDBOT_BROWSER_ENABLE_NOVNC=${
await execDocker(["start", containerName]); params.cfg.browser.enableNoVnc ? "1" : "0"
}`,
)
args.push("-e", `CLAWDBOT_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`)
args.push("-e", `CLAWDBOT_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`)
args.push(
"-e",
`CLAWDBOT_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`,
)
args.push(params.cfg.browser.image)
await execDocker(args)
await execDocker(["start", containerName])
} else if (!state.running) { } else if (!state.running) {
await execDocker(["start", containerName]); await execDocker(["start", containerName])
} }
const mappedCdp = await readDockerPort(containerName, params.cfg.browser.cdpPort); const mappedCdp = await readDockerPort(
containerName,
params.cfg.browser.cdpPort,
)
if (!mappedCdp) { if (!mappedCdp) {
throw new Error(`Failed to resolve CDP port mapping for ${containerName}.`); throw new Error(`Failed to resolve CDP port mapping for ${containerName}.`)
} }
const mappedNoVnc = const mappedNoVnc =
params.cfg.browser.enableNoVnc && !params.cfg.browser.headless params.cfg.browser.enableNoVnc && !params.cfg.browser.headless
? await readDockerPort(containerName, params.cfg.browser.noVncPort) ? await readDockerPort(containerName, params.cfg.browser.noVncPort)
: null; : null
const existing = BROWSER_BRIDGES.get(params.scopeKey); const existing = BROWSER_BRIDGES.get(params.scopeKey)
const existingProfile = existing ? resolveProfile(existing.bridge.state.resolved, "clawd") : null; const existingProfile = existing
? resolveProfile(existing.bridge.state.resolved, "clawd")
: null
const shouldReuse = const shouldReuse =
existing && existing.containerName === containerName && existingProfile?.cdpPort === mappedCdp; existing &&
existing.containerName === containerName &&
existingProfile?.cdpPort === mappedCdp
if (existing && !shouldReuse) { if (existing && !shouldReuse) {
await stopBrowserBridgeServer(existing.bridge.server).catch(() => undefined); await stopBrowserBridgeServer(existing.bridge.server).catch(() => undefined)
BROWSER_BRIDGES.delete(params.scopeKey); BROWSER_BRIDGES.delete(params.scopeKey)
} }
const bridge = (() => { const bridge = (() => {
if (shouldReuse && existing) return existing.bridge; if (shouldReuse && existing) return existing.bridge
return null; return null
})(); })()
const ensureBridge = async () => { const ensureBridge = async () => {
if (bridge) return bridge; if (bridge) return bridge
// Resolve hostname to IP for Chrome CDP compatibility // Resolve hostname to IP for Chrome CDP compatibility
const resolvedCdpHost = await resolveHostToIp(params.cfg.browser.cdpHost); const resolvedCdpHost = await resolveHostToIp(params.cfg.browser.cdpHost)
const onEnsureAttachTarget = params.cfg.browser.autoStart const onEnsureAttachTarget = params.cfg.browser.autoStart
? async () => { ? async () => {
const state = await dockerContainerState(containerName); const state = await dockerContainerState(containerName)
if (state.exists && !state.running) { if (state.exists && !state.running) {
await execDocker(["start", containerName]); await execDocker(["start", containerName])
} }
const ok = await waitForSandboxCdp({ const ok = await waitForSandboxCdp({
cdpHost: resolvedCdpHost, cdpHost: resolvedCdpHost,
cdpPort: mappedCdp, cdpPort: mappedCdp,
timeoutMs: params.cfg.browser.autoStartTimeoutMs, timeoutMs: params.cfg.browser.autoStartTimeoutMs,
}); })
if (!ok) { if (!ok) {
throw new Error( throw new Error(
`Sandbox browser CDP did not become reachable on ${resolvedCdpHost}:${mappedCdp} within ${params.cfg.browser.autoStartTimeoutMs}ms.`, `Sandbox browser CDP did not become reachable on ${resolvedCdpHost}:${mappedCdp} within ${params.cfg.browser.autoStartTimeoutMs}ms.`,
); )
} }
} }
: undefined; : undefined
return await startBrowserBridgeServer({ return await startBrowserBridgeServer({
resolved: buildSandboxBrowserResolvedConfig({ resolved: buildSandboxBrowserResolvedConfig({
@ -217,21 +258,22 @@ export async function ensureSandboxBrowser(params: {
cdpHost: resolvedCdpHost, cdpHost: resolvedCdpHost,
cdpPort: mappedCdp, cdpPort: mappedCdp,
headless: params.cfg.browser.headless, headless: params.cfg.browser.headless,
evaluateEnabled: params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED, evaluateEnabled:
params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED,
}), }),
onEnsureAttachTarget, onEnsureAttachTarget,
}); })
}; }
const resolvedBridge = await ensureBridge(); const resolvedBridge = await ensureBridge()
if (!shouldReuse) { if (!shouldReuse) {
BROWSER_BRIDGES.set(params.scopeKey, { BROWSER_BRIDGES.set(params.scopeKey, {
bridge: resolvedBridge, bridge: resolvedBridge,
containerName, containerName,
}); })
} }
const now = Date.now(); const now = Date.now()
await updateBrowserRegistry({ await updateBrowserRegistry({
containerName, containerName,
sessionKey: params.scopeKey, sessionKey: params.scopeKey,
@ -240,16 +282,18 @@ export async function ensureSandboxBrowser(params: {
image: params.cfg.browser.image, image: params.cfg.browser.image,
cdpPort: mappedCdp, cdpPort: mappedCdp,
noVncPort: mappedNoVnc ?? undefined, noVncPort: mappedNoVnc ?? undefined,
}); })
const noVncUrl = const noVncUrl =
mappedNoVnc && params.cfg.browser.enableNoVnc && !params.cfg.browser.headless mappedNoVnc &&
params.cfg.browser.enableNoVnc &&
!params.cfg.browser.headless
? `http://${params.cfg.browser.cdpHost}:${mappedNoVnc}/vnc.html?autoconnect=1&resize=remote` ? `http://${params.cfg.browser.cdpHost}:${mappedNoVnc}/vnc.html?autoconnect=1&resize=remote`
: undefined; : undefined
return { return {
bridgeUrl: resolvedBridge.baseUrl, bridgeUrl: resolvedBridge.baseUrl,
noVncUrl, noVncUrl,
containerName, containerName,
}; }
} }

View File

@ -1,13 +1,24 @@
import os from "node:os"; import os from "node:os"
import { spawn } from "node:child_process"; import { spawn } from "node:child_process"
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js"
import { formatCliCommand } from "../../cli/command-format.js"; import { formatCliCommand } from "../../cli/command-format.js"
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; import {
import { readRegistry, updateRegistry } from "./registry.js"; DEFAULT_SANDBOX_IMAGE,
import { computeSandboxConfigHash } from "./config-hash.js"; SANDBOX_AGENT_WORKSPACE_MOUNT,
import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js"; } from "./constants.js"
import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js"; import { readRegistry, updateRegistry } from "./registry.js"
import { computeSandboxConfigHash } from "./config-hash.js"
import {
resolveSandboxAgentId,
resolveSandboxScopeKey,
slugifySessionKey,
} from "./shared.js"
import type {
SandboxConfig,
SandboxDockerConfig,
SandboxWorkspaceAccess,
} from "./types.js"
/** /**
* For Docker-in-Docker scenarios, remap container paths to host paths. * For Docker-in-Docker scenarios, remap container paths to host paths.
@ -19,305 +30,334 @@ import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from
* - CLAWDBOT_SANDBOX_HOST_WORKSPACE_DIR: host path for ~/clawd * - CLAWDBOT_SANDBOX_HOST_WORKSPACE_DIR: host path for ~/clawd
*/ */
export function remapPathForDinD(containerPath: string): string { export function remapPathForDinD(containerPath: string): string {
const hostConfigDir = process.env.CLAWDBOT_SANDBOX_HOST_CONFIG_DIR; const hostConfigDir = process.env.CLAWDBOT_SANDBOX_HOST_CONFIG_DIR
const hostWorkspaceDir = process.env.CLAWDBOT_SANDBOX_HOST_WORKSPACE_DIR; const hostWorkspaceDir = process.env.CLAWDBOT_SANDBOX_HOST_WORKSPACE_DIR
// Both must be set for DinD mode, or neither (partial config is invalid) // Both must be set for DinD mode, or neither (partial config is invalid)
if (!hostConfigDir || !hostWorkspaceDir) { if (!hostConfigDir || !hostWorkspaceDir) {
return containerPath; return containerPath
} }
const home = os.homedir(); const home = os.homedir()
const containerConfigDir = `${home}/.clawdbot`; const containerConfigDir = `${home}/.clawdbot`
const containerWorkspaceDir = `${home}/clawd`; const containerWorkspaceDir = `${home}/clawd`
// Remap config directory paths // Remap config directory paths
if (hostConfigDir && containerPath.startsWith(containerConfigDir)) { if (hostConfigDir && containerPath.startsWith(containerConfigDir)) {
return containerPath.replace(containerConfigDir, hostConfigDir); return containerPath.replace(containerConfigDir, hostConfigDir)
} }
// Remap workspace directory paths // Remap workspace directory paths
if (hostWorkspaceDir && containerPath.startsWith(containerWorkspaceDir)) { if (hostWorkspaceDir && containerPath.startsWith(containerWorkspaceDir)) {
return containerPath.replace(containerWorkspaceDir, hostWorkspaceDir); return containerPath.replace(containerWorkspaceDir, hostWorkspaceDir)
} }
return containerPath; return containerPath
} }
const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000; const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000
export function execDocker(args: string[], opts?: { allowFailure?: boolean }) { export function execDocker(args: string[], opts?: { allowFailure?: boolean }) {
return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => { return new Promise<{ stdout: string stderr: string code: number }>(
const child = spawn("docker", args, { (resolve, reject) => {
stdio: ["ignore", "pipe", "pipe"], const child = spawn("docker", args, {
}); stdio: ["ignore", "pipe", "pipe"],
let stdout = ""; })
let stderr = ""; let stdout = ""
child.stdout?.on("data", (chunk) => { let stderr = ""
stdout += chunk.toString(); child.stdout?.on("data", (chunk) => {
}); stdout += chunk.toString()
child.stderr?.on("data", (chunk) => { })
stderr += chunk.toString(); child.stderr?.on("data", (chunk) => {
}); stderr += chunk.toString()
child.on("close", (code) => { })
const exitCode = code ?? 0; child.on("close", (code) => {
if (exitCode !== 0 && !opts?.allowFailure) { const exitCode = code ?? 0
reject(new Error(stderr.trim() || `docker ${args.join(" ")} failed`)); if (exitCode !== 0 && !opts?.allowFailure) {
return; reject(new Error(stderr.trim() || `docker ${args.join(" ")} failed`))
} return
resolve({ stdout, stderr, code: exitCode }); }
}); resolve({ stdout, stderr, code: exitCode })
}); })
},
)
} }
export async function readDockerPort(containerName: string, port: number) { export async function readDockerPort(containerName: string, port: number) {
const result = await execDocker(["port", containerName, `${port}/tcp`], { const result = await execDocker(["port", containerName, `${port}/tcp`], {
allowFailure: true, allowFailure: true,
}); })
if (result.code !== 0) return null; if (result.code !== 0) return null
const line = result.stdout.trim().split(/\r?\n/)[0] ?? ""; const line = result.stdout.trim().split(/\r?\n/)[0] ?? ""
const match = line.match(/:(\d+)\s*$/); const match = line.match(/:(\d+)\s*$/)
if (!match) return null; if (!match) return null
const mapped = Number.parseInt(match[1] ?? "", 10); const mapped = Number.parseInt(match[1] ?? "", 10)
return Number.isFinite(mapped) ? mapped : null; return Number.isFinite(mapped) ? mapped : null
} }
async function dockerImageExists(image: string) { async function dockerImageExists(image: string) {
const result = await execDocker(["image", "inspect", image], { const result = await execDocker(["image", "inspect", image], {
allowFailure: true, allowFailure: true,
}); })
if (result.code === 0) return true; if (result.code === 0) return true
const stderr = result.stderr.trim(); const stderr = result.stderr.trim()
if (stderr.includes("No such image")) { if (stderr.includes("No such image")) {
return false; return false
} }
throw new Error(`Failed to inspect sandbox image: ${stderr}`); throw new Error(`Failed to inspect sandbox image: ${stderr}`)
} }
export async function ensureDockerImage(image: string) { export async function ensureDockerImage(image: string) {
const exists = await dockerImageExists(image); const exists = await dockerImageExists(image)
if (exists) return; if (exists) return
if (image === DEFAULT_SANDBOX_IMAGE) { if (image === DEFAULT_SANDBOX_IMAGE) {
await execDocker(["pull", "debian:bookworm-slim"]); await execDocker(["pull", "debian:bookworm-slim"])
await execDocker(["tag", "debian:bookworm-slim", DEFAULT_SANDBOX_IMAGE]); await execDocker(["tag", "debian:bookworm-slim", DEFAULT_SANDBOX_IMAGE])
return; return
} }
throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`); throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`)
} }
export async function dockerContainerState(name: string) { export async function dockerContainerState(name: string) {
const result = await execDocker(["inspect", "-f", "{{.State.Running}}", name], { const result = await execDocker(
allowFailure: true, ["inspect", "-f", "{{.State.Running}}", name],
}); {
if (result.code !== 0) return { exists: false, running: false }; allowFailure: true,
return { exists: true, running: result.stdout.trim() === "true" }; },
)
if (result.code !== 0) return { exists: false, running: false }
return { exists: true, running: result.stdout.trim() === "true" }
} }
function normalizeDockerLimit(value?: string | number) { function normalizeDockerLimit(value?: string | number) {
if (value === undefined || value === null) return undefined; if (value === undefined || value === null) return undefined
if (typeof value === "number") { if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : undefined; return Number.isFinite(value) ? String(value) : undefined
} }
const trimmed = value.trim(); const trimmed = value.trim()
return trimmed ? trimmed : undefined; return trimmed ? trimmed : undefined
} }
function formatUlimitValue( function formatUlimitValue(
name: string, name: string,
value: string | number | { soft?: number; hard?: number }, value: string | number | { soft?: number hard?: number },
) { ) {
if (!name.trim()) return null; if (!name.trim()) return null
if (typeof value === "number" || typeof value === "string") { if (typeof value === "number" || typeof value === "string") {
const raw = String(value).trim(); const raw = String(value).trim()
return raw ? `${name}=${raw}` : null; return raw ? `${name}=${raw}` : null
} }
const soft = typeof value.soft === "number" ? Math.max(0, value.soft) : undefined; const soft =
const hard = typeof value.hard === "number" ? Math.max(0, value.hard) : undefined; typeof value.soft === "number" ? Math.max(0, value.soft) : undefined
if (soft === undefined && hard === undefined) return null; const hard =
if (soft === undefined) return `${name}=${hard}`; typeof value.hard === "number" ? Math.max(0, value.hard) : undefined
if (hard === undefined) return `${name}=${soft}`; if (soft === undefined && hard === undefined) return null
return `${name}=${soft}:${hard}`; if (soft === undefined) return `${name}=${hard}`
if (hard === undefined) return `${name}=${soft}`
return `${name}=${soft}:${hard}`
} }
export function buildSandboxCreateArgs(params: { export function buildSandboxCreateArgs(params: {
name: string; name: string
cfg: SandboxDockerConfig; cfg: SandboxDockerConfig
scopeKey: string; scopeKey: string
createdAtMs?: number; createdAtMs?: number
labels?: Record<string, string>; labels?: Record<string, string>
configHash?: string; configHash?: string
}) { }) {
const createdAtMs = params.createdAtMs ?? Date.now(); const createdAtMs = params.createdAtMs ?? Date.now()
const args = ["create", "--name", params.name]; const args = ["create", "--name", params.name]
args.push("--label", "moltbot.sandbox=1"); args.push("--label", "moltbot.sandbox=1")
args.push("--label", `moltbot.sessionKey=${params.scopeKey}`); args.push("--label", `moltbot.sessionKey=${params.scopeKey}`)
args.push("--label", `moltbot.createdAtMs=${createdAtMs}`); args.push("--label", `moltbot.createdAtMs=${createdAtMs}`)
if (params.configHash) { if (params.configHash) {
args.push("--label", `moltbot.configHash=${params.configHash}`); args.push("--label", `moltbot.configHash=${params.configHash}`)
} }
for (const [key, value] of Object.entries(params.labels ?? {})) { for (const [key, value] of Object.entries(params.labels ?? {})) {
if (key && value) args.push("--label", `${key}=${value}`); if (key && value) args.push("--label", `${key}=${value}`)
} }
if (params.cfg.readOnlyRoot) args.push("--read-only"); if (params.cfg.readOnlyRoot) args.push("--read-only")
for (const entry of params.cfg.tmpfs) { for (const entry of params.cfg.tmpfs) {
args.push("--tmpfs", entry); args.push("--tmpfs", entry)
} }
if (params.cfg.network) args.push("--network", params.cfg.network); if (params.cfg.network) args.push("--network", params.cfg.network)
if (params.cfg.user) args.push("--user", params.cfg.user); if (params.cfg.user) args.push("--user", params.cfg.user)
for (const cap of params.cfg.capDrop) { for (const cap of params.cfg.capDrop) {
args.push("--cap-drop", cap); args.push("--cap-drop", cap)
} }
args.push("--security-opt", "no-new-privileges"); args.push("--security-opt", "no-new-privileges")
if (params.cfg.seccompProfile) { if (params.cfg.seccompProfile) {
args.push("--security-opt", `seccomp=${params.cfg.seccompProfile}`); args.push("--security-opt", `seccomp=${params.cfg.seccompProfile}`)
} }
if (params.cfg.apparmorProfile) { if (params.cfg.apparmorProfile) {
args.push("--security-opt", `apparmor=${params.cfg.apparmorProfile}`); args.push("--security-opt", `apparmor=${params.cfg.apparmorProfile}`)
} }
for (const entry of params.cfg.dns ?? []) { for (const entry of params.cfg.dns ?? []) {
if (entry.trim()) args.push("--dns", entry); if (entry.trim()) args.push("--dns", entry)
} }
for (const entry of params.cfg.extraHosts ?? []) { for (const entry of params.cfg.extraHosts ?? []) {
if (entry.trim()) args.push("--add-host", entry); if (entry.trim()) args.push("--add-host", entry)
} }
if (typeof params.cfg.pidsLimit === "number" && params.cfg.pidsLimit > 0) { if (typeof params.cfg.pidsLimit === "number" && params.cfg.pidsLimit > 0) {
args.push("--pids-limit", String(params.cfg.pidsLimit)); args.push("--pids-limit", String(params.cfg.pidsLimit))
} }
const memory = normalizeDockerLimit(params.cfg.memory); const memory = normalizeDockerLimit(params.cfg.memory)
if (memory) args.push("--memory", memory); if (memory) args.push("--memory", memory)
const memorySwap = normalizeDockerLimit(params.cfg.memorySwap); const memorySwap = normalizeDockerLimit(params.cfg.memorySwap)
if (memorySwap) args.push("--memory-swap", memorySwap); if (memorySwap) args.push("--memory-swap", memorySwap)
if (typeof params.cfg.cpus === "number" && params.cfg.cpus > 0) { if (typeof params.cfg.cpus === "number" && params.cfg.cpus > 0) {
args.push("--cpus", String(params.cfg.cpus)); args.push("--cpus", String(params.cfg.cpus))
} }
for (const [name, value] of Object.entries(params.cfg.ulimits ?? {}) as Array< for (const [name, value] of Object.entries(
[string, string | number | { soft?: number; hard?: number }] params.cfg.ulimits ?? {},
>) { ) as Array<[string, string | number | { soft?: number hard?: number }]>) {
const formatted = formatUlimitValue(name, value); const formatted = formatUlimitValue(name, value)
if (formatted) args.push("--ulimit", formatted); if (formatted) args.push("--ulimit", formatted)
} }
if (params.cfg.binds?.length) { if (params.cfg.binds?.length) {
for (const bind of params.cfg.binds) { for (const bind of params.cfg.binds) {
args.push("-v", bind); args.push("-v", bind)
} }
} }
return args; return args
} }
async function createSandboxContainer(params: { async function createSandboxContainer(params: {
name: string; name: string
cfg: SandboxDockerConfig; cfg: SandboxDockerConfig
workspaceDir: string; workspaceDir: string
workspaceAccess: SandboxWorkspaceAccess; workspaceAccess: SandboxWorkspaceAccess
agentWorkspaceDir: string; agentWorkspaceDir: string
scopeKey: string; scopeKey: string
configHash?: string; configHash?: string
}) { }) {
const { name, cfg, workspaceDir, scopeKey } = params; const { name, cfg, workspaceDir, scopeKey } = params
await ensureDockerImage(cfg.image); await ensureDockerImage(cfg.image)
const args = buildSandboxCreateArgs({ const args = buildSandboxCreateArgs({
name, name,
cfg, cfg,
scopeKey, scopeKey,
configHash: params.configHash, configHash: params.configHash,
}); })
args.push("--workdir", cfg.workdir); args.push("--workdir", cfg.workdir)
const mainMountSuffix = const mainMountSuffix =
params.workspaceAccess === "ro" && workspaceDir === params.agentWorkspaceDir ? ":ro" : ""; params.workspaceAccess === "ro" && workspaceDir === params.agentWorkspaceDir
? ":ro"
: ""
// Remap paths for Docker-in-Docker scenarios // Remap paths for Docker-in-Docker scenarios
const hostWorkspaceDir = remapPathForDinD(workspaceDir); const hostWorkspaceDir = remapPathForDinD(workspaceDir)
args.push("-v", `${hostWorkspaceDir}:${cfg.workdir}${mainMountSuffix}`); args.push("-v", `${hostWorkspaceDir}:${cfg.workdir}${mainMountSuffix}`)
if (params.workspaceAccess !== "none" && workspaceDir !== params.agentWorkspaceDir) { if (
const agentMountSuffix = params.workspaceAccess === "ro" ? ":ro" : ""; params.workspaceAccess !== "none" &&
const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir); workspaceDir !== params.agentWorkspaceDir
) {
const agentMountSuffix = params.workspaceAccess === "ro" ? ":ro" : ""
const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir)
args.push( args.push(
"-v", "-v",
`${hostAgentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`, `${hostAgentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`,
); )
} }
args.push(cfg.image, "sleep", "infinity"); args.push(cfg.image, "sleep", "infinity")
await execDocker(args); await execDocker(args)
await execDocker(["start", name]); await execDocker(["start", name])
if (cfg.setupCommand?.trim()) { if (cfg.setupCommand?.trim()) {
await execDocker(["exec", "-i", name, "sh", "-lc", cfg.setupCommand]); await execDocker(["exec", "-i", name, "sh", "-lc", cfg.setupCommand])
} }
} }
async function readContainerConfigHash(containerName: string): Promise<string | null> { async function readContainerConfigHash(
containerName: string,
): Promise<string | null> {
const result = await execDocker( const result = await execDocker(
["inspect", "-f", '{{ index .Config.Labels "moltbot.configHash" }}', containerName], [
"inspect",
"-f",
'{{ index .Config.Labels "moltbot.configHash" }}',
containerName,
],
{ allowFailure: true }, { allowFailure: true },
); )
if (result.code !== 0) return null; if (result.code !== 0) return null
const raw = result.stdout.trim(); const raw = result.stdout.trim()
if (!raw || raw === "<no value>") return null; if (!raw || raw === "<no value>") return null
return raw; return raw
} }
function formatSandboxRecreateHint(params: { scope: SandboxConfig["scope"]; sessionKey: string }) { function formatSandboxRecreateHint(params: {
scope: SandboxConfig["scope"]
sessionKey: string
}) {
if (params.scope === "session") { if (params.scope === "session") {
return formatCliCommand(`moltbot sandbox recreate --session ${params.sessionKey}`); return formatCliCommand(
`moltbot sandbox recreate --session ${params.sessionKey}`,
)
} }
if (params.scope === "agent") { if (params.scope === "agent") {
const agentId = resolveSandboxAgentId(params.sessionKey) ?? "main"; const agentId = resolveSandboxAgentId(params.sessionKey) ?? "main"
return formatCliCommand(`moltbot sandbox recreate --agent ${agentId}`); return formatCliCommand(`moltbot sandbox recreate --agent ${agentId}`)
} }
return formatCliCommand("moltbot sandbox recreate --all"); return formatCliCommand("moltbot sandbox recreate --all")
} }
export async function ensureSandboxContainer(params: { export async function ensureSandboxContainer(params: {
sessionKey: string; sessionKey: string
workspaceDir: string; workspaceDir: string
agentWorkspaceDir: string; agentWorkspaceDir: string
cfg: SandboxConfig; cfg: SandboxConfig
}) { }) {
const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey); const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey)
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey); const slug =
const name = `${params.cfg.docker.containerPrefix}${slug}`; params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey)
const containerName = name.slice(0, 63); const name = `${params.cfg.docker.containerPrefix}${slug}`
const containerName = name.slice(0, 63)
const expectedHash = computeSandboxConfigHash({ const expectedHash = computeSandboxConfigHash({
docker: params.cfg.docker, docker: params.cfg.docker,
workspaceAccess: params.cfg.workspaceAccess, workspaceAccess: params.cfg.workspaceAccess,
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
agentWorkspaceDir: params.agentWorkspaceDir, agentWorkspaceDir: params.agentWorkspaceDir,
}); })
const now = Date.now(); const now = Date.now()
const state = await dockerContainerState(containerName); const state = await dockerContainerState(containerName)
let hasContainer = state.exists; let hasContainer = state.exists
let running = state.running; let running = state.running
let currentHash: string | null = null; let currentHash: string | null = null
let hashMismatch = false; let hashMismatch = false
let registryEntry: let registryEntry: {
| { lastUsedAtMs: number
lastUsedAtMs: number; configHash?: string
configHash?: string; } | undefined
}
| undefined;
if (hasContainer) { if (hasContainer) {
const registry = await readRegistry(); const registry = await readRegistry()
registryEntry = registry.entries.find((entry) => entry.containerName === containerName); registryEntry = registry.entries.find(
currentHash = await readContainerConfigHash(containerName); (entry) => entry.containerName === containerName,
)
currentHash = await readContainerConfigHash(containerName)
if (!currentHash) { if (!currentHash) {
currentHash = registryEntry?.configHash ?? null; currentHash = registryEntry?.configHash ?? null
} }
hashMismatch = !currentHash || currentHash !== expectedHash; hashMismatch = !currentHash || currentHash !== expectedHash
if (hashMismatch) { if (hashMismatch) {
const lastUsedAtMs = registryEntry?.lastUsedAtMs; const lastUsedAtMs = registryEntry?.lastUsedAtMs
const isHot = const isHot =
running && running &&
(typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS); (typeof lastUsedAtMs !== "number" ||
now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS)
if (isHot) { if (isHot) {
const hint = formatSandboxRecreateHint({ scope: params.cfg.scope, sessionKey: scopeKey }); const hint = formatSandboxRecreateHint({
scope: params.cfg.scope,
sessionKey: scopeKey,
})
defaultRuntime.log( defaultRuntime.log(
`Sandbox config changed for ${containerName} (recently used). Recreate to apply: ${hint}`, `Sandbox config changed for ${containerName} (recently used). Recreate to apply: ${hint}`,
); )
} else { } else {
await execDocker(["rm", "-f", containerName], { allowFailure: true }); await execDocker(["rm", "-f", containerName], { allowFailure: true })
hasContainer = false; hasContainer = false
running = false; running = false
} }
} }
} }
@ -330,9 +370,9 @@ export async function ensureSandboxContainer(params: {
agentWorkspaceDir: params.agentWorkspaceDir, agentWorkspaceDir: params.agentWorkspaceDir,
scopeKey, scopeKey,
configHash: expectedHash, configHash: expectedHash,
}); })
} else if (!running) { } else if (!running) {
await execDocker(["start", containerName]); await execDocker(["start", containerName])
} }
await updateRegistry({ await updateRegistry({
containerName, containerName,
@ -340,7 +380,8 @@ export async function ensureSandboxContainer(params: {
createdAtMs: now, createdAtMs: now,
lastUsedAtMs: now, lastUsedAtMs: now,
image: params.cfg.docker.image, image: params.cfg.docker.image,
configHash: hashMismatch && running ? (currentHash ?? undefined) : expectedHash, configHash:
}); hashMismatch && running ? (currentHash ?? undefined) : expectedHash,
return containerName; })
return containerName
} }