# ADR — Docker sandboxes as a second turn-isolation runtime *Status: accepted, unverified end to end. Decided while implementing the "Docker sandbox (microVM) runtime for turn containers behind the `ContainerRunner` seam" plan. Every fact about `sbx` below is marked **documented** (read from `docs.docker.com` in August 2026 and cited) or **assumed** (not stated by the docs and not yet observed on a real machine — see §8). The plan gated implementation on an empirical spike; that spike could not be run here — this repository's development environment has neither Docker Desktop nor `sbx` — so it was discharged **from Docker's own documentation instead**, which answers the two load-bearing questions the plan named as its stop condition and leaves four smaller ones open. §8 is the list, and `packages/daemon/test/sandbox-smoke.test.mjs` is where a human closes it.* --- ## 1. Context Radial runs every agent turn in a container (design §13). Containment today is `docker run` with `--cap-drop=ALL`, `no-new-privileges`, a pids cap, a non-root user, a read-only rootfs and a memory cap (`dockerRunArgs`, `packages/daemon/src/container.ts`). That is a shared-kernel boundary, and it buys its strength by taking capabilities away — including the ones a container would need to run Docker itself. [Docker sandboxes](https://docs.docker.com/ai/sandboxes/) run a workload in a **microVM** with its own kernel, its own Docker daemon, its own filesystem and a host-side policy proxy on its egress path, driven by an `sbx` CLI. For Radial that is an alternative turn-isolation runtime buying three things the `docker run` path cannot: 1. **A hypervisor boundary instead of a shared kernel.** A kernel exploit inside a turn no longer reaches the daemon host. 2. **Docker inside the turn.** Under `--cap-drop=ALL` a harness can never build or run a container, so implementation turns and checks for containerized projects are impossible today. 3. **A sanctioned use for the tcp turn transport.** The production unix-socket transport already fails across Docker Desktop's VM, which is why the dev-only tcp path exists. A microVM has the same problem for the same reason, and supporting sandboxes means promoting that path. **Scope.** This is entirely daemon-side operator runtime. No lexicon change, no fold rule, no new record type, no protocol version bump: `core`, `ui`, ingestion and the wire contract are untouched. Every §13 invariant is preserved — protocol credentials still never enter the turn (the sidecar socket remains the only write path), the turn still holds exactly a forge token and a model key, and the requested-type-only guard is unaffected. What changes is only *what kind of box* the harness process runs in. ## 2. Decision — substitute the runner, change nothing downstream `ContainerRunner` (`run` / `listByLabel` / `kill`) is already the abstraction everything downstream consumes: `runTurn`, the check runner and startup orphan reconciliation are all written against the interface, and `FakeContainerRunner` proves it. So the sandbox runtime is **one more implementation of it** — `SandboxRunner` in `packages/daemon/src/sandbox.ts`, shelling out to `sbx` exactly as `DockerRunner` shells out to `docker` — selected once in `cli.ts` by `run.containerRuntime`, with the single instance flowing to turn dispatch, check dispatch and both orphan reconcilers unchanged. Every `sbx` invocation is built by a **pure exported function** (`sbxCreateArgs`, `sbxExecScript`, `sbxExecArgs`, `sbxPolicyArgs`, `sbxRemoveArgs`, `parseSandboxList`), mirroring `dockerRunArgs`. Two reasons, and the second is the load-bearing one: `sbx` is not available in this development environment or in CI, so pure builders are what the unit tests can exercise; and Docker documents the CLI's surface as subject to change, so a re-spelling is a one-file edit against tests that already say what each argv means. `SandboxRunner` takes an injectable `SbxExec` for its short invocations (create, the policy rule, `ls`, `rm`). Orphan reconciliation is nothing but `listByLabel` and `kill`, so `reconcile.test.mjs` drives the **real** runner against a scripted CLI rather than a fake. ## 3. What `sbx` actually does — the four facts everything else follows from **Documented.** | Fact | Source | Consequence for Radial | |---|---|---| | A workspace passes through at the **same absolute path** it has on the host. `sbx run claude ~/a ~/b:ro` — positional paths, an optional `:ro`, no `source:target` form. | [usage](https://docs.docker.com/ai/sandboxes/usage/) | A `ContainerSpec` names targets (`/work`, `/bundle`, `/run/radial-forge`). The exec step bridges them — §4. | | Outbound TCP traverses a **host-side proxy** that enforces per-host rules. `host.docker.internal` is rewritten to `localhost` before forwarding, and the default policy denies localhost and private networks. Non-HTTP TCP is allowable by hostname/address rule; UDP and ICMP are not unblockable at all. | [policy](https://docs.docker.com/ai/sandboxes/security/policy/), [Claude Code + Model Runner guide](https://docs.docker.com/guides/claude-code-sandbox-model-runner/) | The tcp turn socket needs an explicit `localhost:` rule — §5. | | A locally built image becomes a template via `docker image save … -o t.tar` → `sbx template load t.tar` → `--template `. | [templates](https://docs.docker.com/ai/sandboxes/customize/templates/) | `pnpm images` is reused verbatim; the operator loads it once. | | Environment variables are settable at create/run (`-e K=V`, `-e K`, `--env-file`), and a sandbox **persists until `sbx rm`**. | [usage](https://docs.docker.com/ai/sandboxes/usage/) | Removal must be unconditional — §6. | ## 4. Spec mapping — honoured, bridged, superseded, refused | `ContainerSpec` field | Under `SandboxRunner` | |---|---| | `label` | **Honoured** — `--name`, and the sandbox name. It already doubles as the container name in the docker path so a timed-out run can be killed by name, so reconciliation (including the instance-id suffix that keeps two daemons on one machine apart) transfers unchanged. | | `image` | **Honoured** — the `--template`, overridable by `run.sandbox.template`. | | `mounts` | **Bridged** — each source becomes a workspace (`:ro` when read-only); the exec script symlinks each differing target at the passthrough source. | | `argv`, `env`, `workdir` | **Honoured** — by the exec script (§4.1). | | `timeoutMs` | **Honoured** — a runner-side timer plus `sbx rm --force`, the same shape as `DockerRunner`'s. | | `hostPorts` | **Honoured** — one sandbox-scoped policy rule (§5). | | `pidsLimit`, `readOnlyRootfs`, `user`, `tmpfs`, `extraHosts` | **Superseded** by the hypervisor boundary and the sandbox's own network path — every one of them exists to narrow a *shared-kernel* container. Named, never dropped silently: `describeSupersededSpecFields` reports the ones a spec actually carries and the runner logs them per run. | | `memory` | **Honoured** — `--memory`. The existing `4g` floor from `turn.ts` and `check-runner.ts` therefore remains the per-turn/check VM memory bound under the substituted runner. | | `network` | **Refused**, loudly, in both `config.ts` and `sbxCreateArgs`. A sandbox has no docker network to join, and an operator who set one is expressing an intent this runtime cannot honour. | | `onOutput` | **Honoured** — the exec is streamed and captured under the same bound as `DockerRunner`'s, so turn diagnostics and secret redaction (`modelEnvSecrets`) behave identically. | ### 4.1 The exec script, and why the environment goes over stdin `sbx exec sh -s` reads a generated script on **stdin**. It does three things in order: symlink each mount's target at its passthrough source; `export` the turn's environment; `cd` to the workdir and `exec` the harness argv. `set -e` makes a failed bridge a failed turn rather than a harness that starts in the wrong tree, and `exec` makes the harness's exit status the exec's. The bridge is what lets `turn.ts`, the harness prompts, the check orchestrator and the tangled adapter's `GIT_SSH_COMMAND` keep naming `/work`, `/bundle`, `/checks` and `/run/radial-forge` under both runtimes. The alternative — threading a runtime-dependent path through every one of those — would put this runtime's shape into code the docker path also runs, which is exactly what the `ContainerRunner` seam exists to prevent. The environment goes through that script rather than through `-e` flags for two reasons. It keeps every secret off both the host's and the guest's process lists, which `-e K=V` would not (and which `DockerRunner` avoids by passing `-e NAME` and inheriting the value). And it sidesteps an undocumented question — whether create-time variables are visible to a later `exec` session; the docs' separate advice about `/etc/sandbox-persistent.sh` for persisting variables "across sessions" suggests they may not be — rather than betting a turn on it. ## 5. The turn transport: tcp, and one policy rule per sandbox A microVM cannot share the host's AF_UNIX socket, so under this runtime the turn socket is tcp. Two consequences, both settled in code rather than in an operator's head: - **`auto` resolves to tcp under the sandbox runtime on every platform**, not just the VM-backed ones, and `config.ts` **refuses** an explicit `turnTransport: 'unix'` beside `containerRuntime: 'sandbox'`. That combination can only produce ECONNREFUSED an hour into a turn, after the bundle, the checkout and the sandbox have all been built. - **The bind host defaults to `127.0.0.1`** under this runtime (`run.turnSocketHost` overrides). Docker's connection arrives over the bridge, where loopback would refuse it; a sandbox's is made by the host proxy *after* it has rewritten `host.docker.internal` to `localhost`, so loopback is both sufficient and tighter. Every connection is already authenticated by the per-turn bearer token; this is defense in depth, not a new gate. The default policy denies localhost, so the runner writes one rule per turn: `sbx policy allow network --sandbox localhost:` — **scoped to that sandbox**, so a turn's grant dies with the turn instead of widening the machine's global policy, and naming `localhost` because that is what the proxy sees after the rewrite. A rule naming `host.docker.internal` would never match. The port comes from `ContainerSpec.hostPorts`, set by `turn.ts` from the socket it just bound: declared on the spec so the requirement is typed rather than parsed back out of `RADIAL_SIDECAR_SOCKET`. ## 6. Removal is unconditional, because a sandbox is not `docker run --rm` Nothing reclaims a sandbox. Its VM disk holds this turn's model key and forge grant — set by the exec script, and present for as long as the sandbox is — so `run()` removes it in a `finally`, whatever happened, and `kill()` is `sbx rm --force` rather than a stop. Startup orphan reconciliation is therefore load-bearing here in a way it is not under docker: a daemon killed mid-turn leaves a VM holding credentials, and `reconcileOrphans` is what reclaims it. `reconcile.test.mjs` asserts both that, and that one instance's reconciliation leaves the other instance's sandboxes alone. Checks share the runner (`deps.runner`) and so inherit this runtime with no extra wiring — and benefit most, since a check that runs `docker compose up` becomes possible. Checks carry no secrets (design §13), so sandbox persistence is not a leak risk there, but the same `rm`-in-`finally` discipline bounds their disk use. ## 7. Configuration surface (additive only) - `run.containerRuntime?: 'docker' | 'sandbox'` — default `'docker'`. With no new key set, an existing operator's daemon behaves exactly as before: `dockerRunArgs` is untouched, no new startup check fires, and `container.test.mjs` is unchanged. - `run.sandbox?: { binary?, template?, agent?, kits?, createArgs? }` — inert, and **refused**, under the docker runtime, because a settings block that silently does nothing is worse than a startup error naming the switch that would turn it on. - `run.turnSocketHost?: string` — §5. - **Refused combinations**: `sandbox` × `turnTransport: 'unix'`, `sandbox` × `run.network`, `sandbox` × `run.codexAuth` (managed Codex auth mounts a scratch home and an `/etc/passwd` entry keyed to the daemon's host uid, which passthrough workspaces cannot express). - **Startup fail-fast** under the sandbox runtime: `sandboxPreflight` runs `sbx version` and `sbx ls`, and a daemon that cannot dispatch says so at startup rather than at the first claim it wins. `sbx login` is interactive and must have happened out of band — the same posture forge authentication already takes. ## 8. What remains unverified The plan ordered an empirical spike ahead of the code with an explicit stop condition on two items. Both of those are now **documented** (§3): environment passing, and VM→host TCP reachability. The design does *not* fall back to the rejected alternative in §10. What is still **assumed**, and what closes each: 1. **`sbx create` takes an agent positional beside `--template`.** Every documented example pairs them (`sbx run --template my-org/my-template:v1 claude`). Radial never starts that agent — the harness runs through `sbx exec` — so it only names the sandbox's own default entrypoint; `run.sandbox.agent` overrides it, and it defaults to `claude`. *Closed by:* the smoke test's create step. 2. **The exec step can write symlinks at `/` inside the VM.** Kits install with `apt-get`, which implies a root-capable setup path, but the user `sbx exec` runs as is not documented. *Closed by:* the smoke test, which `test -f /bundle/brief.md` before using it. 3. **Create-time env vs. exec-session env.** Sidestepped entirely (§4.1) rather than resolved. 4. **`sbx ls` output shape.** `parseSandboxList` skips a header row and takes the first column, tolerant of added columns by construction, and is the single place the CLI's human output leaks into Radial. *Closed by:* the smoke test's `listByLabel` assertions. ### Manual verification checklist Undischarged. Each line is dated and initialled here when a human has observed it, in the spirit of `docs/adr-private-mode-iroh.md` §9's 2026-08-05 note. - [ ] `RADIAL_SANDBOX_TESTS=1 node --test packages/daemon/test/sandbox-smoke.test.mjs` passes: one fake-harness turn end to end, terminal record written, `sbx ls` empty afterwards. - [ ] An implementation turn inside a sandbox runs `docker build` successfully — motivation 2 **observed**, not assumed. - [ ] A daemon killed mid-turn leaves a sandbox, and `radiald run` removes it at restart. - [ ] No secret survives in any sandbox after a normal turn (`sbx ls` empty is the proxy for this; confirm directly at least once). - [ ] A check run (not just a turn) completes under the sandbox runtime. ## 9. Risks - **`sbx` is young and Docker-account-gated.** The CLI surface may shift, and `sbx login` implies a Docker account. Mitigated by the feature being opt-in, the default path being untouched, and every invocation going through a pure builder. - **Secrets persist in a VM that outlives the process.** A crash between create and rm leaves a disk holding a model key. Mitigated by `rm` in `finally`, startup reconciliation, and the spend-capped-key posture design §13 already takes. - **Per-turn microVM boot latency and disk churn.** Acceptable for v1 — turns are minutes long. Sandbox pooling is explicitly out of scope: it reintroduces the shared mutable substrate the per-turn `--rm` discipline exists to prevent, and would need its own hygiene argument. - **Platform coverage.** Docker Desktop only, so plain-Linux-Engine hosts — today's production posture — cannot use this at all. It is a runtime Radial *offers*; it must not become the recommended one in the docs until that constraint lifts. ## 10. Rejected alternative **Sandbox as a remote Docker engine.** Create one long-lived sandbox, point the existing `DockerRunner` at its inner daemon via `DOCKER_HOST`, and keep `dockerRunArgs` — a VM boundary around the same hardened containers, with the microVM boot cost amortised across turns. Rejected as the primary shape because it denies the agent the inner Docker daemon (motivation 2 dies: the turn is still a `--cap-drop=ALL` container, now one VM deeper), doubles the socket-reachability problem (inner container → VM → host), and makes the long-lived sandbox a shared mutable substrate across turns — exactly what the per-turn `--rm` discipline exists to prevent. It remains the documented fallback if §8's assumptions collapse in a way the exec step cannot bridge.