//! 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, 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 = 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 = [ "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 = 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] ); } } }