ADR-0014 — Box image contract: consumer-supplied image + runtime injection at box-up; no foldyard base image¶
- Status: Accepted (2026-06-14; generic default image + bootstrap skill added 2026-06-21;
generic base moved to Debian 2026-08-27, see Amendment) — implemented (
src/foldyard/box.py,src/foldyard/config.py::box_image,src/foldyard/assets/box/Dockerfile) - Sources: PLAN.md §7.4 (box image contract), §8.1.1 (generic default box), §12 (bundled bootstrapping skill), src/foldyard/box.py
Context¶
The dev-box image is mostly the consumer's project toolchain (node/python/build deps) — foldyard never touches that, and "mostly project-dependent" is correct, not a problem to fix. But foldyard's box features (drive the stack over the socket, self-install the CLI, mount the proxy CA and mode mirror, plugin tooling) still need a few things present in whatever image the consumer brings. The design problem: satisfy those needs without foldyard owning the image. The box splits into three concerns — project toolchain (consumer-owned), foldyard's own needs (deliberately tiny), and plugin needs (plugin-declared, posture-dependent) — and only the middle one is foldyard's to solve.
Decision¶
Lean on runtime injection at box-up, not a foldyard base image. The image contract is minimal and mostly not a build-time concern:
- Required in the image: an engine client that speaks the mounted socket (podman or
docker — near-universal in dev images) plus
git. A compose provider is required only for in-box stack control (the agent-autonomy persona); host-driven stacks don't need it in the box. - Everything else is injected at box-up (
box.py::_up), the pattern the originaldevboxrecipe already proved: the machine socket bind-mounted to/var/run/docker.sockwithCONTAINER_HOST/DOCKER_HOSTpointed at it; the staged proxy CA and posture mirror; plugin env/mounts viaregistry().box_args; a shared on-PATH tools prefix (devbox_toolsvolume at/opt/fy-tools); and a monitored bootstrap (run_step, ✓/⏭/✗ per step + a failure summary) that installs the foldyard CLI itself plus each enabled plugin'sbox_bootstrapsteps and the consumer's[[box.tools]]/[box].bootstrap, then runs the in-boxfy doctoronce as a report (2026-09-29). - The foldyard self-install never assumes an editable checkout on the mount
(
box.py::_foldyard_install_subst/_foldyard_run): a repo that vendors foldyard installs editable from the mount; an editable host install stages a wheel into a VM-visible.devbox-foldyard/dir (stage_foldyard_for_box) so the box pins to the exact host version; a published host install pinsfoldyard==X; else — no wheel, no detectable host version, no vendored source — the install fails closed rather than pulling an unpinnedfoldyardthat could drift from the tested version. - The contract is declared and fails loudly rather than mysteriously: the packaged
Dockerfile documents it, the bootstrap reports every failed step by name, and
fy doctorprobes the in-box engine socket — a non-conforming image says "install X", not "connection refused".
A packaged generic box image covers repos with no custom image (added 2026-06-21, PLAN
§8.1.1). box.py only knows how to build from a Dockerfile, so foldyard ships
src/foldyard/assets/box/Dockerfile — a base (quay.io/podman/stable at decision time,
debian:trixie-slim since the 2026-08-27 amendment below) + git + uv, exactly the minimal
contract (uv is there so box-up can uv tool install foldyard; uv brings its own managed
Python). When a consumer declares no [box].image, config.box_image() falls back to that
packaged Dockerfile with the packaged dir as build context — the generic box must not depend
on repo contents. Consequence: any repo can foldyard init --box-only && foldyard box up with
zero Dockerfile or compose authoring. Consumers that declare [box].image are unaffected
(context defaults to the repo root; target/build_args/context are supported).
A bundled skill grows the generic box into a tuned image by observation. The gap
from generic box to a real [box].image used to be "go write a Dockerfile from scratch". The
packaged bootstrap-devbox skill (src/foldyard/assets/skills/bootstrap-devbox/, installed into
a consumer repo via foldyard skill install bootstrap-devbox) drives the loop instead: use the
running box as a REPL for the image — install interactively, test against the real repo, record
what worked — then codify the transcript into a local box.Dockerfile layered on the generic
base, point [box].image at it, rebuild, and re-run foldyard verify. It ships in-package so it
versions with foldyard and needs no separate registry.
Consequences¶
- Projects with an existing dev image keep it untouched; foldyard's layer arrives at box-up as
mounts + env + installs, so upgrading foldyard never means rebuilding the consumer's image
(only re-running
fy box up). - Tangible (consumer #1) keeps its own
dev-stack/box.Dockerfilewithdocker-clias its engine client — the contract says "an engine client", not "podman specifically" (the engine-selection decision made podman the default foldyard drives, with docker as fallback). - The box stays light: every self-install branch installs foldyard bare (never the
[host]extra) because the box only routes egress through the host's proxy, it never runs mitmproxy — which also shrinks bootstrap egress. - Zero-authoring onboarding works end to end:
foldyard init --box-only && foldyard box upon a stack-less repo, then the skill when the generic box stops being enough. - Open at decision time, resolved pragmatically since: whether the foldyard CLI in the box is required or opt-in (the agent persona needs it; the bootstrap installs it unconditionally today), and contract strictness (current behaviour: degrade with loud per-step failure reports rather than hard-fail).
Rejected alternatives¶
- A foldyard-owned base image consumers must inherit from — couples every consumer to foldyard's distro/toolchain choices and breaks projects that already have an image. Rejected outright (2026-06-14).
- A devcontainer Feature (
ghcr.io/foldyard/foldyard) or install-scriptRUNsnippet — the same layer bound earlier (baked at build time, cached) for consumers who prefer it. Not rejected on principle, but explicitly deferred: optional, may ship later, revisit on demand (reaffirmed out-of-scope in the spin-out plan).
Amendment (2026-08-27) — generic base: quay.io/podman/stable → debian:trixie-slim¶
The original base was picked for the contract's engine client — the podman project's own image ships a current podman for free — and Fedora was only what that image happened to be built on. The distro turned out to be the part consumers actually live with, and it charged rent:
- dnf's metalink mirror system resolves to arbitrary hosts no egress allowlist can name, so
every locked-down consumer needed a
dnf-direct-mirrorworkaround step pinningdl.fedoraproject.org(plusdnf-keepcacheto make a cache volume work). apt pulls from one stable host (deb.debian.org) an allowlist names in one line. - Playwright does not support Fedora: browsers run as an ubuntu fallback build with a BEWARE
warning, and
playwright install-deps(apt-only) can't install the system libraries — so consumers hand-maintained a dnf library list, which is exactly what silently broke when an allowlist regression suppressed it (the incident that prompted this change). - Most
[[box.tools]]recipes and tool docs assume apt.
The contract itself is unchanged — the box needs an engine CLIENT for the mounted socket, never a
nested engine — and Debian satisfies it with one apt-get install podman in the generic
Dockerfile. The nested-virt spikes and docs keep quay.io/podman/stable: they genuinely run
podman inside the container, which is the job that image is actually for. Consumers with their
own [box].image are unaffected, as ever; generic-box consumers pick the new base up on their
next image rebuild and swap their dnf tool steps for apt (or for playwright install-deps,
which now works).
Follow-on (2026-09-08). The live box e2es kept the old Fedora image as the throwaway base for
the sibling box they stand up, so the rig proved the box wiring on a distro the product no longer
ships. They now run Debian too, pinned once in tests/e2e_box.py
(buildpack-deps:trixie-curl — the trixie official image that already carries curl +
ca-certificates, so no box-side install and therefore no egress is needed to reach the contract).
box.py's CA-trust snippet still handles both update-ca-certificates and update-ca-trust:
that branch is for consumers who bring a Fedora/RHEL [box].image, not for the rig — it just no
longer has a test exercising it. The nested-virt docs keep quay.io/podman/stable for the reason
above.