refactor(sandbox): unify scope + per-agent overrides

This commit is contained in:
Peter Steinberger
2026-01-08 01:17:49 +01:00
parent ad8b7c739b
commit 145fe1cec7
10 changed files with 343 additions and 140 deletions

View File

@@ -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 });
});
});

View File

@@ -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<SandboxDockerConfig>;
agentDocker?: Partial<SandboxDockerConfig>;
}): 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<SandboxBrowserConfig>;
agentBrowser?: Partial<SandboxBrowserConfig>;
}): 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<SandboxPruneConfig>;
agentPrune?: Partial<SandboxPruneConfig>;
}): 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;