From 1fd42e6eed5ed0f60af03f180d323c8b37aceec4 Mon Sep 17 00:00:00 2001 From: Firstmate Crew Date: Fri, 31 Jul 2026 19:35:38 -0400 Subject: [PATCH 01/12] fix(home): refuse an ambiently inherited FM_HOME instead of misrouting FM_HOME is inherited down every launch line, so a session opened from another home's pane carries that home's FM_HOME. Nearly every bin/fm-*.sh opened with a copied `FM_HOME="${FM_HOME:-...}"` line that accepted the inherited value unconditionally, with no check that it had anything to do with where the session was running. On 2026-07-31 that took a second mate's session lock from a session whose working directory was the primary home, printed the mate's registry, backlog and fleet state as the primary's, and marked three of the mate's tasks done. Nothing detected it. No environment variable can separate "handed to this process" from "inherited from an ancestor": 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, so resolution now anchors there and refuses rather than guessing when the working directory and FM_HOME are both firstmate home roots naming different homes. bin/fm-home-anchor-lib.sh is the single owner of that rule; the scripts that carried their own copy of the resolution line now defer to it. FM_HOME_BINDING declares a deliberate cross-home selection by naming the home it was issued for, and fm-spawn blanks it on every launch line so it cannot be inherited into an agent session. The test is narrow in both directions: the working directory must BE a home root, never merely be inside one, so a pooled task worktree and a home's own projects/ clone keep the home that launched them. --- AGENTS.md | 1 + bin/backends/cmux.sh | 6 +- bin/backends/herdr.sh | 6 +- bin/backends/zellij.sh | 6 +- bin/fm-afk-launch.sh | 14 +- bin/fm-afk-return.sh | 6 +- bin/fm-afk-start.sh | 6 +- bin/fm-arm-pretool-check.sh | 13 +- bin/fm-backend.sh | 6 +- bin/fm-backlog-handoff.sh | 6 +- bin/fm-bootstrap.sh | 6 +- bin/fm-brief.sh | 7 +- bin/fm-check-register.sh | 6 +- bin/fm-claude-stop-autoarm.sh | 6 +- bin/fm-config-inherit-lib.sh | 8 +- bin/fm-config-push.sh | 6 +- bin/fm-crew-state.sh | 6 +- bin/fm-decision-hold.sh | 6 +- bin/fm-fleet-snapshot.sh | 6 +- bin/fm-fleet-sync.sh | 6 +- bin/fm-guard.sh | 6 +- bin/fm-harness.sh | 6 +- bin/fm-herdr-session-cleanup.sh | 6 +- bin/fm-home-anchor-lib.sh | 158 ++++++++++++++++++++ bin/fm-home-seed.sh | 8 +- bin/fm-lock.sh | 6 +- bin/fm-merge-local.sh | 6 +- bin/fm-peek.sh | 6 +- bin/fm-pending-reply-lib.sh | 3 +- bin/fm-pr-check-migrate.sh | 6 +- bin/fm-pr-check.sh | 6 +- bin/fm-pr-merge.sh | 6 +- bin/fm-primary-scope-lib.sh | 8 +- bin/fm-project-mode.sh | 6 +- bin/fm-promote.sh | 6 +- bin/fm-review-diff.sh | 6 +- bin/fm-send.sh | 9 ++ bin/fm-session-start.sh | 6 +- bin/fm-sessionstart-nudge.sh | 6 +- bin/fm-spawn.sh | 12 +- bin/fm-startup-memory-budget.sh | 6 +- bin/fm-subagent-pretool-check.sh | 8 +- bin/fm-supervise-daemon.sh | 6 +- bin/fm-supervision-instructions.sh | 6 +- bin/fm-teardown.sh | 6 +- bin/fm-test-run.sh | 11 ++ bin/fm-turnend-guard.sh | 6 +- bin/fm-update.sh | 6 +- bin/fm-wake-lib.sh | 6 +- bin/fm-watch.sh | 6 +- bin/fm-x-dismiss.sh | 6 +- bin/fm-x-followup.sh | 6 +- bin/fm-x-link.sh | 6 +- bin/fm-x-poll.sh | 6 +- bin/fm-x-reply.sh | 6 +- docs/configuration.md | 7 + tests/fm-afk-return.test.sh | 1 + tests/fm-backend.test.sh | 1 + tests/fm-claude-stop-autoarm.test.sh | 1 + tests/fm-gotmp.test.sh | 6 + tests/fm-home-anchor.test.sh | 206 +++++++++++++++++++++++++++ tests/fm-sessionstart-nudge.test.sh | 1 + tests/fm-turnend-guard.test.sh | 1 + tests/lib.sh | 16 +++ 64 files changed, 691 insertions(+), 61 deletions(-) create mode 100644 bin/fm-home-anchor-lib.sh create mode 100755 tests/fm-home-anchor.test.sh 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..fbb6c543b7 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}" 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..c4ca9e6f03 --- /dev/null +++ b/bin/fm-home-anchor-lib.sh @@ -0,0 +1,158 @@ +#!/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. Any FM_*_OVERRIDE set -> the caller has taken explicit control of where +# this home's material lives, so FM_HOME is accepted as given. +# 3. FM_HOME_BINDING naming the same home as FM_HOME -> a deliberate +# cross-home selection; FM_HOME is accepted as given. It names the home it +# was issued for, so a stale inherited binding cannot bless a different +# FM_HOME. bin/fm-spawn.sh blanks it on every launch line, so it can never +# reach an agent session by inheritance. +# 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. +# +# 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]}")" && 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 +} + +# 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`. +fm_home_anchor_resolve() { # [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 [ -n "${FM_ROOT_OVERRIDE:-}" ] || [ -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 + fm_home_anchor_is_home_root "${PWD:-}" || return 0 + fm_home_anchor_is_home_root "$given" || return 0 + + 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 [ -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..550968c1ed 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" @@ -734,7 +738,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..6001879f8a 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" || exit 1 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..5917f1b657 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 @@ -1523,7 +1527,11 @@ if [ "$HARNESS" = claude ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then fi 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" + # FM_HOME_BINDING is blanked with the layout overrides 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_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME_BINDING= FM_HOME=$sq_home $LAUNCH" fi # Export GOTMPDIR into the crewmate's pane shell so the agent and every child # process (go build, go test, ...) inherit it. Sent before the launch command so 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..0f488bb2b3 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}" diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index f89a6bade5..e56f49da08 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -122,6 +122,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 +654,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..278670e021 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" 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/configuration.md b/docs/configuration.md index b226ec6888..9493a49476 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -183,6 +183,12 @@ 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 any `FM_*_OVERRIDE` directory, or setting `FM_HOME_BINDING` to the same home as `FM_HOME`, declares the selection deliberate and is accepted as given; `bin/fm-spawn.sh` blanks `FM_HOME_BINDING` on every launch line so a binding can never be inherited into an agent session. +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. 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 +391,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 cross-home FM_HOME by naming the same home; 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/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-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/