feat: add sandbox scope default

This commit is contained in:
Peter Steinberger
2026-01-07 02:31:51 +01:00
parent 4d4e4de915
commit 467d4e17fe
8 changed files with 102 additions and 49 deletions

View File

@@ -32,7 +32,7 @@ describe("buildSandboxCreateArgs", () => {
const args = buildSandboxCreateArgs({
name: "clawdbot-sbx-test",
cfg,
sessionKey: "main",
scopeKey: "main",
createdAtMs: 1700000000000,
labels: { "clawdbot.sandboxBrowser": "1" },
});

View File

@@ -18,6 +18,7 @@ import type { ClawdbotConfig } from "../config/config.js";
import { STATE_DIR_CLAWDBOT } from "../config/config.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { resolveAgentIdFromSessionKey } from "./agent-scope.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
DEFAULT_AGENTS_FILENAME,
@@ -72,9 +73,11 @@ export type SandboxPruneConfig = {
maxAgeDays: number;
};
export type SandboxScope = "session" | "agent" | "shared";
export type SandboxConfig = {
mode: "off" | "non-main" | "all";
perSession: boolean;
scope: SandboxScope;
workspaceRoot: string;
docker: SandboxDockerConfig;
browser: SandboxBrowserConfig;
@@ -197,11 +200,33 @@ function isToolAllowed(policy: SandboxToolPolicy, name: string) {
return allow.includes(name.toLowerCase());
}
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 resolveSandboxScopeKey(scope: SandboxScope, sessionKey: string) {
const trimmed = sessionKey.trim() || "main";
if (scope === "shared") return "shared";
if (scope === "session") return trimmed;
const agentId = resolveAgentIdFromSessionKey(trimmed);
return `agent:${agentId}`;
}
function defaultSandboxConfig(cfg?: ClawdbotConfig): SandboxConfig {
const agent = cfg?.agent?.sandbox;
return {
mode: agent?.mode ?? "off",
perSession: agent?.perSession ?? true,
scope: resolveSandboxScope({
scope: agent?.scope,
perSession: agent?.perSession,
}),
workspaceRoot: agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT,
docker: {
image: agent?.docker?.image ?? DEFAULT_SANDBOX_IMAGE,
@@ -502,14 +527,14 @@ function formatUlimitValue(
export function buildSandboxCreateArgs(params: {
name: string;
cfg: SandboxDockerConfig;
sessionKey: string;
scopeKey: string;
createdAtMs?: number;
labels?: Record<string, string>;
}) {
const createdAtMs = params.createdAtMs ?? Date.now();
const args = ["create", "--name", params.name];
args.push("--label", "clawdbot.sandbox=1");
args.push("--label", `clawdbot.sessionKey=${params.sessionKey}`);
args.push("--label", `clawdbot.sessionKey=${params.scopeKey}`);
args.push("--label", `clawdbot.createdAtMs=${createdAtMs}`);
for (const [key, value] of Object.entries(params.labels ?? {})) {
if (key && value) args.push("--label", `${key}=${value}`);
@@ -557,15 +582,15 @@ async function createSandboxContainer(params: {
name: string;
cfg: SandboxDockerConfig;
workspaceDir: string;
sessionKey: string;
scopeKey: string;
}) {
const { name, cfg, workspaceDir, sessionKey } = params;
const { name, cfg, workspaceDir, scopeKey } = params;
await ensureDockerImage(cfg.image);
const args = buildSandboxCreateArgs({
name,
cfg,
sessionKey,
scopeKey,
});
args.push("--workdir", cfg.workdir);
args.push("-v", `${workspaceDir}:${cfg.workdir}`);
@@ -584,9 +609,9 @@ async function ensureSandboxContainer(params: {
workspaceDir: string;
cfg: SandboxConfig;
}) {
const slug = params.cfg.perSession
? slugifySessionKey(params.sessionKey)
: "shared";
const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey);
const slug =
params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey);
const name = `${params.cfg.docker.containerPrefix}${slug}`;
const containerName = name.slice(0, 63);
const state = await dockerContainerState(containerName);
@@ -595,7 +620,7 @@ async function ensureSandboxContainer(params: {
name: containerName,
cfg: params.cfg.docker,
workspaceDir: params.workspaceDir,
sessionKey: params.sessionKey,
scopeKey,
});
} else if (!state.running) {
await execDocker(["start", containerName]);
@@ -603,7 +628,7 @@ async function ensureSandboxContainer(params: {
const now = Date.now();
await updateRegistry({
containerName,
sessionKey: params.sessionKey,
sessionKey: scopeKey,
createdAtMs: now,
lastUsedAtMs: now,
image: params.cfg.docker.image,
@@ -648,16 +673,15 @@ function buildSandboxBrowserResolvedConfig(params: {
}
async function ensureSandboxBrowser(params: {
sessionKey: string;
scopeKey: string;
workspaceDir: string;
cfg: SandboxConfig;
}): Promise<SandboxBrowserContext | null> {
if (!params.cfg.browser.enabled) return null;
if (!isToolAllowed(params.cfg.tools, "browser")) return null;
const slug = params.cfg.perSession
? slugifySessionKey(params.sessionKey)
: "shared";
const slug =
params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey);
const name = `${params.cfg.browser.containerPrefix}${slug}`;
const containerName = name.slice(0, 63);
const state = await dockerContainerState(containerName);
@@ -666,7 +690,7 @@ async function ensureSandboxBrowser(params: {
const args = buildSandboxCreateArgs({
name: containerName,
cfg: params.cfg.docker,
sessionKey: params.sessionKey,
scopeKey: params.scopeKey,
labels: { "clawdbot.sandboxBrowser": "1" },
});
args.push("-v", `${params.workspaceDir}:${params.cfg.docker.workdir}`);
@@ -710,7 +734,7 @@ async function ensureSandboxBrowser(params: {
? await readDockerPort(containerName, params.cfg.browser.noVncPort)
: null;
const existing = BROWSER_BRIDGES.get(params.sessionKey);
const existing = BROWSER_BRIDGES.get(params.scopeKey);
const existingProfile = existing
? resolveProfile(existing.bridge.state.resolved, "clawd")
: null;
@@ -722,7 +746,7 @@ async function ensureSandboxBrowser(params: {
await stopBrowserBridgeServer(existing.bridge.server).catch(
() => undefined,
);
BROWSER_BRIDGES.delete(params.sessionKey);
BROWSER_BRIDGES.delete(params.scopeKey);
}
let bridge: BrowserBridge;
if (shouldReuse && existing) {
@@ -737,13 +761,13 @@ async function ensureSandboxBrowser(params: {
});
}
if (!shouldReuse) {
BROWSER_BRIDGES.set(params.sessionKey, { bridge, containerName });
BROWSER_BRIDGES.set(params.scopeKey, { bridge, containerName });
}
const now = Date.now();
await updateBrowserRegistry({
containerName,
sessionKey: params.sessionKey,
sessionKey: params.scopeKey,
createdAtMs: now,
lastUsedAtMs: now,
image: params.cfg.browser.image,
@@ -858,9 +882,11 @@ export async function resolveSandboxContext(params: {
await maybePruneSandboxes(cfg);
const workspaceRoot = resolveUserPath(cfg.workspaceRoot);
const workspaceDir = cfg.perSession
? resolveSandboxWorkspaceDir(workspaceRoot, rawSessionKey)
: workspaceRoot;
const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey);
const workspaceDir =
cfg.scope === "shared"
? workspaceRoot
: resolveSandboxWorkspaceDir(workspaceRoot, scopeKey);
const seedWorkspace =
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR;
await ensureSandboxWorkspace(
@@ -876,7 +902,7 @@ export async function resolveSandboxContext(params: {
});
const browser = await ensureSandboxBrowser({
sessionKey: rawSessionKey,
scopeKey,
workspaceDir,
cfg,
});
@@ -905,9 +931,11 @@ export async function ensureSandboxWorkspaceForSession(params: {
if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null;
const workspaceRoot = resolveUserPath(cfg.workspaceRoot);
const workspaceDir = cfg.perSession
? resolveSandboxWorkspaceDir(workspaceRoot, rawSessionKey)
: workspaceRoot;
const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey);
const workspaceDir =
cfg.scope === "shared"
? workspaceRoot
: resolveSandboxWorkspaceDir(workspaceRoot, scopeKey);
const seedWorkspace =
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR;
await ensureSandboxWorkspace(

View File

@@ -532,6 +532,9 @@ export type RoutingConfig = {
model?: string;
sandbox?: {
mode?: "off" | "non-main" | "all";
/** Container/workspace scope for sandbox isolation. */
scope?: "session" | "agent" | "shared";
/** Legacy alias for scope ("session" when true, "shared" when false). */
perSession?: boolean;
workspaceRoot?: string;
};
@@ -912,7 +915,9 @@ export type ClawdbotConfig = {
* - "all": allow session tools to target any session
*/
sessionToolsVisibility?: "spawned" | "all";
/** Use one container per session (recommended for hard isolation). */
/** Container/workspace scope for sandbox isolation. */
scope?: "session" | "agent" | "shared";
/** Legacy alias for scope ("session" when true, "shared" when false). */
perSession?: boolean;
/** Root directory for sandbox workspaces. */
workspaceRoot?: string;

View File

@@ -235,6 +235,13 @@ const RoutingSchema = z
z.literal("all"),
])
.optional(),
scope: z
.union([
z.literal("session"),
z.literal("agent"),
z.literal("shared"),
])
.optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
})
@@ -573,6 +580,13 @@ export const ClawdbotSchema = z.object({
sessionToolsVisibility: z
.union([z.literal("spawned"), z.literal("all")])
.optional(),
scope: z
.union([
z.literal("session"),
z.literal("agent"),
z.literal("shared"),
])
.optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
docker: z