From bac783426a209cf2a413ed85f5df369a02c849c3 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Thu, 20 Aug 2026 20:33:38 +0800 Subject: [PATCH 1/4] fix(spawn): compare worktrees by filesystem identity --- bin/fm-spawn.sh | 84 ++++++++++++++---- bin/fm-test-run.sh | 2 +- tests/fm-spawn-worktree-identity-live.test.sh | 86 +++++++++++++++++++ tests/fm-tangle-guard.test.sh | 28 ++++++ 4 files changed, 183 insertions(+), 17 deletions(-) create mode 100755 tests/fm-spawn-worktree-identity-live.test.sh 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-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..433b6d48ca 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -214,6 +214,33 @@ test_spawn_isolation_abort() { pass "fm-spawn: aborts unless the resolved worktree is a genuine, isolated worktree" } +# A leading // is a portable, textually distinct spelling that resolves to the +# same filesystem object as / on the supported Bash platforms. This drives the +# executable through the exact settle-loop boundary that issue #2654 exposed, +# without relying on a case-insensitive CI filesystem. +test_spawn_same_identity_alias_abort() { + local home proj pane_alias pane_real fakebin out status + home="$TMP_ROOT/spawn-identity-home" + mkdir -p "$home/data" + proj=$(make_repo "$TMP_ROOT/spawn-identity-proj") + pane_alias="/$proj" + pane_real=$(cd "$pane_alias" && pwd -P) + [ "$pane_real" != "$proj" ] \ + || fail "portable identity fixture collapsed its distinct path spellings ('$pane_real')" + [ "$pane_real" -ef "$proj" ] \ + || fail "portable identity fixture paths do not identify the same device and inode" + fakebin=$(make_spawn_fakebin "$TMP_ROOT/spawn-identity-fake") + fm_fake_exit0 "$fakebin" sleep + + out=$(run_spawn "$home" abort-identity-gg7 "$proj" "$pane_alias" "$fakebin"); status=$? + expect_code 1 "$status" "spawn should refuse a textually distinct alias of the primary checkout" + assert_contains "$out" "treehouse get did not enter a worktree" \ + "same-identity primary alias did not produce the settle-loop refusal" + assert_not_contains "$out" "spawned abort-identity-gg7" \ + "same-identity primary alias was wrongly launched" + pass "fm-spawn: filesystem identity refuses a textually distinct alias of the primary checkout" +} + # --- GUARD 1c: fm-spawn tmux window construction ---------------------------- # The prevention guard also depends on fm-spawn building robust tmux commands @@ -306,4 +333,5 @@ test_guard_banner test_bootstrap_line test_brief_assertion_precedes_branch test_spawn_isolation_abort +test_spawn_same_identity_alias_abort test_spawn_tmux_window_construction From 626d2e73388242781a437b54869818f767d7f079 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Thu, 20 Aug 2026 21:03:20 +0800 Subject: [PATCH 2/4] no-mistakes(review): test(spawn): make portable identity regression fail on text revert --- tests/fm-tangle-guard.test.sh | 54 ++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index 433b6d48ca..bc9d636757 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -214,31 +214,51 @@ test_spawn_isolation_abort() { pass "fm-spawn: aborts unless the resolved worktree is a genuine, isolated worktree" } -# A leading // is a portable, textually distinct spelling that resolves to the -# same filesystem object as / on the supported Bash platforms. This drives the -# executable through the exact settle-loop boundary that issue #2654 exposed, -# without relying on a case-insensitive CI filesystem. +# Drive fm-spawn's settle loop with a spelling of the primary checkout that +# shares its device and inode but differs in path text, and require refusal. +# The distinct spelling must survive real_path_or_raw's `pwd -P` canonicalization +# to actually exercise identity over text: a symlink or a leading // both collapse +# to the primary's canonical text (verified: `cd //x && pwd -P` -> /x), so under a +# reverted text comparison they would refuse for the wrong reason and the test +# could not tell identity from text. A case-insensitive filesystem is the portable +# source of such a pair - two case variants that identify one directory yet keep +# distinct `pwd -P` spellings - so where one is available this case exercises the +# real issue #2654 boundary and fails if the guard is reverted to text (the pane +# then passes the settle loop and is rejected later by a different message). Where +# the filesystem is case-sensitive no such pair exists, so we assert the still +# binding exact-path primary refusal instead of overclaiming identity-vs-text. test_spawn_same_identity_alias_abort() { - local home proj pane_alias pane_real fakebin out status + local home proj proj_variant fakebin out status home="$TMP_ROOT/spawn-identity-home" mkdir -p "$home/data" - proj=$(make_repo "$TMP_ROOT/spawn-identity-proj") - pane_alias="/$proj" - pane_real=$(cd "$pane_alias" && pwd -P) - [ "$pane_real" != "$proj" ] \ - || fail "portable identity fixture collapsed its distinct path spellings ('$pane_real')" - [ "$pane_real" -ef "$proj" ] \ - || fail "portable identity fixture paths do not identify the same device and inode" + proj=$(make_repo "$TMP_ROOT/SpawnIdentityProj") + proj_variant="$TMP_ROOT/spawnidentityproj" fakebin=$(make_spawn_fakebin "$TMP_ROOT/spawn-identity-fake") fm_fake_exit0 "$fakebin" sleep - out=$(run_spawn "$home" abort-identity-gg7 "$proj" "$pane_alias" "$fakebin"); status=$? - expect_code 1 "$status" "spawn should refuse a textually distinct alias of the primary checkout" + if [ -d "$proj_variant" ] && [ "$proj" -ef "$proj_variant" ]; then + # Same device and inode, textually distinct spellings: the primary is passed + # as one case variant and the pane settles at the other, so only a + # filesystem-identity comparison can recognize them as the same checkout. + out=$(run_spawn "$home" abort-identity-gg7 "$proj_variant" "$proj" "$fakebin"); status=$? + expect_code 1 "$status" "spawn should refuse a same-inode, distinct-text spelling of the primary checkout" + assert_contains "$out" "treehouse get did not enter a worktree" \ + "same-identity primary spelling did not produce the settle-loop refusal" + assert_not_contains "$out" "spawned abort-identity-gg7" \ + "same-identity primary spelling was wrongly launched" + pass "fm-spawn: filesystem identity refuses a same-inode, distinct-text spelling of the primary checkout" + return + fi + + # Case-sensitive filesystem: no portable distinct-text/same-inode pair exists, + # so pin the exact-path primary refusal (behavior), not identity-vs-text. + out=$(run_spawn "$home" abort-identity-gg7 "$proj" "$proj" "$fakebin"); status=$? + expect_code 1 "$status" "spawn should refuse the primary checkout" assert_contains "$out" "treehouse get did not enter a worktree" \ - "same-identity primary alias did not produce the settle-loop refusal" + "primary checkout did not produce the settle-loop refusal" assert_not_contains "$out" "spawned abort-identity-gg7" \ - "same-identity primary alias was wrongly launched" - pass "fm-spawn: filesystem identity refuses a textually distinct alias of the primary checkout" + "primary checkout was wrongly launched" + pass "fm-spawn: settle loop refuses the primary checkout (case-sensitive fs: exact-path coverage)" } # --- GUARD 1c: fm-spawn tmux window construction ---------------------------- From 3cc71d9023be5ce74d4bad8e9f08dfb84ea65e6e Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Thu, 20 Aug 2026 22:22:43 +0800 Subject: [PATCH 3/4] no-mistakes(review): test(spawn): portable direct identity test replaces alias e2e --- tests/fm-tangle-guard.test.sh | 86 ++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 36 deletions(-) diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index bc9d636757..6a296706f7 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -214,51 +214,64 @@ test_spawn_isolation_abort() { pass "fm-spawn: aborts unless the resolved worktree is a genuine, isolated worktree" } -# Drive fm-spawn's settle loop with a spelling of the primary checkout that -# shares its device and inode but differs in path text, and require refusal. -# The distinct spelling must survive real_path_or_raw's `pwd -P` canonicalization -# to actually exercise identity over text: a symlink or a leading // both collapse -# to the primary's canonical text (verified: `cd //x && pwd -P` -> /x), so under a -# reverted text comparison they would refuse for the wrong reason and the test -# could not tell identity from text. A case-insensitive filesystem is the portable -# source of such a pair - two case variants that identify one directory yet keep -# distinct `pwd -P` spellings - so where one is available this case exercises the -# real issue #2654 boundary and fails if the guard is reverted to text (the pane -# then passes the settle loop and is rejected later by a different message). Where -# the filesystem is case-sensitive no such pair exists, so we assert the still -# binding exact-path primary refusal instead of overclaiming identity-vs-text. -test_spawn_same_identity_alias_abort() { - local home proj proj_variant fakebin out status +# 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/SpawnIdentityProj") - proj_variant="$TMP_ROOT/spawnidentityproj" + proj=$(make_repo "$TMP_ROOT/spawn-identity-proj") fakebin=$(make_spawn_fakebin "$TMP_ROOT/spawn-identity-fake") fm_fake_exit0 "$fakebin" sleep - if [ -d "$proj_variant" ] && [ "$proj" -ef "$proj_variant" ]; then - # Same device and inode, textually distinct spellings: the primary is passed - # as one case variant and the pane settles at the other, so only a - # filesystem-identity comparison can recognize them as the same checkout. - out=$(run_spawn "$home" abort-identity-gg7 "$proj_variant" "$proj" "$fakebin"); status=$? - expect_code 1 "$status" "spawn should refuse a same-inode, distinct-text spelling of the primary checkout" - assert_contains "$out" "treehouse get did not enter a worktree" \ - "same-identity primary spelling did not produce the settle-loop refusal" - assert_not_contains "$out" "spawned abort-identity-gg7" \ - "same-identity primary spelling was wrongly launched" - pass "fm-spawn: filesystem identity refuses a same-inode, distinct-text spelling of the primary checkout" - return - fi - - # Case-sensitive filesystem: no portable distinct-text/same-inode pair exists, - # so pin the exact-path primary refusal (behavior), not identity-vs-text. out=$(run_spawn "$home" abort-identity-gg7 "$proj" "$proj" "$fakebin"); status=$? - expect_code 1 "$status" "spawn should refuse the primary checkout" + 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 (case-sensitive fs: exact-path coverage)" + pass "fm-spawn: settle loop refuses the primary checkout through the CLI" } # --- GUARD 1c: fm-spawn tmux window construction ---------------------------- @@ -353,5 +366,6 @@ test_guard_banner test_bootstrap_line test_brief_assertion_precedes_branch test_spawn_isolation_abort -test_spawn_same_identity_alias_abort +test_path_filesystem_identity +test_spawn_primary_copy_abort test_spawn_tmux_window_construction From 4c8f47bbc858969673ac609cf7e0ad8bfe327c18 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Thu, 20 Aug 2026 22:57:06 +0800 Subject: [PATCH 4/4] no-mistakes: apply CI fixes --- tests/fm-backend-herdr-presentation-e2e.test.sh | 6 ++++++ 1 file changed, 6 insertions(+) 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" &