feat: auto-recreate sandbox containers on config change
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 doesn’t bring it back.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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:
|
||||
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
38
src/agents/sandbox/config-hash.ts
Normal file
38
src/agents/sandbox/config-hash.ts
Normal 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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user