From 2c75ef62c75328d5633eb72f922208977459f030 Mon Sep 17 00:00:00 2001 From: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) Date: Wed, 19 Aug 2026 16:44:25 +0000 Subject: [PATCH] Add a Docker sandbox (microVM) turn runtime behind ContainerRunner `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 the `--cap-drop=ALL` path can never offer. It is one substituted `ContainerRunner`, so `runTurn`, the check runner and both orphan reconcilers are unchanged, and the default docker path is untouched. Every `sbx` invocation is a pure exported builder — `sbx` exists in neither CI nor this development environment, so the builders plus an injected exec seam are what the tests exercise; `sandbox-smoke.test.mjs` is gated for an operator machine. Three facts about `sbx` shape the runner: a workspace passes through at its host path (so the exec script symlinks `/work`, `/bundle`, `/checks`, `/run/radial-forge`); egress crosses a host proxy that rewrites `host.docker.internal` to `localhost` and denies it by default (so the tcp turn socket gets one per-sandbox policy rule, from a typed `ContainerSpec.hostPorts`); and a sandbox persists until `sbx rm` holding the turn's model key on its VM disk (so removal is unconditional in a `finally`). Config refuses `sandbox` beside `turnTransport: "unix"`, `run.network` and `run.codexAuth` at parse time, since each can only fail an hour into a turn, and `radiald run` preflights `sbx` at startup rather than at the first claim it wins. `docs/adr-docker-sandboxes.md` carries the argument, the spec-mapping table, and what is documented versus still assumed — the plan's spike was discharged from Docker's own docs, not from a machine, and the manual checklist is undischarged. Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) --- CLAUDE.md | 21 ++++++++++++++++++++- docs/adr-docker-sandboxes.md | 225 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/design.md | 2 ++ docs/radial-json.md | 37 +++++++++++++++++++++++++++++++++++-- docs/running-an-agent.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++- packages/daemon/README.md | 39 ++++++++++++++++++++++++++++++++++++++- packages/daemon/src/cli.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++------ packages/daemon/src/config.ts | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/daemon/src/container.ts | 14 ++++++++++++-- packages/daemon/src/dispatch.ts | 3 +++ packages/daemon/src/index.ts | 1 + packages/daemon/src/node-shims.d.ts | 4 ++++ packages/daemon/src/sandbox.ts | 422 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/daemon/src/turn.ts | 18 +++++++++++++++--- packages/daemon/test/config.test.mjs | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/daemon/test/reconcile.test.mjs | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- packages/daemon/test/sandbox-smoke.test.mjs | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/daemon/test/sandbox.test.mjs | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 18 file(s) changed, 1492 insertion(s)(+), 17 deletion(s)(-) diff --git a/CLAUDE.md b/CLAUDE.md --- a/CLAUDE.md +++ b/CLAUDE.md @@ -299,6 +299,25 @@ anchoring, signing, and PDS write. `packages/daemon/README.md` covers claims, forges, credentials, transports, and turn types. +**What kind of box that is, is one substituted `ContainerRunner` and nothing else** (`sandbox.ts`, +`docs/adr-docker-sandboxes.md`). `run.containerRuntime: 'sandbox'` runs each turn and check in a +Docker sandbox microVM through the `sbx` CLI — a hypervisor boundary instead of a shared kernel, and +an inner Docker daemon the `--cap-drop=ALL` path can never offer — and `runTurn`, the check runner +and both orphan reconcilers consume it unchanged, because they were already written against the +interface. Docker Desktop only, opt-in, and the default path is byte-identical without it. Three +facts about `sbx` shape the file: a workspace passes through at its **host** path (so the exec +script symlinks `/work`, `/bundle`, `/checks`, `/run/radial-forge` rather than every caller learning +a runtime-dependent path); egress crosses a host proxy that rewrites `host.docker.internal` to +`localhost` and denies it by default (so the tcp turn socket gets one per-sandbox +`sbx policy allow network localhost:` rule, from a typed `ContainerSpec.hostPorts` rather than +a parsed env var); and a sandbox **persists until `sbx rm`** holding the turn's model key on its VM +disk, which is why removal is unconditional in a `finally` and why orphan reconciliation matters +more here than under docker. Every `sbx` invocation is a pure exported builder — `sbx` exists in no +CI and in no development environment here, so the builders and an injected exec seam are the whole +of what is testable; the real paths are `sandbox-smoke.test.mjs`, gated and undischarged. Config +refuses `sandbox` beside `turnTransport: 'unix'`, `run.network` and `run.codexAuth` at parse time, +since each can only fail an hour into a turn. + Forges are a **registry keyed by the project's `gitUrl` host** (`forge.ts`), not one global switch: a space may hold a GitHub project and a tangled project at once. GitHub is observation-only (turns push and open PRs themselves with `gh`); tangled pushes over ssh and the *daemon* opens the pull @@ -371,6 +390,6 @@ | `docs/design.md` | The protocol design; source comments cite its section numbers | | `docs/plan.md` | Phased implementation plan and what is out of scope | | `docs/running-an-agent.md`, `docs/operators.md`, `docs/radial-json.md` | Operator walkthrough, multi-operator concerns, full `radial.json` reference | -| `docs/adr-*.md` | Decision records (tangled forge, claim leases as durations, private mode over iroh) | +| `docs/adr-*.md` | Decision records (tangled forge, claim leases as durations, private mode over iroh, Docker sandboxes) | | `PRODUCT.md`, `DESIGN.md` | Who this is for; the UI design system as shipped | | `packages/*/README.md` | Per-package detail — `ui`'s maps every `src/lib` module | diff --git a/docs/adr-docker-sandboxes.md b/docs/adr-docker-sandboxes.md new file mode 100644 --- /dev/null +++ b/docs/adr-docker-sandboxes.md @@ -0,0 +1,225 @@ +# 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` | **Superseded**, and this one deserves its argument. `turn.ts` floors it at `4g` because "a turn must never run with unbounded memory" — an obligation about the *host*, which a container shares. A microVM is created with a fixed RAM allocation it cannot exceed, so the obligation is discharged by construction. Sizing that allocation per sandbox is not documented; `run.sandbox.createArgs` is the escape hatch until it is. **This is a deliberate deviation from the plan**, which said to refuse a spec carrying `memory` if no mapping existed; refusing would make the runtime unusable, since `turn.ts` and `check-runner.ts` both always set it. | +| `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. +5. **Per-sandbox memory sizing.** Undocumented; superseded by the fixed VM allocation (§4), with + `run.sandbox.createArgs` as the escape hatch. + +### 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. diff --git a/docs/design.md b/docs/design.md --- a/docs/design.md +++ b/docs/design.md @@ -311,6 +311,8 @@ - **Public coordination.** Everything on-protocol is world-readable. Current stance (decided): Radial is for development that can be coordinated in the open (the code itself can still live in a private repo — records leak plans/findings/paths, not file contents, but treat that as public too in practice). Permissioned/private spaces are a long-term goal with no near-term work: when atproto private state matures, membership already gives us the trust boundary to hang it on. - **Protocol credentials never enter containers.** atproto signing stays daemon-side behind the sidecar socket, which is the only harness → protocol write path, and a turn can only emit its requested type. This is the non-negotiable invariant: an agent's atproto identity is unscopeable, and revoking it costs the space its coordination history. Everything else below is scopeable and expendable, and is treated accordingly. - **Containers are a possession boundary, not a network boundary.** A turn container holds exactly two secrets: the operator's forge token (§10) — their own GitHub auth, or a repo-scoped fine-grained PAT if they want scoping — and a spend-capped model API key dedicated to agent turns, for whichever provider the profile's models name (the daemon forwards only credential names its harnesses declare, plus an explicit operator allowlist, and only when set in its own environment; a daemon-side model proxy that would keep the key out entirely — and give per-turn metering — is a possible later upgrade, not v1). Egress is open: agents fetch docs, packages, and arbitrary web resources. Network confinement was never protecting the contents — the repo and bundle are public by stance — and an allowlist taxes every legitimate lookup. What remains is **host isolation**: containers run on an ordinary bridge network (never host network), the sidecar socket is the only daemon-facing surface, and cloud metadata endpoints are blocked when deployed on cloud infra. Check containers get network too (dependency installs need it) but carry no secrets at all. + + Host isolation is the one part of this an operator may choose to strengthen. `run.containerRuntime = "sandbox"` runs each turn and check in a Docker sandbox **microVM** — its own kernel, so a kernel exploit in a turn no longer reaches the daemon host — and hands the turn a full inner Docker daemon, which `--cap-drop=ALL` makes impossible. Nothing above changes: the sidecar socket is still the only daemon-facing surface, the turn still holds exactly a forge token and a model key, and the requested-type-only guard is untouched. It is a substituted `ContainerRunner` and no protocol change at all. `docs/adr-docker-sandboxes.md` is the argument, including what each container hardening flag maps to under a hypervisor boundary, and why a sandbox — unlike `docker run --rm` — must be removed unconditionally, since its VM disk holds the turn's credentials until it is. - **ChatGPT-managed Codex auth is an explicit stronger-credential mode.** `run.codexAuth.mode = "chatgpt-session"` replaces the spend-capped Codex API key with a refreshable account login for operators whose Codex entitlement lives in ChatGPT. A fresh device login belongs to one Radial instance; the operator's ordinary `~/.codex` is never copied or mounted. Each turn receives a scratch auth home, turns sharing it are serialized, and only a validated refreshed `auth.json` is atomically written back. Everything else in that home is discarded. This preserves cross-turn isolation, but not possession isolation: the running turn can read the account credential, so the mode is for trusted workloads and is never automatic. - **Prompt injection:** untrusted input is unbounded — member records in the bundle, repo contents, and anything the agent reads on the open web. The mitigation is entirely write-side. The worst an injected turn can do is waste its capped spend, leak its forge token, and spam forge writes within that token's forge-enforced scope — all attributable and revocable at the forge. The leaked token is the operator's own (§10), so the damage bound is branch protection plus the human merge, and the remedy is operator-side revocation; the deferred App tier (§10) shrinks the leak to an hour-lived scoped token when it lands. It cannot touch the protocol except through the typed, requested-type-only sidecar path (so it can't commission new work, even though the protocol itself wouldn't reject an agent-authored request, §3), and it cannot merge. Everything an agent writes is attributable to its DID and revocable with its membership. Review and the human merge remain the behavioral mitigation. diff --git a/docs/radial-json.md b/docs/radial-json.md --- a/docs/radial-json.md +++ b/docs/radial-json.md @@ -120,14 +120,47 @@ | `forges` | unset | The same setting as a **list**, for a space that holds projects on more than one forge. Each project is routed to an adapter by the host of its `gitUrl`. `forge` and `forges` are two spellings of one setting; keep one. | | `mergePollIntervalMs` / `mergePollBackoffMaxMs` | 60 s / 30 min | Per-PR merge polling base interval and backoff cap. | | `privateAddressRefreshMs` | 60 s | How often `radiald run` checks whether its private transport endpoint has moved — a machine changing network, a VPN coming up, a relay failover — and republishes `deviceAddress` when it has. The check is a local comparison and reaches a PDS only when the address actually changed, so this is a poll rate rather than a write rate. Inert without `privateSpaces`. | -| `network` | Docker's default bridge | Docker network for turn containers. Host and `container:` networking are refused. | +| `network` | Docker's default bridge | Docker network for turn containers. Host and `container:` networking are refused. Refused outright under `containerRuntime: "sandbox"`, which has no docker network to join. | +| `containerRuntime` | `docker` | What kind of box a turn and a check run in. `docker` is the shipped path. `sandbox` runs each one in a [Docker sandbox](https://docs.docker.com/ai/sandboxes/) microVM through the `sbx` CLI — a hypervisor boundary instead of a shared kernel, and a full inner Docker daemon for the turn. Docker Desktop only, and it needs an authenticated `sbx` plus the turn image loaded as a template; `radiald run` checks both at startup. See `docs/adr-docker-sandboxes.md`. | +| `sandbox` | unset | Sandbox-runtime knobs, and refused unless `containerRuntime` is `"sandbox"`. See below. | | `modelEnv` | `[]` | Extra environment variable **names** forwarded from the daemon's own environment into every turn container, on top of what the loaded profiles' harnesses already declare. See "Provider credentials" below. | | `codexAuth` | unset | `{ "mode": "chatgpt-session" }` opts Codex profiles into a dedicated, refreshable ChatGPT login under the instance data directory. Run `radiald codex login` once. Conflicting `CODEX_API_KEY` / `CODEX_ACCESS_TOKEN` variables are then refused rather than allowed to override it. | | `gitSchemes` | `["https"]` | Git remote schemes a turn may clone from. | | `memory` | `4g` floor | Container memory limit (`--memory` syntax). Overrides the floor upward; never below it. | -| `turnTransport` | `auto` | Selects Unix sockets on Linux and TCP on macOS/Windows. Explicit `unix` and `tcp` override detection; TCP is a development escape hatch—do not expose the daemon socket. | +| `turnTransport` | `auto` | Selects Unix sockets on Linux and TCP on macOS/Windows. Explicit `unix` and `tcp` override detection; TCP is a development escape hatch—do not expose the daemon socket. Under `containerRuntime: "sandbox"` `auto` is TCP on every platform (a microVM never shares a host Unix socket), and an explicit `unix` there is refused at parse time rather than producing ECONNREFUSED an hour into a turn. | +| `turnSocketHost` | `0.0.0.0`, or `127.0.0.1` under `containerRuntime: "sandbox"` | The interface the TCP turn socket binds. A container's connection arrives over the docker bridge, which loopback would refuse; a sandbox's is made by the host egress proxy, which has already rewritten `host.docker.internal` to `localhost`, so loopback is both sufficient and tighter there. Every connection is authenticated by the per-turn token regardless — this is defense in depth, not a gate. | | `claims` | see below | Claim and lease timing for **open (unassigned)** requests. Only matters in a space that uses them; a daemon serving assigned requests never writes a claim. | | `jetstream` | unset | Opt-in [Jetstream](https://github.com/bluesky-social/jetstream) ingestion. Polling stays the authority and the backfill, so this only shortens latency. | + +#### `run.sandbox` + +Only read under `containerRuntime: "sandbox"`, and refused when it is set without it — a settings +block that silently does nothing is worse than a startup error naming the switch that would turn it +on. Every field is optional: with none of them set, a sandbox is created from `run.image` as its +template. + +Getting there once, on a Docker Desktop machine: + +```sh +pnpm images # or however you build the turn image +docker image save radial-turn:latest -o /tmp/rt.tar +sbx template load /tmp/rt.tar # a local image is not reachable from a registry +sbx login # interactive; must happen out of band +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `template` | `run.image` | Template image the sandbox is created from. It must already be in the sandbox image store (`sbx template load`), not merely in Docker's. | +| `agent` | `claude` | The agent positional `sbx create` takes beside `--template`. Radial never starts it — the harness runs through `sbx exec` — so this only names the sandbox's own default entrypoint. It is a name `sbx` accepts, unrelated to Radial's `harness`. | +| `kits` | `[]` | `--kit` entries, stacked in order: install steps, files and network rules layered on the template. A path, a `git+https://…` URL or an OCI reference. | +| `createArgs` | `[]` | Extra `sbx create` flags, passed verbatim. The escape hatch for a CLI Docker documents as subject to change — and, today, the only way to size a sandbox's RAM. | +| `binary` | `sbx` | The executable, for a machine that installed it elsewhere. | + +The turn socket needs one egress rule, and the daemon writes it per turn rather than asking you to: +`sbx policy allow network --sandbox localhost:`, scoped to that sandbox so the grant +dies with the turn. It names `localhost` because the host proxy rewrites `host.docker.internal` +before forwarding. A kit that sets its own proxy variables will break this; leave those to the +sandbox. #### `run.claims` diff --git a/docs/running-an-agent.md b/docs/running-an-agent.md --- a/docs/running-an-agent.md +++ b/docs/running-an-agent.md @@ -7,7 +7,8 @@ ## Prerequisites -- **Docker.** Turns run in containers. +- **Docker.** Turns run in containers. (On Docker Desktop they can run in + microVMs instead — see "Running turns in a microVM" under "Run".) - **A model credential** for whichever harness your profiles use — dedicated and spend-capped, since the daemon forwards it into turn containers. - **`gh auth login`**, if implementation turns will push branches and open PRs @@ -206,6 +207,51 @@ interrupted attempts. `radiald turn list` prints the ledger; if a request exhausts its retry limit or needs to be retried immediately, run `radiald turn reset `. + +### Running turns in a microVM instead of a container + +Everything above runs turns as hardened Docker containers, which is the +recommended posture and the only one that works on a plain Linux Engine host. +On **Docker Desktop** you can run each turn in a +[Docker sandbox](https://docs.docker.com/ai/sandboxes/) instead — a microVM +with its own kernel, so a kernel exploit inside a turn no longer reaches your +machine, and with a full inner Docker daemon, so an implementation turn (or a +check) can build and run containers, which `--cap-drop=ALL` makes impossible. + +It is opt-in and needs setting up once. `sbx login` is interactive, and a +locally built image has to be loaded into the sandbox image store — it is not +reachable from a registry: + +```sh +sbx login +pnpm images +docker image save radial-turn:latest -o /tmp/radial-turn.tar +sbx template load /tmp/radial-turn.tar +``` + +Then set the runtime in `radial.json`: + +```json +"run": { + "containerRuntime": "sandbox" +} +``` + +`radiald run` checks `sbx` at startup and refuses to start if it cannot +dispatch, rather than failing at the first request it picks up. It also +writes the one egress rule a turn needs (the sandbox reaches the daemon over +TCP, and a sandbox's default policy denies that) scoped to each sandbox, and +removes every sandbox when its turn ends — unlike `docker run --rm`, nothing +else reclaims one, and its disk holds that turn's model key until it goes. + +Three settings are refused alongside it, at startup rather than an hour into +a turn: `run.turnTransport: "unix"` (a microVM cannot connect to a host Unix +socket), `run.network` (there is no Docker network to join — a sandbox's +egress is governed by `sbx policy`), and `run.codexAuth`. `run.sandbox` +carries the template, kit and agent overrides; +[`docs/radial-json.md`](radial-json.md) is the reference and +[`docs/adr-docker-sandboxes.md`](adr-docker-sandboxes.md) the reasoning, +including what has and has not been verified on a real machine. ## Another harness, another provider diff --git a/packages/daemon/README.md b/packages/daemon/README.md --- a/packages/daemon/README.md +++ b/packages/daemon/README.md @@ -109,7 +109,44 @@ where VM-backed Docker cannot connect to a bind-mounted host Unix socket. The TCP path binds an ephemeral, token-gated server on all host interfaces so Docker can reach it; firewall it, never expose it to a LAN, and do not use it for production deployment. Explicit `unix` and `tcp` values -override platform detection. +override platform detection, and `run.turnSocketHost` narrows the interface it binds. + +## Container runtimes + +`run.containerRuntime` selects what kind of box a turn runs in. It is one setting, resolved once, +and the runner instance it produces is shared by turn dispatch, check dispatch and both startup +orphan reconcilers. + +| | `docker` (default) | `sandbox` | +| --- | --- | --- | +| boundary | shared kernel, `--cap-drop=ALL`, non-root, read-only rootfs, pids and memory caps | a microVM: its own kernel, its own filesystem, a fixed RAM allocation | +| Docker inside the turn | impossible | yes — a full inner Docker daemon | +| turn transport | `unix` on Linux, `tcp` on Docker Desktop | always `tcp`, bound to `127.0.0.1` | +| egress | the docker network (`run.network`) | the host policy proxy — `sbx policy`, and `run.network` is refused | +| cleanup | `docker run --rm` | `sbx rm --force`, unconditionally, in a `finally` | +| requires | Docker Engine | Docker Desktop, an authenticated `sbx`, and the template loaded | + +`sandbox` is opt-in and Docker-Desktop-only, so it is not the recommended posture; with no new key +set, nothing about the docker path changes. To use it: + +```sh +pnpm images # builds radial-turn:latest +docker image save radial-turn:latest -o /tmp/rt.tar +sbx template load /tmp/rt.tar # a local image is not reachable from a registry +sbx login # interactive, once, out of band +``` + +then `"run": { "containerRuntime": "sandbox" }`. `radiald run` checks `sbx` at startup and refuses +to start if it cannot dispatch, rather than failing at the first claim it wins. `run.sandbox` +carries `binary`, `template`, `agent`, `kits` and `createArgs`; `docs/radial-json.md` is the +reference and `docs/adr-docker-sandboxes.md` the argument, including what each `docker run` flag +maps to (or is superseded by), why the turn's environment travels on `sbx exec`'s stdin, and the +per-sandbox `sbx policy allow network localhost:` rule the turn socket needs. + +Three combinations are refused at config-parse time because they can only fail an hour into a turn: +`sandbox` with `turnTransport: "unix"` (a microVM cannot connect to a host Unix socket), with +`run.network` (there is no docker network to join), and with `run.codexAuth` (managed Codex auth +needs mounts a sandbox's passthrough workspaces cannot express). ## Turn types diff --git a/packages/daemon/src/cli.ts b/packages/daemon/src/cli.ts --- a/packages/daemon/src/cli.ts +++ b/packages/daemon/src/cli.ts @@ -47,6 +47,7 @@ configuredForges, type ClaimsConfig, type DaemonRunConfig, + type DaemonSandboxConfig, type Environment, type ForgeConfig, type PrivateSpaceConfig, @@ -67,6 +68,7 @@ import { runCheckRun, type CheckRunInput } from './check-runner.js' import { CodexAuthStore, loginManagedCodex } from './codex-auth.js' import { DockerRunner, type ContainerRunner } from './container.js' +import { SandboxRunner, sandboxPreflight } from './sandbox.js' import { TurnDispatcher } from './dispatch.js' import { ForgeRegistry, type ForgeAdapter, type PullStateContext } from './forge.js' import { GitHubForge } from './forge-github.js' @@ -264,6 +266,14 @@ codexAuth?: { mode: 'chatgpt-session' } /** The resolved transport: `auto` has already become `unix` or `tcp` here. */ turnTransport: 'unix' | 'tcp' + /** The resolved bind interface for the tcp turn socket. See `DaemonRunConfig.turnSocketHost`. */ + turnSocketHost: string + /** The resolved isolation runtime. `docker` unless the operator opted into sandboxes. */ + containerRuntime: 'docker' | 'sandbox' + /** Sandbox knobs, always present (possibly empty) so the runner can be constructed from it + * directly. Inert under `containerRuntime: 'docker'`, which config validation already refuses to + * pair with a `run.sandbox` block. */ + sandbox: DaemonSandboxConfig gitSchemes: string[] /** Container memory limit (docker `--memory` syntax, e.g. `4g`). No default at this layer — * `turn.ts` supplies the `4g` floor when unset (a turn must never run unbounded). */ @@ -310,12 +320,20 @@ ): ResolvedRunConfig { const configuredTransport = run.turnTransport ?? 'auto' const platform = options.platform ?? process.platform + const containerRuntime = run.containerRuntime ?? 'docker' + // `auto` under the sandbox runtime is tcp on every platform, not just the VM-backed ones: a + // microVM never shares the host's AF_UNIX socket, so the platform question does not arise. + // (`config.js` has already refused an explicit `unix` here.) const turnTransport = configuredTransport === 'auto' - ? platform === 'darwin' || platform === 'win32' + ? containerRuntime === 'sandbox' || platform === 'darwin' || platform === 'win32' ? 'tcp' : 'unix' : configuredTransport + // A sandbox's connection is made by the host-side egress proxy, which has already rewritten + // `host.docker.internal` to `localhost` — so loopback is both sufficient and tighter. Docker's + // connection arrives over the bridge instead, where loopback would refuse it. + const turnSocketHost = run.turnSocketHost ?? (containerRuntime === 'sandbox' ? '127.0.0.1' : '0.0.0.0') if (run.network?.toLowerCase() === 'host' || /^container:/i.test(run.network ?? '')) throw new Error('run.network must not use host or container networking') const mergePollInterval = Math.max(1, run.mergePollIntervalMs ?? 60_000) return { @@ -339,6 +357,9 @@ modelEnv: run.modelEnv ?? [], ...(run.codexAuth !== undefined ? { codexAuth: run.codexAuth } : {}), turnTransport, + turnSocketHost, + containerRuntime, + sandbox: run.sandbox ?? {}, gitSchemes: run.gitSchemes ?? ['https'], ...(run.memory !== undefined ? { memory: run.memory } : {}), checkConcurrency: run.checkConcurrency ?? 1, @@ -1030,11 +1051,27 @@ ) for (const spaceUri of run.spaces) console.log(` polling ${spaceUri}`) - if (run.turnTransport === 'tcp') { - console.warn( - '⚠️ turn transport = tcp (dev mode): turns reach the daemon over TCP on host.docker.internal, ' + - 'this is a development transport; do not expose the daemon socket.', + if (run.containerRuntime === 'sandbox') { + console.log( + `container runtime: sandbox (sbx microVM${run.sandbox.template ? `, template ${run.sandbox.template}` : ''}` + + `${(run.sandbox.kits ?? []).length > 0 ? `, kits ${(run.sandbox.kits ?? []).join(', ')}` : ''}) — ` + + `turn socket ${run.turnSocketHost}, egress governed by \`sbx policy\``, ) + } + if (run.turnTransport === 'tcp') { + // Under the sandbox runtime tcp is the only transport a microVM can use and is a supported + // path, not a development escape hatch — the warning would be misinformation there. + if (run.containerRuntime === 'sandbox') { + console.log( + `turn transport = tcp on ${run.turnSocketHost}: each sandbox reaches it at host.docker.internal, ` + + 'which the host proxy rewrites to localhost under a per-sandbox policy rule.', + ) + } else { + console.warn( + '⚠️ turn transport = tcp (dev mode): turns reach the daemon over TCP on host.docker.internal, ' + + 'this is a development transport; do not expose the daemon socket.', + ) + } } await mkdir(run.stateDir, { recursive: true }) @@ -1088,7 +1125,17 @@ // Its own file: claims and turns are independent workstreams with independent lifecycles, and a // daemon that restarts mid-lease reads this to renew rather than claim twice. const claimLedger = new ClaimLedger(join(run.stateDir, 'claim-ledger.db')) - const runner = new DockerRunner() + // One runner instance for turns, checks and both orphan reconcilers — the substitution is the + // whole of the sandbox runtime (docs/adr-docker-sandboxes.md). + let runner: ContainerRunner + if (run.containerRuntime === 'sandbox') { + const problems = await sandboxPreflight(run.sandbox) + // Fail-fast: a daemon that cannot dispatch should say so now, not at the first claim it wins. + if (problems.length > 0) throw new Error(problems.join('\n')) + runner = new SandboxRunner(run.sandbox, (message) => console.log(message)) + } else { + runner = new DockerRunner() + } // Startup orphan reconciliation: any row still "running" belongs to a prior process that died // (or was killed) mid-turn — its container (if any survived) is killed and the row transitioned @@ -1196,6 +1243,7 @@ allowedSchemes: run.gitSchemes, ...(run.memory !== undefined ? { memory: run.memory } : {}), turnTransport: run.turnTransport, + turnSocketHost: run.turnSocketHost, instance: instanceLabel, ...(forge ? { forge } : {}), forges, diff --git a/packages/daemon/src/config.ts b/packages/daemon/src/config.ts --- a/packages/daemon/src/config.ts +++ b/packages/daemon/src/config.ts @@ -106,6 +106,19 @@ * and the TCP bridge on macOS/Windows, where VM-backed Docker cannot connect to a bind-mounted * host AF_UNIX socket. Explicit `unix`/`tcp` remain available as operator overrides. */ turnTransport?: 'auto' | 'unix' | 'tcp' + /** Interface the tcp turn socket binds. Defaults to `0.0.0.0` under the docker runtime (a + * container's connection arrives over the bridge, not the daemon's loopback) and to `127.0.0.1` + * under the sandbox runtime, where the connection is made by the host-side egress proxy after it + * rewrites `host.docker.internal` to `localhost`. Defense in depth only — every connection is + * already authenticated by the per-turn token. */ + turnSocketHost?: string + /** Which isolation runtime turns and checks run in. `docker` (the default) is the shipped path. + * `sandbox` runs each turn in a Docker sandbox microVM via the `sbx` CLI — a hypervisor boundary + * instead of a shared kernel, and a full inner Docker daemon for the turn + * (`docs/adr-docker-sandboxes.md`). Docker Desktop only. */ + containerRuntime?: 'docker' | 'sandbox' + /** Sandbox-runtime knobs. Only meaningful under `containerRuntime: 'sandbox'`. */ + sandbox?: DaemonSandboxConfig gitSchemes?: string[] /** Container memory limit (docker `--memory` syntax, e.g. `4g`/`512m`). Unset means "use the * daemon's default floor" (see `turn.ts`, which floors at `4g` — a turn must never run @@ -142,6 +155,27 @@ export interface CodexAuthConfig { mode: 'chatgpt-session' +} + +/** + * `containerRuntime: 'sandbox'` knobs (`docs/adr-docker-sandboxes.md`). Every field is optional: + * with none of them set, a sandbox is created from `run.image` as its template. + */ +export interface DaemonSandboxConfig { + /** The `sbx` executable, when it is not on PATH under that name. */ + binary?: string + /** Template image, overriding `run.image`. A locally built image must be loaded into the sandbox + * image store first (`docker image save … | sbx template load`). */ + template?: string + /** The agent positional `sbx create` requires. Radial never starts it — the harness runs through + * `sbx exec` — so it only names the sandbox's own default entrypoint. Defaults to `claude`. */ + agent?: string + /** `--kit` entries, stacked in order, for install steps/files/network rules layered on the + * template. */ + kits?: string[] + /** Extra `sbx create` flags, passed verbatim. The escape hatch for a CLI whose surface Docker + * documents as subject to change. */ + createArgs?: string[] } /** One private space this daemon serves (design §18). */ @@ -318,6 +352,32 @@ throw new TypeError(`${where} must be "auto", "unix", or "tcp"`) } return value +} + +function containerRuntimeValue(value: unknown, where: string): 'docker' | 'sandbox' | undefined { + if (value === undefined) return undefined + if (value !== 'docker' && value !== 'sandbox') throw new TypeError(`${where} must be "docker" or "sandbox"`) + return value +} + +function sandboxValue(value: unknown, where: string): DaemonSandboxConfig | undefined { + if (value === undefined) return undefined + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(`${where} must be an object`) + } + const raw = value as Record + const binary = text(raw.binary, `${where}.binary`) + const template = text(raw.template, `${where}.template`) + const agent = text(raw.agent, `${where}.agent`) + const kits = strings(raw.kits, `${where}.kits`) + const createArgs = strings(raw.createArgs, `${where}.createArgs`) + return { + ...(binary !== undefined ? { binary } : {}), + ...(template !== undefined ? { template } : {}), + ...(agent !== undefined ? { agent } : {}), + ...(kits !== undefined ? { kits } : {}), + ...(createArgs !== undefined ? { createArgs } : {}), + } } function bool(value: unknown, where: string): boolean | undefined { @@ -513,6 +573,9 @@ const modelEnv = envNames(value.modelEnv, 'run.modelEnv') const codexAuth = codexAuthValue(value.codexAuth, 'run.codexAuth') const turnTransport = turnTransportValue(value.turnTransport, 'run.turnTransport') + const turnSocketHost = text(value.turnSocketHost, 'run.turnSocketHost') + const containerRuntime = containerRuntimeValue(value.containerRuntime, 'run.containerRuntime') + const sandbox = sandboxValue(value.sandbox, 'run.sandbox') const gitSchemes = strings(value.gitSchemes, 'run.gitSchemes') const memory = text(value.memory, 'run.memory') const checkConcurrency = num(value.checkConcurrency, 'run.checkConcurrency') @@ -527,6 +590,35 @@ const privateSpaces = privateSpacesValue(value.privateSpaces, 'run.privateSpaces') if (forge && forges) { throw new TypeError('run.forge and run.forges are two spellings of the same setting; keep one') + } + // The sandbox runtime's combinations, refused here rather than an hour into a turn. A microVM + // cannot share the host's AF_UNIX turn socket — the same fact that already forces tcp on Docker + // Desktop — so `unix` under this runtime can only ever produce ECONNREFUSED after the container + // has already been built, checked out and launched. + if (containerRuntime === 'sandbox') { + if (turnTransport === 'unix') { + throw new TypeError( + 'run.turnTransport "unix" cannot be used with run.containerRuntime "sandbox": a sandbox is a ' + + 'microVM and cannot connect to a host AF_UNIX socket; use "tcp" or leave it unset ("auto")', + ) + } + if (network !== undefined) { + throw new TypeError( + 'run.network cannot be used with run.containerRuntime "sandbox": a sandbox reaches the network ' + + 'through its host policy proxy, not a docker network (see run.sandbox.kits and `sbx policy`)', + ) + } + if (codexAuth !== undefined) { + throw new TypeError( + 'run.codexAuth cannot be used with run.containerRuntime "sandbox": managed Codex auth mounts a ' + + 'scratch home and an /etc/passwd entry keyed to the daemon\'s host uid, which a sandbox\'s ' + + 'passthrough workspaces cannot express', + ) + } + } else if (sandbox !== undefined) { + // A settings block that silently does nothing is worse than a startup error naming the switch + // that would turn it on. + throw new TypeError('run.sandbox is set but run.containerRuntime is not "sandbox"') } // A space's mode is fixed at creation (design §18), so listing one in both places is not a // preference the daemon can resolve: one of the two lists would silently win, and whichever it was @@ -554,6 +646,9 @@ ...(modelEnv !== undefined ? { modelEnv } : {}), ...(codexAuth !== undefined ? { codexAuth } : {}), ...(turnTransport !== undefined ? { turnTransport } : {}), + ...(turnSocketHost !== undefined ? { turnSocketHost } : {}), + ...(containerRuntime !== undefined ? { containerRuntime } : {}), + ...(sandbox !== undefined ? { sandbox } : {}), ...(gitSchemes !== undefined ? { gitSchemes } : {}), ...(memory !== undefined ? { memory } : {}), ...(checkConcurrency !== undefined ? { checkConcurrency } : {}), diff --git a/packages/daemon/src/container.ts b/packages/daemon/src/container.ts --- a/packages/daemon/src/container.ts +++ b/packages/daemon/src/container.ts @@ -20,6 +20,15 @@ extraHosts?: string[] workdir?: string timeoutMs: number + /** + * TCP ports on the daemon host this container must be able to reach — under the tcp turn + * transport, the turn socket's own port. Docker needs no rule for it (`extraHosts` plus the + * bridge already carry it), so `DockerRunner` ignores the field; a Docker sandbox brokers every + * outbound connection through a host proxy that denies localhost by default, so `SandboxRunner` + * turns each port into a sandbox-scoped policy rule. Declared on the spec rather than inferred + * from `env.RADIAL_SIDECAR_SOCKET` so the requirement is typed instead of parsed. + */ + hostPorts?: number[] tmpfs?: string[] pidsLimit?: number memory?: string @@ -55,8 +64,9 @@ return `/home/radial:uid=${uid},gid=${gid},mode=700` } -/** Keep the tail: command failures conventionally print their useful diagnosis last. */ -function appendCapturedOutput(existing: string, chunk: Uint8Array, decoder: TextDecoder): string { +/** Keep the tail: command failures conventionally print their useful diagnosis last. Exported for + * `sandbox.ts`, which captures its exec output under the identical bound. */ +export function appendCapturedOutput(existing: string, chunk: Uint8Array, decoder: TextDecoder): string { const combined = existing + decoder.decode(chunk) if (combined.length <= MAX_CAPTURED_OUTPUT) return combined return `[... output truncated; retaining last ${MAX_CAPTURED_OUTPUT} characters ...]\n${combined.slice(-MAX_CAPTURED_OUTPUT)}` diff --git a/packages/daemon/src/dispatch.ts b/packages/daemon/src/dispatch.ts --- a/packages/daemon/src/dispatch.ts +++ b/packages/daemon/src/dispatch.ts @@ -554,6 +554,8 @@ memory?: string /** Threaded into `TurnInput.turnTransport`. Unset lets `turn.ts` default to `unix`. */ turnTransport?: 'unix' | 'tcp' + /** Threaded into `TurnInput.turnSocketHost`. Unset lets `turn.ts` default to `0.0.0.0`. */ + turnSocketHost?: string /** This daemon instance's id (`instanceId(stateDir)`), mixed into every container label so two * daemons on one machine never derive the same docker name for one request. */ instance?: string @@ -872,6 +874,7 @@ allowedSchemes: this.#deps.allowedSchemes, ...(this.#deps.memory !== undefined ? { memory: this.#deps.memory } : {}), ...(this.#deps.turnTransport !== undefined ? { turnTransport: this.#deps.turnTransport } : {}), + ...(this.#deps.turnSocketHost !== undefined ? { turnSocketHost: this.#deps.turnSocketHost } : {}), ...(this.#deps.instance !== undefined ? { instance: this.#deps.instance } : {}), ...(checkoutRef !== undefined ? { checkoutRef } : {}), // `prev` is scope- and type-agnostic: any v2 artifact continues its predecessor's chain. Only diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -26,6 +26,7 @@ export * from './private-space.js' export * from './private-transport.js' export * from './runtime.js' +export * from './sandbox.js' export * from './state-lock.js' export * from './turn.js' export * from './turn-socket.js' diff --git a/packages/daemon/src/node-shims.d.ts b/packages/daemon/src/node-shims.d.ts --- a/packages/daemon/src/node-shims.d.ts +++ b/packages/daemon/src/node-shims.d.ts @@ -119,6 +119,10 @@ export interface ChildProcess { stdout: { on(event: 'data', listener: (chunk: Uint8Array) => void): void } | null stderr: { on(event: 'data', listener: (chunk: Uint8Array) => void): void } | null + stdin: { + end(data?: string): void + on(event: 'error', listener: (error: Error) => void): void + } | null on(event: 'close', listener: (code: number | null) => void): ChildProcess on(event: 'error', listener: (error: Error) => void): ChildProcess } diff --git a/packages/daemon/src/sandbox.ts b/packages/daemon/src/sandbox.ts new file mode 100644 --- /dev/null +++ b/packages/daemon/src/sandbox.ts @@ -0,0 +1,422 @@ +import { spawn } from 'node:child_process' +import { + appendCapturedOutput, + type ContainerRunResult, + type ContainerRunner, + type ContainerSpec, +} from './container.js' + +/** + * Docker sandboxes as a second turn-isolation runtime (`docs/adr-docker-sandboxes.md`). + * + * A sandbox is a microVM with its own kernel, its own Docker daemon and a host-side egress proxy, + * driven by the `sbx` CLI. `SandboxRunner` satisfies `ContainerRunner`, so `runTurn`, the check + * runner and startup orphan reconciliation consume it unchanged — the substitution is the whole + * design. Every `sbx` invocation is built by a pure exported function so a CLI that is still young + * can be re-spelled in one file, and so this module is unit-testable on a machine with no `sbx` + * (the same discipline `dockerRunArgs` follows). + * + * Three facts about `sbx` shape everything below: + * + * - **Workspaces pass through at the host path.** `sbx create … :ro` mounts + * each directory inside the VM at the same absolute path it has on the host; there is no + * `source:target` form. A `ContainerSpec` names targets (`/work`, `/bundle`, + * `/run/radial-forge`), so the exec step bridges them with symlinks — see `sbxExecScript`. + * - **Egress is brokered by a host proxy.** `host.docker.internal` is rewritten to `localhost` + * before forwarding, and the default policy denies localhost, so the tcp turn socket needs an + * explicit `sbx policy allow network --sandbox localhost:` rule. + * - **A sandbox persists until it is removed.** Unlike `docker run --rm`, nothing reclaims it, and + * its disk holds this turn's model key and forge grant. `run()` therefore removes it in a + * `finally`, unconditionally. + */ + +export const DEFAULT_SBX_BINARY = 'sbx' + +/** + * `sbx create` takes an agent positional beside `--template`. Radial never starts it — the harness + * argv runs through `sbx exec` — so this only names the sandbox's own default entrypoint. It is + * configurable (`run.sandbox.agent`) because the accepted names are the CLI's, not Radial's. + */ +export const DEFAULT_SBX_AGENT = 'claude' + +export interface SandboxOptions { + /** The `sbx` executable, for a machine that installed it under another name/path. */ + binary?: string + /** Template image. Defaults to the spec's `image`, i.e. `run.image` (`radial-turn:latest`). */ + template?: string + /** The `sbx create` agent positional. See `DEFAULT_SBX_AGENT`. */ + agent?: string + /** `--kit` entries, stacked in the given order. */ + kits?: string[] + /** Extra `sbx create` flags, passed verbatim after the kits. The escape hatch for a CLI whose + * surface is explicitly "subject to change" — an operator can add a flag Radial does not know. */ + createArgs?: string[] +} + +/** + * `ContainerSpec` fields that are docker-run hardening and have no `sbx` equivalent. They are + * SUPERSEDED by the hypervisor boundary and the sandbox's own network policy, not silently + * dropped: `describeSupersededSpecFields` names the ones actually present so the daemon can say so + * once at startup, and the ADR carries the argument field by field. + * + * `memory` is here for the same reason: a microVM is created with a fixed RAM allocation, so a turn + * inside one cannot grow into the host's memory the way a container sharing the host kernel can. + * That is the obligation `turn.ts`'s `4g` floor exists to discharge ("a turn must never run with + * unbounded memory"), and it is discharged by construction here. Sizing that allocation per sandbox + * is not documented by `sbx`; `run.sandbox.createArgs` is the escape hatch until it is. + */ +export const SUPERSEDED_SPEC_FIELDS = [ + 'pidsLimit', + 'readOnlyRootfs', + 'user', + 'tmpfs', + 'extraHosts', + 'memory', +] as const + +/** The superseded fields this spec actually carries, in declaration order. Pure. */ +export function describeSupersededSpecFields(spec: ContainerSpec): string[] { + return SUPERSEDED_SPEC_FIELDS.filter((field) => spec[field] !== undefined) +} + +/** POSIX single-quoting, so a path or an argv element containing a space, a quote or a `$` reaches + * the exec script as one literal word. */ +export function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function requireMappableMounts(spec: ContainerSpec): void { + for (const mount of spec.mounts) { + // A workspace is one positional argument with an optional `:ro` suffix, so a source containing + // a colon cannot be expressed at all. Refuse rather than pass a path `sbx` will mis-split. + if (mount.source.includes(':')) { + throw new Error(`sandbox workspace path must not contain ':' (${mount.source})`) + } + if (!mount.source.startsWith('/')) { + throw new Error(`sandbox workspace path must be absolute (${mount.source})`) + } + if (!mount.target.startsWith('/')) { + throw new Error(`sandbox mount target must be absolute (${mount.target})`) + } + } +} + +/** + * Pure `sbx create` argv builder. The sandbox is created detached and left stopped-of-work: the + * harness runs in the `sbx exec` below, not as the agent positional. + * + * No `-e` flags: the turn's environment — a model key, the turn token, a forge grant — is set by + * the exec script, which reaches `sbx` over stdin. That keeps every secret off both the host's and + * the guest's process lists, and it sidesteps an undocumented question (whether create-time + * variables are visible to a later `exec` session) rather than betting a turn on it. + */ +export function sbxCreateArgs(spec: ContainerSpec, options: SandboxOptions = {}): string[] { + // A sandbox's network is the host proxy plus its policy; there is no docker network to join, and + // an operator who set one is expressing an intent this runtime cannot honour. + if (spec.network !== undefined) { + throw new Error( + `network '${spec.network}' cannot be honoured by the sandbox runtime: a sandbox reaches the ` + + 'network through its host policy proxy (see run.sandbox and `sbx policy`)', + ) + } + requireMappableMounts(spec) + const args = ['create', '--name', spec.label] + for (const kit of options.kits ?? []) args.push('--kit', kit) + args.push('--template', options.template ?? spec.image) + args.push(...(options.createArgs ?? [])) + args.push(options.agent ?? DEFAULT_SBX_AGENT) + for (const mount of spec.mounts) { + args.push(mount.readOnly ? `${mount.source}:ro` : mount.source) + } + return args +} + +/** + * Pure `sbx policy allow network` argv builder, scoped to this one sandbox so a turn's rule dies + * with the turn rather than widening the machine's global policy. + * + * The turn socket binds an ephemeral port and the container reaches it at + * `tcp://host.docker.internal:`; the host proxy rewrites that name to `localhost` before + * forwarding, so `localhost:` — not `host.docker.internal:` — is what a rule must name. + * Returns `undefined` when the spec needs no host access at all (every check run, and any turn on + * the unix transport). + */ +export function sbxPolicyArgs(spec: ContainerSpec): string[] | undefined { + const ports = spec.hostPorts ?? [] + if (ports.length === 0) return undefined + for (const port of ports) { + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error(`hostPorts entry must be a TCP port number, got ${String(port)}`) + } + } + return ['policy', 'allow', 'network', '--sandbox', spec.label, ports.map((port) => `localhost:${port}`).join(',')] +} + +/** + * The shell script `sbx exec` reads on stdin. Three jobs, in order: + * + * 1. **Bridge the passthrough paths.** Each workspace is visible at its host path, so every mount + * whose target differs gets a symlink at the target. This 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 the sandbox's shape + * into code the docker path also runs. + * 2. **Export the turn environment.** Over stdin, so no value appears in an argv anywhere. + * 3. **`exec` the harness argv** from the workdir, so its exit status is the exec's exit status. + * + * `set -e` makes a failed bridge a failed turn rather than a harness that starts in the wrong tree. + */ +export function sbxExecScript(spec: ContainerSpec): string { + requireMappableMounts(spec) + if (spec.argv.length === 0) throw new Error('sandbox exec needs a non-empty argv') + const lines = ['set -eu'] + for (const mount of spec.mounts) { + if (mount.target === mount.source) continue + const parent = mount.target.slice(0, Math.max(1, mount.target.lastIndexOf('/'))) + lines.push(`mkdir -p ${shellQuote(parent)}`) + lines.push(`ln -sfn ${shellQuote(mount.source)} ${shellQuote(mount.target)}`) + } + // Sorted: the argv builders are compared byte for byte in tests, and object key order is not a + // contract worth depending on. + for (const key of Object.keys(spec.env).sort()) { + lines.push(`export ${key}=${shellQuote(spec.env[key] ?? '')}`) + } + if (spec.workdir !== undefined) lines.push(`cd ${shellQuote(spec.workdir)}`) + lines.push(`exec ${spec.argv.map(shellQuote).join(' ')}`) + return `${lines.join('\n')}\n` +} + +/** Pure `sbx exec` argv builder. `sh -s` reads `sbxExecScript` from stdin. */ +export function sbxExecArgs(spec: ContainerSpec): string[] { + return ['exec', spec.label, 'sh', '-s'] +} + +/** Pure `sbx rm` argv builder. Always forced: a sandbox holding a turn's credentials must go away + * whether or not it stopped cleanly. */ +export function sbxRemoveArgs(name: string): string[] { + return ['rm', '--force', name] +} + +/** + * Parse `sbx ls` into sandbox names. The one place the CLI's human output shape leaks into Radial, + * and deliberately isolated here: a header row is skipped, and each remaining row contributes its + * first whitespace-separated field. Tolerant by construction — an added column changes nothing. + */ +export function parseSandboxList(stdout: string): string[] { + const names: string[] = [] + for (const line of stdout.split('\n')) { + const fields = line.trim().split(/\s+/).filter((field) => field.length > 0) + const first = fields[0] + if (first === undefined) continue + if (/^(name|sandbox|sandbox_name)$/i.test(first)) continue + names.push(first) + } + return names +} + +interface SbxResult { + code: number + stdout: string + stderr: string +} + +/** Injectable for tests and for `sandboxPreflight`, which must be answerable without a real CLI. */ +export type SbxExec = (args: string[]) => Promise + +function isMissingSandbox(stderr: string): boolean { + return /no such sandbox|not found/i.test(stderr) +} + +/** + * Startup fail-fast for `containerRuntime: 'sandbox'` — a daemon that cannot dispatch should say so + * now, not at the first claim it wins. Returns the problems it found, each naming its fix; an empty + * array means the runtime looks usable. + * + * `sbx login` is interactive and must have happened out of band, the same posture forge + * authentication already takes. + */ +export async function sandboxPreflight( + options: SandboxOptions = {}, + exec: SbxExec = defaultSbxExec(options.binary ?? DEFAULT_SBX_BINARY), +): Promise { + const binary = options.binary ?? DEFAULT_SBX_BINARY + const problems: string[] = [] + let version: SbxResult + try { + version = await exec(['--version']) + } catch (error) { + return [ + `run.containerRuntime is "sandbox" but ${binary} could not be run (${error instanceof Error ? error.message : String(error)}); ` + + 'install Docker sandboxes (https://docs.docker.com/ai/sandboxes/install/) or set run.containerRuntime to "docker"', + ] + } + if (version.code !== 0) { + problems.push(`${binary} --version failed (exit ${version.code}): ${version.stderr.trim() || version.stdout.trim()}`) + return problems + } + const list = await exec(['ls']).catch((error: unknown) => ({ + code: -1, + stdout: '', + stderr: error instanceof Error ? error.message : String(error), + })) + if (list.code !== 0) { + problems.push( + `${binary} ls failed (exit ${list.code}): ${list.stderr.trim() || list.stdout.trim()}; ` + + `run \`${binary} login\` and make sure Docker Desktop is running`, + ) + } + return problems +} + +function defaultSbxExec(binary: string): SbxExec { + return (args) => + new Promise((resolvePromise, reject) => { + const child = spawn(binary, args) + const decoder = new TextDecoder() + let stdout = '' + let stderr = '' + child.stdout?.on('data', (chunk) => { + stdout += decoder.decode(chunk) + }) + child.stderr?.on('data', (chunk) => { + stderr += decoder.decode(chunk) + }) + child.on('error', reject) + child.on('close', (code) => resolvePromise({ code: code ?? -1, stdout, stderr })) + }) +} + +/** + * Real `sbx` execution. `sbx` is not available in this development environment, so only the pure + * builders above and construction are unit-tested — `run`/`listByLabel`/`kill` are exercised by the + * gated `sandbox-smoke.test.mjs` on an operator machine, exactly as `DockerRunner` is. + */ +export class SandboxRunner implements ContainerRunner { + readonly #options: SandboxOptions + readonly #binary: string + readonly #log: ((message: string) => void) | undefined + readonly #exec: SbxExec + + /** `exec` is the seam the short `sbx` invocations go through — create, the policy rule, `ls` and + * `rm`. Injected, so orphan reconciliation (which is nothing but `listByLabel` and `kill`) is + * covered against a scripted CLI on a machine with no `sbx`. The long harness exec streams and + * times out, and is the gated smoke test's business. */ + constructor(options: SandboxOptions = {}, log?: (message: string) => void, exec?: SbxExec) { + this.#options = options + this.#binary = options.binary ?? DEFAULT_SBX_BINARY + this.#log = log + this.#exec = exec ?? defaultSbxExec(this.#binary) + } + + async run(spec: ContainerSpec): Promise { + const superseded = describeSupersededSpecFields(spec) + if (superseded.length > 0) { + this.#log?.( + `sandbox ${spec.label}: ${superseded.join(', ')} superseded by the microVM boundary and the ` + + 'sandbox network policy (docs/adr-docker-sandboxes.md)', + ) + } + // Build every argv BEFORE creating anything: a spec this runtime cannot honour must fail + // without leaving a sandbox behind to reclaim. + const createArgs = sbxCreateArgs(spec, this.#options) + const policyArgs = sbxPolicyArgs(spec) + const execArgs = sbxExecArgs(spec) + const script = sbxExecScript(spec) + + const created = await this.#exec(createArgs) + if (created.code !== 0) { + // A create that failed part way may still have left a sandbox behind, and this runtime's one + // discipline is that a sandbox never outlives its turn. `kill` tolerates one that is not + // there, so this is safe on the ordinary "nothing was created" failure too. + await this.kill(spec.label).catch(() => {}) + throw new Error(`${this.#binary} create failed (exit ${created.code}): ${created.stderr.trim()}`) + } + try { + if (policyArgs) { + const policy = await this.#exec(policyArgs) + // Loud: without the rule the harness reaches the turn socket only to be denied by the + // proxy, and a turn that cannot write its terminal record burns its whole timeout first. + if (policy.code !== 0) { + throw new Error(`${this.#binary} policy allow network failed (exit ${policy.code}): ${policy.stderr.trim()}`) + } + } + return await this.#execHarness(spec, execArgs, script) + } finally { + // Unconditional, unlike `docker run --rm`: nothing else reclaims a sandbox, and its disk holds + // this turn's model key and forge grant until it is removed. + await this.kill(spec.label).catch((error: unknown) => { + this.#log?.(`sandbox ${spec.label}: removal failed (${error instanceof Error ? error.message : String(error)})`) + }) + } + } + + async listByLabel(label: string): Promise { + const result = await this.#exec(['ls']) + if (result.code !== 0) throw new Error(`${this.#binary} ls failed (exit ${result.code}): ${result.stderr}`) + return parseSandboxList(result.stdout).filter((name) => name === label) + } + + async kill(idOrName: string): Promise { + const result = await this.#exec(sbxRemoveArgs(idOrName)) + if (result.code !== 0 && !isMissingSandbox(result.stderr)) { + throw new Error(`${this.#binary} rm failed (exit ${result.code}): ${result.stderr}`) + } + } + + /** The one long-running invocation: streams output, honours `timeoutMs`, and hands the harness + * its environment over stdin. Shaped exactly like `DockerRunner.run`'s body so turn diagnostics + * and secret redaction behave identically under both runtimes. */ + #execHarness(spec: ContainerSpec, args: string[], script: string): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn(this.#binary, args) + const stdoutCaptureDecoder = new TextDecoder() + const stderrCaptureDecoder = new TextDecoder() + const stdoutLiveDecoder = new TextDecoder() + const stderrLiveDecoder = new TextDecoder() + let stdout = '' + let stderr = '' + let settled = false + const timer = setTimeout(() => { + if (settled) return + settled = true + // Await the removal before resolving, for the reason `DockerRunner` does: `runTurn`'s + // teardown follows immediately and must not race a sandbox still being torn down. The + // `finally` in `run` removes again, which `kill` tolerates. + void (async () => { + try { + await this.kill(spec.label) + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))) + return + } + resolvePromise({ exitCode: -1, timedOut: true, stdout, stderr }) + })() + }, spec.timeoutMs) + child.stdout?.on('data', (chunk: Uint8Array) => { + stdout = appendCapturedOutput(stdout, chunk, stdoutCaptureDecoder) + spec.onOutput?.('stdout', stdoutLiveDecoder.decode(chunk, { stream: true })) + }) + child.stderr?.on('data', (chunk: Uint8Array) => { + stderr = appendCapturedOutput(stderr, chunk, stderrCaptureDecoder) + spec.onOutput?.('stderr', stderrLiveDecoder.decode(chunk, { stream: true })) + }) + child.on('error', (error) => { + if (settled) return + settled = true + clearTimeout(timer) + reject(error) + }) + child.on('close', (code) => { + if (settled) return + settled = true + clearTimeout(timer) + resolvePromise({ exitCode: code ?? -1, timedOut: false, stdout, stderr }) + }) + child.stdin?.on('error', () => { + // A sandbox that died before reading the script surfaces as the non-zero exit below; an + // EPIPE here must not become an unhandled error event. + }) + child.stdin?.end(script) + }) + } +} diff --git a/packages/daemon/src/turn.ts b/packages/daemon/src/turn.ts --- a/packages/daemon/src/turn.ts +++ b/packages/daemon/src/turn.ts @@ -160,6 +160,11 @@ * binds the turn socket on 0.0.0.0 instead and points the container at * `host.docker.internal`. */ turnTransport?: 'unix' | 'tcp' + /** Interface the tcp turn socket binds; `0.0.0.0` when unset. The sandbox runtime resolves it to + * `127.0.0.1`, because the connection is made by the host-side egress proxy after it rewrites + * `host.docker.internal` to `localhost` — loopback is both sufficient and tighter there, where a + * docker container's bridge-borne connection would be refused by it. Ignored under `unix`. */ + turnSocketHost?: string /** This daemon instance's id (`instanceId(stateDir)`), mixed into the container label/name so two * daemons on one machine cannot collide on a name — or reconcile away each other's containers. */ instance?: string @@ -579,18 +584,24 @@ ...(deps.now ? { now: deps.now } : {}), } let sidecarSocket: string + // The host port the container must be allowed to reach, under the tcp transport only. Docker + // needs no rule for it; a sandbox's egress proxy denies localhost by default (see + // ContainerSpec.hostPorts). + let hostPorts: number[] | undefined if (transport === 'unix') { server = new TurnSocketServer(socketPath, context) await server.listen() sidecarSocket = '/run/radial/turn.sock' } else { - // Bind 0.0.0.0, not loopback: VM-originated connections from the turn container arrive over - // the docker bridge, not the daemon's own loopback interface. - server = new TurnSocketServer({ kind: 'tcp', host: '0.0.0.0', port: 0 }, context) + // Default 0.0.0.0, not loopback: VM-originated connections from the turn container arrive + // over the docker bridge, not the daemon's own loopback interface. `turnSocketHost` narrows + // it — to `127.0.0.1` under the sandbox runtime, where the connection is the host proxy's. + server = new TurnSocketServer({ kind: 'tcp', host: input.turnSocketHost ?? '0.0.0.0', port: 0 }, context) await server.listen() const port = server.boundPort if (port === undefined) throw new Error('tcp turn socket failed to bind: no port reported after listen()') sidecarSocket = `tcp://host.docker.internal:${port}` + hostPorts = [port] } // The acting profile's configured models (radial.json `agents..models`, else the @@ -718,6 +729,7 @@ // Required on Linux for the tcp transport to reach the host; harmless (host.docker.internal // already resolves natively) on Docker Desktop. ...(transport === 'tcp' ? { extraHosts: ['host.docker.internal:host-gateway'] } : {}), + ...(hostPorts ? { hostPorts } : {}), workdir: '/work', timeoutMs: input.timeoutMs, tmpfs: [ diff --git a/packages/daemon/test/config.test.mjs b/packages/daemon/test/config.test.mjs --- a/packages/daemon/test/config.test.mjs +++ b/packages/daemon/test/config.test.mjs @@ -333,6 +333,81 @@ assert.equal('turnTransport' in parsed, false) }) +// --- the sandbox turn runtime (docs/adr-docker-sandboxes.md) ---------------------------------- +const SANDBOX_SPACE = 'at://did:plc:human/com.disnetdev.radial.space/space1' + +it('parseRunConfig validates run.containerRuntime, run.sandbox and run.turnSocketHost', () => { + const parsed = parseRunConfig({ + spaces: [SANDBOX_SPACE], + containerRuntime: 'sandbox', + turnSocketHost: '127.0.0.1', + sandbox: { template: 'my-org/radial-turn:v1', agent: 'codex', kits: ['./kits/radial'], createArgs: ['--memory', '8g'] }, + }) + assert.equal(parsed.containerRuntime, 'sandbox') + assert.equal(parsed.turnSocketHost, '127.0.0.1') + assert.deepEqual(parsed.sandbox, { + template: 'my-org/radial-turn:v1', + agent: 'codex', + kits: ['./kits/radial'], + createArgs: ['--memory', '8g'], + }) + assert.throws( + () => parseRunConfig({ spaces: [SANDBOX_SPACE], containerRuntime: 'podman' }), + /run\.containerRuntime must be "docker" or "sandbox"/, + ) + assert.throws(() => parseRunConfig({ spaces: [SANDBOX_SPACE], containerRuntime: 'sandbox', sandbox: [] }), /must be an object/) + assert.throws( + () => parseRunConfig({ spaces: [SANDBOX_SPACE], containerRuntime: 'sandbox', sandbox: { kits: 'one' } }), + /run\.sandbox\.kits must be an array of strings/, + ) +}) + +it('parseRunConfig refuses the sandbox runtime combined with settings it cannot honour', () => { + // Each of these can only ever fail an hour into a turn; the message names the fix. + assert.throws( + () => parseRunConfig({ spaces: [SANDBOX_SPACE], containerRuntime: 'sandbox', turnTransport: 'unix' }), + /cannot connect to a host AF_UNIX socket/, + ) + assert.throws( + () => parseRunConfig({ spaces: [SANDBOX_SPACE], containerRuntime: 'sandbox', network: 'radial-bridge' }), + /run\.network cannot be used with run\.containerRuntime "sandbox"/, + ) + assert.throws( + () => parseRunConfig({ spaces: [SANDBOX_SPACE], containerRuntime: 'sandbox', codexAuth: { mode: 'chatgpt-session' } }), + /run\.codexAuth cannot be used with run\.containerRuntime "sandbox"/, + ) + // A settings block that silently does nothing is worse than a startup error. + assert.throws( + () => parseRunConfig({ spaces: [SANDBOX_SPACE], sandbox: { template: 'x' } }), + /run\.sandbox is set but run\.containerRuntime is not "sandbox"/, + ) +}) + +it('resolveRunConfig defaults an operator with no new keys to byte-identical docker behaviour', () => { + const resolved = resolveRunConfig({ spaces: [SANDBOX_SPACE] }, { platform: 'linux' }) + assert.equal(resolved.containerRuntime, 'docker') + assert.equal(resolved.turnTransport, 'unix') + assert.equal(resolved.turnSocketHost, '0.0.0.0') + assert.deepEqual(resolved.sandbox, {}) +}) + +it('resolveRunConfig makes the sandbox runtime tcp on every platform and binds it to loopback', () => { + // A microVM never shares the host's AF_UNIX socket, so the platform question does not arise; and + // the connection is the host egress proxy's, which has already rewritten host.docker.internal to + // localhost — loopback is both sufficient and tighter. + const linux = resolveRunConfig({ spaces: [SANDBOX_SPACE], containerRuntime: 'sandbox' }, { platform: 'linux' }) + assert.equal(linux.turnTransport, 'tcp') + assert.equal(linux.turnSocketHost, '127.0.0.1') + const darwin = resolveRunConfig({ spaces: [SANDBOX_SPACE], containerRuntime: 'sandbox' }, { platform: 'darwin' }) + assert.equal(darwin.turnTransport, 'tcp') + // An explicit bind host still wins. + const explicit = resolveRunConfig( + { spaces: [SANDBOX_SPACE], containerRuntime: 'sandbox', turnSocketHost: '192.0.2.7' }, + { platform: 'linux' }, + ) + assert.equal(explicit.turnSocketHost, '192.0.2.7') +}) + it('refuses to clobber an existing config unless forced', async () => { const directory = await mkdtemp(join(tmpdir(), 'radial-clobber-')) diff --git a/packages/daemon/test/reconcile.test.mjs b/packages/daemon/test/reconcile.test.mjs --- a/packages/daemon/test/reconcile.test.mjs +++ b/packages/daemon/test/reconcile.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { it } from 'node:test' -import { CheckLedger, FakeContainerRunner, TurnLedger } from '../dist/index.js' +import { CheckLedger, FakeContainerRunner, SandboxRunner, TurnLedger } from '../dist/index.js' import { reconcileCheckOrphans, reconcileOrphans } from '../dist/cli.js' // FIX #6: orphan reconciliation must preserve attempts (markCrashed, not reset). A daemon that @@ -90,4 +90,82 @@ assert.equal(row.attempts, 2) assert.deepEqual(ledger.running(), []) ledger.close() +}) + +// --- the same drill against the sandbox runtime (docs/adr-docker-sandboxes.md) ----------------- +// +// A sandbox is not `docker run --rm`: nothing reclaims it, and its disk holds the dead turn's model +// key and forge grant. So reconciliation matters MORE here than it does under docker, and it must +// work through the real `SandboxRunner` rather than a fake — `listByLabel` and `kill` are the whole +// of what `reconcileOrphan` touches, and both go through the injected `sbx` seam below. + +/** A scripted `sbx`: `ls` prints the named sandboxes as a table, `rm --force` removes one. */ +function fakeSbx(names) { + const live = new Set(names) + const calls = [] + return { + live, + calls, + exec: async (args) => { + calls.push(args) + if (args[0] === 'ls') { + const rows = ['NAME STATUS TEMPLATE'] + for (const name of live) rows.push(`${name} running radial-turn:latest`) + return { code: 0, stdout: `${rows.join('\n')}\n`, stderr: '' } + } + if (args[0] === 'rm') { + const name = args[2] + if (!live.delete(name)) return { code: 1, stdout: '', stderr: `Error: no such sandbox: ${name}` } + return { code: 0, stdout: '', stderr: '' } + } + throw new Error(`unexpected sbx invocation: ${args.join(' ')}`) + }, + } +} + +it('reconcileOrphans removes a killed daemon’s orphaned sandbox, credentials and all', async () => { + const ledger = new TurnLedger(':memory:', { retryBound: 5, cooldownMs: 60_000 }) + ledger.markRunning(REQUEST_URI, REQUEST_CID, { containerLabel: LABEL, checkoutPath: '/tmp/x' }) + + const sbx = fakeSbx([LABEL]) + const runner = new SandboxRunner({}, undefined, sbx.exec) + + await reconcileOrphans(runner, ledger) + + assert.deepEqual([...sbx.live], [], 'the orphaned sandbox must be gone, not merely stopped') + assert.ok(sbx.calls.some((args) => args[0] === 'rm' && args[1] === '--force')) + const row = ledger.get(REQUEST_URI) + assert.ok(row.state === 'crashed' || row.state === 'gave_up', `expected crashed/gave_up, got ${row.state}`) + assert.deepEqual(ledger.running(), []) + ledger.close() +}) + +it('a sandbox reconciliation leaves the other instance’s sandboxes alone', async () => { + // The instance-id suffix that keeps two daemons on one machine out of each other's containers is + // in the LABEL, and the label doubles as the sandbox name — so the non-interference property + // transfers to this runtime unchanged. Assert it rather than assume it. + const ours = `${LABEL}.inst-a` + const theirs = `${LABEL}.inst-b` + const ledger = new TurnLedger(':memory:', { retryBound: 5, cooldownMs: 60_000 }) + ledger.markRunning(REQUEST_URI, REQUEST_CID, { containerLabel: ours, checkoutPath: '/tmp/x' }) + + const sbx = fakeSbx([ours, theirs]) + const runner = new SandboxRunner({}, undefined, sbx.exec) + assert.deepEqual(await runner.listByLabel(ours), [ours]) + + await reconcileOrphans(runner, ledger) + + assert.deepEqual([...sbx.live], [theirs]) + ledger.close() +}) + +it('SandboxRunner.kill tolerates a sandbox that is already gone and reports one that will not go', async () => { + const sbx = fakeSbx([]) + const runner = new SandboxRunner({}, undefined, sbx.exec) + // Already removed: reconciliation must never block startup over it. + await runner.kill('radial.turn.vanished') + await assert.rejects( + new SandboxRunner({}, undefined, async () => ({ code: 1, stdout: '', stderr: 'daemon unreachable' })).kill('x'), + /rm failed \(exit 1\): daemon unreachable/, + ) }) diff --git a/packages/daemon/test/sandbox-smoke.test.mjs b/packages/daemon/test/sandbox-smoke.test.mjs new file mode 100644 --- /dev/null +++ b/packages/daemon/test/sandbox-smoke.test.mjs @@ -0,0 +1,135 @@ +// Real-`sbx` smoke test for the Docker sandbox turn runtime (docs/adr-docker-sandboxes.md) — the +// mirror of docker-smoke.test.mjs, and the only thing in this repo that exercises +// `SandboxRunner.run` end to end. It is skipped unless RADIAL_SANDBOX_TESTS=1 (node:test's `skip` +// option — a clean skip, and no child_process work happens when skipped). +// +// CI cannot run it: Docker sandboxes are a Docker Desktop feature, need an authenticated `sbx`, and +// both workflow files run on plain Linux Engine. It exists for an operator machine: +// +// pnpm images # builds radial-turn:latest +// docker image save radial-turn:latest -o /tmp/rt.tar # a local image must be loaded into the +// sbx template load /tmp/rt.tar # sandbox image store first +// sbx login +// RADIAL_SANDBOX_TESTS=1 node --test packages/daemon/test/sandbox-smoke.test.mjs +// +// What it proves, and what only it can prove: +// +// - a locally built `radial-turn` image works as a template (ADR spike item b); +// - `sbxExecScript`'s symlink bridge really does put the passthrough workspaces at `/work` and +// `/bundle` — i.e. that the exec step can write to `/` inside the VM (spike item d); +// - the harness reaches the token-authenticated tcp turn socket on the host through the egress +// proxy under the per-sandbox `localhost:` policy rule this runner writes (spike item c); +// - the terminal record lands in the PDS, exactly as under docker; +// - and the sandbox is GONE afterwards — the one property that has no `--rm` behind it, and the +// one whose failure leaves a model key on a VM disk. +// +// Like docker-smoke, it never drives a real harness against a model: the container command is a +// deterministic fake-harness `radial artifact submit` invocation. +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, it } from 'node:test' +import { createSession, CredentialClient } from '../../atproto/dist/index.js' +import { COLLECTIONS } from '../../core/dist/index.js' +import { LocalPds } from '../../atproto/test/local-pds.mjs' +import { SandboxRunner, TurnSocketServer, parseSandboxList, sandboxPreflight } from '../dist/index.js' + +const RUN_SANDBOX_TESTS = process.env.RADIAL_SANDBOX_TESTS === '1' +const TEMPLATE = process.env.RADIAL_SANDBOX_TEMPLATE ?? 'radial-turn:latest' +const SANDBOX_NAME = 'radial-sandbox-smoke' + +const AGENT_DID = 'did:plc:agent' +const GOAL = { uri: 'at://did:plc:human/com.disnetdev.radial.goal/goal1', cid: 'goal-cid' } +const REQUEST = { + uri: 'at://did:plc:human/com.disnetdev.radial.artifactRequest/req1', + cid: 'req-cid', + goal: GOAL, +} + +describe('sandbox smoke: a turn in a real sbx microVM', { skip: !RUN_SANDBOX_TESTS }, () => { + it('preflight passes on a machine that is set up for the sandbox runtime', async () => { + // The same check `radiald run` makes at startup. If this fails, everything below would fail + // with a less legible message. + assert.deepEqual(await sandboxPreflight({}), []) + }) + + it('a turn submits a plan artifact through the tcp turn socket, and the sandbox is removed afterwards', async () => { + const pds = new LocalPds(AGENT_DID) + const session = await createSession(pds.service, pds.handle, 'pw', pds.fetch.bind(pds)) + const client = new CredentialClient(session, pds.fetch.bind(pds)) + const token = 'radial-sandbox-smoke-token' + + const runDir = await mkdtemp(join(tmpdir(), 'radial-sandbox-smoke-')) + const workDir = join(runDir, 'work') + const bundleDir = join(runDir, 'bundle') + await mkdir(workDir, { recursive: true }) + await mkdir(bundleDir, { recursive: true }) + await writeFile(join(bundleDir, 'brief.md'), '# Sandbox smoke brief\n\nSay hi.\n') + + // Loopback, not 0.0.0.0: the connection is made by the host-side egress proxy after it rewrites + // host.docker.internal to localhost — the same resolution `resolveRunConfig` makes. + const server = new TurnSocketServer( + { kind: 'tcp', host: '127.0.0.1', port: 0 }, + { token, request: REQUEST, client }, + ) + await server.listen() + const runner = new SandboxRunner({ template: TEMPLATE }) + + try { + const result = await runner.run({ + label: SANDBOX_NAME, + image: TEMPLATE, + argv: [ + 'sh', + '-c', + // Asserts the bridge from inside the VM before using it, so a broken symlink is a legible + // failure rather than a `radial` invocation that cannot find its bundle. + 'test -f /bundle/brief.md && test -d /work && ' + + 'radial artifact submit --title "Sandbox smoke artifact" --body-file /bundle/brief.md', + ], + env: { + RADIAL_SIDECAR_SOCKET: `tcp://host.docker.internal:${server.boundPort}`, + RADIAL_TURN_TOKEN: token, + }, + mounts: [ + { source: workDir, target: '/work', readOnly: false }, + { source: bundleDir, target: '/bundle', readOnly: true }, + ], + hostPorts: [server.boundPort], + workdir: '/work', + timeoutMs: 10 * 60_000, + }) + + assert.equal(result.timedOut, false, 'expected the sandbox exec to finish before the timeout') + assert.equal(result.exitCode, 0, `expected a clean exit, got ${result.exitCode}: ${result.stderr ?? ''}`) + + assert.ok(server.observation.artifact, 'expected the turn socket to have observed a submitted artifact') + const written = [...pds.records.values()].find((record) => record.value?.$type === COLLECTIONS.artifact) + assert.ok(written, 'expected a com.disnetdev.radial.artifact record in the fake PDS') + assert.equal(written.value.body, '# Sandbox smoke brief\n\nSay hi.\n') + } finally { + await server.close() + await rm(runDir, { recursive: true, force: true }).catch(() => {}) + // Belt and braces for a failed assertion above: `run`'s own `finally` already removed it. + await runner.kill(SANDBOX_NAME).catch(() => {}) + } + + // The property with no `--rm` behind it. A surviving sandbox holds this turn's token on its + // VM disk, which is the whole reason removal is unconditional. + assert.deepEqual( + await runner.listByLabel(SANDBOX_NAME), + [], + 'expected the sandbox to be removed after the turn; `sbx ls` still lists it', + ) + }) +}) + +it('parseSandboxList tolerates the `sbx ls` a real CLI prints', { skip: !RUN_SANDBOX_TESTS }, async () => { + // The one place the CLI's human output shape leaks into Radial. Run against the real thing so a + // change in its table is caught here rather than by reconciliation quietly finding nothing. + const runner = new SandboxRunner({}) + const names = await runner.listByLabel('definitely-not-a-sandbox') + assert.deepEqual(names, []) + assert.deepEqual(parseSandboxList('NAME STATUS\n'), []) +}) diff --git a/packages/daemon/test/sandbox.test.mjs b/packages/daemon/test/sandbox.test.mjs new file mode 100644 --- /dev/null +++ b/packages/daemon/test/sandbox.test.mjs @@ -0,0 +1,230 @@ +// Unit coverage for the Docker sandbox runtime (docs/adr-docker-sandboxes.md). `sbx` is not +// available in this development environment — nor in CI — so everything asserted here is over the +// PURE argv/script builders and the injectable preflight, exactly as container.test.mjs is over +// `dockerRunArgs`. The real-`sbx` paths are exercised by the gated sandbox-smoke.test.mjs. +import assert from 'node:assert/strict' +import { it } from 'node:test' +import { + DEFAULT_SBX_AGENT, + SandboxRunner, + describeSupersededSpecFields, + parseSandboxList, + sandboxPreflight, + sbxCreateArgs, + sbxExecArgs, + sbxExecScript, + sbxPolicyArgs, + sbxRemoveArgs, + shellQuote, +} from '../dist/index.js' + +const baseSpec = { + label: 'radial.turn.abc123', + image: 'radial-turn:latest', + argv: ['claude', '-p', 'read /bundle/brief.md'], + env: { RADIAL_TURN_TOKEN: 'tok-1', ANTHROPIC_API_KEY: 'sk-secret' }, + mounts: [ + { source: '/var/lib/radiald/runs/abc/checkout', target: '/work', readOnly: false }, + { source: '/var/lib/radiald/runs/abc/bundle', target: '/bundle', readOnly: true }, + ], + workdir: '/work', + timeoutMs: 60_000, + memory: '4g', + user: '1000:1000', + readOnlyRootfs: true, + tmpfs: ['/tmp'], + hostPorts: [51234], +} + +it('sbxCreateArgs names the sandbox, the template and every workspace, marking read-only ones', () => { + const args = sbxCreateArgs(baseSpec) + + assert.deepEqual(args, [ + 'create', + '--name', + 'radial.turn.abc123', + '--template', + 'radial-turn:latest', + DEFAULT_SBX_AGENT, + '/var/lib/radiald/runs/abc/checkout', + '/var/lib/radiald/runs/abc/bundle:ro', + ]) +}) + +it('sbxCreateArgs carries an operator template, kits, agent and extra flags in a fixed order', () => { + const args = sbxCreateArgs(baseSpec, { + template: 'my-org/radial-turn:v1', + agent: 'codex', + kits: ['./kits/radial', 'ghcr.io/acme/kit:1.0'], + createArgs: ['--memory', '8g'], + }) + + assert.deepEqual(args.slice(0, 11), [ + 'create', + '--name', + 'radial.turn.abc123', + '--kit', + './kits/radial', + '--kit', + 'ghcr.io/acme/kit:1.0', + '--template', + 'my-org/radial-turn:v1', + '--memory', + '8g', + ]) + assert.equal(args[11], 'codex') +}) + +it('sbxCreateArgs never puts an environment value on the command line', () => { + // Secrets reach the sandbox through the exec script on stdin, so nothing here — on either the + // host's or the guest's process list — can carry the model key. + const args = sbxCreateArgs(baseSpec) + assert.ok(!args.includes('-e')) + assert.ok(!args.some((arg) => arg.includes('sk-secret'))) +}) + +it('sbxCreateArgs refuses a docker network and an unexpressible workspace path', () => { + assert.throws(() => sbxCreateArgs({ ...baseSpec, network: 'radial-bridge' }), /host policy proxy/) + assert.throws( + () => + sbxCreateArgs({ + ...baseSpec, + mounts: [{ source: '/var/lib/a:b/checkout', target: '/work', readOnly: false }], + }), + /must not contain ':'/, + ) + assert.throws( + () => sbxCreateArgs({ ...baseSpec, mounts: [{ source: 'relative/path', target: '/work', readOnly: false }] }), + /must be absolute/, + ) +}) + +it('sbxExecScript bridges passthrough workspaces to the paths the turn image expects', () => { + const script = sbxExecScript(baseSpec) + + // Workspaces are visible at their HOST path inside the VM; `/work` and `/bundle` are symlinks. + assert.match(script, /^set -eu$/m) + assert.match(script, /^ln -sfn '\/var\/lib\/radiald\/runs\/abc\/checkout' '\/work'$/m) + assert.match(script, /^ln -sfn '\/var\/lib\/radiald\/runs\/abc\/bundle' '\/bundle'$/m) + assert.match(script, /^cd '\/work'$/m) + assert.match(script, /^exec 'claude' '-p' 'read \/bundle\/brief\.md'$/m) +}) + +it('sbxExecScript exports the turn environment and skips a mount that is already at its target', () => { + const script = sbxExecScript({ + ...baseSpec, + mounts: [{ source: '/srv/shared', target: '/srv/shared', readOnly: true }], + }) + + assert.ok(!script.includes('ln -sfn')) + // Sorted, so the script is byte-stable across runs. + const exports = script.split('\n').filter((line) => line.startsWith('export ')) + assert.deepEqual(exports, [`export ANTHROPIC_API_KEY='sk-secret'`, `export RADIAL_TURN_TOKEN='tok-1'`]) +}) + +it('shellQuote survives a value containing a single quote', () => { + assert.equal(shellQuote(`it's`), `'it'\\''s'`) + const script = sbxExecScript({ ...baseSpec, env: { TRICKY: `a'b$c d` } }) + assert.match(script, /^export TRICKY='a'\\''b\$c d'$/m) +}) + +it('sbxExecScript refuses an empty argv', () => { + assert.throws(() => sbxExecScript({ ...baseSpec, argv: [] }), /non-empty argv/) +}) + +it('sbxExecArgs reads the script from stdin so no value reaches an argv', () => { + assert.deepEqual(sbxExecArgs(baseSpec), ['exec', 'radial.turn.abc123', 'sh', '-s']) +}) + +it('sbxPolicyArgs allows the turn socket port as localhost, scoped to this sandbox', () => { + // The host proxy rewrites host.docker.internal to localhost before forwarding, so a rule naming + // host.docker.internal would never match. + assert.deepEqual(sbxPolicyArgs(baseSpec), [ + 'policy', + 'allow', + 'network', + '--sandbox', + 'radial.turn.abc123', + 'localhost:51234', + ]) +}) + +it('sbxPolicyArgs is undefined when the container needs no host access, and refuses a bad port', () => { + const { hostPorts, ...noHostPorts } = baseSpec + assert.equal(sbxPolicyArgs(noHostPorts), undefined) + assert.equal(sbxPolicyArgs({ ...baseSpec, hostPorts: [] }), undefined) + assert.throws(() => sbxPolicyArgs({ ...baseSpec, hostPorts: [70_000] }), /TCP port number/) +}) + +it('sbxRemoveArgs always forces: a sandbox holds the turn credentials until it is removed', () => { + assert.deepEqual(sbxRemoveArgs('radial.turn.abc123'), ['rm', '--force', 'radial.turn.abc123']) +}) + +it('describeSupersededSpecFields names the docker hardening the microVM boundary replaces', () => { + assert.deepEqual(describeSupersededSpecFields(baseSpec), ['readOnlyRootfs', 'user', 'tmpfs', 'memory']) + assert.deepEqual(describeSupersededSpecFields({ ...baseSpec, memory: undefined, tmpfs: undefined }), [ + 'readOnlyRootfs', + 'user', + ]) +}) + +it('parseSandboxList skips a header row and takes the name column', () => { + const names = parseSandboxList( + ['NAME STATUS TEMPLATE', 'radial.turn.abc running radial-turn:latest', '', ' '].join('\n'), + ) + assert.deepEqual(names, ['radial.turn.abc']) +}) + +it('sandboxPreflight reports a missing sbx and an unauthenticated one, each naming its fix', async () => { + const missing = await sandboxPreflight({}, async () => { + throw new Error('spawn sbx ENOENT') + }) + assert.equal(missing.length, 1) + assert.match(missing[0], /could not be run/) + assert.match(missing[0], /run\.containerRuntime to "docker"/) + + const unauthenticated = await sandboxPreflight({}, async (args) => + args[0] === '--version' + ? { code: 0, stdout: 'sbx 0.4.0', stderr: '' } + : { code: 1, stdout: '', stderr: 'not logged in' }, + ) + assert.equal(unauthenticated.length, 1) + assert.match(unauthenticated[0], /sbx login/) + + const ok = await sandboxPreflight({}, async () => ({ code: 0, stdout: '', stderr: '' })) + assert.deepEqual(ok, []) +}) + +it('SandboxRunner satisfies ContainerRunner and refuses an unhonourable spec before creating anything', async () => { + const runner = new SandboxRunner({ binary: '/nonexistent/sbx' }) + assert.equal(typeof runner.run, 'function') + assert.equal(typeof runner.listByLabel, 'function') + assert.equal(typeof runner.kill, 'function') + // Argv construction happens before the first `sbx` call, so a spec this runtime cannot honour + // never leaves a sandbox behind — the failure is the builder's, not a spawn of a missing binary. + await assert.rejects(runner.run({ ...baseSpec, network: 'radial-bridge' }), /host policy proxy/) +}) + +it('a failed create still tries to remove: a sandbox must never outlive its turn', async () => { + const calls = [] + const runner = new SandboxRunner({}, undefined, async (args) => { + calls.push(args[0]) + if (args[0] === 'create') return { code: 1, stdout: '', stderr: 'template not found' } + return { code: 1, stdout: '', stderr: 'Error: no such sandbox' } + }) + await assert.rejects(runner.run(baseSpec), /create failed \(exit 1\): template not found/) + assert.deepEqual(calls, ['create', 'rm']) +}) + +it('a policy rule that cannot be written fails the turn loudly, and the sandbox still goes', async () => { + // Without the rule the harness reaches the turn socket only to be denied by the proxy, and a turn + // that cannot write its terminal record burns its whole timeout first. + const calls = [] + const runner = new SandboxRunner({}, undefined, async (args) => { + calls.push(args.slice(0, 2).join(' ')) + if (args[0] === 'policy') return { code: 1, stdout: '', stderr: 'policy locked down' } + return { code: 0, stdout: '', stderr: '' } + }) + await assert.rejects(runner.run(baseSpec), /policy allow network failed \(exit 1\): policy locked down/) + assert.deepEqual(calls, ['create --name', 'policy allow', 'rm --force']) +}) -- tangled.sh