refactor(sandbox): unify scope + per-agent overrides
This commit is contained in:
@@ -31,7 +31,7 @@
|
|||||||
- Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first).
|
- 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`.
|
- 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.
|
- 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.<agentId>.sandbox.docker.*` overrides for multi-agent gateways (ignored when `scope: "shared"`).
|
- Sandbox: allow per-agent `routing.agents.<agentId>.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: 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.
|
- Tools: scope `process` sessions per agent to prevent cross-agent visibility.
|
||||||
- Cron: clamp timer delay to avoid TimeoutOverflowWarning. Thanks @emanuelst for PR #412.
|
- Cron: clamp timer delay to avoid TimeoutOverflowWarning. Thanks @emanuelst for PR #412.
|
||||||
|
|||||||
@@ -340,6 +340,8 @@ Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside o
|
|||||||
- `scope`: `"session"` | `"agent"` | `"shared"`
|
- `scope`: `"session"` | `"agent"` | `"shared"`
|
||||||
- `workspaceRoot`: custom sandbox workspace root
|
- `workspaceRoot`: custom sandbox workspace root
|
||||||
- `docker`: per-agent docker overrides (e.g. `image`, `network`, `env`, `setupCommand`, limits; ignored when `scope: "shared"`)
|
- `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 sandbox tool policy (deny wins; overrides `agent.sandbox.tools`)
|
||||||
- `tools`: per-agent tool restrictions (overrides `agent.tools`; applied before sandbox tool policy).
|
- `tools`: per-agent tool restrictions (overrides `agent.tools`; applied before sandbox tool policy).
|
||||||
- `allow`: array of allowed tool names
|
- `allow`: array of allowed tool names
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ Hardening knobs live under `agent.sandbox.docker`:
|
|||||||
`network`, `user`, `pidsLimit`, `memory`, `memorySwap`, `cpus`, `ulimits`,
|
`network`, `user`, `pidsLimit`, `memory`, `memorySwap`, `cpus`, `ulimits`,
|
||||||
`seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`.
|
`seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`.
|
||||||
|
|
||||||
Multi-agent: override `agent.sandbox.docker.*` per agent via `routing.agents.<agentId>.sandbox.docker.*`
|
Multi-agent: override `agent.sandbox.{docker,browser,prune}.*` per agent via `routing.agents.<agentId>.sandbox.{docker,browser,prune}.*`
|
||||||
(ignored when `agent.sandbox.scope` / `routing.agents.<agentId>.sandbox.scope` is `"shared"`).
|
(ignored when `agent.sandbox.scope` / `routing.agents.<agentId>.sandbox.scope` is `"shared"`).
|
||||||
|
|
||||||
### Build the default sandbox image
|
### Build the default sandbox image
|
||||||
|
|||||||
@@ -150,11 +150,12 @@ routing.agents[id].sandbox.scope > agent.sandbox.scope
|
|||||||
routing.agents[id].sandbox.workspaceRoot > agent.sandbox.workspaceRoot
|
routing.agents[id].sandbox.workspaceRoot > agent.sandbox.workspaceRoot
|
||||||
routing.agents[id].sandbox.workspaceAccess > agent.sandbox.workspaceAccess
|
routing.agents[id].sandbox.workspaceAccess > agent.sandbox.workspaceAccess
|
||||||
routing.agents[id].sandbox.docker.* > agent.sandbox.docker.*
|
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:**
|
**Notes:**
|
||||||
- `routing.agents[id].sandbox.docker.*` overrides `agent.sandbox.docker.*` for that agent (ignored when sandbox scope resolves to `"shared"`).
|
- `routing.agents[id].sandbox.{docker,browser,prune}.*` overrides `agent.sandbox.{docker,browser,prune}.*` 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.
|
|
||||||
|
|
||||||
### Tool Restrictions
|
### Tool Restrictions
|
||||||
The filtering order is:
|
The filtering order is:
|
||||||
|
|||||||
83
src/agents/sandbox-merge.test.ts
Normal file
83
src/agents/sandbox-merge.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -207,7 +207,7 @@ function isToolAllowed(policy: SandboxToolPolicy, name: string) {
|
|||||||
return allow.includes(name.toLowerCase());
|
return allow.includes(name.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSandboxScope(params: {
|
export function resolveSandboxScope(params: {
|
||||||
scope?: SandboxScope;
|
scope?: SandboxScope;
|
||||||
perSession?: boolean;
|
perSession?: boolean;
|
||||||
}): SandboxScope {
|
}): SandboxScope {
|
||||||
@@ -218,6 +218,108 @@ function resolveSandboxScope(params: {
|
|||||||
return "agent";
|
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) {
|
function resolveSandboxScopeKey(scope: SandboxScope, sessionKey: string) {
|
||||||
const trimmed = sessionKey.trim() || "main";
|
const trimmed = sessionKey.trim() || "main";
|
||||||
if (scope === "shared") return "shared";
|
if (scope === "shared") return "shared";
|
||||||
@@ -226,7 +328,7 @@ function resolveSandboxScopeKey(scope: SandboxScope, sessionKey: string) {
|
|||||||
return `agent:${agentId}`;
|
return `agent:${agentId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultSandboxConfig(
|
export function resolveSandboxConfigForAgent(
|
||||||
cfg?: ClawdbotConfig,
|
cfg?: ClawdbotConfig,
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
): SandboxConfig {
|
): SandboxConfig {
|
||||||
@@ -246,9 +348,6 @@ function defaultSandboxConfig(
|
|||||||
perSession: agentSandbox?.perSession ?? agent?.perSession,
|
perSession: agentSandbox?.perSession ?? agent?.perSession,
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalDocker = agent?.docker;
|
|
||||||
const agentDocker = scope === "shared" ? undefined : agentSandbox?.docker;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode: agentSandbox?.mode ?? agent?.mode ?? "off",
|
mode: agentSandbox?.mode ?? agent?.mode ?? "off",
|
||||||
scope,
|
scope,
|
||||||
@@ -258,63 +357,27 @@ function defaultSandboxConfig(
|
|||||||
agentSandbox?.workspaceRoot ??
|
agentSandbox?.workspaceRoot ??
|
||||||
agent?.workspaceRoot ??
|
agent?.workspaceRoot ??
|
||||||
DEFAULT_SANDBOX_WORKSPACE_ROOT,
|
DEFAULT_SANDBOX_WORKSPACE_ROOT,
|
||||||
docker: {
|
docker: resolveSandboxDockerConfig({
|
||||||
image: agentDocker?.image ?? globalDocker?.image ?? DEFAULT_SANDBOX_IMAGE,
|
scope,
|
||||||
containerPrefix:
|
globalDocker: agent?.docker,
|
||||||
agentDocker?.containerPrefix ??
|
agentDocker: agentSandbox?.docker,
|
||||||
globalDocker?.containerPrefix ??
|
}),
|
||||||
DEFAULT_SANDBOX_CONTAINER_PREFIX,
|
browser: resolveSandboxBrowserConfig({
|
||||||
workdir:
|
scope,
|
||||||
agentDocker?.workdir ??
|
globalBrowser: agent?.browser,
|
||||||
globalDocker?.workdir ??
|
agentBrowser: agentSandbox?.browser,
|
||||||
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,
|
|
||||||
},
|
|
||||||
tools: {
|
tools: {
|
||||||
allow:
|
allow:
|
||||||
agentSandbox?.tools?.allow ?? agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW,
|
agentSandbox?.tools?.allow ?? agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW,
|
||||||
deny:
|
deny:
|
||||||
agentSandbox?.tools?.deny ?? agent?.tools?.deny ?? DEFAULT_TOOL_DENY,
|
agentSandbox?.tools?.deny ?? agent?.tools?.deny ?? DEFAULT_TOOL_DENY,
|
||||||
},
|
},
|
||||||
prune: {
|
prune: resolveSandboxPruneConfig({
|
||||||
idleHours: agent?.prune?.idleHours ?? DEFAULT_SANDBOX_IDLE_HOURS,
|
scope,
|
||||||
maxAgeDays: agent?.prune?.maxAgeDays ?? DEFAULT_SANDBOX_MAX_AGE_DAYS,
|
globalPrune: agent?.prune,
|
||||||
},
|
agentPrune: agentSandbox?.prune,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,7 +1025,7 @@ export async function resolveSandboxContext(params: {
|
|||||||
const rawSessionKey = params.sessionKey?.trim();
|
const rawSessionKey = params.sessionKey?.trim();
|
||||||
if (!rawSessionKey) return null;
|
if (!rawSessionKey) return null;
|
||||||
const agentId = resolveAgentIdFromSessionKey(rawSessionKey);
|
const agentId = resolveAgentIdFromSessionKey(rawSessionKey);
|
||||||
const cfg = defaultSandboxConfig(params.config, agentId);
|
const cfg = resolveSandboxConfigForAgent(params.config, agentId);
|
||||||
const mainKey = params.config?.session?.mainKey?.trim() || "main";
|
const mainKey = params.config?.session?.mainKey?.trim() || "main";
|
||||||
if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null;
|
if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null;
|
||||||
|
|
||||||
@@ -1025,7 +1088,7 @@ export async function ensureSandboxWorkspaceForSession(params: {
|
|||||||
const rawSessionKey = params.sessionKey?.trim();
|
const rawSessionKey = params.sessionKey?.trim();
|
||||||
if (!rawSessionKey) return null;
|
if (!rawSessionKey) return null;
|
||||||
const agentId = resolveAgentIdFromSessionKey(rawSessionKey);
|
const agentId = resolveAgentIdFromSessionKey(rawSessionKey);
|
||||||
const cfg = defaultSandboxConfig(params.config, agentId);
|
const cfg = resolveSandboxConfigForAgent(params.config, agentId);
|
||||||
const mainKey = params.config?.session?.mainKey?.trim() || "main";
|
const mainKey = params.config?.session?.mainKey?.trim() || "main";
|
||||||
if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null;
|
if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ afterEach(() => {
|
|||||||
const readConfigFileSnapshot = vi.fn();
|
const readConfigFileSnapshot = vi.fn();
|
||||||
const confirm = vi.fn().mockResolvedValue(true);
|
const confirm = vi.fn().mockResolvedValue(true);
|
||||||
const select = vi.fn().mockResolvedValue("node");
|
const select = vi.fn().mockResolvedValue("node");
|
||||||
|
const note = vi.fn();
|
||||||
const writeConfigFile = vi.fn().mockResolvedValue(undefined);
|
const writeConfigFile = vi.fn().mockResolvedValue(undefined);
|
||||||
const migrateLegacyConfig = vi.fn((raw: unknown) => ({
|
const migrateLegacyConfig = vi.fn((raw: unknown) => ({
|
||||||
config: raw as Record<string, unknown>,
|
config: raw as Record<string, unknown>,
|
||||||
@@ -74,7 +75,7 @@ const serviceUninstall = vi.fn().mockResolvedValue(undefined);
|
|||||||
vi.mock("@clack/prompts", () => ({
|
vi.mock("@clack/prompts", () => ({
|
||||||
confirm,
|
confirm,
|
||||||
intro: vi.fn(),
|
intro: vi.fn(),
|
||||||
note: vi.fn(),
|
note,
|
||||||
outro: vi.fn(),
|
outro: vi.fn(),
|
||||||
select,
|
select,
|
||||||
}));
|
}));
|
||||||
@@ -413,6 +414,61 @@ describe("doctor", () => {
|
|||||||
expect(docker.image).toBe("clawdbot-sandbox");
|
expect(docker.image).toBe("clawdbot-sandbox");
|
||||||
expect(docker.containerPrefix).toBe("clawdbot-sbx");
|
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 () => {
|
it("falls back to legacy sandbox image when missing", async () => {
|
||||||
readConfigFileSnapshot.mockResolvedValue({
|
readConfigFileSnapshot.mockResolvedValue({
|
||||||
path: "/tmp/clawdbot.json",
|
path: "/tmp/clawdbot.json",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DEFAULT_SANDBOX_BROWSER_IMAGE,
|
DEFAULT_SANDBOX_BROWSER_IMAGE,
|
||||||
DEFAULT_SANDBOX_COMMON_IMAGE,
|
DEFAULT_SANDBOX_COMMON_IMAGE,
|
||||||
DEFAULT_SANDBOX_IMAGE,
|
DEFAULT_SANDBOX_IMAGE,
|
||||||
|
resolveSandboxScope,
|
||||||
} from "../agents/sandbox.js";
|
} from "../agents/sandbox.js";
|
||||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||||
import { DEFAULT_AGENTS_FILENAME } from "../agents/workspace.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";
|
return cfg.gateway?.mode === "remote" ? "remote" : "local";
|
||||||
}
|
}
|
||||||
|
|
||||||
type SandboxScope = "session" | "agent" | "shared";
|
function hasObjectOverrides(value?: unknown) {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
function resolveSandboxScope(params: {
|
return Object.values(value).some((entry) => entry !== undefined);
|
||||||
scope?: SandboxScope;
|
|
||||||
perSession?: boolean;
|
|
||||||
}): SandboxScope {
|
|
||||||
if (params.scope) return params.scope;
|
|
||||||
if (typeof params.perSession === "boolean") {
|
|
||||||
return params.perSession ? "session" : "shared";
|
|
||||||
}
|
|
||||||
return "agent";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasDockerOverrides(docker?: unknown) {
|
function collectSandboxSharedOverrideWarnings(cfg: ClawdbotConfig) {
|
||||||
if (!docker || typeof docker !== "object") return false;
|
|
||||||
return Object.values(docker).some((value) => value !== undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectSandboxSharedDockerOverrideWarnings(cfg: ClawdbotConfig) {
|
|
||||||
const globalSandbox = cfg.agent?.sandbox;
|
const globalSandbox = cfg.agent?.sandbox;
|
||||||
const agents = cfg.routing?.agents;
|
const agents = cfg.routing?.agents;
|
||||||
if (!agents) return [];
|
if (!agents) return [];
|
||||||
@@ -91,16 +79,21 @@ function collectSandboxSharedDockerOverrideWarnings(cfg: ClawdbotConfig) {
|
|||||||
if (!agentCfg || typeof agentCfg !== "object") continue;
|
if (!agentCfg || typeof agentCfg !== "object") continue;
|
||||||
const agentSandbox = agentCfg.sandbox;
|
const agentSandbox = agentCfg.sandbox;
|
||||||
if (!agentSandbox || typeof agentSandbox !== "object") continue;
|
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({
|
const scope = resolveSandboxScope({
|
||||||
scope: (agentSandbox.scope ?? globalSandbox?.scope) as SandboxScope,
|
scope: agentSandbox.scope ?? globalSandbox?.scope,
|
||||||
perSession: agentSandbox.perSession ?? globalSandbox?.perSession,
|
perSession: agentSandbox.perSession ?? globalSandbox?.perSession,
|
||||||
});
|
});
|
||||||
if (scope !== "shared") continue;
|
if (scope !== "shared") continue;
|
||||||
|
|
||||||
warnings.push(
|
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);
|
await noteSecurityWarnings(cfg);
|
||||||
|
|
||||||
const sharedDockerOverrideWarnings =
|
const sharedOverrideWarnings = collectSandboxSharedOverrideWarnings(cfg);
|
||||||
collectSandboxSharedDockerOverrideWarnings(cfg);
|
if (sharedOverrideWarnings.length > 0) {
|
||||||
if (sharedDockerOverrideWarnings.length > 0) {
|
|
||||||
note(
|
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"),
|
].join("\n"),
|
||||||
"Sandbox",
|
"Sandbox",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -625,6 +625,24 @@ export type SandboxDockerSettings = {
|
|||||||
extraHosts?: string[];
|
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 = {
|
export type GroupChatConfig = {
|
||||||
mentionPatterns?: string[];
|
mentionPatterns?: string[];
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
@@ -663,11 +681,15 @@ export type RoutingConfig = {
|
|||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
/** Docker-specific sandbox overrides for this agent. */
|
/** Docker-specific sandbox overrides for this agent. */
|
||||||
docker?: SandboxDockerSettings;
|
docker?: SandboxDockerSettings;
|
||||||
|
/** Optional sandboxed browser overrides for this agent. */
|
||||||
|
browser?: SandboxBrowserSettings;
|
||||||
/** Tool allow/deny policy for sandboxed sessions (deny wins). */
|
/** Tool allow/deny policy for sandboxed sessions (deny wins). */
|
||||||
tools?: {
|
tools?: {
|
||||||
allow?: string[];
|
allow?: string[];
|
||||||
deny?: string[];
|
deny?: string[];
|
||||||
};
|
};
|
||||||
|
/** Auto-prune overrides for this agent. */
|
||||||
|
prune?: SandboxPruneSettings;
|
||||||
};
|
};
|
||||||
tools?: {
|
tools?: {
|
||||||
allow?: string[];
|
allow?: string[];
|
||||||
@@ -1093,28 +1115,14 @@ export type ClawdbotConfig = {
|
|||||||
/** Docker-specific sandbox settings. */
|
/** Docker-specific sandbox settings. */
|
||||||
docker?: SandboxDockerSettings;
|
docker?: SandboxDockerSettings;
|
||||||
/** Optional sandboxed browser settings. */
|
/** Optional sandboxed browser settings. */
|
||||||
browser?: {
|
browser?: SandboxBrowserSettings;
|
||||||
enabled?: boolean;
|
|
||||||
image?: string;
|
|
||||||
containerPrefix?: string;
|
|
||||||
cdpPort?: number;
|
|
||||||
vncPort?: number;
|
|
||||||
noVncPort?: number;
|
|
||||||
headless?: boolean;
|
|
||||||
enableNoVnc?: boolean;
|
|
||||||
};
|
|
||||||
/** Tool allow/deny policy (deny wins). */
|
/** Tool allow/deny policy (deny wins). */
|
||||||
tools?: {
|
tools?: {
|
||||||
allow?: string[];
|
allow?: string[];
|
||||||
deny?: string[];
|
deny?: string[];
|
||||||
};
|
};
|
||||||
/** Auto-prune sandbox containers. */
|
/** Auto-prune sandbox containers. */
|
||||||
prune?: {
|
prune?: SandboxPruneSettings;
|
||||||
/** Prune if idle for more than N hours (0 disables). */
|
|
||||||
idleHours?: number;
|
|
||||||
/** Prune if older than N days (0 disables). */
|
|
||||||
maxAgeDays?: number;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
/** Global tool allow/deny policy for all providers (deny wins). */
|
/** Global tool allow/deny policy for all providers (deny wins). */
|
||||||
tools?: {
|
tools?: {
|
||||||
|
|||||||
@@ -260,6 +260,33 @@ const SandboxDockerSchema = z
|
|||||||
})
|
})
|
||||||
.optional();
|
.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
|
const RoutingSchema = z
|
||||||
.object({
|
.object({
|
||||||
groupChat: GroupChatSchema,
|
groupChat: GroupChatSchema,
|
||||||
@@ -302,20 +329,12 @@ const RoutingSchema = z
|
|||||||
perSession: z.boolean().optional(),
|
perSession: z.boolean().optional(),
|
||||||
workspaceRoot: z.string().optional(),
|
workspaceRoot: z.string().optional(),
|
||||||
docker: SandboxDockerSchema,
|
docker: SandboxDockerSchema,
|
||||||
tools: z
|
browser: SandboxBrowserSchema,
|
||||||
.object({
|
tools: ToolPolicySchema,
|
||||||
allow: z.array(z.string()).optional(),
|
prune: SandboxPruneSchema,
|
||||||
deny: z.array(z.string()).optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
tools: z
|
|
||||||
.object({
|
|
||||||
allow: z.array(z.string()).optional(),
|
|
||||||
deny: z.array(z.string()).optional(),
|
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
tools: ToolPolicySchema,
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
)
|
)
|
||||||
@@ -706,30 +725,9 @@ export const ClawdbotSchema = z.object({
|
|||||||
perSession: z.boolean().optional(),
|
perSession: z.boolean().optional(),
|
||||||
workspaceRoot: z.string().optional(),
|
workspaceRoot: z.string().optional(),
|
||||||
docker: SandboxDockerSchema,
|
docker: SandboxDockerSchema,
|
||||||
browser: z
|
browser: SandboxBrowserSchema,
|
||||||
.object({
|
tools: ToolPolicySchema,
|
||||||
enabled: z.boolean().optional(),
|
prune: SandboxPruneSchema,
|
||||||
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(),
|
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user