feat: auto-recreate sandbox containers on config change

This commit is contained in:
Peter Steinberger
2026-01-19 01:35:17 +00:00
parent 99bf65c539
commit 79d8267413
10 changed files with 163 additions and 5 deletions

View File

@@ -89,6 +89,15 @@ clawdbot sandbox recreate --all
clawdbot sandbox recreate --all
```
### After changing setupCommand
```bash
clawdbot sandbox recreate --all
# or just one agent:
clawdbot sandbox recreate --agent family
```
### For a specific agent only
```bash

View File

@@ -293,6 +293,9 @@ Starting with v2026.1.6, each agent can have its own sandbox and tool restrictio
}
```
Note: `setupCommand` lives under `sandbox.docker` and runs once on container creation.
Per-agent `sandbox.docker.*` overrides are ignored when the resolved scope is `"shared"`.
**Benefits:**
- **Security isolation**: Restrict tools for untrusted agents
- **Resource control**: Sandbox specific agents while keeping others on host

View File

@@ -1990,6 +1990,9 @@ cross-session isolation. Use `scope: "session"` for per-session isolation.
Legacy: `perSession` is still supported (`true` → `scope: "session"`,
`false` → `scope: "shared"`).
`setupCommand` runs **once** after the container is created (inside the container via `sh -lc`).
For package installs, ensure network egress, a writable root FS, and a root user.
```json5
{
agents: {

View File

@@ -116,6 +116,20 @@ Override with `agents.defaults.sandbox.docker.network`.
Docker installs and the containerized gateway live here:
[Docker](/install/docker)
## setupCommand (one-time container setup)
`setupCommand` runs **once** after the sandbox container is created (not on every run).
It executes inside the container via `sh -lc`.
Paths:
- Global: `agents.defaults.sandbox.docker.setupCommand`
- Per-agent: `agents.list[].sandbox.docker.setupCommand`
Common pitfalls:
- Default `docker.network` is `"none"` (no egress), so package installs will fail.
- `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image.
- `user` must be root for package installs (omit `user` or set `user: "0:0"`).
## Tool policy + escape hatches
Tool allow/deny policies still apply before sandbox rules. If a tool is denied
globally or per-agent, sandboxing doesnt bring it back.

View File

@@ -254,6 +254,14 @@ precedence, and troubleshooting.
### Enable sandboxing
If you plan to install packages in `setupCommand`, note:
- Default `docker.network` is `"none"` (no egress).
- `readOnlyRoot: true` blocks package installs.
- `user` must be root for `apt-get` (omit `user` or set `user: "0:0"`).
Clawdbot auto-recreates containers when `setupCommand` (or docker config) changes
unless the container was **recently used** (within ~5 minutes). Hot containers
log a warning with the exact `clawdbot sandbox recreate ...` command.
```json5
{
agents: {

View File

@@ -18,6 +18,9 @@ This allows you to run multiple agents with different security profiles:
- Family/work agents with restricted tools
- Public-facing agents in sandboxes
`setupCommand` belongs under `sandbox.docker` (global or per-agent) and runs once
when the container is created.
Auth is per-agent: each agent reads from its own `agentDir` auth store at:
```

View File

@@ -111,6 +111,8 @@ Note on sandboxing:
- `requires.bins` is checked on the **host** at skill load time.
- If an agent is sandboxed, the binary must also exist **inside the container**.
Install it via `agents.defaults.sandbox.docker.setupCommand` (or a custom image).
`setupCommand` runs once after the container is created.
Package installs also require network egress, a writable root FS, and a root user in the sandbox.
Example: the `summarize` skill (`skills/summarize/SKILL.md`) needs the `summarize` CLI
in the sandbox container to run there.

View File

@@ -0,0 +1,38 @@
import crypto from "node:crypto";
import type { SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js";
type SandboxHashInput = {
docker: SandboxDockerConfig;
workspaceAccess: SandboxWorkspaceAccess;
workspaceDir: string;
agentWorkspaceDir: string;
};
function normalizeForHash(value: unknown): unknown {
if (value === undefined) return undefined;
if (Array.isArray(value)) {
const normalized = value.map(normalizeForHash).filter((item) => item !== undefined);
const allPrimitive = normalized.every((item) => item === null || typeof item !== "object");
if (allPrimitive) {
return [...normalized].sort((a, b) => String(a).localeCompare(String(b)));
}
return normalized;
}
if (value && typeof value === "object") {
const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
const normalized: Record<string, unknown> = {};
for (const [key, entryValue] of entries) {
const next = normalizeForHash(entryValue);
if (next !== undefined) normalized[key] = next;
}
return normalized;
}
return value;
}
export function computeSandboxConfigHash(input: SandboxHashInput): string {
const payload = normalizeForHash(input);
const raw = JSON.stringify(payload);
return crypto.createHash("sha1").update(raw).digest("hex");
}

View File

@@ -1,10 +1,14 @@
import { spawn } from "node:child_process";
import { defaultRuntime } from "../../runtime.js";
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
import { updateRegistry } from "./registry.js";
import { resolveSandboxScopeKey, slugifySessionKey } from "./shared.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";
const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000;
export function execDocker(args: string[], opts?: { allowFailure?: boolean }) {
return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => {
const child = spawn("docker", args, {
@@ -99,12 +103,16 @@ export function buildSandboxCreateArgs(params: {
scopeKey: string;
createdAtMs?: number;
labels?: Record<string, string>;
configHash?: string;
}) {
const createdAtMs = params.createdAtMs ?? Date.now();
const args = ["create", "--name", params.name];
args.push("--label", "clawdbot.sandbox=1");
args.push("--label", `clawdbot.sessionKey=${params.scopeKey}`);
args.push("--label", `clawdbot.createdAtMs=${createdAtMs}`);
if (params.configHash) {
args.push("--label", `clawdbot.configHash=${params.configHash}`);
}
for (const [key, value] of Object.entries(params.labels ?? {})) {
if (key && value) args.push("--label", `${key}=${value}`);
}
@@ -161,6 +169,7 @@ async function createSandboxContainer(params: {
workspaceAccess: SandboxWorkspaceAccess;
agentWorkspaceDir: string;
scopeKey: string;
configHash?: string;
}) {
const { name, cfg, workspaceDir, scopeKey } = params;
await ensureDockerImage(cfg.image);
@@ -169,6 +178,7 @@ async function createSandboxContainer(params: {
name,
cfg,
scopeKey,
configHash: params.configHash,
});
args.push("--workdir", cfg.workdir);
const mainMountSuffix =
@@ -191,6 +201,28 @@ async function createSandboxContainer(params: {
}
}
async function readContainerConfigHash(containerName: string): Promise<string | null> {
const result = await execDocker(
["inspect", "-f", '{{ index .Config.Labels "clawdbot.configHash" }}', containerName],
{ allowFailure: true },
);
if (result.code !== 0) return null;
const raw = result.stdout.trim();
if (!raw || raw === "<no value>") return null;
return raw;
}
function formatSandboxRecreateHint(params: { scope: SandboxConfig["scope"]; sessionKey: string }) {
if (params.scope === "session") {
return `clawdbot sandbox recreate --session ${params.sessionKey}`;
}
if (params.scope === "agent") {
const agentId = resolveSandboxAgentId(params.sessionKey) ?? "main";
return `clawdbot sandbox recreate --agent ${agentId}`;
}
return "clawdbot sandbox recreate --all";
}
export async function ensureSandboxContainer(params: {
sessionKey: string;
workspaceDir: string;
@@ -201,8 +233,51 @@ export async function ensureSandboxContainer(params: {
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey);
const name = `${params.cfg.docker.containerPrefix}${slug}`;
const containerName = name.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);
if (!state.exists) {
let hasContainer = state.exists;
let running = state.running;
let currentHash: string | null = null;
let hashMismatch = false;
let registryEntry:
| {
lastUsedAtMs: number;
configHash?: string;
}
| undefined;
if (hasContainer) {
const registry = await readRegistry();
registryEntry = registry.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;
const isHot =
running &&
(typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS);
if (isHot) {
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,
@@ -210,17 +285,18 @@ export async function ensureSandboxContainer(params: {
workspaceAccess: params.cfg.workspaceAccess,
agentWorkspaceDir: params.agentWorkspaceDir,
scopeKey,
configHash: expectedHash,
});
} else if (!state.running) {
} else if (!running) {
await execDocker(["start", containerName]);
}
const now = Date.now();
await updateRegistry({
containerName,
sessionKey: scopeKey,
createdAtMs: now,
lastUsedAtMs: now,
image: params.cfg.docker.image,
configHash: hashMismatch && running ? currentHash ?? undefined : expectedHash,
});
return containerName;
}

View File

@@ -12,6 +12,7 @@ export type SandboxRegistryEntry = {
createdAtMs: number;
lastUsedAtMs: number;
image: string;
configHash?: string;
};
type SandboxRegistry = {
@@ -56,6 +57,7 @@ export async function updateRegistry(entry: SandboxRegistryEntry) {
...entry,
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
image: existing?.image ?? entry.image,
configHash: entry.configHash ?? existing?.configHash,
});
await writeRegistry({ entries: next });
}