Files
clawmates/scripts/fc-build-rootfs.sh
T
Omar SobhandClaude Opus 5 f56d41f5b7 feat(backend): local-ornith — a mission backend served by the node's own GPU
Claude Code pointed at the Ollama already installed on every GPU node. Ollama
has served a native Anthropic-compatible /v1/messages since v0.14, so this is
an env contract rather than a translation layer — the fourth variation on the
same idea as agent-glm and agent-kimi.

The route is NOT the egress proxy, and that is the design. `egress` speaks
CONNECT, takes a destination from the guest, resolves it and decides; every one
of those powers is a liability, which is why it refuses non-443 ports and IP
literals after a unit test caught them being bypassed. Routing a local model
through it would have meant relaxing both.

`local_model` is the opposite shape: there is no destination in the protocol.
fcagent listens on guest 127.0.0.1:11434 and pumps to vsock 9003; the node
splices that onto its own 127.0.0.1:11434 and copies bytes. A compromised guest
cannot redirect it because there is nothing to redirect — it is a pipe, not a
proxy, and strictly narrower than anything an allow-list could express. The
bytes never touch a network, so there is no wire for TLS to protect, and Ollama
stays bound to loopback rather than being exposed on the tailnet.

The socket is bound only for a backend declared to use a local model, so a
`local-ornith` VM reaches the forge through egress and nothing else, while every
other backend's guest port simply refuses. Both halves have negative controls.

`scripts/fleet-model-setup.sh` exists because of one measurement: stock
ornith:9b reported input_tokens=2050 for a 48000-word prompt and answered as
though nothing had been dropped. Ollama's default window is ~2K whatever the
model card says, and it truncates silently — the exact failure an agent turn
would hit and never report. The script pins num_ctx=131072 into a derived tag
and then PROVES both the window and tool calling before declaring success.
Verified on architect: ~65536 words -> 65604 input tokens, stop_reason=tool_use.

Placement needs no new capability key: building the rootfs only on GPU nodes
means `nodes::online_for_backend`'s existing `rootfs @> ["local-ornith"]`
predicate does the affinity, so morpheus never offers the backend.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-09 13:13:54 -07:00

209 lines
9.5 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
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)"