Skip to content

fix(bin): refuse an ambiently inherited FM_HOME instead of misrouting a session - #1

Merged
brchue-ux merged 12 commits into
mainfrom
fm/fm-home-anchor
Aug 5, 2026
Merged

fix(bin): refuse an ambiently inherited FM_HOME instead of misrouting a session#1
brchue-ux merged 12 commits into
mainfrom
fm/fm-home-anchor

Conversation

@brchue-ux

@brchue-ux brchue-ux commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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

  • Added bin/fm-home-anchor-lib.sh as the single owner of FM_HOME resolution: when the working directory and FM_HOME are both firstmate home roots naming different homes, it refuses with a diagnostic naming both candidates rather than guessing; a deliberate selection is declared with FM_HOME_BINDING (the same home's path per invocation, or test-harness for a process tree), and a full set of FM_STATE/DATA/PROJECTS/CONFIG_OVERRIDE is also accepted. Resolution is recorded per-process in a non-exported, PID-bound variable so repeated resolves settle identically, and pooled task worktrees and projects/<name> clones are never treated as home roots.
  • Replaced the ad-hoc FM_HOME="${FM_HOME:-...}" fallback across ~50 scripts in bin/ and bin/backends/ with fm_home_anchor_resolve; hooks and pre-tool checks pass quiet and exit 0 so a refusal never breaks a turn. bin/fm-spawn.sh now blanks FM_HOME_BINDING on every launch line so no binding can be inherited into an agent session, and bin/fm-send.sh layers the shared resolver on top of its existing fail-closed set-FM_HOME requirement.
  • Documented the rule and the new FM_HOME_BINDING variable in docs/configuration.md, pointed hook and backend docs at the resolver as the owner, and added docs/verification/home-resolution.md plus tests/fm-home-anchor.test.sh (33 assertions covering refusal in both directions, override layouts, both binding forms, and non-leakage to descendants); existing suites and tests/lib.sh were 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)
ok - primary cwd + inherited second mate FM_HOME refuses and names both homes
ok - second mate cwd + inherited primary FM_HOME refuses and names both homes
ok - second mate at its own home root with an explicit FM_HOME still works
ok - an unnormalized FM_HOME naming the same home is not treated as a conflict
ok - crewmate in a pooled task worktree keeps its launching home
ok - a task worktree is never promoted to a home, even carrying data/ and state/
ok - worker inside <home>/projects/<name> keeps the mate that launched it
ok - a project clone never promotes a worker to the home containing it
ok - a complete home-material override set keeps working across homes
ok - a partial home-material override set does not wave an inherited home through
ok - FM_ROOT_OVERRIDE plus a derived config override is not a declaration
ok - FM_ROOT_OVERRIDE still behaves as the whole-root override when FM_HOME is unset
ok - a binding naming the same home confirms a deliberate cross-home command
ok - a binding naming a different home does not bless an inherited FM_HOME
ok - the process-tree declaration covers a caller that names every home it hands down
ok - the process-tree declaration is not overwritten by a resolved home
ok - a near-miss of the process-tree literal declares nothing
ok - an unset FM_HOME still resolves to the code root
ok - an FM_HOME that is not a home root raises no rival claim
ok - fm-send still refuses to resolve targets without an explicit home
ok - fm-send refuses to steer a fleet reached through an inherited home
ok - an inherited home cannot take another home's session lock
ok - a home standing in itself still takes its own session lock
ok - blanked overrides and a blanked binding are not a deliberate declaration
ok - resolving a home does not bless a later command standing in a different home
ok - resolution hands no declaration to the processes it launches
ok - a binding passed per invocation still reaches the command tree it was issued for
ok - the process-tree declaration keeps reaching the tree its owner drives
ok - a same-home hand-off with a partial override set still reaches the child
ok - a hand-off that binds the home it passes reaches the child from any home root
ok - a process standing in another home root does not refuse its own resolved home
ok - a resolved home is a decision about that home, not a blanket blessing
ok - a resolution record arriving in the environment is not a decision this process made
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
=== Scenario: primary cwd + inherited mate FM_HOME -> refuse (misroute prevented) ===
$ cd /tmp/fm-manual-demo/primary && FM_HOME=/tmp/fm-manual-demo/mate fm-project-mode.sh alpha
error: FM_HOME names a different firstmate home than the one this command is running in.
  running in: /tmp/fm-manual-demo/primary
  FM_HOME:    /tmp/fm-manual-demo/mate
