fix(teardown): a mission dir with root-owned files is now actually removed

The server runs as uid 65532, so `remove_dir_all` on a mission directory
returns PermissionDenied the moment anything root-owned is left in it — and the
old code logged that at the same level as "file not found" and moved on. The
directory then lived forever.

After the seed-copy fix a mission holds 3281 files owned by 65532 and 26 owned
by root: `.claude.json` and the session jsonl the per-mission ZeroClaw daemon
writes itself, after the copy has been chowned. Twenty-six files is small
enough to keep every mission directory alive without anyone noticing why.

PermissionDenied now falls back to `root_copy::purge`, which deletes from
inside the container as root — the same escape hatch `container_exec` keeps for
exactly this, clearing debris a root process created. And if the directory
survives even that, it says so, because a cleanup that silently failed is the
thing being fixed.

Removing the last 26 properly means running the per-mission daemon as 65532,
which needs `/mission` pre-created in the image with that ownership — the
daemon creates it at boot today and cannot at a lower uid. That is an image
change, deliberately not bundled here.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-09 11:17:38 -07:00
co-authored by Claude Opus 5
parent 4967b9b8fd
commit f68fc019e4
+24 -2
View File
@@ -1023,8 +1023,30 @@ impl MissionRuntimeProvisioner {
// path is bind-mounted into cm-api, so we can reap it directly. // path is bind-mounted into cm-api, so we can reap it directly.
let mission_dir = format!("{}/{mission_id}", missions_host_root()); let mission_dir = format!("{}/{mission_id}", missions_host_root());
if let Err(e) = tokio::fs::remove_dir_all(&mission_dir).await { if let Err(e) = tokio::fs::remove_dir_all(&mission_dir).await {
if e.kind() != std::io::ErrorKind::NotFound { match e.kind() {
eprintln!("mission_runtime: rm workspace dir {mission_dir}: {e}"); std::io::ErrorKind::NotFound => {}
// The server runs as 65532 and cannot delete root-owned files.
// Almost everything under a mission is 65532 now, but the
// per-mission ZeroClaw daemon still runs as root and leaves ~26
// of its own files (`.claude.json`, session jsonl) behind after
// the seed copy has been chowned. Without this fallback those
// few files keep the whole directory alive forever — the
// cleanup-that-cannot-clean-up shape, at a scale small enough
// to go unnoticed for a long time.
std::io::ErrorKind::PermissionDenied => {
eprintln!(
"mission_runtime: {mission_dir} holds root-owned files — removing it from inside the runtime container"
);
let container = std::env::var("CLAWMATES_RUNTIME_CONTAINER")
.unwrap_or_else(|_| "clawmates-runtime".to_string());
crate::root_copy::purge(&container, std::path::Path::new(&mission_dir)).await;
if tokio::fs::metadata(&mission_dir).await.is_ok() {
eprintln!(
"mission_runtime: {mission_dir} SURVIVED the root purge — it will keep accumulating"
);
}
}
_ => eprintln!("mission_runtime: rm workspace dir {mission_dir}: {e}"),
} }
} }
Ok(()) Ok(())