Files
clawmates/crates/cm-api/src/runtime_preflight.rs
T
Omar SobhandClaude Opus 5 cdc45bd082
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
chore(runtime): promote v0.8.4 from canary to the default image
Every mission on gw-04 was already running v0.8.4 — pinned by
CLAWMATES_RUNTIME_IMAGE in .env. The canary is retired: the default tag
`clawmates-runtime:sync` now IS that image, the override is commented out,
and the built-in default is the single source of truth again.

Promoting it exposed why the pin was load-bearing in the first place. The
default tag resolved to zeroclaw 0.8.3 — two releases behind what was
actually running — and the REGISTRY copy of the same tag was a different
image again, 849MB against 2.31GB, without the Rust toolchain. A host that
pulled `sync` rather than retagging it would have lost the on-green test
gate with every probe still reporting success.

A moving tag pointing somewhere old resolves perfectly, starts perfectly,
and runs old code. Nothing anywhere said which image a mission got, so two
things now do:

- mission_runtime logs the image it resolved and whether that came from
  the env override or the built-in default, once at startup.
- runtime_preflight probes `zeroclaw --version` alongside the other tools
  and prints every tool's VERSION, not just that it is present. A presence
  check passes happily on an image two releases behind, which is exactly
  what happened here and was found by running the binary by hand.

Rollback is a retag: `clawmates-runtime:pre-v084-default` on gw-04 holds
the previous default, and .env.pre-v084-default holds the previous pin.

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

265 lines
10 KiB
Rust

