diff --git a/AGENTS.md b/AGENTS.md index 90f9e796e..13c1ebcfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -234,7 +234,7 @@ Write the task-specific brief under section 11 before spawning. ### Dispatch and supervision handoff Spawn only through `bin/fm-spawn.sh` after the profile and backend checks in section 4. -The spawn must resolve a genuine isolated task worktree distinct from the primary checkout; a failed isolation assertion stops the task. +The spawn must resolve a genuine isolated task worktree distinct from both the primary checkout and the firstmate home/root; a failed isolation assertion stops the task. After spawning, confirm the worker is processing the brief, handle any trust dialog through `harness-adapters`, and record ship or scout work as in flight. A persistent secondmate is recorded in the secondmate registry and runtime state, never as a backlog work item. @@ -336,7 +336,7 @@ A forced repair must use the home-scoped owner path emitted by supervision instr Guard warnings do not replace the contract. Queued wakes must be drained before other action, stale liveness must be repaired through the emitted protocol, and the worktree-tangle warning must be resolved without touching unlanded work. -The spawn assertion and generated ship brief must both enforce that project work starts in an isolated disposable worktree, never the primary checkout. +The spawn assertion and generated ship brief must both enforce that project work starts in an isolated disposable worktree, never the primary checkout or the firstmate home/root. Harness-aware turn-end guards are structural backstops, not permission to omit the live cycle. ### Away-mode stub diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index c973e4416..06b08020e 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -55,8 +55,12 @@ # provisioned firstmate home; the default is kind=ship. # Before a secondmate launch, the home is locally fast-forwarded to the primary # default-branch commit when safe; skipped syncs warn and launch unchanged. -# Ship/scout spawns refuse to launch unless the resolved task path is a real -# git worktree root distinct from the primary project checkout. +# Ship/scout treehouse-backed spawns accept a sampled task path only once it is a +# real git worktree root distinct from the primary project checkout and the +# firstmate home/root (treehouse setup hops through intermediate cwds; mid-hop +# samples are rejected and polling continues), and refuse to launch if none +# appears within FM_SPAWN_WT_TIMEOUT (default 60s); orca spawns keep validating +# the worktree path orca returns against the project checkout. # Batch dispatch: pass one or more `id=repo` pairs instead of a single , e.g. # fm-spawn.sh fix-a-k3=projects/foo add-b-q7=projects/bar [--scout] # Each pair re-execs this script in single-task mode, so the single path stays the only @@ -104,6 +108,8 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-config-inherit-lib.sh" # shellcheck source=bin/fm-backend.sh . "$SCRIPT_DIR/fm-backend.sh" +# shellcheck source=bin/fm-tangle-lib.sh +. "$SCRIPT_DIR/fm-tangle-lib.sh" # shellcheck source=bin/fm-gate-refuse-lib.sh . "$SCRIPT_DIR/fm-gate-refuse-lib.sh" # shellcheck source=bin/fm-pr-lib.sh @@ -678,18 +684,11 @@ real_path_or_raw() { # # that every downstream operation (send/capture/kill) already treats as opaque # per-backend routing (fm_backend_resolve_selector). validate_spawn_worktree() { # - local source=$1 inspect_target=$2 wt_real proj_real wt_top wt_top_real - wt_real= - if ! wt_real=$(cd "$WT" 2>/dev/null && pwd -P); then - wt_real= - fi - proj_real=$PROJ_ABS_REAL - wt_top=$(git -C "$WT" rev-parse --show-toplevel 2>/dev/null || true) - wt_top_real= - if ! wt_top_real=$(cd "$wt_top" 2>/dev/null && pwd -P); then - wt_top_real= - fi - if [ -z "$wt_real" ] || [ -z "$wt_top_real" ] || [ "$wt_real" != "$wt_top_real" ] || [ "$wt_real" = "$proj_real" ]; then + local source=$1 inspect_target=$2 wt_top + # One definition of "a genuine isolated worktree": fm_isolated_worktree_root + # (bin/fm-tangle-lib.sh), shared with the treehouse worktree-discovery poll. + if ! fm_isolated_worktree_root "$WT" "$PROJ_ABS_REAL" >/dev/null; then + wt_top=$(git -C "$WT" rev-parse --show-toplevel 2>/dev/null || true) echo "error: $source did not yield an isolated worktree (resolved '$WT'; worktree root '${wt_top:-none}'; primary '$PROJ_ABS'); refusing to launch to avoid tangling the primary checkout. Inspect target $inspect_target" >&2 exit 1 fi @@ -845,23 +844,34 @@ if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then # automatic-rename slips through), display-message -t falls back to the # active client's window, which would misread firstmate's OWN pane path as the # worktree and tangle a hook into the primary checkout. The window id never lies. - # Compare against PROJ_ABS_REAL (physical), not PROJ_ABS: a symlinked project - # prefix would otherwise make the pane's OS-level cwd read differ from - # PROJ_ABS on the very first poll, before the pane has actually moved. - for _ in $(seq 1 60); do + # The move is not atomic - treehouse's setup hops through intermediate cwds + # (the user's home, the firstmate home) before settling - so "left the + # project" is not enough: a sample landing mid-hop would record a wrong + # worktree= in meta and install the crew's turn-end hook into the WRONG + # checkout. Accept a candidate only once it proves it is a genuine ISOLATED + # worktree root: physically AT a git worktree top, distinct from the project's + # primary checkout AND from the firstmate home/root (a mis-sample can itself be + # another valid repo root; see fm_isolated_worktree_root in fm-tangle-lib.sh). + # Branching/committing in a mis-detected checkout would tangle it onto a + # feature branch (fm-tangle-lib.sh), so anything else keeps polling. The + # predicate compares physical (pwd -P) forms on both sides, so a symlinked + # project prefix cannot make the identical directory read as two strings. + home_real=$(real_path_or_raw "$FM_HOME") + root_real=$(real_path_or_raw "$FM_ROOT") + wt_wait_secs=${FM_SPAWN_WT_TIMEOUT:-60} # override is for tests; production keeps 60s + p= + for _ in $(seq 1 "$wt_wait_secs"); do p=$(spawn_current_path "$WT_TARGET" || true) - if [ -n "$p" ] && [ "$(real_path_or_raw "$p")" != "$PROJ_ABS_REAL" ]; then + if [ -n "$p" ] && fm_isolated_worktree_root "$p" "$PROJ_ABS_REAL" "$home_real" "$root_real" >/dev/null; then WT="$p" break fi sleep 1 done if [ -z "$WT" ]; then - echo "error: treehouse get did not enter a worktree within 60s; inspect window $T" >&2 + echo "error: treehouse get did not enter an isolated worktree within ${wt_wait_secs}s (project '$PROJ_ABS'); refusing to launch to avoid tangling a primary checkout. Last sample '$p'. Inspect window $T" >&2 exit 1 fi - - validate_spawn_worktree "treehouse get" "$T" fi # Per-task temp root: /tmp/fm-/ with Go's build temp nested at gotmp/. Go won't diff --git a/bin/fm-tangle-lib.sh b/bin/fm-tangle-lib.sh index a8554fbd6..e92527350 100644 --- a/bin/fm-tangle-lib.sh +++ b/bin/fm-tangle-lib.sh @@ -51,3 +51,29 @@ fm_primary_tangle_branch() { printf '%s\n' "$cur" return 0 } + +# Prevention-side isolation check for the same tangle, used by fm-spawn's +# worktree-discovery poll. Accept as a genuine ISOLATED worktree +# root only when its physical path (a) sits AT a git worktree top - not merely +# inside some repo or in a bare non-repo directory - and (b) differs from every +# physical path passed after it (the project's primary checkout, +# the firstmate home/root). treehouse get's subshell hops through intermediate +# cwds (the user's home, the firstmate home) before settling in the final +# worktree, and a mis-sample can itself be another VALID repo root, so the +# not-the-project check alone is insufficient. Echoes the candidate's physical +# path and returns 0 on acceptance; echoes nothing and returns 1 otherwise. +fm_isolated_worktree_root() { + local cand=$1 cand_real top top_real f + shift + cand_real=$(cd "$cand" 2>/dev/null && pwd -P) || return 1 + top=$(git -C "$cand_real" rev-parse --show-toplevel 2>/dev/null) || return 1 + top_real=$(cd "$top" 2>/dev/null && pwd -P) || return 1 + [ "$cand_real" = "$top_real" ] || return 1 + for f in "$@"; do + if [ -n "$f" ] && [ "$cand_real" = "$f" ]; then + return 1 + fi + done + printf '%s\n' "$cand_real" + return 0 +} diff --git a/docs/architecture.md b/docs/architecture.md index 044149810..700243310 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,6 +95,7 @@ Codex App support is recorded in `docs/codex-app-backend.md`; it is not selectab Crewmates never intentionally touch your project clone; [treehouse](https://github.com/kunchenguid/treehouse) pools clean worktrees for tmux, herdr, zellij, and cmux tasks, while Orca creates its own worktrees for `backend=orca`. For ship and scout work, `fm-spawn.sh` refuses to launch unless the resolved task path is a real git worktree root that is distinct from the project primary checkout. +Treehouse-backed spawns apply that check to every sampled pane path and additionally reject the firstmate home/root: treehouse setup hops through intermediate directories (the user's home, the firstmate home) before settling, so mid-hop samples - which can themselves be valid repo roots - are rejected and polling continues until a genuine isolated worktree appears or the wait bound expires. The firstmate repo has one extra exposure because it can dispatch crewmates to work on itself. Its operating checkout (`FM_ROOT`) and the disposable crewmate worktrees are all linked git worktrees of the same repository, so the valid discriminator is branch state, not whether the checkout is linked. diff --git a/docs/configuration.md b/docs/configuration.md index 6dd0a1d0e..27dcbac00 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -368,6 +368,7 @@ FM_CODEX_WATCH_CHECKPOINT=180 # seconds per foreground watcher checkpoint in C FM_CREW_STATE_NM_TIMEOUT=10 # seconds allowed per no-mistakes query inside fm-crew-state.sh FM_CREW_STATE_RUNS_LIMIT=200 # recent no-mistakes runs rows scanned when cross-branch attribution falls back from axi status FM_CREW_STATE_BIN=bin/fm-crew-state.sh # test override for the current-state reader used by working/paused watcher triage +FM_SPAWN_WT_TIMEOUT=60 # seconds fm-spawn waits for treehouse get to settle in an isolated worktree; test-only override, production keeps 60 FMX_PAIRING_TOKEN= # X mode pairing token; .env opt-in authorizes replies and eligible lifecycle actions FMX_RELAY_URL=https://myfirstmate.io # optional X relay override, mainly for local relay development FMX_ENV_FILE= # optional alternate .env file for direct X client invocations; bootstrap still checks $FM_HOME/.env diff --git a/docs/scripts.md b/docs/scripts.md index b346d73ff..e38cd5431 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -51,7 +51,7 @@ The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm | `fm-supervisor-target-lib.sh` | Resolve the shared supervisor target and backend for the daemon and launcher | | `fm-supervise-daemon.sh` | Presence-gated away-mode sub-supervisor: self-handle routine wakes, escalate batched digests, alert on failed delivery | | `fm-crew-state.sh` | Print one deterministic current-state line for a crew | -| `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification | +| `fm-tangle-lib.sh` | Shared default-branch resolution, primary-checkout tangle classification, and the isolated-worktree-root check used by fm-spawn's worktree-discovery poll and validation | | `fm-supervision-lib.sh` | Shared in-flight-work-without-fresh-watcher-beacon predicate | | `fm-ff-lib.sh` | Shared guarded fast-forward helper for origin pulls and local secondmate syncs | | `fm-lock-lib.sh` | Shared "is this git lock provably abandoned?" proof used by teardown and fleet-sync | diff --git a/tests/fm-spawn-worktree-wait.test.sh b/tests/fm-spawn-worktree-wait.test.sh new file mode 100755 index 000000000..b3987a2ea --- /dev/null +++ b/tests/fm-spawn-worktree-wait.test.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Behavior tests for fm-spawn's worktree-discovery poll after `treehouse get`. +# +# The pane's cwd does not move from the project to the worktree atomically: +# treehouse's setup hops through intermediate directories (the user's home, the +# firstmate home) before settling. The old poll accepted the FIRST cwd that +# differed from the project clone, so a sample landing mid-hop recorded a wrong +# worktree= in the task meta and installed the crew's turn-end hook into the +# wrong checkout. The fix accepts a candidate only once fm_isolated_worktree_root +# (bin/fm-tangle-lib.sh) proves it: physically AT a git worktree top, distinct +# from the project clone and from the firstmate home/root. +# +# These tests pin the predicate against every observed mis-sample shape, then +# replay the observed hop sequence (project -> home dir -> firstmate home -> +# real worktree) through the poll's accept logic and assert it settles only on +# the last. Structural checks pin fm-spawn's poll to the predicate. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# shellcheck source=bin/fm-tangle-lib.sh +. "$ROOT/bin/fm-tangle-lib.sh" + +SPAWN="$ROOT/bin/fm-spawn.sh" +TMP_ROOT=$(fm_test_tmproot fm-spawn-worktree-wait) +fm_git_identity fmtest fmtest@example.invalid + +# --- fixtures: the directory shapes the pane cwd can land in ----------------- + +# The project clone: a normal git repo on main with one commit. +PROJ="$TMP_ROOT/projects/someproj" +mkdir -p "$(dirname "$PROJ")" +git init -q -b main "$PROJ" +git -C "$PROJ" commit -q --allow-empty -m init + +# The firstmate home: a DIFFERENT valid git repo root (the 2026-07-04 recurrence: +# a mis-sample can be another valid repo root, so not-the-project is not enough). +FM_HOME_DIR="$TMP_ROOT/fm-home" +git init -q -b main "$FM_HOME_DIR" +git -C "$FM_HOME_DIR" commit -q --allow-empty -m init + +# A bare non-repo directory (the user's home mid-hop). +HOME_DIR="$TMP_ROOT/userhome" +mkdir -p "$HOME_DIR" + +# A real linked worktree of the project: a genuine isolated worktree top. +WORKTREE="$TMP_ROOT/pool/someproj-wt" +mkdir -p "$(dirname "$WORKTREE")" +git -C "$PROJ" worktree add -q --detach "$WORKTREE" +mkdir -p "$WORKTREE/subdir" + +# A symlink to the project clone: physical resolution must still reject it. +PROJ_LINK="$TMP_ROOT/proj-link" +ln -s "$PROJ" "$PROJ_LINK" + +PROJ_REAL=$(cd "$PROJ" && pwd -P) +FM_HOME_REAL=$(cd "$FM_HOME_DIR" && pwd -P) +WORKTREE_REAL=$(cd "$WORKTREE" && pwd -P) + +# --- the predicate: accept only a genuine isolated worktree root -------------- + +test_predicate_cases() { + local label cand expect out status + while IFS='|' read -r label cand expect; do + [ -n "$label" ] || continue + out=$(fm_isolated_worktree_root "$cand" "$PROJ_REAL" "$FM_HOME_REAL") && status=0 || status=$? + if [ "$expect" = accept ]; then + [ "$status" -eq 0 ] || fail "$label: expected accept, got reject" + [ "$out" = "$WORKTREE_REAL" ] || fail "$label: echoed '$out', want '$WORKTREE_REAL'" + else + [ "$status" -ne 0 ] || fail "$label: expected reject, got accept ('$out')" + fi + done </dev/null; then + WT="$p" + break + fi + done + [ "$WT" = "$WORKTREE" ] || fail "poll settled on '$WT', want '$WORKTREE'" + pass "poll logic skips project/home-dir/firstmate-home hops and settles on the worktree" +} + +# --- structural: fm-spawn's poll is wired to the predicate -------------------- + +test_spawn_uses_predicate() { + # shellcheck disable=SC2016 # literal source string + grep -F '. "$SCRIPT_DIR/fm-tangle-lib.sh"' "$SPAWN" >/dev/null \ + || fail "fm-spawn does not source fm-tangle-lib.sh" + # shellcheck disable=SC2016 # literal source string + grep -F 'fm_isolated_worktree_root "$p"' "$SPAWN" >/dev/null \ + || fail "fm-spawn's poll does not validate candidates with fm_isolated_worktree_root" + # The racy first-cwd-that-differs acceptance must be gone (both the original + # raw-string form and the later physically-canonicalized form of it). + # shellcheck disable=SC2016 # literal source strings + if grep -F '[ "$p" != "$PROJ_ABS" ]' "$SPAWN" >/dev/null \ + || grep -F '!= "$PROJ_ABS_REAL" ]' "$SPAWN" >/dev/null; then + fail "fm-spawn still accepts the first cwd that merely differs from the project" + fi + pass "fm-spawn's worktree poll validates each sample with fm_isolated_worktree_root" +} + +test_predicate_cases +test_poll_settles_only_on_the_worktree +test_spawn_uses_predicate diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index a6f24eaa5..17562735f 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -180,7 +180,7 @@ run_spawn() { FM_ROOT_OVERRIDE='' FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ - FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$pane" TMUX="fake,1,0" \ + FM_SPAWN_NO_GUARD=1 FM_SPAWN_WT_TIMEOUT=2 FM_FAKE_PANE_PATH="$pane" TMUX="fake,1,0" \ PATH="$fakebin:$PATH" \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" codex 2>&1 } @@ -196,21 +196,23 @@ test_spawn_isolation_abort() { mkdir -p "$TMP_ROOT/spawn-notgit" "$proj/sub" # Abort: the pane resolves to a plain non-git directory (not a worktree at all). + # Invalid candidates are rejected per-sample by the discovery poll, so the + # spawn times out without ever accepting one. out=$(run_spawn "$home" abort-notgit-dd4 "$proj" "$TMP_ROOT/spawn-notgit" "$fakebin"); status=$? expect_code 1 "$status" "spawn into a non-worktree dir should abort" - assert_contains "$out" "did not yield an isolated worktree" "non-worktree spawn lacked the isolation error" + assert_contains "$out" "did not enter an isolated worktree" "non-worktree spawn lacked the isolation error" assert_absent "$home/state/abort-notgit-dd4.meta" "aborted spawn must not record meta" # Abort: the pane resolves INTO the primary checkout (a subdir of PROJ_ABS). out=$(run_spawn "$home" abort-primary-ee5 "$proj" "$proj/sub" "$fakebin"); status=$? expect_code 1 "$status" "spawn landing inside the primary checkout should abort" - assert_contains "$out" "did not yield an isolated worktree" "primary-checkout spawn lacked the isolation error" + assert_contains "$out" "did not enter an isolated worktree" "primary-checkout spawn lacked the isolation error" # Proceed: the pane resolves to a genuine, isolated worktree. out=$(run_spawn "$home" ok-isolated-ff6 "$proj" "$TMP_ROOT/spawn-wt" "$fakebin"); status=$? expect_code 0 "$status" "spawn into a genuine isolated worktree should succeed" assert_contains "$out" "spawned ok-isolated-ff6" "isolated spawn did not report success" - assert_not_contains "$out" "did not yield an isolated worktree" "isolated spawn wrongly tripped the guard" + assert_not_contains "$out" "did not enter an isolated worktree" "isolated spawn wrongly tripped the guard" pass "fm-spawn: aborts unless the resolved worktree is a genuine, isolated worktree" }