Skip to content
Merged
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<subject>.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.
Expand Down
11 changes: 9 additions & 2 deletions bin/fm-afk-contract.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() { # <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() { # <text>
printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
}
Expand Down Expand Up @@ -831,8 +838,8 @@ fm_afk_contract_parse_inputs() { # <args...>; 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"
Expand Down Expand Up @@ -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")
Expand Down
171 changes: 171 additions & 0 deletions tests/fm-ambient-home-guard.test.sh
Original file line number Diff line number Diff line change
@@ -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() { # <dir>
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() { # <root>
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" <<EOF
schema=fm-secondmate-parent.v1
route=local
parent_home=$main
EOF
fm_write_secondmate_meta "$main/state/mate.meta" "$mate"
printf 'working: delegated scope\n' > "$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
4 changes: 4 additions & 0 deletions tests/fm-arm-pretool-check.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=()
Expand Down
6 changes: 3 additions & 3 deletions tests/fm-cursor-primary.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion tests/fm-sessionstart-nudge.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
Loading
Loading