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 "<dir>/install.sh"     (or the curl one-liner when not adjacent)
  windows  powershell -ExecutionPolicy Bypass -File "<dir>\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 <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-07-29 10:22:19 +02:00
parent c725d9fad0
commit 9ecabe0a6f
5 changed files with 179 additions and 254 deletions
+67 -81
View File
@@ -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:-<missing>}"
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
+84 -28
View File
@@ -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) ────────────── */
+4 -122
View File
@@ -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 {
+16 -20
View File
@@ -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",
+8 -3
View File
@@ -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