fix(runtime): the seed copy reads as root and hands the result to 65532

Running the seed copier as 65532 broke mission launch, and broke it quietly.
The seed dir is root-owned with parts at mode 0600 (`.claude.json`,
`clawmates-mcp.json`), so uid 65532 cannot READ them: `cp` failed on the first
unreadable entry, `set -e` abandoned the rest, and the mission came up with a
runtime-data holding `.zeroclaw` and nothing else — no Claude credentials, no
door config. The daemon then never created its agents' workspace, and the phase
failed 200 lines later on "Could not find the file /mission in container",
which points nowhere near the cause.

It was quiet because `seed_runtime_data` polled for the container to STOP and
returned Ok without ever reading its exit code. A copier that died on a
permission error and one that finished cleanly were indistinguishable. It now
reads the status and says what went wrong.

So: root for the read, `chown -R 65532:65532 /dst` for the result. Both halves
matter and they pull opposite ways — root is needed to read the seed, and
65532 is needed because everything else in the missions tree is 65532 and a GC
running as 65532 cannot delete what root left behind.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-09 11:11:49 -07:00
co-authored by Claude Opus 5
parent 8ddea454d1
commit 4967b9b8fd
+64 -12
View File
@@ -324,6 +324,8 @@ fn missions_host_root() -> String {
/// runtime. Concurrent daemons opening the same sessions.db can /// runtime. Concurrent daemons opening the same sessions.db can
/// interleave; in practice topology_worker sequentializes runs per /// interleave; in practice topology_worker sequentializes runs per
/// mission so this rarely bites. Long-term: copy-on-write per mission. /// mission so this rarely bites. Long-term: copy-on-write per mission.
use crate::container_exec::MISSION_UID;
const DEFAULT_SEED_DIR: &str = "/root/clawmates-runtime/data"; const DEFAULT_SEED_DIR: &str = "/root/clawmates-runtime/data";
@@ -369,6 +371,17 @@ fn copy_script() -> String {
"if [ -e '/seed/{p}' ]; then cp -a '/seed/{p}' /dst/; fi\n" "if [ -e '/seed/{p}' ]; then cp -a '/seed/{p}' /dst/; fi\n"
)); ));
} }
// The copy itself must run as root: the seed dir is root-owned and parts of
// it are mode 0600 (`.claude.json`, `clawmates-mcp.json`), so uid 65532
// cannot even READ them. Running the container as 65532 made `cp` fail on
// the first unreadable entry, `set -e` aborted the rest, and the mission
// got a runtime-data holding `.zeroclaw` and nothing else — no Claude
// credentials, no door config.
//
// So keep root for the read, and hand the RESULT to 65532. That is what the
// destination needs: every other writer into the missions tree is 65532,
// and a GC running as 65532 cannot delete what root left behind.
out.push_str(&format!("chown -R {MISSION_UID} /dst\n"));
out out
} }
@@ -402,13 +415,6 @@ async fn seed_runtime_data(
let name = format!("cm-seed-{}", Uuid::now_v7().simple()); let name = format!("cm-seed-{}", Uuid::now_v7().simple());
let config = ContainerCreateBody { let config = ContainerCreateBody {
image: Some(image.to_string()), image: Some(image.to_string()),
// As 65532, like every other writer into the missions tree. Left unset,
// this `cp -a` ran as root and laid down ~3200 root-owned files per
// mission under `runtime-data/` — the same absent-`user` omission that
// `container_exec` had, in a container create rather than an exec. The
// seed source is 65532-owned and the destination is created by the
// server (also 65532), so the copy has no reason to out-rank either.
user: Some(crate::container_exec::MISSION_UID.to_string()),
entrypoint: Some(vec!["/bin/sh".to_string()]), entrypoint: Some(vec!["/bin/sh".to_string()]),
cmd: Some(vec!["-c".to_string(), copy_script()]), cmd: Some(vec!["-c".to_string(), copy_script()]),
host_config: Some(HostConfig { host_config: Some(HostConfig {
@@ -457,14 +463,31 @@ async fn seed_runtime_data(
.inspect_container(&name, None::<InspectContainerOptions>) .inspect_container(&name, None::<InspectContainerOptions>)
.await .await
{ {
// `auto_remove` already took it away, which only happens after a
// clean-ish exit; the status is gone with it. Treated as success
// because the alternative — failing every mission on a race with
// the reaper — is worse, and `stamp_workspace_paths` fails loudly
// downstream if the config did not arrive.
Err(_) => return Ok(()), Err(_) => return Ok(()),
Ok(info) => { Ok(info) => {
let running = info let state = info.state.as_ref();
.state let running = state.and_then(|st| st.running).unwrap_or(false);
.as_ref()
.and_then(|st| st.running)
.unwrap_or(false);
if !running { if !running {
// The exit code was never read. A `cp` that died on an
// unreadable seed file left `set -e` to abort the rest, and
// this returned Ok — so the mission came up with a
// half-seeded runtime, the daemon never created its agents'
// workspace, and the phase then failed on
// "Could not find the file /mission in container",
// 200 lines and one indirection away from the real cause.
let code = state.and_then(|st| st.exit_code).unwrap_or(0);
if code != 0 {
return Err(format!(
"seed copier exited {code} — the mission's runtime-data is \
incomplete (the seed dir is root-owned and partly mode 0600; \
check that this container still runs as root)"
));
}
return Ok(()); return Ok(());
} }
} }
@@ -1493,6 +1516,35 @@ allowed_tools = ["file_read", "file_edit"]
/// a future edit that points the mount back at the seed dir restores the /// a future edit that points the mount back at the seed dir restores the
/// credential sharing with no other visible symptom. /// credential sharing with no other visible symptom.
#[test] #[test]
/// The seed copy reads as root and leaves the result to 65532.
///
/// Both halves are load-bearing and they pull in opposite directions. The
/// seed dir is root-owned with parts at mode 0600, so a copier running as
/// 65532 cannot read it — that was tried, `cp` failed on the first
/// unreadable entry, `set -e` abandoned the rest, and the mission booted
/// with `.zeroclaw` and nothing else. But leaving the RESULT root-owned is
/// what put ~3200 undeletable files per mission into the missions tree.
#[test]
fn the_seed_copy_hands_its_result_to_the_mission_uid() {
let script = copy_script();
assert!(
script.contains(&format!("chown -R {MISSION_UID} /dst")),
"the copied seed must end up owned by the mission uid:\n{script}"
);
// Every seeded path is attempted, and absence is tolerated — a fresh
// deployment genuinely has no `.kimi-code` yet.
for p in SEEDED_PATHS {
assert!(
script.contains(&format!("if [ -e '/seed/{p}' ]")),
"{p} is not guarded by an existence check"
);
}
// The chown must come AFTER the copies, or it chowns an empty dir.
let last_cp = script.rfind("cp -a").expect("at least one copy");
let chown = script.find("chown -R").expect("a chown");
assert!(chown > last_cp, "chown must run after the copies");
}
fn runtime_data_is_scoped_to_one_mission() { fn runtime_data_is_scoped_to_one_mission() {
let a = Uuid::now_v7(); let a = Uuid::now_v7();
let b = Uuid::now_v7(); let b = Uuid::now_v7();