diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 85ec24e30c..c85a863984 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1511,6 +1511,39 @@ real_path_or_raw() { # fi } +path_filesystem_identity() { # -> "device:inode" + local identity device inode + case $(uname -s 2>/dev/null) in + Darwin) identity=$(LC_ALL=C stat -f '%d:%i' "$1" 2>/dev/null) || return 1 ;; + Linux) identity=$(LC_ALL=C stat -c '%d:%i' -- "$1" 2>/dev/null) || return 1 ;; + *) + identity=$(LC_ALL=C stat -c '%d:%i' -- "$1" 2>/dev/null) \ + || identity=$(LC_ALL=C stat -f '%d:%i' "$1" 2>/dev/null) \ + || return 1 + ;; + esac + case "$identity" in + *:*) + device=${identity%%:*} + inode=${identity#*:} + case "$device:$inode" in + *:*:*|:*|*:|*[!0-9:]*) return 1 ;; + esac + printf '%s:%s\n' "$device" "$inode" + ;; + *) return 1 ;; + esac +} + +path_identity_or_refuse() { # + local identity + identity=$(path_filesystem_identity "$1") || { + echo "error: cannot read filesystem identity for '$1' while $2 on platform '$(uname -s 2>/dev/null || echo unknown)'; refusing to launch" >&2 + return 1 + } + printf '%s\n' "$identity" +} + # Session-provider container-ensure + task creation. tmux stays exactly as P1 # left it (same session-name / new-window sequence, see bin/backends/tmux.sh); # a herdr spawn goes through the version-gated, workspace-per-HOME, @@ -1521,6 +1554,7 @@ real_path_or_raw() { # # 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 + local wt_identity proj_identity wt_top_identity wt_real= if ! wt_real=$(cd "$WT" 2>/dev/null && pwd -P); then wt_real= @@ -1531,7 +1565,14 @@ validate_spawn_worktree() { # 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 + if [ -z "$wt_real" ] || [ -z "$wt_top_real" ]; then + 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 + wt_identity=$(path_identity_or_refuse "$wt_real" "validating $source's resolved worktree") || exit 1 + proj_identity=$(path_identity_or_refuse "$proj_real" "validating $source's primary checkout") || exit 1 + wt_top_identity=$(path_identity_or_refuse "$wt_top_real" "validating $source's git worktree root") || exit 1 + if [ "$wt_identity" != "$wt_top_identity" ] || [ "$wt_identity" = "$proj_identity" ]; then 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 @@ -1971,13 +2012,19 @@ if [ "$RELAUNCH" -eq 1 ]; then # that worktree, so the replacement agent starts where the work is rather # than wherever the pane happened to drift. relaunch_wt_real=$(real_path_or_raw "$WT") + relaunch_wt_identity=$(path_identity_or_refuse "$relaunch_wt_real" "validating task $ID's recorded relaunch worktree") || exit 1 relaunch_seen= + relaunch_seen_identity= for _ in $(seq 1 10); do relaunch_seen=$(spawn_current_path "$WT_TARGET" || true) - [ -z "$relaunch_seen" ] || [ "$(real_path_or_raw "$relaunch_seen")" != "$relaunch_wt_real" ] || break + if [ -n "$relaunch_seen" ]; then + relaunch_seen_real=$(real_path_or_raw "$relaunch_seen") + relaunch_seen_identity=$(path_identity_or_refuse "$relaunch_seen_real" "validating task $ID's relaunch endpoint") || exit 1 + [ "$relaunch_seen_identity" != "$relaunch_wt_identity" ] || break + fi sleep 0.5 done - if [ -z "$relaunch_seen" ] || [ "$(real_path_or_raw "$relaunch_seen")" != "$relaunch_wt_real" ]; then + if [ -z "$relaunch_seen" ] || [ "$relaunch_seen_identity" != "$relaunch_wt_identity" ]; then echo "error: task $ID's endpoint is in '${relaunch_seen:-unknown}', not its recorded worktree '$WT'; refusing to relaunch an agent outside the copy holding its work" >&2 exit 1 fi @@ -1990,37 +2037,42 @@ elif [ "$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. + # Compare filesystem identities rather than path text. Physical path spelling + # can still retain caller-supplied capitalization on case-insensitive macOS, + # and POSIX permits the same directory to retain a distinct leading // form. # - # A single read that already differs from PROJ_ABS_REAL is not proof the pane - # settled there: on some tmux/WSL setups a brand-new window's pane_current_path + # A single read whose identity already differs from the primary checkout is + # not proof the pane settled there: on some tmux/WSL setups a brand-new + # window's pane_current_path # transiently reports an unrelated stale path (seen live as another real git # checkout entirely) before the shell catches up with treehouse get's cd. That - # stale path still passes the PROJ_ABS_REAL comparison and validate_spawn_worktree + # stale path still passes the primary-checkout identity comparison and + # validate_spawn_worktree # below (it resolves to a real, distinct worktree top-level too), so accepting it # on one read alone silently records the wrong worktree= in state/.meta. Require - # two consecutive reads to agree on the same non-project path before accepting it; + # two consecutive reads to identify the same non-project directory before + # accepting it; # a mismatch just becomes the new candidate rather than resetting the wait, so a # pane that is already settled by the first real read only costs the one existing # inter-poll sleep as confirmation, not a whole extra cycle on top. - candidate="" + candidate_identity="" + project_identity=$(path_identity_or_refuse "$PROJ_ABS_REAL" "waiting for treehouse get to leave the primary checkout") || exit 1 for _ in $(seq 1 60); do p=$(spawn_current_path "$WT_TARGET" || true) if [ -n "$p" ]; then p_real=$(real_path_or_raw "$p") - if [ "$p_real" != "$PROJ_ABS_REAL" ]; then - if [ -n "$candidate" ] && [ "$p_real" = "$candidate" ]; then + p_identity=$(path_identity_or_refuse "$p_real" "waiting for treehouse get to settle") || exit 1 + if [ "$p_identity" != "$project_identity" ]; then + if [ -n "$candidate_identity" ] && [ "$p_identity" = "$candidate_identity" ]; then WT="$p" break fi - candidate="$p_real" + candidate_identity="$p_identity" else - candidate="" + candidate_identity="" fi else - candidate="" + candidate_identity="" fi sleep 1 done diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index c115718385..713a7f5004 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -187,7 +187,7 @@ family_for_basename() { fm-grok-stop-live-e2e.test.sh|fm-harness-liveness-drift-live-e2e.test.sh|\ fm-opencode-primary-live-e2e.test.sh|fm-pi-primary-live-e2e.test.sh|\ fm-quota-array-dispatch-live-e2e.test.sh|fm-send-secondmate-marker-herdr-e2e.test.sh|\ - fm-stow-open-record-live-e2e.test.sh) + fm-spawn-worktree-identity-live.test.sh|fm-stow-open-record-live-e2e.test.sh) printf '%s\n' live-harness-optin ;; fm-backend-herdr.test.sh|fm-backend-tmux-smoke.test.sh|fm-backend.test.sh|\ diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh index 0d0aedc593..9fb098f585 100755 --- a/tests/fm-backend-herdr-presentation-e2e.test.sh +++ b/tests/fm-backend-herdr-presentation-e2e.test.sh @@ -810,6 +810,12 @@ assert_no_ordering_lifecycle_calls_since "$FAIL_START" "failed presentation orde pass "real Herdr lab: forced workspace.move failure leaves a successful worker in default order with a warning and no cleanup" mkdir -p "$POST_CREATE_ABORT_CONTROL" +# A pane's foreground_cwd is always a real, live directory. Materialize the +# non-worktree location the fixture points settled panes at so its filesystem +# identity reads cleanly: the post-create abort must then come from +# validate_spawn_worktree rejecting a real directory that is not an isolated +# git worktree, not from an identity-read failure on a phantom path. +mkdir -p "$POST_CREATE_ABORT_CONTROL/not-a-worktree" ABORT_START=$(log_line_count) ABORT_FOCUS_START=$(focus_audit_line_count) spawn_task abort-a "$HOME_DIR" "$PROJECT_DIR" > "$TMP_ROOT/abort-a.out" 2> "$TMP_ROOT/abort-a.err" & diff --git a/tests/fm-spawn-worktree-identity-live.test.sh b/tests/fm-spawn-worktree-identity-live.test.sh new file mode 100755 index 0000000000..61b2731966 --- /dev/null +++ b/tests/fm-spawn-worktree-identity-live.test.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Live macOS case-insensitive-filesystem proof for fm-spawn's worktree guard. +# +# Run explicitly with FM_SPAWN_CASE_IDENTITY_LIVE=1 on the macOS host whose +# filesystem behavior is being trusted. The opt-in refuses every non-Darwin +# platform by name, refuses a case-sensitive fixture, drives fm-spawn.sh through +# its public CLI, and requires the real case-variant primary path to be refused. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +if [ "${FM_SPAWN_CASE_IDENTITY_LIVE:-0}" != 1 ]; then + echo "skip: set FM_SPAWN_CASE_IDENTITY_LIVE=1 to run the macOS case-insensitive worktree-identity proof" + exit 0 +fi + +PLATFORM=$(uname -s 2>/dev/null || echo unknown) +[ "$PLATFORM" = Darwin ] || { + echo "not ok - FM_SPAWN_CASE_IDENTITY_LIVE=1 requires macOS Darwin, got '$PLATFORM'" >&2 + exit 1 +} + +TMP_ROOT=$(fm_test_tmproot fm-spawn-worktree-identity-live) +fm_git_identity fmtest fmtest@example.invalid + +PROJECT="$TMP_ROOT/PrimaryProject" +PROJECT_VARIANT="$TMP_ROOT/primaryproject" +HOME_DIR="$TMP_ROOT/home" +ID=case-identity-live +mkdir -p "$PROJECT" "$HOME_DIR/data/$ID" +git init -q -b main "$PROJECT" +git -C "$PROJECT" commit -q --allow-empty -m init +printf 'brief\n' > "$HOME_DIR/data/$ID/brief.md" + +[ -d "$PROJECT_VARIANT" ] || { + echo "not ok - Darwin filesystem at '$TMP_ROOT' is case-sensitive; live case-identity proof cannot run" >&2 + exit 1 +} +[ "$PROJECT" -ef "$PROJECT_VARIANT" ] || { + echo "not ok - Darwin case variants do not identify the same device and inode at '$TMP_ROOT'" >&2 + exit 1 +} + +FAKEBIN=$(fm_fakebin "$TMP_ROOT/fake") +cat > "$FAKEBIN/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:?}"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + new-window) printf '@caseidentity\n'; exit 0 ;; + list-windows|has-session|new-session|send-keys|set-window-option) exit 0 ;; +esac +exit 0 +SH +chmod +x "$FAKEBIN/tmux" +fm_fake_exit0 "$FAKEBIN" treehouse +fm_fake_exit0 "$FAKEBIN" sleep + +mkdir -p "$HOME_DIR/state" "$HOME_DIR/projects" "$HOME_DIR/config" +set +e +OUT=$(FM_ROOT_OVERRIDE='' FM_HOME="$HOME_DIR" \ + FM_STATE_OVERRIDE="$HOME_DIR/state" FM_DATA_OVERRIDE="$HOME_DIR/data" \ + FM_PROJECTS_OVERRIDE="$HOME_DIR/projects" FM_CONFIG_OVERRIDE="$HOME_DIR/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$PROJECT" TMUX="fake,1,0" \ + PATH="$FAKEBIN:$PATH" \ + "$ROOT/bin/fm-spawn.sh" "$ID" "$PROJECT_VARIANT" codex \ + --mode no-mistakes --yolo off 2>&1) +STATUS=$? +set -e + +printf '# command: FM_SPAWN_CASE_IDENTITY_LIVE=1 tests/fm-spawn-worktree-identity-live.test.sh\n' +printf '# platform: %s\n' "$PLATFORM" +printf '# primary: %s\n' "$PROJECT_VARIANT" +printf '# pane cwd: %s\n' "$PROJECT" +printf '# exit: %s\n' "$STATUS" +printf '%s\n' "$OUT" + +expect_code 1 "$STATUS" "case-variant primary checkout should be refused" +assert_contains "$OUT" "treehouse get did not enter a worktree" \ + "case-variant primary checkout did not produce the settle-loop refusal" +assert_not_contains "$OUT" "spawned $ID" "case-variant primary checkout was wrongly launched" +pass "Darwin case-insensitive filesystem: fm-spawn refuses the primary checkout by identity" diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index 50e8ba298e..6a296706f7 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -214,6 +214,66 @@ test_spawn_isolation_abort() { pass "fm-spawn: aborts unless the resolved worktree is a genuine, isolated worktree" } +# The identity primitive that every spawn same-directory comparison now routes +# through. Load the real function out of the shipped script (fm-spawn.sh is a +# top-level program, not a sourceable library, so eval its exact definition) and +# exercise it: a device+inode pair is portable on BSD stat and GNU stat, two +# textually distinct spellings of one directory must compare EQUAL, and two +# distinct directories must compare UNEQUAL. A textual path comparison would call +# the distinct spellings unequal, so this fails if the guard's comparison is +# reverted to text - on both Linux and macOS, with no case-insensitive filesystem +# required. `..`-traversal (not a symlink or leading //) is the distinct spelling, +# because those collapse under canonicalization while stat still resolves inode. +test_path_filesystem_identity() { + local fn dir dir_alias other id_dir id_alias id_other + fn=$(awk '/^path_filesystem_identity\(\)/{f=1} f{print} f&&/^}/{exit}' "$ROOT/bin/fm-spawn.sh") + [ -n "$fn" ] || fail "could not load path_filesystem_identity from bin/fm-spawn.sh" + eval "$fn" + + mkdir -p "$TMP_ROOT/identity-dir" "$TMP_ROOT/identity-other" + dir="$TMP_ROOT/identity-dir" + dir_alias="$TMP_ROOT/identity-other/../identity-dir" + other="$TMP_ROOT/identity-other" + [ "$dir" != "$dir_alias" ] || fail "identity fixture spellings are not textually distinct" + + id_dir=$(path_filesystem_identity "$dir") || fail "identity read failed for '$dir'" + id_alias=$(path_filesystem_identity "$dir_alias") || fail "identity read failed for '$dir_alias'" + id_other=$(path_filesystem_identity "$other") || fail "identity read failed for '$other'" + + case "$id_dir" in + [0-9]*:[0-9]*) ;; + *) fail "identity is not a device:inode pair ('$id_dir')" ;; + esac + [ "$id_dir" = "$id_alias" ] \ + || fail "same device+inode via distinct spellings compared unequal ('$id_dir' vs '$id_alias')" + [ "$id_dir" != "$id_other" ] \ + || fail "distinct directories shared an identity ('$id_dir')" + + path_filesystem_identity "$TMP_ROOT/identity-does-not-exist" >/dev/null 2>&1 \ + && fail "identity read of a missing path should fail loudly, not succeed" + pass "path_filesystem_identity: distinct spellings of one dir identify equal; different dirs differ; missing path fails" +} + +# The exact-path primary-copy refusal through the fm-spawn CLI: when the pane +# settles at the primary checkout itself, the settle loop never leaves it and the +# spawn refuses rather than tangling a hook into the primary. +test_spawn_primary_copy_abort() { + local home proj fakebin out status + home="$TMP_ROOT/spawn-identity-home" + mkdir -p "$home/data" + proj=$(make_repo "$TMP_ROOT/spawn-identity-proj") + fakebin=$(make_spawn_fakebin "$TMP_ROOT/spawn-identity-fake") + fm_fake_exit0 "$fakebin" sleep + + out=$(run_spawn "$home" abort-identity-gg7 "$proj" "$proj" "$fakebin"); status=$? + expect_code 1 "$status" "spawn should refuse the primary checkout itself" + assert_contains "$out" "treehouse get did not enter a worktree" \ + "primary checkout did not produce the settle-loop refusal" + assert_not_contains "$out" "spawned abort-identity-gg7" \ + "primary checkout was wrongly launched" + pass "fm-spawn: settle loop refuses the primary checkout through the CLI" +} + # --- GUARD 1c: fm-spawn tmux window construction ---------------------------- # The prevention guard also depends on fm-spawn building robust tmux commands @@ -306,4 +366,6 @@ test_guard_banner test_bootstrap_line test_brief_assertion_precedes_branch test_spawn_isolation_abort +test_path_filesystem_identity +test_spawn_primary_copy_abort test_spawn_tmux_window_construction