Files
codebase-memory-mcp/tests/windows/test_windows_update_handoff.py
Martin Vogel 8eabe191d2 fix(install,daemon): unbreak npx clients, group-writable homes, and legacy updaters
Five field reports in the 24 hours after v0.10.0 all pointed at the same thing:
gates that were right in principle refused real, ordinary setups, and then
failed to say why. Per the consolidated strictness decision, each gate keeps the
protection that matters and drops the part that was refusing legitimate users —
and every refusal now names what it refused and how to proceed.

**Daemon image gate: npx and every ephemeral install path (#1539, #1383).**
The admission check treated "the peer's image hashes differently" and "the
peer's image cannot be examined at all" as one failure. The second is what
`npx codebase-memory-mcp` always produces (ephemeral cache path,
unfingerprintable), so every npx-invoked client was rejected — and, because the
client never reported it, agents saw a transport that closed mid-handshake with
zero bytes on stdout. Reported by @wassolles with the admission path already
read and the fix space mapped.

An unverifiable image is now admitted: the rendezvous HELLO immediately above it
has already proven semantic version, build fingerprint, and protocol/store/
feature ABI, and the image check was trading that real proof for an unavailable
one. It logs daemon.client_image_unverifiable_admitted so the weaker check is
never invisible. A fingerprint MISMATCH — the tamper case the gate exists for —
still rejects hard. Separate test seams keep the two modes testable apart.

**Client bootstrap failures are no longer silent (#1539).**
An MCP client that cannot reach the daemon now emits a JSON-RPC error on stdout
naming the reason, plus the same text on stderr. Previously the reason sat in
bootstrap_result.message and the process exited having written nothing at all.

**POSIX activation: group-writable ancestors (#1535, discussion #1526).**
activation_directory_secure required no group or other write bit on the install
directory AND every ancestor. WSL2 ships ~ and ~/.local at 0775, as do several
distro skeletons and any site using a shared primary group, so install.sh failed
for a large fraction of Linux users — reporting a policy refusal as "activation
transaction I/O failed", which sent reporters after disk errors and filesystem
types. Root-caused by @AmirF194 in a clean ubuntu container; @shochdoerfer and
@iandol confirmed independently.

World-writable ancestors are still refused (any local user could swap a path
component mid-transaction). Group-writable ancestors are now warned about and
admitted. The LEAF directory stays strictly owner-private — that is where the
binary is published, and group write there would let another account replace the
executable between validation and exec. Refusals now name the directory, its
mode, and which rule refused.

**The obsolete ui/standard chooser (#1538, from discussion #1526).**
v0.10.0 consolidated to one archive per platform with the UI always embedded,
but `update` still offered a variant choice: "ui" could only 404, and "standard"
quietly WAS the UI build. Reported by @iandol upgrading 0.9.0 -> 0.10.0. The
chooser, its --standard/--ui flags, and the ui- URL plumbing are removed, along
with the CBM_VARIANT=ui remnant in the npm installer.

Already-released 0.9.x binaries cannot be fixed retroactively, so the release
workflow now publishes byte-identical ui-*-named alias assets — their updaters
work again with no user action. The aliases are uploaded AFTER the VirusTotal
gate: they are the same bytes as archives it already cleared, and uploading them
earlier would duplicate every object in the scan set and the provenance manifest.

**macOS install noise and attribution (#1537).**
install.sh silenced the "No such xattr: com.apple.quarantine" line, which is
what happens when a curl-downloaded archive carries no quarantine attribute —
harmless, and it became the title of a bug report about an unrelated failure.
The session-stop refusal now points at `daemon status` to list the client
processes actually holding the daemon, instead of asserting sessions exist and
leaving the reader to guess. Reported by @listepo.

**Riders.** hatchling is pinned in pkg/pypi (an unpinned backend resolved fresh
inside `python -m build` is what emitted Metadata-Version 2.5 and broke the
v0.10.1 publish); SECURITY.md's supported-versions table moves to 0.10.x.

Tests: separate seams for unverifiable vs mismatched peer images with a test per
outcome; activation refusal must name directory + mode + rule; a group-writable
ancestor must stage successfully. The update tests drop the flag that no longer
exists. Verified against each reporter's environment shape.

**Open security alerts (all three, OSSF Scorecard).**
- HIGH, binary artifact: an 8.8 MB compiled Go ELF wrapper had been committed at
  pkg/go/codebase-memory-mcp by accident. Removed, and both it and its .exe
  sibling are gitignored so `go build` in that directory cannot repeat it.
- HIGH, GHSA-2v37-7h3g-55p8: nanoid < 3.3.17 loops forever when a custom
  generator is called with size 0. It reaches us transitively (postcss -> vite),
  so it is pinned through the existing graph-ui overrides block rather than
  promoted to a direct dependency; the lockfile resolves 3.3.18.
- MEDIUM, unpinned pip command: the publish step installed build/twine by
  version only, leaving the whole transitive graph resolved at run time.
  pkg/pypi/requirements-publish.txt now hash-pins the complete toolchain (316
  hashes), generated on a linux/amd64 python:3.12 image so the wheels match what
  ubuntu-latest resolves, and the step runs pip with --require-hashes. Verified
  by installing from it in that same image.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-11 13:37:32 +02:00

213 lines
7.3 KiB
Python

"""GREEN native-Windows guard for the `update` -> install.ps1 handoff.
Windows ships one executable in its runtime set, exactly like Linux and macOS:
``codebase-memory-mcp.exe``.
There used to be a second, permanently resident launcher stub whose only job
was to swap the product binary out from under itself, because a running .exe
cannot replace its own image on Windows. Historical variants of that stub
received Microsoft Wacatac verdicts. Those observations did not expose a
stable feature or prove causation, but the stub remained unnecessary loader-
like behavior and a second artifact to audit. The swap therefore moved OUT of
the process into install.ps1, which runs while CBM is NOT running.
This guard asserts the replacement contract on real native Windows:
* ``update`` exits 0 and prints the exact install.ps1 command.
* ``update`` NEVER replaces the running image in-process — the executable's
bytes are unchanged, and no launcher/payload sibling appears next to it.
* ``update`` refuses to reach the network first: it hands off before it
consults CBM_DOWNLOAD_URL, so it stays fast even when that URL is a black
hole.
* ``update`` does not disturb an already-open MCP/daemon session.
A regression here means the in-process self-update and removed launcher stub
came back.
Exit code: 0 == contract honored, 1 == regression, 2 == precondition failure.
Usage:
python test_windows_update_handoff.py <codebase-memory-mcp.exe>
"""
import hashlib
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from mcp_stdio import McpError, McpServer # noqa: E402
class GuardFailure(Exception):
pass
def require(condition, message):
if not condition:
raise GuardFailure(message)
def sha256_file(path):
return hashlib.sha256(pathlib.Path(path).read_bytes()).hexdigest()
def output_text(result):
return ((result.stdout or b"") + b"\n" + (result.stderr or b"")).decode(
"utf-8", "replace"
)
def run(command, env, timeout=30):
try:
return subprocess.run(
[str(part) for part in command],
input=b"",
capture_output=True,
env=env,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
raise GuardFailure(
"command exceeded %ss: %s" % (timeout, " ".join(map(str, command)))
) from exc
def isolated_environment(work):
home = work / "home"
cache = work / "cache"
home.mkdir(parents=True)
cache.mkdir(parents=True)
env = dict(os.environ)
env.update(
{
"HOME": str(home),
"USERPROFILE": str(home),
"APPDATA": str(home / "AppData" / "Roaming"),
"LOCALAPPDATA": str(home / "AppData" / "Local"),
"CBM_CACHE_DIR": str(cache),
"PYTHONUTF8": "1",
}
)
return env, cache
def copy_binary(source, directory):
directory.mkdir(parents=True, exist_ok=True)
binary = directory / "codebase-memory-mcp.exe"
shutil.copy2(source, binary)
return binary
def assert_update_hands_off_to_install_script(source, env, work):
binary = copy_binary(source, work / "update-handoff")
before = sha256_file(binary)
command_env = dict(env)
# If the handoff regresses into a real in-process update, keep its
# unintended network path deterministic and fast: a correct implementation
# never consults this URL.
command_env["CBM_DOWNLOAD_URL"] = "https://127.0.0.1:1"
# Warm the freshly-copied cold binary first: first-touch antivirus scanning
# of the just-written image inflates process load time, and the handoff
# itself is a fast STATELESS local print (no daemon IPC, no download) whose
# timing is what this guard measures. The warm-up behaves identically.
run([binary, "update", "--yes"], command_env, timeout=20)
started = time.monotonic()
result = run([binary, "update", "--yes"], command_env, timeout=20)
elapsed = time.monotonic() - started
diagnostic = output_text(result)
lowered = diagnostic.lower()
require(
result.returncode == 0,
"update exited %s; the Windows handoff must succeed: %s"
% (result.returncode, diagnostic[-800:]),
)
require(
"install.ps1" in lowered,
"update did not print the install.ps1 command: %s" % diagnostic[-800:],
)
require(
"powershell" in lowered,
"update did not print a runnable PowerShell command: %s" % diagnostic[-800:],
)
require(
elapsed < 8.0,
"update took %.1fs — it must hand off before any network I/O" % elapsed,
)
require(
sha256_file(binary) == before,
"update replaced the running image in-process (a running .exe cannot "
"replace itself; that is exactly what the removed launcher stub was for)",
)
require(
not (binary.parent / "codebase-memory-mcp.payload.exe").exists(),
"update recreated a launcher/payload pair beside the binary",
)
require(
not list(binary.parent.glob("*launcher*")),
"update produced a launcher artifact beside the binary",
)
print("PASS: update handed off to install.ps1 without touching its own image")
def assert_update_does_not_drain_active_session(source, env, cache, work):
binary = copy_binary(source, work / "update-session")
with McpServer(str(binary), cache_dir=str(cache), extra_env=env) as server:
server.initialize(timeout=30)
require(server.tools_list(timeout=30), "MCP control session has no tools")
command = copy_binary(source, work / "update-session-command")
command_env = dict(env)
command_env["CBM_DOWNLOAD_URL"] = "https://127.0.0.1:1"
result = run([command, "update", "--yes"], command_env, timeout=20)
require(
result.returncode == 0,
"update exited %s beside a live session: %s"
% (result.returncode, output_text(result)[-800:]),
)
# The same already-open stdio session must still own the same live
# daemon connection. A stop-and-transparent-restart is not enough: the
# existing pipe itself has to remain usable.
require(
server.tools_list(timeout=10),
"update drained the active MCP/daemon session",
)
print("PASS: update left the active MCP/daemon session untouched")
def main():
if os.name != "nt":
print("PRECONDITION: native Windows is required")
return 2
if len(sys.argv) != 2:
print("usage: python test_windows_update_handoff.py <codebase-memory-mcp.exe>")
return 2
source = pathlib.Path(sys.argv[1]).resolve()
if not source.is_file():
print("PRECONDITION: binary not found: %s" % source)
return 2
work = pathlib.Path(tempfile.mkdtemp(prefix="cbm_win_update_"))
try:
env, cache = isolated_environment(work)
assert_update_hands_off_to_install_script(source, env, work)
assert_update_does_not_drain_active_session(source, env, cache, work)
print("\nGREEN: Windows update handoff contract honored.")
return 0
except (GuardFailure, McpError, OSError, subprocess.SubprocessError) as exc:
print("\nRED: %s" % exc)
return 1
finally:
shutil.rmtree(work, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main())