diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5250d77e555..36e7d3a724c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -118,6 +118,7 @@ Some of those loops are also catching a transient rather than waiting for a sett Discover tests by listing `tests/*.test.sh`: each is a self-contained bash script named `.test.sh`, and its header comment describes what it covers, so pass one to `bin/fm-test-run.sh` to focus on a subject with canonical timing output. Shared test helpers live in `tests/lib.sh` (reporters, temp roots, git fixtures), `tests/fixtures.sh` (fake toolchain and spawn-world builders), `tests/wake-helpers.sh`, `tests/secondmate-helpers.sh`, and `tests/git-config-helpers.sh` (fixture Git isolation from the host's global and system configuration, already sourced by `tests/lib.sh` and `tests/herdr-test-safety.sh`; a suite that sources neither must source it itself before its first Git operation so a direct invocation stays isolated). Source those instead of copying a fake toolchain into a new suite. +The ambient-home guard comment in [`tests/lib.sh`](tests/lib.sh) owns fixture home initialization and override ordering. A fixture may shorten a production timeout to keep a failure path prompt, but never below what the real work inside that window costs on a loaded machine: a fork, an exec, a lock acquisition, a beacon publication, or a first-poll check. Where a case's assertion is not about the timeout itself, give that window headroom over the measured loaded cost, and bound the test's own waiting with iteration-counted poll loops, which stretch under load where a wall-clock budget does not. Tests that need a real optional backend or an explicit opt-in (real herdr/zellij/cmux smoke tests, the live Pi regression) skip themselves and print the tool or environment gate needed to enable them, so the portable suite remains safe on machines without those tools. diff --git a/bin/fm-afk-contract.sh b/bin/fm-afk-contract.sh index 04f8197f9a6..194f0ce7305 100755 --- a/bin/fm-afk-contract.sh +++ b/bin/fm-afk-contract.sh @@ -226,6 +226,13 @@ fm_afk_contract_now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ } +# Derives the ISO string from the same epoch a caller already captured, so an +# entered/entered_epoch (or confirmed/confirmed_epoch) pair can never straddle +# a second boundary the way two independent `date` calls can. +fm_afk_contract_iso_of_epoch() { # + date -u -r "$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ +} + fm_afk_contract_lower() { # printf '%s' "$1" | tr '[:upper:]' '[:lower:]' } @@ -831,8 +838,8 @@ fm_afk_contract_parse_inputs() { # ; sets WORDS, the CLAUSE_* arrays, fm_afk_contract_cmd_propose() { local entered entered_epoch proposal rc=0 refused fm_afk_contract_parse_inputs "$@" || return 2 - entered=$(fm_afk_contract_now_iso) entered_epoch=$(date +%s) + entered=$(fm_afk_contract_iso_of_epoch "$entered_epoch") proposal=$(fm_afk_contract_proposal_path) fm_afk_contract_render_body "$entered" "$entered_epoch" | fm_afk_contract_write_atomic "$proposal" || { fm_afk_contract_log "failed to write the proposal at $proposal" @@ -864,8 +871,8 @@ fm_afk_contract_cmd_confirm() { local record proposal body confirmed confirmed_epoch archived archived_tmp staged session_entered session_entered_epoch record=$(fm_afk_contract_path) proposal=$(fm_afk_contract_proposal_path) - confirmed=$(fm_afk_contract_now_iso) confirmed_epoch=$(date +%s) + confirmed=$(fm_afk_contract_iso_of_epoch "$confirmed_epoch") if [ -f "$proposal" ]; then fm_afk_contract_validate "$proposal" 0 || return 1 body=$(cat "$proposal") diff --git a/tests/fm-ambient-home-guard.test.sh b/tests/fm-ambient-home-guard.test.sh new file mode 100755 index 00000000000..d39a048ac56 --- /dev/null +++ b/tests/fm-ambient-home-guard.test.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# Regression coverage for the ambient-home poison guard in tests/lib.sh. +# +# Incident: a test fixture invoked a publisher script (the fm-inactive-reconcile.sh +# ledger-first parent delivery covered by tests/fm-inactive-reconcile.test.sh) +# without an explicit FM_HOME override. That script's own fallback, +# `${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}`, resolved FM_HOME to the checkout the +# script physically lives in - the operator's real, live firstmate home when the +# suite runs there, as it normally does. The fixture's fabricated `done: PR +# https://github.com/example/repo/pull/1 ...` child ledger line was then +# delivered for real onto a live secondmate's parent channel +# (bin/fm-parent-channel-lib.sh). +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +RECON="$ROOT/bin/fm-inactive-reconcile.sh" +TMP_ROOT=$(fm_test_tmproot fm-ambient-home-guard) + +# --- shared fixture builder -------------------------------------------------- +# +# Mirrors tests/fm-inactive-reconcile.test.sh's make_world/bind_secondmate/ +# write_child/write_mate_meta shape: a MAIN home with a secondmate MATE bound to +# it, and MATE holding one aged ship child whose ledger already ends in a `done: +# PR ...` line. Scanning MATE delivers that child's ledger line onto MATE's +# parent channel, which is MAIN's state/mate.status - the live secondmate parent +# channel the incident corrupted. + +fm_dir_fingerprint() { # + if command -v shasum >/dev/null 2>&1; then + ( cd "$1" && find . -type f -exec shasum -a 256 {} + 2>/dev/null | LC_ALL=C sort ) + else + ( cd "$1" && find . -type f -exec sha256sum {} + 2>/dev/null | LC_ALL=C sort ) + fi +} + +build_sentinel() { # + local root=$1 main mate fake + main="$root/main"; mate="$root/mate"; fake="$root/fakebin" + mkdir -p "$main"/{state,data,config,projects} "$mate"/{state,data,config,projects,bin} "$fake" + : > "$mate/AGENTS.md" + printf 'mate\n' > "$mate/.fm-secondmate-home" + cat > "$mate/.fm-secondmate-parent" < "$main/state/mate.status" + + fm_write_meta "$mate/state/leak-child.meta" \ + "window=firstmate:fm-leak-child" "worktree=$mate/projects/leak-child" "project=alpha" \ + 'harness=codex' 'kind=ship' 'mode=no-mistakes' 'yolo=off' \ + "spawn_gen=s1" 'pr=https://github.com/example/repo/pull/1' + printf 'done: PR https://github.com/example/repo/pull/1 checks green\n' > "$mate/state/leak-child.status" + : > "$mate/state/leak-child.turn-ended" + fm_touch_epoch "$(( $(date +%s) - 120 ))" \ + "$main/state/mate.meta" "$main/state/mate.status" \ + "$mate/state/leak-child.meta" "$mate/state/leak-child.status" "$mate/state/leak-child.turn-ended" + + cat > "$fake/fm-crew-state.sh" <<'SH' +#!/usr/bin/env bash +printf 'state: done (fake) · source: fake\n' +SH + local tool + for tool in gh gh-axi curl tmux; do + cat > "$fake/$tool" <<'SH' +#!/usr/bin/env bash +exit 97 +SH + done + chmod +x "$fake"/* +} + +# --- the guard is actually in place ------------------------------------------ + +test_lib_pins_fm_home_away_from_root() { + [ -n "${FM_HOME:-}" ] || fail "tests/lib.sh must export a non-empty FM_HOME" + [ "$FM_HOME" != "$ROOT" ] || fail "FM_HOME must never default to the firstmate checkout itself" + [ -d "$FM_HOME" ] || fail "the pinned FM_HOME must exist: $FM_HOME" + assert_present "$FM_HOME/.fm-ambient-guard" "the pinned FM_HOME must carry its own marker" + [ ! -e "$FM_HOME/state" ] || fail "the pinned FM_HOME must seed no state a forgotten call could exploit" + pass "tests/lib.sh pins FM_HOME to an empty, non-root directory" +} + +# --- the hazard is real when the guard is bypassed --------------------------- +# +# Explicitly unsets FM_HOME (undoing tests/lib.sh's pin for this one subshell +# only) and sets FM_ROOT_OVERRIDE to a PRIVATE fixture copy standing in for a +# live home - never $ROOT and never any real operator data - so the reproduction +# proves the mechanism without ever touching a real checkout. + +test_bypassing_the_guard_reproduces_the_incident() { + local root before after + root="$TMP_ROOT/bypass" + mkdir -p "$root" + build_sentinel "$root" + before=$(fm_dir_fingerprint "$root") + + ( + unset FM_HOME + PATH="$root/fakebin:$PATH" FM_ROOT_OVERRIDE="$root/mate" FM_INACTIVE_RECONCILE_SECS=60 \ + FM_INACTIVE_CREW_STATE_BIN="$root/fakebin/fm-crew-state.sh" \ + "$RECON" scan >/dev/null 2>&1 + ) + + after=$(fm_dir_fingerprint "$root") + [ "$before" != "$after" ] \ + || fail "reproduction did not change the fixture; the scenario no longer exercises the hazard" + assert_grep 'child leak-child done' "$root/main/state/mate.status" \ + "the reproduction must show the fabricated PR line landing on the parent channel" + pass "unsetting FM_HOME reproduces the incident against a private fixture (never against \$ROOT)" +} + +# --- the guard closes it ------------------------------------------------------ +# +# Same fixture, same missing FM_HOME override on the scan call itself, but this +# time FM_HOME stays exactly what tests/lib.sh pinned it to (nothing unsets it). + +test_forgotten_home_lands_in_the_guard_not_the_sentinel() { + local root before after + root="$TMP_ROOT/guarded" + mkdir -p "$root" + build_sentinel "$root" + before=$(fm_dir_fingerprint "$root") + + PATH="$root/fakebin:$PATH" FM_ROOT_OVERRIDE="$root/mate" FM_INACTIVE_RECONCILE_SECS=60 \ + FM_INACTIVE_CREW_STATE_BIN="$root/fakebin/fm-crew-state.sh" \ + "$RECON" scan >/dev/null 2>&1 + + after=$(fm_dir_fingerprint "$root") + [ "$before" = "$after" ] \ + || fail "a forgotten FM_HOME override still reached the sentinel live home" + assert_no_grep 'leak-child' "$root/main/state/mate.status" \ + "the sentinel parent channel must show no trace of the fabricated child" + pass "a forgotten FM_HOME override lands in the pinned guard directory, not the sentinel" +} + +test_inherited_directory_overrides_cannot_reach_the_sentinel() { + local root before after + root="$TMP_ROOT/inherited" + mkdir -p "$root" + build_sentinel "$root" + printf 'working: real captain work, do not touch\n' > "$root/mate/state/leak-child.status" + fm_touch_epoch "$(( $(date +%s) - 120 ))" "$root/mate/state/leak-child.status" + before=$(fm_dir_fingerprint "$root") + + PATH="$root/fakebin:$PATH" FM_HOME="$root/mate" FM_ROOT_OVERRIDE="$root/mate" \ + FM_STATE_OVERRIDE="$root/mate/state" FM_DATA_OVERRIDE="$root/mate/data" \ + FM_CONFIG_OVERRIDE="$root/mate/config" FM_PROJECTS_OVERRIDE="$root/mate/projects" \ + FM_PENDING_REPLY_DIR_OVERRIDE="$root/mate/state/pending-replies" \ + FM_INACTIVE_RECONCILE_SECS=60 FM_INACTIVE_CREW_STATE_BIN="$root/fakebin/fm-crew-state.sh" \ + bash -eu -c ' + . "$1/tests/lib.sh" + "$1/bin/fm-inactive-reconcile.sh" scan + for override in FM_ROOT_OVERRIDE FM_STATE_OVERRIDE FM_DATA_OVERRIDE FM_CONFIG_OVERRIDE FM_PROJECTS_OVERRIDE FM_PENDING_REPLY_DIR_OVERRIDE; do + [ -z "${!override-}" ] || fail "inherited $override survived fixture initialization" + done + ' _ "$ROOT" || fail "scan with inherited directory overrides failed" + + after=$(fm_dir_fingerprint "$root") + [ "$before" = "$after" ] \ + || fail "inherited directory overrides reached the sentinel live home" + pass "fixture initialization clears inherited directory overrides and protects live state" +} + +test_lib_pins_fm_home_away_from_root +test_bypassing_the_guard_reproduces_the_incident +test_forgotten_home_lands_in_the_guard_not_the_sentinel +test_inherited_directory_overrides_cannot_reach_the_sentinel diff --git a/tests/fm-arm-pretool-check.test.sh b/tests/fm-arm-pretool-check.test.sh index 267efd286df..e9d4447eb9d 100755 --- a/tests/fm-arm-pretool-check.test.sh +++ b/tests/fm-arm-pretool-check.test.sh @@ -14,6 +14,10 @@ set -u CHECK="$ROOT/bin/fm-arm-pretool-check.sh" POLICY="$ROOT/bin/fm-arm-command-policy.mjs" +# This suite simulates a genuine single-checkout primary session: the seatbelt +# resolves its active home from FM_HOME, so pin it to $ROOT here. +export FM_HOME="$ROOT" + # --- full cross-harness acceptance matrix ---------------------------------- MATRIX_IDS=() diff --git a/tests/fm-cursor-primary.test.sh b/tests/fm-cursor-primary.test.sh index fb872c520fd..efafa4df7e1 100755 --- a/tests/fm-cursor-primary.test.sh +++ b/tests/fm-cursor-primary.test.sh @@ -184,10 +184,10 @@ test_turnend_guard_stands_down_on_cursor_payload() { local dir out status dir=$(make_primary_dir "$TMP_ROOT/host-turnend") : > "$dir/state/task1.meta" - out=$(printf '%s' "$CURSOR_PAYLOAD" | bash "$dir/bin/fm-turnend-guard.sh" 2>&1); status=$? + out=$(printf '%s' "$CURSOR_PAYLOAD" | FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard.sh" 2>&1); status=$? expect_code 0 "$status" "a Cursor-delivered Stop payload must not block through the Claude-settings duplicate" [ -z "$out" ] || fail "duplicate entry produced output: $out" - out=$(printf '%s' "$CURSOR_PAYLOAD" | bash "$dir/bin/fm-turnend-guard.sh" --cursor 2>&1); status=$? + out=$(printf '%s' "$CURSOR_PAYLOAD" | FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard.sh" --cursor 2>&1); status=$? expect_code 2 "$status" "--cursor must let Cursor's own adapter reach the shared block decision" case "$out" in *'TURN WOULD END BLIND'*) ;; *) fail "expected the shared banner, got: $out" ;; esac pass "fm-turnend-guard: Cursor payload is inert without --cursor and blocks with it" @@ -197,7 +197,7 @@ test_turnend_guard_still_blocks_for_claude_payload() { local dir status dir=$(make_primary_dir "$TMP_ROOT/host-claude") : > "$dir/state/task1.meta" - printf '%s' "$CLAUDE_STOP_PAYLOAD" | bash "$dir/bin/fm-turnend-guard.sh" >/dev/null 2>&1 + printf '%s' "$CLAUDE_STOP_PAYLOAD" | FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard.sh" >/dev/null 2>&1 status=$? expect_code 2 "$status" "the host guard must not disturb a genuine Claude Stop payload" pass "fm-turnend-guard: a non-Cursor payload keeps blocking" diff --git a/tests/fm-sessionstart-nudge.test.sh b/tests/fm-sessionstart-nudge.test.sh index 03bedf85502..2b1c831a1ff 100755 --- a/tests/fm-sessionstart-nudge.test.sh +++ b/tests/fm-sessionstart-nudge.test.sh @@ -172,7 +172,7 @@ test_opencode_plugin_delivers_exact_nudge_once() { "$ROOT/bin/fm-gate-refuse-lib.sh" "$ROOT/bin/fm-operational-input.sh" "$root/bin/" chmod +x "$root/bin/fm-sessionstart-nudge.sh" out=$(PLUGIN="$ROOT/.opencode/plugins/fm-primary-sessionstart-nudge.js" \ - WORKTREE="$root" EXPECTED="$NUDGE_LINE" node --input-type=module 2>&1 <<'EOF' + WORKTREE="$root" EXPECTED="$NUDGE_LINE" FM_HOME="$root" node --input-type=module 2>&1 <<'EOF' import { pathToFileURL } from "node:url"; const prompts = []; diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index f0245f6a827..7aec369d1c5 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -742,7 +742,7 @@ test_grok_adapter_forces_one_resume_when_unhealthy() { } >> "$log" EOF chmod +x "$fakebin/grok" - out=$(printf '{"sessionId":"session-test","hookEventName":"stop"}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + out=$(printf '{"sessionId":"session-test","hookEventName":"stop"}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? expect_code 0 "$status" "grok adapter must fail open after queuing a forced resume" [ -z "$out" ] || fail "grok adapter printed output: $out" assert_contains "$(cat "$log")" 'active=1' "grok adapter must mark its forced resume as loop-guarded" @@ -765,7 +765,7 @@ test_grok_adapter_loop_guard_skips_resume() { printf 'called\n' >> "$log" EOF chmod +x "$fakebin/grok" - out=$(printf '{"sessionId":"session-test","hookEventName":"stop"}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" GROK_TURNEND_GUARD_ACTIVE=1 bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + out=$(printf '{"sessionId":"session-test","hookEventName":"stop"}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" GROK_TURNEND_GUARD_ACTIVE=1 FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? expect_code 0 "$status" "grok adapter must allow its own forced resume turn to end" [ -z "$out" ] || fail "grok adapter printed output while loop-guarded: $out" [ ! -e "$log" ] || fail "grok adapter spawned another resume while loop-guarded: $(cat "$log")" @@ -780,7 +780,7 @@ test_grok_adapter_native_false_blocks_without_resume() { log="$TMP_ROOT/grok-native-false.log" printf '#!/usr/bin/env bash\nprintf called >> %q\n' "$log" > "$fakebin/grok" chmod +x "$fakebin/grok" - out=$(printf '%s' '{"sessionId":"native","stopHookActive":false}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + out=$(printf '%s' '{"sessionId":"native","stopHookActive":false}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? expect_code 2 "$status" "native stopHookActive=false must return the shared blocking status" assert_contains "$out" 'TURN WOULD END BLIND' "native block must pass shared guard feedback to Grok" [ ! -e "$log" ] || fail "native path started grok --resume" @@ -795,7 +795,7 @@ test_grok_adapter_native_true_allows_without_resume() { log="$TMP_ROOT/grok-native-true.log" printf '#!/usr/bin/env bash\nprintf called >> %q\n' "$log" > "$fakebin/grok" chmod +x "$fakebin/grok" - out=$(printf '%s' '{"sessionId":"native","stopHookActive":true}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + out=$(printf '%s' '{"sessionId":"native","stopHookActive":true}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? expect_code 0 "$status" "native stopHookActive=true must allow the bounded continuation to stop" [ -z "$out" ] || fail "native true produced output: $out" [ ! -e "$log" ] || fail "native true started grok --resume" @@ -806,12 +806,12 @@ test_grok_adapter_snake_case_native_and_camel_precedence() { local dir out status dir=$(make_primary_dir "$TMP_ROOT/grok-native-spellings") : > "$dir/state/task1.meta" - out=$(printf '%s' '{"sessionId":"native","stop_hook_active":false}' | GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + out=$(printf '%s' '{"sessionId":"native","stop_hook_active":false}' | GROK_WORKSPACE_ROOT="$dir" FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? expect_code 2 "$status" "typed snake_case false must select native blocking" assert_contains "$out" 'TURN WOULD END BLIND' "snake_case native block lost feedback" - out=$(printf '%s' '{"sessionId":"native","stopHookActive":true,"stop_hook_active":false}' | GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + out=$(printf '%s' '{"sessionId":"native","stopHookActive":true,"stop_hook_active":false}' | GROK_WORKSPACE_ROOT="$dir" FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? expect_code 0 "$status" "camelCase true must win over snake_case false" - out=$(printf '%s' '{"sessionId":"native","stopHookActive":false,"stop_hook_active":true}' | GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + out=$(printf '%s' '{"sessionId":"native","stopHookActive":false,"stop_hook_active":true}' | GROK_WORKSPACE_ROOT="$dir" FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? expect_code 2 "$status" "camelCase false must win over snake_case true" pass "fm-turnend-guard-grok: both spellings are typed and camelCase has deterministic precedence" } @@ -835,12 +835,12 @@ test_grok_adapter_invalid_inputs_start_neither_path() { '{"sessionId":"x","stop_hook_active":false,"stop_hook_active":false}' \ '{"sessionId":"x","sessionId":"y"}' do - out=$(printf '%s' "$payload" | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + out=$(printf '%s' "$payload" | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? expect_code 0 "$status" "invalid Grok payload must conservatively allow without choosing a path" [ -z "$out" ] || fail "invalid Grok payload produced output: $out" done [ ! -e "$log" ] || fail "invalid Grok payload started a resume process" - out=$(printf '%s' '{"sessionId":"x","stopHookActive":false}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$TMP_ROOT/missing-grok-root" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + out=$(printf '%s' '{"sessionId":"x","stopHookActive":false}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$TMP_ROOT/missing-grok-root" FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? expect_code 0 "$status" "missing shared-guard prerequisite must conservatively allow" [ -z "$out" ] || fail "missing prerequisite produced output: $out" [ ! -e "$log" ] || fail "missing prerequisite started a resume process" @@ -859,13 +859,13 @@ test_grok_adapter_missing_jq_and_no_supervision_allow() { done printf '#!/usr/bin/env bash\nprintf called >> %q\n' "$log" > "$fakebin/grok" chmod +x "$fakebin/grok" - out=$(printf '%s' '{"sessionId":"x","stopHookActive":false}' | PATH="$fakebin" GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + out=$(printf '%s' '{"sessionId":"x","stopHookActive":false}' | PATH="$fakebin" GROK_WORKSPACE_ROOT="$dir" FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? expect_code 0 "$status" "missing jq must conservatively allow" [ -z "$out" ] || fail "missing jq produced output: $out" [ ! -e "$log" ] || fail "missing jq started a resume process" dir=$(make_primary_dir "$TMP_ROOT/grok-native-no-work") - out=$(printf '%s' '{"sessionId":"x","stopHookActive":false}' | GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + out=$(printf '%s' '{"sessionId":"x","stopHookActive":false}' | GROK_WORKSPACE_ROOT="$dir" FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? expect_code 0 "$status" "healthy no-supervision-needed native stop must allow" [ -z "$out" ] || fail "no-supervision-needed native stop produced output: $out" pass "fm-turnend-guard-grok: missing jq and no-supervision-needed stops stay silent and bounded" diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index c0d37f0cf61..5d1d137865e 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -142,7 +142,7 @@ test_guard_warnings() { printf 'project=x\n' > "$state/task.meta" printf 'project=y\n' > "$state/task2.meta" append_wake "$state" heartbeat heartbeat heartbeat || fail "guard heartbeat append failed" - PATH="$blind:$PATH" CLAUDECODE=1 PI_CODING_AGENT='' GROK_AGENT='' FM_ROOT_OVERRIDE="$dir" FM_STATE_OVERRIDE="$state" FM_GUARD_GRACE=1 "$ROOT/bin/fm-guard.sh" 2> "$err" >/dev/null || fail "guard failed" + PATH="$blind:$PATH" CLAUDECODE=1 PI_CODING_AGENT='' GROK_AGENT='' FM_HOME="$dir" FM_ROOT_OVERRIDE="$dir" FM_STATE_OVERRIDE="$state" FM_GUARD_GRACE=1 "$ROOT/bin/fm-guard.sh" 2> "$err" >/dev/null || fail "guard failed" first=$(grep -v '^[[:space:]]*$' "$err" | head -1) case "$first" in '●'*) ;; @@ -168,7 +168,7 @@ test_guard_warnings() { mkdir -p "$dir/config" printf 'project=x\n' > "$state/task.meta" : > "$dir/config/x-mode.env" - PATH="$blind:$PATH" CLAUDECODE=1 PI_CODING_AGENT='' GROK_AGENT='' FM_ROOT_OVERRIDE="$dir" FM_STATE_OVERRIDE="$state" FM_GUARD_GRACE=1 "$ROOT/bin/fm-guard.sh" 2> "$err" >/dev/null || fail "guard failed" + PATH="$blind:$PATH" CLAUDECODE=1 PI_CODING_AGENT='' GROK_AGENT='' FM_HOME="$dir" FM_ROOT_OVERRIDE="$dir" FM_STATE_OVERRIDE="$state" FM_GUARD_GRACE=1 "$ROOT/bin/fm-guard.sh" 2> "$err" >/dev/null || fail "guard failed" grep -F "source '$dir/config/x-mode.env' first" "$err" >/dev/null || fail "guard repair line did not source the X-mode cadence config" # (2) live watcher plus fresh beacon, empty queue -> silence. @@ -187,7 +187,7 @@ test_guard_warnings() { touch "$state/.last-watcher-beat" # Non-git FM_ROOT keeps the worktree-tangle check inert so "fresh watcher -> # total silence" stays a pure assertion about watcher state. - FM_ROOT_OVERRIDE="$dir" FM_STATE_OVERRIDE="$state" FM_GUARD_GRACE=300 "$ROOT/bin/fm-guard.sh" 2> "$err" >/dev/null || fail "guard failed" + FM_HOME="$dir" FM_ROOT_OVERRIDE="$dir" FM_STATE_OVERRIDE="$state" FM_GUARD_GRACE=300 "$ROOT/bin/fm-guard.sh" 2> "$err" >/dev/null || fail "guard failed" kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true [ ! -s "$err" ] || fail "guard warned with a live watcher and fresh beacon: $(cat "$err")" diff --git a/tests/lib.sh b/tests/lib.sh index 4429362ae55..f4882292dc2 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -237,6 +237,32 @@ if [ "${FM_TEST_SKIP_ORPHAN_REAP:-0}" != 1 ]; then fm_test_reap_orphans fi +# --- ambient-home poison guard ----------------------------------------------- +# +# Publishers such as fm-inactive-reconcile.sh fall back from FM_HOME through +# FM_ROOT_OVERRIDE to the tracked code root. When that checkout is also a live +# fleet home, a forgotten fixture override can publish fabricated task or PR +# outcomes into real state and secondmate parent channels. +# +# On first source, discard inherited root and operational-directory overrides +# listed below and replace even an inherited FM_HOME with a fresh per-process +# scratch directory, registered for cleanup with a guard marker but no fleet state. +# Clearing directory overrides matters because they take precedence over FM_HOME. +# Fixtures needing a home must seed and explicitly set their private FM_HOME +# after sourcing this library; set any required directory overrides then too. +# Setting FM_ROOT_OVERRIDE alone does not replace the exported guard home. +# Calls that omit a fixture home can fail or do nothing against missing state, +# or write into scratch space, without falling back to the live fleet home. +# This is a safe default, not a sandbox against later explicit overrides. +# tests/fm-ambient-home-guard.test.sh compares a private sentinel with and +# without the guard and checks protection against inherited directory overrides. +unset FM_ROOT_OVERRIDE FM_STATE_OVERRIDE FM_DATA_OVERRIDE FM_CONFIG_OVERRIDE FM_PROJECTS_OVERRIDE FM_PENDING_REPLY_DIR_OVERRIDE +FM_TEST_AMBIENT_GUARD=$(fm_test_tmproot fm-ambient-guard) || return 1 +printf 'do-not-use: catches a forgotten FM_HOME override; see tests/lib.sh ambient-home poison guard\n' \ + > "$FM_TEST_AMBIENT_GUARD/.fm-ambient-guard" || return 1 +FM_HOME="$FM_TEST_AMBIENT_GUARD" +export FM_HOME + # --- live-capability gate --------------------------------------------------- # # fm_live_gate [tool ...]