FM_HOME is inherited by every process launched from another home's session, so it cannot
show by itself whether it was chosen for this command or carried in from an unrelated
ancestor. Refusing rather than picking one, because guessing wrong takes the other home's
session lock and writes to its durable records.
  to use the home you are standing in:   unset FM_HOME
  to confirm the other home on purpose:  FM_HOME_BINDING="$FM_HOME" <command>
exit=1

=== Scenario: standing correctly in the mate's own home with matching FM_HOME -> works, reads its OWN registry ===
local-only off
exit=0

=== Consequence check: refused command must not leave a stray session lock in the mate's home ===
fm-lock.sh exit=1
error: FM_HOME names a different firstmate home than the one this command is running in.
  running in: /tmp/fm-manual-demo/primary
  FM_HOME:    /tmp/fm-manual-demo/mate
FM_HOME is inherited by every process launched from another home's session, so it cannot
show by itself whether it was chosen for this command or carried in from an unrelated
ancestor. Refusing rather than picking one, because guessing wrong takes the other home's
session lock and writes to its durable records.
  to use the home you are standing in:   unset FM_HOME
  to confirm the other home on purpose:  FM_HOME_BINDING="$FM_HOME" <command>
PASS: no .lock file created in mate/state -- misroute did not take the other home's session lock
Evidence: fm-arm-pretool-check.test.sh (touched by this change)
ok - matrix A01: allow through all five entry forms
ok - matrix A02: allow through all five entry forms
ok - matrix A03: allow through all five entry forms
ok - matrix A04: allow through all five entry forms
ok - matrix A05: allow through all five entry forms
ok - matrix A06: allow through all five entry forms
ok - matrix A07: allow through all five entry forms
ok - matrix A08: allow through all five entry forms
ok - matrix A09: allow through all five entry forms
ok - matrix A10: allow through all five entry forms
ok - matrix A11: allow through all five entry forms
ok - matrix A12: allow through all five entry forms
ok - matrix A13: allow through all five entry forms
ok - matrix A14: allow through all five entry forms
ok - matrix A15: allow through all five entry forms
ok - matrix A16: allow through all five entry forms
ok - matrix A17: allow through all five entry forms
ok - matrix R01: allow through all five entry forms
ok - matrix R02: allow through all five entry forms
ok - matrix R03: allow through all five entry forms
ok - matrix R04: allow through all five entry forms
ok - matrix R05: allow through all five entry forms
ok - matrix R06: allow through all five entry forms
ok - matrix R07: allow through all five entry forms
ok - matrix R08: allow through all five entry forms
ok - matrix R09: allow through all five entry forms
ok - matrix R10: allow through all five entry forms
ok - matrix R11: allow through all five entry forms
ok - matrix R12: allow through all five entry forms
ok - matrix R13: allow through all five entry forms
ok - matrix R14: allow through all five entry forms
ok - matrix R15: allow through all five entry forms
ok - matrix R16: allow through all five entry forms
ok - matrix R17: allow through all five entry forms
ok - matrix R18: allow through all five entry forms
ok - matrix R19: allow through all five entry forms
ok - matrix D01: deny through all five entry forms
ok - matrix D02: deny through all five entry forms
ok - matrix D03: deny through all five entry forms
ok - matrix D04: deny through all five entry forms
ok - matrix D05: deny through all five entry forms
ok - matrix D06: deny through all five entry forms
ok - matrix D07: deny through all five entry forms
ok - matrix D08: deny through all five entry forms
ok - matrix D09: deny through all five entry forms
ok - matrix D10: deny through all five entry forms
ok - matrix D11: deny through all five entry forms
ok - matrix D12: deny through all five entry forms
ok - matrix D13: deny through all five entry forms
ok - matrix D14: deny through all five entry forms
ok - matrix D15: deny through all five entry forms
ok - matrix D16: deny through all five entry forms
ok - matrix D17: deny through all five entry forms
ok - matrix D18: deny through all five entry forms
ok - matrix D19: deny through all five entry forms
ok - matrix D20: deny through all five entry forms
ok - matrix D21: deny through all five entry forms
ok - matrix D22: deny through all five entry forms
ok - matrix D23: deny through all five entry forms
ok - matrix D24: deny through all five entry forms
ok - matrix D25: deny through all five entry forms
ok - matrix D26: deny through all five entry forms
ok - matrix D27: deny through all five entry forms
ok - matrix D28: deny through all five entry forms
ok - matrix D29: deny through all five entry forms
ok - matrix D30: deny through all five entry forms
ok - matrix D31: deny through all five entry forms
ok - matrix D32: deny through all five entry forms
ok - matrix D33: deny through all five entry forms
ok - matrix D34: deny through all five entry forms
ok - matrix D35: deny through all five entry forms
ok - matrix D36: deny through all five entry forms
ok - matrix D37: deny through all five entry forms
ok - matrix D38: deny through all five entry forms
ok - matrix D39: deny through all five entry forms
ok - matrix D40: deny through all five entry forms
ok - matrix D41: deny through all five entry forms
ok - matrix D42: deny through all five entry forms
ok - matrix D43: deny through all five entry forms
ok - matrix D44: deny through all five entry forms
ok - matrix D45: deny through all five entry forms
ok - matrix D46: deny through all five entry forms
ok - matrix D47: deny through all five entry forms
ok - matrix D48: deny through all five entry forms
ok - matrix D49: deny through all five entry forms
ok - matrix D50: deny through all five entry forms
ok - matrix D51: deny through all five entry forms
ok - matrix D52: deny through all five entry forms
ok - matrix D53: deny through all five entry forms
ok - matrix D54: deny through all five entry forms
ok - matrix D55: deny through all five entry forms
ok - matrix D56: deny through all five entry forms
ok - matrix D57: deny through all five entry forms
ok - matrix D58: deny through all five entry forms
ok - matrix E01: allow through all five entry forms
ok - matrix E02: allow through all five entry forms
ok - matrix E03: allow through all five entry forms
ok - matrix E04: allow through all five entry forms
ok - matrix E05: deny through all five entry forms
ok - matrix E06: deny through all five entry forms
ok - matrix E07: deny through all five entry forms
ok - matrix E08: deny through all five entry forms
ok - matrix E09: deny through all five entry forms
ok - matrix E10: deny through all five entry forms
ok - matrix E11: deny through all five entry forms
ok - matrix E12: allow through all five entry forms
ok - matrix E13: allow through all five entry forms
ok - matrix E14: allow through all five entry forms
ok - matrix E15: allow through all five entry forms
ok - matrix E16: allow through all five entry forms
ok - matrix E17: allow through all five entry forms
ok - direct policy direct-data-pkill: allow
ok - direct policy direct-broad-pkill: deny	broad-watcher-kill
ok - direct policy direct-loop-broad-pkill: deny	broad-watcher-kill
ok - direct policy direct-loop-broad-kill-pgrep: deny	broad-watcher-kill
ok - direct policy direct-loop-no-kill-allowed: allow
ok - direct policy direct-pipeline: deny	watcher-pipeline
ok - direct policy direct-leading-redirection: deny	watcher-redirection
ok - direct policy direct-unclassifiable: deny	unclassifiable-protected-command
ok - direct policy direct-unsupported: deny	unclassifiable-protected-command
ok - direct policy direct-constructed-payload: deny	watcher-nested
ok - direct policy direct-parameter-export: allow
ok - direct policy direct-expanded-arm-blessed: allow
ok - direct policy direct-expanded-arm-background: deny	watcher-background
ok - direct policy direct-expanded-arm-pipeline: deny	watcher-pipeline
ok - direct policy direct-watch-not-blessed: deny	watcher-direct
ok - direct policy direct-watch-expanded: deny	watcher-direct
ok - direct policy direct-watch-safe-shape: deny	watcher-direct
ok - direct policy direct-heredoc-data: allow
ok - direct policy direct-heredoc-watcher: deny	watcher-redirection
ok - --command=<val> equals-form parses correctly
ok - --background is accepted for interface parity and is never itself a deny signal
ok - unknown CLI flag is rejected
ok - stdin grok schema (toolInput.command): denied with Grok-shaped stdout JSON
ok - stdin claude/codex schema (tool_input.command): blessed shape allowed
ok - stdin claude/codex schema (tool_input.command): backgrounded shape denied
ok - stdin: unrelated command is a fast allow
ok - transport prefilter is a strict superset: non-fm-watch fast-allows, every fm-watch and quoting-decoder-marker command reaches the classifier
ok - fail-open: empty stdin
ok - fail-open: unparseable JSON on stdin
ok - fail-open: missing jq on stdin path
ok - fail-open: missing classifier runtime
ok - --claude: stdout empty, stderr carries hookSpecificOutput deny JSON
ok - default mode: stdout carries Grok-shaped decision JSON on deny
ok - allow is silent on both stdout and stderr in default and --claude mode
ok - bin/fm-arm-pretool-check.sh is shellcheck-clean
Evidence: fm-cd-pretool-check.test.sh (touched by this change)
ok - cd-guard acceptance matrix: 63 cases x 5 harness entry forms, block/allow all correct
ok - cd-guard: fires in a secondmate home (its own primary session is a primary)
ok - cd-guard: inert in a crewmate/scout task worktree (linked git worktree)
ok - cd-guard: inert in a non-firstmate repo (no AGENTS.md)
ok - cd-guard: inert when not inside a git repo
ok - cd-guard: reproduces the cwd leak and denies the exact command that causes it
ok - cd-guard: fails open on empty stdin
ok - cd-guard: fails open on unparseable stdin JSON
ok - cd-guard: fails open (never blocks) when node is missing
ok - cd-guard: fails open on the stdin path when jq is missing
ok - cd-guard: prefilter fast-allows (skips node) when no cd/pushd/popd substring is present
ok - cd-guard: fm-cd-command-policy.mjs CLI honors the deny/allow output contract
ok - bin/fm-cd-pretool-check.sh is shellcheck-clean
Evidence: fm-spawn-dispatch-profile.test.sh (touched by this change)
ok - no --model/--effort records defaults and types the claude launch instructions
ok - relative home overrides ignore CDPATH and become absolute before spawn launch construction
ok - FM_HOME defaults resolve relative paths and preserve absolute spellings
ok - absolute override spellings are preserved in spawn launch paths
ok - unresolvable relative spawn overrides fail with named diagnostics
ok - active crew-dispatch profile requires an explicit harness for ship spawns
ok - active crew-dispatch profile requires an explicit harness for scout spawns
ok - active crew-dispatch profile allows an explicit resolved harness
ok - active crew-dispatch profile allows the legacy positional harness form
ok - active crew-dispatch profile allows the raw launch-command escape hatch
ok - claude receives --model and --effort profile flags
ok - codex receives --model and model_reasoning_effort profile flags
ok - codex omits unsupported max effort instead of passing a bad config value
ok - grok receives --model and --reasoning-effort profile flags
ok - grok omits unsupported max reasoning effort
ok - grok omits unsupported xhigh reasoning effort
ok - opencode receives --model and omits the unsupported effort axis
ok - pi receives --model and --thinking max profile flags
ok - pi-signed shares Pi launch semantics while preserving its configured and recorded identity
ok - pi-signed refuses safely and actionably when the selected executable is unavailable
ok - pi-signed is a distinct persistent secondmate runtime with shared Pi supervision semantics
ok - batch dispatch forwards shared --harness, --model, and --effort to every pair
ok - claude forwards firstmate's CLAUDE_CONFIG_DIR so the crewmate uses the same credential store
ok - claude omits the config-dir prefix when firstmate runs with the single-store default
ok - non-claude harnesses do not receive the claude CLAUDE_CONFIG_DIR prefix
ok - active crew-dispatch profile does not block secondmate launches
# all fm-spawn-dispatch-profile tests passed
Evidence: fm-gotmp.test.sh (touched by this change)
ok - fm-teardown removes the dir pointed to by tasktmp= in meta
ok - fm-teardown skips gracefully when tasktmp= is absent (backward compat)
ok - fm-teardown skips gracefully when tasktmp= points to a nonexistent dir
Evidence: fm-kimi-harness.test.sh (touched by this change)
ok - Kimi hook install is idempotent and removal restores every foreign config byte
ok - Kimi hook removal preserves owned newline boundaries and pristine bytes
ok - Kimi hook install refuses missing, malformed, and surprising config without writing
ok - Kimi hook install refuses without jq before any config write
ok - fm-spawn: kimi launches, delivers its brief, and registers a guarded turn-end token
ok - Kimi hook stays silent and inert without a Firstmate registry token
ok - fm-spawn: unsafe Kimi global config refuses before pane creation
ok - fm-teardown: Kimi task pointer and registry token are removed
ok - fm-spawn: Kimi fallback expands the active HOME
ok - fm-spawn: missing Kimi executable refuses before pane creation
ok - fm-spawn: kimi treats a silent pointer drop as a failed spawn
ok - fm-spawn: kimi never sends the brief pointer before an observable ready signal
ok - fm-harness: markerless kimi is detected by ancestry after env-marker precedence
lock acquired: harness pid 1110049
ok - fm-lock recognizes Kimi ancestry and live lock holders
ok - busy detection: real Kimi moon-plus-middot captures require its harness while idle labels stay idle
ok - fm-watch: Kimi spinner matching is metadata-scoped and ignores Grok's busy token
ok - composer classifier: kimi's existing bordered > shape is already safe without an override
Evidence: fm-afk-return.test.sh (touched by this change)
ok - return catch-up precedes Bearings, owns live blocker remediation, preserves evidence once, and clears idempotently
ok - tmux and Herdr blockers require the same explicit durable reclassification before ordinary work
ok - needs-decision remains reportable without masquerading as a firstmate-actionable blocker
ok - away-mode re-entry fails closed while the prior return catch-up is pending
ok - check retries recorded terminal teardown and keeps catch-up gated until success
Evidence: fm-backend.test.sh (touched by this change)
ok - fm_backend_name: FM_BACKEND env > config/backend > default tmux
ok - fm_backend_detect: no markers -> undetected, HERDR_ENV=1 -> herdr, $TMUX -> tmux, CMUX_WORKSPACE_ID -> cmux, nested combinations resolve innermost-first
ok - fm_backend_detect: falls back to __CFBundleIdentifier=com.cmuxterm.app when CMUX_WORKSPACE_ID is absent (signal bundle-id; foreign bundle ids rejected)
ok - fm_backend_detect: the cmux fallback signals are macOS-only (inert on a non-Darwin uname)
ok - fm_backend_detect: an inherited cmux bundle id never outranks $TMUX or HERDR_ENV (tmux/herdr-inside-cmux false positive absorbed)
ok - fm_backend_detect: ancestry fallback matches the lsappinfo-resolved (bundle-id) cmux app pid in the parent chain
ok - fm_backend_detect: ancestry fallback matches a bundle-shaped cmux comm path at any install location when lsappinfo cannot resolve a pid
ok - fm_backend_detect: ancestry fallback stops undetected at launchd (a reparented tmux server never reaches cmux)
ok - fm_backend_name: a fallback-detected cmux prints a NOTICE naming the fallback signal; the primary-marker notice is unchanged
ok - fm_backend_name: auto-detect selects herdr or cmux (loud notice) or tmux (silent, including nested tmux-in-herdr/tmux-in-cmux)
ok - fm_backend_name: an explicit FM_BACKEND or config/backend setting always wins over runtime auto-detection, including an ambient cmux marker
ok - fm_backend_validate: implemented adapters accepted, unknown and blocked codex-app backends refused loudly
ok - zsh: shell-portable backend matching skipped (zsh not found)
ok - bash: fm_backend_source recognizes known backends and rejects unknown ones
ok - fm_backend_validate_spawn: all implemented lifecycle backends are spawn-supported
ok - fm_meta_get / fm_backend_of_meta: read key=value, default backend to tmux
ok - fm_backend_resolve_selector: session:window literal, exact task id first, legacy fm-<id> label fallback, ad hoc bare name via tmux list-windows
ok - fm_backend_of_selector: exact task ids, legacy fm-<id> labels, and matching explicit targets inherit metadata backend
ok - fm-send.sh: explicit tmux targets are verified, while --key/plain/slash send command shape stays old-compatible
ok - fm-peek.sh: capture-pane invocation and output are byte-identical old vs new
ok - fm-spawn.sh: a project reached through a symlinked prefix (e.g. macOS /tmp -> /private/tmp) does not trip the isolation guard's false refusal
ok - fm-teardown.sh: treehouse return remains compatible while tmux cleanup uses exact selectors
ok - fm-spawn.sh --backend bogus is refused loudly
ok - fm-spawn.sh --backend codex-app is refused
ok - fm-spawn.sh honors FM_BACKEND and refuses an unimplemented value loudly
ok - fm-spawn.sh: an explicit --backend tmux resolves silently and writes no backend= (missing means tmux)
ok - fm-spawn.sh: explicit --backend tmux wins over an ambient HERDR_ENV=1 auto-detect marker
ok - fm-spawn.sh: auto-detect resolves nested tmux-in-herdr to tmux and stays silent end to end
Evidence: fm-claude-stop-autoarm.test.sh (touched by this change)
ok - auto-arm: inert in a linked child worktree even when in-flight
ok - auto-arm: inert with no session lock
ok - auto-arm: a demonstrably dead recorded session owner is reclaimed through fm-lock.sh before arming
ok - auto-arm: inert without arm, rewake, or lock replacement when another live harness owns the home
ok - auto-arm: inert while AFK owns supervision
ok - auto-arm: stale-owner recovery leaves the AFK and supervision-need gates unchanged
ok - auto-arm: resolves the outermost pid of a nested contiguous claude ancestry (bg-spare chain)
ok - auto-arm: inert with nothing in flight and no X-mode need
ok - auto-arm: actionable close translates to exactly one exit-2 rewake with reason
ok - auto-arm: watcher: FAILED translates to an exit-2 alarm rewake
ok - auto-arm: clean close exits silently with a clean epoch
ok - auto-arm: X-mode poll need arms the cycle even with no tasks in flight
ok - auto-arm: concurrent firings admit one owner and one rewake translation
ok - auto-arm: need vanishing mid-cycle closes without a rewake
ok - auto-arm: mid-cycle AFK hands triage to the daemon with no rewake
ok - auto-arm: active in a marked secondmate home
ok - fm-lock: shared session-lock lib preserves the status path
Evidence: fm-sessionstart-nudge.test.sh (touched by this change)
ok - fm-sessionstart-nudge: a genuine primary gets one explicitly marked instruction line
ok - fm-sessionstart-nudge: NO_MISTAKES_GATE is silent
ok - fm-sessionstart-nudge: .no-mistakes gate common-dir is silent
ok - fm-sessionstart-nudge: an unmarked linked task worktree is silent
ok - fm-sessionstart-nudge: a marked linked secondmate home is a primary
ok - fm-sessionstart-nudge: a checkout without state is silent
ok - fm-sessionstart-nudge: a lock holder in process ancestry is already run
ok - OpenCode session.created delivers the exact wrapper nudge once per session
Evidence: fm-calm-pi-extension.test.sh (pre-existing environment failure, unrelated)
skip: installed @earendil-works/pi-coding-agent package not found
ok - Pi calm compatibility evidence never rejects a Pi version for being newer than 0.82.0, and still fails closed on a missing or malformed version
skip: installed @earendil-works/pi-coding-agent package not found
not ok - Pi calm missing-adapter-export path failed: node:internal/modules/esm/get_format:219
  throw new ERR_UNKNOWN_FILE_EXTENSION(ext, filepath);
        ^

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /tmp/fm-calm-pi-extension.qOVMKW/missing-adapter-exports/project/.pi/extensions/lib/fm-calm-assistant-layout.ts
    at Object.getFileProtocolModuleFormat [as file:] (node:internal/modules/esm/get_format:219:9)
    at defaultGetFormat (node:internal/modules/esm/get_format:245:36)
    at defaultLoad (node:internal/modules/esm/load:98:16)
    at ModuleLoader.load (node:internal/modules/esm/loader:815:12)
    at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:594:31)
    at #createModuleJob (node:internal/modules/esm/loader:624:36)
    at #getJobFromResolveResult (node:internal/modules/esm/loader:343:34)
    at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:311:41)
    at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:664:25) {
  code: 'ERR_UNKNOWN_FILE_EXTENSION'
}

