Merge: B4.4a real agent-claude microVM image + fail-closed CLI check
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user