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 c2e3b4e1ca1..60be00cb4d0 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" @@ -179,6 +179,111 @@ 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_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 + DISABLE_AUTOUPDATER=1 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'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) \ @@ -217,4 +322,10 @@ 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_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 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 }