Skip to content

Commit 3e2f9a0

Browse files
authored
Merge pull request #1332 from DeusData/fix/no-inprocess-update
refactor(cli): move self-update to the install scripts; strip Mach-O fully
2 parents 560ad40 + 9ecabe0 commit 3e2f9a0

6 files changed

Lines changed: 191 additions & 262 deletions

File tree

scripts/package-release.sh

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,17 +113,21 @@ NAME="codebase-memory-mcp${SUFFIX}-${GOOS}-${GOARCH}"
113113
strip_release_binary() {
114114
local binary="$1"
115115
[ -f "$binary" ] || return 0
116+
# --strip-all on every format, Mach-O included.
117+
#
118+
# This first shipped as `strip -x` on Mach-O out of caution that a full
119+
# strip could leave an image dyld will not load. That caution was wrong for
120+
# this binary and it cost us a release cycle: -x retains external symbols --
121+
# 4058 of them -- so the macOS artifacts kept the very symbol table the ELF
122+
# legs had just shed, and they were the only ones VirusTotal then flagged.
123+
# Measured on the flagged darwin-arm64 artifact: --strip-all leaves 373
124+
# symbols, `codesign --verify` passes, the binary runs, and the scan goes
125+
# from 1 malicious to 0/61 clean.
116126
local stripped=""
117127
for tool in "${STRIP:-}" llvm-strip strip; do
118128
[ -n "$tool" ] || continue
119129
command -v "$tool" >/dev/null 2>&1 || continue
120-
if [ "$GOOS" = "darwin" ]; then
121-
# -x keeps external symbols: a full strip of a Mach-O can leave an
122-
# image dyld will not load.
123-
"$tool" -x "$binary" 2>/dev/null && stripped="$tool"
124-
else
125-
"$tool" --strip-all "$binary" 2>/dev/null && stripped="$tool"
126-
fi
130+
"$tool" --strip-all "$binary" 2>/dev/null && stripped="$tool"
127131
[ -n "$stripped" ] && break
128132
done
129133
if [ -z "$stripped" ]; then

scripts/smoke-test.sh

Lines changed: 67 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -927,46 +927,47 @@ fi
927927
echo "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.
930935
echo "--- Phase 6c: update --dry-run ---"
931936
if [[ "$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"
946938
else
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
957945
fi
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"
968970
fi
969-
echo "OK: update --dry-run --standard completed"
970971

971972
# 6d: config set/get/reset round-trip
972973
echo "--- 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

Comments
 (0)