@pai/sandbox-docker
Docker-backed SandboxProvider implementation for local development and self-hosted coding agents.
Package: @pai/sandbox-docker
Implements the SandboxProvider contract with one long-lived Docker container
per sandbox and a host workspace directory bind-mounted at /workspace.
Commands run in the container; files persist on the host, which is what a local
coding agent wants.
Quick start
import { createAgentRuntime } from "@pai/core";
import { sandboxCapability } from "@pai/sandbox";
import { createDockerSandboxProvider } from "@pai/sandbox-docker";
const runtime = createAgentRuntime({
agent, // tools: { ...sandboxTools() }
capabilities: {
sandbox: sandboxCapability({
provider: createDockerSandboxProvider({
image: "node:22-bookworm-slim",
workspaceRoot: ".pai/docker-sandboxes",
}),
}),
},
});The Docker CLI must be installed and able to reach a daemon.
Options
type DockerSandboxProviderOptions = {
/** Default image. `create({ template })` overrides it per sandbox. */
image?: string; // node:22-bookworm-slim
/** Host directory holding per-sandbox workspaces. Defaults to a temp directory. */
workspaceRoot?: string;
/** Copied into each new workspace before the container starts. */
workspaceTemplateDir?: string;
/** Provider-owned state. Defaults to `.pai/sandboxes/docker` under the workspace root. */
stateDir?: string;
/** Working directory when `runCommand.cwd` is omitted. Defaults to /workspace. */
defaultCwd?: string;
/** Idle lifetime for sandboxes created without `ttlMs`. Defaults to 5 minutes. */
defaultTtlMs?: number;
/** How often idle containers are stopped. Defaults to 30s; `false` disables it. */
idleSweepIntervalMs?: number | false;
/** Hard cleanup window after a sandbox goes idle. Omitted keeps files indefinitely. */
deleteAfterIdleMs?: number;
/** Network policy when `create` receives none. Defaults to `{ access: "none" }`. */
defaultNetwork?: SandboxNetworkConfig;
/** Applied to every container and command. */
env?: Record<string, string>;
/** Delete the host workspace on `kill()`. Defaults to false. */
deleteWorkspaceOnKill?: boolean;
containerNamePrefix?: string; // pai-sandbox
/** Docker CLI options for the default driver. */
dockerCli?: DockerCliDriverOptions;
/** Injectable driver, for tests and alternate runtimes. */
driver?: DockerSandboxDriver;
now?: () => number;
generateId?: () => string;
};createDockerCliDriver(options?) is the default driver, exported so a
deployment can wrap or replace how the CLI is invoked.
Behaviour notes
- Files are on the host.
/workspaceis a bind-mounted host directory and/tmpis a provider-owned per-sandbox directory mounted into the container. File operations run host-side, so they work while the container is stopped. - Commands run through
docker exec ... sh -lc. A stopped or idled container is restarted first. ttlMsis an idle deadline. A background sweeper stops idle containers and keeps registry state and files, soconnectcan restart them. Files are kept indefinitely unlessdeleteAfterIdleMsordeleteWorkspaceOnKillsays otherwise;kill()removes the container and temp directory.pause()stops the container, andconnect()restarts stopped or idle ones and returns a live session.- Network is off by default. Docker enforces
{ access: "none" }and{ access: "full" };allowlistis rejected, because plain Docker cannot enforce domain rules. probe()asks the daemon, and creates nothing. It runsdocker versionunder a five-second budget, so a daemon that is not answering — including a socket that accepts the connection and then goes quiet — isSandboxUnavailableErrorrather than a hang, with the daemon's own message as thecause: "permission denied on the socket" and "the daemon is down" are different problems. A configuredworkspaceRootis then checked for writability without being created, becausecreateis what prepares it.resources.diskMiBis rejected, since the workspace is a bind mount.bindingKeyis ignored, so replicas creating for one key at once can each get a sandbox. Metadata rediscovery throughliststill reconnects after a restart.
Security boundary
This is a local-development provider, not a hostile multi-tenant isolation
boundary. File operations verify that paths and symlinks resolve inside the
mounted workspace, but host-side Node.js filesystem calls cannot atomically
enforce openat2(RESOLVE_BENEATH) semantics, so code racing symlink changes can
undermine the check. Do not share one provider root across mutually untrusted
tenants, and use E2B or GKE Agent Sandboxes for untrusted code.