Files
clawmates/crates/cm-api/src/root_copy.rs
T
Omar SobhandClaude Opus 5 dcd9514622 fix(exec): mission work runs as uid 65532, so it stops creating debris it cannot delete
`CreateExecOptions` never set `user`. Not a wrong value — an ABSENT one: the
daemon defaults to root, and twelve callers inherited that without any of them
choosing it. That single omission is the origin of four separate patches —
root-owned `target/` directories inside a checkout owned by 65532, `root_copy`
existing at all, and a cleanup that had to re-enter the container as root to
undo its own mess.

The rule is positional and lives in ONE place: an exec whose workdir is inside
`missions_root()` runs as 65532; anything else (preflight probes, image checks)
keeps the daemon default so unrelated call sites cannot break. Twelve callers
each remembering to pass a uid is twelve chances to forget, and the one that
forgets leaves debris the other eleven cannot remove.

Non-root needs an environment the image does not provide. Measured in the
deployed image: uid 65532's HOME (/zeroclaw-data) and /usr/local/cargo are both
root-owned and unwritable, so this would otherwise break every cargo call — the
benchmark runner, the judge's sandbox, the delivery test gate — far more quietly
than the leak it fixes. The missions root IS bind-mounted and writable by 65532,
so HOME/CARGO_HOME move there and the cargo cache is shared across missions
rather than re-fetched per mission. Verified on gw-04: a clean `cargo build` as
65532 with those three variables produces output owned entirely by 65532.

Root remains reachable only through `exec_as_root`, whose name says so, and
which exists solely to clear debris earlier root execs left. `runtime_preflight`
now probes the whole policy at boot, so an image that moves or tightens that
mount fails loudly instead of failing every cargo call for a reason no error
message would connect to a uid. evaluator_tools' inlined fourth copy of the
purge is replaced by `root_copy::purge`.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-09 10:47:59 -07:00

160 lines
6.2 KiB
Rust

//! A throwaway copy of a mission checkout, for commands that run as ROOT.
//!
//! Three places in this codebase run a real command against a mission's tree —
//! the judge's verification (`evaluator_tools::Sandbox`), the benchmark runner,
//! and the `on_green_tests` delivery gate. All three enter a container running as
//! root with the missions root bind-mounted, and all three run something that
//! writes `target/`. All three now go through here; the judge was the last to
//! move, having carried its own copy of this logic since before it existed.
//!
//! Run against the live checkout, that breaks the single-writer invariant: the
//! tree is owned by uid 65532 and now contains root-owned build output, so the
//! next phase's `cargo` hits permission-denied on a directory it cannot write.
//! The harness's uid probe reports it as `uids=0,65532`.
//!
//! # The cleanup half, which is the part that keeps being got wrong
//!
//! The copy inherits the same problem: its `target/` is root-owned, so the
//! server process (uid 65532) **cannot delete it**. A `Drop` calling
//! `std::fs::remove_dir_all` fails, and because that error is discarded the tree
//! survives forever — measured at 1.2 MB per benchmark run and 16 MB of stranded
//! judge sandboxes before this existed.
//!
//! So removal goes back through the container, as root, where the files were
//! written. `Drop` remains only as a fallback for the paths where nothing has
//! run as root yet, and does not pretend to be more.
use std::path::{Path, PathBuf};
/// Where throwaway copies live: siblings of the per-mission directories, like
/// `_outputs` and `_verify`, so reaping a mission cannot race a running command.
pub fn copy_root(kind: &str, mission_id: uuid::Uuid) -> PathBuf {
crate::mission_workspace::missions_root()
.join(kind)
.join(mission_id.to_string())
}
/// Delete a copy from inside the container that wrote it.
///
/// Best-effort and loud: a housekeeping failure must not cost a real verdict or
/// a real benchmark, but it must not be silent either — silence is how the leaks
/// this module exists for went unnoticed for a day.
pub async fn purge(container: &str, root: &Path) {
let Ok(docker) = crate::container_exec::connect() else {
return;
};
let argv = vec![
"rm".to_string(),
"-rf".to_string(),
root.display().to_string(),
];
// Explicitly root: this exists to delete files an EARLIER root-run exec
// created, which uid 65532 cannot touch. Everything else now runs as 65532
// (see `container_exec`), so this is cleaning up history, not policy.
if let Err(e) = crate::container_exec::exec_as_root(
&docker,
container,
Some("/"),
&argv,
std::time::Duration::from_secs(120),
)
.await
{
eprintln!(
"root_copy: could not remove {} from {container}: {e}",
root.display()
);
}
}
/// A copy of a checkout, removed when it goes out of scope.
pub struct RootCopy {
root: PathBuf,
workdir: PathBuf,
}
impl RootCopy {
/// Copy `source` into `root`, returning a handle whose `workdir` is the tree
/// to run in.
///
/// Packed through `mission_fs::pack_dir`, so the copy carries exactly what a
/// delivered diff carries — no `target/`, no `node_modules/`. One exclusion
/// list, four consumers.
pub fn of(source: &Path, root: &Path) -> Result<RootCopy, String> {
let archive = crate::mission_fs::pack_dir(source, "repo")
.map_err(|e| format!("pack {} for a root-run command: {e}", source.display()))?;
crate::mission_fs::unpack_into(&archive, root)
.map_err(|e| format!("unpack copy into {}: {e}", root.display()))?;
let workdir = root.join("repo");
if !workdir.is_dir() {
return Err(format!("copy missing at {}", workdir.display()));
}
Ok(RootCopy {
root: root.to_path_buf(),
workdir,
})
}
pub fn workdir(&self) -> &Path {
&self.workdir
}
/// Take the working directory and give up automatic cleanup.
///
/// For a caller whose copy outlives this handle — `evaluator_tools::Sandbox`
/// hands the path to a judge that has not run yet, so letting `Drop` fire on
/// return would delete the tree out from under it. That caller becomes
/// responsible for calling [`purge`], which is the only thing that can
/// remove root-owned build output anyway.
///
/// Spelled as a method rather than `mem::forget` at the call site, so the
/// transfer of responsibility is visible in the type rather than implied by
/// a leak.
pub fn into_workdir(self) -> PathBuf {
let workdir = self.workdir.clone();
std::mem::forget(self);
workdir
}
}
impl Drop for RootCopy {
/// Fallback only. This CANNOT remove root-owned build output — see
/// [`purge`], which is what actually clears a copy something has run in.
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A copy must be a SIBLING of the per-mission directory, never inside it:
/// `teardown_container` removes `<missions_root>/<mission_id>` wholesale and
/// would take a running command's tree with it.
#[test]
fn copies_live_beside_the_mission_directory_not_inside_it() {
let mission = uuid::Uuid::now_v7();
let mission_dir = crate::mission_workspace::missions_root().join(mission.to_string());
for kind in ["_bench", "_gate", "_verify"] {
let root = copy_root(kind, mission);
assert!(!root.starts_with(&mission_dir), "{root:?}");
assert!(
root.starts_with(crate::mission_workspace::missions_root().join(kind)),
"{root:?}"
);
}
}
/// The copy is not the checkout. Stated as a test because the whole defect
/// class is "ran the real command against the real tree".
#[test]
fn a_copy_is_never_the_checkout() {
let mission = uuid::Uuid::now_v7();
let live = crate::mission_workspace::checkout_path(mission);
for kind in ["_bench", "_gate", "_verify"] {
assert_ne!(copy_root(kind, mission).join("repo"), live);
}
}
}