Node.js v22.22.1
Evidence: fm-turnend-guard.test.sh (pre-existing environment failure, unrelated)
ok - fm_supervision_unhealthy: false with no state/*.meta at all
ok - fm_supervision_unhealthy: true with in-flight task and no beacon ever
ok - fm_supervision_unhealthy: true with in-flight task and a beacon far outside the grace window
ok - fm_supervision_unhealthy: false with in-flight task and a fresh beacon
ok - fm_supervision_status: FM_SUP_QUEUE_PENDING tracks state/.wake-queue
ok - fm_supervision_needed: X-mode relay poll needs supervision without changing the task predicate
ok - fm-turnend-guard: silent no-op with nothing in flight
ok - fm-turnend-guard: blocks when a fresh beacon has no live watcher lock
ok - fm-turnend-guard: blocks on a dead watcher lock even when the beacon is fresh
ok - fm-turnend-guard: silent no-op with a live watcher lock and fresh beacon
ok - fm-turnend-guard: blocks on a live watcher lock with an ancient beacon
ok - fm-turnend-guard: blocks with the exact required reason in the primary when unhealthy
ok - fm-turnend-guard: blocks from active FM_HOME state, not only repo-root state
ok - fm-turnend-guard: X-mode repair reason sources the cadence config
ok - fm-turnend-guard: ignores stale repo-root state when FM_HOME is set
ok - fm-turnend-guard: uses FM_STATE_OVERRIDE ahead of FM_HOME/state
ok - fm-turnend-guard: stop_hook_active=true always allows the stop (never blocks twice in one turn)
ok - fm-turnend-guard: blocks a blind turn end in a secondmate's own home (.fm-secondmate-home no longer excludes it)
ok - fm-turnend-guard: idle-by-default - silent in a secondmate home with nothing in flight
ok - fm-turnend-guard: stop_hook_active=true allows the stop in a secondmate home (never blocks twice in one turn)
ok - fm-turnend-guard: secondmate deferred-death recovery - silent while watched, forces re-arm once the watcher exits
ok - fm-turnend-guard: inert in a secondmate's own child worktree (linked git worktree) even when unhealthy
ok - fm-turnend-guard: blocks a blind turn end in a treehouse-leased LINKED secondmate home (marker force-include)
ok - fm-turnend-guard: an invalid (empty) marker cannot spoof inclusion; linked worktree stays exempt
ok - fm-turnend-guard: a non-ASCII marker cannot spoof inclusion; linked worktree stays exempt
ok - fm-turnend-guard: inert in a crewmate/scout task worktree (linked git worktree) even when unhealthy
ok - fm-turnend-guard: fails open (never blocks) when jq is missing
ok - fm-turnend-guard: silent no-op on empty stdin
ok - fm-turnend-guard: runs well under the generous timing margin (0s)
ok - fm-turnend-guard-grok: forces one explicitly marked same-session resume when the shared predicate blocks
ok - fm-turnend-guard-grok: legacy environment loop guard prevents a nested resume loop
ok - fm-turnend-guard-grok: native false delegates blocking feedback with zero resume processes
ok - fm-turnend-guard-grok: native true remains bounded and starts no resume process
ok - fm-turnend-guard-grok: both spellings are typed and camelCase has deterministic precedence
ok - fm-turnend-guard-grok: malformed, invalidly typed, and missing-prerequisite payloads start neither path
ok - fm-turnend-guard-grok: missing jq and no-supervision-needed stops stay silent and bounded
ok - .codex/hooks.json: Stop hook uses hook process root when payload cwd is outside
ok - .codex/hooks.json: Stop hook ignores nested git root guard scripts
ok - .opencode primary plugin: guard path is anchored to worktree, not directory
not ok - Pi guard must inject once for no-tool and multi-tool logical runs: expected exit 0, got 1
- Outcome: ⚠️ 1 info across 1 run (6m50s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

⚠️ **Test** - 1 info
  • ℹ️ 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.sh
  • bash tests/fm-cd-pretool-check.test.sh
  • bash tests/fm-spawn-dispatch-profile.test.sh
  • bash tests/fm-gotmp.test.sh
  • bash tests/fm-kimi-harness.test.sh
  • bash tests/fm-afk-return.test.sh
  • bash tests/fm-backend.test.sh
  • bash tests/fm-claude-stop-autoarm.test.sh
  • bash tests/fm-sessionstart-nudge.test.sh
  • bash tests/fm-turnend-guard.test.sh
  • bash tests/fm-calm-pi-extension.test.sh
  • Manual 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 ✅
  • ⚠️ linter found issues (exit code 2)

🔧 Fix: no lint changes needed; ShellCheck clean on re-run
✅ Re-checked - no issues remain.

✅ **Push** - passed

✅ No issues found.

Firstmate Crew and others added 12 commits July 31, 2026 19:35
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).
@brchue-ux
brchue-ux merged commit 57a9478 into main Aug 5, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant