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]>
This commit is contained in:
Omar Sobh
2026-08-09 10:47:59 -07:00
co-authored by Claude Opus 5
parent 42108c840d
commit dcd9514622
4 changed files with 218 additions and 24 deletions
+59
View File
@@ -23,6 +23,7 @@
//! 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);
@@ -113,9 +114,67 @@ pub async fn probe(container: &str) -> Result<Vec<ToolStatus>, String> {
};
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