Running tests safely on a shared box¶
af's test suite and play-tests drive real tmux servers and real af
daemons. The tests themselves are hermetic (every tmux-touching package
runs on a private tmux server via internal/testguard, so plain
go test ./... is safe on a normal machine and in CI) — but on a shared
dev box where a real af daemon and other people's tmux sessions are
running, one escaped process or one environment mistake is an outage.
This repo ships a container harness that makes that whole class
structurally impossible: the suite and play-tests run inside docker (or
podman) with no access to the host tmux server, the real
~/.agent-factory, or the repo checkout (mounted read-only).
Requirements: docker or podman on PATH. Everything else lives in the
image (scripts/container/Dockerfile.test: golang 1.25 + tmux + git).
make test-container — the full suite¶
Runs go test -count=1 ./... inside the container. This is the one
sanctioned way to run the bare full suite on a shared box — on the host,
skip the daemon package (go test $(go list ./... | grep -v '/daemon'))
or use this target.
What the harness does:
- mounts the repo read-only at
/srcand copies it to a writable tree inside the container before running, so no test can modify the checkout; - caches modules and build artifacts in named volumes
(
af-testbox-gomod,af-testbox-gobuild) so the second run is fast; - if the host has a warm Go module cache, exposes it as a read-only
file://module proxy, so runs need no network (go.sumstill verifies every module); - caps pids and memory (
AF_TESTBOX_PIDS,AF_TESTBOX_MEMORY) so a runaway process generator suffocates inside the container instead of taking the box down; - cleans up after itself on the way out, pass or fail, so repeated runs don't
grow
/var/lib/docker— see disk footprint.
Disk footprint¶
This harness once filled a 2TB dev box (#2133). / hit 100%, and the first
thing to break was not the tests — it was the running af daemon, which
shares the filesystem and could no longer persist session state. So every
target here is now self-limiting on disk, and none of the cleanup can
reach anything the harness did not create.
Every image, container, and cache volume the harness makes carries the label
af.harness=testbox — the same idea as the af.session label the docker
backend puts on session containers. Cleanup filters on it, so a prune here can
only ever match the harness's own artifacts.
On every run, on the way out — including when the suite fails, which is exactly the run you re-run and therefore the one that used to compound fastest:
- containers are
--rm, so no writable layer and no anonymous volume survives; - images this harness built that no longer carry a tag are pruned. Every target
builds a stable tag, so editing a Dockerfile — or just running from a
sibling worktree whose Dockerfile differs — moves the tag off the previous
image (~1.2GB for the testbox image, ~4GB for web-selftest). Whether that
image then lingers depends on the engine: the classic
overlay2image store leaves it as a dangling<none>forever, while the containerd snapshotter (docker 29's default) collects it itself. The reap costs nothing on the engines that don't need it; - the shared BuildKit cache is held under a ceiling (default 10GB,
AF_TESTBOX_CACHE_MAX). This is the one cleanup that cannot be label-scoped — BuildKit cache records carry no labels — so it is deliberately a ceiling and not a wipe: docker evicts least-recently-used records until the total fits, which leaves another builder's warm cache alone as long as it is warmer than ours, and anything evicted is rebuildable. SetAF_TESTBOX_CACHE_MAX=offto skip it entirely.
The net effect: images, containers and build cache cost about as much for N
runs as for one. The Go cache volumes are the honest exception — they keep
growing with how much you build, bounded only by Go's own 5-day cache trim,
which is why make testbox-clean exists.
When you want the space back, including the Go caches:
That removes the harness's stopped containers and images, empties the four
named cache volumes (af-testbox-{gomod,gobuild},
af-web-selftest-{gomod,gobuild}), caps the build cache, and prints
docker system df. The Go build cache volume is the largest thing here — tens
of GB on a busy box — and no automatic step touches it, because emptying it
costs the next run a full cold rebuild. Go trims its own cache at 5 days, so it
is bounded, just generously.
make testbox-clean reports running containers rather than removing them
and prints the exact docker rm -f for each. On a box with several worktrees a
running labelled container is most likely a sibling's in-flight suite or a
parked play-test sandbox, and a cleanup target is not allowed to be the thing
that kills it.
Nothing here ever runs docker system prune or an unfiltered
docker volume prune. Both would have fixed the disk and deleted co-tenants'
images to do it. make testbox-selftest asserts exactly that, against a fake
docker, in about a second — it gates every PR.
Tagged-image cleanup is also serialized against the only unsafe window in a
sibling run: from rebuilding a stable harness tag until Docker/Podman reports
the first container using it as running. The lock is shared across worktrees and
released at that positive engine observation, so suites still run concurrently;
afterward the container reference itself prevents image deletion. This keeps
image prune -a and the legacy exact-tag cleanup fallback from deleting a local-
only image between build and run, and also keeps two sibling Dockerfile builds
from retagging the name out from under a not-yet-created container.
Before a run, if free space on the docker root filesystem is under 20GB
(AF_TESTBOX_MIN_FREE_GB, 0 silences it), the harness says so and points at
make testbox-clean. It warns and continues rather than refusing: nothing here
knows how much room a given run needs, and a harness that won't start is its
own outage. Every way of not being able to tell — a remote engine, an
unreadable root — stays quiet instead of guessing.
What actually accumulates¶
Measured on the box that filled up, so the next person doesn't have to re-derive it. Categories, largest first:
| Category | Bounded by | Notes |
|---|---|---|
| Cache volumes (~30GB) | Go's own 5-day cache trim; make testbox-clean |
The largest item by far, and docker system prune -af does not touch volumes |
| Build cache (~5GB) | the per-run ceiling above | Nothing pruned it before |
| Harness images (~5GB) | the per-run reap above | Only strands copies on overlay2; see above |
| Containers | --rm |
Never a leak; --rm was always there |
One correction to the incident write-up in #2133 while we're here. The reclaim
that recovered the box was docker system prune -af plus clearing the
host's ~/.cache/go-build, and the "post-prune /var/lib/docker was 4.0K"
reading that the write-up took as "docker held essentially all of it" was a
du without root — /var/lib/docker is drwx--x--- root root, so a non-root
du prints a permission error on stderr and 4.0K on stdout. Docker did not
hold all 312GB; the host Go build cache held a share of it that this reading
could not see. Both are regenerable, and only the docker half is this harness's
to bound — but if this box fills again, check ~/.cache/go-build too.
One-time note for boxes that ran the harness before this landed: images built then carry no label, so the label-scoped reap cannot see them. Clear that backlog once with
docker image prune -f(dangling images only).
make remote-roundtrip-container — mock remote hooks¶
Runs the focused remote-hook round-trip inside the testbox (#1592 Phase 4 PR7).
The test builds af, configures a repo backend=hook with a mock launch_cmd
that clones the workspace and starts a REAL af agent-server on the host, then
drives the migrated provision-and-expose path end to end: create (launch_cmd
echoes the agent-server's {url,token}) → the daemon drives it
over http:///ws:// → Subscribe + typed Input echoes back over the stream → Preview/
Snapshot/Alive reflect the pane → Kill runs delete_cmd, which reaps the
agent-server (no leak). It does not need Docker-in-Docker; the agent-server runs
as a host subprocess inside the already-isolated test container (needs git + tmux).
make playtest-container — TUI play-testing¶
Builds af from your checkout inside the container and drops you into a
shell with a ready-made sandbox: a throwaway AGENT_FACTORY_HOME (with
program_overrides so instances run bash, not real agents), a small
mock project repo to drive sessions against, and the container's own tmux
server — tmux kill-server in there is harmless. Exiting the shell tears
down everything: the daemon, the tmux server, and every process the
play-test spawned. Teardown is container exit, not a checklist.
For scripted/agent-driven play-tests (the tui-playtest skill), park the
sandbox in the background and drive it with docker exec. The container name
defaults to a unique per-run value (#1171) so concurrent runs can't
docker rm -f each other — pin it with AF_PLAYTEST_NAME so every
docker exec/rm targets this run's container:
export AF_PLAYTEST_NAME="af-playtest-$$"
make playtest-container-detached
docker exec "$AF_PLAYTEST_NAME" sh -c 'until [ -x /home/dev/bin/af ]; do sleep 1; done'
docker exec "$AF_PLAYTEST_NAME" tmux new-session -d -s drive -x 80 -y 24
docker exec "$AF_PLAYTEST_NAME" tmux send-keys -t drive 'cd ~/sandbox/mock-repo && af' Enter
docker exec "$AF_PLAYTEST_NAME" tmux capture-pane -p -t drive
docker rm -f "$AF_PLAYTEST_NAME" # teardown: one command reaps everything
Rather than hand-rolling send-keys/capture-pane/sleep, drive the TUI
through the deterministic driver (scripts/tui-driver.sh): every action
waits on a screen marker instead of a blind sleep, and it ships real
assertions. make tui-driver-selftest is the acceptance gate; make
tui-driver drops you into a live driven session. See
tui-manual-testing.md.
make lifecycle-container — clean install + upgrade¶
The one target here that does not just test this tree: it installs a REAL
previous release into a throwaway machine, puts sessions on it, upgrades it the
way a user does (af upgrade and the launch-time auto-update), and asserts the
machine is coherent afterwards — daemon restarted onto the new binary, no
client/daemon version skew, exactly one daemon, sessions survived, af doctor
clean. That version boundary is where #1921 and #796 shipped, and no
single-version test can construct it.
Two things make it unlike the other targets: it needs network (it downloads published release tarballs), so it is wired nightly rather than per-PR; and it cannot cover the autostart-supervision assertion here, because there is no systemd in the container — that assertion is SKIPped loudly and covered by the CI runner leg. See lifecycle-testing.md.
Known limitations¶
- No systemd inside — autostart-unit flows (
af daemon install) still need CI or careful host testing. It fails outright in the container ("systemctl: executable file not found"), which is why the lifecycle gate SKIPs its supervision assertion here and runs it on the CI runner instead. - Network-dependent flows (
gh, pushing branches) need an explicitly injected token; the sandbox has none by default, which keeps external access opt-in and visible. - The first image build downloads the golang base image (a few minutes); every run after that reuses cached layers.
Where the container is NOT the answer¶
CI runs the suite bare on GitHub runners (already isolated) and external
contributors will always run plain go test ./... — both fine, because
the in-tree isolation (internal/testguard, #1122/#1125) makes the tests
themselves safe. The container is the belt-and-suspenders outer wall for
shared dev boxes, not a substitute for hermetic tests.