import { EventEmitter } from "node:events"; import path from "node:path"; import { Readable } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../config/config.js"; // We need to test the internal defaultSandboxConfig function, but it's not exported. // Instead, we test the behavior through resolveSandboxContext which uses it. type SpawnCall = { command: string; args: string[]; }; const spawnCalls: SpawnCall[] = []; vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, spawn: (command: string, args: string[]) => { spawnCalls.push({ command, args }); const child = new EventEmitter() as { stdout?: Readable; stderr?: Readable; on: (event: string, cb: (...args: unknown[]) => void) => void; }; child.stdout = new Readable({ read() {} }); child.stderr = new Readable({ read() {} }); const dockerArgs = command === "docker" ? args : []; const shouldFailContainerInspect = dockerArgs[0] === "inspect" && dockerArgs[1] === "-f" && dockerArgs[2] === "{{.State.Running}}"; const shouldSucceedImageInspect = dockerArgs[0] === "image" && dockerArgs[1] === "inspect"; const code = shouldFailContainerInspect ? 1 : 0; if (shouldSucceedImageInspect) { queueMicrotask(() => child.emit("close", 0)); } else { queueMicrotask(() => child.emit("close", code)); } return child; }, }; }); describe("Agent-specific sandbox config", () => { beforeEach(() => { spawnCalls.length = 0; }); it( "should use global sandbox config when no agent-specific config exists", { timeout: 15_000 }, async () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { agents: { defaults: { sandbox: { mode: "all", scope: "agent", }, }, list: [ { id: "main", workspace: "~/clawd", }, ], }, }; const context = await resolveSandboxContext({ config: cfg, sessionKey: "agent:main:main", workspaceDir: "/tmp/test", }); expect(context).toBeDefined(); expect(context?.enabled).toBe(true); }, ); it("should allow agent-specific docker setupCommand overrides", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { agents: { defaults: { sandbox: { mode: "all", scope: "agent", docker: { setupCommand: "echo global", }, }, }, list: [ { id: "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"); expect( spawnCalls.some( (call) => call.command === "docker" && call.args[0] === "exec" && call.args.includes("-lc") && call.args.includes("echo work"), ), ).toBe(true); }); it("should ignore agent-specific docker overrides when scope is shared", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { agents: { defaults: { sandbox: { mode: "all", scope: "shared", docker: { setupCommand: "echo global", }, }, }, list: [ { id: "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"); expect( spawnCalls.some( (call) => call.command === "docker" && call.args[0] === "exec" && call.args.includes("-lc") && call.args.includes("echo global"), ), ).toBe(true); }); it("should allow agent-specific docker settings beyond setupCommand", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { agents: { defaults: { sandbox: { mode: "all", scope: "agent", docker: { image: "global-image", network: "none", }, }, }, list: [ { id: "work", workspace: "~/clawd-work", sandbox: { mode: "all", scope: "agent", docker: { image: "work-image", network: "bridge", }, }, }, ], }, }; const context = await resolveSandboxContext({ config: cfg, sessionKey: "agent:work:main", workspaceDir: "/tmp/test-work", }); expect(context).toBeDefined(); expect(context?.docker.image).toBe("work-image"); expect(context?.docker.network).toBe("bridge"); }); it("should override with agent-specific sandbox mode 'off'", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { agents: { defaults: { sandbox: { mode: "all", // Global default scope: "agent", }, }, list: [ { id: "main", workspace: "~/clawd", sandbox: { mode: "off", // Agent override }, }, ], }, }; const context = await resolveSandboxContext({ config: cfg, sessionKey: "agent:main:main", workspaceDir: "/tmp/test", }); // Should be null because mode is "off" expect(context).toBeNull(); }); it("should use agent-specific sandbox mode 'all'", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { agents: { defaults: { sandbox: { mode: "off", // Global default }, }, list: [ { id: "family", workspace: "~/clawd-family", sandbox: { mode: "all", // Agent override scope: "agent", }, }, ], }, }; const context = await resolveSandboxContext({ config: cfg, sessionKey: "agent:family:whatsapp:group:123", workspaceDir: "/tmp/test-family", }); expect(context).toBeDefined(); expect(context?.enabled).toBe(true); }); it("should use agent-specific scope", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { agents: { defaults: { sandbox: { mode: "all", scope: "session", // Global default }, }, list: [ { id: "work", workspace: "~/clawd-work", sandbox: { mode: "all", scope: "agent", // Agent override }, }, ], }, }; const context = await resolveSandboxContext({ config: cfg, sessionKey: "agent:work:slack:channel:456", workspaceDir: "/tmp/test-work", }); expect(context).toBeDefined(); // The container name should use agent scope (agent:work) expect(context?.containerName).toContain("agent-work"); }); it("should use agent-specific workspaceRoot", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { agents: { defaults: { sandbox: { mode: "all", scope: "agent", workspaceRoot: "~/.clawdbot/sandboxes", // Global default }, }, list: [ { id: "isolated", workspace: "~/clawd-isolated", sandbox: { mode: "all", scope: "agent", workspaceRoot: "/tmp/isolated-sandboxes", // Agent override }, }, ], }, }; const context = await resolveSandboxContext({ config: cfg, sessionKey: "agent:isolated:main", workspaceDir: "/tmp/test-isolated", }); expect(context).toBeDefined(); expect(context?.workspaceDir).toContain( path.resolve("/tmp/isolated-sandboxes"), ); }); it("should prefer agent config over global for multiple agents", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { agents: { defaults: { sandbox: { mode: "non-main", scope: "session", }, }, list: [ { id: "main", workspace: "~/clawd", sandbox: { mode: "off", // main: no sandbox }, }, { id: "family", workspace: "~/clawd-family", sandbox: { mode: "all", // family: always sandbox scope: "agent", }, }, ], }, }; // main agent should not be sandboxed const mainContext = await resolveSandboxContext({ config: cfg, sessionKey: "agent:main:telegram:group:789", workspaceDir: "/tmp/test-main", }); expect(mainContext).toBeNull(); // family agent should be sandboxed const familyContext = await resolveSandboxContext({ config: cfg, sessionKey: "agent:family:whatsapp:group:123", workspaceDir: "/tmp/test-family", }); expect(familyContext).toBeDefined(); expect(familyContext?.enabled).toBe(true); }); it("should prefer agent-specific sandbox tool policy", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { agents: { defaults: { sandbox: { mode: "all", scope: "agent", }, }, list: [ { id: "restricted", workspace: "~/clawd-restricted", sandbox: { mode: "all", scope: "agent", }, tools: { sandbox: { tools: { allow: ["read", "write"], deny: ["edit"], }, }, }, }, ], }, tools: { sandbox: { tools: { allow: ["read"], deny: ["exec"], }, }, }, }; const context = await resolveSandboxContext({ config: cfg, sessionKey: "agent:restricted:main", workspaceDir: "/tmp/test-restricted", }); expect(context).toBeDefined(); expect(context?.tools).toEqual({ allow: ["read", "write"], deny: ["edit"], }); }); it("includes session_status in default sandbox allowlist", async () => { const { resolveSandboxConfigForAgent } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { agents: { defaults: { sandbox: { mode: "all", scope: "agent", }, }, }, }; const sandbox = resolveSandboxConfigForAgent(cfg, "main"); expect(sandbox.tools.allow).toContain("session_status"); }); });