`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]>
160 lines
6.2 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|