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
+18 -13
View File
@@ -470,22 +470,27 @@ pub async fn capture_phase_diff_at(
/// Run git in `repo`, returning stdout.
///
/// Every invocation carries `-c safe.directory`: the server clones as uid
/// 65532 while agents write into the same tree as root, so without it git
/// refuses the repository outright — the failure that had the phase evaluator
/// silently falling back to guesswork.
/// Every invocation carries `-c safe.directory`: under `CLAWMATES_MISSION_FS=bind`
/// the server clones as uid 65532 while agents write into the same tree as
/// root, so without it git refuses the repository outright — the failure that
/// 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> {
let repo_s = repo.display().to_string();
let mut full = vec![
"-C",
&repo_s,
"-c",
// Leaked into a `String` so it can live in a `&str` slice alongside
// the borrowed args; the process is short-lived and this is one
// allocation per git call.
Box::leak(format!("safe.directory={repo_s}").into_boxed_str()),
// Owned, not `Box::leak`. The leak was justified as "the process is
// short-lived", which is true of a CLI and false of cm-api — it is a
// long-running server, so that was one permanently leaked allocation per
// git call, growing with every phase of every mission for the life of the
// process.
let mut full: Vec<String> = vec![
"-C".into(),
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 out = tokio::process::Command::new("git")
.args(&full)