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 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 +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 -# 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 ! 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; + * 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 #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 " + 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 - if (!update_seam_portable) { + { + 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", -) -# 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", + "Phase 14 must drive update from the installed binary on every platform", ) +require( + "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", ) -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", +# 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: 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