From a7d61240f3d57286f7cd33681974b52b6a2577c0 Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Sat, 1 Aug 2026 08:53:17 -0700 Subject: [PATCH 1/2] fm-watch: harden hash_pane() against PATH gaps hash_pane() hard-errored on every poll cycle when a watcher's PATH lacked both md5 and md5sum (observed on a secondmate whose runtime PATH omitted both, needing ~3 manual watcher restarts). Add an FM_MD5_SBIN_OVERRIDE test seam for the existing hardcoded /sbin/md5 check, and extend the fallback chain past md5sum to openssl dgst -md5, then shasum, then cksum, then a wc -c last resort that cannot itself be absent. The hash is only compared for change-detection between polls, so any tool that returns a stable token in its first whitespace-delimited field is interchangeable. Covered by tests/fm-hash-pane.test.sh. --- bin/fm-test-run.sh | 3 +- bin/fm-watch.sh | 20 +++++- tests/fm-hash-pane.test.sh | 129 +++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 4 deletions(-) create mode 100755 tests/fm-hash-pane.test.sh diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 5d958eab35..65b88545ab 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -131,7 +131,8 @@ family_for_basename() { fm-test-run.test.sh|fm-test-isolation-proof.test.sh) printf '%s\n' pure-contract-unit ;; - fm-daemon.test.sh|fm-guard-stale-banner.test.sh|fm-pi-watch-extension.test.sh|\ + fm-daemon.test.sh|fm-guard-stale-banner.test.sh|fm-hash-pane.test.sh|\ + fm-pi-watch-extension.test.sh|\ fm-supervision-events.test.sh|fm-turnend-guard.test.sh|fm-wake-daemon-lifecycle-e2e.test.sh|\ fm-wake-queue.test.sh|fm-watch-checkpoint.test.sh|fm-watch-triage.test.sh|\ fm-watcher-lock.test.sh) diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 766023008c..9947e261b5 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -174,14 +174,28 @@ _event_cap_fails=0 afk_present() { [ -e "$STATE/.afk" ]; } hash_pane() { + local sbin_md5="${FM_MD5_SBIN_OVERRIDE:-/sbin/md5}" + # Only used for change-detection between polls, so any tool that reliably + # returns the checksum in its first whitespace-delimited field is + # interchangeable here. The chain degrades through progressively more + # universal tools rather than hard-erroring when a watcher's PATH is + # missing md5/md5sum (observed on a secondmate whose runtime PATH omitted + # both): openssl and cksum are near-universal fallbacks, and wc -c is a + # last-resort that cannot itself be absent. if command -v md5 >/dev/null 2>&1; then md5 -q - elif [ -x /sbin/md5 ]; then - /sbin/md5 -q + elif [ -x "$sbin_md5" ]; then + "$sbin_md5" -q elif command -v md5sum >/dev/null 2>&1; then md5sum | cut -d' ' -f1 - else + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -md5 -r | cut -d' ' -f1 + elif command -v shasum >/dev/null 2>&1; then shasum | cut -d' ' -f1 + elif command -v cksum >/dev/null 2>&1; then + printf '%x\n' "$(cksum | cut -d' ' -f1)" + else + wc -c | tr -d ' ' fi } diff --git a/tests/fm-hash-pane.test.sh b/tests/fm-hash-pane.test.sh new file mode 100755 index 0000000000..a12aedcdc4 --- /dev/null +++ b/tests/fm-hash-pane.test.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# tests/fm-hash-pane.test.sh - bin/fm-watch.sh's hash_pane() tool-resolution +# fallback chain. hash_pane() only feeds poll-to-poll change detection, so any +# tool that yields a stable token is interchangeable; these tests confirm the +# fallback chain never hard-errors when a watcher's PATH is missing md5 and +# md5sum (observed on a secondmate whose runtime PATH omitted both, causing +# repeated watcher FAILED cycles that needed manual restarts). +# +# Each case builds a minimal PATH from symlinks to individually resolved +# binaries rather than trimming directories out of the ambient PATH: on at +# least one dev machine md5, md5sum, and openssl are all symlinked from the +# same homebrew bin directory, so removing "the directory containing md5" +# would silently remove openssl too and defeat the tier being tested. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-hash-pane) + +# Resolve real tool paths once, before any test narrows PATH. +REAL_CUT=$(command -v cut) || fail "cut not found on the test host" +REAL_TR=$(command -v tr) || fail "tr not found on the test host" +REAL_WC=$(command -v wc) || fail "wc not found on the test host" +REAL_SBIN_MD5=/sbin/md5 +[ -x "$REAL_SBIN_MD5" ] || REAL_SBIN_MD5=$(command -v md5 || true) +REAL_OPENSSL=$(command -v openssl || true) +REAL_SHASUM=$(command -v shasum || true) + +# make_fakebin [tool=path ...]: symlink each named tool from its +# resolved absolute path into , plus cut/tr/wc always (hash_pane's +# fallback tiers pipe through them). Echoes . +make_fakebin() { + local dir=$1 spec tool src + shift + mkdir -p "$dir" + ln -sf "$REAL_CUT" "$dir/cut" + ln -sf "$REAL_TR" "$dir/tr" + ln -sf "$REAL_WC" "$dir/wc" + for spec in "$@"; do + tool=${spec%%=*} + src=${spec#*=} + [ -n "$src" ] || fail "make_fakebin: no resolved path for $tool on this host" + ln -sf "$src" "$dir/$tool" + done + printf '%s\n' "$dir" +} + +# source_watch : source fm-watch.sh's function definitions into the +# current shell without running its main-entry watcher loop (fm-watch.sh +# returns early when sourced; see its "Main entry" guard). +source_watch() { + local home=$1 + FM_HOME="$home" + FM_STATE_OVERRIDE="$home/state" + export FM_HOME FM_STATE_OVERRIDE + # shellcheck source=/dev/null + . "$ROOT/bin/fm-watch.sh" +} + +test_hash_pane_stable_and_distinct_via_sbin_md5() ( + local home fakebin out status + home="$TMP_ROOT/sbin-md5" + mkdir -p "$home" + source_watch "$home" + [ -n "$REAL_SBIN_MD5" ] || fail "no BSD-compatible md5 -q binary found on this host" + fakebin=$(make_fakebin "$home/fakebin") + status=0 + out=$(PATH=$fakebin FM_MD5_SBIN_OVERRIDE="$REAL_SBIN_MD5" hash_pane <<<"pane text a") || status=$? + expect_code 0 "$status" "hash_pane exit via the sbin md5 tier" + [ -n "$out" ] || fail "hash_pane produced empty output via the sbin md5 tier" + [ "$out" = "$(PATH=$fakebin FM_MD5_SBIN_OVERRIDE="$REAL_SBIN_MD5" hash_pane <<<"pane text a")" ] \ + || fail "hash_pane is not stable for identical input via the sbin md5 tier" + [ "$out" != "$(PATH=$fakebin FM_MD5_SBIN_OVERRIDE="$REAL_SBIN_MD5" hash_pane <<<"pane text b")" ] \ + || fail "hash_pane produced the same hash for different input via the sbin md5 tier" + pass "hash_pane returns a stable, input-sensitive hash via the sbin md5 tier" +) + +test_hash_pane_falls_back_to_openssl_without_md5_or_md5sum() ( + local home fakebin out expected status + home="$TMP_ROOT/no-md5-no-md5sum" + mkdir -p "$home" + source_watch "$home" + [ -n "$REAL_OPENSSL" ] || fail "openssl not available to exercise the fallback tier" + fakebin=$(make_fakebin "$home/fakebin" "openssl=$REAL_OPENSSL") + status=0 + out=$(PATH=$fakebin FM_MD5_SBIN_OVERRIDE="$home/no-such-md5" hash_pane <<<"pane text a") || status=$? + expect_code 0 "$status" "hash_pane exit with md5/md5sum absent from PATH" + [ -n "$out" ] || fail "hash_pane produced empty output with md5/md5sum absent from PATH" + expected=$(printf 'pane text a\n' | "$REAL_OPENSSL" dgst -md5 -r | "$REAL_CUT" -d' ' -f1) + [ "$out" = "$expected" ] \ + || fail "hash_pane did not use the openssl fallback tier when md5/md5sum are unresolvable (got '$out', wanted '$expected')" + pass "hash_pane falls back to openssl and exits cleanly when md5 and md5sum are absent from PATH" +) + +test_hash_pane_falls_back_past_openssl_to_shasum() ( + local home fakebin out expected status + home="$TMP_ROOT/no-md5-no-openssl" + mkdir -p "$home" + source_watch "$home" + [ -n "$REAL_SHASUM" ] || fail "shasum not available to exercise the fallback tier" + fakebin=$(make_fakebin "$home/fakebin" "shasum=$REAL_SHASUM") + status=0 + out=$(PATH=$fakebin FM_MD5_SBIN_OVERRIDE="$home/no-such-md5" hash_pane <<<"pane text a") || status=$? + expect_code 0 "$status" "hash_pane exit with md5/md5sum/openssl absent from PATH" + [ -n "$out" ] || fail "hash_pane produced empty output with md5/md5sum/openssl absent from PATH" + expected=$(printf 'pane text a\n' | "$REAL_SHASUM" | "$REAL_CUT" -d' ' -f1) + [ "$out" = "$expected" ] \ + || fail "hash_pane did not use the shasum fallback tier past openssl (got '$out', wanted '$expected')" + pass "hash_pane falls back past openssl to shasum and still exits cleanly" +) + +test_hash_pane_never_hard_errors_with_no_hash_tool_at_all() ( + local home fakebin out status + home="$TMP_ROOT/no-hash-tool" + mkdir -p "$home" + source_watch "$home" + fakebin=$(make_fakebin "$home/fakebin") + status=0 + out=$(PATH=$fakebin FM_MD5_SBIN_OVERRIDE="$home/no-such-md5" hash_pane <<<"pane text a") || status=$? + expect_code 0 "$status" "hash_pane exit with no md5/md5sum/openssl/shasum/cksum on PATH" + [ -n "$out" ] || fail "hash_pane produced empty output with no hash tool at all on PATH" + pass "hash_pane never hard-errors even with no hashing tool at all on PATH" +) + +test_hash_pane_stable_and_distinct_via_sbin_md5 +test_hash_pane_falls_back_to_openssl_without_md5_or_md5sum +test_hash_pane_falls_back_past_openssl_to_shasum +test_hash_pane_never_hard_errors_with_no_hash_tool_at_all From 68e6533b6637f707a771a16fb057d337214c67f8 Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Sat, 1 Aug 2026 09:01:20 -0700 Subject: [PATCH 2/2] no-mistakes(document): Document FM_MD5_SBIN_OVERRIDE in configuration.md env var reference --- docs/configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/configuration.md b/docs/configuration.md index a5add630d8..4d10f7d7a2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -439,6 +439,7 @@ FM_BOOTSTRAP_DETECT_ONLY=0 # internal/read-only session-start mode: skip boots FM_GUARD_READ_ONLY=0 # internal/read-only guard mode: keep alarms but suppress drain, supervision repair, and checkout repair commands FM_GUARD_CONTINUE_LINE='This is a supervision warning only; the guarded operation WILL still run.' # banner continuation line; fm-send.sh overrides it to name the requested message specifically FM_POLL=15 # seconds between watcher poll cycles +FM_MD5_SBIN_OVERRIDE=/sbin/md5 # override for hash_pane()'s hardcoded /sbin/md5 fallback tier, mainly for tests; hash_pane also falls back through md5sum, openssl, shasum, and cksum before a wc -c last resort that cannot itself be absent, so a watcher's poll-to-poll pane hashing never hard-errors when PATH lacks md5/md5sum FM_HEARTBEAT=600 # base seconds between heartbeat scans; no-change heartbeats are absorbed while idle FM_HEARTBEAT_MAX=7200 # heartbeat backoff cap FM_CHECK_INTERVAL=300 # seconds between slow checks (authenticated merge polls, custom checks, or X-mode dispatch)