Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 68 additions & 16 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1511,6 +1511,39 @@ real_path_or_raw() { # <path>
fi
}

path_filesystem_identity() { # <path> -> "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() { # <path> <context>
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,
Expand All @@ -1521,6 +1554,7 @@ real_path_or_raw() { # <path>
# per-backend routing (fm_backend_resolve_selector).
validate_spawn_worktree() { # <source> <inspect-target>
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=
Expand All @@ -1531,7 +1565,14 @@ validate_spawn_worktree() { # <source> <inspect-target>
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
Expand Down Expand Up @@ -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
Expand All @@ -1990,37 +2037,42 @@ elif [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then
# automatic-rename slips through), display-message -t <bad-name> 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/<id>.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
Expand Down
2 changes: 1 addition & 1 deletion bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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|\
Expand Down
6 changes: 6 additions & 0 deletions tests/fm-backend-herdr-presentation-e2e.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" &
Expand Down
86 changes: 86 additions & 0 deletions tests/fm-spawn-worktree-identity-live.test.sh
Original file line number Diff line number Diff line change
@@ -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"
62 changes: 62 additions & 0 deletions tests/fm-tangle-guard.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading