mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-24 16:11:05 +00:00
The sandbox registry stores one JSON document per scope (containers
and browsers), with every writer serialized through
`acquireSessionWriteLock` against that single file. In a host running
several sessions in parallel — multiple pairings, subagent spawns, or
just an `ensureSandboxContainer` landing at the same moment as a
`removeRegistryEntry` — each writer waits up to 60s for the lock, and
a crashed process can leave the lock file behind long enough to
wedge every subsequent sandbox operation until the stale-lock
threshold elapses. The lock's only job is to keep entries from
trampling each other inside one JSON blob, so it is a whole-file
mutex gating reads/writes that touch disjoint entries.
Each container already has a unique name (enforced at creation), so
each entry's storage can be disjoint too. This change turns the
`~/.openclaw/sandbox/containers.json` and `browsers.json`
monolithic files into per-entry JSON files under
`~/.openclaw/sandbox/containers/` and `~/.openclaw/sandbox/browsers/`
directories. `writeJsonAtomic` (tmp-file + rename) keeps each
per-entry write crash-safe, and because concurrent writers only
touch their own files there is nothing left to serialize across.
Changes:
- `src/agents/sandbox/constants.ts`: add `SANDBOX_CONTAINERS_DIR`
and `SANDBOX_BROWSERS_DIR` sibling to the existing monolithic
paths. The old paths stay exported because the one-shot migration
still needs to locate the legacy file.
- `src/agents/sandbox/registry.ts`: replace the
`withRegistryLock` / `readRegistryFromFile("strict")` /
`writeRegistryFile` loop with per-entry read/write/remove
primitives against the sharded directories, and drop the
`acquireSessionWriteLock` import. The existing upstream additions
are preserved: the zod `RegistryEntrySchema`, the
`backendId`/`runtimeLabel`/`configLabelKind` fields on
`SandboxRegistryEntry`, and `normalizeSandboxRegistryEntry` still
decorate reads. Upsert merge semantics (preserve `createdAtMs` and
`image` from the prior entry, prefer the newer `configHash`) are
kept bit-for-bit.
- `src/agents/sandbox/registry.ts`: add `readRegistryEntry(name)`
for O(1) single-container lookup. The previous hot path in
`ensureSandboxContainer` had to read the whole registry and
`Array.find` the one entry it wanted; the new API avoids both the
full directory scan and the JSON round-trip on every other entry.
- `src/agents/sandbox/registry.ts`: add a one-shot
`migrateMonolithicIfNeeded` helper invoked at the top of every
public read/write. If a legacy `containers.json` / `browsers.json`
exists, its entries are fanned out into per-entry files, the old
file and its `.lock` are removed, and subsequent calls skip the
migration branch entirely. Malformed legacy files are dropped
rather than throwing forever, because a corrupt single-file
registry that has already been superseded by the new storage
would otherwise block every sandbox ensure/remove on every boot.
Live per-entry files still go through the same schema validation
the upstream strict path used — a corrupt per-entry file is
simply skipped during enumeration so that one bad file cannot
hide every other running container from the operator.
- `src/agents/sandbox/docker.ts`: swap the `readRegistry()` +
`Array.find` lookup in `ensureSandboxContainer` for the new
`readRegistryEntry(containerName)`. This is the only in-tree
caller that needed the full scan just to pick one entry.
- `src/agents/sandbox/registry.test.ts`: rewrite around the new
per-file semantics. The old tests covered two properties that no
longer exist — "the lock serializes concurrent update/remove so
the later write cannot resurrect a removed entry" and "a
malformed monolithic file makes every `update` throw" — both of
which were artifacts of the single-file design. The rewrite keeps
the normalizeSandboxRegistryEntry contract, the
concurrent-updates-succeed contract (now without any lock in
play), the malformed-legacy-migration contract, and adds coverage
for `readRegistryEntry`, the stale-`.lock` cleanup, and the
"corrupt per-entry file does not hide its siblings" guarantee.
- `src/agents/sandbox/docker.config-hash-recreate.test.ts`: update
the mock module to expose `readRegistryEntry` instead of
`readRegistry`, and return single-entry objects or `null` rather
than `{ entries: [...] }`.
Other in-tree consumers (`manage.ts`, `prune.ts`, `browser.ts`,
`context.ts`) only call the public `readRegistry` / `updateRegistry`
/ `remove*` surface, whose return shapes and observable behavior
are unchanged; their existing tests (`manage.test.ts`,
`browser.create.test.ts`, `sandbox.resolveSandboxContext.test.ts`)
all pass unmodified.
Default behavior is unchanged from the operator's point of view:
the first boot on the new code sees the legacy files, migrates them
in place, and deletes them. Subsequent boots never touch the
migration path. No config surface, no types, and no public exports
are removed.
632 lines
20 KiB
TypeScript
632 lines
20 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
|
import {
|
|
materializeWindowsSpawnProgram,
|
|
resolveWindowsSpawnProgram,
|
|
} from "../../plugin-sdk/windows-spawn.js";
|
|
import { sanitizeEnvVars } from "./sanitize-env-vars.js";
|
|
import type { EnvSanitizationOptions } from "./sanitize-env-vars.js";
|
|
|
|
type ExecDockerRawOptions = {
|
|
allowFailure?: boolean;
|
|
input?: Buffer | string;
|
|
signal?: AbortSignal;
|
|
};
|
|
|
|
export type ExecDockerRawResult = {
|
|
stdout: Buffer;
|
|
stderr: Buffer;
|
|
code: number;
|
|
};
|
|
|
|
type ExecDockerRawError = Error & {
|
|
code: number;
|
|
stdout: Buffer;
|
|
stderr: Buffer;
|
|
};
|
|
|
|
function createAbortError(): Error {
|
|
const err = new Error("Aborted");
|
|
err.name = "AbortError";
|
|
return err;
|
|
}
|
|
|
|
type DockerSpawnRuntime = {
|
|
platform: NodeJS.Platform;
|
|
env: NodeJS.ProcessEnv;
|
|
execPath: string;
|
|
};
|
|
|
|
const DEFAULT_DOCKER_SPAWN_RUNTIME: DockerSpawnRuntime = {
|
|
platform: process.platform,
|
|
env: process.env,
|
|
execPath: process.execPath,
|
|
};
|
|
|
|
export function resolveDockerSpawnInvocation(
|
|
args: string[],
|
|
runtime: DockerSpawnRuntime = DEFAULT_DOCKER_SPAWN_RUNTIME,
|
|
): { command: string; args: string[]; shell?: boolean; windowsHide?: boolean } {
|
|
const program = resolveWindowsSpawnProgram({
|
|
command: "docker",
|
|
platform: runtime.platform,
|
|
env: runtime.env,
|
|
execPath: runtime.execPath,
|
|
packageName: "docker",
|
|
allowShellFallback: false,
|
|
});
|
|
const resolved = materializeWindowsSpawnProgram(program, args);
|
|
return {
|
|
command: resolved.command,
|
|
args: resolved.argv,
|
|
shell: resolved.shell,
|
|
windowsHide: resolved.windowsHide,
|
|
};
|
|
}
|
|
|
|
export function execDockerRaw(
|
|
args: string[],
|
|
opts?: ExecDockerRawOptions,
|
|
): Promise<ExecDockerRawResult> {
|
|
return new Promise<ExecDockerRawResult>((resolve, reject) => {
|
|
const spawnInvocation = resolveDockerSpawnInvocation(args);
|
|
const child = spawn(spawnInvocation.command, spawnInvocation.args, {
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
shell: spawnInvocation.shell,
|
|
windowsHide: spawnInvocation.windowsHide,
|
|
});
|
|
const stdoutChunks: Buffer[] = [];
|
|
const stderrChunks: Buffer[] = [];
|
|
let aborted = false;
|
|
|
|
const signal = opts?.signal;
|
|
const handleAbort = () => {
|
|
if (aborted) {
|
|
return;
|
|
}
|
|
aborted = true;
|
|
child.kill("SIGTERM");
|
|
};
|
|
if (signal) {
|
|
if (signal.aborted) {
|
|
handleAbort();
|
|
} else {
|
|
signal.addEventListener("abort", handleAbort, { once: true });
|
|
}
|
|
}
|
|
|
|
child.stdout?.on("data", (chunk) => {
|
|
stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
});
|
|
child.stderr?.on("data", (chunk) => {
|
|
stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
});
|
|
|
|
child.on("error", (error) => {
|
|
if (signal) {
|
|
signal.removeEventListener("abort", handleAbort);
|
|
}
|
|
if (
|
|
error &&
|
|
typeof error === "object" &&
|
|
"code" in error &&
|
|
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
) {
|
|
const friendly = Object.assign(
|
|
new Error(
|
|
'Sandbox mode requires Docker, but the "docker" command was not found in PATH. Install Docker (and ensure "docker" is available), or set `agents.defaults.sandbox.mode=off` to disable sandboxing.',
|
|
),
|
|
{ code: "INVALID_CONFIG", cause: error },
|
|
);
|
|
reject(friendly);
|
|
return;
|
|
}
|
|
reject(error);
|
|
});
|
|
|
|
child.on("close", (code) => {
|
|
if (signal) {
|
|
signal.removeEventListener("abort", handleAbort);
|
|
}
|
|
const stdout = Buffer.concat(stdoutChunks);
|
|
const stderr = Buffer.concat(stderrChunks);
|
|
if (aborted || signal?.aborted) {
|
|
reject(createAbortError());
|
|
return;
|
|
}
|
|
const exitCode = code ?? 0;
|
|
if (exitCode !== 0 && !opts?.allowFailure) {
|
|
const message = stderr.length > 0 ? stderr.toString("utf8").trim() : "";
|
|
const error: ExecDockerRawError = Object.assign(
|
|
new Error(message || `docker ${args.join(" ")} failed`),
|
|
{
|
|
code: exitCode,
|
|
stdout,
|
|
stderr,
|
|
},
|
|
);
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolve({ stdout, stderr, code: exitCode });
|
|
});
|
|
|
|
const stdin = child.stdin;
|
|
if (stdin) {
|
|
if (opts?.input !== undefined) {
|
|
stdin.end(opts.input);
|
|
} else {
|
|
stdin.end();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
import { formatCliCommand } from "../../cli/command-format.js";
|
|
import { markOpenClawExecEnv } from "../../infra/openclaw-exec-env.js";
|
|
import { defaultRuntime } from "../../runtime.js";
|
|
import { computeSandboxConfigHash } from "./config-hash.js";
|
|
import { DEFAULT_SANDBOX_IMAGE } from "./constants.js";
|
|
import { readRegistryEntry, updateRegistry } from "./registry.js";
|
|
import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js";
|
|
import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js";
|
|
import { validateSandboxSecurity } from "./validate-sandbox-security.js";
|
|
import { appendWorkspaceMountArgs, SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js";
|
|
|
|
const log = createSubsystemLogger("docker");
|
|
|
|
const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000;
|
|
|
|
export type ExecDockerOptions = ExecDockerRawOptions;
|
|
|
|
export async function execDocker(args: string[], opts?: ExecDockerOptions) {
|
|
const result = await execDockerRaw(args, opts);
|
|
return {
|
|
stdout: result.stdout.toString("utf8"),
|
|
stderr: result.stderr.toString("utf8"),
|
|
code: result.code,
|
|
};
|
|
}
|
|
|
|
export async function readDockerContainerLabel(
|
|
containerName: string,
|
|
label: string,
|
|
): Promise<string | null> {
|
|
const result = await execDocker(
|
|
["inspect", "-f", `{{ index .Config.Labels "${label}" }}`, containerName],
|
|
{ allowFailure: true },
|
|
);
|
|
if (result.code !== 0) {
|
|
return null;
|
|
}
|
|
const raw = result.stdout.trim();
|
|
if (!raw || raw === "<no value>") {
|
|
return null;
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
export async function readDockerContainerEnvVar(
|
|
containerName: string,
|
|
envVar: string,
|
|
): Promise<string | null> {
|
|
const result = await execDocker(
|
|
["inspect", "-f", "{{range .Config.Env}}{{println .}}{{end}}", containerName],
|
|
{ allowFailure: true },
|
|
);
|
|
if (result.code !== 0) {
|
|
return null;
|
|
}
|
|
for (const line of result.stdout.split(/\r?\n/)) {
|
|
if (line.startsWith(`${envVar}=`)) {
|
|
return line.slice(envVar.length + 1);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function readDockerNetworkDriver(network: string): Promise<string | null> {
|
|
const result = await execDocker(["network", "inspect", "-f", "{{.Driver}}", network], {
|
|
allowFailure: true,
|
|
});
|
|
if (result.code !== 0) {
|
|
return null;
|
|
}
|
|
const driver = result.stdout.trim();
|
|
return driver || null;
|
|
}
|
|
|
|
export async function readDockerNetworkGateway(network: string): Promise<string | null> {
|
|
const result = await execDocker(
|
|
["network", "inspect", "-f", "{{range .IPAM.Config}}{{println .Gateway}}{{end}}", network],
|
|
{ allowFailure: true },
|
|
);
|
|
if (result.code !== 0) {
|
|
return null;
|
|
}
|
|
// Filter valid, non-empty gateways (handles dual-stack / multi-subnet networks
|
|
// and filters Docker's "<no value>" sentinel for nil IPAM entries).
|
|
const gateways = result.stdout
|
|
.split(/\r?\n/)
|
|
.map((l) => l.trim())
|
|
.filter((l) => l && l !== "<no value>");
|
|
// Prefer IPv4: the CDP relay binds on 0.0.0.0 so an IPv6-only range would
|
|
// reject forwarded IPv4 traffic from the bridge gateway.
|
|
const gw = gateways.find((g) => !g.includes(":")) ?? gateways[0] ?? "";
|
|
return gw || null;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
const DOCKER_DAEMON_UNAVAILABLE_MARKERS = [
|
|
"cannot connect to the docker daemon",
|
|
"dial unix",
|
|
"docker daemon is not running",
|
|
"connection refused",
|
|
];
|
|
|
|
export function isDockerDaemonUnavailable(stderr: string): boolean {
|
|
return DOCKER_DAEMON_UNAVAILABLE_MARKERS.some((marker) => stderr.toLowerCase().includes(marker));
|
|
}
|
|
|
|
export function formatDockerDaemonUnavailableError(stderr: string): string {
|
|
const detail = stderr.trim();
|
|
return [
|
|
"Sandbox mode requires Docker, but the Docker daemon is not available.",
|
|
"Start Docker, or set `agents.defaults.sandbox.mode=off` to disable sandboxing.",
|
|
detail ? `Docker said: ${detail}` : undefined,
|
|
]
|
|
.filter((line): line is string => Boolean(line))
|
|
.join(" ");
|
|
}
|
|
|
|
async function inspectDockerImage(image: string): Promise<"exists" | "missing"> {
|
|
const result = await execDocker(["image", "inspect", image], {
|
|
allowFailure: true,
|
|
});
|
|
if (result.code === 0) {
|
|
return "exists";
|
|
}
|
|
const stderr = result.stderr.trim();
|
|
if (stderr.toLowerCase().includes("no such image")) {
|
|
return "missing";
|
|
}
|
|
if (isDockerDaemonUnavailable(stderr)) {
|
|
throw new Error(formatDockerDaemonUnavailableError(stderr));
|
|
}
|
|
throw new Error(`Failed to inspect sandbox image: ${stderr}`);
|
|
}
|
|
|
|
export async function ensureDockerImage(image: string) {
|
|
const imageState = await inspectDockerImage(image);
|
|
if (imageState === "exists") {
|
|
return;
|
|
}
|
|
if (image === DEFAULT_SANDBOX_IMAGE) {
|
|
throw new Error(
|
|
`Sandbox image not found: ${image}. Build it with scripts/sandbox-setup.sh before enabling Docker sandboxing. The default image includes python3 for sandbox write/edit helpers; OpenClaw will not substitute plain debian:bookworm-slim.`,
|
|
);
|
|
}
|
|
throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`);
|
|
}
|
|
|
|
export 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" };
|
|
}
|
|
|
|
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>;
|
|
configHash?: string;
|
|
includeBinds?: boolean;
|
|
bindSourceRoots?: string[];
|
|
allowSourcesOutsideAllowedRoots?: boolean;
|
|
allowReservedContainerTargets?: boolean;
|
|
allowContainerNamespaceJoin?: boolean;
|
|
envSanitizationOptions?: EnvSanitizationOptions;
|
|
}) {
|
|
// Runtime security validation: blocks dangerous bind mounts, network modes, and profiles.
|
|
validateSandboxSecurity({
|
|
...params.cfg,
|
|
allowedSourceRoots: params.bindSourceRoots,
|
|
allowSourcesOutsideAllowedRoots:
|
|
params.allowSourcesOutsideAllowedRoots ??
|
|
params.cfg.dangerouslyAllowExternalBindSources === true,
|
|
allowReservedContainerTargets:
|
|
params.allowReservedContainerTargets ??
|
|
params.cfg.dangerouslyAllowReservedContainerTargets === true,
|
|
dangerouslyAllowContainerNamespaceJoin:
|
|
params.allowContainerNamespaceJoin ??
|
|
params.cfg.dangerouslyAllowContainerNamespaceJoin === true,
|
|
});
|
|
|
|
const createdAtMs = params.createdAtMs ?? Date.now();
|
|
const args = ["create", "--name", params.name];
|
|
args.push("--label", "openclaw.sandbox=1");
|
|
args.push("--label", `openclaw.sessionKey=${params.scopeKey}`);
|
|
args.push("--label", `openclaw.createdAtMs=${createdAtMs}`);
|
|
args.push("--label", `openclaw.mountFormatVersion=${SANDBOX_MOUNT_FORMAT_VERSION}`);
|
|
if (params.configHash) {
|
|
args.push("--label", `openclaw.configHash=${params.configHash}`);
|
|
}
|
|
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);
|
|
}
|
|
const envSanitization = sanitizeEnvVars(params.cfg.env ?? {}, params.envSanitizationOptions);
|
|
if (envSanitization.blocked.length > 0) {
|
|
log.warn(`Blocked sensitive environment variables: ${envSanitization.blocked.join(", ")}`);
|
|
}
|
|
if (envSanitization.warnings.length > 0) {
|
|
log.warn(`Suspicious environment variables: ${envSanitization.warnings.join(", ")}`);
|
|
}
|
|
for (const [key, value] of Object.entries(markOpenClawExecEnv(envSanitization.allowed))) {
|
|
args.push("--env", `${key}=${value}`);
|
|
}
|
|
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));
|
|
}
|
|
const gpus = params.cfg.gpus?.trim();
|
|
if (gpus) {
|
|
args.push("--gpus", gpus);
|
|
}
|
|
for (const [name, value] of Object.entries(params.cfg.ulimits ?? {})) {
|
|
const formatted = formatUlimitValue(name, value);
|
|
if (formatted) {
|
|
args.push("--ulimit", formatted);
|
|
}
|
|
}
|
|
if (params.includeBinds !== false && params.cfg.binds?.length) {
|
|
for (const bind of params.cfg.binds) {
|
|
args.push("-v", bind);
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function appendCustomBinds(args: string[], cfg: SandboxDockerConfig): void {
|
|
if (!cfg.binds?.length) {
|
|
return;
|
|
}
|
|
for (const bind of cfg.binds) {
|
|
args.push("-v", bind);
|
|
}
|
|
}
|
|
|
|
async function createSandboxContainer(params: {
|
|
name: string;
|
|
cfg: SandboxDockerConfig;
|
|
workspaceDir: string;
|
|
workspaceAccess: SandboxWorkspaceAccess;
|
|
agentWorkspaceDir: string;
|
|
scopeKey: string;
|
|
configHash?: string;
|
|
}) {
|
|
const { name, cfg, workspaceDir, scopeKey } = params;
|
|
await ensureDockerImage(cfg.image);
|
|
|
|
const args = buildSandboxCreateArgs({
|
|
name,
|
|
cfg,
|
|
scopeKey,
|
|
configHash: params.configHash,
|
|
includeBinds: false,
|
|
bindSourceRoots: [workspaceDir, params.agentWorkspaceDir],
|
|
});
|
|
args.push("--workdir", cfg.workdir);
|
|
appendWorkspaceMountArgs({
|
|
args,
|
|
workspaceDir,
|
|
agentWorkspaceDir: params.agentWorkspaceDir,
|
|
workdir: cfg.workdir,
|
|
workspaceAccess: params.workspaceAccess,
|
|
});
|
|
appendCustomBinds(args, cfg);
|
|
args.push(cfg.image, "sleep", "infinity");
|
|
|
|
await execDocker(args);
|
|
await execDocker(["start", name]);
|
|
|
|
if (cfg.setupCommand?.trim()) {
|
|
await execDocker(["exec", "-i", name, "/bin/sh", "-lc", cfg.setupCommand]);
|
|
}
|
|
}
|
|
|
|
async function readContainerConfigHash(containerName: string): Promise<string | null> {
|
|
return await readDockerContainerLabel(containerName, "openclaw.configHash");
|
|
}
|
|
|
|
function formatSandboxRecreateHint(params: { scope: SandboxConfig["scope"]; sessionKey: string }) {
|
|
if (params.scope === "session") {
|
|
return formatCliCommand(`openclaw sandbox recreate --session ${params.sessionKey}`);
|
|
}
|
|
if (params.scope === "agent") {
|
|
const agentId = resolveSandboxAgentId(params.sessionKey) ?? "main";
|
|
return formatCliCommand(`openclaw sandbox recreate --agent ${agentId}`);
|
|
}
|
|
return formatCliCommand("openclaw sandbox recreate --all");
|
|
}
|
|
|
|
export async function ensureSandboxContainer(params: {
|
|
sessionKey: string;
|
|
workspaceDir: string;
|
|
agentWorkspaceDir: 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 expectedHash = computeSandboxConfigHash({
|
|
docker: params.cfg.docker,
|
|
workspaceAccess: params.cfg.workspaceAccess,
|
|
workspaceDir: params.workspaceDir,
|
|
agentWorkspaceDir: params.agentWorkspaceDir,
|
|
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
|
|
});
|
|
const now = Date.now();
|
|
const state = await dockerContainerState(containerName);
|
|
let hasContainer = state.exists;
|
|
let running = state.running;
|
|
let currentHash: string | null = null;
|
|
let hashMismatch = false;
|
|
let registryEntry:
|
|
| {
|
|
lastUsedAtMs: number;
|
|
configHash?: string;
|
|
}
|
|
| undefined;
|
|
if (hasContainer) {
|
|
registryEntry = (await readRegistryEntry(containerName)) ?? undefined;
|
|
currentHash = await readContainerConfigHash(containerName);
|
|
if (!currentHash) {
|
|
currentHash = registryEntry?.configHash ?? null;
|
|
}
|
|
hashMismatch = !currentHash || currentHash !== expectedHash;
|
|
if (hashMismatch) {
|
|
const lastUsedAtMs = registryEntry?.lastUsedAtMs;
|
|
const isHot =
|
|
running &&
|
|
(typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS);
|
|
if (isHot) {
|
|
const hint = formatSandboxRecreateHint({ scope: params.cfg.scope, sessionKey: scopeKey });
|
|
defaultRuntime.log(
|
|
`Sandbox config changed for ${containerName} (recently used). Recreate to apply: ${hint}`,
|
|
);
|
|
} else {
|
|
await execDocker(["rm", "-f", containerName], { allowFailure: true });
|
|
hasContainer = false;
|
|
running = false;
|
|
}
|
|
}
|
|
}
|
|
if (!hasContainer) {
|
|
await createSandboxContainer({
|
|
name: containerName,
|
|
cfg: params.cfg.docker,
|
|
workspaceDir: params.workspaceDir,
|
|
workspaceAccess: params.cfg.workspaceAccess,
|
|
agentWorkspaceDir: params.agentWorkspaceDir,
|
|
scopeKey,
|
|
configHash: expectedHash,
|
|
});
|
|
} else if (!running) {
|
|
await execDocker(["start", containerName]);
|
|
}
|
|
await updateRegistry({
|
|
containerName,
|
|
backendId: "docker",
|
|
runtimeLabel: containerName,
|
|
sessionKey: scopeKey,
|
|
createdAtMs: now,
|
|
lastUsedAtMs: now,
|
|
image: params.cfg.docker.image,
|
|
configLabelKind: "Image",
|
|
configHash: hashMismatch && running ? (currentHash ?? undefined) : expectedHash,
|
|
});
|
|
return containerName;
|
|
}
|