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-agentagent_command_templatepath).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— fromXMODE_SANDBOX_*(fallbackCODEX_DOCKER_*).--cap-drop ALLand--security-opt no-new-privileges— always.--read-onlyroot filesystem — opt-in viaXMODE_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:
CodexSessionMessageJobrecords the task id and hands off toCodexCloudPollJobinstead of marking the session ready.CodexCloud::PollerpollsCodexCloud::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 (recordinglast_error). A recurringCodexCloud::Poller.sweepre-enqueues stuck tasks.CodexCloud::Clientis a thin, isolated wrapper over thecodex cloudretrieval surface. The exact subcommands vary by CLI version and are overridable viaCODEX_CLOUD_STATUS_COMMAND; the poller depends only on the client'sResultcontract, 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.