From 537a8df6e7300b801f745bb4309ac03a39e75b12 Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Mon, 28 Sep 2026 06:45:01 -0400 Subject: [PATCH 1/3] fix(tests): disable Claude Code's auto-updater during live harness runs fm_live_gate let a live run proceed without ever setting DISABLE_AUTOUPDATER, so a live Claude test could let the real updater repoint ~/.local/bin/claude into a temporary directory and stop every Claude process on the machine from starting. Export DISABLE_AUTOUPDATER=1 on every path where the gate lets a live run proceed, and assert the export in tests/fm-live-gate.test.sh, including that it reaches a child process the same way a real harness pane would inherit it. --- tests/fm-live-gate.test.sh | 44 ++++++++++++++++++++++++++++++++++++++ tests/lib.sh | 5 +++++ 2 files changed, 49 insertions(+) diff --git a/tests/fm-live-gate.test.sh b/tests/fm-live-gate.test.sh index c2e3b4e1ca1..2fe4187cb72 100755 --- a/tests/fm-live-gate.test.sh +++ b/tests/fm-live-gate.test.sh @@ -179,6 +179,46 @@ test_gate_lets_a_guard_drive_the_real_fleet_scripts_under_a_gate_marker() { "the shared gate must carry the test-suite bypass so a live guard can drive the real fleet scripts" } +test_gate_exports_disable_autoupdater_for_a_proceeding_run() { + local path out rc + path="$TMP_ROOT/proceed-autoupdater.test.sh" + { + printf '#!/usr/bin/env bash\nset -u\n' + printf '. "%s/tests/lib.sh"\n' "$ROOT" + printf 'fm_live_gate default-on FM_FAKE_LIVE fmfakeharness\n' + # shellcheck disable=SC2016 # the written guard script expands this at its own runtime, not here + printf 'printf "autoupdater=%%s\\n" "${DISABLE_AUTOUPDATER:-unset}"\n' + } > "$path" + chmod +x "$path" + set +e + out=$(clean_env PATH="$BIN:/usr/bin:/bin" "$path" 2>&1) + rc=$? + set -e + expect_code 0 "$rc" "a proceeding guard must exit cleanly" + assert_contains "$out" "autoupdater=1" \ + "a live run the gate lets proceed must export DISABLE_AUTOUPDATER=1 so Claude Code's auto-updater cannot run" +} + +test_disable_autoupdater_reaches_a_child_process_the_guard_spawns() { + local path out rc + path="$TMP_ROOT/pane.test.sh" + { + printf '#!/usr/bin/env bash\nset -u\n' + printf '. "%s/tests/lib.sh"\n' "$ROOT" + printf 'fm_live_gate default-on FM_FAKE_LIVE fmfakeharness\n' + printf 'bash -c %s\n' \ + "'printf \"pane:%s\\n\" \"\${DISABLE_AUTOUPDATER:-unset}\"'" + } > "$path" + chmod +x "$path" + set +e + out=$(clean_env PATH="$BIN:/usr/bin:/bin" "$path" 2>&1) + rc=$? + set -e + expect_code 0 "$rc" "a guard opened with the shared gate must run its spawned child cleanly" + assert_contains "$out" "pane:1" \ + "a live run's DISABLE_AUTOUPDATER export must reach a child process it spawns, the same inheritance a real harness pane relies on" +} + test_every_live_guard_is_wired_to_the_shared_gate() { local script out listing checked=0 listing=$("$ROOT/bin/fm-test-run.sh" --family live-harness-optin --list) \ @@ -217,4 +257,8 @@ test_any_of_several_entry_points_turns_a_guard_on pass "any entry point of a multi-mode guard turns it on" test_gate_lets_a_guard_drive_the_real_fleet_scripts_under_a_gate_marker pass "the shared gate carries the gate-refusal bypass into every live guard" +test_gate_exports_disable_autoupdater_for_a_proceeding_run +pass "a proceeding live run exports DISABLE_AUTOUPDATER=1" +test_disable_autoupdater_reaches_a_child_process_the_guard_spawns +pass "DISABLE_AUTOUPDATER reaches a child process the way a real harness pane would inherit it" test_every_live_guard_is_wired_to_the_shared_gate diff --git a/tests/lib.sh b/tests/lib.sh index 68acd735148..0267f841277 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -320,6 +320,10 @@ fi # lets a live guard drive the real fm-spawn/fm-send/fm-teardown from inside a # no-mistakes gate worktree instead of being refused by # bin/fm-gate-refuse-lib.sh. +# +# Every path that lets a live run proceed also exports DISABLE_AUTOUPDATER=1, +# so a live harness invocation never lets Claude Code's auto-updater rewrite +# the installed binary out from under the host. fm_live_gate() { local policy=$1 vars=$2 @@ -383,6 +387,7 @@ fm_live_gate() { exit 0 done + export DISABLE_AUTOUPDATER=1 return 0 } From 11879d7b0678b26e9d33a0c0e20ccf1ad4fb465e Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Mon, 28 Sep 2026 07:47:36 -0400 Subject: [PATCH 2/3] no-mistakes(ci): Greptile flagged that the PR's DISABLE_AUTOUPDATER inheritance test only checked a `bash -c` direct child, not the fm-spawn.sh launch path. The user chose to fix it with a regression on that path. In tests/fm-live-gate.test.sh I replaced the generic child test with test_disable_autoupdater_reaches_the_claude_pane_on_the_fm_spawn_launch_path: it drives the real fm-spawn claude launch through the spawn fixtures, captures the exact staged launch command, and runs it as a synthetic pane whose only `claude` is a stub recording the inherited DISABLE_AUTOUPDATER, asserting it saw 1. Switched the file to source fixtures.sh (pulls in lib.sh, guarded) for the spawn helpers. Verified it is a real guard: the stub records `1` when the ambient var is set and `unset` when absent, so it fails if fm-spawn ever scrubbed the variable (e.g. env -i or -u). This confirms fm-spawn's launch construction never references the name and passes it through via ordinary ambient inheritance with no allowlist. Full suite passes (12 tests ok), shellcheck clean. Note for the outer executor: I embedded the daemon caveat as a code comment in the test, but the finding also asks the PR body to state that a backend daemon already running before the gate exported the variable does not inherit it and fully covering that would need launcher support - that forge-side PR-body sentence is outside this CI phase's scope --- tests/fm-live-gate.test.sh | 60 ++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/tests/fm-live-gate.test.sh b/tests/fm-live-gate.test.sh index 2fe4187cb72..48622b5a13b 100755 --- a/tests/fm-live-gate.test.sh +++ b/tests/fm-live-gate.test.sh @@ -15,8 +15,8 @@ # cheap because a disabled gate exits before a guard touches a harness. set -u -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=tests/fixtures.sh +. "$(dirname "${BASH_SOURCE[0]}")/fixtures.sh" TMP_ROOT=$(fm_test_tmproot fm-live-gate) BIN="$TMP_ROOT/bin" @@ -199,24 +199,46 @@ test_gate_exports_disable_autoupdater_for_a_proceeding_run() { "a live run the gate lets proceed must export DISABLE_AUTOUPDATER=1 so Claude Code's auto-updater cannot run" } -test_disable_autoupdater_reaches_a_child_process_the_guard_spawns() { - local path out rc - path="$TMP_ROOT/pane.test.sh" - { - printf '#!/usr/bin/env bash\nset -u\n' - printf '. "%s/tests/lib.sh"\n' "$ROOT" - printf 'fm_live_gate default-on FM_FAKE_LIVE fmfakeharness\n' - printf 'bash -c %s\n' \ - "'printf \"pane:%s\\n\" \"\${DISABLE_AUTOUPDATER:-unset}\"'" - } > "$path" - chmod +x "$path" +test_disable_autoupdater_reaches_the_claude_pane_on_the_fm_spawn_launch_path() { + # The gate exports DISABLE_AUTOUPDATER=1 into the ambient environment; this + # proves fm-spawn's claude launch construction preserves that ambient value + # all the way to the harness process, the inheritance a real pane relies on. + # It stages a real claude launch, then runs that exact command as a synthetic + # pane whose only claude is a stub recording the variable it inherited. (A + # backend daemon already running before the gate exported the variable is a + # separate case this cannot cover without launcher support.) + local case_dir home proj wt fakebin launchlog panebin panelog launch rc + case_dir="$TMP_ROOT/spawn-launch-path" + home="$case_dir/home"; proj="$case_dir/proj"; wt="$case_dir/wt" + launchlog="$case_dir/launch.log" + fakebin=$(make_spawn_fakebin "$case_dir/fake" gh gh-axi) + fm_test_spawn_home "$home" claude + fm_git_worktree "$proj" "$wt" "wt-autoupdater" + fm_test_spawn_brief "$home" AU-1 + : > "$launchlog" + FM_FAKE_LAUNCH_LOG="$launchlog" \ + fm_test_run_spawn "$home" "$wt" "$fakebin" AU-1 "$proj" --mode no-mistakes --yolo off \ + >/dev/null 2>&1 || fail "the claude spawn must stage its launch command" + launch=$(cat "$launchlog") + [ -n "$launch" ] || fail "no claude launch command was captured" + + # Synthetic pane: only claude is a recording stub, and DISABLE_AUTOUPDATER=1 + # stands in for the value the live gate put in the ambient environment. + panebin="$case_dir/panebin"; mkdir -p "$panebin" + panelog="$case_dir/pane-autoupdater.log" + cat > "$panebin/claude" < "$panelog" +exit 0 +SH + chmod +x "$panebin/claude" set +e - out=$(clean_env PATH="$BIN:/usr/bin:/bin" "$path" 2>&1) + DISABLE_AUTOUPDATER=1 PATH="$panebin:/usr/bin:/bin" bash -c "$launch" >/dev/null 2>&1 rc=$? set -e - expect_code 0 "$rc" "a guard opened with the shared gate must run its spawned child cleanly" - assert_contains "$out" "pane:1" \ - "a live run's DISABLE_AUTOUPDATER export must reach a child process it spawns, the same inheritance a real harness pane relies on" + expect_code 0 "$rc" "the staged claude launch must run cleanly in the synthetic pane" + assert_contains "$(cat "$panelog")" "autoupdater=1" \ + "fm-spawn's claude launch must pass the ambient DISABLE_AUTOUPDATER through to the harness pane, so the auto-updater cannot run" } test_every_live_guard_is_wired_to_the_shared_gate() { @@ -259,6 +281,6 @@ test_gate_lets_a_guard_drive_the_real_fleet_scripts_under_a_gate_marker pass "the shared gate carries the gate-refusal bypass into every live guard" test_gate_exports_disable_autoupdater_for_a_proceeding_run pass "a proceeding live run exports DISABLE_AUTOUPDATER=1" -test_disable_autoupdater_reaches_a_child_process_the_guard_spawns -pass "DISABLE_AUTOUPDATER reaches a child process the way a real harness pane would inherit it" +test_disable_autoupdater_reaches_the_claude_pane_on_the_fm_spawn_launch_path +pass "DISABLE_AUTOUPDATER rides fm-spawn's claude launch through to the harness pane" test_every_live_guard_is_wired_to_the_shared_gate From cc99904935dab1556318739e40375815d4a977f9 Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Mon, 28 Sep 2026 09:38:39 -0400 Subject: [PATCH 3/3] no-mistakes(ci): Fixed Greptile finding ci-1. Root cause: fm-spawn.sh handed its launch command to an already-running backend daemon that never inherited the test process's exported DISABLE_AUTOUPDATER, so ambient inheritance dropped it and Claude's auto-updater could still run. Fix (bin/fm-spawn.sh): when DISABLE_AUTOUPDATER is set in the spawn's own environment, embed `export DISABLE_AUTOUPDATER=;` into the LAUNCH command text (same idiom as the adjacent COMPACT_ADVISER_DISABLE export), so it survives a daemon-built pane, the env -i allowlist path, and relaunch alike; gated on presence so ordinary spawns are unchanged. Added regression test test_disable_autoupdater_survives_a_daemon_pane_that_never_inherited_it in tests/fm-live-gate.test.sh: stages a real claude launch with DISABLE_AUTOUPDATER set in the spawn env, then runs that exact command in a synthetic pane with `env -u DISABLE_AUTOUPDATER` and asserts the claude stub still recorded autoupdater=1. Verified the test fails (autoupdater=unset) without the fix and passes with it; the round-1 ambient test stays green either way. Full suite passes (13 ok); test file and isolated snippet shellcheck-clean (full fm-spawn.sh shellcheck kept getting terminated by the memory-constrained host, not by findings). Forge-side note for the outer executor: the PR-body caveat that fully covering the daemon case would need launcher support no longer applies to the Claude launch path and should be corrected --- bin/fm-spawn.sh | 10 +++++++++ tests/fm-live-gate.test.sh | 45 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 705ac0f98fd..1e0d4dd6be4 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -5165,6 +5165,16 @@ if [ "$LAVISH_AXI_HOST_CONFIG_PRESENT" = 1 ]; then LAUNCH="export LAVISH_AXI_HOST=$(shell_quote "$LAVISH_AXI_HOST"); $LAUNCH" fi LAUNCH="export COMPACT_ADVISER_DISABLE=1; $LAUNCH" +# When the live-harness gate has exported DISABLE_AUTOUPDATER into this spawn's +# own environment, carry it into the launch command text so Claude Code's +# auto-updater cannot rewrite the shared binary during a live run. Embedding the +# assignment - like COMPACT_ADVISER_DISABLE above - rather than leaning on +# ambient inheritance is what survives a pre-existing backend daemon that +# constructs the pane command without the gate's environment. It is gated on the +# value being set here so ordinary spawns are unchanged. +if [ -n "${DISABLE_AUTOUPDATER:-}" ]; then + LAUNCH="export DISABLE_AUTOUPDATER=$(shell_quote "$DISABLE_AUTOUPDATER"); $LAUNCH" +fi if [ -z "$SPAWN_TRACEPARENT" ] && [ "$RELAUNCH" -eq 1 ]; then LAUNCH="unset TRACEPARENT; $LAUNCH" fi diff --git a/tests/fm-live-gate.test.sh b/tests/fm-live-gate.test.sh index 48622b5a13b..60be00cb4d0 100755 --- a/tests/fm-live-gate.test.sh +++ b/tests/fm-live-gate.test.sh @@ -241,6 +241,49 @@ SH "fm-spawn's claude launch must pass the ambient DISABLE_AUTOUPDATER through to the harness pane, so the auto-updater cannot run" } +test_disable_autoupdater_survives_a_daemon_pane_that_never_inherited_it() { + # The finding: a live test exports DISABLE_AUTOUPDATER, but the pane is created + # by an already-running backend daemon that does not inherit the test process's + # environment, so ambient inheritance alone drops it and Claude's updater runs. + # This stages a real claude launch with DISABLE_AUTOUPDATER set in the spawn's + # own environment, then runs that exact command in a synthetic pane whose + # environment lacks the variable (standing in for the daemon). Claude must still + # see it, which only holds if fm-spawn embedded the assignment into the launch + # command text rather than relying on the pane inheriting it. + local case_dir home proj wt fakebin launchlog panebin panelog launch rc + case_dir="$TMP_ROOT/spawn-daemon-path" + home="$case_dir/home"; proj="$case_dir/proj"; wt="$case_dir/wt" + launchlog="$case_dir/launch.log" + fakebin=$(make_spawn_fakebin "$case_dir/fake" gh gh-axi) + fm_test_spawn_home "$home" claude + fm_git_worktree "$proj" "$wt" "wt-daemon-autoupdater" + fm_test_spawn_brief "$home" AU-2 + : > "$launchlog" + DISABLE_AUTOUPDATER=1 FM_FAKE_LAUNCH_LOG="$launchlog" \ + fm_test_run_spawn "$home" "$wt" "$fakebin" AU-2 "$proj" --mode no-mistakes --yolo off \ + >/dev/null 2>&1 || fail "the claude spawn must stage its launch command" + launch=$(cat "$launchlog") + [ -n "$launch" ] || fail "no claude launch command was captured" + + panebin="$case_dir/panebin"; mkdir -p "$panebin" + panelog="$case_dir/pane-autoupdater.log" + cat > "$panebin/claude" < "$panelog" +exit 0 +SH + chmod +x "$panebin/claude" + # The synthetic daemon-launched pane runs the staged command with the variable + # absent from its own environment; env -u strips any value the suite inherited. + set +e + env -u DISABLE_AUTOUPDATER PATH="$panebin:/usr/bin:/bin" bash -c "$launch" >/dev/null 2>&1 + rc=$? + set -e + expect_code 0 "$rc" "the staged claude launch must run cleanly in the synthetic pane" + assert_contains "$(cat "$panelog")" "autoupdater=1" \ + "fm-spawn must embed DISABLE_AUTOUPDATER in the launch command so a daemon-built pane that never inherited it still runs Claude with the updater off" +} + test_every_live_guard_is_wired_to_the_shared_gate() { local script out listing checked=0 listing=$("$ROOT/bin/fm-test-run.sh" --family live-harness-optin --list) \ @@ -283,4 +326,6 @@ test_gate_exports_disable_autoupdater_for_a_proceeding_run pass "a proceeding live run exports DISABLE_AUTOUPDATER=1" test_disable_autoupdater_reaches_the_claude_pane_on_the_fm_spawn_launch_path pass "DISABLE_AUTOUPDATER rides fm-spawn's claude launch through to the harness pane" +test_disable_autoupdater_survives_a_daemon_pane_that_never_inherited_it +pass "DISABLE_AUTOUPDATER is embedded in the launch so a daemon-built pane keeps it" test_every_live_guard_is_wired_to_the_shared_gate