Files
clawmates/scripts/fc-build-rootfs.sh
T
Omar SobhandClaude Opus 5 794f2124bc
deploy / test (push) Successful in 5m18s
deploy / build (push) Successful in 5m54s
feat(microvm): Claude Code 2.1.276 rootfs pins, and a VM run that says what it ran
Every rootfs on the fleet had sat on Claude Code 2.1.223–2.1.226 since August
while the container tier moved to 2.1.276, and nothing recorded either. GLM
and Kimi exist only as microVM backends, so "have we upgraded GLM and Kimi"
is this change and the rebuild it drives.

Pins. All four agent-* images pin 2.1.276 — as separate ARGs, since Docker has
no include and each file has to stay reproducible alone — and
scripts/fc-build-rootfs.sh refuses to build if they disagree, naming the odd
one out. They had already drifted (claude 226, the rest 223) under comments
saying "same version on purpose". Between 2.1.226 and 2.1.276, 2.1.265 and
2.1.275 each broke every turn on ANTHROPIC_BASE_URL endpoints, which is how
glm and kimi reach `claude` inside a VM; the container-tier verification never
exercised that path, so the VM runs on those backends are the real test.

Provenance. `VmOutcome` carries the rootfs the node reported booting and the
guest's own `claude --version`; `launch_microvm_phase` persists both as
`checkpoint.vm` beside `records` (the two readers parse only `records`) and
names them in its log line. "Which image and CLI did this mission run on" is
a query now.

Independence. `evaluator` derived the implementer family from a constant
`"anthropic"`, true while every backend was Claude on Anthropic. With glm and
kimi rootfs it made a glm mission judged by glm:glm-5.3 read as
`independent = true` — the one claim that path exists to make honestly.
`implementer_family(missions.backend)` mirrors `microvm_credential_for`; the
subscription judge is now independent exactly when the agent did NOT run on
Anthropic.

Harness. `verify-mission-delivery.sh glm|kimi` run the microvm scenario on
each backend and add the proof the mission itself cannot give: the placed
node's journal must show the VM dialling that provider's host, never being
denied it, and dialling nothing else but the forge — a model's self-report is
measured worthless here. `assert_cli_version` reads checkpoint.vm. The stale
scratch-repo default (dead since the 09-14 wipe) is the re-synced id.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-18 20:16:06 -05:00

