From c725d9fad0e93ed799a8ff3b3e6cb49310e7f56a Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 29 Jul 2026 10:22:19 +0200 Subject: [PATCH 1/2] fix(release): strip Mach-O fully, not just local symbols The first strip landed as `strip -x` on Mach-O, out of caution that a full strip could leave an image dyld will not load. That caution was wrong for this binary, and it cost a release cycle. `-x` retains external symbols -- 4058 of them on darwin-arm64 -- so the macOS artifacts kept the very symbol table the ELF legs had just shed. Run 30414312364 then flagged exactly those two artifacts (darwin-arm64, ui-darwin-amd64) while every Linux and Windows binary came back clean, which read as the detection "moving to macOS" when it had simply stayed on the unstripped set. Measured on the flagged darwin-arm64 artifact: strip -x 4058 symbols VirusTotal 1 malicious / 61 --strip-all 373 symbols VirusTotal 0 malicious / 61 CLEAN The packaged binary still verifies (`codesign --verify --strict` passes, having been re-signed after stripping) and still runs. Symbol-table removal has now cleared the detection on three independent artifacts across two platforms: linux-amd64 twice, and darwin-arm64 here. Signed-off-by: Martin Vogel --- scripts/package-release.sh | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 4f632c9f6..bc2faa76e 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -113,17 +113,21 @@ NAME="codebase-memory-mcp${SUFFIX}-${GOOS}-${GOARCH}" strip_release_binary() { local binary="$1" [ -f "$binary" ] || return 0 + # --strip-all on every format, Mach-O included. + # + # This first shipped as `strip -x` on Mach-O out of caution that a full + # strip could leave an image dyld will not load. That caution was wrong for + # this binary and it cost us a release cycle: -x retains external symbols -- + # 4058 of them -- so the macOS artifacts kept the very symbol table the ELF + # legs had just shed, and they were the only ones VirusTotal then flagged. + # Measured on the flagged darwin-arm64 artifact: --strip-all leaves 373 + # symbols, `codesign --verify` passes, the binary runs, and the scan goes + # from 1 malicious to 0/61 clean. local stripped="" for tool in "${STRIP:-}" llvm-strip strip; do [ -n "$tool" ] || continue command -v "$tool" >/dev/null 2>&1 || continue - if [ "$GOOS" = "darwin" ]; then - # -x keeps external symbols: a full strip of a Mach-O can leave an - # image dyld will not load. - "$tool" -x "$binary" 2>/dev/null && stripped="$tool" - else - "$tool" --strip-all "$binary" 2>/dev/null && stripped="$tool" - fi + "$tool" --strip-all "$binary" 2>/dev/null && stripped="$tool" [ -n "$stripped" ] && break done if [ -z "$stripped" ]; then From 9ecabe0a6fe8d5c641d269b0db0ac36c19183b78 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 29 Jul 2026 10:22:19 +0200 Subject: [PATCH 2/2] refactor(cli): move self-update out of the product on every platform Windows already handed updating to install.ps1, because a running .exe cannot replace its own image. The remaining platforms now do the same, for a different and better reason. An in-process updater is structurally a downloader: it fetches a remote archive, verifies it, extracts it, marks the result executable and runs it. That composite lived in every shipped artifact -- along with the release URLs it fetched -- to serve a command most users run a handful of times. The install scripts already do all of it, are idempotent (so re-running one IS the update), and run while cbm is not running. `update` now prints the exact command for the running platform and exits 0: unix bash "/install.sh" (or the curl one-liner when not adjacent) windows powershell -ExecutionPolicy Bypass -File "\install.ps1" Both scripts ship inside their release archive, so the printed path resolves next to the binary. Flags are still parsed and validated, so `update --dry-run --ui` keeps rejecting typos rather than silently accepting them. The updater itself is EXCLUDED from release builds rather than left to dead-code elimination: build_update_url, download_verify_install, extract_and_install_binary, checksum fetch, detect_os/detect_arch and the cli_download_* helpers now compile only under CBM_CLI_ENABLE_TEST_API, which release builds do not define. The C suite still covers the flow through the activation test seam. Verified against the release binary: `releases/latest/download` is gone entirely. Also removes the MCP background update check. It ran on the first tool call and did: cbm_popen("curl -sf --max-time 5 -H 'Accept: application/vnd.github+json' " "'https://api.github.com/repos/.../releases/latest' 2>/dev/null") An embedded shell command plus a startup network callout, in every session, to tell users a newer version exists. It shelled out, depended on curl being installed, and phoned home from every agent session. The install scripts report versions; this did not need to be in the server. Smoke follows the same collapse. Phases 6c and 14 asserted a Windows-specific handoff and a POSIX in-process replacement; they now assert one platform-neutral contract selected by UPDATE_SCRIPT, and 14a's byte-identity check compares the driver against ITSELF before and after -- comparing against "$BINARY" passed on Windows only because Windows has no signing step, while the POSIX fixture ad-hoc re-signs its copy and so differed before `update` ever ran. The Linux glibc guard is RELOCATED, not retired. The standard linux asset links glibc 2.38+ and breaks Debian 11, RHEL 8 and Ubuntu 20.04, so the installer must fetch the static "-portable" build. That constraint moved from the binary to install.sh, and the smoke now guards it where the behaviour lives. Signed-off-by: Martin Vogel --- scripts/smoke-test.sh | 148 ++++++++++++-------------- src/cli/cli.c | 112 ++++++++++++++----- src/mcp/mcp.c | 126 +--------------------- tests/test_smoke_fixture_contract.sh | 36 +++---- tests/test_windows_bundle_contract.sh | 11 +- 5 files changed, 179 insertions(+), 254 deletions(-) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index f082fc26a..d1d5e0c06 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -927,46 +927,47 @@ fi echo "OK: uninstall --dry-run completed" # 6c: update --dry-run --standard -y +# The product binary never replaces itself on ANY platform. `update` is a +# handoff: it prints the shipped install script's command and exits 0. An +# in-process updater is structurally a downloader -- fetch archive, extract, +# chmod, exec -- which is impossible on Windows without a second resident +# binary, and is the shape Defender's ML scores as a dropper everywhere else. echo "--- Phase 6c: update --dry-run ---" if [[ "$BINARY" == *.exe ]]; then - # Same contract Phase 14a asserts against a real update: Windows never - # replaces the running image in-process, so `update` is a handoff — it exits 0 - # and prints the exact install.ps1 command. The old refusal here was the - # portable-vs-managed split, which died with the launcher stub. - if ! UPDATE_OUT=$(run_dryrun_env "$BINARY" update --dry-run --standard -y 2>&1); then - echo "FAIL: Windows update handoff exited non-zero" - echo "$UPDATE_OUT" - exit 1 - fi - if ! echo "$UPDATE_OUT" | grep -q 'install.ps1'; then - echo "FAIL: Windows update did not print the install.ps1 handoff" - echo "$UPDATE_OUT" - exit 1 - fi + UPDATE_SCRIPT="install.ps1" else - UPDATE_OUT=$(run_dryrun_env "$BINARY" update --dry-run --standard -y 2>&1) - if ! echo "$UPDATE_OUT" | grep -qi 'dry-run'; then - echo "FAIL: update --dry-run did not indicate dry-run mode" - echo "$UPDATE_OUT" - exit 1 - fi - if ! echo "$UPDATE_OUT" | grep -qi 'standard'; then - echo "FAIL: update --dry-run did not respect --standard flag" - exit 1 - fi + UPDATE_SCRIPT="install.sh" fi -# On Linux the binary must self-update from the static "-portable" asset: the -# standard linux asset dynamically links glibc 2.38+ and breaks on older distros -# (Debian 11, RHEL 8, Ubuntu 20.04). Guards build_update_url in src/cli/cli.c. -if [ "$(uname -s)" = "Linux" ]; then - if ! echo "$UPDATE_OUT" | grep -q -- '-portable'; then - echo "FAIL: linux update --dry-run does not target the -portable asset" - echo "$UPDATE_OUT" +if ! UPDATE_OUT=$(run_dryrun_env "$BINARY" update --dry-run --standard -y 2>&1); then + echo "FAIL: update handoff exited non-zero" + echo "$UPDATE_OUT" + exit 1 +fi +if ! echo "$UPDATE_OUT" | grep -q "$UPDATE_SCRIPT"; then + echo "FAIL: update did not print the $UPDATE_SCRIPT handoff" + echo "$UPDATE_OUT" + exit 1 +fi +# A handoff that still fetched something would defeat the entire point. +if echo "$UPDATE_OUT" | grep -qiE 'downloading |releases/latest/download'; then + echo "FAIL: update still performs an in-process download" + echo "$UPDATE_OUT" + exit 1 +fi +echo "OK: update hands off to $UPDATE_SCRIPT without downloading" + +# The glibc constraint did NOT disappear with in-process update -- it moved. The +# standard linux asset dynamically links glibc 2.38+ and breaks on Debian 11, +# RHEL 8 and Ubuntu 20.04, so the installer must fetch the static "-portable" +# build. Guard it where the behaviour now lives instead of retiring the +# protection along with the code that used to implement it. +if [ -f "$REPO_ROOT/install.sh" ]; then + if ! grep -q 'PORTABLE="-portable"' "$REPO_ROOT/install.sh"; then + echo "FAIL: install.sh no longer selects the static -portable Linux asset" exit 1 fi - echo "OK: linux update targets the -portable (static) asset" + echo "OK: install.sh targets the -portable (static) Linux asset" fi -echo "OK: update --dry-run --standard completed" # 6d: config set/get/reset round-trip echo "--- Phase 6d: config set/get/reset ---" @@ -2954,15 +2955,14 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then fi fi - # POSIX runs `update` from the retired image so the in-process replacement is - # exercised end to end. Windows has no in-process replacement: a running .exe - # cannot replace itself, so `update` hands off to install.ps1 (asserted in - # 14a below) and the installed copy drives the later uninstall phases. + # No platform replaces its own image any more, so there is no in-process + # swap left to exercise from a retired copy: every platform drives `update` + # from the installed binary, and the installed copy drives the later + # uninstall phases. if [[ "$BINARY" == *.exe ]]; then UPDATE_DRIVER="$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe" else - RETIRED_DIR=$(cd "$UPDATE_HOME/retired-install" && pwd -P) - UPDATE_DRIVER="$RETIRED_DIR/codebase-memory-mcp" + UPDATE_DRIVER="$UPDATE_HOME/.local/bin/codebase-memory-mcp" fi # Pre-install agent config with positive prior-install identity. POSIX runs @@ -2975,11 +2975,7 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then # make 14f demand that uninstall delete an entry owned by a DIFFERENT # installation, which it correctly refuses to do. install.ps1 re-runs # `install`, so this is exactly what a real Windows user is left holding. - if [[ "$BINARY" == *.exe ]]; then - STALE_CMD="$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe" - else - STALE_CMD="$UPDATE_DRIVER" - fi + STALE_CMD="$UPDATE_DRIVER" if command -v cygpath &>/dev/null; then STALE_CMD=$(cygpath -m "$STALE_CMD") fi @@ -2993,30 +2989,34 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then UPDATE_VARIANT="--ui" fi UPDATE_LOG=$(smoke_mktemp_file) + # Hash the driver BEFORE the run and compare it against itself afterwards. + # Comparing against "$BINARY" instead looks equivalent but is not: the POSIX + # fixture ad-hoc re-signs its copy on macOS, so the two differ before `update` + # is ever invoked and the assertion fires on a difference the fixture created. + UPDATE_BIN_SHA_BEFORE=$(smoke_file_sha256 "$UPDATE_DRIVER") HOME="$UPDATE_HOME" CBM_DOWNLOAD_URL="$UPDATE_DOWNLOAD_URL" \ "$UPDATE_DRIVER" update $UPDATE_VARIANT -y > "$UPDATE_LOG" 2>&1 UPDATE_RC=$? cat "$UPDATE_LOG" - if [[ "$BINARY" == *.exe ]]; then - # Windows contract: update NEVER replaces the running image in-process. It - # exits 0 and prints the install.ps1 command. Regressing to an in-process - # self-update means reintroducing the AV-flagged launcher stub. - if [ "$UPDATE_RC" -ne 0 ]; then - echo "FAIL 14a: Windows update exited rc=$UPDATE_RC (expected 0)" - exit 1 - fi - if ! grep -q "install.ps1" "$UPDATE_LOG"; then - echo "FAIL 14a: Windows update did not print the install.ps1 command" - exit 1 - fi - if [ "$(smoke_file_sha256 "$BINARY")" != \ - "$(smoke_file_sha256 "$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe")" ]; then - echo "FAIL 14a: Windows update replaced the binary in-process" - exit 1 - fi - echo "OK 14a: Windows update handed off to install.ps1 without touching the binary" + # Contract, every platform: update NEVER replaces the running image in + # process. It exits 0 and prints the shipped install script's command. On + # Windows regressing this means reintroducing the AV-flagged launcher stub; + # everywhere else it means putting download -> extract -> chmod -> exec back + # into the product binary. + if [ "$UPDATE_RC" -ne 0 ]; then + echo "FAIL 14a: update exited rc=$UPDATE_RC (expected 0)" + exit 1 fi + if ! grep -q "$UPDATE_SCRIPT" "$UPDATE_LOG"; then + echo "FAIL 14a: update did not print the $UPDATE_SCRIPT command" + exit 1 + fi + if [ "$UPDATE_BIN_SHA_BEFORE" != "$(smoke_file_sha256 "$UPDATE_DRIVER")" ]; then + echo "FAIL 14a: update replaced the binary in-process" + exit 1 + fi + echo "OK 14a: update handed off to $UPDATE_SCRIPT without touching the binary" rm -f "$UPDATE_LOG" # 14b: Verify new binary exists and runs @@ -3038,25 +3038,11 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then fi echo "OK 14b: updated binary runs" - # 14c: Verify agent config was refreshed to the exact installed binary. - # Windows has no in-process update, so there is no config refresh to assert: - # install.ps1 re-runs `install`, which is covered by Phase 8 and Phase 13. - if [[ "$BINARY" == *.exe ]]; then - echo "SKIP 14c: Windows update hands off to install.ps1 (config refresh covered by install)" - else - 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 "") - EXPECTED_UPD_CMD="$UPD_BIN" - if command -v cygpath &>/dev/null; then - EXPECTED_UPD_CMD=$(cygpath -w "$UPD_BIN") - fi - if [ "$UPD_CMD" != "$EXPECTED_UPD_CMD" ]; then - echo "FAIL 14c: agent config does not point at the updated binary" - echo " expected: $EXPECTED_UPD_CMD" - echo " actual: ${UPD_CMD:-}" - exit 1 - fi - echo "OK 14c: agent config refreshed (path=$UPD_CMD)" - fi + # 14c: there is no in-process update on any platform now, so there is no + # config refresh for this phase to assert. The install script re-runs + # `install`, which performs the refresh and is covered by Phase 8 (agent + # config install E2E) and Phase 13 (install script E2E). + echo "SKIP 14c: update hands off to $UPDATE_SCRIPT (config refresh covered by install)" # ── 14d-f: Real uninstall with binary removal ── # First verify binary + configs exist diff --git a/src/cli/cli.c b/src/cli/cli.c index 5db30c73a..04393d101 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -6759,6 +6759,7 @@ int cbm_cli_checksum_manifest_digest(const char *manifest_path, const char *arch /* ── Download helper (shell-free curl via exec) ───────────────── */ +#ifdef CBM_CLI_ENABLE_TEST_API static bool cli_download_is_explicit_file_override(const char *url) { char override_buffer[CLI_BUF_512]; const char *override = @@ -6782,6 +6783,7 @@ static const char *cli_download_protocol(const char *url) { return NULL; } +/* Download primitives: update was their only caller. */ static int cbm_download_to_file(const char *url, const char *dest) { const char *protocol = cli_download_protocol(url); if (!protocol || !dest) { @@ -6810,6 +6812,8 @@ static int cbm_download_to_file_quiet(const char *url, const char *dest) { return cbm_exec_no_shell(argv); } +#endif /* CBM_CLI_ENABLE_TEST_API */ + /* ── macOS ad-hoc signing ─────────────────────────────────────── */ #ifdef __APPLE__ @@ -6824,6 +6828,7 @@ static int cbm_macos_adhoc_sign(const char *binary_path) { } #endif +#ifdef CBM_CLI_ENABLE_TEST_API /* Download checksums.txt and verify the archive integrity. Every non-zero * result is a fail-closed refusal; verification is never optional. */ static int verify_download_checksum(const char *archive_path, const char *archive_name) { @@ -6902,8 +6907,11 @@ static int verify_download_checksum(const char *archive_path, const char *archiv return 0; } +#endif /* CBM_CLI_ENABLE_TEST_API */ + /* ── Detect OS/arch for download URL ──────────────────────────── */ +#ifdef CBM_CLI_ENABLE_TEST_API static const char *detect_os(void) { #ifdef _WIN32 return "windows"; @@ -6922,6 +6930,8 @@ static const char *detect_arch(void) { #endif } +#endif /* CBM_CLI_ENABLE_TEST_API */ + /* ── Agent config install/refresh (shared by install + update) ── */ static void print_detected_registry_agents(const char *home, bool *any); @@ -10739,6 +10749,7 @@ typedef struct { bool delete_indexes; } extract_install_args_t; +#ifdef CBM_CLI_ENABLE_TEST_API typedef struct { const char *bin_dest; const char *home; @@ -10942,6 +10953,13 @@ static int extract_and_install_binary(extract_install_args_t args) { return activation_rc == CLI_OK ? CLI_OK : CLI_TRUE; } +#endif /* CBM_CLI_ENABLE_TEST_API */ + +#ifdef CBM_CLI_ENABLE_TEST_API +/* Update-only helpers. The release build hands updating to the install + * script, so none of this ships: no release URL construction, no archive + * download, no extract-and-exec. Retained for the C suite, which still + * covers the flow through the activation test seam. */ /* Build the download URL for the update command. */ static void build_update_url(char *url, int url_sz, const char *os, const char *arch, const char *ext, bool want_ui) { @@ -11148,6 +11166,8 @@ static bool check_already_latest(void) { return false; } +#endif /* CBM_CLI_ENABLE_TEST_API */ + int cbm_cmd_update(int argc, char **argv) { parse_auto_answer(argc, argv); @@ -11170,53 +11190,88 @@ int cbm_cmd_update(int argc, char **argv) { } } -#ifdef _WIN32 - /* Windows updates run from install.ps1, not from this process. + /* Updates run from the install script, not from this process — on every + * platform. * - * A running .exe cannot replace itself on Windows — the image lock refuses - * the replace — so an in-process updater needs a second, permanently - * resident binary to swap the first one out. That launcher stub (small, - * unsigned, doing nothing but verify-and-execute) is exactly the shape - * Defender's ML scores as a dropper, and on x64 no build variant escaped - * it. Handing the swap to a script that runs while we are NOT running - * removes the constraint and the stub together: one binary per platform, - * matching Linux and macOS. + * Windows forced the split first: a running .exe cannot replace itself, so + * an in-process updater needed a second resident binary to swap the first + * one out, and that launcher stub was exactly the shape Defender's ML + * scores as a dropper. * - * install.ps1 ships beside the binary and is idempotent, so re-running it - * IS the update. Print the exact command instead of feigning self-update. */ - bool update_seam_portable = false; -#ifdef CBM_CLI_ENABLE_TEST_API - update_seam_portable = g_cli_activation_test_ops_set; - if (update_seam_portable) { - (void)fprintf(stderr, "*** cbm test seam: portable update flow engaged; the Windows " - "script-update handoff is bypassed (test builds only) ***\n"); - } + * The rest followed for the same reason rather than a different one. An + * in-process updater is, structurally, a downloader: it fetches a remote + * archive, extracts it, marks the result executable and runs it. That is + * the behaviour Microsoft's Wacatac family describes almost verbatim, and + * carrying it in the product binary put download/extract/chmod/exec in + * every shipped artifact for a command most users run a handful of times. + * + * The install script already does all of it, is idempotent -- so re-running + * it IS the update -- and runs while cbm is NOT running. Print the exact + * command instead of feigning self-update. */ +#ifndef CBM_CLI_ENABLE_TEST_API + /* A release build has nothing to do but hand off. The flags are still + * parsed and validated above, so `update --dry-run` and friends keep + * rejecting typos instead of silently accepting them. */ + (void)dry_run; + (void)force; + (void)variant_flag; #endif - if (!update_seam_portable) { +#ifdef CBM_CLI_ENABLE_TEST_API + if (g_cli_activation_test_ops_set) { + (void)fprintf(stderr, "*** cbm test seam: portable update flow engaged; the " + "script-update handoff is bypassed (test builds only) ***\n"); + } else +#endif + { + char self_dir[CLI_BUF_1K] = {0}; + bool have_dir = false; +#ifdef _WIN32 /* Native separators on purpose: this path is printed for the user to * paste into PowerShell verbatim. */ - char self_exe[CLI_BUF_1K] = {0}; - DWORD self_len = GetModuleFileNameA(NULL, self_exe, (DWORD)sizeof(self_exe)); + DWORD self_len = GetModuleFileNameA(NULL, self_dir, (DWORD)sizeof(self_dir)); char *last_sep = - (self_len > 0 && (size_t)self_len < sizeof(self_exe)) ? strrchr(self_exe, '\\') : NULL; + (self_len > 0 && (size_t)self_len < sizeof(self_dir)) ? strrchr(self_dir, '\\') : NULL; +#else + char *last_sep = cbm_detect_self_path(self_dir, sizeof(self_dir), cbm_get_home_dir()) + ? strrchr(self_dir, '/') + : NULL; +#endif if (last_sep) { *last_sep = '\0'; + have_dir = true; } printf("codebase-memory-mcp update (current: %s)\n\n", CBM_VERSION); - printf("On Windows the update runs from install.ps1: a running executable\n" - "cannot replace itself. Close any running sessions, then run\n\n"); - if (last_sep) { - printf(" powershell -ExecutionPolicy Bypass -File \"%s\\install.ps1\"\n\n", self_exe); +#ifdef _WIN32 + printf("The update runs from install.ps1, not from this process. Close any\n" + "running sessions, then run\n\n"); + if (have_dir) { + printf(" powershell -ExecutionPolicy Bypass -File \"%s\\install.ps1\"\n\n", self_dir); } else { printf(" powershell -ExecutionPolicy Bypass -File install.ps1\n\n"); } printf("It downloads the latest release, verifies its checksum, and replaces\n" "this binary in place. If PowerShell refuses to run the script because\n" "it came from the internet, Unblock-File it first.\n"); +#else + printf("The update runs from install.sh, not from this process. Run\n\n"); + if (have_dir) { + printf(" bash \"%s/install.sh\"\n\n", self_dir); + } else { + printf(" curl -fsSL https://raw.githubusercontent.com/DeusData/" + "codebase-memory-mcp/main/install.sh | bash\n\n"); + } + printf("It downloads the latest release, verifies its checksum, and replaces\n" + "this binary in place. install.sh is idempotent, so re-running it IS\n" + "the update; pass --ui for the UI build.\n"); +#endif return 0; } -#endif + /* Everything below is the in-process updater and is excluded from release + * builds entirely -- that exclusion, not dead-code elimination, is what + * keeps download/extract/chmod/exec and the release URLs out of the + * shipped artifact. */ +#ifdef CBM_CLI_ENABLE_TEST_API const char *home = cbm_get_home_dir(); if (!home) { (void)fprintf(stderr, "error: HOME not set (use USERPROFILE on Windows)\n"); @@ -11308,6 +11363,7 @@ int cbm_cmd_update(int argc, char **argv) { "properly take this into account.\n"); (void)variant; return 0; +#endif /* CBM_CLI_ENABLE_TEST_API */ } /* ── CLI tool arguments (flags / --args-file / --help) ────────────── */ diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index d891e7dd9..c35934edf 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1431,14 +1431,10 @@ bool cbm_mcp_get_bool_arg(const char *args_json, const char *key) { * ══════════════════════════════════════════════════════════════════ */ struct cbm_mcp_server { - cbm_store_t *store; /* currently open project store (or NULL) */ - bool owns_store; /* true if we opened the store */ - char *current_project; /* which project store is open for (heap) */ - time_t store_last_used; /* last time resolve_store was called for a named project */ - char update_notice[CBM_SZ_256]; /* one-shot update notice, cleared after first injection */ - bool update_checked; /* true after background check has been launched */ - cbm_thread_t update_tid; /* background update check thread */ - bool update_thread_active; /* true if update thread was started and needs joining */ + cbm_store_t *store; /* currently open project store (or NULL) */ + bool owns_store; /* true if we opened the store */ + char *current_project; /* which project store is open for (heap) */ + time_t store_last_used; /* last time resolve_store was called for a named project */ /* Session + auto-index state */ char session_root[CBM_SZ_1K]; /* detected project root path */ @@ -1622,9 +1618,6 @@ void cbm_mcp_server_free(cbm_mcp_server_t *srv) { if (!srv) { return; } - if (srv->update_thread_active) { - cbm_thread_join(&srv->update_tid); - } if (srv->autoindex_active) { cbm_thread_join(&srv->autoindex_tid); } @@ -10642,115 +10635,6 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) { } } -/* ── Background update check ──────────────────────────────────── */ - -#define UPDATE_CHECK_URL "https://api.github.com/repos/DeusData/codebase-memory-mcp/releases/latest" - -static void *update_check_thread(void *arg) { - cbm_mcp_server_t *srv = (cbm_mcp_server_t *)arg; - - /* Use curl with 5s timeout to fetch latest release tag */ - FILE *fp = cbm_popen("curl -sf --max-time 5 -H 'Accept: application/vnd.github+json' " - "'" UPDATE_CHECK_URL "' 2>/dev/null", - "r"); - if (!fp) { - srv->update_checked = true; - return NULL; - } - - char buf[CBM_SZ_4K]; - size_t total = 0; - while (total < sizeof(buf) - SKIP_ONE) { - size_t n = fread(buf + total, SKIP_ONE, sizeof(buf) - SKIP_ONE - total, fp); - if (n == 0) { - break; - } - total += n; - } - buf[total] = '\0'; - cbm_pclose(fp); - - /* Parse tag_name from JSON response */ - yyjson_doc *doc = yyjson_read(buf, total, 0); - if (!doc) { - srv->update_checked = true; - return NULL; - } - - yyjson_val *root = yyjson_doc_get_root(doc); - yyjson_val *tag = yyjson_obj_get(root, "tag_name"); - const char *tag_str = yyjson_get_str(tag); - - if (tag_str) { - const char *current = cbm_cli_get_version(); - if (cbm_compare_versions(tag_str, current) > 0) { - snprintf(srv->update_notice, sizeof(srv->update_notice), - "Update available: %s -> %s -- run: codebase-memory-mcp update | " - "Enjoying codebase-memory-mcp? Please leave a star: " - "https://github.com/DeusData/codebase-memory-mcp", - current, tag_str); - cbm_log_info("update.available", "current", current, "latest", tag_str); - } - } - - yyjson_doc_free(doc); - srv->update_checked = true; - return NULL; -} - -static void start_update_check(cbm_mcp_server_t *srv) { - if (srv->update_checked) { - return; - } - srv->update_checked = true; /* prevent double-launch */ - if (cbm_thread_create(&srv->update_tid, 0, update_check_thread, srv) == 0) { - srv->update_thread_active = true; - } -} - -/* Prepend update notice to a tool result, then clear it (one-shot). */ -static char *inject_update_notice(cbm_mcp_server_t *srv, char *result_json) { - if (srv->update_notice[0] == '\0') { - return result_json; - } - - /* Parse existing result, prepend notice text, rebuild */ - yyjson_doc *doc = yyjson_read(result_json, strlen(result_json), 0); - if (!doc) { - return result_json; - } - - yyjson_mut_doc *mdoc = yyjson_mut_doc_new(NULL); - yyjson_mut_val *root = yyjson_val_mut_copy(mdoc, yyjson_doc_get_root(doc)); - yyjson_doc_free(doc); - if (!root) { - yyjson_mut_doc_free(mdoc); - return result_json; - } - yyjson_mut_doc_set_root(mdoc, root); - - /* Find the "content" array */ - yyjson_mut_val *content = yyjson_mut_obj_get(root, "content"); - if (content && yyjson_mut_is_arr(content)) { - /* Prepend a text content item with the update notice */ - yyjson_mut_val *notice_item = yyjson_mut_obj(mdoc); - yyjson_mut_obj_add_str(mdoc, notice_item, "type", "text"); - yyjson_mut_obj_add_str(mdoc, notice_item, "text", srv->update_notice); - yyjson_mut_arr_prepend(content, notice_item); - } - - size_t len; - char *new_json = yyjson_mut_write(mdoc, YYJSON_WRITE_ALLOW_INVALID_UNICODE, &len); - yyjson_mut_doc_free(mdoc); - - if (new_json) { - free(result_json); - srv->update_notice[0] = '\0'; /* clear — one-shot */ - return new_json; - } - return result_json; -} - /* ── Server request handler ───────────────────────────────────── */ bool cbm_mcp_jsonrpc_response_prepend_notice(char **response_io, const char *notice) { @@ -10830,7 +10714,6 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { result_json = cbm_mcp_initialize_response_for_profile(req.params_raw, srv->tool_profile); detect_session(srv); if (srv->background_tasks && srv->tool_profile == CBM_MCP_TOOL_PROFILE_ALL) { - start_update_check(srv); maybe_auto_index(srv); } } else if (strcmp(req.method, "ping") == 0) { @@ -10873,7 +10756,6 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { cbm_log_mcp_request(req.method, tool_name, is_err, request_dur_us); request_logged = true; - result_json = inject_update_notice(srv, result_json); free(tool_name); free(tool_args); } else { diff --git a/tests/test_smoke_fixture_contract.sh b/tests/test_smoke_fixture_contract.sh index c4ba81094..111b5c7cd 100755 --- a/tests/test_smoke_fixture_contract.sh +++ b/tests/test_smoke_fixture_contract.sh @@ -325,33 +325,29 @@ require( and "invalid Windows PATH smoke seam fell back" in smoke_test, "Windows release smoke must prove malformed PATH-test gating fails closed", ) +# There is no in-process update left to refresh the MCP command, so Phase 14 +# cannot assert a refresh. The refresh itself still happens -- the install +# script re-runs `install` -- and is covered by Phase 8 (agent config install +# E2E) and Phase 13 (install script E2E). Phase 14 must say so rather than +# quietly dropping the step. require( - 'if [ "$UPD_CMD" != "$EXPECTED_UPD_CMD" ]' in smoke_test, - "Phase 14 must require the refreshed MCP command to equal the updated binary", + "config refresh covered by install" in smoke_test, + "Phase 14 must name where the config-refresh coverage moved to", ) +# The retired-image driver existed to exercise an in-process replacement that no +# platform performs any more: `update` prints the shipped install script's +# command and touches nothing. Phase 14 now drives from the installed binary +# everywhere, and 14a asserts the binary is byte-identical afterwards. require( - 'UPDATE_DRIVER="$RETIRED_DIR/codebase-memory-mcp"' in smoke_test + 'UPDATE_DRIVER="$UPDATE_HOME/.local/bin/codebase-memory-mcp"' in smoke_test and 'STALE_CMD="$UPDATE_DRIVER"' in smoke_test, - "POSIX Phase 14 must refresh from positive running-image identity without probing config paths", + "Phase 14 must drive update from the installed binary on every platform", ) -# This used to pin the retired path so Windows Phase 14 carried a config entry -# naming a missing executable. Two things retired that intent. The fixture now -# COPIES a binary to the retired path, so it stopped being missing regardless of -# what this string says -- the requirement was only ever checking the string, -# never the property. And Windows update is a handoff to install.ps1 now, so -# nothing rewrites this entry in-process the way the launcher-managed update -# did: an entry naming a foreign path simply survives to uninstall, which -# correctly refuses to remove a config entry owned by a DIFFERENT installation, -# and 14f would then be demanding the one thing uninstall must never do. -# -# The missing-executable classification lives on in named unit tests instead of -# here: cli_editor_mcp_preserves_unrecorded_posix_absolute_entries_without_probe -# and cli_editor_mcp_preserves_unsafe_windows_drive_probe (tests/test_cli.c), -# the latter covering the Windows missing-drive case specifically. require( - 'STALE_CMD="$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe"' in smoke_test, - "Windows Phase 14 must seed the MCP command at the binary uninstall will own", + "update replaced the binary in-process" in smoke_test, + "Phase 14 must assert update leaves the binary byte-identical", ) + for changed_path in ( "install\\.(sh|ps1)", "scripts/smoke-local", diff --git a/tests/test_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh index 532462d1b..e3382206d 100644 --- a/tests/test_windows_bundle_contract.sh +++ b/tests/test_windows_bundle_contract.sh @@ -656,10 +656,15 @@ require( and 'CBM_DOWNLOAD_URL="$UPDATE_DOWNLOAD_URL"' in smoke_script, "Phase 14 native update must use an explicit file:// fixture override", ) +# The handoff contract is no longer Windows-specific: no platform replaces its +# own image in process, so Phase 14 asserts one platform-neutral contract and +# selects the script name via UPDATE_SCRIPT. Windows still has the strictest +# reason for it -- regressing here means reintroducing the launcher stub. require( - "FAIL 14a: Windows update replaced the binary in-process" in smoke_script - and 'grep -q "install.ps1" "$UPDATE_LOG"' in smoke_script, - "Phase 14 must assert the Windows update handoff instead of an in-process replacement", + "FAIL 14a: update replaced the binary in-process" in smoke_script + and 'grep -q "$UPDATE_SCRIPT" "$UPDATE_LOG"' in smoke_script + and 'UPDATE_SCRIPT="install.ps1"' in smoke_script, + "Phase 14 must assert the update handoff instead of an in-process replacement", ) require( 'HOME="$WIN_HOME" TEMP="$WIN_HOME" TMP="$WIN_HOME"' in smoke_script