fix(bin): refuse an ambiently inherited FM_HOME instead of misrouting a session - #1
Merged
Conversation
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/<name> clone keep the home that launched them.
Two gaps in the first pass. A partial set of layout overrides was treated as explicit control, which the Pi and OpenCode watcher-arm paths trip: both derive FM_CONFIG_OVERRIDE from the ambient FM_HOME and set FM_ROOT_OVERRIDE, so an inherited home laundered itself into a declaration and armed a watcher on the wrong home. Only a complete set of home-material overrides now counts, because only then does FM_HOME select nothing left to misroute. FM_ROOT_OVERRIDE never counts on its own - it relocates the code root, not the home. Resolving a home now also declares it to the processes that home's scripts launch, so a child never has to re-derive its parent's reasoning from a partial set of overrides. Exports reach only descendants, so this cannot travel back to the session that invoked the script. The refusal fires whenever the working directory is a live firstmate home, which is exactly what the captain's primary home is, so running the suite there refused across roughly a third of the tests. The suite now declares itself with the process-tree form of the binding: every fixture home is built by the test that then selects it, so nothing it hands down is ambient. fm-spawn already blanks the binding on every launch line, and the secondmate lifecycle test now pins that so the declaration cannot follow an agent out.
…hook sweep Captures the axes tests/fm-home-anchor.test.sh cannot assert: which primary harness and runtime backend surfaces carry a home of their own, which are not applicable, and what each hook does when resolution refuses - including the one hook that must stay protective rather than decline.
…checks Both pre-tool tests ran bare `shellcheck <file>`, which is a second copy of the lint definition that bin/fm-lint.sh owns - and a copy that asks a different question. The owner passes --external-sources, so it resolves `# shellcheck source=` directives; bare shellcheck reports every sourced library as unfollowed (SC1091) and exits non-zero. That divergence was latent until bin/fm-arm-pretool-check.sh grew a `.` line for the shared FM_HOME resolver. The script is clean under the owner, CI, and the pre-push gate, and fails only here, so the failure says nothing about the script. Align both invocations with the owner and note the cross-reference. --source-path keeps the directive resolving from whatever directory the suite runs in, since the spelling in the directive is repo-root-relative. The sibling check sources nothing today but would have inherited the identical false failure the day it does. This is strictly more checking than before, not less: --external-sources also lints the sourced file. Verified a deliberately broken copy still fails under the new invocation.
fm-lock.sh's real session-lock acquisition requires fm_harness_ancestry_pid() to find an actual harness process via ps. CI's runner has no such process, so "a mate must still take its own session lock" failed there with "cannot locate harness process in ancestry" while passing locally by accident (a real harness process happens to be in the local dev ancestry). Every other lock-touching test file stubs this with a fake ps reporting a claude process; fm-home-anchor.test.sh was the one missing it. Verified: reproduced the CI failure in isolation with an empty ps stub (exit 1, same error), then confirmed the fake-claude ps stub fixes it (exit 0, lock acquired) - independent of the two automated CI fix-agent attempts, which both misdiagnosed the failure as an unrelated, actually-passing test (fm-calm-pi-extension.test.sh).
…me fm-send examples
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Make it impossible for an ambiently inherited FM_HOME environment variable to silently misroute a firstmate session into the wrong operational home.
What Changed
bin/fm-home-anchor-lib.shas the single owner ofFM_HOMEresolution: when the working directory andFM_HOMEare both firstmate home roots naming different homes, it refuses with a diagnostic naming both candidates rather than guessing; a deliberate selection is declared withFM_HOME_BINDING(the same home's path per invocation, ortest-harnessfor a process tree), and a full set ofFM_STATE/DATA/PROJECTS/CONFIG_OVERRIDEis also accepted. Resolution is recorded per-process in a non-exported, PID-bound variable so repeated resolves settle identically, and pooled task worktrees andprojects/<name>clones are never treated as home roots.FM_HOME="${FM_HOME:-...}"fallback across ~50 scripts inbin/andbin/backends/withfm_home_anchor_resolve; hooks and pre-tool checks passquietand exit 0 so a refusal never breaks a turn.bin/fm-spawn.shnow blanksFM_HOME_BINDINGon every launch line so no binding can be inherited into an agent session, andbin/fm-send.shlayers the shared resolver on top of its existing fail-closed set-FM_HOMErequirement.FM_HOME_BINDINGvariable indocs/configuration.md, pointed hook and backend docs at the resolver as the owner, and addeddocs/verification/home-resolution.mdplustests/fm-home-anchor.test.sh(33 assertions covering refusal in both directions, override layouts, both binding forms, and non-leakage to descendants); existing suites andtests/lib.shwere updated for the harness binding.Risk Assessment
✅ Low: The change comprehensively centralizes FM_HOME resolution in bin/fm-home-anchor-lib.sh, is applied consistently across all ~52 call sites (verified no stragglers with the old inline pattern remain), the FM_HOME_BINDING hand-off is correctly scoped (never exported, blanked on every fm-spawn.sh launch line, read-once at hand-off sites), sites that intentionally bypass the binding correctly rely on the documented all-four-overrides exemption (fm-fleet-snapshot.sh), hooks correctly fail open with
quiet, tests explicitly cover every contract rule including inheritance non-leakage and idempotent re-resolution, and docs/AGENTS.md accurately reflect the implementation. The branch's own history shows the author already caught and fixed a real prior bug within this branch (binding export leaking to descendants) and a CI-only test gap, both resolved.Testing
The dedicated fm-home-anchor.test.sh suite (33 assertions) and every test file this change touches pass, and a manual end-to-end reproduction against the real fm-project-mode.sh and fm-lock.sh executables confirms the actual user-facing behavior: an ambiently inherited FM_HOME now refuses with a clear diagnostic and takes no lock in the wrong home, while a correctly-matched FM_HOME still works and takes its own lock. Two unrelated failures (tests/fm-calm-pi-extension.test.sh and one Pi-guard case in tests/fm-turnend-guard.test.sh) were confirmed pre-existing at the base commit — a Node 22 ESM loader/missing-package environment limitation unconnected to the FM_HOME anchoring feature — so they are reported as informational only.
Evidence: fm-home-anchor.test.sh full run (33/33 pass)
Evidence: Manual CLI reproduction of the reported incident scenario: primary cwd + inherited mate FM_HOME refuses, mate-home cwd + matching FM_HOME works, and the refused command leaves no session lock in the other home
Evidence: fm-arm-pretool-check.test.sh (touched by this change)
Evidence: fm-cd-pretool-check.test.sh (touched by this change)
Evidence: fm-spawn-dispatch-profile.test.sh (touched by this change)
Evidence: fm-gotmp.test.sh (touched by this change)
Evidence: fm-kimi-harness.test.sh (touched by this change)
Evidence: fm-afk-return.test.sh (touched by this change)
Evidence: fm-backend.test.sh (touched by this change)
Evidence: fm-claude-stop-autoarm.test.sh (touched by this change)
Evidence: fm-sessionstart-nudge.test.sh (touched by this change)
Evidence: fm-calm-pi-extension.test.sh (pre-existing environment failure, unrelated)
Evidence: fm-turnend-guard.test.sh (pre-existing environment failure, unrelated)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
✅ **Review** - passed
✅ No issues found.
tests/fm-calm-pi-extension.test.sh- tests/fm-calm-pi-extension.test.sh and one case in tests/fm-turnend-guard.test.sh ("Pi guard must inject once for no-tool and multi-tool logical runs") fail in this sandbox because Node 22's ESM loader cannot import .pi extension .ts files directly (ERR_UNKNOWN_FILE_EXTENSION), and the required @earendil-works/pi-coding-agent package is not installed. Verified by checking out the base commit 79e62b8 into an isolated worktree and re-running both suites there: identical failures occur, confirming this is a pre-existing environment/toolchain limitation, not a regression introduced by the FM_HOME anchoring change.bash tests/fm-home-anchor.test.sh (33 assertions covering: inherited-FM_HOME refusal in both directions, own-home success, pooled task worktrees, projects/<name> clones, FM_*_OVERRIDE layout control, FM_HOME_BINDING per-invocation and process-tree forms, fm-send's fail-closed contract, fm-lock.sh session-lock non-leak, no declaration leakage to descendants, idempotent same-process resolution)bash tests/fm-arm-pretool-check.test.shbash tests/fm-cd-pretool-check.test.shbash tests/fm-spawn-dispatch-profile.test.shbash tests/fm-gotmp.test.shbash tests/fm-kimi-harness.test.shbash tests/fm-afk-return.test.shbash tests/fm-backend.test.shbash tests/fm-claude-stop-autoarm.test.shbash tests/fm-sessionstart-nudge.test.shbash tests/fm-turnend-guard.test.shbash tests/fm-calm-pi-extension.test.shManual reproduction: cd into a primary home fixture with FM_HOME pointed at a second-mate home fixture and ran bin/fm-project-mode.sh alpha (refuses, exit 1) and bin/fm-lock.sh (refuses, exit 1, leaves no .lock in the mate's state dir); then ran the same command standing correctly in the mate home with matching FM_HOME (succeeds, exit 0, reads its own registry)Regression check: checked out base commit 79e62b8 into an isolated git worktree and re-ran the two failing suites there to confirm both failures pre-date this change✅ **Document** - passed
✅ No issues found.
🔧 **Lint** - 1 issue found → auto-fixed ✅
🔧 Fix: no lint changes needed; ShellCheck clean on re-run
✅ Re-checked - no issues remain.
✅ **Push** - passed
✅ No issues found.