222 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
# Turn a Docker image into a Firecracker rootfs, and prove a VM boots from it.
#
# Phase B4. Until now microVMs booted Firecracker's CI Ubuntu image with a
# python guest agent bolted on: no git, no toolchain, no CLI. That is fine for
# proving vsock works and useless for running a mission.
#
# Building FROM a Docker image rather than debootstrapping a new one is the
# point. The per-CLI images (agent-claude / agent-kimi / agent-glm, per A6) are
# already Dockerfiles with a tested env contract — CLI on PATH, credentials in
# known places, toolchain present. Rebuilding that as a VM image by hand would
# mean maintaining the same facts twice and discovering the drift in production.
# `docker export` flattens the image to a tar; this wraps it in an ext4 and adds
# the guest agent.
#
# Usage:
# scripts/fc-build-rootfs.sh <ssh-host> <docker-image> [out-name] [size]
# scripts/fc-build-rootfs.sh osobh@tank clawmates/agent-base:dev agent-base 4G
#
# The result lands at $WORK/rootfs-<out-name>.ext4 on the node. It is NOT made
# the default: `vm_create` still boots `rootfs.ext4`, and repointing that is a
# deliberate act (B4.3 adds per-mission image selection).
set -uo pipefail
# Drift guard. The four agent-* images must pin ONE Claude Code version: a solo
# run and a composed run's verifier should differ by provider and by nothing
# else, and on 2026-09-18 they had already drifted (claude 2.1.226, the rest
# 2.1.223) under comments saying "same version on purpose". Refuse to build a
# rootfs from a set that disagrees, and say which.
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
pins=$(grep -h '^ARG CLAUDE_CODE_VERSION=' "$repo_root"/images/agent-{claude,glm,kimi,ornith}/Dockerfile 2>/dev/null | sort -u)
if [ "$(printf '%s\n' "$pins" | grep -c .)" -ne 1 ]; then
echo "fc-build-rootfs: the agent-* images pin different Claude Code versions — fix that first:" >&2
grep -H '^ARG CLAUDE_CODE_VERSION=' "$repo_root"/images/agent-{claude,glm,kimi,ornith}/Dockerfile >&2
exit 2
fi
WORK="${FC_WORK:-/opt/clawmates-fc}"
FAILURES=0
pass() { printf 'PASS %s\n' "$*"; }
fail() { printf 'FAIL %s\n' "$*"; FAILURES=$((FAILURES + 1)); }
die() { printf 'ABORT %s\n' "$*" >&2; exit 2; }
[ $# -ge 2 ] || die "usage: $0 <ssh-host> <docker-image> [out-name] [size]"
HOST="$1"
IMAGE="$2"
NAME="${3:-$(printf '%s' "$2" | tr '/:' '--')}"
SIZE="${4:-4G}"
OUT="$WORK/rootfs-$NAME.ext4"
# The static musl guest agent, built on the node (see B4.2).
# Single-quoted $HOME on purpose: it must expand on the NODE, not here. The
# first version used double quotes and looked for the binary under this Mac's
# home directory on a Linux host.
AGENT_BIN=${FC_AGENT_BIN:-'$HOME/clawmates/target/x86_64-unknown-linux-musl/release/fcagent'}
# The agent CLI this image is supposed to contain, probed inside the booted VM.
# Derived from the out-name so `... clawmates/agent-claude:dev claude` checks
# claude without being told twice; override with FC_CLI for anything else, or
# set it empty to skip. The first image built on this track turned out to hold
# git and nothing else, and the boot said nothing about it — the whole point of
# building an image is the CLI inside, so the builder asks.
case "${FC_CLI-unset}" in
unset) case "$NAME" in
# `glm` and `kimi` are both Claude Code pointed at another
# provider's Anthropic-compatible endpoint, so the binary in the
# image is `claude` for all three. Moonshot ship their own `kimi`
# CLI, and this map used to expect it — a leftover from before the
# endpoint was measured, which failed a perfectly good rootfs.
# `local-ornith` joins them: Claude Code again, pointed at the
# node's own Ollama. The binary in the image is `claude` for all
# four; only ANTHROPIC_BASE_URL differs.
claude|glm|kimi|local-ornith) FC_CLI="claude --version" ;;
*) FC_CLI="" ;;
esac ;;
esac
echo "── building $OUT on $HOST from $IMAGE ──"
# 1. Flatten the image to a tar and unpack it into an ext4.
#
# `docker export` on a created (never started) container gives the filesystem
# with none of the image's metadata — no ENV, no ENTRYPOINT, no WORKDIR. That
# metadata matters: a CLI that relies on ENV PATH or ENV HOME would silently
# behave differently in the VM. It is extracted separately below and written
# into the guest's profile, rather than left to be discovered later.
built=$(ssh "$HOST" "
set -e
cd '$WORK'
docker image inspect '$IMAGE' >/dev/null 2>&1 || docker pull -q '$IMAGE' >/dev/null
cid=\$(docker create '$IMAGE' /bin/true)
trap 'docker rm -f \$cid >/dev/null 2>&1 || true' EXIT
rm -f '$OUT' export.tar
docker export \"\$cid\" -o export.tar
echo \"export=\$(du -m export.tar | cut -f1)MB\"
# The ext4 is created empty and filled via a mount rather than \`mkfs -d\`:
# -d cannot handle device nodes or hard links that a container image may
# contain, and fails late and cryptically when it hits one.
truncate -s '$SIZE' '$OUT'
mkfs.ext4 -q -F '$OUT'
sudo mkdir -p /mnt/fcbuild
sudo mount -o loop '$OUT' /mnt/fcbuild
sudo tar -xf export.tar -C /mnt/fcbuild
rm -f export.tar
# Image metadata the export dropped. Written to /etc/profile.d so both a
# login shell and the agent's \`sh -c\` see it.
docker image inspect '$IMAGE' --format '{{range .Config.Env}}export {{.}}
{{end}}' | sudo tee /mnt/fcbuild/etc/profile.d/00-image-env.sh >/dev/null
sudo chmod 0644 /mnt/fcbuild/etc/profile.d/00-image-env.sh
echo \"env_vars=\$(wc -l < /mnt/fcbuild/etc/profile.d/00-image-env.sh)\"
" 2>&1) || { fail "could not build the filesystem: $(printf '%s' "$built" | tail -3)"; ssh "$HOST" "sudo umount /mnt/fcbuild 2>/dev/null; true"; exit 1; }
pass "unpacked $IMAGE into $OUT ($(printf '%s' "$built" | tr '\n' ' '))"
# 2. Install the guest agent and init.
#
# Not a systemd unit: a `docker export` rootfs usually has no init system at
# all, and adding one to boot a single agent would be a large amount of surface
# for no benefit. `init=` runs the agent as pid 1 directly — and pid 1 must
# never exit, so the init script execs it rather than backgrounding it. The
# agent mounts /proc, /sys, /dev and /tmp itself, so it works either way.
agent=$(ssh "$HOST" "
set -e
# The guest agent is a STATIC musl binary, so it needs nothing from the image.
# The python version could only run where python3 happened to be installed —
# which is no image of ours — and an agent that dictates the image's contents
# has the dependency backwards.
test -x $AGENT_BIN || { echo NO-AGENT-BINARY; exit 1; }
sudo install -m0755 $AGENT_BIN /mnt/fcbuild/usr/local/bin/fcagent
printf '%s\\n' '#!/bin/sh' 'echo FC-GUEST-ALIVE kernel=\$(uname -r) cpus=\$(nproc)' \
'exec /usr/local/bin/fcagent' | sudo tee /mnt/fcbuild/usr/local/bin/fcinit >/dev/null
sudo chmod 0755 /mnt/fcbuild/usr/local/bin/fcinit
sudo umount /mnt/fcbuild
echo installed
" 2>&1)
case "$agent" in
*installed*) pass "static guest agent installed (no image dependency)" ;;
*NO-AGENT-BINARY*) fail "no agent binary at $AGENT_BIN on $HOST — build it: cargo build --release -p fcagent --target x86_64-unknown-linux-musl"; ssh "$HOST" "sudo umount /mnt/fcbuild 2>/dev/null; true" ;;
*) fail "could not install the guest agent: $(printf '%s' "$agent" | tail -2)"; ssh "$HOST" "sudo umount /mnt/fcbuild 2>/dev/null; true" ;;
esac
# 3. Boot it. An image that builds and cannot boot is worse than no image,
# because it looks finished. Everything below runs as the daemon user.
if [ "$FAILURES" -eq 0 ]; then
boot=$(ssh "$HOST" "
cd '$WORK'
cp --sparse=always '$OUT' probe-rootfs.ext4
cat > probe-vm.json <<JSON
{
\"boot-source\": {
\"kernel_image_path\": \"$WORK/vmlinux\",
\"boot_args\": \"console=ttyS0 reboot=k panic=1 pci=off init=/usr/local/bin/fcinit\"
},
\"drives\": [{\"drive_id\":\"rootfs\",\"path_on_host\":\"$WORK/probe-rootfs.ext4\",\"is_root_device\":true,\"is_read_only\":false}],
\"machine-config\": {\"vcpu_count\":2,\"mem_size_mib\":2048,\"smt\":false},
\"vsock\": {\"guest_cid\": 3, \"uds_path\": \"$WORK/probe.sock\"}
}
JSON
rm -f probe.sock probe.log
setsid timeout 40 firecracker --no-api --config-file probe-vm.json > probe.log 2>&1 &
PG=\$!
for i in \$(seq 1 400); do grep -qa FC-AGENT-LISTENING probe.log && break; sleep 0.05; done
python3 - <<'PY' 2>&1 | tail -6
import socket, struct, json
def call(cmd):
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect('$WORK/probe.sock')
s.sendall(b'CONNECT 9001\n'); s.recv(64)
req = json.dumps({'op':'exec','cmd':cmd,'timeout':60}).encode()
s.sendall(struct.pack('>I', len(req)) + req)
n = struct.unpack('>I', s.recv(4))[0]
buf = b''
while len(buf) < n: buf += s.recv(n - len(buf))
s.close()
return json.loads(buf)
# What a mission actually needs, asked of the image rather than assumed.
probes = [('git', 'git --version'),
('shell-env', '. /etc/profile.d/00-image-env.sh 2>/dev/null; echo PATH=\$PATH'),
('write', 'mkdir -p /mission && echo ok > /mission/x && cat /mission/x')]
cli = '''$FC_CLI'''
if cli:
probes.append(('cli', cli))
for label, cmd in probes:
try:
r = call(cmd)
print('%s rc=%s %s' % (label, r.get('rc'), (r.get('stdout') or r.get('stderr') or '').strip()[:90]))
except Exception as e:
print('%s UNREACHABLE %s' % (label, e))
PY
kill -- -\$PG 2>/dev/null
rm -f probe.sock probe-rootfs.ext4 probe-vm.json
" 2>&1)
printf '%s\n' "$boot" | sed 's/^/ /'
case "$boot" in
*"git rc=0"*) pass "the built rootfs boots and has git" ;;
*UNREACHABLE*) fail "the built rootfs booted but its agent was unreachable" ;;
*) fail "the built rootfs did not provide git" ;;
esac
case "$boot" in
*"write rc=0"*) pass "the guest can write to /mission" ;;
*) fail "the guest could not write to /mission" ;;
esac
if [ -n "$FC_CLI" ]; then
case "$boot" in
*"cli rc=0"*) pass "the guest provides the agent CLI (\`$FC_CLI\`)" ;;
*) fail "the guest does NOT provide \`$FC_CLI\` — this image has no agent to run" ;;
esac
else
echo " (no agent CLI probed for out-name '$NAME' — set FC_CLI to check one)"
fi
fi
echo
if [ "$FAILURES" -gt 0 ]; then
echo "$FAILURES check(s) failed"
exit 1
fi
ssh "$HOST" "ls -lh '$OUT' | awk '{print \$5\" \"\$9}'"
echo "rootfs ready (not yet the default — vm_create still boots rootfs.ext4)"