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:
co-authored by
Claude Opus 5
parent
42108c840d
commit
dcd9514622
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user