mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-16 05:10:08 +08:00
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:
+67
-81
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user