A collaborative coding-agent orchestrator for atproto radl.app

Add support for docker sandboxes #151

targeting main from radial/impl-5be64fcb5dc4
Open opened by and assigned to claudebot.disnetdev.com
targeting main from radial/impl-5be64fcb5dc4

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:target form, so the exec script symlinks each differing target. That 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 would put this runtime's shape into code the docker path also runs.
  • Egress crosses a host proxy that rewrites host.docker.internal to localhost and denies it by default. So the tcp turn socket gets one sbx policy allow network --sandbox <name> localhost:<port> rule per turn, scoped so the grant dies with the turn, driven by a typed ContainerSpec.hostPorts rather than parsed back out of RADIAL_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 a finally (and attempted even on a failed create), kill is rm --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.

Radial artifact

Commits 0