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
222 lines
10 KiB
Bash
Executable File
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)"
|