//! Does the mission runtime actually carry the tools we depend on?
//!
//! Every capability in this codebase is written twice: once as code that
//! invokes a binary, and once as a Dockerfile line that installs it. The two
//! are only connected by someone having built and shipped the image, and
//! nothing checked that they agreed.
//!
//! They did not. `deploy/clawmates-runtime/Dockerfile` gained a Rust
//! toolchain, `gitleaks`, `trivy`, `semgrep` and `cargo-audit`; the image was
//! never built, and gw-04 kept running the previous one for days. The
//! consequences were all silent:
//!
//! - `verify_tests` could not launch `cargo test`, so every `on_green_tests`
//! phase landed on `-wip` — indistinguishable from "no test suite here"
//! - `security_scan` emitted `tool_error` rows and reported completion
//! - the evaluator's allow-listed checks could not run the scanners
//!
//! No error, no log line, no failing test. The code was right and the machine
//! was not. This module makes that specific disagreement observable: it asks
//! the running container what it has and says so plainly at boot.
//!
//! It is a report, not a gate. A missing scanner should not stop the server
//! from serving — it should stop us believing a scan that scanned nothing.
use crate::container_exec;
use bollard::Docker;
use std::time::Duration;
const PROBE_TIMEOUT: Duration = Duration::from_secs(20);
/// A tool the platform invokes inside the runtime container, and what breaks
/// without it. The consequence text is the point: a bare list of missing
/// binaries does not tell an operator what is now quietly not happening.
struct Dependency {
argv: &'static [&'static str],
needed_for: &'static str,
}
const DEPENDENCIES: &[Dependency] = &[
Dependency {
argv: &["zeroclaw", "--version"],
needed_for: "driving every container-tier turn; the version is also how \
a runtime image that silently rolled back is noticed",
},
Dependency {
argv: &["cargo", "--version"],
needed_for: "the on_green_tests gate for Rust repos; without it every \
phase is unverified and lands on -wip",
},
Dependency {
argv: &["git", "--version"],
needed_for: "agent-side git operations in the mission checkout",
},
Dependency {
argv: &["gitleaks", "version"],
needed_for: "secret scanning in security_scan phases and evaluator checks",
},
Dependency {
argv: &["trivy", "--version"],
needed_for: "vulnerability scanning in security_scan phases",
},
Dependency {
argv: &["semgrep", "--version"],
needed_for: "static analysis in security_scan phases",
},
Dependency {
argv: &["cargo-audit", "--version"],
needed_for: "dependency advisories in security_scan phases",
},
];
/// One tool's availability, as reported by the container itself.
pub struct ToolStatus {
pub program: String,
pub present: bool,
/// Version string when present, error when not.
pub detail: String,
pub needed_for: &'static str,
}
/// Probe the runtime container for everything we invoke inside it.
///
/// Returns an empty vec if Docker itself is unreachable — that is a different
/// and louder failure which the caller reports separately, and emitting six
/// "missing" lines for it would be misleading.
pub async fn probe(container: &str) -> Result<Vec<ToolStatus>, String> {
let docker = container_exec::connect().map_err(|e| format!("docker unreachable: {e}"))?;
let mut out = Vec::with_capacity(DEPENDENCIES.len());
for dep in DEPENDENCIES {
let argv: Vec<String> = dep.argv.iter().map(|s| s.to_string()).collect();
let status =
match container_exec::exec(&docker, container, None, &argv, PROBE_TIMEOUT).await {
Ok(r) if r.success() => ToolStatus {
program: dep.argv[0].to_string(),
present: true,
detail: r
.combined()
.lines()
.next()
.unwrap_or("")
.trim()
.chars()
.take(80)
.collect(),
needed_for: dep.needed_for,
},
Ok(r) => ToolStatus {
program: dep.argv[0].to_string(),
present: false,
detail: r.combined().trim().chars().take(160).collect(),
needed_for: dep.needed_for,
},
Err(e) => ToolStatus {
program: dep.argv[0].to_string(),
present: false,
detail: e.chars().take(160).collect(),
needed_for: dep.needed_for,
},
};
out.push(status);
}
out.push(probe_mission_uid_can_write(&docker, container).await);
Ok(out)
}
/// Can uid 65532 actually work in the missions tree?
///
/// `container_exec` now runs every mission exec as 65532 rather than root, so
/// that no phase leaves behind files the cleanup (which runs as 65532) cannot
/// delete. That only holds while the image gives 65532 a writable `HOME` and
/// `CARGO_HOME` — and in the deployed image its default `HOME`
/// (`/zeroclaw-data`) and `/usr/local/cargo` are BOTH root-owned, which is why
/// `container_exec::mission_env` redirects them into the missions root.
///
/// If a future image moves that mount or tightens its permissions, every cargo
/// invocation starts failing for a reason no error message would connect to a
/// uid. So it is probed at boot, alongside the tools, and reported the same way.
async fn probe_mission_uid_can_write(docker: &Docker, container: &str) -> ToolStatus {
let root = crate::mission_workspace::missions_root();
let probe = root.join("_probe-uid");
// Through `exec`, not `exec_as_root`: the point is to exercise the exact
// policy real mission work gets, including the env it is given.
let argv: Vec<String> = [
"sh",
"-c",
&format!(
"set -e; mkdir -p \"$HOME\" \"$CARGO_HOME\" {p}; : > {p}/w; rm -rf {p}; echo \"uid=$(id -u) HOME=$HOME CARGO_HOME=$CARGO_HOME\"",
p = probe.display()
),
]
.iter()
.map(|s| s.to_string())
.collect();
let detail = match container_exec::exec(
docker,
container,
Some(&root.display().to_string()),
&argv,
PROBE_TIMEOUT,
)
.await
{
Ok(r) if r.success() => {
return ToolStatus {
program: "mission-uid".to_string(),
present: true,
detail: r.combined().trim().chars().take(120).collect(),
needed_for: "every mission exec, so no phase leaves root-owned files",
}
}
Ok(r) => r.combined().trim().chars().take(160).collect(),
Err(e) => e.chars().take(160).collect(),
};
ToolStatus {
program: "mission-uid".to_string(),
present: false,
detail,
needed_for: "every mission exec, so no phase leaves root-owned files",
}
}
/// Probe at startup and write the result to stderr.
///
/// Spawned rather than awaited so a slow or absent Docker socket cannot delay
/// the server coming up — the report is diagnostic, and the platform has to
/// keep working without it.
pub fn report_at_boot() {
tokio::spawn(async {
let container = std::env::var("CLAWMATES_RUNTIME_CONTAINER")
.unwrap_or_else(|_| "clawmates-runtime".to_string());
match probe(&container).await {
Err(e) => eprintln!(
"runtime_preflight: could not probe `{container}` ({e}) — mission \
test gating and security scans may silently do nothing"
),
Ok(tools) => {
let missing: Vec<&ToolStatus> = tools.iter().filter(|t| !t.present).collect();
if missing.is_empty() {
let names: Vec<&str> = tools.iter().map(|t| t.program.as_str()).collect();
// The VERSIONS, not just the names. A tag that quietly
// points at an older build passes a presence check
// perfectly: gw-04's default tag was two zeroclaw releases
// behind while every probe said "present", and the only way
// anyone found out was running the binary by hand.
let detail: Vec<String> = tools
.iter()
.map(|t| format!("{}={}", t.program, t.detail))
.collect();
eprintln!(
"runtime_preflight: `{container}` has all {} expected tools ({}) — {}",
tools.len(),
names.join(", "),
detail.join("; ")
);
return;
}
eprintln!(
"runtime_preflight: `{container}` is MISSING {} of {} tools the \
platform invokes. The image on this host is behind \
deploy/clawmates-runtime/Dockerfile — rebuild and redeploy it.",
missing.len(),
tools.len()
);
for t in missing {
eprintln!(
"runtime_preflight: {} — absent. Disables: {}. ({})",
t.program, t.needed_for, t.detail
);
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
/// Every dependency must be probed with a flag that exits zero and prints
/// a version. A typo here produces a permanent false "missing" that would
/// train an operator to ignore the report — worse than no report at all.
#[test]
fn every_dependency_probe_is_a_version_query() {
for dep in DEPENDENCIES {
assert!(
dep.argv.len() >= 2,
"{} needs an argument that exits 0",
dep.argv[0]
);
let flag = dep.argv[1];
assert!(
flag == "--version" || flag == "version",
"{} probes with `{flag}`, which may not exit 0",
dep.argv[0]
);
assert!(
!dep.needed_for.is_empty(),
"{} must say what breaks without it",
dep.argv[0]
);
}
}
}