Merge: B4.4a real agent-claude microVM image + fail-closed CLI check

This commit is contained in:
Omar Sobh
2026-08-05 11:33:11 -07:00
4 changed files with 269 additions and 3 deletions
+82
View File
@@ -155,6 +155,29 @@ fn rootfs_for(backend: Option<&str>) -> Result<PathBuf, String> {
Ok(path) Ok(path)
} }
/// The agent CLI a backend image is named for, and the command that proves it
/// is present.
///
/// A rootfs built from the wrong Dockerfile boots perfectly well and then has
/// no agent inside it. The first rootfs built on this track came from
/// `clawmates/agent-terminal:dev`, which turned out to contain git and nothing
/// else — no `claude`, no `node`. Nothing about the boot said so; it would have
/// surfaced as a mission that ran, produced no files, and reported completed.
/// So the selftest asks the image directly rather than trusting its name.
///
/// An unrecognised backend has no required CLI — that is reported as unchecked,
/// never as a pass.
fn required_cli(backend: Option<&str>) -> Option<(&'static str, &'static str)> {
match backend {
Some("claude") => Some(("claude", "claude --version")),
Some("kimi") => Some(("kimi", "kimi --version")),
// GLM is Claude Code pointed at z.ai's anthropic-compatible endpoint;
// the binary in the image is still `claude`.
Some("glm") => Some(("claude", "claude --version")),
_ => None,
}
}
/// Boot a VM and wait until its agent answers. /// Boot a VM and wait until its agent answers.
/// ///
/// "Started" is not "usable": a VM whose agent never comes up is a process that /// "Started" is not "usable": a VM whose agent never comes up is a process that
@@ -552,6 +575,43 @@ pub async fn selftest() -> bool {
format!("{r:?}"), format!("{r:?}"),
); );
// The image must actually contain the agent the mission will run. This is
// the check that separates "a VM booted" from "a mission could run in it",
// and it is exec'd inside the guest rather than inferred from the image name.
match required_cli(backend.as_deref()) {
Some((cli, probe)) => {
let r = exec(&vms, id, probe, None, 60).await;
let (rc, out) = match r.as_ref() {
Ok(v) => (
v["rc"].as_i64(),
v["stdout"].as_str().unwrap_or_default().trim().to_string(),
),
Err(e) => (None, e.clone()),
};
check(
rc == Some(0),
&format!("the guest provides the {cli} CLI"),
format!("`{probe}` gave rc={rc:?} {out}"),
);
if rc == Some(0) {
println!(" {cli}: {out}");
}
// git is what delivery is built on: the host captures a phase by
// diffing the collected tree, so an image without git delivers
// nothing no matter which CLI it has.
let r = exec(&vms, id, "git --version", None, 30).await;
check(
r.as_ref().map(|v| v["rc"] == json!(0)).unwrap_or(false),
"the guest provides git",
format!("{r:?}"),
);
}
None => println!(
"SKIP no agent CLI is required of backend {} — its contents are unchecked",
backend.as_deref().unwrap_or("(default rootfs.ext4)")
),
}
let r = destroy(&vms, id).await; let r = destroy(&vms, id).await;
check( check(
r.as_ref() r.as_ref()
@@ -592,6 +652,28 @@ pub async fn selftest() -> bool {
mod tests { mod tests {
use super::*; use super::*;
/// Each per-CLI backend must name a CLI to probe for, or a rootfs built
/// from the wrong image passes the selftest by saying nothing.
#[test]
fn every_per_cli_backend_declares_the_cli_it_must_contain() {
for (backend, want) in [("claude", "claude"), ("kimi", "kimi"), ("glm", "claude")] {
let (cli, probe) = required_cli(Some(backend))
.unwrap_or_else(|| panic!("backend {backend} requires no CLI"));
assert_eq!(cli, want, "backend {backend}");
assert!(probe.starts_with(cli), "probe {probe:?} must run {cli}");
}
}
/// And an unknown backend reports "unchecked", which the selftest prints as
/// SKIP. Returning a plausible default here would claim a guarantee about
/// an image nobody has looked inside.
#[test]
fn an_unknown_backend_requires_no_cli_rather_than_a_guessed_one() {
for b in [None, Some(""), Some("default"), Some("agent-terminal")] {
assert!(required_cli(b).is_none(), "backend {b:?}");
}
}
/// A vm id becomes a path component, so it must not be able to describe a /// A vm id becomes a path component, so it must not be able to describe a
/// path. These are rejected, not sanitised — a caller that sent `../../etc` /// path. These are rejected, not sanitised — a caller that sent `../../etc`
/// wanted something we should not silently reinterpret. /// wanted something we should not silently reinterpret.
+39
View File
@@ -0,0 +1,39 @@
# Plan A6, first of three: one image per agent CLI, independently versioned.
#
# Everything shared lives in agent-toolchain (git, node, rust, scanners, tea).
# This layer is only the CLI and its env contract, so bumping Claude Code does
# not rebuild 3 GB of toolchain and cannot disturb agent-kimi / agent-glm.
#
# Build (on the node that will run it — see agent-toolchain for why this is not
# in AGENT_IMAGES):
#
# ssh osobh@tank "cd ~/clawmates && \
# docker build -f images/agent-toolchain/Dockerfile -t clawmates/agent-toolchain:dev images/agent-toolchain/ && \
# docker build -f images/agent-claude/Dockerfile -t clawmates/agent-claude:dev images/agent-claude/"
#
# Then turn it into a microVM rootfs and prove a VM boots from it:
#
# scripts/fc-build-rootfs.sh osobh@tank clawmates/agent-claude:dev claude 8G
FROM clawmates/agent-toolchain:dev
# Pinned: an unpinned `npm i -g` makes the image's behaviour depend on the day
# it was built, and a mission that regresses would have no version to compare.
ARG CLAUDE_CODE_VERSION=2.1.220
RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
&& npm cache clean --force \
&& rm -rf /root/.npm \
&& claude --version
# The CLI reads its credentials from $HOME/.claude. On the container path HOME is
# /zeroclaw-data because the daemon owns it; here there is no daemon, so HOME is
# just root's home. Credential injection (B4.4) writes into this directory over
# vsock at VM start so the credentials live and die with the VM and are never
# baked into the image.
ENV HOME=/root \
CLAWMATES_AGENT_CLI=claude
RUN mkdir -p /root/.claude
# No ANTHROPIC_API_KEY, and none is accepted: this backend authenticates by
# subscription via CLAUDE_CODE_OAUTH_TOKEN. An API key present in the
# environment silently overrides the subscription OAuth (fixed once already,
# task #16) and would bill per-token against a plan we already pay for.
+119
View File
@@ -0,0 +1,119 @@
# The shared mission toolchain: everything a mission phase needs that is NOT
# the agent CLI itself. The per-CLI images (agent-claude / agent-kimi /
# agent-glm, plan A6) are FROM this, so the toolchain facts live in one place.
#
# Why a base image rather than three self-contained Dockerfiles: this layer is
# ~3 GB and ~15 minutes to build. Copying it into each per-CLI image would mean
# three builds, three copies on disk, and — the actual risk — three places for
# the scanner and toolchain versions to drift apart. A mission's `done_when`
# evaluator runs the project's own suite to verify a claim rather than believe
# it; if `cargo` is present in one image and absent in another, the same mission
# passes or fails depending on which backend it landed on, and nothing says why.
#
# The contents are the ones proven in production by
# `deploy/clawmates-runtime/Dockerfile`, minus the zeroclaw daemon. A microVM
# mission runs the direct-session model — `claude -p` exec'd over vsock, see
# `session_executor::run_session` — so there is no daemon to host and no
# gateway port to expose. Layer order is copied from that Dockerfile too:
# cheapest-and-most-stable first, so a version bump low down does not
# invalidate the expensive layers above it.
#
# NOT in `AGENT_IMAGES` in scripts/deploy.sh, deliberately: at this size a
# `docker save | load` to every fleet node on each deploy would dominate the
# deploy, and only nodes that actually run microVMs need it. Build it on the
# node that will run it:
#
# ssh osobh@tank "cd ~/clawmates && \
# docker build -f images/agent-toolchain/Dockerfile -t clawmates/agent-toolchain:dev images/agent-toolchain/"
FROM debian:bookworm-slim
# Node 22 (Kimi Code needs >= 22.19; Claude Code is fine on it) is the runtime
# for every agent CLI, so it belongs to the shared base rather than to any one
# of them.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl gnupg git jq less procps \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& npm cache clean --force \
&& rm -rf /var/lib/apt/lists/* /root/.npm \
&& node -v && git --version
# Upstream Gitea SDLC tooling for agents that operate on git.redclaw.dev.
# `tea` covers shell-level ops (clone/push, PR checkout); `gitea-mcp` gives the
# LLM a structured tool surface. Both official upstream binaries.
ARG TEA_VERSION=0.14.2
ARG GITEA_MCP_VERSION=1.3.0
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) mcp_asset="Linux_x86_64" ;; \
arm64) mcp_asset="Linux_arm64" ;; \
*) echo "unsupported arch: $arch"; exit 1 ;; \
esac; \
curl -fsSL "https://dl.gitea.com/tea/${TEA_VERSION}/tea-${TEA_VERSION}-linux-${arch}" \
-o /usr/local/bin/tea && chmod +x /usr/local/bin/tea; \
tmp="$(mktemp -d)" && \
curl -fsSL "https://gitea.com/gitea/gitea-mcp/releases/download/v${GITEA_MCP_VERSION}/gitea-mcp_${mcp_asset}.tar.gz" \
-o "$tmp/gitea-mcp.tgz" && \
tar -xzf "$tmp/gitea-mcp.tgz" -C "$tmp" && \
install -m 0755 "$tmp/gitea-mcp" /usr/local/bin/gitea-mcp && \
rm -rf "$tmp"; \
tea --version | head -1
# Security scanners. `security_scan.rs` shells out to all four; when they were
# missing, every scan produced `<tool>:tool_error` rows instead of findings —
# a scan that scanned nothing and reported cleanly.
ARG GITLEAKS_VERSION=8.30.1
ARG TRIVY_VERSION=0.72.0
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) gl_arch=x64; tv_arch=64bit ;; \
arm64) gl_arch=arm64; tv_arch=ARM64 ;; \
*) echo "unsupported arch: $arch"; exit 1 ;; \
esac; \
curl -fsSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${gl_arch}.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks; \
curl -fsSL "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-${tv_arch}.tar.gz" \
| tar -xz -C /usr/local/bin trivy; \
gitleaks version; trivy --version | head -1
# semgrep in its own venv so its pinned dependency tree cannot collide with
# anything else installed here.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv \
&& python3 -m venv /opt/semgrep \
&& /opt/semgrep/bin/pip install --no-cache-dir semgrep \
&& ln -s /opt/semgrep/bin/semgrep /usr/local/bin/semgrep \
&& rm -rf /var/lib/apt/lists/* \
&& semgrep --version
# Rust last: the largest layer and the one most likely to be bumped, so it sits
# where a rebuild costs the least cache. `templates/teams/rust_sdlc.toml` tells
# the coder to run `cargo test`, and the evaluator runs it again to check.
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libc6-dev pkg-config libssl-dev make cmake build-essential \
&& curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable \
&& cargo install cargo-audit --locked --no-default-features \
&& rm -rf /var/lib/apt/lists/* "$CARGO_HOME/registry" "$CARGO_HOME/git" \
&& chmod -R a+rX "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo audit --version
# PATH is spelled out in full above rather than as `/usr/local/cargo/bin:$PATH`
# on purpose. `scripts/fc-build-rootfs.sh` reads the image's resolved
# `Config.Env` into the guest's /etc/profile.d, and that is the only way the
# VM learns its PATH — `docker export` carries no image metadata at all. An
# interpolated value resolves at build time, so this is equivalent; being
# explicit means the guest's PATH is readable here rather than inferred.
# Where a mission's checkout is injected, on both the container and the microVM
# path. Created here so the guest agent's first write is not also a mkdir.
RUN mkdir -p /mission/repo
WORKDIR /mission/repo
# Idle keep-alive. On the container path the orchestrator execs work in; on the
# microVM path pid 1 is the guest agent instead and this is never reached.
CMD ["sleep", "infinity"]
+29 -3
View File
@@ -41,6 +41,20 @@ OUT="$WORK/rootfs-$NAME.ext4"
# home directory on a Linux host. # home directory on a Linux host.
AGENT_BIN=${FC_AGENT_BIN:-'$HOME/clawmates/target/x86_64-unknown-linux-musl/release/fcagent'} 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
claude|glm) FC_CLI="claude --version" ;;
kimi) FC_CLI="kimi --version" ;;
*) FC_CLI="" ;;
esac ;;
esac
echo "── building $OUT on $HOST from $IMAGE ──" echo "── building $OUT on $HOST from $IMAGE ──"
# 1. Flatten the image to a tar and unpack it into an ext4. # 1. Flatten the image to a tar and unpack it into an ext4.
@@ -141,9 +155,13 @@ def call(cmd):
s.close() s.close()
return json.loads(buf) return json.loads(buf)
# What a mission actually needs, asked of the image rather than assumed. # What a mission actually needs, asked of the image rather than assumed.
for label, cmd in [('git', 'git --version'), probes = [('git', 'git --version'),
('shell-env', '. /etc/profile.d/00-image-env.sh 2>/dev/null; echo PATH=\$PATH'), ('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')]: ('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: try:
r = call(cmd) r = call(cmd)
print('%s rc=%s %s' % (label, r.get('rc'), (r.get('stdout') or r.get('stderr') or '').strip()[:90])) print('%s rc=%s %s' % (label, r.get('rc'), (r.get('stdout') or r.get('stderr') or '').strip()[:90]))
@@ -164,6 +182,14 @@ PY
*"write rc=0"*) pass "the guest can write to /mission" ;; *"write rc=0"*) pass "the guest can write to /mission" ;;
*) fail "the guest could not write to /mission" ;; *) fail "the guest could not write to /mission" ;;
esac 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 fi
echo echo