From b03a1ad814ebf1e3f79fe36207cc696f563d057e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 00:52:15 +0100 Subject: [PATCH] feat(sandbox): per-agent docker setupCommand --- CHANGELOG.md | 1 + docs/concepts/multi-agent.md | 4 ++ docs/gateway/configuration.md | 2 + docs/install/docker.md | 3 + src/agents/sandbox-agent-config.test.ts | 79 +++++++++++++++++++++++++ src/agents/sandbox.ts | 15 +++-- src/config/types.ts | 5 ++ src/config/zod-schema.ts | 5 ++ 8 files changed, 109 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 942734a64..2363b16df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first). - Sandbox: add `agent.sandbox.workspaceAccess` (`none`/`ro`/`rw`) to control agent workspace visibility inside the container; `ro` hard-disables `write`/`edit`. - Routing: allow per-agent sandbox overrides (including `workspaceAccess` and `sandbox.tools`) plus per-agent tool policies in multi-agent configs. Thanks @pasogott for PR #380. +- Sandbox: allow per-agent `routing.agents..sandbox.docker.setupCommand` overrides for multi-agent gateways (ignored when `scope: "shared"`). - Tools: make per-agent tool policies override global defaults and run bash synchronously when `process` is disallowed. - Tools: scope `process` sessions per agent to prevent cross-agent visibility. - Cron: clamp timer delay to avoid TimeoutOverflowWarning. Thanks @emanuelst for PR #412. diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 131ed3a96..c7556a6cc 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -152,6 +152,10 @@ Starting with v2026.1.6, each agent can have its own sandbox and tool restrictio sandbox: { mode: "all", // Always sandboxed scope: "agent", // One container per agent + docker: { + // Optional one-time setup after container creation + setupCommand: "apt-get update && apt-get install -y git curl", + }, }, tools: { allow: ["read"], // Only read tool diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 4109dead0..6b4264823 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -339,6 +339,7 @@ Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside o - `workspaceAccess`: `"none"` | `"ro"` | `"rw"` - `scope`: `"session"` | `"agent"` | `"shared"` - `workspaceRoot`: custom sandbox workspace root + - `docker.setupCommand`: optional one-time setup command (runs once after container creation; ignored when `scope: "shared"`) - `tools`: per-agent sandbox tool policy (deny wins; overrides `agent.sandbox.tools`) - `tools`: per-agent tool restrictions (overrides `agent.tools`; applied before sandbox tool policy). - `allow`: array of allowed tool names @@ -1115,6 +1116,7 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`, capDrop: ["ALL"], env: { LANG: "C.UTF-8" }, setupCommand: "apt-get update && apt-get install -y git curl jq", + // Per-agent override (multi-agent): routing.agents..sandbox.docker.setupCommand pidsLimit: 256, memory: "1g", memorySwap: "2g", diff --git a/docs/install/docker.md b/docs/install/docker.md index 0f3879de4..63c0a6a59 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -160,6 +160,9 @@ Hardening knobs live under `agent.sandbox.docker`: `network`, `user`, `pidsLimit`, `memory`, `memorySwap`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`. +Multi-agent: override `setupCommand` per agent via `routing.agents..sandbox.docker.setupCommand` +(ignored when `agent.sandbox.scope` / `routing.agents..sandbox.scope` is `"shared"`). + ### Build the default sandbox image ```bash diff --git a/src/agents/sandbox-agent-config.test.ts b/src/agents/sandbox-agent-config.test.ts index 2333e67fc..6862580a6 100644 --- a/src/agents/sandbox-agent-config.test.ts +++ b/src/agents/sandbox-agent-config.test.ts @@ -54,6 +54,85 @@ describe("Agent-specific sandbox config", () => { expect(context?.enabled).toBe(true); }); + it("should allow agent-specific docker setupCommand overrides", async () => { + const { resolveSandboxContext } = await import("./sandbox.js"); + + const cfg: ClawdbotConfig = { + agent: { + sandbox: { + mode: "all", + scope: "agent", + docker: { + setupCommand: "echo global", + }, + }, + }, + routing: { + agents: { + work: { + workspace: "~/clawd-work", + sandbox: { + mode: "all", + scope: "agent", + docker: { + setupCommand: "echo work", + }, + }, + }, + }, + }, + }; + + const context = await resolveSandboxContext({ + config: cfg, + sessionKey: "agent:work:main", + workspaceDir: "/tmp/test-work", + }); + + expect(context).toBeDefined(); + expect(context?.docker.setupCommand).toBe("echo work"); + }); + + it("should ignore agent-specific docker overrides when scope is shared", async () => { + const { resolveSandboxContext } = await import("./sandbox.js"); + + const cfg: ClawdbotConfig = { + agent: { + sandbox: { + mode: "all", + scope: "shared", + docker: { + setupCommand: "echo global", + }, + }, + }, + routing: { + agents: { + work: { + workspace: "~/clawd-work", + sandbox: { + mode: "all", + scope: "shared", + docker: { + setupCommand: "echo work", + }, + }, + }, + }, + }, + }; + + const context = await resolveSandboxContext({ + config: cfg, + sessionKey: "agent:work:main", + workspaceDir: "/tmp/test-work", + }); + + expect(context).toBeDefined(); + expect(context?.docker.setupCommand).toBe("echo global"); + expect(context?.containerName).toContain("shared"); + }); + it("should override with agent-specific sandbox mode 'off'", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index eeb2ea96f..6f2542963 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -241,12 +241,14 @@ function defaultSandboxConfig( } } + const scope = resolveSandboxScope({ + scope: agentSandbox?.scope ?? agent?.scope, + perSession: agentSandbox?.perSession ?? agent?.perSession, + }); + return { mode: agentSandbox?.mode ?? agent?.mode ?? "off", - scope: resolveSandboxScope({ - scope: agentSandbox?.scope ?? agent?.scope, - perSession: agentSandbox?.perSession ?? agent?.perSession, - }), + scope, workspaceAccess: agentSandbox?.workspaceAccess ?? agent?.workspaceAccess ?? "none", workspaceRoot: @@ -264,7 +266,10 @@ function defaultSandboxConfig( user: agent?.docker?.user, capDrop: agent?.docker?.capDrop ?? ["ALL"], env: agent?.docker?.env ?? { LANG: "C.UTF-8" }, - setupCommand: agent?.docker?.setupCommand, + setupCommand: + scope === "shared" + ? agent?.docker?.setupCommand + : (agentSandbox?.docker?.setupCommand ?? agent?.docker?.setupCommand), pidsLimit: agent?.docker?.pidsLimit, memory: agent?.docker?.memory, memorySwap: agent?.docker?.memorySwap, diff --git a/src/config/types.ts b/src/config/types.ts index e625a5914..3f7851edc 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -617,6 +617,11 @@ export type RoutingConfig = { /** Legacy alias for scope ("session" when true, "shared" when false). */ perSession?: boolean; workspaceRoot?: string; + /** Docker-specific sandbox overrides for this agent. */ + docker?: { + /** Optional setup command run once after container creation. */ + setupCommand?: string; + }; /** Tool allow/deny policy for sandboxed sessions (deny wins). */ tools?: { allow?: string[]; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index a9458dc9d..8412a8bb4 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -265,6 +265,11 @@ const RoutingSchema = z .optional(), perSession: z.boolean().optional(), workspaceRoot: z.string().optional(), + docker: z + .object({ + setupCommand: z.string().optional(), + }) + .optional(), tools: z .object({ allow: z.array(z.string()).optional(),