927927echo " OK: uninstall --dry-run completed"
928928
929929# 6c: update --dry-run --standard -y
930+ # The product binary never replaces itself on ANY platform. `update` is a
931+ # handoff: it prints the shipped install script's command and exits 0. An
932+ # in-process updater is structurally a downloader -- fetch archive, extract,
933+ # chmod, exec -- which is impossible on Windows without a second resident
934+ # binary, and is the shape Defender's ML scores as a dropper everywhere else.
930935echo " --- Phase 6c: update --dry-run ---"
931936if [[ " $BINARY " == * .exe ]]; then
932- # Same contract Phase 14a asserts against a real update: Windows never
933- # replaces the running image in-process, so `update` is a handoff — it exits 0
934- # and prints the exact install.ps1 command. The old refusal here was the
935- # portable-vs-managed split, which died with the launcher stub.
936- if ! UPDATE_OUT=$( run_dryrun_env " $BINARY " update --dry-run --standard -y 2>&1 ) ; then
937- echo " FAIL: Windows update handoff exited non-zero"
938- echo " $UPDATE_OUT "
939- exit 1
940- fi
941- if ! echo " $UPDATE_OUT " | grep -q ' install.ps1' ; then
942- echo " FAIL: Windows update did not print the install.ps1 handoff"
943- echo " $UPDATE_OUT "
944- exit 1
945- fi
937+ UPDATE_SCRIPT=" install.ps1"
946938else
947- UPDATE_OUT=$( run_dryrun_env " $BINARY " update --dry-run --standard -y 2>&1 )
948- if ! echo " $UPDATE_OUT " | grep -qi ' dry-run' ; then
949- echo " FAIL: update --dry-run did not indicate dry-run mode"
950- echo " $UPDATE_OUT "
951- exit 1
952- fi
953- if ! echo " $UPDATE_OUT " | grep -qi ' standard' ; then
954- echo " FAIL: update --dry-run did not respect --standard flag"
955- exit 1
956- fi
939+ UPDATE_SCRIPT=" install.sh"
940+ fi
941+ if ! UPDATE_OUT=$( run_dryrun_env " $BINARY " update --dry-run --standard -y 2>&1 ) ; then
942+ echo " FAIL: update handoff exited non-zero"
943+ echo " $UPDATE_OUT "
944+ exit 1
957945fi
958- # On Linux the binary must self-update from the static "-portable" asset: the
959- # standard linux asset dynamically links glibc 2.38+ and breaks on older distros
960- # (Debian 11, RHEL 8, Ubuntu 20.04). Guards build_update_url in src/cli/cli.c.
961- if [ " $( uname -s) " = " Linux" ]; then
962- if ! echo " $UPDATE_OUT " | grep -q -- ' -portable' ; then
963- echo " FAIL: linux update --dry-run does not target the -portable asset"
964- echo " $UPDATE_OUT "
946+ if ! echo " $UPDATE_OUT " | grep -q " $UPDATE_SCRIPT " ; then
947+ echo " FAIL: update did not print the $UPDATE_SCRIPT handoff"
948+ echo " $UPDATE_OUT "
949+ exit 1
950+ fi
951+ # A handoff that still fetched something would defeat the entire point.
952+ if echo " $UPDATE_OUT " | grep -qiE ' downloading |releases/latest/download' ; then
953+ echo " FAIL: update still performs an in-process download"
954+ echo " $UPDATE_OUT "
955+ exit 1
956+ fi
957+ echo " OK: update hands off to $UPDATE_SCRIPT without downloading"
958+
959+ # The glibc constraint did NOT disappear with in-process update -- it moved. The
960+ # standard linux asset dynamically links glibc 2.38+ and breaks on Debian 11,
961+ # RHEL 8 and Ubuntu 20.04, so the installer must fetch the static "-portable"
962+ # build. Guard it where the behaviour now lives instead of retiring the
963+ # protection along with the code that used to implement it.
964+ if [ -f " $REPO_ROOT /install.sh" ]; then
965+ if ! grep -q ' PORTABLE="-portable"' " $REPO_ROOT /install.sh" ; then
966+ echo " FAIL: install.sh no longer selects the static -portable Linux asset"
965967 exit 1
966968 fi
967- echo " OK: linux update targets the -portable (static) asset"
969+ echo " OK: install.sh targets the -portable (static) Linux asset"
968970fi
969- echo " OK: update --dry-run --standard completed"
970971
971972# 6d: config set/get/reset round-trip
972973echo " --- Phase 6d: config set/get/reset ---"
@@ -2954,15 +2955,14 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then
29542955 fi
29552956 fi
29562957
2957- # POSIX runs `update` from the retired image so the in-process replacement is
2958- # exercised end to end. Windows has no in-process replacement: a running .exe
2959- # cannot replace itself, so `update` hands off to install.ps1 (asserted in
2960- # 14a below) and the installed copy drives the later uninstall phases.
2958+ # No platform replaces its own image any more, so there is no in-process
2959+ # swap left to exercise from a retired copy: every platform drives `update`
2960+ # from the installed binary, and the installed copy drives the later
2961+ # uninstall phases.
29612962 if [[ " $BINARY " == * .exe ]]; then
29622963 UPDATE_DRIVER=" $UPDATE_HOME /.local/bin/codebase-memory-mcp.exe"
29632964 else
2964- RETIRED_DIR=$( cd " $UPDATE_HOME /retired-install" && pwd -P)
2965- UPDATE_DRIVER=" $RETIRED_DIR /codebase-memory-mcp"
2965+ UPDATE_DRIVER=" $UPDATE_HOME /.local/bin/codebase-memory-mcp"
29662966 fi
29672967
29682968 # Pre-install agent config with positive prior-install identity. POSIX runs
@@ -2975,11 +2975,7 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then
29752975 # make 14f demand that uninstall delete an entry owned by a DIFFERENT
29762976 # installation, which it correctly refuses to do. install.ps1 re-runs
29772977 # `install`, so this is exactly what a real Windows user is left holding.
2978- if [[ " $BINARY " == * .exe ]]; then
2979- STALE_CMD=" $UPDATE_HOME /.local/bin/codebase-memory-mcp.exe"
2980- else
2981- STALE_CMD=" $UPDATE_DRIVER "
2982- fi
2978+ STALE_CMD=" $UPDATE_DRIVER "
29832979 if command -v cygpath & > /dev/null; then
29842980 STALE_CMD=$( cygpath -m " $STALE_CMD " )
29852981 fi
@@ -2993,30 +2989,34 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then
29932989 UPDATE_VARIANT=" --ui"
29942990 fi
29952991 UPDATE_LOG=$( smoke_mktemp_file)
2992+ # Hash the driver BEFORE the run and compare it against itself afterwards.
2993+ # Comparing against "$BINARY" instead looks equivalent but is not: the POSIX
2994+ # fixture ad-hoc re-signs its copy on macOS, so the two differ before `update`
2995+ # is ever invoked and the assertion fires on a difference the fixture created.
2996+ UPDATE_BIN_SHA_BEFORE=$( smoke_file_sha256 " $UPDATE_DRIVER " )
29962997 HOME=" $UPDATE_HOME " CBM_DOWNLOAD_URL=" $UPDATE_DOWNLOAD_URL " \
29972998 " $UPDATE_DRIVER " update $UPDATE_VARIANT -y > " $UPDATE_LOG " 2>&1
29982999 UPDATE_RC=$?
29993000 cat " $UPDATE_LOG "
30003001
3001- if [[ " $BINARY " == * .exe ]]; then
3002- # Windows contract: update NEVER replaces the running image in-process. It
3003- # exits 0 and prints the install.ps1 command. Regressing to an in-process
3004- # self-update means reintroducing the AV-flagged launcher stub.
3005- if [ " $UPDATE_RC " -ne 0 ]; then
3006- echo " FAIL 14a: Windows update exited rc=$UPDATE_RC (expected 0)"
3007- exit 1
3008- fi
3009- if ! grep -q " install.ps1" " $UPDATE_LOG " ; then
3010- echo " FAIL 14a: Windows update did not print the install.ps1 command"
3011- exit 1
3012- fi
3013- if [ " $( smoke_file_sha256 " $BINARY " ) " != \
3014- " $( smoke_file_sha256 " $UPDATE_HOME /.local/bin/codebase-memory-mcp.exe" ) " ]; then
3015- echo " FAIL 14a: Windows update replaced the binary in-process"
3016- exit 1
3017- fi
3018- echo " OK 14a: Windows update handed off to install.ps1 without touching the binary"
3002+ # Contract, every platform: update NEVER replaces the running image in
3003+ # process. It exits 0 and prints the shipped install script's command. On
3004+ # Windows regressing this means reintroducing the AV-flagged launcher stub;
3005+ # everywhere else it means putting download -> extract -> chmod -> exec back
3006+ # into the product binary.
3007+ if [ " $UPDATE_RC " -ne 0 ]; then
3008+ echo " FAIL 14a: update exited rc=$UPDATE_RC (expected 0)"
3009+ exit 1
3010+ fi
3011+ if ! grep -q " $UPDATE_SCRIPT " " $UPDATE_LOG " ; then
3012+ echo " FAIL 14a: update did not print the $UPDATE_SCRIPT command"
3013+ exit 1
30193014 fi
3015+ if [ " $UPDATE_BIN_SHA_BEFORE " != " $( smoke_file_sha256 " $UPDATE_DRIVER " ) " ]; then
3016+ echo " FAIL 14a: update replaced the binary in-process"
3017+ exit 1
3018+ fi
3019+ echo " OK 14a: update handed off to $UPDATE_SCRIPT without touching the binary"
30203020 rm -f " $UPDATE_LOG "
30213021
30223022 # 14b: Verify new binary exists and runs
@@ -3038,25 +3038,11 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then
30383038 fi
30393039 echo " OK 14b: updated binary runs"
30403040
3041- # 14c: Verify agent config was refreshed to the exact installed binary.
3042- # Windows has no in-process update, so there is no config refresh to assert:
3043- # install.ps1 re-runs `install`, which is covered by Phase 8 and Phase 13.
3044- if [[ " $BINARY " == * .exe ]]; then
3045- echo " SKIP 14c: Windows update hands off to install.ps1 (config refresh covered by install)"
3046- else
3047- UPD_CMD=$( cat " $UPDATE_HOME /.claude.json" 2> /dev/null | python3 -c " import json,sys; d=json.load(sys.stdin); print(d.get('mcpServers',{}).get('codebase-memory-mcp',{}).get('command',''))" 2> /dev/null || echo " " )
3048- EXPECTED_UPD_CMD=" $UPD_BIN "
3049- if command -v cygpath & > /dev/null; then
3050- EXPECTED_UPD_CMD=$( cygpath -w " $UPD_BIN " )
3051- fi
3052- if [ " $UPD_CMD " != " $EXPECTED_UPD_CMD " ]; then
3053- echo " FAIL 14c: agent config does not point at the updated binary"
3054- echo " expected: $EXPECTED_UPD_CMD "
3055- echo " actual: ${UPD_CMD:- <missing>} "
3056- exit 1
3057- fi
3058- echo " OK 14c: agent config refreshed (path=$UPD_CMD )"
3059- fi
3041+ # 14c: there is no in-process update on any platform now, so there is no
3042+ # config refresh for this phase to assert. The install script re-runs
3043+ # `install`, which performs the refresh and is covered by Phase 8 (agent
3044+ # config install E2E) and Phase 13 (install script E2E).
3045+ echo " SKIP 14c: update hands off to $UPDATE_SCRIPT (config refresh covered by install)"
30603046
30613047 # ── 14d-f: Real uninstall with binary removal ──
30623048 # First verify binary + configs exist
0 commit comments