diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index c0e27b1e21c..eb1859d68d4 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -24,10 +24,30 @@ fm_pid_alive() { } fm_pid_identity() { - local pid=$1 out + local pid=$1 out proc_root stat_line starttime cmdline_hex + local -a stat_fields case "$pid" in ''|*[!0-9]*) return 1 ;; esac + proc_root=${FM_PROC_ROOT_OVERRIDE:-/proc} + # Prefer /proc on Linux: stat field 22 (starttime, clock ticks since boot) is + # immune to the wall-clock steps that re-render the ps lstart fallback's date + # (observed as WSL2 btime drift) and would evict a live watcher; combining the + # full NUL-separated cmdline keeps PID reuse a mismatch even on a tick collision. + if [ "$(uname)" = Linux ] && [ -r "$proc_root/$pid/stat" ] && [ -r "$proc_root/$pid/cmdline" ]; then + stat_line=$(cat "$proc_root/$pid/stat" 2>/dev/null) || return 1 + # After the final comm delimiter, array index 19 is proc stat field 22. + read -r -a stat_fields <<< "${stat_line##*)}" + [ "${#stat_fields[@]}" -ge 20 ] || return 1 + starttime=${stat_fields[19]} + case "$starttime" in + ''|*[!0-9]*) return 1 ;; + esac + cmdline_hex=$(od -An -v -tx1 "$proc_root/$pid/cmdline" 2>/dev/null | tr -d '[:space:]') || return 1 + [ -n "$cmdline_hex" ] || return 1 + printf 'linux-starttime=%s cmdline-hex=%s\n' "$starttime" "$cmdline_hex" + return 0 + fi # Pin LC_ALL=C so lstart's date format is locale-invariant: the identity is # written under one locale but re-read under the machine's ambient locale, which # would otherwise mismatch on a non-C locale (e.g. ko_KR) and reject a live watcher. diff --git a/docs/configuration.md b/docs/configuration.md index bbe5ca4f355..a23ebb03b93 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -348,6 +348,7 @@ FM_STATE_OVERRIDE= # alternate state dir, mainly for tests FM_DATA_OVERRIDE= # alternate data dir, mainly for tests FM_PROJECTS_OVERRIDE= # alternate projects dir, mainly for tests FM_CONFIG_OVERRIDE= # alternate config dir, mainly for tests +FM_PROC_ROOT_OVERRIDE= # alternate /proc root for the Linux process-identity read in fm-wake-lib.sh, mainly for tests FM_BACKEND= # optional runtime backend override for new spawns; tmux/herdr/zellij/orca/cmux support ship/scout spawns, codex-app is not accepted HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md) FM_BACKEND_HERDR_COMPOSER_LINES=20 # herdr-only: tail lines scanned by composer-state guard/fallback paths; idle-baseline submit confirmation uses agent-state diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index 9d4154e505c..705f765dd9a 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -147,7 +147,7 @@ write_watcher_lock() { local state=$1 home=$2 pid=$3 identity rm -rf "$state/.watch.lock" mkdir "$state/.watch.lock" - identity=$(LC_ALL=C ps -p "$pid" -o lstart= -o command= 2>/dev/null | sed 's/^[[:space:]]*//') + identity=$(FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$ROOT/bin/fm-wake-lib.sh" "$pid") [ -n "$identity" ] || fail "could not capture fake older-watcher identity" printf '%s\n' "$pid" > "$state/.watch.lock/pid" printf '%s\n' "$home" > "$state/.watch.lock/fm-home" diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index 8a077f84335..4de8e4b0ff6 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -887,20 +887,20 @@ test_stopped_watcher_is_live_but_stale_then_exit_is_classified() { } test_pid_identity_is_locale_invariant() { - # The watcher records its process identity under one locale; arm/guard/turn-end - # re-read it under the machine's ambient locale. ps's lstart date format follows - # LC_TIME, so an unpinned read on a non-C locale (e.g. ko_KR) would differ only - # in the date portion and reject a genuinely live watcher. The fix pins LC_ALL=C - # inside fm_pid_identity, so its output must be byte-identical regardless of the - # caller's exported LC_ALL/LC_TIME. That invariant holds on any host because the - # pin is internal, so this stays deterministic on CI even where an alternate + # The portable fallback records its process identity under one locale, then + # arm/guard/turn-end re-read it under the machine's ambient locale. ps's lstart + # date format follows LC_TIME, so an unpinned read on a non-C locale (e.g. ko_KR) + # would reject a genuinely live watcher. The fallback pins LC_ALL=C inside + # fm_pid_identity, so its output must be byte-identical regardless of the caller's + # exported LC_ALL/LC_TIME. This stays deterministic on CI even where an alternate # locale like ko_KR.UTF-8 is not installed (the equality then holds trivially). - local live baseline via_lc_all via_lc_time + local live no_proc baseline via_lc_all via_lc_time sleep 300 & live=$! - baseline=$(LC_ALL=C bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) - via_lc_all=$(LC_ALL=ko_KR.UTF-8 bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) - via_lc_time=$(LC_TIME=ko_KR.UTF-8 bash -c 'unset LC_ALL; . "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + no_proc="$TMP_ROOT/no-proc" + baseline=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=C bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + via_lc_all=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=ko_KR.UTF-8 bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + via_lc_time=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_TIME=ko_KR.UTF-8 bash -c 'unset LC_ALL; . "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) kill "$live" 2>/dev/null || true wait "$live" 2>/dev/null || true [ -n "$baseline" ] || fail "fm_pid_identity produced no baseline identity under LC_ALL=C" @@ -909,8 +909,49 @@ test_pid_identity_is_locale_invariant() { pass "fm_pid_identity is locale-invariant across LC_ALL/LC_TIME" } +write_fake_proc_identity() { + local proc_root=$1 pid=$2 starttime=$3 + mkdir -p "$proc_root/$pid" + printf '%s\n' "$pid (watcher ) with spaces) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 $starttime 20 21 22" > "$proc_root/$pid/stat" + printf 'bash\0/path with spaces/fm-watch.sh\0--flag\0' > "$proc_root/$pid/cmdline" +} + +test_linux_pid_identity_ignores_wall_clock_and_detects_pid_reuse() { + local dir state proc_root pid before after_time_jump after_pid_reuse + [ "$(uname)" = Linux ] || { + pass "Linux process identity clock-step regression skipped on non-Linux host" + return + } + dir=$(make_case linux-pid-identity) + state="$dir/state" + proc_root="$dir/proc" + pid=4242 + mkdir -p "$proc_root" + printf 'btime 1784094040\n' > "$proc_root/stat" + write_fake_proc_identity "$proc_root" "$pid" 987654 + + before=$(FM_PROC_ROOT_OVERRIDE="$proc_root" FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$pid") \ + || fail "could not read initial fake Linux process identity" + printf 'btime 1784094016\n' > "$proc_root/stat" + after_time_jump=$(FM_PROC_ROOT_OVERRIDE="$proc_root" FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$pid") \ + || fail "could not re-read fake Linux process identity after btime change" + + [ "$after_time_jump" = "$before" ] \ + || fail "Linux process identity changed with btime (before '$before', after '$after_time_jump')" + [ "$before" = 'linux-starttime=987654 cmdline-hex=62617368002f706174682077697468207370616365732f666d2d77617463682e7368002d2d666c616700' ] \ + || fail "Linux process identity did not combine parsed starttime field 22 with the full cmdline ('$before')" + pass "Linux process identity ignores simulated btime changes" + + write_fake_proc_identity "$proc_root" "$pid" 987655 + after_pid_reuse=$(FM_PROC_ROOT_OVERRIDE="$proc_root" FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$pid") \ + || fail "could not read reused fake Linux pid identity" + [ "$after_pid_reuse" != "$before" ] || fail "Linux process identity missed changed starttime for reused pid" + pass "Linux process identity detects pid reuse" +} + test_singleton_start test_pid_identity_is_locale_invariant +test_linux_pid_identity_ignores_wall_clock_and_detects_pid_reuse test_stale_watch_lock_reclaimed test_live_stale_watch_lock_is_actionable test_guard_warnings