PAIPAI

@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. /workspace is a bind-mounted host directory and /tmp is 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.
  • ttlMs is an idle deadline. A background sweeper stops idle containers and keeps registry state and files, so connect can restart them. Files are kept indefinitely unless deleteAfterIdleMs or deleteWorkspaceOnKill says otherwise; kill() removes the container and temp directory.
  • pause() stops the container, and connect() restarts stopped or idle ones and returns a live session.
  • Network is off by default. Docker enforces { access: "none" } and { access: "full" }; allowlist is rejected, because plain Docker cannot enforce domain rules.
  • probe() asks the daemon, and creates nothing. It runs docker version under a five-second budget, so a daemon that is not answering — including a socket that accepts the connection and then goes quiet — is SandboxUnavailableError rather than a hang, with the daemon's own message as the cause: "permission denied on the socket" and "the daemon is down" are different problems. A configured workspaceRoot is then checked for writability without being created, because create is what prepares it.
  • resources.diskMiB is rejected, since the workspace is a bind mount.
  • bindingKey is ignored, so replicas creating for one key at once can each get a sandbox. Metadata rediscovery through list still 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.

On this page