diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 52d7c8aee4b..16945dab68a 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -1,8 +1,11 @@ #!/usr/bin/env bash # Acquire or inspect the per-home firstmate session lock. -# Writes the harness (agent) process PID found by walking the shell's ancestry, -# which lives as long as the firstmate session - unlike the transient subshell -# PID of any one tool call, which is dead moments after it is written. +# Writes the PID that identifies this session, resolved by +# bin/fm-session-lock-lib.sh - which owns that decision, including why the +# harness's own declaration of its session process is preferred over the +# ancestry walk where one exists. Either way it is a process that lives as long +# as the firstmate session, unlike the transient subshell PID of any one tool +# call, which is dead moments after it is written. # Usage: fm-lock.sh acquire; exit 1 unless ownership is verified # fm-lock.sh status print holder and liveness; always exits 0 set -u diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index d77e563f0b4..38a7aef33a8 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # Shared session-lock harness identity. # -# ONE owner of the "which verified-harness process holds this home's session -# lock, and does the current process descend from that same harness?" decision. +# ONE owner of the "which process identifies this session, which verified-harness +# process holds this home's session lock, and does the current process descend +# from that same harness?" decision. # bin/fm-lock.sh uses it to acquire and inspect state/.lock; # bin/fm-claude-stop-autoarm.sh uses it to prove a Stop hook fires inside the # lock-owning primary session before it may arm or rewake. @@ -125,15 +126,64 @@ fm_harness_ancestry_pids() { [ "$printed" -eq 1 ] } +# The harness's own statement of which process IS this session, or return 1. +# +# Ancestry alone cannot answer that question under a session-hosting daemon, +# because two different arrangements produce the identical process chain: +# - an async hook or tool call of session X, run for X inside a daemon-hosted +# worker (the pid to record is X, several hops up), and +# - a background session of its own, launched BY session X through that same +# daemon (the pid to record is the background session, several hops down). +# In both, every hop from the caller up to X is harness-named with no gap, so no +# process-table fact separates them. Claude Code does separate them: it exports +# CLAUDE_PID into the processes it spawns for a session, set to that session's +# own pid, overriding whatever value those processes inherited. A stale inherited +# value is therefore possible only where the harness did not spawn the process at +# all (a tmux server started from a session, say, and every pane below it), which +# is why the value is trusted only after the checks in the caller below. +fm_harness_declared_session_pid() { + local declared=${CLAUDE_PID:-} + case "$declared" in + ''|*[!0-9]*) return 1 ;; + esac + printf '%s\n' "$declared" +} + # Print the one pid that identifies this session when the session lock is being -# WRITTEN: the outermost pid of the contiguous run. That is the pid that lives as -# long as the session - a Claude worker several levels in is reaped when its hook -# returns, and a lock naming it would look stale moments later while the session -# is still running. Every non-Claude harness reports a single pid, so this is its -# innermost match unchanged. +# WRITTEN. +# +# The harness's own declaration wins when it names a live harness process inside +# this contiguous run. Requiring membership is what makes an untrustworthy value +# harmless: an inherited pid from an unrelated session is not in this ancestry +# and is ignored, and a dead one cannot be recorded as a live owner. +# +# Otherwise fall back to the outermost pid of the contiguous run. That is the pid +# that lives as long as the session for every harness that declares nothing - a +# Claude worker several levels in is reaped when its hook returns, and a lock +# naming it would look stale moments later while the session is still running. +# Every non-Claude harness reports a single pid, so this is its innermost match +# unchanged. +# +# The fallback is also what the outermost pid costs: under a session-hosting +# daemon it reaches past this session's own processes onto the session that +# launched it, so the lock records a pid that is only transiently an ancestor. +# When the daemon between them exits, that pid stops being an ancestor while +# still naming a live harness, and fm_session_lock_owned_by_self below then +# reads this session's own lock as a competing session's - permanently, for the +# rest of the session. tests/fm-session-lock-ancestry.test.sh drives that shape. fm_harness_ancestry_pid() { - local pids pid outermost='' + local pids pid outermost='' declared pids=$(fm_harness_ancestry_pids) || return 1 + if declared=$(fm_harness_declared_session_pid) && fm_harness_pid_alive "$declared"; then + while IFS= read -r pid; do + if [ "$pid" = "$declared" ]; then + printf '%s\n' "$declared" + return 0 + fi + done < shadowing ps. kill is stubbed so # liveness questions are decided by the process table alone. +# +# The harness declaration is cleared unless the case sets FM_TEST_DECLARED_PID, +# so no assertion here depends on the ambient CLAUDE_PID of whatever session runs +# this suite - a real one colliding with a fixture pid would otherwise decide the +# outcome silently. lib_eval() { # local fakebin=$1 expr=$2 - PATH="$fakebin:$PATH" bash -c " + local -a declaration=(-u CLAUDE_PID) + [ -z "${FM_TEST_DECLARED_PID+x}" ] || declaration=("CLAUDE_PID=$FM_TEST_DECLARED_PID") + env "${declaration[@]}" PATH="$fakebin:$PATH" bash -c " . \"\$0\" kill() { return 0; } $expr @@ -220,6 +227,89 @@ SH pass "session-lock: a live version-named session holding the lock is not mistaken for a stale owner" } +test_declaration_is_trusted_only_inside_this_live_ancestry() { + local dir fakebin got bogus + dir="$TMP_ROOT/declaration-guards" + fakebin=$(fm_fakebin "$dir") + mkdir -p "$dir/state" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +field= pid= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) field=$2; shift 2 ;; + -p) pid=$2; shift 2 ;; + *) shift ;; + esac +done +# Nothing runs under 410: real ps reports nothing and fails for a dead pid. +[ "$pid" != 410 ] || exit 1 +case "$pid:$field" in + 300:comm=) printf '%s\n' claude ;; + 300:args=) printf '%s\n' claude ;; + 300:ppid=) printf '%s\n' 310 ;; + 310:comm=) printf '%s\n' claude ;; + 310:args=) printf '%s\n' claude ;; + 310:ppid=) printf '%s\n' 320 ;; + 320:comm=) printf '%s\n' claude ;; + 320:args=) printf '%s\n' claude ;; + 320:ppid=) printf '%s\n' 1 ;; + 400:comm=) printf '%s\n' claude ;; + 400:args=) printf '%s\n' claude ;; + 400:ppid=) printf '%s\n' 1 ;; + *:comm=) printf '%s\n' bash ;; + *:args=) printf '%s\n' 'bash /repo/bin/fm-lock.sh' ;; + *:ppid=) printf '%s\n' 300 ;; +esac +SH + chmod +x "$fakebin/ps" + + # The contiguous run is session 300 -> daemon 310 -> launching session 320, so + # the ancestry fallback always answers 320 while every declaration below names + # something else. Which branch decided the identity is therefore readable off + # the answer, and no assertion here can pass for both branches at once. + got=$(lib_eval "$fakebin" 'fm_harness_ancestry_pid') \ + || fail "the contiguous harness run was not resolved without a declaration" + [ "$got" = 320 ] \ + || fail "with nothing declared the identity must be the outermost pid 320, got '$got'" + + got=$(FM_TEST_DECLARED_PID=300 lib_eval "$fakebin" 'fm_harness_ancestry_pid') \ + || fail "a session pid declared inside the ancestry left identity unresolved" + [ "$got" = 300 ] \ + || fail "a live declaration inside the ancestry must beat the fallback 320, got '$got'" + + # Alive, harness-named, and not in this ancestry: the shape an inherited value + # from an unrelated session takes. Membership alone must reject it. + lib_eval "$fakebin" 'fm_harness_pid_alive 400' \ + || fail "fixture is wrong: 400 must be a live harness for the membership guard to be what rejects it" + got=$(FM_TEST_DECLARED_PID=400 lib_eval "$fakebin" 'fm_harness_ancestry_pid') \ + || fail "a declaration outside the ancestry left identity unresolved instead of falling back" + [ "$got" != 400 ] \ + || fail "a live harness pid outside this ancestry was recorded as this session's identity" + [ "$got" = 320 ] \ + || fail "a declaration outside the ancestry must fall back to 320, got '$got'" + + # Dead: recording it would name an owner no liveness check can ever confirm. + if lib_eval "$fakebin" 'fm_harness_pid_alive 410'; then + fail "fixture is wrong: 410 must be dead for the liveness guard to be what rejects it" + fi + got=$(FM_TEST_DECLARED_PID=410 lib_eval "$fakebin" 'fm_harness_ancestry_pid') \ + || fail "a dead declaration left identity unresolved instead of falling back" + [ "$got" != 410 ] \ + || fail "a dead declared pid was recorded as this session's live identity" + [ "$got" = 320 ] \ + || fail "a dead declaration must fall back to 320, got '$got'" + + for bogus in '' claude-300 '30 0' -300; do + got=$(FM_TEST_DECLARED_PID="$bogus" lib_eval "$fakebin" 'fm_harness_ancestry_pid') \ + || fail "a non-numeric declaration '$bogus' left identity unresolved instead of falling back" + [ "$got" = 320 ] \ + || fail "a non-numeric declaration '$bogus' must fall back to 320, got '$got'" + done + pass "session-lock: a declared session pid is used only while it is live and inside this ancestry" +} + # --- end-to-end layer: the real Stop auto-arm in real process trees ---------- install_autoarm_scripts() { @@ -356,10 +446,327 @@ test_e2e_daemon_parented_version_named_session_keeps_its_lock() { pass "session-lock e2e: a version-named session under a harness-named daemon keeps its own lock" } +# --- background-session layer: which pid the WRITER records ------------------- +# +# A daemon-hosted background session sits several harness-named hops below the +# session that launched it: session -> pty host -> daemon -> launching session, +# with no non-harness process anywhere in between. The whole chain therefore +# reads as one contiguous harness run, so resolving "this session" as the +# outermost pid of that run reaches past this session's own processes and lands +# on the launching session. The fixtures below build that real shape and drive +# the real bin/fm-lock.sh and the real Stop auto-arm through it. + +install_guard_scripts() { # + local dir=$1 + cp "$ROOT/bin/fm-turnend-guard.sh" "$dir/bin/fm-turnend-guard.sh" + chmod +x "$dir/bin/fm-turnend-guard.sh" + # The guard shells out for its repair line and matches watcher identity by + # this path; neither decides anything these cases assert. + cat > "$dir/bin/fm-supervision-instructions.sh" <<'SH' +#!/usr/bin/env bash +printf 'arm supervision\n' +SH + cat > "$dir/bin/fm-watch.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$dir/bin/fm-supervision-instructions.sh" "$dir/bin/fm-watch.sh" +} + +# A primary home plus the three-level launcher/daemon/session fixture. Each +# level runs through a real executable named "claude" so the ancestry walk sees +# a genuine contiguous harness run, and each records its own pid before doing +# anything else so bash cannot tail-exec-collapse two levels into one. +make_bg_session_home() { # + local dir=$1 + mkdir -p "$dir/state" + git init -q "$dir" + git -C "$dir" commit -q --allow-empty -m init + : > "$dir/AGENTS.md" + : > "$dir/state/task.meta" + install_autoarm_scripts "$dir" + install_guard_scripts "$dir" + + # The session a background job is launched FROM. It stays alive for the whole + # case, so its pid is always a live harness pid. + cat > "$dir/launcher.sh" <<'SH' +#!/usr/bin/env bash +i=0 +while [ "$i" -lt 400 ] && [ "$(ps -o ppid= -p $$ 2>/dev/null | tr -d ' ')" != 1 ]; do + sleep 0.05 + i=$((i + 1)) +done +printf '%s\n' "$$" > "$FM_HOME/state/launcher-pid" +if [ "${FM_FIXTURE_LAUNCHER_TAKES_LOCK:-0}" = 1 ]; then + CLAUDE_PID=$$ "$FM_HOME/bin/fm-lock.sh" > "$FM_HOME/state/launcher-lock.out" 2>&1 + printf '%s\n' "$?" > "$FM_HOME/state/launcher-lock.rc" +fi +"$FM_CLAUDE_BIN" "$FM_HOME/daemon.sh" & +i=0 +while [ "$i" -lt 600 ] && [ ! -e "$FM_HOME/state/lock-done" ]; do + sleep 0.05 + i=$((i + 1)) +done +if [ "${FM_FIXTURE_LAUNCHER_EXITS:-0}" = 1 ]; then + : > "$FM_HOME/state/launcher-gone" + exit 0 +fi +i=0 +while [ "$i" -lt 900 ] && [ ! -e "$FM_HOME/state/finished" ]; do + sleep 0.05 + i=$((i + 1)) +done +SH + + # The shared daemon that hosts background sessions. It exits once the session + # has taken the lock, which is what severs the chain above the session and + # leaves the session's own recorded identity unreachable from its ancestry. + cat > "$dir/daemon.sh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$$" > "$FM_HOME/state/daemon-pid" +"$FM_CLAUDE_BIN" "$FM_HOME/session.sh" & +i=0 +while [ "$i" -lt 600 ] && [ ! -e "$FM_HOME/state/lock-done" ]; do + sleep 0.05 + i=$((i + 1)) +done +exit 0 +SH + + # The background session itself: session start first, then the Stop hooks + # after the daemon above it has gone. + cat > "$dir/session.sh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$$" > "$FM_HOME/state/session-pid" +# The harness names the session process to everything it spawns; the fixture +# stands in for that. FM_FIXTURE_DECLARED_PID_FILE overrides it with whatever +# the case planted there, which is how an inherited value from somewhere else +# is modelled. +if [ -n "${FM_FIXTURE_DECLARED_PID_FILE:-}" ]; then + export CLAUDE_PID=$(cat "$FM_FIXTURE_DECLARED_PID_FILE") +else + export CLAUDE_PID=$$ +fi +"$FM_HOME/bin/fm-lock.sh" > "$FM_HOME/state/session-lock.out" 2>&1 +printf '%s\n' "$?" > "$FM_HOME/state/session-lock.rc" +cp "$FM_HOME/state/.lock" "$FM_HOME/state/lock-after-start" 2>/dev/null +: > "$FM_HOME/state/lock-done" +i=0 +while [ "$i" -lt 600 ] && [ "$(ps -o ppid= -p $$ 2>/dev/null | tr -d ' ')" != 1 ]; do + sleep 0.05 + i=$((i + 1)) +done +if [ "${FM_FIXTURE_LAUNCHER_EXITS:-0}" = 1 ]; then + i=0 + while [ "$i" -lt 600 ] && [ ! -e "$FM_HOME/state/launcher-gone" ]; do + sleep 0.05 + i=$((i + 1)) + done +fi +"$FM_HOME/bin/fm-claude-stop-autoarm.sh" "$FM_HOME/state/hook.out" 2>&1 +printf '%s\n' "$?" > "$FM_HOME/state/hook.rc" +printf '%s' '{"session_id":"fixture","stop_hook_active":false}' \ + | "$FM_HOME/bin/fm-turnend-guard.sh" --claude > "$FM_HOME/state/guard.out" 2>&1 +printf '%s\n' "$?" > "$FM_HOME/state/guard.rc" +: > "$FM_HOME/state/finished" +SH + chmod +x "$dir/launcher.sh" "$dir/daemon.sh" "$dir/session.sh" +} + +# Start the fixture detached, so the launcher itself is orphaned and the walk +# can never climb out of the fixture into the session running this suite. +run_bg_session_tree() { # [...] + local dir=$1 i + shift + env FM_HOME="$dir" FM_CLAUDE_BIN="$NAMED_CLAUDE" "$@" \ + bash -c '"$0" "$1" &' "$NAMED_CLAUDE" "$dir/launcher.sh" + i=0 + while [ "$i" -lt 900 ] && [ ! -e "$dir/state/finished" ]; do + sleep 0.05 + i=$((i + 1)) + done + [ -e "$dir/state/finished" ] || fail "the background-session fixture never finished" +} + +fixture_pid() { # + tr -d '[:space:]' < "$1/state/$2" +} + +assert_distinct_chain() { # + local dir=$1 launcher daemon session + launcher=$(fixture_pid "$dir" launcher-pid) + daemon=$(fixture_pid "$dir" daemon-pid) + session=$(fixture_pid "$dir" session-pid) + [ -n "$launcher" ] && [ -n "$daemon" ] && [ -n "$session" ] \ + && [ "$launcher" != "$daemon" ] && [ "$daemon" != "$session" ] && [ "$launcher" != "$session" ] \ + || fail "fixture did not produce three distinct harness levels: launcher=$launcher daemon=$daemon session=$session" +} + +# The real turn-end guard runs last in every fixture below, on the same Stop +# event as the auto-arm before it, so its verdict is the second half of the same +# identity decision: it may only stand down where this session was recognized as +# its home's owner and the auto-arm therefore claimed recovery. Asserting it is +# what stops a guard that crashed, blocked blindly, or allowed blindly from +# passing unnoticed underneath the identity assertions. +guard_rc() { # + tr -d '[:space:]' < "$1/state/guard.rc" +} + +assert_guard_stood_down() { # + local dir=$1 why=$2 + expect_code 0 "$(guard_rc "$dir")" "$why" + [ ! -s "$dir/state/guard.out" ] \ + || fail "the guard allowed the turn but still printed a banner: $(cat "$dir/state/guard.out")" +} + +assert_guard_blocked_blind_turn() { # + local dir=$1 why=$2 + expect_code 2 "$(guard_rc "$dir")" "$why" + grep -q 'TURN WOULD END BLIND' "$dir/state/guard.out" \ + || fail "the guard blocked without its repair banner: $(cat "$dir/state/guard.out")" + grep -q 'task(s) in flight, but no live watcher holds this home lock' "$dir/state/guard.out" \ + || fail "the guard's banner did not record the supervision need it blocked on: $(cat "$dir/state/guard.out")" + grep -q 'The Stop-owned auto-arm did not claim this home' "$dir/state/guard.out" \ + || fail "the guard's banner did not record that no auto-arm claim covered it: $(cat "$dir/state/guard.out")" +} + +test_bg_session_records_its_own_identity_and_keeps_arming() { + local dir launcher session recorded + dir="$TMP_ROOT/bg-session-sole" + make_bg_session_home "$dir" + run_bg_session_tree "$dir" + assert_distinct_chain "$dir" + launcher=$(fixture_pid "$dir" launcher-pid) + session=$(fixture_pid "$dir" session-pid) + recorded=$(fixture_pid "$dir" lock-after-start) + + [ "$recorded" != "$launcher" ] \ + || fail "session start recorded the LAUNCHING session's pid $launcher as this session's identity" + [ "$recorded" = "$session" ] \ + || fail "session start recorded '$recorded' as this session's identity, expected the session pid $session" + expect_code 2 "$(hook_rc "$dir")" \ + "a background session that owns its home must claim it and rewake after the daemon above it has gone" + [ -e "$dir/state/arm-ran" ] \ + || fail "supervision never armed for a background session that owns its home" + [ "$(epoch_outcome "$dir")" = rewake ] \ + || fail "no claim was recorded for a background session, got: $(epoch_outcome "$dir")" + assert_guard_stood_down "$dir" \ + "the turn-end guard must stand down for the claim the auto-arm just recorded" + pass "session-lock: a background session records its own identity and keeps claiming its home" +} + +test_bg_session_never_claims_a_home_a_live_session_owns() { + local dir launcher recorded + dir="$TMP_ROOT/bg-session-competing" + make_bg_session_home "$dir" + run_bg_session_tree "$dir" FM_FIXTURE_LAUNCHER_TAKES_LOCK=1 + assert_distinct_chain "$dir" + launcher=$(fixture_pid "$dir" launcher-pid) + recorded=$(fixture_pid "$dir" lock-after-start) + + expect_code 1 "$(fixture_pid "$dir" session-lock.rc)" \ + "a background session must be refused the lock a live launching session already holds" + grep -q 'another live firstmate session holds the lock' "$dir/state/session-lock.out" \ + || fail "the refusal did not name the competing live session: $(cat "$dir/state/session-lock.out")" + [ "$recorded" = "$launcher" ] \ + || fail "the live owner's lock was overwritten: expected $launcher, got $recorded" + expect_code 0 "$(hook_rc "$dir")" "a session that does not own the home must stay inert" + [ ! -e "$dir/state/arm-ran" ] || fail "a session that does not own the home armed supervision" + [ -z "$(epoch_outcome "$dir")" ] || fail "a non-owning session wrote an auto-arm claim" + assert_guard_blocked_blind_turn "$dir" \ + "with no auto-arm claim behind it the turn-end guard must block rather than allow a blind turn" + pass "session-lock: a background session never claims a home a live launching session owns" +} + +test_bg_session_recovers_a_genuinely_dead_owner() { + local dir session recorded + dir="$TMP_ROOT/bg-session-stale" + make_bg_session_home "$dir" + run_bg_session_tree "$dir" FM_FIXTURE_LAUNCHER_TAKES_LOCK=1 FM_FIXTURE_LAUNCHER_EXITS=1 + assert_distinct_chain "$dir" + session=$(fixture_pid "$dir" session-pid) + recorded=$(tr -d '[:space:]' < "$dir/state/.lock") + + expect_code 2 "$(hook_rc "$dir")" "a demonstrably dead owner must be reclaimed and the home claimed" + [ -e "$dir/state/arm-ran" ] || fail "supervision never armed after reclaiming a dead owner" + [ "$recorded" = "$session" ] \ + || fail "the reclaimed lock does not name the recovering session: expected $session, got $recorded" + assert_guard_stood_down "$dir" \ + "the turn-end guard must stand down once the reclaiming session's auto-arm has claimed the home" + pass "session-lock: a background session still reclaims a genuinely dead owner" +} + +test_bg_session_stays_inert_while_away_mode_owns_supervision() { + local dir + dir="$TMP_ROOT/bg-session-afk" + make_bg_session_home "$dir" + : > "$dir/state/.afk" + run_bg_session_tree "$dir" + expect_code 0 "$(hook_rc "$dir")" "away mode must keep the auto-arm inert" + [ ! -e "$dir/state/arm-ran" ] || fail "the auto-arm armed supervision while away mode owned it" + [ -z "$(epoch_outcome "$dir")" ] || fail "the auto-arm claimed the home while away mode owned it" + assert_guard_blocked_blind_turn "$dir" \ + "away mode silences the auto-arm, not the turn-end guard, which must still block a blind turn" + pass "session-lock: away mode still owns supervision for a background session" +} + +test_bg_session_ignores_a_declaration_outside_its_own_ancestry() { + local dir launcher session recorded outsider + dir="$TMP_ROOT/bg-session-stale-declaration" + make_bg_session_home "$dir" + + # A live harness process the fixture's session does not descend from: the shape + # a genuinely stale inherited declaration takes, such as a tmux server started + # from another session and every pane below it. It outlives the lock write so + # liveness cannot be what rejects it - only ancestry membership can. + "$NAMED_CLAUDE" -c ' +i=0 +while [ "$i" -lt 900 ] && [ ! -e "$0" ]; do + sleep 0.05 + i=$((i + 1)) +done +' "$dir/state/outsider-stop" & + outsider=$! + printf '%s\n' "$outsider" > "$dir/state/declared-pid" + + run_bg_session_tree "$dir" FM_FIXTURE_DECLARED_PID_FILE="$dir/state/declared-pid" + assert_distinct_chain "$dir" + launcher=$(fixture_pid "$dir" launcher-pid) + session=$(fixture_pid "$dir" session-pid) + recorded=$(fixture_pid "$dir" lock-after-start) + # shellcheck source=/dev/null + ( . "$LIB" && fm_harness_pid_alive "$outsider" ) \ + || fail "fixture is wrong: the declared pid $outsider was not a live harness across the lock write" + : > "$dir/state/outsider-stop" + wait "$outsider" 2>/dev/null || true + + # The three candidate answers are deliberately three different pids, so the + # recorded owner names which rule decided. Trusting the declaration would + # record the outsider; the guard rejects it and the ancestry fallback answers + # the launching session instead, exactly as before this branch. The auto-arm + # consequently stays inert rather than arming blind. + [ "$outsider" != "$launcher" ] && [ "$outsider" != "$session" ] && [ "$launcher" != "$session" ] \ + || fail "fixture did not diverge: outsider=$outsider launcher=$launcher session=$session" + [ "$recorded" != "$outsider" ] \ + || fail "a live harness outside this session's ancestry was recorded as its identity: $outsider" + [ "$recorded" = "$launcher" ] \ + || fail "an inherited declaration did not fall back to prior behavior: got '$recorded', expected $launcher" + expect_code 0 "$(hook_rc "$dir")" "the documented fallback keeps the hook inert, not arming blind" + assert_guard_blocked_blind_turn "$dir" \ + "the ignored declaration leaves no auto-arm claim, so the turn-end guard must block the blind turn" + pass "session-lock: a declaration outside this session's ancestry is ignored for the prior identity" +} + test_version_named_session_is_identified_on_both_platforms test_ordinary_paths_are_never_harness_processes test_harness_beyond_a_gap_never_owns_the_lock test_competing_version_named_session_is_seen_as_live +test_declaration_is_trusted_only_inside_this_live_ancestry test_e2e_version_named_session_claims_the_home test_e2e_daemon_parented_session_claims_the_home test_e2e_daemon_parented_version_named_session_keeps_its_lock +test_bg_session_records_its_own_identity_and_keeps_arming +test_bg_session_never_claims_a_home_a_live_session_owns +test_bg_session_recovers_a_genuinely_dead_owner +test_bg_session_stays_inert_while_away_mode_owns_supervision +test_bg_session_ignores_a_declaration_outside_its_own_ancestry diff --git a/tests/fm-session-lock-declaration-live-e2e.test.sh b/tests/fm-session-lock-declaration-live-e2e.test.sh new file mode 100755 index 00000000000..78fc234e894 --- /dev/null +++ b/tests/fm-session-lock-declaration-live-e2e.test.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# tests/fm-session-lock-declaration-live-e2e.test.sh - opt-in drift guard proving +# a real Claude Code session still declares its own process to the processes it +# spawns, which is what bin/fm-session-lock-lib.sh records as the session-lock +# identity. +# +# Why this file exists: under a session-hosting daemon the process table cannot +# tell an async hook running FOR a session apart from a background session +# launched BY it - both chains are harness-named end to end with no gap. The only +# signal that separates them is the harness's own declaration, so the writer +# prefers it. That makes a vendor-controlled surface load-bearing: if Claude Code +# stopped setting CLAUDE_PID, or started letting an inherited value through, the +# writer would silently fall back to recording the launching session's pid again +# and a background session's supervision would go inert mid-session. +# +# The guard therefore asserts the property that actually matters, not just that +# the variable exists: the session is launched with a DELIBERATELY WRONG +# CLAUDE_PID in its environment, and the value the harness reports inside its own +# SessionStart hook must be the session's own live process instead. A stub agent +# cannot prove that; only the real harness can. +# +# Standard CI has no harness binary and no credentials, so this is opt-in and +# on-demand. tests/fm-session-lock-ancestry.test.sh pins the same logic portably +# in CI with real processes and no harness. Run this guard after every Claude Code +# upgrade and before trusting refreshed evidence in +# docs/verification/supervision.md. +# +# Two real sessions are exercised, because they are different shapes and only the +# second is the one the fix exists for: +# - a print-mode session, whose hook runs directly under it, and +# - a real background agent (claude --bg), whose hook runs several harness-named +# hops below the shared session-hosting daemon. Without a declaration the +# writer would record that DAEMON as this session's identity - a process +# shared by every background session in the home, which outlives any one of +# them. +# Each runs one turn with a one-word prompt, the smallest real session that fires +# a hook. That token cost is deliberate: the alternative is a check that can only +# confirm the assumption already written into its own stub. +set -u + +if [ "${FM_SESSION_LOCK_DECLARATION_DRIFT:-0}" != 1 ]; then + echo "skip: set FM_SESSION_LOCK_DECLARATION_DRIFT=1 to run the installed-harness session-declaration drift guard" + exit 0 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +LAB= +cleanup_all() { [ -n "${LAB:-}" ] && rm -rf "$LAB"; } +fail() { printf 'not ok - %s\n' "$1" >&2; cleanup_all; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } +note() { printf '# %s\n' "$1"; } + +CLAUDE_BIN=$(command -v claude 2>/dev/null || true) +[ -n "$CLAUDE_BIN" ] && [ -x "$CLAUDE_BIN" ] \ + || fail "claude is not installed, so this guard verified nothing; install it or run the portable counterpart instead" + +LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-session-lock-declaration.XXXXXX") || fail "could not create the lab directory" +trap cleanup_all EXIT + +VERSION=$("$CLAUDE_BIN" --version 2>/dev/null | head -1) +note "claude: ${VERSION:-unknown version}" + +# A pid no live process can hold, planted in the launch environment. The harness +# must override it; anything that lets it through is the drift this guard exists +# to catch. +POISON=2147483646 + +# The lab is a plain directory, never a firstmate home, so the only hook that can +# run is this guard's own reporter. +setup_lab_shape() { # + local shape=$1 + mkdir -p "$LAB/$shape/.claude" "$LAB/$shape/out" + cat > "$LAB/$shape/report.sh" < "$LAB/$shape/out/report.txt" 2>&1 +exit 0 +SH + chmod +x "$LAB/$shape/report.sh" + printf '{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"%s"}]}]}}\n' \ + "$LAB/$shape/report.sh" > "$LAB/$shape/.claude/settings.json" +} + +read_field() { # + sed -n "s/^$2=//p" "$LAB/$1/out/report.txt" | head -1 +} + +# Assert the whole property for one shape: the harness declared a live process of +# its own instead of the planted value, the library reads the same thing, that pid +# is inside the hook's own harness ancestry, and the writer records it. Results go +# to SHAPE_* rather than stdout, so a diagnostic line can never be captured as a +# pid by a caller. +SHAPE_DECLARED= +SHAPE_ANCESTRY= +SHAPE_OUTERMOST= +assert_shape() { # + local shape=$1 what=$2 declared lib_declared writer ancestry outermost + [ -s "$LAB/$shape/out/report.txt" ] \ + || fail "$what: the real session never ran its own SessionStart hook, so nothing was verified: $(tail -3 "$LAB/$shape/out/turn.log" 2>/dev/null)" + declared=$(read_field "$shape" declared) + lib_declared=$(read_field "$shape" lib_declared) + writer=$(read_field "$shape" writer_identity) + ancestry=$(read_field "$shape" ancestry) + + case "$declared" in + ''|none|*[!0-9]*) + fail "$what: claude ${VERSION:-?} no longer declares a numeric session pid to its own hooks (got '$declared'); session-lock identity has silently fallen back to the outermost pid of the ancestry" + ;; + esac + [ "$declared" != "$POISON" ] \ + || fail "$what: claude ${VERSION:-?} passed an INHERITED CLAUDE_PID through to its own hook instead of declaring the session; the writer can no longer tell a session from the plumbing above it" + [ "$lib_declared" = "$declared" ] \ + || fail "$what: the library read '$lib_declared' where the harness declared '$declared'" + [ "$writer" = "$declared" ] \ + || fail "$what: the session-lock writer recorded '$writer' instead of the declared session pid '$declared'; ancestry was: $ancestry" + case " $ancestry " in + *" $declared "*) : ;; + *) fail "$what: the declared session pid '$declared' was not in the hook's own harness ancestry ($ancestry), so the writer's membership check rejected it and fell back" ;; + esac + outermost=$(printf '%s' "$ancestry" | awk '{print $NF}') + SHAPE_DECLARED=$declared + SHAPE_ANCESTRY=$ancestry + SHAPE_OUTERMOST=$outermost + note "$what: declared $declared, ancestry [$ancestry], planted $POISON" +} + +# --- shape 1: a print-mode session, hook directly beneath it ------------------ +setup_lab_shape print +( cd "$LAB/print" && CLAUDE_PID="$POISON" timeout 300 "$CLAUDE_BIN" \ + -p 'Reply with the single word: ok' --dangerously-skip-permissions \ + >"$LAB/print/out/turn.log" 2>&1 ) || true +assert_shape print "print-mode session" + +# --- shape 2: a real background agent under the session-hosting daemon -------- +BG_ID= +stop_background_agent() { + [ -n "${BG_ID:-}" ] || return 0 + "$CLAUDE_BIN" stop "$BG_ID" >/dev/null 2>&1 || true + BG_ID= +} +trap 'stop_background_agent; cleanup_all' EXIT + +setup_lab_shape background +( cd "$LAB/background" && CLAUDE_PID="$POISON" timeout 300 "$CLAUDE_BIN" \ + --bg 'Reply with the single word: ok' --dangerously-skip-permissions \ + >"$LAB/background/out/turn.log" 2>&1 ) || true +BG_ID=$(sed -n 's/^backgrounded[^a-f0-9]*\([0-9a-f][0-9a-f]*\).*/\1/p' "$LAB/background/out/turn.log" | head -1) + +i=0 +while [ "$i" -lt 120 ] && [ ! -s "$LAB/background/out/report.txt" ]; do + sleep 1 + i=$((i + 1)) +done +assert_shape background "background agent" +BG_OUTERMOST=$SHAPE_OUTERMOST +BG_DECLARED=$SHAPE_DECLARED +BG_ANCESTRY=$SHAPE_ANCESTRY + +# The shape only proves anything if the background session really did sit below +# harness-named plumbing. If the chain is one hop, this run did not exercise the +# case the fix exists for and must say so rather than passing vacuously. +[ "$BG_OUTERMOST" != "$BG_DECLARED" ] \ + || fail "background agent: the hook's harness ancestry was a single process ($BG_ANCESTRY), so this run never exercised a daemon-hosted session and proved nothing about it" +note "background agent: without the declaration the writer would have recorded $BG_OUTERMOST, the shared plumbing above this session" + +stop_background_agent +pass "session-lock: claude ${VERSION:-?} declares its own session process in both a print-mode and a daemon-hosted background session, and the writer records it"