Docs

Sandbox Architecture

xmode runs a governed development loop: objective -> plan -> sandbox -> evidence -> review -> Change Request. Sandboxes are first-class, inspectable, resource-bounded, reapable run environments. This document describes how they execute.

One seam: Sandboxes::Engine

Every command that runs inside a sandbox worktree goes through one adapter, so docker resource limits, the production mount model, security hardening, and the storage-root guard live in one place rather than scattered across callers.

Sandboxes::Engine.for(execution_environment) -> adapter
  Sandboxes::Providers::LocalProcess   # host Open3 (dev / local_worktree)
  Sandboxes::Providers::DockerEngine   # docker run (resource-bounded, hardened)

Adapters return a raw [stdout, stderr, status] triple, so callers keep their own artifact/log/evidence logic. The three callers are:

  • Runners::LocalShell — pipeline action steps (and the CLI-agent agent_command_template path).
  • Sandboxes::CommandRunner — the ad-hoc sandbox terminal.
  • (Phase D onward) the Codex/Claude agent runtimes, which bind their working directory to the run's sandbox worktree.

runner_mode -> adapter (gated)

runner_mode Adapter
local_worktree LocalProcess (host)
docker DockerEngine on the local socket
cloud_worker DockerEngine iff a sandbox engine is configured (XMODE_SANDBOX_DOCKER_HOST set or XMODE_SANDBOX_ENGINE=docker); otherwise LocalProcess

The cloud_worker gate is deliberate: with no engine configured (dev/test/CI), it runs on the host exactly as before, so existing behavior and specs are unchanged. Production opts in with one env var.

The docker mount model (bind vs volume)

DockerEngine uses two mount strategies, selected by XMODE_SANDBOX_STORAGE_VOLUME / CODEX_DOCKER_STORAGE_VOLUME:

  • bind (dev): -v <worktree>:/workspace -w /workspace — works against a host-local docker daemon where the worktree is a real host path.
  • volume (prod): --volume xmode_storage:/rails/storage -w /rails/storage/runs/<run>/worktree.

The volume model is required in the deployed runner. The job container runs bin/jobs with /var/run/docker.sock mounted; a sibling container it spawns cannot bind-mount /rails/storage/... because the host daemon resolves that path on the host, where it does not exist — it only exists inside the job container's xmode_storage volume. The named volume is the only mount both the daemon and the job container agree on. storage/runs lives under /rails/storage (Rails root /rails + the xmode_storage:/rails/storage volume), so the worktree is always reachable via the volume.

Resource limits and security

In volume (prod) mode every sandbox container is bounded and hardened:

  • --cpus / --memory / --pids-limit / --tmpfs /tmp / --network — from XMODE_SANDBOX_* (fallback CODEX_DOCKER_*).
  • --cap-drop ALL and --security-opt no-new-privileges — always.
  • --read-only root filesystem — opt-in via XMODE_SANDBOX_READONLY_ROOT (breaks workloads that write outside the workspace or /tmp).
  • The docker socket is never mounted into a sandbox container.

Bind (dev) mode keeps the historical lightweight argv so local docker and the terminal specs are unchanged.

Lifecycle and reaping (Phase B)

Sessions carry expires_at, container_id, and worktree_path. Sandboxes::Reaper (run hourly by SandboxReaperJob, and synchronously from SandboxSession#stop!):

  • marks expired open sessions destroyed;
  • tears down any container/volume via the adapter's destroy;
  • reclaims the worktree checkout from disk — artifacts are preserved (logs, diffs, change-request packages live in the per-step run directory, not the worktree);
  • refuses to delete a worktree still shared by another open session.

open_usage_for excludes expired sessions, so a stuck session never permanently consumes a user's open-sandbox quota.

Persistent project machines (Phase C)

With XMODE_PROJECT_MACHINES=1, Sandboxes::ProjectMachine keeps a warm per-project checkout at storage/project_machines/<id>/repo and branches a detached git worktree per run instead of git clone --depth 1 every time. Because the checkout lives under storage/ (the xmode_storage volume), the same path works on the host and inside the container without a dedicated long-lived container. Caching language deps (bundle/node_modules) inside a per-project machine is a future extension; the named volume (xmode_proj_<id>) is recorded for it.

Codex Cloud loop + agent/sandbox binding (Phase D)

Agent binding. CodexSdk::Runner derives its working directory from sandbox_session.worktree_path when the session is bound to a sandbox (codex -C, claude --workdir, host chdir). The Codex/Claude agent then edits the same checkout the pipeline captures, and its diff flows through the existing ChangeRequests::Builder.

Cloud loop. cloud_subscription submissions were fire-and-forget. Now:

  • CodexSessionMessageJob records the task id and hands off to CodexCloudPollJob instead of marking the session ready.
  • CodexCloud::Poller polls CodexCloud::Client, streams the transcript into the live session thread, and on a terminal state marks the session ready (capturing the diff for review) or failed (recording last_error). A recurring CodexCloud::Poller.sweep re-enqueues stuck tasks.
  • CodexCloud::Client is a thin, isolated wrapper over the codex cloud retrieval surface. The exact subcommands vary by CLI version and are overridable via CODEX_CLOUD_STATUS_COMMAND; the poller depends only on the client's Result contract, so the CLI specifics can be corrected on the deploy host without touching the loop.

Configuration reference

Env var Purpose Default
XMODE_SANDBOX_ENGINE / XMODE_SANDBOX_DOCKER_HOST Containerize cloud_worker unset (host)
XMODE_SANDBOX_STORAGE_VOLUME Volume-mount the worktree (prod) CODEX_DOCKER_STORAGE_VOLUME
XMODE_SANDBOX_CPUS / _MEMORY / _PIDS / _TMPFS / _NETWORK Container limits 2 / 4g / 512 / 1g / bridge
XMODE_SANDBOX_READONLY_ROOT Read-only root fs (opt-in) off
XMODE_PROJECT_MACHINES Warm per-project checkouts off
XMODE_OPEN_SANDBOX_LIMIT Max open sandboxes per user 3
CODEX_CLOUD_STATUS_COMMAND Override the cloud status command codex cloud status %{task_id} --json

Deploy notes

The single-host Kamal deploy (config/deploy.yml) mounts /var/run/docker.sock into the job container and the xmode_storage volume at /rails/storage. Set XMODE_SANDBOX_ENGINE=docker to turn cloud_worker into real bounded containers after verifying docker socket access and that the base images (ruby:3.4-bookworm, node:20-bookworm) can be pulled. A dedicated sandbox docker host slots in as XMODE_SANDBOX_DOCKER_HOST without code changes.