954 lines
28 KiB
TypeScript
954 lines
28 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
type BrowserBridge,
|
|
startBrowserBridgeServer,
|
|
stopBrowserBridgeServer,
|
|
} from "../browser/bridge-server.js";
|
|
import {
|
|
type ResolvedBrowserConfig,
|
|
resolveProfile,
|
|
} from "../browser/config.js";
|
|
import { DEFAULT_CLAWD_BROWSER_COLOR } from "../browser/constants.js";
|
|
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,
|
|
DEFAULT_BOOTSTRAP_FILENAME,
|
|
DEFAULT_IDENTITY_FILENAME,
|
|
DEFAULT_SOUL_FILENAME,
|
|
DEFAULT_TOOLS_FILENAME,
|
|
DEFAULT_USER_FILENAME,
|
|
ensureAgentWorkspace,
|
|
} from "./workspace.js";
|
|
|
|
export type SandboxToolPolicy = {
|
|
allow?: string[];
|
|
deny?: string[];
|
|
};
|
|
|
|
export type SandboxBrowserConfig = {
|
|
enabled: boolean;
|
|
image: string;
|
|
containerPrefix: string;
|
|
cdpPort: number;
|
|
vncPort: number;
|
|
noVncPort: number;
|
|
headless: boolean;
|
|
enableNoVnc: boolean;
|
|
};
|
|
|
|
export type SandboxDockerConfig = {
|
|
image: string;
|
|
containerPrefix: string;
|
|
workdir: string;
|
|
readOnlyRoot: boolean;
|
|
tmpfs: string[];
|
|
network: string;
|
|
user?: string;
|
|
capDrop: string[];
|
|
env?: Record<string, string>;
|
|
setupCommand?: string;
|
|
pidsLimit?: number;
|
|
memory?: string | number;
|
|
memorySwap?: string | number;
|
|
cpus?: number;
|
|
ulimits?: Record<string, string | number | { soft?: number; hard?: number }>;
|
|
seccompProfile?: string;
|
|
apparmorProfile?: string;
|
|
dns?: string[];
|
|
extraHosts?: string[];
|
|
};
|
|
|
|
export type SandboxPruneConfig = {
|
|
idleHours: number;
|
|
maxAgeDays: number;
|
|
};
|
|
|
|
export type SandboxScope = "session" | "agent" | "shared";
|
|
|
|
export type SandboxConfig = {
|
|
mode: "off" | "non-main" | "all";
|
|
scope: SandboxScope;
|
|
workspaceRoot: string;
|
|
docker: SandboxDockerConfig;
|
|
browser: SandboxBrowserConfig;
|
|
tools: SandboxToolPolicy;
|
|
prune: SandboxPruneConfig;
|
|
};
|
|
|
|
export type SandboxBrowserContext = {
|
|
controlUrl: string;
|
|
noVncUrl?: string;
|
|
containerName: string;
|
|
};
|
|
|
|
export type SandboxContext = {
|
|
enabled: boolean;
|
|
sessionKey: string;
|
|
workspaceDir: string;
|
|
containerName: string;
|
|
containerWorkdir: string;
|
|
docker: SandboxDockerConfig;
|
|
tools: SandboxToolPolicy;
|
|
browser?: SandboxBrowserContext;
|
|
};
|
|
|
|
export type SandboxWorkspaceInfo = {
|
|
workspaceDir: string;
|
|
containerWorkdir: string;
|
|
};
|
|
|
|
const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join(
|
|
os.homedir(),
|
|
".clawdbot",
|
|
"sandboxes",
|
|
);
|
|
export const DEFAULT_SANDBOX_IMAGE = "clawdbot-sandbox:bookworm-slim";
|
|
const DEFAULT_SANDBOX_CONTAINER_PREFIX = "clawdbot-sbx-";
|
|
const DEFAULT_SANDBOX_WORKDIR = "/workspace";
|
|
const DEFAULT_SANDBOX_IDLE_HOURS = 24;
|
|
const DEFAULT_SANDBOX_MAX_AGE_DAYS = 7;
|
|
const DEFAULT_TOOL_ALLOW = [
|
|
"bash",
|
|
"process",
|
|
"read",
|
|
"write",
|
|
"edit",
|
|
"sessions_list",
|
|
"sessions_history",
|
|
"sessions_send",
|
|
"sessions_spawn",
|
|
];
|
|
const DEFAULT_TOOL_DENY = [
|
|
"browser",
|
|
"canvas",
|
|
"nodes",
|
|
"cron",
|
|
"discord",
|
|
"gateway",
|
|
];
|
|
export const DEFAULT_SANDBOX_BROWSER_IMAGE =
|
|
"clawdbot-sandbox-browser:bookworm-slim";
|
|
export const DEFAULT_SANDBOX_COMMON_IMAGE =
|
|
"clawdbot-sandbox-common:bookworm-slim";
|
|
const DEFAULT_SANDBOX_BROWSER_PREFIX = "clawdbot-sbx-browser-";
|
|
const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222;
|
|
const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900;
|
|
const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080;
|
|
|
|
const SANDBOX_STATE_DIR = path.join(STATE_DIR_CLAWDBOT, "sandbox");
|
|
const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json");
|
|
const SANDBOX_BROWSER_REGISTRY_PATH = path.join(
|
|
SANDBOX_STATE_DIR,
|
|
"browsers.json",
|
|
);
|
|
|
|
type SandboxRegistryEntry = {
|
|
containerName: string;
|
|
sessionKey: string;
|
|
createdAtMs: number;
|
|
lastUsedAtMs: number;
|
|
image: string;
|
|
};
|
|
|
|
type SandboxRegistry = {
|
|
entries: SandboxRegistryEntry[];
|
|
};
|
|
|
|
type SandboxBrowserRegistryEntry = {
|
|
containerName: string;
|
|
sessionKey: string;
|
|
createdAtMs: number;
|
|
lastUsedAtMs: number;
|
|
image: string;
|
|
cdpPort: number;
|
|
noVncPort?: number;
|
|
};
|
|
|
|
type SandboxBrowserRegistry = {
|
|
entries: SandboxBrowserRegistryEntry[];
|
|
};
|
|
|
|
let lastPruneAtMs = 0;
|
|
const BROWSER_BRIDGES = new Map<
|
|
string,
|
|
{ bridge: BrowserBridge; containerName: string }
|
|
>();
|
|
|
|
function normalizeToolList(values?: string[]) {
|
|
if (!values) return [];
|
|
return values
|
|
.map((value) => value.trim())
|
|
.filter(Boolean)
|
|
.map((value) => value.toLowerCase());
|
|
}
|
|
|
|
function isToolAllowed(policy: SandboxToolPolicy, name: string) {
|
|
const deny = new Set(normalizeToolList(policy.deny));
|
|
if (deny.has(name.toLowerCase())) return false;
|
|
const allow = normalizeToolList(policy.allow);
|
|
if (allow.length === 0) return true;
|
|
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",
|
|
scope: resolveSandboxScope({
|
|
scope: agent?.scope,
|
|
perSession: agent?.perSession,
|
|
}),
|
|
workspaceRoot: agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT,
|
|
docker: {
|
|
image: agent?.docker?.image ?? DEFAULT_SANDBOX_IMAGE,
|
|
containerPrefix:
|
|
agent?.docker?.containerPrefix ?? DEFAULT_SANDBOX_CONTAINER_PREFIX,
|
|
workdir: agent?.docker?.workdir ?? DEFAULT_SANDBOX_WORKDIR,
|
|
readOnlyRoot: agent?.docker?.readOnlyRoot ?? true,
|
|
tmpfs: agent?.docker?.tmpfs ?? ["/tmp", "/var/tmp", "/run"],
|
|
network: agent?.docker?.network ?? "none",
|
|
user: agent?.docker?.user,
|
|
capDrop: agent?.docker?.capDrop ?? ["ALL"],
|
|
env: agent?.docker?.env ?? { LANG: "C.UTF-8" },
|
|
setupCommand: agent?.docker?.setupCommand,
|
|
pidsLimit: agent?.docker?.pidsLimit,
|
|
memory: agent?.docker?.memory,
|
|
memorySwap: agent?.docker?.memorySwap,
|
|
cpus: agent?.docker?.cpus,
|
|
ulimits: agent?.docker?.ulimits,
|
|
seccompProfile: agent?.docker?.seccompProfile,
|
|
apparmorProfile: agent?.docker?.apparmorProfile,
|
|
dns: agent?.docker?.dns,
|
|
extraHosts: agent?.docker?.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: {
|
|
allow: agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW,
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
|
|
function shouldSandboxSession(
|
|
cfg: SandboxConfig,
|
|
sessionKey: string,
|
|
mainKey: string,
|
|
) {
|
|
if (cfg.mode === "off") return false;
|
|
if (cfg.mode === "all") return true;
|
|
return sessionKey.trim() !== mainKey.trim();
|
|
}
|
|
|
|
function slugifySessionKey(value: string) {
|
|
const trimmed = value.trim() || "session";
|
|
const hash = crypto
|
|
.createHash("sha1")
|
|
.update(trimmed)
|
|
.digest("hex")
|
|
.slice(0, 8);
|
|
const safe = trimmed
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
const base = safe.slice(0, 32) || "session";
|
|
return `${base}-${hash}`;
|
|
}
|
|
|
|
function resolveSandboxWorkspaceDir(root: string, sessionKey: string) {
|
|
const resolvedRoot = resolveUserPath(root);
|
|
const slug = slugifySessionKey(sessionKey);
|
|
return path.join(resolvedRoot, slug);
|
|
}
|
|
|
|
async function readRegistry(): Promise<SandboxRegistry> {
|
|
try {
|
|
const raw = await fs.readFile(SANDBOX_REGISTRY_PATH, "utf-8");
|
|
const parsed = JSON.parse(raw) as SandboxRegistry;
|
|
if (parsed && Array.isArray(parsed.entries)) return parsed;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return { entries: [] };
|
|
}
|
|
|
|
async function writeRegistry(registry: SandboxRegistry) {
|
|
await fs.mkdir(SANDBOX_STATE_DIR, { recursive: true });
|
|
await fs.writeFile(
|
|
SANDBOX_REGISTRY_PATH,
|
|
`${JSON.stringify(registry, null, 2)}\n`,
|
|
"utf-8",
|
|
);
|
|
}
|
|
|
|
async function updateRegistry(entry: SandboxRegistryEntry) {
|
|
const registry = await readRegistry();
|
|
const existing = registry.entries.find(
|
|
(item) => item.containerName === entry.containerName,
|
|
);
|
|
const next = registry.entries.filter(
|
|
(item) => item.containerName !== entry.containerName,
|
|
);
|
|
next.push({
|
|
...entry,
|
|
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
|
|
image: existing?.image ?? entry.image,
|
|
});
|
|
await writeRegistry({ entries: next });
|
|
}
|
|
|
|
async function removeRegistryEntry(containerName: string) {
|
|
const registry = await readRegistry();
|
|
const next = registry.entries.filter(
|
|
(item) => item.containerName !== containerName,
|
|
);
|
|
if (next.length === registry.entries.length) return;
|
|
await writeRegistry({ entries: next });
|
|
}
|
|
|
|
async function readBrowserRegistry(): Promise<SandboxBrowserRegistry> {
|
|
try {
|
|
const raw = await fs.readFile(SANDBOX_BROWSER_REGISTRY_PATH, "utf-8");
|
|
const parsed = JSON.parse(raw) as SandboxBrowserRegistry;
|
|
if (parsed && Array.isArray(parsed.entries)) return parsed;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return { entries: [] };
|
|
}
|
|
|
|
async function writeBrowserRegistry(registry: SandboxBrowserRegistry) {
|
|
await fs.mkdir(SANDBOX_STATE_DIR, { recursive: true });
|
|
await fs.writeFile(
|
|
SANDBOX_BROWSER_REGISTRY_PATH,
|
|
`${JSON.stringify(registry, null, 2)}\n`,
|
|
"utf-8",
|
|
);
|
|
}
|
|
|
|
async function updateBrowserRegistry(entry: SandboxBrowserRegistryEntry) {
|
|
const registry = await readBrowserRegistry();
|
|
const existing = registry.entries.find(
|
|
(item) => item.containerName === entry.containerName,
|
|
);
|
|
const next = registry.entries.filter(
|
|
(item) => item.containerName !== entry.containerName,
|
|
);
|
|
next.push({
|
|
...entry,
|
|
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
|
|
image: existing?.image ?? entry.image,
|
|
});
|
|
await writeBrowserRegistry({ entries: next });
|
|
}
|
|
|
|
async function removeBrowserRegistryEntry(containerName: string) {
|
|
const registry = await readBrowserRegistry();
|
|
const next = registry.entries.filter(
|
|
(item) => item.containerName !== containerName,
|
|
);
|
|
if (next.length === registry.entries.length) return;
|
|
await writeBrowserRegistry({ entries: next });
|
|
}
|
|
|
|
function execDocker(args: string[], opts?: { allowFailure?: boolean }) {
|
|
return new Promise<{ stdout: string; stderr: string; code: number }>(
|
|
(resolve, reject) => {
|
|
const child = spawn("docker", args, {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout?.on("data", (chunk) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
child.stderr?.on("data", (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
child.on("close", (code) => {
|
|
const exitCode = code ?? 0;
|
|
if (exitCode !== 0 && !opts?.allowFailure) {
|
|
reject(new Error(stderr.trim() || `docker ${args.join(" ")} failed`));
|
|
return;
|
|
}
|
|
resolve({ stdout, stderr, code: exitCode });
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
async function readDockerPort(containerName: string, port: number) {
|
|
const result = await execDocker(["port", containerName, `${port}/tcp`], {
|
|
allowFailure: true,
|
|
});
|
|
if (result.code !== 0) return null;
|
|
const line = result.stdout.trim().split(/\r?\n/)[0] ?? "";
|
|
const match = line.match(/:(\d+)\s*$/);
|
|
if (!match) return null;
|
|
const mapped = Number.parseInt(match[1] ?? "", 10);
|
|
return Number.isFinite(mapped) ? mapped : null;
|
|
}
|
|
|
|
async function dockerImageExists(image: string) {
|
|
const result = await execDocker(["image", "inspect", image], {
|
|
allowFailure: true,
|
|
});
|
|
return result.code === 0;
|
|
}
|
|
|
|
async function ensureDockerImage(image: string) {
|
|
const exists = await dockerImageExists(image);
|
|
if (exists) return;
|
|
if (image === DEFAULT_SANDBOX_IMAGE) {
|
|
await execDocker(["pull", "debian:bookworm-slim"]);
|
|
await execDocker(["tag", "debian:bookworm-slim", DEFAULT_SANDBOX_IMAGE]);
|
|
return;
|
|
}
|
|
throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`);
|
|
}
|
|
|
|
async function dockerContainerState(name: string) {
|
|
const result = await execDocker(
|
|
["inspect", "-f", "{{.State.Running}}", name],
|
|
{ allowFailure: true },
|
|
);
|
|
if (result.code !== 0) return { exists: false, running: false };
|
|
return { exists: true, running: result.stdout.trim() === "true" };
|
|
}
|
|
|
|
async function ensureSandboxWorkspace(
|
|
workspaceDir: string,
|
|
seedFrom?: string,
|
|
skipBootstrap?: boolean,
|
|
) {
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
if (seedFrom) {
|
|
const seed = resolveUserPath(seedFrom);
|
|
const files = [
|
|
DEFAULT_AGENTS_FILENAME,
|
|
DEFAULT_SOUL_FILENAME,
|
|
DEFAULT_TOOLS_FILENAME,
|
|
DEFAULT_IDENTITY_FILENAME,
|
|
DEFAULT_USER_FILENAME,
|
|
DEFAULT_BOOTSTRAP_FILENAME,
|
|
];
|
|
for (const name of files) {
|
|
const src = path.join(seed, name);
|
|
const dest = path.join(workspaceDir, name);
|
|
try {
|
|
await fs.access(dest);
|
|
} catch {
|
|
try {
|
|
const content = await fs.readFile(src, "utf-8");
|
|
await fs.writeFile(dest, content, { encoding: "utf-8", flag: "wx" });
|
|
} catch {
|
|
// ignore missing seed file
|
|
}
|
|
}
|
|
}
|
|
}
|
|
await ensureAgentWorkspace({
|
|
dir: workspaceDir,
|
|
ensureBootstrapFiles: !skipBootstrap,
|
|
});
|
|
}
|
|
|
|
function normalizeDockerLimit(value?: string | number) {
|
|
if (value === undefined || value === null) return undefined;
|
|
if (typeof value === "number") {
|
|
return Number.isFinite(value) ? String(value) : undefined;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
function formatUlimitValue(
|
|
name: string,
|
|
value: string | number | { soft?: number; hard?: number },
|
|
) {
|
|
if (!name.trim()) return null;
|
|
if (typeof value === "number" || typeof value === "string") {
|
|
const raw = String(value).trim();
|
|
return raw ? `${name}=${raw}` : null;
|
|
}
|
|
const soft =
|
|
typeof value.soft === "number" ? Math.max(0, value.soft) : undefined;
|
|
const hard =
|
|
typeof value.hard === "number" ? Math.max(0, value.hard) : undefined;
|
|
if (soft === undefined && hard === undefined) return null;
|
|
if (soft === undefined) return `${name}=${hard}`;
|
|
if (hard === undefined) return `${name}=${soft}`;
|
|
return `${name}=${soft}:${hard}`;
|
|
}
|
|
|
|
export function buildSandboxCreateArgs(params: {
|
|
name: string;
|
|
cfg: SandboxDockerConfig;
|
|
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.scopeKey}`);
|
|
args.push("--label", `clawdbot.createdAtMs=${createdAtMs}`);
|
|
for (const [key, value] of Object.entries(params.labels ?? {})) {
|
|
if (key && value) args.push("--label", `${key}=${value}`);
|
|
}
|
|
if (params.cfg.readOnlyRoot) args.push("--read-only");
|
|
for (const entry of params.cfg.tmpfs) {
|
|
args.push("--tmpfs", entry);
|
|
}
|
|
if (params.cfg.network) args.push("--network", params.cfg.network);
|
|
if (params.cfg.user) args.push("--user", params.cfg.user);
|
|
for (const cap of params.cfg.capDrop) {
|
|
args.push("--cap-drop", cap);
|
|
}
|
|
args.push("--security-opt", "no-new-privileges");
|
|
if (params.cfg.seccompProfile) {
|
|
args.push("--security-opt", `seccomp=${params.cfg.seccompProfile}`);
|
|
}
|
|
if (params.cfg.apparmorProfile) {
|
|
args.push("--security-opt", `apparmor=${params.cfg.apparmorProfile}`);
|
|
}
|
|
for (const entry of params.cfg.dns ?? []) {
|
|
if (entry.trim()) args.push("--dns", entry);
|
|
}
|
|
for (const entry of params.cfg.extraHosts ?? []) {
|
|
if (entry.trim()) args.push("--add-host", entry);
|
|
}
|
|
if (typeof params.cfg.pidsLimit === "number" && params.cfg.pidsLimit > 0) {
|
|
args.push("--pids-limit", String(params.cfg.pidsLimit));
|
|
}
|
|
const memory = normalizeDockerLimit(params.cfg.memory);
|
|
if (memory) args.push("--memory", memory);
|
|
const memorySwap = normalizeDockerLimit(params.cfg.memorySwap);
|
|
if (memorySwap) args.push("--memory-swap", memorySwap);
|
|
if (typeof params.cfg.cpus === "number" && params.cfg.cpus > 0) {
|
|
args.push("--cpus", String(params.cfg.cpus));
|
|
}
|
|
for (const [name, value] of Object.entries(params.cfg.ulimits ?? {})) {
|
|
const formatted = formatUlimitValue(name, value);
|
|
if (formatted) args.push("--ulimit", formatted);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
async function createSandboxContainer(params: {
|
|
name: string;
|
|
cfg: SandboxDockerConfig;
|
|
workspaceDir: string;
|
|
scopeKey: string;
|
|
}) {
|
|
const { name, cfg, workspaceDir, scopeKey } = params;
|
|
await ensureDockerImage(cfg.image);
|
|
|
|
const args = buildSandboxCreateArgs({
|
|
name,
|
|
cfg,
|
|
scopeKey,
|
|
});
|
|
args.push("--workdir", cfg.workdir);
|
|
args.push("-v", `${workspaceDir}:${cfg.workdir}`);
|
|
args.push(cfg.image, "sleep", "infinity");
|
|
|
|
await execDocker(args);
|
|
await execDocker(["start", name]);
|
|
|
|
if (cfg.setupCommand?.trim()) {
|
|
await execDocker(["exec", "-i", name, "sh", "-lc", cfg.setupCommand]);
|
|
}
|
|
}
|
|
|
|
async function ensureSandboxContainer(params: {
|
|
sessionKey: string;
|
|
workspaceDir: string;
|
|
cfg: SandboxConfig;
|
|
}) {
|
|
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);
|
|
if (!state.exists) {
|
|
await createSandboxContainer({
|
|
name: containerName,
|
|
cfg: params.cfg.docker,
|
|
workspaceDir: params.workspaceDir,
|
|
scopeKey,
|
|
});
|
|
} else if (!state.running) {
|
|
await execDocker(["start", containerName]);
|
|
}
|
|
const now = Date.now();
|
|
await updateRegistry({
|
|
containerName,
|
|
sessionKey: scopeKey,
|
|
createdAtMs: now,
|
|
lastUsedAtMs: now,
|
|
image: params.cfg.docker.image,
|
|
});
|
|
return containerName;
|
|
}
|
|
|
|
async function ensureSandboxBrowserImage(image: string) {
|
|
const exists = await dockerImageExists(image);
|
|
if (exists) return;
|
|
throw new Error(
|
|
`Sandbox browser image not found: ${image}. Build it with scripts/sandbox-browser-setup.sh.`,
|
|
);
|
|
}
|
|
|
|
function buildSandboxBrowserResolvedConfig(params: {
|
|
controlPort: number;
|
|
cdpPort: number;
|
|
headless: boolean;
|
|
}): ResolvedBrowserConfig {
|
|
const controlHost = "127.0.0.1";
|
|
const controlUrl = `http://${controlHost}:${params.controlPort}`;
|
|
const cdpHost = "127.0.0.1";
|
|
return {
|
|
enabled: true,
|
|
controlUrl,
|
|
controlHost,
|
|
controlPort: params.controlPort,
|
|
cdpProtocol: "http",
|
|
cdpHost,
|
|
cdpIsLoopback: true,
|
|
color: DEFAULT_CLAWD_BROWSER_COLOR,
|
|
executablePath: undefined,
|
|
headless: params.headless,
|
|
noSandbox: false,
|
|
attachOnly: true,
|
|
defaultProfile: "clawd",
|
|
profiles: {
|
|
clawd: { cdpPort: params.cdpPort, color: DEFAULT_CLAWD_BROWSER_COLOR },
|
|
},
|
|
};
|
|
}
|
|
|
|
async function ensureSandboxBrowser(params: {
|
|
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.scope === "shared"
|
|
? "shared"
|
|
: slugifySessionKey(params.scopeKey);
|
|
const name = `${params.cfg.browser.containerPrefix}${slug}`;
|
|
const containerName = name.slice(0, 63);
|
|
const state = await dockerContainerState(containerName);
|
|
if (!state.exists) {
|
|
await ensureSandboxBrowserImage(params.cfg.browser.image);
|
|
const args = buildSandboxCreateArgs({
|
|
name: containerName,
|
|
cfg: params.cfg.docker,
|
|
scopeKey: params.scopeKey,
|
|
labels: { "clawdbot.sandboxBrowser": "1" },
|
|
});
|
|
args.push("-v", `${params.workspaceDir}:${params.cfg.docker.workdir}`);
|
|
args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`);
|
|
if (params.cfg.browser.enableNoVnc && !params.cfg.browser.headless) {
|
|
args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`);
|
|
}
|
|
args.push(
|
|
"-e",
|
|
`CLAWDBOT_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`,
|
|
);
|
|
args.push(
|
|
"-e",
|
|
`CLAWDBOT_BROWSER_ENABLE_NOVNC=${
|
|
params.cfg.browser.enableNoVnc ? "1" : "0"
|
|
}`,
|
|
);
|
|
args.push("-e", `CLAWDBOT_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`);
|
|
args.push("-e", `CLAWDBOT_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`);
|
|
args.push(
|
|
"-e",
|
|
`CLAWDBOT_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`,
|
|
);
|
|
args.push(params.cfg.browser.image);
|
|
await execDocker(args);
|
|
await execDocker(["start", containerName]);
|
|
} else if (!state.running) {
|
|
await execDocker(["start", containerName]);
|
|
}
|
|
|
|
const mappedCdp = await readDockerPort(
|
|
containerName,
|
|
params.cfg.browser.cdpPort,
|
|
);
|
|
if (!mappedCdp) {
|
|
throw new Error(`Failed to resolve CDP port mapping for ${containerName}.`);
|
|
}
|
|
|
|
const mappedNoVnc =
|
|
params.cfg.browser.enableNoVnc && !params.cfg.browser.headless
|
|
? await readDockerPort(containerName, params.cfg.browser.noVncPort)
|
|
: null;
|
|
|
|
const existing = BROWSER_BRIDGES.get(params.scopeKey);
|
|
const existingProfile = existing
|
|
? resolveProfile(existing.bridge.state.resolved, "clawd")
|
|
: null;
|
|
const shouldReuse =
|
|
existing &&
|
|
existing.containerName === containerName &&
|
|
existingProfile?.cdpPort === mappedCdp;
|
|
if (existing && !shouldReuse) {
|
|
await stopBrowserBridgeServer(existing.bridge.server).catch(
|
|
() => undefined,
|
|
);
|
|
BROWSER_BRIDGES.delete(params.scopeKey);
|
|
}
|
|
let bridge: BrowserBridge;
|
|
if (shouldReuse && existing) {
|
|
bridge = existing.bridge;
|
|
} else {
|
|
bridge = await startBrowserBridgeServer({
|
|
resolved: buildSandboxBrowserResolvedConfig({
|
|
controlPort: 0,
|
|
cdpPort: mappedCdp,
|
|
headless: params.cfg.browser.headless,
|
|
}),
|
|
});
|
|
}
|
|
if (!shouldReuse) {
|
|
BROWSER_BRIDGES.set(params.scopeKey, { bridge, containerName });
|
|
}
|
|
|
|
const now = Date.now();
|
|
await updateBrowserRegistry({
|
|
containerName,
|
|
sessionKey: params.scopeKey,
|
|
createdAtMs: now,
|
|
lastUsedAtMs: now,
|
|
image: params.cfg.browser.image,
|
|
cdpPort: mappedCdp,
|
|
noVncPort: mappedNoVnc ?? undefined,
|
|
});
|
|
|
|
const noVncUrl =
|
|
mappedNoVnc &&
|
|
params.cfg.browser.enableNoVnc &&
|
|
!params.cfg.browser.headless
|
|
? `http://127.0.0.1:${mappedNoVnc}/vnc.html?autoconnect=1&resize=remote`
|
|
: undefined;
|
|
|
|
return {
|
|
controlUrl: bridge.baseUrl,
|
|
noVncUrl,
|
|
containerName,
|
|
};
|
|
}
|
|
|
|
async function pruneSandboxContainers(cfg: SandboxConfig) {
|
|
const now = Date.now();
|
|
const idleHours = cfg.prune.idleHours;
|
|
const maxAgeDays = cfg.prune.maxAgeDays;
|
|
if (idleHours === 0 && maxAgeDays === 0) return;
|
|
const registry = await readRegistry();
|
|
for (const entry of registry.entries) {
|
|
const idleMs = now - entry.lastUsedAtMs;
|
|
const ageMs = now - entry.createdAtMs;
|
|
if (
|
|
(idleHours > 0 && idleMs > idleHours * 60 * 60 * 1000) ||
|
|
(maxAgeDays > 0 && ageMs > maxAgeDays * 24 * 60 * 60 * 1000)
|
|
) {
|
|
try {
|
|
await execDocker(["rm", "-f", entry.containerName], {
|
|
allowFailure: true,
|
|
});
|
|
} catch {
|
|
// ignore prune failures
|
|
} finally {
|
|
await removeRegistryEntry(entry.containerName);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function pruneSandboxBrowsers(cfg: SandboxConfig) {
|
|
const now = Date.now();
|
|
const idleHours = cfg.prune.idleHours;
|
|
const maxAgeDays = cfg.prune.maxAgeDays;
|
|
if (idleHours === 0 && maxAgeDays === 0) return;
|
|
const registry = await readBrowserRegistry();
|
|
for (const entry of registry.entries) {
|
|
const idleMs = now - entry.lastUsedAtMs;
|
|
const ageMs = now - entry.createdAtMs;
|
|
if (
|
|
(idleHours > 0 && idleMs > idleHours * 60 * 60 * 1000) ||
|
|
(maxAgeDays > 0 && ageMs > maxAgeDays * 24 * 60 * 60 * 1000)
|
|
) {
|
|
try {
|
|
await execDocker(["rm", "-f", entry.containerName], {
|
|
allowFailure: true,
|
|
});
|
|
} catch {
|
|
// ignore prune failures
|
|
} finally {
|
|
await removeBrowserRegistryEntry(entry.containerName);
|
|
const bridge = BROWSER_BRIDGES.get(entry.sessionKey);
|
|
if (bridge?.containerName === entry.containerName) {
|
|
await stopBrowserBridgeServer(bridge.bridge.server).catch(
|
|
() => undefined,
|
|
);
|
|
BROWSER_BRIDGES.delete(entry.sessionKey);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function maybePruneSandboxes(cfg: SandboxConfig) {
|
|
const now = Date.now();
|
|
if (now - lastPruneAtMs < 5 * 60 * 1000) return;
|
|
lastPruneAtMs = now;
|
|
try {
|
|
await pruneSandboxContainers(cfg);
|
|
await pruneSandboxBrowsers(cfg);
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: typeof error === "string"
|
|
? error
|
|
: JSON.stringify(error);
|
|
defaultRuntime.error?.(
|
|
`Sandbox prune failed: ${message ?? "unknown error"}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function resolveSandboxContext(params: {
|
|
config?: ClawdbotConfig;
|
|
sessionKey?: string;
|
|
workspaceDir?: string;
|
|
}): Promise<SandboxContext | null> {
|
|
const rawSessionKey = params.sessionKey?.trim();
|
|
if (!rawSessionKey) return null;
|
|
const cfg = defaultSandboxConfig(params.config);
|
|
const mainKey = params.config?.session?.mainKey?.trim() || "main";
|
|
if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null;
|
|
|
|
await maybePruneSandboxes(cfg);
|
|
|
|
const workspaceRoot = resolveUserPath(cfg.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(
|
|
workspaceDir,
|
|
seedWorkspace,
|
|
params.config?.agent?.skipBootstrap,
|
|
);
|
|
|
|
const containerName = await ensureSandboxContainer({
|
|
sessionKey: rawSessionKey,
|
|
workspaceDir,
|
|
cfg,
|
|
});
|
|
|
|
const browser = await ensureSandboxBrowser({
|
|
scopeKey,
|
|
workspaceDir,
|
|
cfg,
|
|
});
|
|
|
|
return {
|
|
enabled: true,
|
|
sessionKey: rawSessionKey,
|
|
workspaceDir,
|
|
containerName,
|
|
containerWorkdir: cfg.docker.workdir,
|
|
docker: cfg.docker,
|
|
tools: cfg.tools,
|
|
browser: browser ?? undefined,
|
|
};
|
|
}
|
|
|
|
export async function ensureSandboxWorkspaceForSession(params: {
|
|
config?: ClawdbotConfig;
|
|
sessionKey?: string;
|
|
workspaceDir?: string;
|
|
}): Promise<SandboxWorkspaceInfo | null> {
|
|
const rawSessionKey = params.sessionKey?.trim();
|
|
if (!rawSessionKey) return null;
|
|
const cfg = defaultSandboxConfig(params.config);
|
|
const mainKey = params.config?.session?.mainKey?.trim() || "main";
|
|
if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null;
|
|
|
|
const workspaceRoot = resolveUserPath(cfg.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(
|
|
workspaceDir,
|
|
seedWorkspace,
|
|
params.config?.agent?.skipBootstrap,
|
|
);
|
|
|
|
return {
|
|
workspaceDir,
|
|
containerWorkdir: cfg.docker.workdir,
|
|
};
|
|
}
|