feat(agentenv): deliver the runner bundle as a read-only volume
Summary
- Publish AgentENV read-only host trees as content-addressed volumes (
preloop-runner-<sha256 tree, 12 hex>) and attach them withaenv start --volume. - Populate once through an exclusive staging volume, verify the archive digest in-guest, freeze a read-only fork, and reuse it across starts with per-process memoization that is revalidated on every use.
- Keep AgentENV fork bases volume-free: AgentENV 0.2.1 rejects snapshots of sandboxes with read-only volumes.
fork_with_volumesattaches the current bundle to each clone instead. - The complete bundle carries Node externals; AgentENV uses one mount at
/opt/preloop/binand links/home/runner/externalsinto it.preloop volumes list|prunemanages lifecycle cleanup.
Base and the dependency split
This branch is based on chore/preloop-vm-hex-sha2 (#665, chore(deps): add hex and sha2 to preloop-vm), not on main.
The volume work needs sha2 and hex, which changes Cargo.lock. The repository requires a lockfile change to be a dedicated dependency PR, so that change is now #665: crates/preloop-vm/Cargo.toml plus the two lockfile edges inside the existing preloop-vm entry (no new packages, no version changes). It passes the supply-chain guard as a dedicated dependency PR — no dependency-migration label, which this PR never relies on.
Merge order: #665 first, then this PR retargets to main and rebases. git diff chore/preloop-vm-hex-sha2...HEAD -- Cargo.lock crates/preloop-vm/Cargo.toml is empty: this PR no longer touches either file. Its diff against its base therefore contains no policy file at all.
Verified locally with the new guard script (PR #666, supply-chain/guard-policy-files.sh): the dependency PR (main → chore/preloop-vm-hex-sha2) reports Dependency/policy PR detected: OK for Cargo.lock + crates/preloop-vm/Cargo.toml, and this branch (chore/preloop-vm-hex-sha2 → this head) reports Policy file guard: OK (no policy or manifest files modified).
Rebased onto main
The branch was rebuilt from main @ fd08d18 (the merge of #620) by cherry-picking only this PR's own commits. The other four commits that used to be on this branch (the guest-wrapper refactor, sudo -n, the DNS fix, resume-before-snapshot) are already on main in rebased form via #620, so they were dropped rather than replayed.
Conflicts, and how each was resolved:
crates/preloop-vm/Cargo.toml/Cargo.lock— resolved by the split: the dependency PR owns them, this branch's commits no longer touch them.CHANGELOG.md— both sides added entries under## [Unreleased]. Keptmain's structure and appended this PR's entry tomain's### Changed.crates/preloop-vm/src/agentenv.rs— merged textually clean, then checked hunk by hunk. #620's snapshot behaviour is intact and this PR's volume path replaces the old materialization path:resume_if_pausedandsnapshot_runningare unchanged frommain, including the re-pause on success and on error, and both call sites (packand the fork path) still go throughsnapshot_running.- The old per-start copy path (
materialize_volumes,ensure_volumes,volumes_materialized,tar_directory) is gone, replaced byread_only_mounts/ensure_read_only_volume/populate_volume. prepare_guest_dirsstays, reduced to the secret directory it still owns;startkeepsmain's "record the id before anything else can fail" ordering and now passesrecord.spec.volumesintostart_sandbox_supervised.
crates/preloop-vm/tests/agentenv_provider.rs— merged clean. Every testmaingained in #620 is kept and none needed a signature change:guest_scratch_directories_are_prepared_through_the_root_or_sudo_wrapper,a_guest_without_passwordless_sudo_fails_start_with_the_prerequisite_named,an_explicit_dns_is_refused_before_anything_runs_and_an_absent_one_starts,pack_resumes_a_paused_machine_before_snapshotting,pack_pauses_the_sandbox_again_after_snapshotting_it,pack_pauses_the_sandbox_again_when_the_snapshot_fails,fork_pauses_the_golden_again_after_snapshotting_it.crates/preloop-orchestrator/src/lib.rs,crates/preloop-vm/src/lib.rs— merged clean; #620's code is untouched and the volume capability/fork changes are additive.
Two tests are removed rather than adapted, because the code they covered no longer exists: host_volumes_are_streamed_in_as_a_tar_and_read_only_mounts_lose_write_bits and a_clone_inherits_materialized_volumes_instead_of_re_uploading_them. They asserted the old copy-into-the-guest design that this PR replaces.
Review fixes
- Stale volumes (devin,
agentenv.rs). A remembered volume is no longer trusted: each start re-derives a cheap tree fingerprint (a metadata walk — names, types, modes, sizes, mtimes, link targets, plus the version pins — not an archive) and re-checks that the volume still exists before mounting it. An in-place bundle update therefore publishes a new volume, and a volume deleted under a running engine is populated again instead of being mounted dead. - One lock for every tree (devin). The single mutex around archiving and populating is now one lock per host tree: different trees populate concurrently, repeated starts of one tree still populate once.
- Symlinks that escape the volume (devin, security). A symlink whose target is absolute, or whose relative target climbs out of the tree with
.., fails the archive with an error naming the link and its target. Every link in the tree is checked, so a chain cannot escape through an unchecked link either. The six links in the real bundle (externals/node{20,24}/bin/{npm,npx,corepack} -> ../lib/node_modules/...) are relative and in-tree, so nothing legitimate is refused. - Set-user-ID, set-group-ID and sticky bits.
mode_ofmasked0o7777; it now masks to0o777, for directories as well as files, so a host bundle cannot hand a guest a setuid binary or a sticky directory. A tree that had those bits publishes a new volume (new name), whichprunereclaims. AgentENV decides the volume's mount flags, sonosuid/nodevare not something this backend can set or verify. - Manifest inside the volume. The archive carries a synthetic
.preloop-bundle.jsonat the tree root — never written to the host directory — with sorted keys and nothing time- or host-dependent:{"bundle_bytes":…,"bundle_files":…,"node_externals":{"node20":…,"node24":…},"runner_version":…,"schema":1}. The versions come fromversions.tomlthroughpreloop_orchestrator::runner_bundle_manifest()(build.rscompiles the pins into the CLI and the orchestrator, not intopreloop-vm, so the provider is handed the manifest); an unknown pin is recorded asunknowninstead of failing. It is part of the digest, so a version change publishes a new volume. - Local volume index. When a populate succeeds the engine writes
<preloop home>/agentenv-runner-volumes.json(atomic,0600, best-effort) mapping the volume name to the manifest, the host tree, and the creation time — the creation time lives only there, never in the tree.preloop volumes listprints runner/Node versions from that index and marks a volume with no entryunknown (pre-manifest or created elsewhere), because AgentENV cannot be asked what is inside a volume without mounting it. - Default prune kept the wrong bundle (devin, codex P2). The default keep list is now the same selection the pool makes:
discovered_runner_bundle()(the same discoverylocal_runner_pool_configuses,PRELOOP_RUNNER_BUNDLEor the installed/development bundle beside the binary) passed throughpreloop_orchestrator::effective_runner_bundle_path()(including the engine-owned mirror), plus the standalone externals tree only when it exists. A missing externals tree no longer aborts a prune. - Dry run misreported mounted volumes (devin). A dry run now reports
candidatefor each volume a real run would attempt to delete, and claims nothing was deleted. It cannot reportin usefor a specific volume: AgentENV reveals a reservation only when a delete is attempted (HTTP 409: volume is reserved by sandbox …), and no read-only API exposes it (the CLI'svolumerecord is{volumeID, name, mode, sizeMB, status}, andaenv ls --output jsondrops the sandbox'svolumeMounts). The real run still classifies from the server's own refusal, so itsin uselist is authoritative. - Pruning versus a running engine (coderabbit). The doc claim that a deleted volume is simply repopulated is now true rather than aspirational, and is stated where it belongs: with revalidation, deleting a live volume costs a repopulate, not a failure; the unsafe window is a volume deleted while it is being populated (between its create and its first mount).
delete_volumes(names)was extracted so a caller that plans a set (the automatic collection pass being built on top of this branch) can delete exactly what it classified. - Lockfile threads (devin, codex P1). Answered by the split above: #618 no longer touches
Cargo.lock; the two edges live in #665, and no label is involved. - Rename.
preloop runner-volumesis nowpreloop volumes(list|prune),crates/preloop-cli/src/runner_volumes.rs→volumes.rs, and the provider API follows:list_volumes/prune_volumes.listgained aKINDcolumn (runner-bundle, orrunner-bundle (staging)for a populate leftover). The volume name prefix stayspreloop-runner-for runner-bundle volumes. The command is unreleased, so this is a clean cutover: no alias, no shim.
Evidence
Experiments on cpane used only rvtest-* resources, one sandbox at a time, and were cleaned up. No engine/config/golden/zot changes were made.
- Bundle: 422,129,834 bytes, 11,157 entries; host deterministic tar: 431,400,960 bytes in 2.26 s.
- Exclusive populate: volume create 1.47 s, sandbox start 3.06 s, 431 MB upload 0.95 s, unpack 1.01 s, commit on sandbox delete 54.6–58.4 s, read-only fork 1.3 s.
- Host and guest manifests match (paths/types/modes/symlink targets/content hashes).
- Read-only mount: uid 1001, ext4
ro,relatime,norecovery, nonoexec; runner and Node 20/24 execute. User and root writes fail with EROFS; root remount fails. - Two simultaneous readers mount successfully. Delete while mounted is refused with
HTTP 409: volume is reserved by sandbox .... - Pause/resume with a read-only mount works.
- Snapshotting a sandbox with a read-only mount fails in AgentENV 0.2.1 (
HTTP 500 ... volume ... must enter uploading state). A plain snapshot with no volume, followed by clone starts with--volume, works; two such clones run the runner and Node successfully. - A single bundle mount plus
rm -rf /home/runner/externals && ln -s /opt/preloop/bin/externals /home/runner/externalsworks end to end for runner and Node binaries. A second direct mount at/home/runner/externalsfails in envd; the implementation therefore uses the single-volume layout. - After deleting the exclusive source, a fresh ro mount produced identical
full manifests: host84e1230621aea3cfa64fafb88853963fae5ba896f021df2554ecbbb068499993
/b2155bc3ffd70e6ab8a45cbb17efcf9c04ee01a3ebe0aa05aca05b67312b4d5a;
guest produced the same two digests. Runner 0.2.0 and Node 20/24 executed
from that post-delete mount. - An adopted-alias smoke clone ran as uid 1001, linked
/home/runner/externals -> /opt/preloop/bin/externals, executed Node 20/24
and runner 0.2.0, and matched runner SHA256
1151129f0403b355b632804e54ac2e31b4527a2d8e03a4f681415eb57c906dda. - The six symlinks in the real bundle (
~/preloop-aenv/bundle) are all relative
and in-tree, which is what the archive's symlink check expects.
Verification (on the rebased tree)
Run in clean worktrees on this machine with the repo's kache build cache. Test commands were run with --locked (the gate commands plus a lockfile check):
| Gate | This head | Previous head | main today |
|---|---|---|---|
cargo fmt --all -- --check | clean | clean | — |
cargo clippy --locked -p preloop-vm -p preloop-cli -p preloop-orchestrator --all-targets -- -D warnings | clean (exit 0) | clean | — |
cargo test -p preloop-vm | 107 passed | 96 | 83 |
cargo test -p preloop-cli volumes | 4 passed | 2 | 0 (no such command) |
cargo test -p preloop-orchestrator | 164 passed | 164 | 164 |
Counts: preloop-vm = 53 lib + 37 tests/agentenv_provider.rs + 17 tests/provider.rs + 0 doc-tests. preloop-orchestrator = 150 + 14 + 0 doc-tests. No failures, no ignored tests. cargo vet --locked on the dependency PR: Vetting Succeeded (17 fully audited, 2 partially audited, 467 exempted).
Where the growth comes from: +11 in preloop-vm (six volume unit tests — manifest determinism, a changed pin, an unknown pin, escaping symlinks, masked mode bits, a plain tree — and five provider tests — in-place update, pruned volume, index round-trip, dry-run candidates versus a mounted-volume refusal, concurrent populates of different trees), and +2 in the CLI (volumes) for the keep list and the list rendering. The orchestrator suite is unchanged: the test-name set is identical to main's.
CI notes
- zizmor (
impostor-commitondtolnay/rust-toolchain@686976e1…) is a stale run, not this branch. The failing check flagged that pin atci.ymllines 48/152/296/420, which is exactly the old base's file (fix/agentenv-guest-privileges, andmainbefore9d67035); this head'sci.ymlpinsb725eda5…at 48/153/299/423 and this PR does not touch.github/workflowsat all. The run's own step table shows it checked out the old toolchain pin, i.e. the pre-retarget merge ref.uvx zizmor .github/workflows/(v1.31.1) on this head reports "No findings to report". A fresh run on the new head is what settles it. rust shard 4 of 4:tests::kill_vm_processes_in_kills_only_the_named_machines_hypervisor. Not this branch's regression: this PR's diff tocrates/preloop-vm/src/lib.rsis capabilities plusfork_with_volumesand does not touchkill_vm_processes_inor that test, and the failing run also used the stale checkout above. Locally the test passes 20/20 in a loop andcargo nextest run -p preloop-vm --partition hash:4/4passes (nextest reports itLEAKy — the test's fake_boot-vmshells leave theirsleep 60children behind — which is a nextest annotation, not a failure). It scanspsfor_boot-vmprocesses by machine data dir, so on a shared runner it is exposed to other jobs' processes; the assertion to watch iskilled == 1.
Operator switch-over
Do not deploy this PR without reviewing the AgentENV service's existing binary/unit. Once #665 and this PR are merged, keep PRELOOP_VM_BACKEND=agentenv and switch PRELOOP_RUNNER_BUNDLE to the combined bundle before replacing the engine binary:
- cpane's engine currently points
PRELOOP_RUNNER_BUNDLEat/home/bnjoroge/preloop-guest-privs-release3/bundle(runner only). - The combined runner bundle — the runner plus the Node externals it now carries into
/opt/preloop/bin— sits in~/preloop-aenv/bundle. PointPRELOOP_RUNNER_BUNDLEat that tree, then replace the engine binary and restart the engine so the per-process volume memo is rebuilt.
The first bundle version populates its volume once; later starts mount it by name. A changed bundle tree — including a changed version pin, because the manifest is part of the digest — is a new volume, so the old one is reclaimed by preloop volumes prune, not by a golden rebuild. Because every existing tree's digest changes when the manifest lands, the volumes published by earlier builds become unreferenced and are reclaimed by the next prune. Run prune while no runner is being provisioned; it is otherwise safe while the engine runs, because a remembered volume is revalidated before reuse and repopulated if it is gone. This PR does not deploy or modify any live engine/config.
Not verified
- No real job has been run on a clone with the read-only bundle volume. The evidence above shows the runner and Node executing from a read-only mount, a pause/resume, a mounted-delete refusal, and manifest identity — not a workflow job (registration, a step, artifact/cache I/O) completing on an AgentENV clone whose
/opt/preloop/binis anrovolume. Treat that as the merge gate before deploying. - The design assumes the runner never writes under
/opt/preloop/bin. That is already how production SmolVM mounts the same host tree:crates/preloop-orchestrator/src/lib.rs:107-109mountseffective_runner_bundle(config)at/opt/preloop/binwithread_only: trueand the runner is executed from there (crates/preloop-orchestrator/src/lib.rs:6971), so a read-only bundle directory is not a new assumption for the runner. It is still an assumption for this mount path: AgentENV enforces it with a block device instead of virtiofs. A grep over non-test CLI and runner code showscurrent_exe()only ever locates a binary to exec, never a directory to write into — but that is static reading, not a run. - A supporting local experiment on the SmolVM side (not AgentENV) ran the real bundled binaries —
preloop-runner(run --once,configure --no-externals) and Node 20/24 — from the same~/preloop-aenv/bundletree mounted read-only both as--disk img:roand as--volume <dir>:/opt/preloop/bin:ro: no write-class syscall anywhere under/opt/preloop/bin, noEROFSfrom them, and a read-write copy's tree manifest stayed byte-identical. Job-level execution was not exercised there either (configureaborts early without a CA bundle), so this narrows the gap but does not close it. - The measurements in Evidence were taken before the rebase, on cpane, against AgentENV 0.2.1. Nothing in Evidence was re-run after the rebase; only the gates in Verification were.
- The manifest and the volume index are covered by unit and provider tests over the fake
aenv, not by a populate against a live server: no volume has been archived and mounted with a manifest, and no index has been read back from a real engine home. The index is best-effort by design (a failed write is logged), sovolumes listdegrades tounknownrather than failing. - The dry run's
candidateoutput is a deliberate limitation, not a stand-in for a reservation query: AgentENV 0.2.1 has no read API for volume reservations, so a dry run cannot sayin usefor a specific volume. It is also not a promise: a volume a sandbox mounts will be reportedin useby the real run and left alone. preloop volumes pruneis exercised by provider tests over the fake CLI, not against a live engine. Itsin useclassification and theprune-while-idle caveat come from the AgentENV behaviour observed during the populate experiments, not from a production prune.- Fork bases are volume-free because of an AgentENV 0.2.1 limitation (
fork_bases_may_mount_read_only_volumes: false). If a later AgentENV can snapshot a sandbox with anrovolume mounted, that flag should be revisited — otherwise every future clone keeps paying the per-clone volume attach.
Base
Rebased onto main (fd08d18, the merge of #620), then rebased onto chore/preloop-vm-hex-sha2 (#665) for the lockfile split. After #665 merges this retargets to main.