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:
co-authored by
Claude Opus 5
parent
8ddea454d1
commit
4967b9b8fd
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user