feat(missions): make copy-in/copy-out the default filesystem model

Copy mode shipped opt-in so that changing how every mission receives its
code required someone to type it. Four production missions and a
fail-closed harness later, 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 runs whenever nobody sets the
variable. `CLAWMATES_MISSION_FS=bind` still selects it; anything else —
unset, empty, misspelt — gets copy mode, so a typo lands on the safer
path rather than the one being retired.

Also fixes a real leak found while scoping the deletion below: the git
helper built its `safe.directory` argument with `Box::leak`, justified as
"the process is short-lived". That is true of a CLI and false of cm-api,
which is a long-running server — so it leaked one allocation per git
call, growing with every phase of every mission.

The A5 deletion is NOT done here, and two of its items should never be
done:

  - `scrub_remote_credentials` is a security control, not a uid
    workaround. Copy mode uploads the whole `.git` into a container the
    agent controls as root, which makes stripping the token from
    `.git/config` more necessary, not less.
  - `has_local_work` / `checkout_in_use` guard `fetch_and_reset` at every
    phase launch and have nothing to do with who writes the checkout.
    The host checkout still persists across phases under copy mode —
    mission `019fcf62` shows the marker firing there. Deleting them
    reintroduces PRIOR-PHASE-WORK-WAS-LOST.

The rest (`share_repository_across_uids`, `clear_stale_commit_editmsg`,
`-c safe.directory`) are genuinely obsolete under copy mode but stay
while `bind` remains selectable: a workaround may only be deleted once
the situation it works around can no longer be chosen.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-04 18:29:29 -07:00
co-authored by Claude Opus 5
parent 3c91d0e172
commit 4f6719c80e
2 changed files with 44 additions and 25 deletions
+26 -12
View File
@@ -167,14 +167,20 @@ pub async fn copy_out(
/// Is the copy-in/copy-out filesystem model enabled?
///
/// Opt-in. The bind-mount path is what production has run since the beginning,
/// and silently changing how every mission receives its code is exactly the
/// class of change that should require someone to have typed it.
/// **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("copy")
)
!matches!(std::env::var("CLAWMATES_MISSION_FS").as_deref(), Ok("bind"))
}
/// 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
/// the proven bind-mount path rather than silently changing it.
/// 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 copy_mode_requires_the_exact_word() {
for wrong in ["Copy", "copies", "bind", "1", "true", ""] {
assert_ne!(wrong, "copy", "{wrong:?} must not enable copy mode");
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"
);
}
}