Merge: copy-in/copy-out is the default mission filesystem

Verified with CLAWMATES_MISSION_FS removed from gw-04's .env — compose
passes it through as empty, which under the old opt-in logic would have
selected bind. scripts/verify-mission-delivery.sh all → 7/7.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-04 18:38:34 -07:00
co-authored by Claude Opus 5
2 changed files with 44 additions and 25 deletions
+18 -13
View File
@@ -470,22 +470,27 @@ pub async fn capture_phase_diff_at(
/// Run git in `repo`, returning stdout. /// Run git in `repo`, returning stdout.
/// ///
/// Every invocation carries `-c safe.directory`: the server clones as uid /// Every invocation carries `-c safe.directory`: under `CLAWMATES_MISSION_FS=bind`
/// 65532 while agents write into the same tree as root, so without it git /// the server clones as uid 65532 while agents write into the same tree as
/// refuses the repository outright — the failure that had the phase evaluator /// root, so without it git refuses the repository outright — the failure that
/// silently falling back to guesswork. /// had the phase evaluator silently falling back to guesswork. Copy mode makes
/// the tree single-uid and this redundant, but it stays while the bind path is
/// still selectable: a workaround may only be deleted once the situation it
/// works around can no longer be chosen.
async fn git(repo: &Path, args: &[&str]) -> Result<String, String> { async fn git(repo: &Path, args: &[&str]) -> Result<String, String> {
let repo_s = repo.display().to_string(); let repo_s = repo.display().to_string();
let mut full = vec![ // Owned, not `Box::leak`. The leak was justified as "the process is
"-C", // short-lived", which is true of a CLI and false of cm-api — it is a
&repo_s, // long-running server, so that was one permanently leaked allocation per
"-c", // git call, growing with every phase of every mission for the life of the
// Leaked into a `String` so it can live in a `&str` slice alongside // process.
// the borrowed args; the process is short-lived and this is one let mut full: Vec<String> = vec![
// allocation per git call. "-C".into(),
Box::leak(format!("safe.directory={repo_s}").into_boxed_str()), repo_s.clone(),
"-c".into(),
format!("safe.directory={repo_s}"),
]; ];
full.extend_from_slice(args); full.extend(args.iter().map(|a| (*a).to_string()));
let (name, email) = commit_identity(); let (name, email) = commit_identity();
let out = tokio::process::Command::new("git") let out = tokio::process::Command::new("git")
.args(&full) .args(&full)
+26 -12
View File
@@ -167,14 +167,20 @@ pub async fn copy_out(
/// Is the copy-in/copy-out filesystem model enabled? /// Is the copy-in/copy-out filesystem model enabled?
/// ///
/// Opt-in. The bind-mount path is what production has run since the beginning, /// **Default since 2026-08-04.** It shipped opt-in, on the principle that
/// and silently changing how every mission receives its code is exactly the /// silently changing how every mission receives its code should require
/// class of change that should require someone to have typed it. /// 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 { pub fn copy_mode() -> bool {
matches!( !matches!(std::env::var("CLAWMATES_MISSION_FS").as_deref(), Ok("bind"))
std::env::var("CLAWMATES_MISSION_FS").as_deref(),
Ok("copy")
)
} }
/// Host directory holding a mission's checkout. /// Host directory holding a mission's checkout.
@@ -305,12 +311,20 @@ mod tests {
); );
} }
/// The switch must be explicit — a near-miss value leaves production on /// Only the exact word `bind` opts out. A typo must land on copy mode —
/// the proven bind-mount path rather than silently changing it. /// the path with a verification harness behind it — rather than silently
/// selecting the one with four documented work-loss incidents.
#[test] #[test]
fn copy_mode_requires_the_exact_word() { fn only_the_exact_word_bind_opts_out() {
for wrong in ["Copy", "copies", "bind", "1", "true", ""] { // Cannot set env vars in a test process without racing every other
assert_ne!(wrong, "copy", "{wrong:?} must not enable copy mode"); // 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"
);
} }
} }