mirror of
https://github.com/clawdbot/clawdbot.git
synced 2026-01-31 19:37:45 +01:00
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
|
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
|
### For a specific agent only
|
||||||
|
|
||||||
```bash
|
```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:**
|
**Benefits:**
|
||||||
- **Security isolation**: Restrict tools for untrusted agents
|
- **Security isolation**: Restrict tools for untrusted agents
|
||||||
- **Resource control**: Sandbox specific agents while keeping others on host
|
- **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"`,
|
Legacy: `perSession` is still supported (`true` → `scope: "session"`,
|
||||||
`false` → `scope: "shared"`).
|
`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
|
```json5
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
@@ -116,6 +116,20 @@ Override with `agents.defaults.sandbox.docker.network`.
|
|||||||
Docker installs and the containerized gateway live here:
|
Docker installs and the containerized gateway live here:
|
||||||
[Docker](/install/docker)
|
[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 policy + escape hatches
|
||||||
Tool allow/deny policies still apply before sandbox rules. If a tool is denied
|
Tool allow/deny policies still apply before sandbox rules. If a tool is denied
|
||||||
globally or per-agent, sandboxing doesn’t bring it back.
|
globally or per-agent, sandboxing doesn’t bring it back.
|
||||||
|
|||||||
@@ -254,6 +254,14 @@ precedence, and troubleshooting.
|
|||||||
|
|
||||||
### Enable sandboxing
|
### 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
|
```json5
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ This allows you to run multiple agents with different security profiles:
|
|||||||
- Family/work agents with restricted tools
|
- Family/work agents with restricted tools
|
||||||
- Public-facing agents in sandboxes
|
- 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:
|
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.
|
- `requires.bins` is checked on the **host** at skill load time.
|
||||||
- If an agent is sandboxed, the binary must also exist **inside the container**.
|
- 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).
|
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
|
Example: the `summarize` skill (`skills/summarize/SKILL.md`) needs the `summarize` CLI
|
||||||
in the sandbox container to run there.
|
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 { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
|
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
|
||||||
import { updateRegistry } from "./registry.js";
|
import { readRegistry, updateRegistry } from "./registry.js";
|
||||||
import { resolveSandboxScopeKey, slugifySessionKey } from "./shared.js";
|
import { computeSandboxConfigHash } from "./config-hash.js";
|
||||||
|
import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js";
|
||||||
import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.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 }) {
|
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 }>((resolve, reject) => {
|
||||||
const child = spawn("docker", args, {
|
const child = spawn("docker", args, {
|
||||||
@@ -99,12 +103,16 @@ export function buildSandboxCreateArgs(params: {
|
|||||||
scopeKey: string;
|
scopeKey: string;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, 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", "clawdbot.sandbox=1");
|
args.push("--label", "clawdbot.sandbox=1");
|
||||||
args.push("--label", `clawdbot.sessionKey=${params.scopeKey}`);
|
args.push("--label", `clawdbot.sessionKey=${params.scopeKey}`);
|
||||||
args.push("--label", `clawdbot.createdAtMs=${createdAtMs}`);
|
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 ?? {})) {
|
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}`);
|
||||||
}
|
}
|
||||||
@@ -161,6 +169,7 @@ async function createSandboxContainer(params: {
|
|||||||
workspaceAccess: SandboxWorkspaceAccess;
|
workspaceAccess: SandboxWorkspaceAccess;
|
||||||
agentWorkspaceDir: string;
|
agentWorkspaceDir: string;
|
||||||
scopeKey: string;
|
scopeKey: string;
|
||||||
|
configHash?: string;
|
||||||
}) {
|
}) {
|
||||||
const { name, cfg, workspaceDir, scopeKey } = params;
|
const { name, cfg, workspaceDir, scopeKey } = params;
|
||||||
await ensureDockerImage(cfg.image);
|
await ensureDockerImage(cfg.image);
|
||||||
@@ -169,6 +178,7 @@ async function createSandboxContainer(params: {
|
|||||||
name,
|
name,
|
||||||
cfg,
|
cfg,
|
||||||
scopeKey,
|
scopeKey,
|
||||||
|
configHash: params.configHash,
|
||||||
});
|
});
|
||||||
args.push("--workdir", cfg.workdir);
|
args.push("--workdir", cfg.workdir);
|
||||||
const mainMountSuffix =
|
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: {
|
export async function ensureSandboxContainer(params: {
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
@@ -201,8 +233,51 @@ export async function ensureSandboxContainer(params: {
|
|||||||
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey);
|
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey);
|
||||||
const name = `${params.cfg.docker.containerPrefix}${slug}`;
|
const name = `${params.cfg.docker.containerPrefix}${slug}`;
|
||||||
const containerName = name.slice(0, 63);
|
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);
|
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({
|
await createSandboxContainer({
|
||||||
name: containerName,
|
name: containerName,
|
||||||
cfg: params.cfg.docker,
|
cfg: params.cfg.docker,
|
||||||
@@ -210,17 +285,18 @@ export async function ensureSandboxContainer(params: {
|
|||||||
workspaceAccess: params.cfg.workspaceAccess,
|
workspaceAccess: params.cfg.workspaceAccess,
|
||||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||||
scopeKey,
|
scopeKey,
|
||||||
|
configHash: expectedHash,
|
||||||
});
|
});
|
||||||
} else if (!state.running) {
|
} else if (!running) {
|
||||||
await execDocker(["start", containerName]);
|
await execDocker(["start", containerName]);
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
|
||||||
await updateRegistry({
|
await updateRegistry({
|
||||||
containerName,
|
containerName,
|
||||||
sessionKey: scopeKey,
|
sessionKey: scopeKey,
|
||||||
createdAtMs: now,
|
createdAtMs: now,
|
||||||
lastUsedAtMs: now,
|
lastUsedAtMs: now,
|
||||||
image: params.cfg.docker.image,
|
image: params.cfg.docker.image,
|
||||||
|
configHash: hashMismatch && running ? currentHash ?? undefined : expectedHash,
|
||||||
});
|
});
|
||||||
return containerName;
|
return containerName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type SandboxRegistryEntry = {
|
|||||||
createdAtMs: number;
|
createdAtMs: number;
|
||||||
lastUsedAtMs: number;
|
lastUsedAtMs: number;
|
||||||
image: string;
|
image: string;
|
||||||
|
configHash?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SandboxRegistry = {
|
type SandboxRegistry = {
|
||||||
@@ -56,6 +57,7 @@ export async function updateRegistry(entry: SandboxRegistryEntry) {
|
|||||||
...entry,
|
...entry,
|
||||||
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
|
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
|
||||||
image: existing?.image ?? entry.image,
|
image: existing?.image ?? entry.image,
|
||||||
|
configHash: entry.configHash ?? existing?.configHash,
|
||||||
});
|
});
|
||||||
await writeRegistry({ entries: next });
|
await writeRegistry({ entries: next });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user