feat(fleet): B4.4a — a real agent-claude microVM image, and a check that it has an agent in it

The only rootfs on this track came from clawmates/agent-terminal:dev. Mounted,
it held git and nothing else: no claude, no node, no cargo. A VM booted from it
looks perfect and cannot run a mission, so B4.5 could have been written and
never verified.

images/agent-toolchain — the shared mission toolchain (node 22, git, rust +
cargo-audit, gitleaks/trivy/semgrep, tea/gitea-mcp), lifted from the proven
deploy/clawmates-runtime image minus the zeroclaw daemon: a microVM mission runs
the direct-session model, so there is no daemon to host. A base image rather
than three self-contained Dockerfiles because this layer is ~3 GB and the real
risk is scanner and toolchain versions drifting between per-CLI images — the
evaluator runs the project's own suite to check a claim, so `cargo` present in
one image and absent in another makes the same mission pass or fail by backend
with nothing saying why.

images/agent-claude — plan A6, first of three: the pinned CLI and its env
contract only, so bumping Claude Code does not rebuild the toolchain and cannot
disturb agent-kimi / agent-glm. HOME=/root with an empty .claude for B4.4 to
inject into; no ANTHROPIC_API_KEY, since it silently overrides the subscription
OAuth we already pay for.

Both the builder and the node selftest now ASK the guest for the CLI the image
is named for, instead of trusting the name. `required_cli` maps claude/kimi/glm
to a probe; an unrecognised backend reports unchecked and prints SKIP rather
than passing quietly.

Verified on tank:
  - rootfs-claude.ext4 boots; git, node, cargo, a real git commit all work
  - `claude --version` → 2.1.220 over vsock, in both the builder and
    `--vm-selftest` (11/11, create 1498 ms)
  - negative control: the same builder run against agent-terminal with
    FC_CLI forced reports `cli rc=127 claude: not found` and exits 1, so the
    green result above is a measurement and not a default
  - `claude -p hello` fails with "Not logged in · Please run /login" — the CLI
    runs headless in the VM, and B4.4 only has to supply the credential
  - no leaked firecracker processes or vm dirs afterwards

437 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-05 11:33:07 -07:00
co-authored by Claude Opus 5
parent fd16b3c126
commit 0c291ed1bb
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)
}
/// 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.
///
/// "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:?}"),
);
// 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;
check(
r.as_ref()
@@ -592,6 +652,28 @@ pub async fn selftest() -> bool {
mod tests {
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
/// path. These are rejected, not sanitised — a caller that sent `../../etc`
/// wanted something we should not silently reinterpret.