From 145fe1cec7cb493755143b6d20ef38970db0a026 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 01:17:49 +0100 Subject: [PATCH] refactor(sandbox): unify scope + per-agent overrides --- CHANGELOG.md | 2 +- docs/gateway/configuration.md | 2 + docs/install/docker.md | 2 +- docs/multi-agent-sandbox-tools.md | 5 +- src/agents/sandbox-merge.test.ts | 83 ++++++++++++++ src/agents/sandbox.ts | 179 ++++++++++++++++++++---------- src/commands/doctor.test.ts | 58 +++++++++- src/commands/doctor.ts | 42 +++---- src/config/types.ts | 40 ++++--- src/config/zod-schema.ts | 70 ++++++------ 10 files changed, 343 insertions(+), 140 deletions(-) create mode 100644 src/agents/sandbox-merge.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c30fbe87..5a4f9b463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,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.*` overrides for multi-agent gateways (ignored when `scope: "shared"`). +- Sandbox: allow per-agent `routing.agents..sandbox.{docker,browser,prune}.*` 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/gateway/configuration.md b/docs/gateway/configuration.md index 16f695add..9648eb65c 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -340,6 +340,8 @@ Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside o - `scope`: `"session"` | `"agent"` | `"shared"` - `workspaceRoot`: custom sandbox workspace root - `docker`: per-agent docker overrides (e.g. `image`, `network`, `env`, `setupCommand`, limits; ignored when `scope: "shared"`) + - `browser`: per-agent sandboxed browser overrides (ignored when `scope: "shared"`) + - `prune`: per-agent sandbox pruning overrides (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 diff --git a/docs/install/docker.md b/docs/install/docker.md index 1d7dc5cc6..2c38f962e 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -160,7 +160,7 @@ Hardening knobs live under `agent.sandbox.docker`: `network`, `user`, `pidsLimit`, `memory`, `memorySwap`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`. -Multi-agent: override `agent.sandbox.docker.*` per agent via `routing.agents..sandbox.docker.*` +Multi-agent: override `agent.sandbox.{docker,browser,prune}.*` per agent via `routing.agents..sandbox.{docker,browser,prune}.*` (ignored when `agent.sandbox.scope` / `routing.agents..sandbox.scope` is `"shared"`). ### Build the default sandbox image diff --git a/docs/multi-agent-sandbox-tools.md b/docs/multi-agent-sandbox-tools.md index a60200db3..f31b43d5f 100644 --- a/docs/multi-agent-sandbox-tools.md +++ b/docs/multi-agent-sandbox-tools.md @@ -150,11 +150,12 @@ routing.agents[id].sandbox.scope > agent.sandbox.scope routing.agents[id].sandbox.workspaceRoot > agent.sandbox.workspaceRoot routing.agents[id].sandbox.workspaceAccess > agent.sandbox.workspaceAccess routing.agents[id].sandbox.docker.* > agent.sandbox.docker.* +routing.agents[id].sandbox.browser.* > agent.sandbox.browser.* +routing.agents[id].sandbox.prune.* > agent.sandbox.prune.* ``` **Notes:** -- `routing.agents[id].sandbox.docker.*` overrides `agent.sandbox.docker.*` for that agent (ignored when sandbox scope resolves to `"shared"`). -- `browser` and `prune` settings under `agent.sandbox` are still **global** and apply to all sandboxed agents. +- `routing.agents[id].sandbox.{docker,browser,prune}.*` overrides `agent.sandbox.{docker,browser,prune}.*` for that agent (ignored when sandbox scope resolves to `"shared"`). ### Tool Restrictions The filtering order is: diff --git a/src/agents/sandbox-merge.test.ts b/src/agents/sandbox-merge.test.ts new file mode 100644 index 000000000..904debcce --- /dev/null +++ b/src/agents/sandbox-merge.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +describe("sandbox config merges", () => { + it("resolves sandbox scope deterministically", async () => { + const { resolveSandboxScope } = await import("./sandbox.js"); + + expect(resolveSandboxScope({})).toBe("agent"); + expect(resolveSandboxScope({ perSession: true })).toBe("session"); + expect(resolveSandboxScope({ perSession: false })).toBe("shared"); + expect(resolveSandboxScope({ perSession: true, scope: "agent" })).toBe( + "agent", + ); + }); + + it("merges sandbox docker env and ulimits (agent wins)", async () => { + const { resolveSandboxDockerConfig } = await import("./sandbox.js"); + + const resolved = resolveSandboxDockerConfig({ + scope: "agent", + globalDocker: { + env: { LANG: "C.UTF-8", FOO: "1" }, + ulimits: { nofile: { soft: 10, hard: 20 } }, + }, + agentDocker: { + env: { FOO: "2", BAR: "3" }, + ulimits: { nproc: 256 }, + }, + }); + + expect(resolved.env).toEqual({ LANG: "C.UTF-8", FOO: "2", BAR: "3" }); + expect(resolved.ulimits).toEqual({ + nofile: { soft: 10, hard: 20 }, + nproc: 256, + }); + }); + + it("ignores agent docker overrides under shared scope", async () => { + const { resolveSandboxDockerConfig } = await import("./sandbox.js"); + + const resolved = resolveSandboxDockerConfig({ + scope: "shared", + globalDocker: { image: "global" }, + agentDocker: { image: "agent" }, + }); + + expect(resolved.image).toBe("global"); + }); + + it("applies per-agent browser and prune overrides (ignored under shared scope)", async () => { + const { resolveSandboxBrowserConfig, resolveSandboxPruneConfig } = + await import("./sandbox.js"); + + const browser = resolveSandboxBrowserConfig({ + scope: "agent", + globalBrowser: { enabled: false, headless: false, enableNoVnc: true }, + agentBrowser: { enabled: true, headless: true, enableNoVnc: false }, + }); + expect(browser.enabled).toBe(true); + expect(browser.headless).toBe(true); + expect(browser.enableNoVnc).toBe(false); + + const prune = resolveSandboxPruneConfig({ + scope: "agent", + globalPrune: { idleHours: 24, maxAgeDays: 7 }, + agentPrune: { idleHours: 0, maxAgeDays: 1 }, + }); + expect(prune).toEqual({ idleHours: 0, maxAgeDays: 1 }); + + const browserShared = resolveSandboxBrowserConfig({ + scope: "shared", + globalBrowser: { enabled: false }, + agentBrowser: { enabled: true }, + }); + expect(browserShared.enabled).toBe(false); + + const pruneShared = resolveSandboxPruneConfig({ + scope: "shared", + globalPrune: { idleHours: 24, maxAgeDays: 7 }, + agentPrune: { idleHours: 0, maxAgeDays: 1 }, + }); + expect(pruneShared).toEqual({ idleHours: 24, maxAgeDays: 7 }); + }); +}); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 7b3c6cf7e..4792c6379 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -207,7 +207,7 @@ function isToolAllowed(policy: SandboxToolPolicy, name: string) { return allow.includes(name.toLowerCase()); } -function resolveSandboxScope(params: { +export function resolveSandboxScope(params: { scope?: SandboxScope; perSession?: boolean; }): SandboxScope { @@ -218,6 +218,108 @@ function resolveSandboxScope(params: { return "agent"; } +export function resolveSandboxDockerConfig(params: { + scope: SandboxScope; + globalDocker?: Partial; + agentDocker?: Partial; +}): SandboxDockerConfig { + const agentDocker = + params.scope === "shared" ? undefined : params.agentDocker; + const globalDocker = params.globalDocker; + + const env = agentDocker?.env + ? { ...(globalDocker?.env ?? { LANG: "C.UTF-8" }), ...agentDocker.env } + : (globalDocker?.env ?? { LANG: "C.UTF-8" }); + + const ulimits = agentDocker?.ulimits + ? { ...globalDocker?.ulimits, ...agentDocker.ulimits } + : globalDocker?.ulimits; + + return { + image: agentDocker?.image ?? globalDocker?.image ?? DEFAULT_SANDBOX_IMAGE, + containerPrefix: + agentDocker?.containerPrefix ?? + globalDocker?.containerPrefix ?? + DEFAULT_SANDBOX_CONTAINER_PREFIX, + workdir: + agentDocker?.workdir ?? globalDocker?.workdir ?? DEFAULT_SANDBOX_WORKDIR, + readOnlyRoot: + agentDocker?.readOnlyRoot ?? globalDocker?.readOnlyRoot ?? true, + tmpfs: agentDocker?.tmpfs ?? + globalDocker?.tmpfs ?? ["/tmp", "/var/tmp", "/run"], + network: agentDocker?.network ?? globalDocker?.network ?? "none", + user: agentDocker?.user ?? globalDocker?.user, + capDrop: agentDocker?.capDrop ?? globalDocker?.capDrop ?? ["ALL"], + env, + setupCommand: agentDocker?.setupCommand ?? globalDocker?.setupCommand, + pidsLimit: agentDocker?.pidsLimit ?? globalDocker?.pidsLimit, + memory: agentDocker?.memory ?? globalDocker?.memory, + memorySwap: agentDocker?.memorySwap ?? globalDocker?.memorySwap, + cpus: agentDocker?.cpus ?? globalDocker?.cpus, + ulimits, + seccompProfile: agentDocker?.seccompProfile ?? globalDocker?.seccompProfile, + apparmorProfile: + agentDocker?.apparmorProfile ?? globalDocker?.apparmorProfile, + dns: agentDocker?.dns ?? globalDocker?.dns, + extraHosts: agentDocker?.extraHosts ?? globalDocker?.extraHosts, + }; +} + +export function resolveSandboxBrowserConfig(params: { + scope: SandboxScope; + globalBrowser?: Partial; + agentBrowser?: Partial; +}): SandboxBrowserConfig { + const agentBrowser = + params.scope === "shared" ? undefined : params.agentBrowser; + const globalBrowser = params.globalBrowser; + return { + enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false, + image: + agentBrowser?.image ?? + globalBrowser?.image ?? + DEFAULT_SANDBOX_BROWSER_IMAGE, + containerPrefix: + agentBrowser?.containerPrefix ?? + globalBrowser?.containerPrefix ?? + DEFAULT_SANDBOX_BROWSER_PREFIX, + cdpPort: + agentBrowser?.cdpPort ?? + globalBrowser?.cdpPort ?? + DEFAULT_SANDBOX_BROWSER_CDP_PORT, + vncPort: + agentBrowser?.vncPort ?? + globalBrowser?.vncPort ?? + DEFAULT_SANDBOX_BROWSER_VNC_PORT, + noVncPort: + agentBrowser?.noVncPort ?? + globalBrowser?.noVncPort ?? + DEFAULT_SANDBOX_BROWSER_NOVNC_PORT, + headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false, + enableNoVnc: + agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true, + }; +} + +export function resolveSandboxPruneConfig(params: { + scope: SandboxScope; + globalPrune?: Partial; + agentPrune?: Partial; +}): SandboxPruneConfig { + const agentPrune = params.scope === "shared" ? undefined : params.agentPrune; + const globalPrune = params.globalPrune; + return { + idleHours: + agentPrune?.idleHours ?? + globalPrune?.idleHours ?? + DEFAULT_SANDBOX_IDLE_HOURS, + maxAgeDays: + agentPrune?.maxAgeDays ?? + globalPrune?.maxAgeDays ?? + DEFAULT_SANDBOX_MAX_AGE_DAYS, + }; +} + function resolveSandboxScopeKey(scope: SandboxScope, sessionKey: string) { const trimmed = sessionKey.trim() || "main"; if (scope === "shared") return "shared"; @@ -226,7 +328,7 @@ function resolveSandboxScopeKey(scope: SandboxScope, sessionKey: string) { return `agent:${agentId}`; } -function defaultSandboxConfig( +export function resolveSandboxConfigForAgent( cfg?: ClawdbotConfig, agentId?: string, ): SandboxConfig { @@ -246,9 +348,6 @@ function defaultSandboxConfig( perSession: agentSandbox?.perSession ?? agent?.perSession, }); - const globalDocker = agent?.docker; - const agentDocker = scope === "shared" ? undefined : agentSandbox?.docker; - return { mode: agentSandbox?.mode ?? agent?.mode ?? "off", scope, @@ -258,63 +357,27 @@ function defaultSandboxConfig( agentSandbox?.workspaceRoot ?? agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT, - docker: { - image: agentDocker?.image ?? globalDocker?.image ?? DEFAULT_SANDBOX_IMAGE, - containerPrefix: - agentDocker?.containerPrefix ?? - globalDocker?.containerPrefix ?? - DEFAULT_SANDBOX_CONTAINER_PREFIX, - workdir: - agentDocker?.workdir ?? - globalDocker?.workdir ?? - DEFAULT_SANDBOX_WORKDIR, - readOnlyRoot: - agentDocker?.readOnlyRoot ?? globalDocker?.readOnlyRoot ?? true, - tmpfs: agentDocker?.tmpfs ?? - globalDocker?.tmpfs ?? ["/tmp", "/var/tmp", "/run"], - network: agentDocker?.network ?? globalDocker?.network ?? "none", - user: agentDocker?.user ?? globalDocker?.user, - capDrop: agentDocker?.capDrop ?? globalDocker?.capDrop ?? ["ALL"], - env: agentDocker?.env - ? { ...(globalDocker?.env ?? { LANG: "C.UTF-8" }), ...agentDocker.env } - : (globalDocker?.env ?? { LANG: "C.UTF-8" }), - setupCommand: agentDocker?.setupCommand ?? globalDocker?.setupCommand, - pidsLimit: agentDocker?.pidsLimit ?? globalDocker?.pidsLimit, - memory: agentDocker?.memory ?? globalDocker?.memory, - memorySwap: agentDocker?.memorySwap ?? globalDocker?.memorySwap, - cpus: agentDocker?.cpus ?? globalDocker?.cpus, - ulimits: agentDocker?.ulimits - ? { ...globalDocker?.ulimits, ...agentDocker.ulimits } - : globalDocker?.ulimits, - seccompProfile: - agentDocker?.seccompProfile ?? globalDocker?.seccompProfile, - apparmorProfile: - agentDocker?.apparmorProfile ?? globalDocker?.apparmorProfile, - dns: agentDocker?.dns ?? globalDocker?.dns, - extraHosts: agentDocker?.extraHosts ?? globalDocker?.extraHosts, - }, - browser: { - enabled: agent?.browser?.enabled ?? false, - image: agent?.browser?.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE, - containerPrefix: - agent?.browser?.containerPrefix ?? DEFAULT_SANDBOX_BROWSER_PREFIX, - cdpPort: agent?.browser?.cdpPort ?? DEFAULT_SANDBOX_BROWSER_CDP_PORT, - vncPort: agent?.browser?.vncPort ?? DEFAULT_SANDBOX_BROWSER_VNC_PORT, - noVncPort: - agent?.browser?.noVncPort ?? DEFAULT_SANDBOX_BROWSER_NOVNC_PORT, - headless: agent?.browser?.headless ?? false, - enableNoVnc: agent?.browser?.enableNoVnc ?? true, - }, + docker: resolveSandboxDockerConfig({ + scope, + globalDocker: agent?.docker, + agentDocker: agentSandbox?.docker, + }), + browser: resolveSandboxBrowserConfig({ + scope, + globalBrowser: agent?.browser, + agentBrowser: agentSandbox?.browser, + }), tools: { allow: agentSandbox?.tools?.allow ?? agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW, deny: agentSandbox?.tools?.deny ?? agent?.tools?.deny ?? DEFAULT_TOOL_DENY, }, - prune: { - idleHours: agent?.prune?.idleHours ?? DEFAULT_SANDBOX_IDLE_HOURS, - maxAgeDays: agent?.prune?.maxAgeDays ?? DEFAULT_SANDBOX_MAX_AGE_DAYS, - }, + prune: resolveSandboxPruneConfig({ + scope, + globalPrune: agent?.prune, + agentPrune: agentSandbox?.prune, + }), }; } @@ -962,7 +1025,7 @@ export async function resolveSandboxContext(params: { const rawSessionKey = params.sessionKey?.trim(); if (!rawSessionKey) return null; const agentId = resolveAgentIdFromSessionKey(rawSessionKey); - const cfg = defaultSandboxConfig(params.config, agentId); + const cfg = resolveSandboxConfigForAgent(params.config, agentId); const mainKey = params.config?.session?.mainKey?.trim() || "main"; if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null; @@ -1025,7 +1088,7 @@ export async function ensureSandboxWorkspaceForSession(params: { const rawSessionKey = params.sessionKey?.trim(); if (!rawSessionKey) return null; const agentId = resolveAgentIdFromSessionKey(rawSessionKey); - const cfg = defaultSandboxConfig(params.config, agentId); + const cfg = resolveSandboxConfigForAgent(params.config, agentId); const mainKey = params.config?.session?.mainKey?.trim() || "main"; if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null; diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index e48a01ce2..3f0158d00 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -25,6 +25,7 @@ afterEach(() => { const readConfigFileSnapshot = vi.fn(); const confirm = vi.fn().mockResolvedValue(true); const select = vi.fn().mockResolvedValue("node"); +const note = vi.fn(); const writeConfigFile = vi.fn().mockResolvedValue(undefined); const migrateLegacyConfig = vi.fn((raw: unknown) => ({ config: raw as Record, @@ -74,7 +75,7 @@ const serviceUninstall = vi.fn().mockResolvedValue(undefined); vi.mock("@clack/prompts", () => ({ confirm, intro: vi.fn(), - note: vi.fn(), + note, outro: vi.fn(), select, })); @@ -413,6 +414,61 @@ describe("doctor", () => { expect(docker.image).toBe("clawdbot-sandbox"); expect(docker.containerPrefix).toBe("clawdbot-sbx"); }); + + it("warns when per-agent sandbox docker/browser/prune overrides are ignored under shared scope", async () => { + readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/clawdbot.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + config: { + agent: { + sandbox: { + mode: "all", + scope: "shared", + }, + }, + routing: { + agents: { + work: { + workspace: "~/clawd-work", + sandbox: { + mode: "all", + scope: "shared", + docker: { + setupCommand: "echo work", + }, + }, + }, + }, + }, + }, + issues: [], + legacyIssues: [], + }); + + note.mockClear(); + + const { doctorCommand } = await import("./doctor.js"); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await doctorCommand(runtime, { nonInteractive: true }); + + expect( + note.mock.calls.some( + ([message, title]) => + title === "Sandbox" && + typeof message === "string" && + message.includes("routing.agents.work.sandbox") && + message.includes('scope resolves to "shared"'), + ), + ).toBe(true); + }); it("falls back to legacy sandbox image when missing", async () => { readConfigFileSnapshot.mockResolvedValue({ path: "/tmp/clawdbot.json", diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 5dfb6832a..3acc69e18 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -11,6 +11,7 @@ import { DEFAULT_SANDBOX_BROWSER_IMAGE, DEFAULT_SANDBOX_COMMON_IMAGE, DEFAULT_SANDBOX_IMAGE, + resolveSandboxScope, } from "../agents/sandbox.js"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import { DEFAULT_AGENTS_FILENAME } from "../agents/workspace.js"; @@ -63,25 +64,12 @@ function resolveMode(cfg: ClawdbotConfig): "local" | "remote" { return cfg.gateway?.mode === "remote" ? "remote" : "local"; } -type SandboxScope = "session" | "agent" | "shared"; - -function resolveSandboxScope(params: { - scope?: SandboxScope; - perSession?: boolean; -}): SandboxScope { - if (params.scope) return params.scope; - if (typeof params.perSession === "boolean") { - return params.perSession ? "session" : "shared"; - } - return "agent"; +function hasObjectOverrides(value?: unknown) { + if (!value || typeof value !== "object") return false; + return Object.values(value).some((entry) => entry !== undefined); } -function hasDockerOverrides(docker?: unknown) { - if (!docker || typeof docker !== "object") return false; - return Object.values(docker).some((value) => value !== undefined); -} - -function collectSandboxSharedDockerOverrideWarnings(cfg: ClawdbotConfig) { +function collectSandboxSharedOverrideWarnings(cfg: ClawdbotConfig) { const globalSandbox = cfg.agent?.sandbox; const agents = cfg.routing?.agents; if (!agents) return []; @@ -91,16 +79,21 @@ function collectSandboxSharedDockerOverrideWarnings(cfg: ClawdbotConfig) { if (!agentCfg || typeof agentCfg !== "object") continue; const agentSandbox = agentCfg.sandbox; if (!agentSandbox || typeof agentSandbox !== "object") continue; - if (!hasDockerOverrides(agentSandbox.docker)) continue; + + const hasOverrides = + hasObjectOverrides(agentSandbox.docker) || + hasObjectOverrides(agentSandbox.browser) || + hasObjectOverrides(agentSandbox.prune); + if (!hasOverrides) continue; const scope = resolveSandboxScope({ - scope: (agentSandbox.scope ?? globalSandbox?.scope) as SandboxScope, + scope: agentSandbox.scope ?? globalSandbox?.scope, perSession: agentSandbox.perSession ?? globalSandbox?.perSession, }); if (scope !== "shared") continue; warnings.push( - `- routing.agents.${agentId}.sandbox.docker.* is ignored when sandbox scope resolves to "shared" (single shared container).`, + `- routing.agents.${agentId}.sandbox.{docker,browser,prune}.* is ignored when sandbox scope resolves to "shared" (single shared container).`, ); } @@ -1020,14 +1013,13 @@ export async function doctorCommand( await noteSecurityWarnings(cfg); - const sharedDockerOverrideWarnings = - collectSandboxSharedDockerOverrideWarnings(cfg); - if (sharedDockerOverrideWarnings.length > 0) { + const sharedOverrideWarnings = collectSandboxSharedOverrideWarnings(cfg); + if (sharedOverrideWarnings.length > 0) { note( [ - ...sharedDockerOverrideWarnings, + ...sharedOverrideWarnings, "", - 'Fix: set scope to "agent"/"session", or move the docker config to agent.sandbox.docker (global).', + 'Fix: set scope to "agent"/"session", or move the config to agent.sandbox.{docker,browser,prune} (global).', ].join("\n"), "Sandbox", ); diff --git a/src/config/types.ts b/src/config/types.ts index 2c014168b..a059f4a74 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -625,6 +625,24 @@ export type SandboxDockerSettings = { extraHosts?: string[]; }; +export type SandboxBrowserSettings = { + enabled?: boolean; + image?: string; + containerPrefix?: string; + cdpPort?: number; + vncPort?: number; + noVncPort?: number; + headless?: boolean; + enableNoVnc?: boolean; +}; + +export type SandboxPruneSettings = { + /** Prune if idle for more than N hours (0 disables). */ + idleHours?: number; + /** Prune if older than N days (0 disables). */ + maxAgeDays?: number; +}; + export type GroupChatConfig = { mentionPatterns?: string[]; historyLimit?: number; @@ -663,11 +681,15 @@ export type RoutingConfig = { workspaceRoot?: string; /** Docker-specific sandbox overrides for this agent. */ docker?: SandboxDockerSettings; + /** Optional sandboxed browser overrides for this agent. */ + browser?: SandboxBrowserSettings; /** Tool allow/deny policy for sandboxed sessions (deny wins). */ tools?: { allow?: string[]; deny?: string[]; }; + /** Auto-prune overrides for this agent. */ + prune?: SandboxPruneSettings; }; tools?: { allow?: string[]; @@ -1093,28 +1115,14 @@ export type ClawdbotConfig = { /** Docker-specific sandbox settings. */ docker?: SandboxDockerSettings; /** Optional sandboxed browser settings. */ - browser?: { - enabled?: boolean; - image?: string; - containerPrefix?: string; - cdpPort?: number; - vncPort?: number; - noVncPort?: number; - headless?: boolean; - enableNoVnc?: boolean; - }; + browser?: SandboxBrowserSettings; /** Tool allow/deny policy (deny wins). */ tools?: { allow?: string[]; deny?: string[]; }; /** Auto-prune sandbox containers. */ - prune?: { - /** Prune if idle for more than N hours (0 disables). */ - idleHours?: number; - /** Prune if older than N days (0 disables). */ - maxAgeDays?: number; - }; + prune?: SandboxPruneSettings; }; /** Global tool allow/deny policy for all providers (deny wins). */ tools?: { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index a58ae9695..2ef882387 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -260,6 +260,33 @@ const SandboxDockerSchema = z }) .optional(); +const SandboxBrowserSchema = z + .object({ + enabled: z.boolean().optional(), + image: z.string().optional(), + containerPrefix: z.string().optional(), + cdpPort: z.number().int().positive().optional(), + vncPort: z.number().int().positive().optional(), + noVncPort: z.number().int().positive().optional(), + headless: z.boolean().optional(), + enableNoVnc: z.boolean().optional(), + }) + .optional(); + +const SandboxPruneSchema = z + .object({ + idleHours: z.number().int().nonnegative().optional(), + maxAgeDays: z.number().int().nonnegative().optional(), + }) + .optional(); + +const ToolPolicySchema = z + .object({ + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), + }) + .optional(); + const RoutingSchema = z .object({ groupChat: GroupChatSchema, @@ -302,20 +329,12 @@ const RoutingSchema = z perSession: z.boolean().optional(), workspaceRoot: z.string().optional(), docker: SandboxDockerSchema, - tools: z - .object({ - allow: z.array(z.string()).optional(), - deny: z.array(z.string()).optional(), - }) - .optional(), - }) - .optional(), - tools: z - .object({ - allow: z.array(z.string()).optional(), - deny: z.array(z.string()).optional(), + browser: SandboxBrowserSchema, + tools: ToolPolicySchema, + prune: SandboxPruneSchema, }) .optional(), + tools: ToolPolicySchema, }) .optional(), ) @@ -706,30 +725,9 @@ export const ClawdbotSchema = z.object({ perSession: z.boolean().optional(), workspaceRoot: z.string().optional(), docker: SandboxDockerSchema, - browser: z - .object({ - enabled: z.boolean().optional(), - image: z.string().optional(), - containerPrefix: z.string().optional(), - cdpPort: z.number().int().positive().optional(), - vncPort: z.number().int().positive().optional(), - noVncPort: z.number().int().positive().optional(), - headless: z.boolean().optional(), - enableNoVnc: z.boolean().optional(), - }) - .optional(), - tools: z - .object({ - allow: z.array(z.string()).optional(), - deny: z.array(z.string()).optional(), - }) - .optional(), - prune: z - .object({ - idleHours: z.number().int().nonnegative().optional(), - maxAgeDays: z.number().int().nonnegative().optional(), - }) - .optional(), + browser: SandboxBrowserSchema, + tools: ToolPolicySchema, + prune: SandboxPruneSchema, }) .optional(), })