Docker sandbox turn runtime #
run.containerRuntime: "sandbox" runs each turn and check in a Docker sandbox microVM through
the sbx CLI instead of a hardened container — a hypervisor boundary instead of a shared kernel,
and a full inner Docker daemon that --cap-drop=ALL can never offer. It is opt-in, Docker
Desktop only, and the default path is byte-identical without it.
Branch radial/impl-5be64fcb5dc4 · Commit 2c75ef62c75328d5633eb72f922208977459f030 ·
base main @ 2470b3e
What landed #
packages/daemon/src/sandbox.ts — SandboxRunner implements ContainerRunner. Because
runTurn, the check runner and both orphan reconcilers were already written against the interface,
the substitution is the feature: nothing downstream changed. Every sbx invocation is a pure
exported builder (sbxCreateArgs, sbxExecScript, sbxExecArgs, sbxPolicyArgs,
sbxRemoveArgs, parseSandboxList), for the reason dockerRunArgs is — sbx exists in neither CI
nor this development environment, so the builders and an injected exec seam are what is testable —
and because Docker documents the CLI's surface as subject to change.
Three facts about sbx shape the file:
- A workspace passes through at its host path. There is no
source:targetform, so the exec script symlinks each differing target. That is what letsturn.ts, the harness prompts, the check orchestrator and the tangled adapter'sGIT_SSH_COMMANDkeep naming/work,/bundle,/checksand/run/radial-forgeunder both runtimes — the alternative would put this runtime's shape into code the docker path also runs. - Egress crosses a host proxy that rewrites
host.docker.internaltolocalhostand denies it by default. So the tcp turn socket gets onesbx policy allow network --sandbox <name> localhost:<port>rule per turn, scoped so the grant dies with the turn, driven by a typedContainerSpec.hostPortsrather than parsed back out ofRADIAL_SIDECAR_SOCKET. - A sandbox persists until
sbx rm, holding the turn's model key and forge grant on its VM disk. Removal is therefore unconditional in afinally(and attempted even on a failed create),killisrm --force, and startup orphan reconciliation matters more here than under docker.
The turn's environment travels on sbx exec's stdin, not -e flags: no secret reaches either
process list, and it sidesteps an undocumented question (whether create-time variables survive into
an exec session) rather than betting a turn on it.
Config — run.containerRuntime, run.sandbox (binary/template/agent/kits/
createArgs), run.turnSocketHost. auto resolves to tcp under this runtime on every platform,
and the bind host defaults to 127.0.0.1 there (the connection is the host proxy's, so loopback is
sufficient and tighter). Four combinations are refused at parse time because each can only fail an
hour into a turn: sandbox × turnTransport: "unix", × run.network, × run.codexAuth, and
run.sandbox without the runtime switch. radiald run preflights sbx at startup rather than at
the first claim it wins.
Docs — docs/adr-docker-sandboxes.md (new), plus design.md §13, radial-json.md,
running-an-agent.md, packages/daemon/README.md and CLAUDE.md.
Checks #
pnpm lint and pnpm test green — 1391 tests, 0 failures, 1 skipped (the gated sandbox smoke
suite). New coverage: sandbox.test.mjs (18), sandbox cases in config.test.mjs (4) and
reconcile.test.mjs (3, driving the real SandboxRunner against a scripted sbx, including
the two-instances non-interference case), and the gated sandbox-smoke.test.mjs.
container.test.mjs is deliberately unchanged: an operator who sets nothing gets the same
dockerRunArgs output as before.
Two deviations from the plan, both deliberate #
The gating spike was discharged from Docker's documentation, not from a machine. This
environment has neither Docker Desktop nor sbx. The plan's stop condition named two items, and
both turned out to be documented: env passing (-e K=V, -e K, --env-file) and VM→host TCP
reachability (host.docker.internal → localhost rewrite, non-HTTP TCP allowable by rule,
sbx policy allow network localhost:<port>). So the design does not fall back to the rejected
alternative. Four smaller facts remain assumed and are listed in ADR §8 with what closes each: the
sbx create agent positional, whether the exec step can symlink at / inside the VM, sbx ls
output shape, and per-sandbox memory sizing.
memory is superseded rather than refused. The plan said to refuse a spec carrying memory if
no mapping existed. Refusing would make the runtime unusable — turn.ts and check-runner.ts both
always set it. The obligation (turn.ts:154, "a turn must never run with unbounded memory") is
about the host, which a container shares and a microVM does not: a VM is created with a fixed RAM
allocation it cannot exceed, so it is discharged by construction. Sizing that allocation per sandbox
is undocumented; run.sandbox.createArgs is the escape hatch. Argued in ADR §4.
Remaining risk #
Nothing here has been run against a real sbx. The manual checklist in ADR §8 is undischarged —
including the one that matters most for the feature's own motivation, that an implementation turn
inside a sandbox really can docker build. RADIAL_SANDBOX_TESTS=1 node --test packages/daemon/test/sandbox-smoke.test.mjs on a Docker Desktop machine closes most of it in one
run; the ADR says what each line proves. Also: sbx is young and Docker-account-gated, so a CLI
re-spelling is likely — which is why every invocation goes through one pure builder with a test
saying what its argv means.