//! Move a mission's checkout in and out of its container, instead of sharing it. //! //! Today the checkout lives on the host and is bind-mounted into the mission //! container. That single directory is written by **two users** — cm-api as //! uid 65532 and the agent as root — and every bug that pattern can produce, //! it has produced: //! //! | Symptom | Fix that was needed | //! |---|---| //! | `.git/objects` permission denied | `core.sharedRepository=0777` | //! | capture base overwritten each phase | advance the base after commit | //! | `.git/COMMIT_EDITMSG` root-owned | unlink before commit | //! | `reset --hard` deleting a prior phase | `.git/clawmates-in-use` marker | //! //! Four fixes, one cause. `core.sharedRepository` was never a general //! solution — it covers objects and refs, and every *other* file git touches //! is a fresh opportunity. //! //! Copy-in/copy-out removes the cause: the agent owns its filesystem //! completely, as root, with no other writer. Nothing on the host is shared, //! so nothing on the host can collide. //! //! # Cost //! //! Measured on gw-04 against a real 65 MB checkout of this repository: //! **0.23s in, 0.18s out**. That was the one open risk in the plan — a //! monorepo copied per phase — and it is not a risk at this size. Measure //! again before assuming it holds for a repository an order of magnitude //! larger. //! //! No compression: the payload crosses a local Docker socket, so gzip would //! spend CPU to save nothing. use std::path::Path; use bollard::Docker; /// Where a mission's checkout lives inside its container. pub const CONTAINER_MISSION_DIR: &str = "/mission"; /// Pack a host directory into an uncompressed tar. /// /// `name_in_archive` is the top-level entry, so unpacking at /// [`CONTAINER_MISSION_DIR`] yields `/mission/`. Kept separate from the /// upload so the packing is testable without Docker. pub fn pack_dir(root: &Path, name_in_archive: &str) -> Result, String> { let mut builder = tar::Builder::new(Vec::new()); // Follow no symlinks: a checkout can contain a link pointing outside the // tree, and dereferencing it would pull host files into the container. builder.follow_symlinks(false); append_filtered(&mut builder, root, Path::new(name_in_archive)) .map_err(|e| format!("pack {}: {e}", root.display()))?; builder .into_inner() .map_err(|e| format!("finish archive for {}: {e}", root.display())) } /// Directory names never carried across the boundary. /// /// The same list the delivery diff uses, deliberately: see /// [`crate::mission_delivery::EXCLUDED_PATHS`]. A build directory is not work — /// it is regenerable output that dwarfs the source, and shipping it cost a /// mission its results when `vm_collect` timed out with the agent's finished work /// still inside the VM. pub fn transport_excludes() -> &'static [&'static str] { crate::mission_delivery::EXCLUDED_PATHS } /// Should this directory entry be left out of the archive? /// /// Matched on the entry NAME at any depth, not on a path prefix: a workspace has /// a `target/` per crate, and excluding only the root one would still ship the /// rest. pub fn is_excluded(name: &str) -> bool { transport_excludes().contains(&name) } /// Recursive `append_dir_all` that skips [`transport_excludes`]. /// /// Hand-rolled because `tar::Builder::append_dir_all` takes no filter. Symlinks /// are added as links rather than followed, matching `follow_symlinks(false)`. fn append_filtered( builder: &mut tar::Builder, dir: &Path, prefix: &Path, ) -> std::io::Result<()> { builder.append_dir(prefix, dir)?; let mut entries: Vec<_> = std::fs::read_dir(dir)?.collect::, _>>()?; // Stable order so an archive of the same tree is byte-identical, which makes // a size or content difference between two runs mean something. entries.sort_by_key(|e| e.file_name()); for entry in entries { let name = entry.file_name(); let name_str = name.to_string_lossy(); let path = entry.path(); let dest = prefix.join(&name); let meta = std::fs::symlink_metadata(&path)?; if meta.is_dir() { if is_excluded(&name_str) { continue; } append_filtered(builder, &path, &dest)?; } else if meta.is_symlink() { let mut header = tar::Header::new_gnu(); header.set_metadata(&meta); header.set_entry_type(tar::EntryType::Symlink); header.set_size(0); let target = std::fs::read_link(&path)?; builder.append_link(&mut header, &dest, &target)?; } else { let mut f = std::fs::File::open(&path)?; builder.append_file(&dest, &mut f)?; } } Ok(()) } /// Unpack a tar into a host directory. /// /// `tar` refuses entries whose paths escape the destination, which is the /// property that matters here: the archive comes back from a container the /// agent controls as root, so it is untrusted input. A `../../etc` entry must /// not be able to write outside the collection directory. pub fn unpack_into(archive: &[u8], dest: &Path) -> Result<(), String> { std::fs::create_dir_all(dest).map_err(|e| format!("mkdir {}: {e}", dest.display()))?; let mut ar = tar::Archive::new(archive); ar.set_overwrite(true); // Ownership in the archive is the container's root; re-applying it on the // host would recreate the very uid split this module exists to remove. ar.set_preserve_permissions(false); // Filter on the way OUT as well as on the way in. // // `pack_dir` (host -> container) skips `transport_excludes`, but `copy_out` // (container -> host) is the raw Docker archive API, which carries the whole // tree — `target/` included. The asymmetry was invisible for as long as the // runtime image had no `cmake`, because nothing could compile and no // `target/` existed. The moment missions could build, every collection // failed on a build artifact: // // failed to unpack `…/repo/target/debug/build/ahash-…/build_script_build-…` // // and `phase_runner` correctly refused to capture a stale tree — so a // coding phase that HAD done the work delivered nothing, retrying forever. // // Entries are skipped by NAME at any depth, the same rule `is_excluded` // uses, because a workspace has a `target/` per crate. let mut skipped = 0usize; for entry in ar .entries() .map_err(|e| format!("read archive for {}: {e}", dest.display()))? { let mut entry = entry.map_err(|e| format!("read entry for {}: {e}", dest.display()))?; let path = entry .path() .map_err(|e| format!("entry path for {}: {e}", dest.display()))? .into_owned(); if path .components() .any(|c| is_excluded(&c.as_os_str().to_string_lossy())) { skipped += 1; continue; } entry .unpack_in(dest) .map_err(|e| format!("unpack into {}: {e}", dest.display()))?; } if skipped > 0 { eprintln!( "mission_fs: unpack into {} skipped {skipped} excluded entr{} (build output)", dest.display(), if skipped == 1 { "y" } else { "ies" } ); } Ok(()) } /// Copy a host directory into a running container at [`CONTAINER_MISSION_DIR`]. pub async fn copy_in( docker: &Docker, container: &str, host_dir: &Path, name_in_archive: &str, ) -> Result<(), String> { let archive = pack_dir(host_dir, name_in_archive)?; let opts = bollard::query_parameters::UploadToContainerOptionsBuilder::default() .path(CONTAINER_MISSION_DIR) .build(); docker .upload_to_container(container, Some(opts), bollard::body_full(archive.into())) .await .map_err(|e| format!("copy into {container}:{CONTAINER_MISSION_DIR}: {e}")) } /// Build a one-entry tar. Split out from [`put_file`] so the size-independence /// that is the whole point can be tested without Docker. fn single_file_archive(name: &str, contents: &[u8]) -> Result, String> { let mut header = tar::Header::new_gnu(); header .set_path(name) .map_err(|e| format!("tar path {name}: {e}"))?; header.set_size(contents.len() as u64); header.set_mode(0o600); header.set_entry_type(tar::EntryType::Regular); header.set_cksum(); let mut builder = tar::Builder::new(Vec::new()); builder .append(&header, contents) .map_err(|e| format!("tar {name}: {e}"))?; builder .into_inner() .map_err(|e| format!("finish archive for {name}: {e}")) } /// Write one file into a container, at any size. /// /// The obvious way to do this is `sh -c "printf … > file"`, and it works right /// up until the payload approaches `ARG_MAX`, at which point exec fails with /// `argument list too long`. That is a size-dependent failure in a code path /// whose payload grows with use, which makes it a bug that ships green and /// surfaces in production — as it did, silently unpinning every agent in /// mission `019fcf62`. Tar has no argv limit. /// /// The write is not atomic. Callers that need it can upload beside the target /// and rename; the config writer does not, because the daemon reads its config /// once at boot and is restarted afterwards. pub async fn put_file( docker: &Docker, container: &str, path: &str, contents: &[u8], ) -> Result<(), String> { let (dir, file) = path .rsplit_once('/') .ok_or_else(|| format!("{path} is not an absolute path"))?; let dir = if dir.is_empty() { "/" } else { dir }; let archive = single_file_archive(file, contents)?; let opts = bollard::query_parameters::UploadToContainerOptionsBuilder::default() .path(dir) .build(); docker .upload_to_container(container, Some(opts), bollard::body_full(archive.into())) .await .map_err(|e| format!("upload {path} to {container}: {e}")) } /// Build a flat tar of several files. [`single_file_archive`] for many. fn files_archive(files: &[(String, Vec)]) -> Result, String> { let mut builder = tar::Builder::new(Vec::new()); for (name, contents) in files { let mut header = tar::Header::new_gnu(); header .set_path(name) .map_err(|e| format!("tar path {name}: {e}"))?; header.set_size(contents.len() as u64); // World-readable, unlike `single_file_archive`'s 0600: that one carries // a credential, this one carries procedures the agent is meant to read. header.set_mode(0o644); header.set_entry_type(tar::EntryType::Regular); header.set_cksum(); builder .append(&header, contents.as_slice()) .map_err(|e| format!("tar {name}: {e}"))?; } builder .into_inner() .map_err(|e| format!("finish archive of {} files: {e}", files.len())) } /// Write several files into one directory of a container, in one upload. /// /// `dir` must already exist — `upload_to_container` will not create it, the /// same constraint [`sync_in`] works around. Size-independent for the reason /// [`put_file`] gives; fifty skill bodies would be well past `ARG_MAX` as a /// printf. pub async fn put_files( docker: &Docker, container: &str, dir: &str, files: &[(String, Vec)], ) -> Result<(), String> { let archive = files_archive(files)?; let opts = bollard::query_parameters::UploadToContainerOptionsBuilder::default() .path(dir) .build(); docker .upload_to_container(container, Some(opts), bollard::body_full(archive.into())) .await .map_err(|e| format!("upload {} files to {container}:{dir}: {e}", files.len())) } /// Copy a directory back out of a container onto the host. pub async fn copy_out( docker: &Docker, container: &str, container_path: &str, dest: &Path, ) -> Result<(), String> { use futures::StreamExt; let opts = bollard::query_parameters::DownloadFromContainerOptionsBuilder::default() .path(container_path) .build(); let mut stream = docker.download_from_container(container, Some(opts)); let mut archive = Vec::new(); while let Some(chunk) = stream.next().await { let bytes = chunk.map_err(|e| format!("copy out of {container}:{container_path}: {e}"))?; archive.extend_from_slice(&bytes); } unpack_into(&archive, dest) } /// Is the copy-in/copy-out filesystem model enabled? /// /// **Default since 2026-08-04.** It shipped opt-in, on the principle that /// silently changing how every mission receives its code should require /// someone to have typed it. Four production missions and a fail-closed /// harness later (`scripts/verify-mission-delivery.sh`), the opt-in is the /// riskier setting: the bind path is the one with four documented work-loss /// incidents, and leaving it as the default means the untested path is what /// runs when nobody sets the variable. /// /// `CLAWMATES_MISSION_FS=bind` still selects the old behaviour, so a revert is /// one line in `.env` rather than a rollback. Anything else — unset, empty, /// misspelt — gets copy mode, because the failure mode of a typo should be the /// safer path, not the one being retired. pub fn copy_mode() -> bool { !matches!(std::env::var("CLAWMATES_MISSION_FS").as_deref(), Ok("bind")) } /// Host directory holding a mission's checkout. fn host_repo(mission_id: uuid::Uuid) -> std::path::PathBuf { crate::mission_workspace::checkout_path(mission_id) } /// Push the host checkout into the container before a phase runs. /// /// A repo-less mission has no checkout to push, but it still needs /// `/mission/repo` to EXIST inside the container: the phase prompt tells the /// agent that is its working directory, `mission_orchestrator` pins every /// claw's `workspace.path` to it, and `mission_outputs` copies it back out to /// register artifacts. This used to return early instead, so none of those three /// were true — the pin resolved to nothing, ZeroClaw fell back to each agent's /// own sandbox, and the agents (correctly) reported they had no such directory /// and refused to work. Creating it empty is what the microVM tier already does, /// for the same reason: see `microvm_executor::inject` ("the guest needs the /// workspace to exist before the agent writes into it"). /// /// Creating it host-side rather than `mkdir`-ing in the container keeps the copy /// cycle symmetric — `sync_out` unpacks over this same path, so work written by /// one phase survives into the next instead of being wiped by the next /// `sync_in`. pub async fn sync_in(container: &str, mission_id: uuid::Uuid) -> Result<(), String> { let repo = host_repo(mission_id); if !repo.is_dir() { tokio::fs::create_dir_all(&repo) .await .map_err(|e| format!("create empty workspace {}: {e}", repo.display()))?; } let docker = crate::container_exec::connect()?; // `upload_to_container` requires the DESTINATION to exist: uploading into // `/mission` when the container has no `/mission` fails with // "404 Could not find the file /mission in container", which reads like a // missing source file rather than a missing target directory. Nothing else // creates it — not the image, not the container spec (in copy mode there is // no `/mission` bind) — so create it here, immediately before the copy that // depends on it. let mkdir = [ "mkdir".to_string(), "-p".to_string(), CONTAINER_MISSION_DIR.to_string(), ]; if let Err(e) = crate::container_exec::exec_as_root( &docker, container, None, &mkdir, std::time::Duration::from_secs(20), ) .await { return Err(format!("create {CONTAINER_MISSION_DIR} in {container}: {e}")); } copy_in(&docker, container, &repo, "repo").await } /// Pull the agent's work back onto the host after a phase. /// /// Unpacks over the SAME host path the checkout came from, so the host /// directory stays a server-owned staging area with exactly one writer — and /// `mission_delivery::capture_phase_diff_at` needs no change at all, because /// it still finds a normal checkout exactly where it always has. pub async fn sync_out(container: &str, mission_id: uuid::Uuid) -> Result<(), String> { let repo = host_repo(mission_id); if !repo.is_dir() { return Ok(()); } let parent = repo .parent() .ok_or_else(|| format!("{} has no parent", repo.display()))?; let docker = crate::container_exec::connect()?; copy_out(&docker, container, "/mission/repo", parent).await } #[cfg(test)] mod tests { use super::*; fn seed(root: &Path) { std::fs::create_dir_all(root.join("src")).unwrap(); std::fs::create_dir_all(root.join(".git")).unwrap(); std::fs::write(root.join("src/lib.rs"), "pub fn x() {}\n").unwrap(); std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); } /// Build output must be dropped on the way BACK, not only on the way out. /// /// `copy_out` uses the raw Docker archive API, which carries `target/` /// whatever `pack_dir` did. Unpacking it failed on a build-script binary /// and took the whole collection down with it, so a coding phase that had /// really done the work delivered nothing. #[test] fn unpacking_drops_build_output_but_keeps_the_source() { let tmp = tempfile::tempdir().unwrap(); let src = tmp.path().join("repo"); std::fs::create_dir_all(src.join("src")).unwrap(); std::fs::create_dir_all(src.join("target/debug/build")).unwrap(); std::fs::create_dir_all(src.join("crates/inner/target")).unwrap(); std::fs::write(src.join("src/lib.rs"), "pub fn x() {}\n").unwrap(); std::fs::write(src.join("target/debug/build/script"), "ELF").unwrap(); std::fs::write(src.join("crates/inner/target/blob"), "ELF").unwrap(); // Built WITHOUT the filter, the way the Docker API hands it to us. let mut buf = Vec::new(); { let mut b = tar::Builder::new(&mut buf); b.append_dir_all("repo", &src).unwrap(); b.finish().unwrap(); } let dest = tmp.path().join("out"); unpack_into(&buf, &dest).expect("must not fail on build output"); assert!(dest.join("repo/src/lib.rs").is_file(), "source must survive"); assert!( !dest.join("repo/target").exists(), "root target/ must be dropped" ); assert!( !dest.join("repo/crates/inner/target").exists(), "a per-crate target/ must be dropped too — matched by NAME at any depth" ); } /// A checkout must survive the round trip intact — including `.git`, /// without which the whole delivery path (diff, commit, push) is dead. #[test] fn a_checkout_round_trips_with_its_git_dir() { let tmp = tempfile::tempdir().unwrap(); let src = tmp.path().join("repo"); seed(&src); let archive = pack_dir(&src, "repo").unwrap(); let dest = tmp.path().join("out"); unpack_into(&archive, &dest).unwrap(); assert_eq!( std::fs::read_to_string(dest.join("repo/src/lib.rs")).unwrap(), "pub fn x() {}\n" ); assert!( dest.join("repo/.git/HEAD").exists(), "the .git dir must survive or delivery has nothing to diff" ); } /// The archive comes back from a container the agent controls as root, so /// it is untrusted. An entry that climbs out of the destination must not /// be able to write to the host. #[test] fn an_archive_cannot_escape_the_destination() { let tmp = tempfile::tempdir().unwrap(); let dest = tmp.path().join("dest"); let canary = tmp.path().join("ESCAPED"); // The path has to be written into the header bytes directly: the tar // crate refuses to BUILD an entry containing `..`, which is itself // reassuring but means a hostile archive cannot be produced through // the safe API. A real attacker writes the bytes, so the test does. let body = b"pwned\n"; let mut header = tar::Header::new_gnu(); header.set_size(body.len() as u64); header.set_mode(0o644); header.set_entry_type(tar::EntryType::Regular); { let gnu = header.as_gnu_mut().expect("gnu header"); let evil = b"../ESCAPED"; gnu.name[..evil.len()].copy_from_slice(evil); } header.set_cksum(); let mut archive = Vec::new(); archive.extend_from_slice(header.as_bytes()); let mut block = [0u8; 512]; block[..body.len()].copy_from_slice(body); archive.extend_from_slice(&block); archive.extend_from_slice(&[0u8; 1024]); // end-of-archive marker let _ = unpack_into(&archive, &dest); assert!( !canary.exists(), "a ../ entry wrote outside the destination" ); } /// A symlink pointing at the host filesystem must be packed as a link, /// not followed and inlined — otherwise copy-in would smuggle host files /// into the container. #[test] fn symlinks_are_not_dereferenced_into_the_archive() { let tmp = tempfile::tempdir().unwrap(); let src = tmp.path().join("repo"); seed(&src); let secret = tmp.path().join("host-secret"); std::fs::write(&secret, "TOP SECRET\n").unwrap(); std::os::unix::fs::symlink(&secret, src.join("link")).unwrap(); let archive = pack_dir(&src, "repo").unwrap(); let haystack = String::from_utf8_lossy(&archive); assert!( !haystack.contains("TOP SECRET"), "symlink target contents were inlined into the archive" ); } /// Only the exact word `bind` opts out. A typo must land on copy mode — /// the path with a verification harness behind it — rather than silently /// selecting the one with four documented work-loss incidents. #[test] fn only_the_exact_word_bind_opts_out() { // Cannot set env vars in a test process without racing every other // test, so this asserts the predicate the function is built from. let opts_out = |v: &str| v == "bind"; assert!(opts_out("bind")); for near_miss in ["Bind", "binds", "bound", "copy", "0", "false", ""] { assert!( !opts_out(near_miss), "{near_miss:?} must NOT select the bind path" ); } } /// The regression this exists for: a config large enough to blow `ARG_MAX` /// via `sh -c` must round-trip untouched. 2 MB is well past the ~128 KB /// limit that unpinned every agent in mission `019fcf62`. #[test] fn a_file_far_past_arg_max_round_trips() { let big = "workspace_path = \"/mission/repo\"\n".repeat(64 * 1024); assert!(big.len() > 2_000_000, "the fixture must exceed ARG_MAX"); let archive = single_file_archive("config.toml", big.as_bytes()).unwrap(); let tmp = tempfile::tempdir().unwrap(); unpack_into(&archive, tmp.path()).unwrap(); assert_eq!( std::fs::read_to_string(tmp.path().join("config.toml")).unwrap(), big, "a large config must survive byte-for-byte" ); } /// TOML holding quotes, newlines and backslashes went through a shell /// before; nothing may depend on quoting now. #[test] fn shell_metacharacters_survive_the_archive() { let nasty = "path = \"/a'b\\\"c\"\n$(rm -rf /) `id` \\\\ \n"; let archive = single_file_archive("config.toml", nasty.as_bytes()).unwrap(); let tmp = tempfile::tempdir().unwrap(); unpack_into(&archive, tmp.path()).unwrap(); assert_eq!( std::fs::read_to_string(tmp.path().join("config.toml")).unwrap(), nasty ); } #[test] fn an_empty_directory_packs_without_error() { let tmp = tempfile::tempdir().unwrap(); let src = tmp.path().join("empty"); std::fs::create_dir_all(&src).unwrap(); let archive = pack_dir(&src, "repo").unwrap(); let dest = tmp.path().join("out"); unpack_into(&archive, &dest).unwrap(); assert!(dest.join("repo").is_dir()); } }