Building isopod inside isopod
isopod's builds run inside its own sandboxes — dogfooding the stage model with the
heaviest workload we have, and keeping toolchains and build state off the host. Proven
end-to-end over the MCP server on 2026-07-22 (see docs/dogfood-findings.md, MCP v2
gauntlet section).
Stage chain
| stage | contents | rebuild trigger |
|---|---|---|
rust-stable |
rustup stable x86_64-unknown-linux-musl under /root/.rustup + /root/.cargo |
toolchain bump |
isopod-src |
workspace source at /root/src on top of rust-stable |
source refresh |
isopod-build |
source + crates.io cache + target/ (≈1.5 GiB layer) |
after a clean build |
The chain is built once; every build after that forks the newest
isopod-build/* stage and throws the VM away. Fork the newest, not the bare
isopod-build label: that one was committed before the current dependency set
and an offline build on it fails to resolve a crate that is in the lockfile but
not in its cache (docs/dogfood-findings.md #50). stage_list — or isopod
stage ls — is how you find the newest. Source goes in over stdin, binaries come
out over copy_out, and the stage is only re-committed when you want the
refreshed target/ kept:
flowchart TB
RS["rust-stable<br/>rustup toolchain in /root/.rustup and /root/.cargo"]
RS -->|"commit_as"| SRC["isopod-src<br/>workspace source at /root/src"]
SRC -->|"commit_as"| BLD["isopod-build<br/>source + crates.io cache + target/, about 1.5 GiB"]
BLD -->|"fork, on every build"| RUN["ephemeral build VM<br/>4 vcpu · 3072 MiB · 8192 MiB scratch"]
TREE["host working tree"] -.->|"stdin_file, a tar of the changed crates"| RUN
RUN -.->|"copy_out target/release/isopod"| BIN["host binary, static-pie musl"]
RUN -.->|"commit_as isopod-build/DATE, only when you want it kept"| BLD
Every build cmd starts with:
export RUSTUP_HOME=/root/.rustup CARGO_HOME=/root/.cargo PATH=/root/.cargo/bin:$PATH
cd /root/src
Getting source in
For uncommitted local changes — or a guest without network access — use stdin_file (a host
path — works on both the CLI --stdin-file and, since the #21 fix, the MCP sandbox_run
param) rather than inline MCP stdin, which would transit the payload through model context:
Send the tarball raw. The channel is binary-safe on both surfaces — bytes are base64'd inside the protocol frame either way, so encoding them yourself first only inflates the payload by a third:
tar czf - Cargo.toml Cargo.lock rust-toolchain.toml crates > /tmp/src.tgz
isopod run --stage isopod-build/0.12.0-tested --scratch-mib 8192 --stdin-file /tmp/src.tgz -- \
/bin/sh -c 'tar xzf - -C /root/src && cd /root/src && \
export RUSTUP_HOME=/root/.rustup CARGO_HOME=/root/.cargo PATH=/root/.cargo/bin:$PATH && \
cargo build --workspace'
There is a ceiling, and it is low. PutFile is one frame, capped at
MAX_FRAME_LEN (8 MiB) after base64 — so about 6 MiB of raw input, or 4.5 MiB
if you encode it yourself as well. The MCP surface refuses at 4 MiB before booting
and names the limit; the CLI does not check, so it boots a VM and then fails with
the post-encoding byte count. isopod's own workspace source is ~540 KiB and fits
comfortably; a repo with its .git does not. Nothing larger gets in without the
network — copy_out is streamed and unbounded, but there is no inbound equivalent
(docs/dogfood-findings.md #46).
Untarring over /root/src updates only changed files' mtimes, so cargo rebuilds just the
touched crates (measured: 6.93 s after touching crates/cli/src/main.rs, vs 2 m 06 s clean).
To persist the refreshed state, add --commit-as isopod-build/<date> (label-reuse semantics
for an existing label are untested — use versioned labels until that's gauntleted).
For committed state, git clone/git pull in-guest from the remote replaces the tarball
dance (a private repo needs a read token supplied to the guest), and is the only route
for anything over the ceiling.
Everyday check/test loop (MCP)
For Claude sessions: sandbox_run with stage: "isopod-build/0.12.0-tested", vcpus: 4, mem_mib: 3072,
scratch_mib: 8192, timeout_s: 300 (600 for clean builds; commit adds ≈20 s/GiB). Run
cargo build/cargo check/cargo test as needed — since coreutils landed in base-alpine,
the full workspace test suite passes in-guest (132/132 core). Only tests needing
/dev/kvm or live host state (taps, a real ~/.isopod) stay on the host (they are
#[ignore]d live tests anyway).
Running the mutation harness in-guest
scripts/mutation-check.py is destructive by design — it edits source to prove the
suite notices — so a sandbox is where it belongs, and the host tree never has to be
trusted back. It works from git archive HEAD, so the overlaid source needs a repo:
cd /root/src
git init -q .
printf 'target/\n.git/\n' > .git/info/exclude # ← the trap; see below
git config user.email t@t.invalid && git config user.name t
git add -A && git commit -qm 'tree under test'
python3 scripts/mutation-check.py --only <mutation-name>
target/ must be excluded before git add -A, and nothing in the tarball does it
for you. The stage already carries the previous build's target/ (≈1.5 GiB) at
/root/src/target, and a source tarball built from Cargo.toml Cargo.lock crates
scripts contains no .gitignore — so git add -A sweeps all of it into the index and
git archive HEAD then tries to export it. What that looks like is a VM killed by the
OOM reaper seconds after exporting HEAD to …, with no other diagnostic, which reads
like a memory shortage and is not one: it survives unchanged at the host's memory cap
and disappears entirely once target/ is excluded. The check that tells them apart is
git ls-files | wc -l — the workspace is ~94 files.
One mutation runs in ~2.5 min at 4 vcpu / 3072 MiB / 16384 MiB scratch, most of it the
cold warming the build cache pass over the exported tree.
Getting binaries out
Use --copy-out GUEST:HOST (CLI) or copy_out: [{guest, host}] (MCP) — the streamed,
binary-safe channel with no size ceiling; mode bits (the exec bit) are preserved and byte
counts verified, with the written files listed under copied in the result:
isopod run --stage isopod-build/0.12.0-tested --scratch-mib 8192 --vcpus 4 --mem-mib 3072 --timeout-s 600 \
--copy-out /root/src/target/release/isopod:/tmp/isopod-built -- \
/bin/sh -c 'cd /root/src && export RUSTUP_HOME=/root/.rustup CARGO_HOME=/root/.cargo \
PATH=/root/.cargo/bin:$PATH && cargo build --release -p isopod-cli'
Release binaries are static-pie musl — they run unmodified on the glibc host. (The old base64-over-stdout recipe still works as a fallback but is obsolete.)
Note: replacing target/release/isopod-mcp requires restarting the MCP server, and a
PROTO_VERSION bump requires rebuilding all guest images together (finding #17).
Sizing (example: a 4-core / 6 GiB host)
One build VM at a time; 4 vcpu / 3072 MiB (3584 for release) / 8192 MiB scratch. Never run a build VM alongside a fleet of test VMs — memory pressure has killed agents before.
Rendered from docs/sandbox-build.md on the main branch.