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
+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 {