diff --git a/AGENTS.md b/AGENTS.md index c75eeb7759..50a5673abe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ Never add an agent name as a commit co-author. `docs/configuration.md` is the single owner of the top-level operational-home layout and configuration schemas; each producing script's header and help own exact child fields and mutation mechanics. `FM_HOME` selects an instance's private `data/`, `state/`, `config/`, and `projects/`, while scripts continue to come from their tracked code root. +`bin/fm-home-anchor-lib.sh` owns how `FM_HOME` resolves and refuses when the working directory and `FM_HOME` name different homes, so an inherited value cannot silently route a session into another home. Each secondmate has a persistent isolated `FM_HOME`, including its own state, backlog, projects, and session lock. `bin/fm-send.sh` fails closed unless `FM_HOME` is explicit, so a steer cannot silently resolve against another home. diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 12dc7629eb..bd090dddfd 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -109,7 +109,11 @@ # bin/backends/zellij.sh's identical fallback. FM_BACKEND_CMUX_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-${FM_ROOT:-$FM_BACKEND_CMUX_ROOT}}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$FM_BACKEND_CMUX_ROOT/bin/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 # shellcheck source=bin/fm-backend-hometag-lib.sh . "$FM_BACKEND_CMUX_ROOT/bin/fm-backend-hometag-lib.sh" diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 237d2348c5..99ea4bf536 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -60,7 +60,11 @@ # pre-P3 behavior when a test does not care about home-specific labeling). FM_BACKEND_HERDR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-${FM_ROOT:-$FM_BACKEND_HERDR_ROOT}}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$FM_BACKEND_HERDR_ROOT/bin/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 # Shared composer-content classifier (empty|pending|unknown, and the fleet-wide # dead-shell-vs-agent-composer rule). Owned by bin/fm-composer-lib.sh, reused by diff --git a/bin/backends/zellij.sh b/bin/backends/zellij.sh index 20d53a3c2d..f2f575e226 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -114,7 +114,11 @@ # below. FM_BACKEND_ZELLIJ_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-${FM_ROOT:-$FM_BACKEND_ZELLIJ_ROOT}}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$FM_BACKEND_ZELLIJ_ROOT/bin/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 # shellcheck source=bin/fm-backend-hometag-lib.sh . "$FM_BACKEND_ZELLIJ_ROOT/bin/fm-backend-hometag-lib.sh" diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 3bc1a1cbac..4327d59144 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -47,7 +47,11 @@ set -u FM_AFK_LAUNCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$FM_AFK_LAUNCH_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$FM_AFK_LAUNCH_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 case "$FM_HOME" in /*) ;; *) @@ -416,8 +420,8 @@ fm_afk_launch_create_herdr() { # IFS=$'\t' read -r wsid pane <<< "$recovered" fi entry=$(fm_afk_launch_entry_cmd) - cmd=$(printf 'exec env FM_HOME=%q FM_SUPERVISOR_TARGET=%q FM_SUPERVISOR_BACKEND=%q %q' \ - "$FM_HOME" "$captain_target" "$captain_backend" "$entry") + cmd=$(printf 'exec env FM_HOME=%q FM_HOME_BINDING=%q FM_SUPERVISOR_TARGET=%q FM_SUPERVISOR_BACKEND=%q %q' \ + "$FM_HOME" "$FM_HOME" "$captain_target" "$captain_backend" "$entry") if ! fm_afk_launch_record_write herdr "$session:$pane" "$wsid"; then fm_afk_launch_log "failed to persist herdr daemon terminal record; closing $session:$pane" fm_afk_launch_close_terminal herdr "$session:$pane" @@ -443,8 +447,8 @@ fm_afk_launch_create_tmux() { # nonce="$$-${RANDOM:-0}-$(date '+%s')" session="fm-afk-daemon-$hash-$nonce" entry=$(fm_afk_launch_entry_cmd) - cmd=$(printf 'exec env FM_HOME=%q FM_SUPERVISOR_TARGET=%q FM_SUPERVISOR_BACKEND=%q %q' \ - "$FM_HOME" "$captain_target" "$captain_backend" "$entry") + cmd=$(printf 'exec env FM_HOME=%q FM_HOME_BINDING=%q FM_SUPERVISOR_TARGET=%q FM_SUPERVISOR_BACKEND=%q %q' \ + "$FM_HOME" "$FM_HOME" "$captain_target" "$captain_backend" "$entry") if ! fm_afk_launch_record_write tmux "$session" ""; then fm_afk_launch_log "failed to persist planned tmux daemon session '$session'" return 1 diff --git a/bin/fm-afk-return.sh b/bin/fm-afk-return.sh index 316479852f..6610865d65 100755 --- a/bin/fm-afk-return.sh +++ b/bin/fm-afk-return.sh @@ -24,7 +24,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" GATE="$STATE/.afk-return-catchup" LOCK="$STATE/.afk-return-catchup.lock" diff --git a/bin/fm-afk-start.sh b/bin/fm-afk-start.sh index 532d57b7ce..8f3e8c6b74 100755 --- a/bin/fm-afk-start.sh +++ b/bin/fm-afk-start.sh @@ -34,7 +34,11 @@ set -eu FM_AFK_START_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$FM_AFK_START_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$FM_AFK_START_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 FM_AFK_STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" FM_AFK_LOCK="$FM_AFK_STATE/.supervise-daemon.lock" FM_AFK_DAEMON="$FM_AFK_START_DIR/fm-supervise-daemon.sh" diff --git a/bin/fm-arm-pretool-check.sh b/bin/fm-arm-pretool-check.sh index 6ac8941b95..170cb03a4c 100755 --- a/bin/fm-arm-pretool-check.sh +++ b/bin/fm-arm-pretool-check.sh @@ -144,7 +144,18 @@ esac SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P) || exit 0 ROOT=$(CDPATH='' cd -- "$SCRIPT_DIR/.." 2>/dev/null && pwd -P) || exit 0 -ACTIVE_HOME=${FM_HOME:-$ROOT} +# FM_HOME resolution, including the refusal on an ambiently inherited home, has +# one owner: bin/fm-home-anchor-lib.sh. When it cannot say which home this +# session belongs to, anchor on this checkout instead of trusting the inherited +# value: the policy then declines to recognize another home's watcher as this +# session's own, which can only widen the deny, never narrow it. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +if fm_home_anchor_resolve "$ROOT" quiet; then + ACTIVE_HOME=$FM_HOME +else + ACTIVE_HOME=$ROOT +fi POLICY="$ROOT/bin/fm-arm-command-policy.mjs" command -v node >/dev/null 2>&1 || exit 0 diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index e505b99f75..f654d253f3 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -51,7 +51,11 @@ FM_BACKEND_LIB_DIR="$(cd "$(dirname "$FM_BACKEND_SCRIPT")" && pwd)" unset FM_BACKEND_SCRIPT FM_BACKEND_DEFAULT_ROOT="$(cd "$FM_BACKEND_LIB_DIR/.." && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-${FM_ROOT:-$FM_BACKEND_DEFAULT_ROOT}}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$FM_BACKEND_LIB_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 FM_BACKEND_CONFIG_DIR="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" # Verified backend adapters. Extend only after a backend gets its own diff --git a/bin/fm-backlog-handoff.sh b/bin/fm-backlog-handoff.sh index 66da67b8a8..c179314e5a 100755 --- a/bin/fm-backlog-handoff.sh +++ b/bin/fm-backlog-handoff.sh @@ -47,7 +47,11 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" REG="$DATA/secondmates.md" MAIN_BACKLOG="$DATA/backlog.md" diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 16102adfa4..3bac0c1d89 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -90,7 +90,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" @@ -250,7 +254,7 @@ secondmate_sync() { } secondmate_send_nudge() { - local id=$1 home=$2 commit=$3 instr=$4 selector marker out + local id=$1 home=$2 commit=$3 instr=$4 selector marker out caller_home selector="fm-$id" marker=$(secondmate_nudge_marker_path "$id") || { echo "NUDGE_SECONDMATES: secondmate $id: send failed: unsafe id" @@ -260,7 +264,10 @@ secondmate_sync() { echo "NUDGE_SECONDMATES: secondmate $id: send failed: cannot record retry marker" return 0 fi - if out=$(FM_HOME="$FM_HOME" FM_ROOT_OVERRIDE="$FM_ROOT" FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-send.sh" "$selector" "$SECOND_MATE_NUDGE_MESSAGE" 2>&1); then + # Read once so the binding cannot be read out of the assignment prefix that + # is setting FM_HOME in the same command. This is our own home, not $home. + caller_home=$FM_HOME + if out=$(FM_HOME="$caller_home" FM_HOME_BINDING="$caller_home" FM_ROOT_OVERRIDE="$FM_ROOT" FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-send.sh" "$selector" "$SECOND_MATE_NUDGE_MESSAGE" 2>&1); then rm -f "$marker" echo "BOOTSTRAP_INFO: nudged $selector with '$SECOND_MATE_NUDGE_MESSAGE'" else @@ -274,8 +281,11 @@ secondmate_sync() { } secondmate_retry_pending_nudges() { - local marker id selector home commit message expected_marker meta meta_home home_real head + local marker id selector home commit message expected_marker meta meta_home home_real head caller_home [ -d "$SECOND_MATE_NUDGE_PENDING_DIR" ] || return 0 + # Read once so the binding cannot be read out of the assignment prefix that + # is setting FM_HOME in the same command. This is our own home, not $home. + caller_home=$FM_HOME for marker in "$SECOND_MATE_NUDGE_PENDING_DIR"/*.pending; do [ -f "$marker" ] || continue id=$(fm_meta_get "$marker" id) @@ -320,7 +330,7 @@ secondmate_sync() { echo "NUDGE_SECONDMATES: secondmate $id: send failed: retry target is not at recorded instruction commit" continue } - if out=$(FM_HOME="$FM_HOME" FM_ROOT_OVERRIDE="$FM_ROOT" FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-send.sh" "$selector" "$SECOND_MATE_NUDGE_MESSAGE" 2>&1); then + if out=$(FM_HOME="$caller_home" FM_HOME_BINDING="$caller_home" FM_ROOT_OVERRIDE="$FM_ROOT" FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-send.sh" "$selector" "$SECOND_MATE_NUDGE_MESSAGE" 2>&1); then rm -f "$marker" echo "BOOTSTRAP_INFO: nudged $selector with '$SECOND_MATE_NUDGE_MESSAGE'" else diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 9c98723b01..fdf3c51e7b 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -80,7 +80,12 @@ resolve_directory_input() { } FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME=$(resolve_directory_input FM_HOME "${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}") || exit 1 +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 +FM_HOME=$(resolve_directory_input FM_HOME "$FM_HOME") || exit 1 if [ -n "${FM_DATA_OVERRIDE:-}" ]; then DATA=$(resolve_directory_input FM_DATA_OVERRIDE "$FM_DATA_OVERRIDE") || exit 1 else diff --git a/bin/fm-check-register.sh b/bin/fm-check-register.sh index d77d02b64f..60f9ecf3c5 100755 --- a/bin/fm-check-register.sh +++ b/bin/fm-check-register.sh @@ -5,7 +5,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-pr-lib.sh diff --git a/bin/fm-claude-stop-autoarm.sh b/bin/fm-claude-stop-autoarm.sh index df9ee1128f..33b968912d 100755 --- a/bin/fm-claude-stop-autoarm.sh +++ b/bin/fm-claude-stop-autoarm.sh @@ -47,7 +47,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" quiet || exit 0 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" GRACE=${FM_GUARD_GRACE:-300} diff --git a/bin/fm-config-inherit-lib.sh b/bin/fm-config-inherit-lib.sh index bffbd5234d..882ab92a4f 100644 --- a/bin/fm-config-inherit-lib.sh +++ b/bin/fm-config-inherit-lib.sh @@ -806,7 +806,7 @@ fm_config_reread_send_failure() { # fm_config_reread_send_pointer fm_config_reread_send_pointer() { - local id=$1 instruction_path=$2 pending_path selector out rc send_bin message pending_pointer + local id=$1 instruction_path=$2 pending_path selector out rc send_bin message pending_pointer home pending_path="$instruction_path.pending" if [ ! -f "$instruction_path" ] || [ -L "$instruction_path" ]; then printf 'CONFIG_REREAD: secondmate %s: send failed: pending instruction file is missing\n' "$id" @@ -828,7 +828,11 @@ fm_config_reread_send_pointer() { return 1 fi message="CONFIG_REREAD: $instruction_path" - out=$(FM_HOME="$FM_HOME" \ + # Read once so the binding cannot be read out of the assignment prefix that is + # setting FM_HOME in the same command. + home=$FM_HOME + out=$(FM_HOME="$home" \ + FM_HOME_BINDING="$home" \ FM_ROOT_OVERRIDE="${FM_ROOT_OVERRIDE:-}" \ FM_STATE_OVERRIDE="${FM_STATE_OVERRIDE:-}" \ FM_SEND_SETTLE="${FM_SEND_SETTLE:-0}" \ diff --git a/bin/fm-config-push.sh b/bin/fm-config-push.sh index b760666dd1..5e3ecd70d2 100755 --- a/bin/fm-config-push.sh +++ b/bin/fm-config-push.sh @@ -61,7 +61,11 @@ esac SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 32dff23668..095f533079 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -53,7 +53,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-tmux-lib.sh diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index aeb140a296..067ff53c6d 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -41,7 +41,11 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 1dee81bb84..4836f36395 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -59,7 +59,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" diff --git a/bin/fm-fleet-sync.sh b/bin/fm-fleet-sync.sh index 5c338edf68..3da89dec96 100755 --- a/bin/fm-fleet-sync.sh +++ b/bin/fm-fleet-sync.sh @@ -31,7 +31,11 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" # shellcheck source=bin/fm-lock-lib.sh . "$SCRIPT_DIR/fm-lock-lib.sh" diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index e36b7f46b0..65c0a79c6c 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -21,7 +21,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" GRACE=${FM_GUARD_GRACE:-300} diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index 824b95804d..440bceea18 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -24,7 +24,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" detect_own() { diff --git a/bin/fm-herdr-session-cleanup.sh b/bin/fm-herdr-session-cleanup.sh index 05b6db9f94..9bbf602ba1 100755 --- a/bin/fm-herdr-session-cleanup.sh +++ b/bin/fm-herdr-session-cleanup.sh @@ -25,7 +25,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-wake-lib.sh diff --git a/bin/fm-home-anchor-lib.sh b/bin/fm-home-anchor-lib.sh new file mode 100644 index 0000000000..b13812a355 --- /dev/null +++ b/bin/fm-home-anchor-lib.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +# fm-home-anchor-lib.sh - the single owner of firstmate's FM_HOME resolution. +# Sourced by every bin/fm-*.sh and bin/backends/*.sh that needs a home; it has +# no side effects on source. +# +# Why this exists +# --------------- +# FM_HOME is inherited down every launch line, so a session opened from another +# home's pane silently carries that home's FM_HOME. The variable alone cannot +# say whether it was handed to THIS process deliberately or merely inherited +# from an ancestor that had nothing to do with it, and no environment variable +# can: a child and a grandchild see byte-identical environments, so a launch +# token is inherited exactly as far as FM_HOME is. +# +# The one observable difference is on disk. When a process is standing IN a +# firstmate home root that is not the FM_HOME it was handed, two different homes +# are claiming the same command and nothing in the environment can say which is +# right. This resolver refuses that case with a diagnostic instead of guessing, +# because a silent misroute takes another home's session lock and edits another +# home's durable records. +# +# The contract +# ------------ +# 1. FM_HOME unset -> FM_ROOT_OVERRIDE, else the caller's code root. +# 2. EVERY home-material override set (FM_STATE_OVERRIDE, FM_DATA_OVERRIDE, +# FM_PROJECTS_OVERRIDE, FM_CONFIG_OVERRIDE) -> FM_HOME no longer selects any +# of this home's material, so there is nothing left for it to misroute and +# it is accepted as given. A partial set is deliberately not enough: the +# directories that were not overridden still come from FM_HOME, and a +# caller that derives one override FROM an ambient FM_HOME would otherwise +# launder that ambient value into a declaration. FM_ROOT_OVERRIDE never +# counts on its own, because it relocates the code root, not the home. +# 3. FM_HOME_BINDING declares the selection deliberate, in one of two forms. +# A PATH naming the same home as FM_HOME is the per-invocation form every +# cross-home caller in bin/ uses; because it names the home it was issued +# for, a stale inherited binding cannot bless a different FM_HOME. The +# literal `test-harness` is the process-tree form, for a caller that +# constructs and names every home it hands down - firstmate's own test +# runner is the only such caller, since each fixture home is built by the +# test that then selects it. bin/fm-spawn.sh blanks FM_HOME_BINDING on +# every launch line, so neither form can reach an agent session by +# inheritance. +# Resolution READS a binding and never issues one. Nothing here is +# exported, so a declaration covers the command tree its caller chose to +# hand it to and no other: a long-lived process started from a home - the +# multiplexer server bin/backends/tmux.sh and bin/backends/zellij.sh create, +# whose every later pane inherits its environment - can only capture a +# declaration that was deliberately handed to it. +# 4. Otherwise, when the working directory and FM_HOME are BOTH firstmate home +# roots and are not the same home -> refuse, naming both candidates. +# 5. Otherwise FM_HOME stands. +# +# Resolution is idempotent within one process. Rule 1 turns an unset FM_HOME +# into a set one, and no environment variable can record that THIS process made +# that choice, so a later resolve - the libraries a script sources re-resolve at +# source time - would re-judge the value the first resolve assigned as if it had +# been inherited, and a process standing in a home root whose code root is a +# different home root would refuse itself. Resolution therefore records the home +# it settled on, as a physical path, in a plain non-exported shell variable, and +# reads that record back instead of re-judging. The record is bound to this +# process by PID, because bash presents an environment variable of the same name +# as an ordinary shell variable and a value arriving from outside must never be +# read as a decision this process made. It is honored only while FM_HOME still +# names that same home: it records a decision about one specific home, never a +# blanket blessing of whatever FM_HOME holds later. +# +# Rule 4 is deliberately narrow in both directions. The working directory must +# BE a home root, never merely be contained in one: crewmates legitimately run +# inside a home's projects/ clone and inside pooled task worktrees, and +# promoting either to that home would be its own misroute. FM_HOME must also be +# a real home root, so a temp path or a half-built fixture is never treated as a +# rival claim. +# +# Usage, after the caller has resolved its own FM_ROOT (the code root): +# # shellcheck source=bin/fm-home-anchor-lib.sh +# . "$SCRIPT_DIR/fm-home-anchor-lib.sh" +# fm_home_anchor_resolve "$FM_ROOT" || exit 1 +# +# Hooks that must never act on the wrong home, but must also never break a turn, +# pass `quiet` and treat a non-zero return as "do not act": +# fm_home_anchor_resolve "$FM_ROOT" quiet || exit 0 + +if [ -n "${FM_HOME_ANCHOR_LIB_SOURCED:-}" ]; then + return 0 +fi +FM_HOME_ANCHOR_LIB_SOURCED=1 + +FM_HOME_ANCHOR_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" + +# The secondmate-home marker predicate has one owner; reuse it rather than +# re-deriving what a valid marker looks like. +# shellcheck source=bin/fm-primary-scope-lib.sh +. "$FM_HOME_ANCHOR_LIB_DIR/fm-primary-scope-lib.sh" + +# Physical path of $1, or empty when it does not resolve to a directory. +fm_home_anchor_physical() { # + [ -n "${1:-}" ] || return 0 + (CDPATH='' cd -- "$1" 2>/dev/null && pwd -P) || true +} + +# Return 0 when $1 is provably a firstmate HOME root - not merely a firstmate +# code root, and never something that only lives inside one. +# +# Ordered so the cheapest rejection comes first: this runs inside per-turn hooks, +# and only the last test forks git. +fm_home_anchor_is_home_root() { # + local dir=${1:-} parent git_dir git_common + [ -n "$dir" ] || return 1 + # Every home root is also a firstmate code root. + [ -f "$dir/AGENTS.md" ] || return 1 + [ -d "$dir/bin" ] || return 1 + # A valid marker is definitive: only bin/fm-home-seed.sh writes one. + if fm_root_is_secondmate_home "$dir"; then + return 0 + fi + # Otherwise a home must carry this home's own private material. Both dirs are + # gitignored, so a pooled task worktree and a fresh clone have neither, and + # this rejects them without forking git. + [ -d "$dir/data" ] || [ -d "$dir/state" ] || return 1 + # A clone under some home's projects/ can accumulate those directories over + # time; it is still that home's project, never a home of its own. + parent=${dir%/*} + if [ "${parent##*/}" = projects ] && [ -f "${parent%/*}/AGENTS.md" ]; then + return 1 + fi + # A linked worktree of a home is not that home. + git_dir=$(git -C "$dir" rev-parse --git-dir 2>/dev/null) || return 1 + git_common=$(git -C "$dir" rev-parse --git-common-dir 2>/dev/null) || return 1 + [ "$git_dir" = "$git_common" ] +} + +fm_home_anchor_refuse() { # + local cwd_home=$1 given=$2 given_phys=$3 shown=$2 + [ "$given_phys" = "$given" ] || shown="$given (resolves to $given_phys)" + { + echo "error: FM_HOME names a different firstmate home than the one this command is running in." + echo " running in: $cwd_home" + echo " FM_HOME: $shown" + echo "FM_HOME is inherited by every process launched from another home's session, so it cannot" + echo "show by itself whether it was chosen for this command or carried in from an unrelated" + echo "ancestor. Refusing rather than picking one, because guessing wrong takes the other home's" + echo "session lock and writes to its durable records." + echo " to use the home you are standing in: unset FM_HOME" + echo " to confirm the other home on purpose: FM_HOME_BINDING=\"\$FM_HOME\" " + } >&2 +} + +# The process-tree declaration (contract rule 3). Kept as a constant so the one +# literal firstmate's test runner exports has exactly one spelling. +FM_HOME_ANCHOR_HARNESS_BINDING=test-harness + +# This process's own resolution, never exported. Cleared here on the first source +# so a same-named value inherited from the environment starts out as no record at +# all, and PID-bound below so it could not be honored even if it arrived already +# exported. +FM_HOME_ANCHOR_RESOLVED_PID= +FM_HOME_ANCHOR_RESOLVED_HOME= + +# Remember that this process resolved to $1, so a later resolve reads the +# decision back rather than re-judging it. +fm_home_anchor_record() { # + local phys + phys=$(fm_home_anchor_physical "${1:-}") + [ -n "$phys" ] || phys=${1:-} + FM_HOME_ANCHOR_RESOLVED_HOME=$phys + FM_HOME_ANCHOR_RESOLVED_PID=$$ +} + +# Return 0 when THIS process already resolved to the home $1 names. +fm_home_anchor_recorded() { # + local given=${1:-} phys + [ "${FM_HOME_ANCHOR_RESOLVED_PID:-}" = "$$" ] || return 1 + [ -n "${FM_HOME_ANCHOR_RESOLVED_HOME:-}" ] || return 1 + [ "$given" != "$FM_HOME_ANCHOR_RESOLVED_HOME" ] || return 0 + phys=$(fm_home_anchor_physical "$given") + [ -n "$phys" ] && [ "$phys" = "$FM_HOME_ANCHOR_RESOLVED_HOME" ] +} + +# Resolve FM_HOME for this process. Sets FM_HOME; returns non-zero only when +# resolution is genuinely ambiguous, after printing the diagnostic above unless +# the caller asked for `quiet`. A resolution that stands is recorded, so every +# later resolve in the same process settles the same way. +fm_home_anchor_resolve() { # [quiet] + fm_home_anchor_judge "${1:-}" "${2:-}" || return 1 + fm_home_anchor_record "$FM_HOME" +} + +# The judgement itself, with no record of its own: contract rules 1 to 5. +fm_home_anchor_judge() { # [quiet] + local default_root=${1:-} quiet=${2:-} given cwd home_phys bound + + if [ -z "${FM_HOME:-}" ]; then + FM_HOME="${FM_ROOT_OVERRIDE:-$default_root}" + return 0 + fi + given=$FM_HOME + + if fm_home_anchor_recorded "$given"; then + return 0 + fi + + if [ -n "${FM_STATE_OVERRIDE:-}" ] && [ -n "${FM_DATA_OVERRIDE:-}" ] && + [ -n "${FM_PROJECTS_OVERRIDE:-}" ] && [ -n "${FM_CONFIG_OVERRIDE:-}" ]; then + return 0 + fi + + # Fork-free rejections before any path normalization. + if [ "${PWD:-}" = "$given" ]; then + return 0 + fi + if ! fm_home_anchor_is_home_root "${PWD:-}" || ! fm_home_anchor_is_home_root "$given"; then + return 0 + fi + + cwd=$(fm_home_anchor_physical "${PWD:-}") + home_phys=$(fm_home_anchor_physical "$given") + if [ -z "$cwd" ] || [ -z "$home_phys" ] || [ "$cwd" = "$home_phys" ]; then + return 0 + fi + + if [ "${FM_HOME_BINDING:-}" = "$FM_HOME_ANCHOR_HARNESS_BINDING" ]; then + return 0 + fi + if [ -n "${FM_HOME_BINDING:-}" ]; then + bound=$(fm_home_anchor_physical "$FM_HOME_BINDING") + [ -n "$bound" ] || bound=$FM_HOME_BINDING + if [ "$bound" = "$home_phys" ]; then + return 0 + fi + fi + + [ "$quiet" = quiet ] || fm_home_anchor_refuse "$cwd" "$given" "$home_phys" + return 1 +} diff --git a/bin/fm-home-seed.sh b/bin/fm-home-seed.sh index d506b95e4f..a6c7e5ee2d 100755 --- a/bin/fm-home-seed.sh +++ b/bin/fm-home-seed.sh @@ -33,7 +33,11 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" REG="$DATA/secondmates.md" @@ -534,13 +538,16 @@ EOF } clone_project() { - local project=$1 home=$2 src dst url dst_url mode + local project=$1 home=$2 src dst url dst_url mode caller_home src="$PROJECTS/$project" dst=$(validate_project_destination "$home" "$project") || return 1 [ -d "$src" ] || { echo "error: project $project not found at $src" >&2; return 1; } git -C "$src" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "error: project $project is not a git repo" >&2; return 1; } + # Read once so the binding cannot be read out of the assignment prefix that is + # setting FM_HOME in the same command. This is our own home, not $home. + caller_home=$FM_HOME read -r mode _ <&2 @@ -562,12 +569,15 @@ EOF } validate_seed_project() { - local project=$1 src mode url + local project=$1 src mode url caller_home src="$PROJECTS/$project" [ -d "$src" ] || { echo "error: project $project not found at $src" >&2; return 1; } git -C "$src" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "error: project $project is not a git repo" >&2; return 1; } + # Read once so the binding cannot be read out of the assignment prefix that is + # setting FM_HOME in the same command. + caller_home=$FM_HOME read -r mode _ <&2 @@ -734,7 +744,7 @@ registry_line_for_project() { project_mode_in_home() { local home=$1 project=$2 mode read -r mode _ </dev/null || { diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index fdc8011488..6ce549c802 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -14,7 +14,11 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" "$FM_ROOT/bin/fm-guard.sh" || true ID=${1:?usage: fm-merge-local.sh } diff --git a/bin/fm-peek.sh b/bin/fm-peek.sh index 97d2ffe2d2..1e58b4a622 100755 --- a/bin/fm-peek.sh +++ b/bin/fm-peek.sh @@ -7,7 +7,11 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-backend.sh diff --git a/bin/fm-pending-reply-lib.sh b/bin/fm-pending-reply-lib.sh index 5d04b65d67..4234056e65 100755 --- a/bin/fm-pending-reply-lib.sh +++ b/bin/fm-pending-reply-lib.sh @@ -681,7 +681,8 @@ fm_pending_reply_send_recovery() { # else if [ -z "$parent_home" ] || [ ! -d "$parent_home" ]; then send_status=1 - elif ! env FM_HOME="$parent_home" FM_PENDING_REPLY_EXISTING_CORR="$corr" \ + elif ! env FM_HOME="$parent_home" FM_HOME_BINDING="$parent_home" \ + FM_PENDING_REPLY_EXISTING_CORR="$corr" \ "$_FM_PENDING_REPLY_LIB_DIR/fm-send.sh" "$task_id" "$msg"; then send_status=1 fi diff --git a/bin/fm-pr-check-migrate.sh b/bin/fm-pr-check-migrate.sh index e81d105e20..813f446dcc 100755 --- a/bin/fm-pr-check-migrate.sh +++ b/bin/fm-pr-check-migrate.sh @@ -11,7 +11,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" TEMPLATE="$SCRIPT_DIR/fm-pr-poll.sh" LOG="$STATE/.pr-check-migration.log" diff --git a/bin/fm-pr-check.sh b/bin/fm-pr-check.sh index 57858db376..c96443734a 100755 --- a/bin/fm-pr-check.sh +++ b/bin/fm-pr-check.sh @@ -10,7 +10,11 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-pr-lib.sh diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 8226798a67..493055bcbd 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -12,7 +12,11 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-pr-lib.sh diff --git a/bin/fm-primary-scope-lib.sh b/bin/fm-primary-scope-lib.sh index 536e62e7ab..1814cb56c7 100755 --- a/bin/fm-primary-scope-lib.sh +++ b/bin/fm-primary-scope-lib.sh @@ -5,10 +5,10 @@ # Return 0 when $1 carries a genuine secondmate-home marker. fm_root_is_secondmate_home() { - local marker="$1/.fm-secondmate-home" id LC_ALL=C - [ -L "$marker" ] && return 1 - [ -f "$marker" ] || return 1 - IFS= read -r id < "$marker" 2>/dev/null || return 1 + local scope_marker="$1/.fm-secondmate-home" id LC_ALL=C + [ -L "$scope_marker" ] && return 1 + [ -f "$scope_marker" ] || return 1 + IFS= read -r id < "$scope_marker" 2>/dev/null || return 1 id=${id//[[:space:]]/} [ -n "$id" ] || return 1 case "$id" in diff --git a/bin/fm-project-mode.sh b/bin/fm-project-mode.sh index 6a6754c10d..06b72f4010 100755 --- a/bin/fm-project-mode.sh +++ b/bin/fm-project-mode.sh @@ -23,7 +23,11 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" REG="$DATA/projects.md" NAME=${1:?usage: fm-project-mode.sh } diff --git a/bin/fm-promote.sh b/bin/fm-promote.sh index 827c17998f..be5da685ce 100755 --- a/bin/fm-promote.sh +++ b/bin/fm-promote.sh @@ -11,7 +11,11 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" "$FM_ROOT/bin/fm-guard.sh" || true ID=$1 diff --git a/bin/fm-review-diff.sh b/bin/fm-review-diff.sh index 06e0efb5bd..2d8fe6e477 100755 --- a/bin/fm-review-diff.sh +++ b/bin/fm-review-diff.sh @@ -16,7 +16,11 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" "$FM_ROOT/bin/fm-guard.sh" || true diff --git a/bin/fm-send.sh b/bin/fm-send.sh index dfae6f49e6..372289cd5b 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -59,6 +59,15 @@ if [ -z "${FM_HOME+x}" ] || [ -z "${FM_HOME:-}" ]; then exit 1 fi +# The set-FM_HOME requirement above is fm-send's own, and stays. It proves only +# that SOMETHING chose a home, never that this process was the one it was chosen +# for, so the shared resolver still gets to refuse an ambiently inherited home +# (bin/fm-home-anchor-lib.sh). Both checks are fail-closed and neither relaxes +# the other. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 + STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" if [ ! -d "$FM_HOME" ]; then echo "error: FM_HOME '$FM_HOME' is not a directory; fm-send cannot resolve this home's state" >&2 diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 1abbace4bf..f2f4fa3a57 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -93,7 +93,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" diff --git a/bin/fm-sessionstart-nudge.sh b/bin/fm-sessionstart-nudge.sh index fccf775dd9..5a9025065d 100755 --- a/bin/fm-sessionstart-nudge.sh +++ b/bin/fm-sessionstart-nudge.sh @@ -7,7 +7,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" quiet || exit 0 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-gate-refuse-lib.sh diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 3526572550..ed030a08a8 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -123,7 +123,11 @@ case "${1:-}" in esac FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 resolve_directory_input() { local name=$1 path=$2 resolved @@ -1521,6 +1525,11 @@ LAUNCH=${LAUNCH//__OPINPUT__/$sq_opinput} if [ "$HARNESS" = claude ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then LAUNCH="CLAUDE_CONFIG_DIR=$(shell_quote "$CLAUDE_CONFIG_DIR") $LAUNCH" fi +# FM_HOME_BINDING is blanked on every launch line, whatever the kind, so a +# binding issued for some other home can never ride an inherited environment +# into an agent session and bless an FM_HOME that session never chose +# (bin/fm-home-anchor-lib.sh). +LAUNCH="FM_HOME_BINDING= $LAUNCH" if [ "$KIND" = secondmate ]; then sq_home=$(shell_quote "$PROJ_ABS") LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH" diff --git a/bin/fm-startup-memory-budget.sh b/bin/fm-startup-memory-budget.sh index 715da54948..727f219013 100755 --- a/bin/fm-startup-memory-budget.sh +++ b/bin/fm-startup-memory-budget.sh @@ -14,7 +14,11 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" diff --git a/bin/fm-subagent-pretool-check.sh b/bin/fm-subagent-pretool-check.sh index 8edb507218..aa237613ce 100755 --- a/bin/fm-subagent-pretool-check.sh +++ b/bin/fm-subagent-pretool-check.sh @@ -172,7 +172,13 @@ done SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P) || exit 0 FM_ROOT=${FM_ROOT_OVERRIDE:-$(CDPATH='' cd -- "$SCRIPT_DIR/.." 2>/dev/null && pwd -P)} || exit 0 -FM_HOME=${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}} +# FM_HOME resolution, including the refusal on an ambiently inherited home, has +# one owner: bin/fm-home-anchor-lib.sh. An unresolvable home is inert here for +# the same reason every other failure below is: this check never blocks a call it +# cannot confirm. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" quiet || exit 0 STATE=${FM_STATE_OVERRIDE:-$FM_HOME/state} # Scope to a genuine primary home, exactly as the session-start nudge and the diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 30554edbbd..fd0206a40c 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -144,7 +144,11 @@ set -u FM_DAEMON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$FM_DAEMON_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$FM_DAEMON_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 # Shared tmux pane primitives for supervisor injection (busy/composer detection # + verify-retry submit). Sourced at top level so BOTH the executed daemon and diff --git a/bin/fm-supervision-instructions.sh b/bin/fm-supervision-instructions.sh index 6cd87699b0..10a0c6e2c0 100755 --- a/bin/fm-supervision-instructions.sh +++ b/bin/fm-supervision-instructions.sh @@ -6,7 +6,11 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$REPO_ROOT}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" DOC_DIR="$REPO_ROOT/docs/supervision-protocols" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 6164ebdd78..b8929ceb44 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -90,7 +90,11 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" @@ -998,7 +1002,7 @@ cleanup_firstmate_home_children() { if [ "$child_backend" = zellij ]; then # Zellij titles are scoped by the owning home tag, so forced secondmate # cleanup must verify child tabs as that child home, not the parent. - ( unset FM_ROOT_OVERRIDE; FM_HOME=$home FM_ROOT=$home fm_backend_kill "$child_backend" "$child_t" "$(meta_value "$child_meta" zellij_tab_id)" "fm-$child_id" ) 2>/dev/null || true + ( unset FM_ROOT_OVERRIDE; FM_HOME=$home FM_HOME_BINDING=$home FM_ROOT=$home fm_backend_kill "$child_backend" "$child_t" "$(meta_value "$child_meta" zellij_tab_id)" "fm-$child_id" ) 2>/dev/null || true else fm_backend_kill "$child_backend" "$child_t" "$(meta_value "$child_meta" zellij_tab_id)" "fm-$child_id" 2>/dev/null || true fi @@ -1085,7 +1089,10 @@ if [ "$KIND" = scout ] && [ "$FORCE" != "--force" ]; then echo "The report is the work product. Have the crewmate write it, or use --force after explicit discard approval." >&2 exit 1 fi - if ! FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" FM_DATA_OVERRIDE="$DATA" \ + # Read once so the binding cannot be read out of the assignment prefix that is + # setting FM_HOME in the same command. + caller_home=$FM_HOME + if ! FM_HOME="$caller_home" FM_HOME_BINDING="$caller_home" FM_STATE_OVERRIDE="$STATE" FM_DATA_OVERRIDE="$DATA" \ FM_CONFIG_OVERRIDE="$CONFIG" "$SCRIPT_DIR/fm-decision-hold.sh" verify "$ID" >/dev/null; then echo "REFUSED: scout task $ID has not passed the unresolved-decision completion gate." >&2 echo "Inventory its report and any visual review through bin/fm-decision-hold.sh before teardown." >&2 diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index f89a6bade5..d6bb2de6e0 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -67,6 +67,12 @@ set -eu ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" || exit 1 +# Declare this process tree to the FM_HOME resolver (bin/fm-home-anchor-lib.sh) +# for the scripts that do not source tests/lib.sh. The runner works from the code +# root, which on a captain's machine is also a live firstmate home, while every +# fixture home a test selects is one the test itself built. +export FM_HOME_BINDING=test-harness + MODE= LIST_ONLY=0 LIST_FAMILIES=0 @@ -122,6 +128,7 @@ family_for_basename() { fm-composer-ghost.test.sh|fm-composer-lib.test.sh|\ fm-crew-state.test.sh|fm-decision-hold-lifecycle.test.sh|\ fm-documentation-audiences.test.sh|fm-ensure-agents-md.test.sh|fm-grok-harness.test.sh|\ + fm-home-anchor.test.sh|\ fm-kimi-harness.test.sh|fm-herdr-lab.test.sh|fm-lint.test.sh|\ fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\ fm-send-popup-settle.test.sh|fm-send-settle.test.sh|\ @@ -653,6 +660,16 @@ families_for_changed_path() { bin/fm-config-inherit-lib.sh|bin/fm-config-push.sh|bin/fm-shared*) printf '%s\n' secondmate ;; + bin/fm-home-anchor-lib.sh) + # Every script defers its FM_HOME resolution to this lib, so a change here + # can reroute any home-scoped command. Select every family that resolves a + # home rather than only the unit that owns the rule. + printf '%s\n' pure-contract-unit + printf '%s\n' session-bootstrap + printf '%s\n' secondmate + printf '%s\n' backend-dispatch + printf '%s\n' watcher-wake-lock + ;; bin/fm-session-start.sh|bin/fm-bootstrap.sh|bin/fm-fleet-sync.sh|\ bin/fm-sessionstart-nudge.sh|bin/fm-tangle*|bin/fm-update.sh|\ bin/fm-gate-refuse*|bin/fm-lock*) diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index 2e96fb33e4..56b767a1e9 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -61,7 +61,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" quiet || exit 0 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" GRACE=${FM_GUARD_GRACE:-300} diff --git a/bin/fm-update.sh b/bin/fm-update.sh index c3b0e674f7..1870bd531b 100755 --- a/bin/fm-update.sh +++ b/bin/fm-update.sh @@ -31,7 +31,11 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" SECONDMATES_MD="$FM_HOME/data/secondmates.md" # shellcheck source=bin/fm-ff-lib.sh diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index 8cec58bec1..3c71e4bd06 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -4,7 +4,11 @@ FM_WAKE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_WAKE_DEFAULT_ROOT="$(cd "$FM_WAKE_LIB_DIR/.." && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-${FM_ROOT:-$FM_WAKE_DEFAULT_ROOT}}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$FM_WAKE_LIB_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-${STATE:-$FM_HOME/state}}" FM_WAKE_QUEUE="${FM_WAKE_QUEUE:-$STATE/.wake-queue}" FM_WAKE_QUEUE_LOCK="${FM_WAKE_QUEUE_LOCK:-$STATE/.wake-queue.lock}" diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index e5501f852b..9142eb46f3 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -55,7 +55,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" mkdir -p "$STATE" @@ -750,7 +754,10 @@ while :; do if [ "$(basename "$c")" = x-watch.check.sh ]; then if fmx_poll_shim_valid "$c" "$FM_HOME" "$FM_ROOT" \ && [ -f "$FM_ROOT/bin/fm-x-poll.sh" ] && [ ! -L "$FM_ROOT/bin/fm-x-poll.sh" ]; then - FM_HOME="$FM_HOME" run_check_capture "$FM_ROOT/bin/fm-x-poll.sh" || exit 1 + # Read once so the binding cannot be read out of the assignment prefix + # that is setting FM_HOME in the same command. + home=$FM_HOME + FM_HOME="$home" FM_HOME_BINDING="$home" run_check_capture "$FM_ROOT/bin/fm-x-poll.sh" || exit 1 out=$FM_CHECK_RESULT else rejected_checks="$rejected_checks $c" diff --git a/bin/fm-x-dismiss.sh b/bin/fm-x-dismiss.sh index 0654d4e6e3..e802b7cc56 100755 --- a/bin/fm-x-dismiss.sh +++ b/bin/fm-x-dismiss.sh @@ -32,7 +32,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-x-lib.sh . "$SCRIPT_DIR/fm-x-lib.sh" diff --git a/bin/fm-x-followup.sh b/bin/fm-x-followup.sh index 603678cfca..d28f49911e 100755 --- a/bin/fm-x-followup.sh +++ b/bin/fm-x-followup.sh @@ -59,7 +59,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-x-lib.sh . "$SCRIPT_DIR/fm-x-lib.sh" diff --git a/bin/fm-x-link.sh b/bin/fm-x-link.sh index 5301359a45..f1f207ddd9 100755 --- a/bin/fm-x-link.sh +++ b/bin/fm-x-link.sh @@ -39,7 +39,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-x-lib.sh . "$SCRIPT_DIR/fm-x-lib.sh" diff --git a/bin/fm-x-poll.sh b/bin/fm-x-poll.sh index db7c1ebba0..b8c0e773e6 100755 --- a/bin/fm-x-poll.sh +++ b/bin/fm-x-poll.sh @@ -30,7 +30,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-x-lib.sh . "$SCRIPT_DIR/fm-x-lib.sh" diff --git a/bin/fm-x-reply.sh b/bin/fm-x-reply.sh index 44e85cb469..5b3dda7235 100755 --- a/bin/fm-x-reply.sh +++ b/bin/fm-x-reply.sh @@ -82,7 +82,11 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" || exit 1 STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-x-lib.sh . "$SCRIPT_DIR/fm-x-lib.sh" diff --git a/docs/arm-pretool-check.md b/docs/arm-pretool-check.md index f4747e0abd..956fff9cfe 100644 --- a/docs/arm-pretool-check.md +++ b/docs/arm-pretool-check.md @@ -29,7 +29,7 @@ It tokenizes the bytes and classifies lexical execution positions only. - `--claude` to preserve Claude's stderr-only deny requirement. The wrapper discovers the code root from its own location. -The active firstmate home is `${FM_HOME:-}`. +The active firstmate home comes from `bin/fm-home-anchor-lib.sh`, the single owner of `FM_HOME` resolution described in [configuration.md](configuration.md#fm_home); when that resolution refuses because it cannot say which home this session belongs to, the wrapper anchors on its own code root instead, which can only widen the deny. It passes both roots and the exact command string to the Node policy owner. The wrapper fast-allows a command without invoking the Node policy owner only when the command cannot contain the `fm-watch` byte sequence even after the classifier's decoders run. diff --git a/docs/cmux-backend.md b/docs/cmux-backend.md index 4bd438bc74..91c4a70ada 100644 --- a/docs/cmux-backend.md +++ b/docs/cmux-backend.md @@ -43,7 +43,7 @@ It can also be runtime auto-detected when Firstmate itself runs inside cmux. A spawn stops with an actionable setup message when the app, minimum version, `jq`, socket access, or password is unavailable. The adapter may launch the app with `open -a cmux` only when the socket is down; it does not relaunch the app for access-denied or authentication errors. -Routine supervision uses `bin/fm-peek.sh ` and `FM_HOME= bin/fm-send.sh ''` without bringing the cmux window forward. +Routine supervision uses `bin/fm-peek.sh ` and `FM_HOME= FM_HOME_BINDING= bin/fm-send.sh ''` without bringing the cmux window forward. Task workspace and surface creation use `focus=false`. Verify setup by spawning a small task and confirming metadata contains `backend=cmux`, `cmux_workspace_id=`, and `cmux_surface_id=`. diff --git a/docs/configuration.md b/docs/configuration.md index b226ec6888..d2d38bdd60 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -183,6 +183,19 @@ When it is unset, most scripts use the repo root as the home; when it is set, sc When `FM_HOME` is unset, it also behaves as the old whole-root override. `bin/fm-send.sh` is intentionally stricter than that general fallback: it requires `FM_HOME` to be set before resolving a target, so operator steers cannot silently resolve against the wrong home. `FM_STATE_OVERRIDE`, `FM_DATA_OVERRIDE`, `FM_PROJECTS_OVERRIDE`, and `FM_CONFIG_OVERRIDE` override individual operational directories for tests and specialized harness setup. + +`FM_HOME` is inherited by every process launched from a session, so a session opened from another home's pane carries that home's `FM_HOME` even though the selection was never made for it. +`bin/fm-home-anchor-lib.sh` is the single owner of the resulting resolution rule, and its header states that rule in full; every other script defers to it rather than resolving `FM_HOME` itself. +The rule in short: a set `FM_HOME` stands, except that when the working directory and `FM_HOME` are both firstmate home roots and name different homes, resolution refuses with a diagnostic naming both candidates instead of guessing which one the command was meant for. +Setting `FM_HOME_BINDING` to the same home as `FM_HOME` declares the selection deliberate and is accepted as given; every genuine cross-home call in `bin/` sets it per invocation, alongside the `FM_HOME` it is declaring. +Resolution reads that declaration and never issues one of its own, so a binding reaches only the command tree its caller handed it to and can never be picked up by an unrelated descendant of a process that merely resolved a home. +`FM_HOME_BINDING=test-harness` is the process-tree form of that declaration, for a caller that constructs and names every home it hands down; firstmate's own test runner is the only such caller, because each fixture home is built by the test that then selects it. +`bin/fm-spawn.sh` blanks `FM_HOME_BINDING` on every launch line, so neither form can be inherited into an agent session. +Relocating every one of this home's directories at once - `FM_STATE_OVERRIDE`, `FM_DATA_OVERRIDE`, `FM_PROJECTS_OVERRIDE`, and `FM_CONFIG_OVERRIDE` together - is also accepted, because `FM_HOME` then selects no material left to misroute. +A partial set is deliberately not enough, since whatever was not overridden still comes from `FM_HOME`, and `FM_ROOT_OVERRIDE` never declares a home on its own because it relocates the code root rather than the home. +A pooled task worktree and a home's own `projects/` clone are never home roots, so a crewmate working in either keeps the home that launched it. +Within one process the rule is idempotent: resolution records the home it settled on and reads that record back when a script's sourced libraries resolve again, so a command whose code root is a different home root from the one it is standing in never refuses the home it assigned itself. +That record is a non-exported shell variable bound to the resolving process, it is honored only while `FM_HOME` still names the same home, and a same-named value arriving in the environment is never read as a decision the process made - so it is a within-process detail and never a way to declare a home. Before `fm-brief.sh`, `fm-spawn.sh`, or `fm-afk-launch.sh` persists a path or passes it to another process, it resolves each applicable relative `FM_HOME`, `FM_STATE_OVERRIDE`, or `FM_DATA_OVERRIDE` directory against the caller's working directory, preserves absolute spellings unchanged, and rejects an unresolvable relative directory with the offending variable named. Bootstrap applies the same relative `FM_HOME` resolution only when embedding that home in the generated X-mode poll shim; other transient consumers retain their existing shell-relative behavior. For the herdr backend, `FM_HOME` also determines the workspace label used by the adapter. @@ -385,6 +398,7 @@ Runtime tuning via environment variables (defaults shown): ```sh FM_HOME= # optional operational home for most scripts, unset means this repo root; fm-send requires it explicitly +FM_HOME_BINDING= # declares a deliberate FM_HOME: the same home's path, or test-harness for a whole process tree; see the FM_HOME section FM_ROOT_OVERRIDE= # override firstmate repo root, tangle-guard target, and zellij/cmux home-title hash; also legacy whole-root override when FM_HOME is unset FM_STATE_OVERRIDE= # alternate state dir, mainly for tests FM_DATA_OVERRIDE= # alternate data dir, mainly for tests diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 8f70cd346c..3bb52b8a9b 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -303,6 +303,10 @@ "path": "docs/turnend-guard.md", "audience": "operator-current" }, + { + "path": "docs/verification/home-resolution.md", + "audience": "maintainer-verification" + }, { "path": "docs/verification/runtime-backends.md", "audience": "maintainer-verification" diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 91047bcc6f..499e8cc876 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -39,7 +39,7 @@ A secondmate home uses `2ndmate-`, derived from its validated `.f The secondmate process and every child it launches resolve the same home label; a secondmate launched by the primary receives a narrowly scoped home override during container creation. Attach to the selected named Herdr session and switch to the relevant home workspace to watch its task tabs. -Routine supervision uses `bin/fm-peek.sh ` and `FM_HOME= bin/fm-send.sh ''` without attaching. +Routine supervision uses `bin/fm-peek.sh ` and `FM_HOME= FM_HOME_BINDING= bin/fm-send.sh ''` without attaching. Workspace and tab creation use `--no-focus`. The first workspace in a completely empty Herdr session must become focused because no prior target exists, but later task creation does not intentionally steal focus. diff --git a/docs/orca-backend.md b/docs/orca-backend.md index 9812993e83..53f11df5c2 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -23,7 +23,7 @@ The first task for a project registers that repository with `orca repo add --pat No manual repository registration is required. Open the Orca app to watch a task's terminal. -Routine supervision uses the recorded endpoint through `bin/fm-peek.sh ` and `FM_HOME= bin/fm-send.sh ''`. +Routine supervision uses the recorded endpoint through `bin/fm-peek.sh ` and `FM_HOME= FM_HOME_BINDING= bin/fm-send.sh ''`. Enter and Ctrl-C are supported; Escape is not. ## Task shape and metadata diff --git a/docs/scripts.md b/docs/scripts.md index 6a10d1310a..af4b3099f4 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -28,6 +28,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-ensure-agents-md.sh` | Ensure a project's real `AGENTS.md`, its `CLAUDE.md` symlink, and the canonical self-governance section | | `fm-guard.sh` | Warn on primary-checkout tangles, pending queued wakes, and stale watcher liveness | | `fm-primary-scope-lib.sh` | Shared marker-or-plain-checkout primary-home predicate for tracked hooks | +| `fm-home-anchor-lib.sh` | Single owner of `FM_HOME` resolution and its refusal on an ambiently inherited home (docs/configuration.md) | | `fm-session-lock-lib.sh` | Shared session-lock harness identity (ancestry walk and holder liveness) for fm-lock.sh and the Claude Stop auto-arm | | `fm-claude-stop-autoarm.sh` | Claude Stop `asyncRewake` hook owning tokenless watcher continuity with single-flight exit-2 rewake (docs/watcher-continuity.md) | | `fm-turnend-guard.sh` | Shared primary turn-end guard predicate so no turn ends blind (docs/turnend-guard.md) | diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index c39c814925..b61176a977 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -11,6 +11,7 @@ The Ahoy skill owns the rule that this marked operational input is never a capta It sources `bin/fm-gate-refuse-lib.sh` and stays silent for a no-mistakes gate agent identified by `NO_MISTAKES_GATE` or a `.no-mistakes/repos/*.git` git-common-dir. It shares `bin/fm-primary-scope-lib.sh` with `bin/fm-turnend-guard.sh`, so the hooks use one primary-detection owner. The Shared Predicate section of [`turnend-guard.md`](turnend-guard.md#shared-predicate) owns marker validation, plain-checkout detection, and required Firstmate-shaped paths. +It resolves the home through `bin/fm-home-anchor-lib.sh` ([configuration.md](configuration.md#fm_home)) and stays silent when that resolution refuses, because it is only an enforcement layer for a digest the primary can still run itself, and the exit-0 rule below forbids a louder failure. Before printing, the wrapper reads `state/.lock` and walks at most eight parents from its own pid in its own separate, hard-coded loop, independent of `bin/fm-lock.sh`'s ancestry walk (`fm_harness_ancestry_pid()` in `bin/fm-session-lock-lib.sh`, which now walks up to sixteen parents and can extend past a claude-named match to a still-more-ancestral one) and of Pi's `lockOwnership()`. If the lock names a live pid in that ancestry, session start already ran in this harness session and the wrapper stays silent. diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index 3bf20fe9d1..a1c340f0c2 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -38,7 +38,7 @@ tmux select-window -t :fm- ``` Typing into an attached task window is authoritative direct intervention. -Routine supervision does not require attachment: `bin/fm-peek.sh ` captures a bounded tail and `FM_HOME= bin/fm-send.sh ''` steers the recorded endpoint. +Routine supervision does not require attachment: `bin/fm-peek.sh ` captures a bounded tail and `FM_HOME= FM_HOME_BINDING= bin/fm-send.sh ''` steers the recorded endpoint. Verify setup by spawning a small task and confirming its `fm-` window appears in the selected session. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 8ee750de39..8414f2a7cd 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -33,6 +33,7 @@ A stale beacon blocks even when a watcher pid is live. A fresh leftover beacon blocks when the lock is missing, dead, or identity-mismatched. `FM_STATE_OVERRIDE` wins over `FM_HOME/state`, and `FM_HOME` wins over repository-root `state/`. +`FM_HOME` itself resolves through `bin/fm-home-anchor-lib.sh` ([configuration.md](configuration.md#fm_home)); when that resolution refuses because it cannot say which home this session belongs to, the guard exits 0 rather than guarding a turn against another home's state. `FM_GUARD_GRACE` controls beacon freshness and defaults to 300 seconds. If `jq` is missing or hook stdin is empty, the guard exits 0 because it cannot safely read loop-guard fields. diff --git a/docs/verification/home-resolution.md b/docs/verification/home-resolution.md new file mode 100644 index 0000000000..4b93e96511 --- /dev/null +++ b/docs/verification/home-resolution.md @@ -0,0 +1,74 @@ +# Home resolution verification + +Audience: maintainer verification. + +This record supports the current guarantee that an ambiently inherited `FM_HOME` cannot route a command into a home it was not chosen for. +The rule itself is owned by `bin/fm-home-anchor-lib.sh`, and the operator-facing description lives in [`docs/configuration.md`](../configuration.md) under "FM_HOME". +Executable coverage of the rule is `tests/fm-home-anchor.test.sh`; this record holds only the axes a test cannot assert - which integration surfaces were inspected, and what the hooks do when resolution refuses. + +## Primary harnesses + +Checked on 2026-07-31 against every verified adapter. + +Resolution is a shell-level concern, so `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, and `kimi` share one path through `bin/fm-home-anchor-lib.sh` whenever they invoke a `bin/fm-*.sh`. +The harness-specific surfaces that carry a home of their own are these: + +- `bin/fm-turnend-guard.sh` is the shared Stop guard for every harness; `bin/fm-turnend-guard-grok.sh` delegates to it and reads no home itself, and `bin/fm-kimi-turnend-hook.sh` reads no `FM_*` variable at all. +- `bin/fm-claude-stop-autoarm.sh`, `bin/fm-arm-pretool-check.sh`, `bin/fm-cd-pretool-check.sh`, and `bin/fm-subagent-pretool-check.sh` are the Claude-registered hooks. `fm-cd-pretool-check.sh` never reads `FM_HOME` and is not applicable. +- `bin/fm-sessionstart-nudge.sh` is the one session-open command every adapter invokes, registered by `.claude/settings.json`, `.codex/hooks.json`, `.grok/hooks/fm-primary-sessionstart-nudge.json`, and `.opencode/plugins/fm-primary-sessionstart-nudge.js`, with Pi reaching it through `.pi/extensions/fm-primary-turnend-guard.ts`. + It reads `FM_HOME` for the state directory it checks, and declines silently under refusal because a Claude `SessionStart` non-zero exit blocks session initialization. + `docs/sessionstart-nudge.md` owns that transport and exit contract. +- `.pi/extensions/fm-primary-pi-watch.ts` and `.opencode/plugins/fm-primary-watch-arm.js` compute their own `FM_HOME` candidate in TypeScript and JavaScript. They remain candidates, not resolutions: each hands the value to `bin/fm-watch-arm.sh`, which resolves through the shared owner. Both set `FM_ROOT_OVERRIDE` and derive `FM_CONFIG_OVERRIDE` from that candidate, which is why a partial override set does not declare a home - see the contract in the owner's header. + +## Runtime backends + +Checked on 2026-07-31 against every spawn backend. + +`bin/backends/herdr.sh`, `bin/backends/zellij.sh`, and `bin/backends/cmux.sh` each carried their own copy of the resolution line for direct unit sourcing and now defer to the shared owner. +`bin/backends/tmux.sh` and `bin/backends/orca.sh` read no `FM_HOME` and are not applicable. +`codex-app` is not a selectable spawn backend and is not applicable. + +## Hook behavior when resolution refuses + +Reproduced with two synthetic home roots, the second carrying a `.fm-secondmate-home` marker, running each hook from the first with `FM_HOME` naming the second. +Every hook declined without writing into the other home, and the count of files under the other home's `state/` was 0 before and 0 after: + +``` +fm-turnend-guard.sh exit=0 (silent decline) +fm-claude-stop-autoarm.sh exit=0 (silent decline) +fm-sessionstart-nudge.sh exit=0 (silent decline, no nudge printed) +fm-subagent-pretool-check.sh exit=0 (inert, never blocks a call it cannot confirm) +fm-cd-pretool-check.sh exit=0 (does not read FM_HOME) +fm-arm-pretool-check.sh exit=2 (a broad watcher kill is still denied) +fm-watch.sh --status exit=1 error: FM_HOME names a different firstmate home ... +``` + +The same sweep with `FM_HOME` naming the home it stands in is the control: `fm-watch.sh` proceeds and writes that home's own watcher state, so the refusal above is the anchoring decision rather than an inert environment. + +`bin/fm-arm-pretool-check.sh` is the one hook that must stay protective under refusal, because declining to act would allow the dangerous command it exists to deny. +It anchors on its own checkout when resolution cannot say which home the session belongs to, which can only widen the deny. + +## How far a declaration reaches + +Resolution reads `FM_HOME_BINDING` and never sets or exports one, so a declaration reaches only the command tree whose caller passed it. +That boundary is load-bearing for the long-lived process trees firstmate creates. +`bin/backends/tmux.sh` and `bin/backends/zellij.sh` ensure a multiplexer server by running a create command from whatever environment the spawning command had, and when no server is running yet that call starts one, whose captured environment every later pane inherits. +A declaration minted by resolution and exported to descendants would have been captured there, and would then have blessed an inherited `FM_HOME` for a human session opened in one of those panes from a different home root - the exact misroute this rule exists to refuse. +The per-invocation form firstmate's own cross-home calls use is unaffected, because each of those callers names the home it is declaring on the same command line. + +## Idempotence within one process + +Because resolution mints no declaration, a process has nothing in its environment saying it already chose a home. +Scripts source libraries that resolve again at source time - `bin/fm-wake-lib.sh`, `bin/fm-backend.sh`, and the `bin/backends/*.sh` adapters - so a command standing in one home root whose code root is a different home root would re-judge the home its own first resolve assigned and refuse itself. +Resolution therefore records that decision in a non-exported shell variable inside the owner, bound to the resolving process and honored only while `FM_HOME` still names the same home. +The boundary above is unchanged by it: the record never leaves the process, so `tests/fm-home-anchor.test.sh` still pins that a resolved home blesses nothing in a command the process launches, and that a same-named value arriving in the environment - even one carrying the reading process's own PID, which `exec` makes reachable - declares nothing. + +The cross-process half of the same problem is the hand-off, where a parent passes a child the `FM_HOME` its own rule-1 resolve assigned. +A record cannot reach a child, so those call sites pass the per-invocation binding alongside the home, as every cross-home caller already did: `bin/fm-home-seed.sh` for both project-mode reads, `bin/fm-teardown.sh` for the unresolved-decision gate, `bin/fm-bootstrap.sh` for both secondmate nudges, and `bin/fm-watch.sh` for the X-mode poll dispatch. +`bin/fm-fleet-snapshot.sh` needs none, because both of its cross-home calls already relocate every one of the home's directories. + +## Suite behavior from a home root + +The captain's primary home is both the code root and a live firstmate home, so the suite runs from a directory that is itself a home root while each test selects a fixture home. +`bin/fm-test-run.sh` and `tests/lib.sh` therefore export the process-tree form of the declaration described in the owner's header. +Without it, roughly a third of the suite refuses in that configuration; `tests/fm-secondmate-lifecycle-e2e.test.sh` pins that `bin/fm-spawn.sh` blanks the declaration on every launch line, so it cannot follow an agent out of the suite. diff --git a/docs/zellij-backend.md b/docs/zellij-backend.md index 367da98ebb..77f72b65b3 100644 --- a/docs/zellij-backend.md +++ b/docs/zellij-backend.md @@ -27,7 +27,7 @@ zellij attach ``` Routine supervision does not require attachment. -Use `bin/fm-peek.sh ` and `FM_HOME= bin/fm-send.sh ''` against the metadata-routed endpoint. +Use `bin/fm-peek.sh ` and `FM_HOME= FM_HOME_BINDING= bin/fm-send.sh ''` against the metadata-routed endpoint. Verify setup by spawning a small task and confirming metadata contains `backend=zellij`, `zellij_session=`, `zellij_tab_id=`, and `zellij_pane_id=`. diff --git a/tests/fm-afk-return.test.sh b/tests/fm-afk-return.test.sh index aa3107440b..1dfea02526 100755 --- a/tests/fm-afk-return.test.sh +++ b/tests/fm-afk-return.test.sh @@ -16,6 +16,7 @@ TMP_ROOT=$(fm_test_tmproot fm-afk-return-tests) install_runner() { # local dir=$1 mkdir -p "$dir/bin" "$dir/home/state" "$dir/home/data" "$dir/home/config" + fm_copy_core_libs "$dir/bin" cp "$ROOT/bin/fm-afk-return.sh" "$dir/bin/" cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/" cp "$ROOT/bin/fm-classify-lib.sh" "$dir/bin/" diff --git a/tests/fm-arm-pretool-check.test.sh b/tests/fm-arm-pretool-check.test.sh index 5ba750aea0..07fb8a08f3 100755 --- a/tests/fm-arm-pretool-check.test.sh +++ b/tests/fm-arm-pretool-check.test.sh @@ -443,7 +443,14 @@ test_allow_is_silent_both_modes() { test_shellcheck_clean() { command -v shellcheck >/dev/null 2>&1 || { pass "shellcheck not installed, skipping"; return; } - shellcheck "$CHECK" >/dev/null 2>&1 || fail "bin/fm-arm-pretool-check.sh is not shellcheck-clean" + # bin/fm-lint.sh owns the lint definition and resolves `# shellcheck source=` + # directives, so this check must ask the same question it does. Bare shellcheck + # asks a different one: it reports every sourced lib as unfollowed, which turns + # any script that grows a `.` line into a false failure here while the owner, + # CI, and the pre-push gate all still pass. --source-path keeps that resolution + # working from whatever directory this suite happens to run in. + shellcheck --norc --external-sources --source-path="$ROOT" "$CHECK" >/dev/null 2>&1 \ + || fail "bin/fm-arm-pretool-check.sh is not shellcheck-clean" pass "bin/fm-arm-pretool-check.sh is shellcheck-clean" } diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 7ac873f087..6810dae77f 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -151,6 +151,7 @@ build_old_bin() { # -> echoes root dir (root/bin/