Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 113 additions & 2 deletions tests/fm-live-gate.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" <<SH
#!/usr/bin/env bash
printf 'autoupdater=%s\n' "\${DISABLE_AUTOUPDATER:-unset}" > "$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" <<SH
#!/usr/bin/env bash
printf 'autoupdater=%s\n' "\${DISABLE_AUTOUPDATER:-unset}" > "$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) \
Expand Down Expand Up @@ -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
5 changes: 5 additions & 0 deletions tests/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -383,6 +387,7 @@ fm_live_gate() {
exit 0
done

export DISABLE_AUTOUPDATER=1
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return 0
}

Expand Down
Loading