diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 0f4dd1a5a..1048b4189 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -85,13 +85,13 @@ jobs: # Archive layout/name live in the ONE canonical script the local # artifact-flow smoke lane also runs (venue-parity contract). - name: Archive release binary (canonical package-release.sh) - run: scripts/package-release.sh ${{ matrix.goos }} ${{ matrix.goarch }} --variant ui + run: scripts/package-release.sh ${{ matrix.goos }} ${{ matrix.goarch }} - name: Attest release binary provenance if: ${{ inputs.attest }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: - subject-path: codebase-memory-mcp-ui-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz + subject-path: codebase-memory-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -133,13 +133,13 @@ jobs: # artifact-flow smoke lane also runs (venue-parity contract). - name: Archive release binary (canonical package-release.sh) shell: msys2 {0} - run: scripts/package-release.sh windows amd64 --variant ui CC=clang CXX=clang++ + run: scripts/package-release.sh windows amd64 CC=clang CXX=clang++ - name: Attest release binary provenance if: ${{ inputs.attest }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: - subject-path: codebase-memory-mcp-ui-windows-amd64.zip + subject-path: codebase-memory-mcp-windows-amd64.zip - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -183,13 +183,13 @@ jobs: - name: Archive release binary (canonical package-release.sh) shell: msys2 {0} - run: scripts/package-release.sh windows arm64 --variant ui CC=clang CXX=clang++ + run: scripts/package-release.sh windows arm64 CC=clang CXX=clang++ - name: Attest release binary provenance if: ${{ inputs.attest }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: - subject-path: codebase-memory-mcp-ui-windows-arm64.zip + subject-path: codebase-memory-mcp-windows-arm64.zip - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -235,13 +235,13 @@ jobs: ldd build/c/codebase-memory-mcp 2>&1 | grep -q "not a dynamic executable" || ldd build/c/codebase-memory-mcp 2>&1 | grep -q "statically linked" - name: Archive release binary (canonical package-release.sh) - run: scripts/package-release.sh linux ${{ matrix.arch }}-portable --variant ui + run: scripts/package-release.sh linux ${{ matrix.arch }}-portable - name: Attest release binary provenance if: ${{ inputs.attest }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: - subject-path: codebase-memory-mcp-ui-linux-${{ matrix.arch }}-portable.tar.gz + subject-path: codebase-memory-mcp-linux-${{ matrix.arch }}-portable.tar.gz - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/Makefile.cbm b/Makefile.cbm index 246e8f1d2..bd954cccc 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -358,19 +358,17 @@ GIT_SRCS = src/git/git_context.c # CLI module (new) CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c src/cli/client_adapter.c \ - src/cli/agent_clients.c src/cli/agent_profiles.c src/cli/integration_assets.c \ + src/cli/agent_clients.c src/cli/agent_profiles.c \ src/cli/config_json_like.c src/cli/config_toml_edit.c src/cli/config_yaml_edit.c \ src/cli/config_text_edit.c src/cli/activation_transaction.c -# UI module (graph visualization). Production-standard builds deliberately link -# only the no-I/O pack stub. Test binaries substitute the real parser plus the -# empty manifest so parser/install behavior remains covered without a UI pack. +# UI module (graph visualization) UI_SRCS = \ src/ui/config.c \ src/ui/http_server.c \ src/ui/layout3d.c \ src/ui/httpd.c \ - src/ui/asset_pack_stub.c + src/ui/embedded_stub.c # mimalloc (vendored, global allocator override) # @@ -458,7 +456,6 @@ YYJSON_SRC = vendored/yyjson/yyjson.c # All production sources PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DAEMON_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(GIT_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC) -TEST_PROD_SRCS = $(subst src/ui/asset_pack_stub.c,src/ui/asset_pack.c src/ui/asset_manifest_stub.c,$(PROD_SRCS)) EXISTING_C_SRCS = $(EXTRACTION_SRCS) $(LSP_SRCS) $(TS_RUNTIME_SRC) \ $(GRAMMAR_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) @@ -476,17 +473,6 @@ EXISTING_C_SRCS = $(EXTRACTION_SRCS) $(LSP_SRCS) $(TS_RUNTIME_SRC) \ PROJECT_HDRS = $(wildcard src/*.h src/*/*.h $(CBM_DIR)/*.h $(CBM_DIR)/lsp/*.h tests/*.h \ tests/repro/*.h) -# ── Integration-asset integrity constant (generated, never hand-edited) ── -# The binary embeds exactly ONE fact about the shipped integration templates: -# the SHA-256 of assets/cbm-integrations.json, generated at build time (rule -# below the BUILD_DIR definition; target lines expand immediately). Appended -# to PROJECT_HDRS so every single-invocation target (test-runner, -# test-repro-runner, test-runner-tsan, codebase-memory-mcp) rebuilds when the -# asset changes — a stale constant would make every install fail verification. -INTEGRATIONS_ASSET = assets/cbm-integrations.json -INTEGRATIONS_HASH_HDR = $(BUILD_DIR)/generated/cbm_integrations_hash.h -PROJECT_HDRS += $(INTEGRATIONS_HASH_HDR) - # ── Test sources ───────────────────────────────────────────────── TEST_FOUNDATION_SRCS = \ @@ -702,11 +688,6 @@ ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS BUILD_DIR = build/c -# Integration-asset hash header (variables defined next to PROJECT_HDRS; the -# rule lives here because target lines expand $(BUILD_DIR) immediately). -$(INTEGRATIONS_HASH_HDR): $(INTEGRATIONS_ASSET) scripts/gen-integrations-hash.sh | $(BUILD_DIR) - sh scripts/gen-integrations-hash.sh $(INTEGRATIONS_ASSET) $@ - # ── Object file compilation (grammars need relaxed warnings) ───── # Grammar + tree-sitter runtime: compiled without -Werror (upstream code has warnings) @@ -728,7 +709,7 @@ PP_OBJ_TSAN = $(BUILD_DIR)/tsan_preprocessor.o # ── Targets ────────────────────────────────────────────────────── -.PHONY: test test-par test-repro test-foundation test-tsan test-daemon-smoke cbm cbm-with-ui frontend ui-pack clean-c lint lint-tidy lint-cppcheck lint-format security +.PHONY: test test-par test-repro test-foundation test-tsan test-daemon-smoke cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format security $(BUILD_DIR): mkdir -p $(BUILD_DIR) @@ -871,9 +852,9 @@ $(UNIXCODER_OBJ): $(UNIXCODER_BLOB_SRC) vendored/nomic/code_vectors.bin | $(BUIL OBJS_VENDORED_TEST = $(MIMALLOC_OBJ_TEST) $(SQLITE3_OBJ_TEST) $(TRE_OBJ_TEST) $(GRAMMAR_OBJS_TEST) $(TS_RUNTIME_OBJ_TEST) $(LSP_OBJ_TEST) $(PP_OBJ_TEST) $(LZ4_OBJ_TEST) $(ZSTD_OBJ_TEST) $(UNIXCODER_OBJ) OBJS_VENDORED_TSAN = $(MIMALLOC_OBJ_TSAN) $(SQLITE3_OBJ_TSAN) $(TRE_OBJ_TSAN) $(GRAMMAR_OBJS_TSAN) $(TS_RUNTIME_OBJ_TSAN) $(LSP_OBJ_TSAN) $(PP_OBJ_TSAN) $(LZ4_OBJ_TSAN) $(ZSTD_OBJ_TSAN) $(UNIXCODER_OBJ) -$(BUILD_DIR)/test-runner: $(ALL_TEST_SRCS) $(TEST_PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR) +$(BUILD_DIR)/test-runner: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR) $(CC) $(CFLAGS_TEST) -Itests -Itests/repro -o $@ \ - $(ALL_TEST_SRCS) $(TEST_PROD_SRCS) \ + $(ALL_TEST_SRCS) $(PROD_SRCS) \ $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \ $(OBJS_VENDORED_TEST) \ $(LDFLAGS_TEST) @@ -899,9 +880,9 @@ test-focused: $(BUILD_DIR)/test-runner # Mirrors test-runner's link line but uses repro_main.c (own main + counters) # and TEST_REPRO_SRCS instead of ALL_TEST_SRCS. Exits non-zero while any bug is # still reproduced (the expected state); bug-repro.yml surfaces it as a board. -$(BUILD_DIR)/test-repro-runner: $(TEST_REPRO_SRCS) $(TEST_PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR) +$(BUILD_DIR)/test-repro-runner: $(TEST_REPRO_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR) $(CC) $(CFLAGS_TEST) -Itests -o $@ \ - $(TEST_REPRO_SRCS) $(TEST_PROD_SRCS) \ + $(TEST_REPRO_SRCS) $(PROD_SRCS) \ $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \ $(OBJS_VENDORED_TEST) \ $(LDFLAGS_TEST) @@ -959,9 +940,9 @@ TSAN_OPTIONS ?= halt_on_error=1:report_thread_leaks=0 # Normal ASan/native/soak paths remain uncapped. High-worker TSan diagnostics # invoke test-runner-tsan directly so release gates cannot drift accidentally. -$(BUILD_DIR)/test-runner-tsan: $(ALL_TEST_SRCS) $(TEST_PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TSAN) $(PROJECT_HDRS) | $(BUILD_DIR) +$(BUILD_DIR)/test-runner-tsan: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TSAN) $(PROJECT_HDRS) | $(BUILD_DIR) $(CC) $(CFLAGS_TSAN) -Itests -Itests/repro -o $@ \ - $(ALL_TEST_SRCS) $(TEST_PROD_SRCS) \ + $(ALL_TEST_SRCS) $(PROD_SRCS) \ $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \ $(OBJS_VENDORED_TSAN) \ $(LDFLAGS_TSAN) @@ -1048,40 +1029,29 @@ $(BUILD_DIR)/codebase-memory-mcp: $(MAIN_SRC) $(PROD_SRCS) $(EXTRACTION_SRCS) $( cbm: $(BUILD_DIR)/codebase-memory-mcp @echo "Built: $(BUILD_DIR)/codebase-memory-mcp" -# ── Build with external UI asset pack (requires Node.js) ──────── +# ── Build with embedded UI (requires Node.js) ─────────────────── -# The native image contains only a generated manifest (pack basename, SHA-256 -# and byte length). Frontend bytes stay in a content-addressed sidecar next to -# the binary so release scanners can inspect them independently. -UI_ASSET_MANIFEST = $(BUILD_DIR)/generated/ui_asset_manifest.c -UI_ASSET_DIST ?= graph-ui/dist -UI_ASSET_PREBUILT ?= 0 -ifeq ($(UI_ASSET_PREBUILT),1) -UI_ASSET_BUILD_PREREQ := -else -UI_ASSET_BUILD_PREREQ := frontend -endif -PROD_SRCS_WITH_ASSETS = $(subst src/ui/asset_pack_stub.c,src/ui/asset_pack.c $(UI_ASSET_MANIFEST),$(PROD_SRCS)) +# Swap embedded_stub.c for the generated embedded_assets.c +UI_SRCS_WITH_ASSETS = $(subst src/ui/embedded_stub.c,src/ui/embedded_assets.c,$(UI_SRCS)) +PROD_SRCS_WITH_ASSETS = $(subst src/ui/embedded_stub.c,src/ui/embedded_assets.c,$(PROD_SRCS)) + +# Embedded asset object files (generated by embed script) +EMBED_OBJS = $(wildcard $(BUILD_DIR)/embedded/embed_*.o) frontend: cd graph-ui && npm ci && npm run build -$(UI_ASSET_MANIFEST): $(UI_ASSET_BUILD_PREREQ) scripts/pack-ui-assets.mjs | $(BUILD_DIR) - node scripts/pack-ui-assets.mjs $(UI_ASSET_DIST) $(BUILD_DIR) $@ +embed: frontend + scripts/embed-frontend.sh graph-ui/dist $(BUILD_DIR)/embedded -ui-pack: $(UI_ASSET_MANIFEST) - -# $(INTEGRATIONS_HASH_HDR): generated header included by integration_assets.c. -# The standard target gets it via $(PROJECT_HDRS); the UI target compiles in one -# shot and must list it too, or the build fails with "cbm_integrations_hash.h -# file not found" (seen on the Windows UI build). -cbm-with-ui: ui-pack $(INTEGRATIONS_HASH_HDR) $(OBJS_VENDORED_PROD) +cbm-with-ui: embed $(OBJS_VENDORED_PROD) $(CC) $(CFLAGS_PROD) -o $(BUILD_DIR)/codebase-memory-mcp \ $(MAIN_SRC) $(PROD_SRCS_WITH_ASSETS) \ $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \ $(OBJS_VENDORED_PROD) \ + $(wildcard $(BUILD_DIR)/embedded/embed_*.o) \ $(LDFLAGS) - @echo "Built with UI asset pack: $(BUILD_DIR)/codebase-memory-mcp" + @echo "Built with UI: $(BUILD_DIR)/codebase-memory-mcp" clean-c: export BUILD_DIR := $(BUILD_DIR) clean-c: @@ -1114,7 +1084,7 @@ lint-tidy: @$(CLANG_TIDY) --quiet $(LINT_SRCS) -- $(CFLAGS_COMMON) $(SYSROOT_FLAG) # cppcheck: complementary analysis (config in .cppcheck) -lint-cppcheck: $(INTEGRATIONS_HASH_HDR) +lint-cppcheck: @echo "=== cppcheck ===" @$(CPPCHECK) --enable=warning,style,performance,portability \ --std=c11 --language=c \ diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index f33d0ce83..e0a1fc82e 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -66,7 +66,7 @@ License summary: - **Copyright:** (c) 2025 InterSystems Corporation - **Vendored at:** `internal/cbm/vendored/grammars/objectscript_udl/`, `internal/cbm/vendored/grammars/objectscript_routine/` - **Pinned commit:** `a7ffcdf` -- **Notes:** InterSystems-maintained grammar for the ObjectScript language (InterSystems IRIS / Caché). Vendor-maintained; not in nvim-treesitter or Helix registries. Each `scanner.c`'s upstream `#include "../../common/scanner.h"` is repointed to a per-directory `objectscript_common.h` copied from upstream `common/scanner.h`; two loop counters in that copy are widened from `uint8_t` to `int` as documented in `internal/cbm/vendored/grammars/MANIFEST.md`. +- **Notes:** InterSystems-maintained grammar for the ObjectScript language (InterSystems IRIS / Caché). Vendor-maintained; not in nvim-treesitter or Helix registries. Each `scanner.c`'s upstream `#include "../../common/scanner.h"` is repointed to a per-directory `objectscript_common.h` (verbatim copy of upstream `common/scanner.h`). ## Vendored C/C++ Libraries @@ -142,17 +142,13 @@ follows: documentation and language specifications; no upstream source code was extracted or transcribed. -## External Graph UI asset pack +## Embedded Graph UI -Release builds made with `--with-ui` ship the compiled `graph-ui/` frontend in -a deterministic, content-addressed `cbm-ui-.pack` beside the native -executable. The executable contains only the expected pack name, size, and -SHA-256; it does not contain the HTML, JavaScript, or CSS payload. - -The pack's npm dependencies (React, three.js, @react-three/*, radix-ui, -lucide-react, tailwindcss, and friends) are all under permissive licenses -(MIT / ISC / Apache-2.0 / Zlib). The exact set is recorded in +Release binaries built with `--with-ui` embed the compiled `graph-ui/` +frontend bundle. Its npm dependencies (React, three.js, @react-three/*, +radix-ui, lucide-react, tailwindcss, and friends) are all under permissive +licenses (MIT / ISC / Apache-2.0 / Zlib); the exact set is recorded in `graph-ui/package.json` and `graph-ui/package-lock.json`, and the per-package license texts of the production bundle are appended to the -`THIRD_PARTY_NOTICES.md` shipped inside each `-ui` release archive (generated -by `scripts/gen-ui-licenses.py`). +`THIRD_PARTY_NOTICES.md` shipped inside the `-ui` release archives +(generated by `scripts/gen-ui-licenses.py`). diff --git a/assets/cbm-integrations.json b/assets/cbm-integrations.json deleted file mode 100644 index 35caa19c2..000000000 --- a/assets/cbm-integrations.json +++ /dev/null @@ -1 +0,0 @@ -{"format":1,"product":"codebase-memory-mcp","templates":{"claude_gate":{"posix":{"text":"#!/usr/bin/env bash\n# codebase-memory-mcp search augmenter (Claude Code PreToolUse).\n# NOTE: the legacy filename is kept for zero-migration upgrades.\n# Despite the name this NEVER blocks a tool call - it only adds\n# graph context. Any failure is silent (exit 0, no output).\nBIN={{BIN}}\n[ -x \"$BIN\" ] || exit 0\n\"$BIN\" hook-augment 2>/dev/null\nexit 0\n","bin":"sh"},"windows":{"text":"@echo off\r\nsetlocal DisableDelayedExpansion\r\nREM PreToolUse search and read coverage adapter installed by codebase-memory-mcp.\r\nREM Fail-open: it never blocks or logs hook or prompt content.\r\nset \"BIN={{BIN}}\"\r\nif not exist \"%BIN%\" exit /b 0\r\n\"%BIN%\" hook-augment 2>NUL\r\nexit /b 0\r\n","bin":"cmd"},"released":[{"text":"#!/usr/bin/env bash\n# codebase-memory-mcp search augmenter (Claude Code PreToolUse).\n# NOTE: the legacy filename is kept for zero-migration upgrades.\n# Despite the name this NEVER blocks a tool call - it only adds\n# graph context. Any failure is silent (exit 0, no output).\nBIN=\"{{BIN}}\"\n[ -x \"$BIN\" ] || exit 0\n\"$BIN\" hook-augment 2>/dev/null\nexit 0\n","bin":"raw"}]},"claude_session":{"posix":{"text":"#!/usr/bin/env bash\n# SessionStart context adapter installed by codebase-memory-mcp.\n# Fail-open: it never blocks or logs hook/prompt content.\nBIN={{BIN}}\n[ -x \"$BIN\" ] || exit 0\n\"$BIN\" hook-augment 2>/dev/null\nexit 0\n","bin":"sh"},"windows":{"text":"@echo off\r\nsetlocal DisableDelayedExpansion\r\nREM SessionStart context adapter installed by codebase-memory-mcp.\r\nREM Fail-open: it never blocks or logs hook or prompt content.\r\nset \"BIN={{BIN}}\"\r\nif not exist \"%BIN%\" exit /b 0\r\n\"%BIN%\" hook-augment 2>NUL\r\nexit /b 0\r\n","bin":"cmd"},"released":[{"text":"#!/usr/bin/env bash\n# SessionStart hook: remind agent to use codebase-memory-mcp tools.\n# Installed by codebase-memory-mcp. Fires on startup/resume/clear/compact.\ncat << 'REMINDER'\nCRITICAL - Code Discovery Protocol:\n1. ALWAYS use codebase-memory-mcp tools FIRST for ANY code exploration:\n - search_graph(name_pattern/label/qn_pattern) to find functions/classes/routes\n - trace_path(function_name, mode=calls|data_flow|cross_service) for call chains\n - get_code_snippet(qualified_name) for exact symbol source (precise ranges)\n - query_graph(query) for complex Cypher patterns\n - get_architecture(aspects) for project structure\n - search_code(pattern) for text search (graph-augmented grep)\n2. Use Grep/Glob/Read freely for text, configs, non-code files, and\n always Read a file before editing it.\n3. If a project is not indexed yet, run index_repository FIRST.\nREMINDER\n"}]},"claude_subagent":{"posix":{"text":"#!/usr/bin/env bash\n# SubagentStart context adapter installed by codebase-memory-mcp.\n# Fail-open: it never blocks or logs hook/prompt content.\nBIN={{BIN}}\n[ -x \"$BIN\" ] || exit 0\n\"$BIN\" hook-augment 2>/dev/null\nexit 0\n","bin":"sh"},"windows":{"text":"@echo off\r\nsetlocal DisableDelayedExpansion\r\nREM SubagentStart context adapter installed by codebase-memory-mcp.\r\nREM Fail-open: it never blocks or logs hook or prompt content.\r\nset \"BIN={{BIN}}\"\r\nif not exist \"%BIN%\" exit /b 0\r\n\"%BIN%\" hook-augment 2>NUL\r\nexit /b 0\r\n","bin":"cmd"},"released":[{"text":"#!/usr/bin/env bash\n# SubagentStart hook: tell subagents to use codebase-memory-mcp tools.\n# Installed by codebase-memory-mcp. Fires when any subagent is spawned.\n# SubagentStart injects context via JSON additionalContext, not plain stdout.\ncat << 'REMINDER'\n{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"Code discovery: prefer codebase-memory-mcp tools (search_graph, trace_path, get_code_snippet, query_graph, get_architecture, search_code) over grep/file-read for navigating code. Use Grep/Glob/Read for text, configs, and non-code files.\"}}\nREMINDER\n"}]},"augment_session":{"posix":{"text":"#!/bin/sh\n# SessionStart adapter installed by codebase-memory-mcp.\nBIN={{BIN}}\n[ -x \"$BIN\" ] || exit 0\nexec \"$BIN\" hook-augment --event SessionStart 2>/dev/null\n","bin":"sh"},"windows":{"text":"# SessionStart adapter installed by codebase-memory-mcp.\n$bin = {{BIN}}\nif (-not (Test-Path -LiteralPath $bin -PathType Leaf)) { exit 0 }\n& $bin hook-augment --event SessionStart 2>$null\nexit 0\n","bin":"ps"}},"augment_coverage":{"posix":{"text":"#!/bin/sh\n# PostToolUse view adapter installed by codebase-memory-mcp.\nBIN={{BIN}}\n[ -x \"$BIN\" ] || exit 0\nexec \"$BIN\" hook-augment --dialect augment 2>/dev/null\n","bin":"sh"},"windows":{"text":"# PostToolUse view adapter installed by codebase-memory-mcp.\n$bin = {{BIN}}\nif (-not (Test-Path -LiteralPath $bin -PathType Leaf)) { exit 0 }\n& $bin hook-augment --dialect augment 2>$null\nexit 0\n","bin":"ps"}},"cline_context":{"posix":{"text":"#!/bin/sh\n# Cline {{EVENT}} context adapter installed by codebase-memory-mcp.\nBIN={{BIN}}\n[ -x \"$BIN\" ] || exit 0\nexec \"$BIN\" hook-augment --dialect cline --event {{EVENT}} 2>/dev/null\n","bin":"sh"},"windows":{"text":"# Cline {{EVENT}} context adapter installed by codebase-memory-mcp.\n$bin = {{BIN}}\nif (-not (Test-Path -LiteralPath $bin -PathType Leaf)) { exit 0 }\n& $bin hook-augment --dialect cline --event {{EVENT}} 2>$null\nexit 0\n","bin":"ps"}},"adapter_pi":{"all":{"text":"// Generated by codebase-memory-mcp for pi.\n// Regenerated on install/update from the MCP tool registry, so it cannot\n// drift from the tools the server actually serves. Edits inside this block\n// are overwritten; edit outside it, or remove the markers to take ownership.\nimport { spawn } from 'node:child_process';\n\nconst BIN = '{{BIN}}';\n\nasync function call(tool, args, signal) {\n return new Promise((resolve) => {\n const child = spawn(BIN, ['cli', tool, JSON.stringify(args ?? {})], {\n stdio: ['ignore', 'pipe', 'pipe'],\n env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n });\n let out = '';\n const onAbort = () => { if (!child.killed) child.kill(); };\n signal?.addEventListener('abort', onAbort, { once: true });\n child.stdout.on('data', (d) => (out += d.toString()));\n child.on('error', (e) => {\n signal?.removeEventListener('abort', onAbort);\n resolve({ error: String(e && e.message ? e.message : e) });\n });\n child.on('close', () => {\n signal?.removeEventListener('abort', onAbort);\n const lines = out.split('\\n').map((l) => l.trim()).filter(Boolean);\n for (let i = lines.length - 1; i >= 0; i--) {\n try { return resolve(JSON.parse(lines[i])); } catch { /* keep scanning */ }\n }\n resolve({ error: 'no JSON response from codebase-memory-mcp' });\n });\n });\n}\n\nexport function register(pi) {\n{{TOOLS}}}\n","bin":"js"},"tool_line":" pi.registerTool({ name: '{{TOOL}}', run: (args, ctx) => call('{{TOOL}}', args, ctx?.signal) });\n"},"adapter_opencode":{"all":{"text":"// Generated by codebase-memory-mcp for OpenCode.\n// Regenerated on install/update from the MCP tool registry, so it cannot\n// drift from the tools the server actually serves. Edits inside this block\n// are overwritten; edit outside it, or remove the markers to take ownership.\n// OpenCode already reaches every tool over MCP; this adds only the\n// automatic graph lookup before a grep/glob, which other clients get\n// through their own hook configuration.\nimport { spawn } from 'node:child_process';\n\nconst BIN = '{{BIN}}';\n\nfunction augment(tool, args) {\n return new Promise((resolve) => {\n const child = spawn(BIN, ['hook-augment'], {\n stdio: ['pipe', 'pipe', 'ignore'],\n env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n });\n let out = '';\n child.stdout.on('data', (d) => (out += d.toString()));\n child.on('error', () => resolve(''));\n child.on('close', () => resolve(out));\n child.stdin.end(JSON.stringify({\n hook_event_name: 'PreToolUse',\n tool_name: tool,\n tool_input: args ?? {},\n }));\n });\n}\n\nexport const CodebaseMemory = async () => ({\n 'tool.execute.after': async (input, output) => {\n const tool = input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' : null;\n if (!tool) return;\n const extra = await augment(tool, output?.args);\n if (extra && typeof output?.output === 'string') {\n output.output += '\\n' + extra;\n }\n },\n});\n","bin":"js"}}}} diff --git a/install.ps1 b/install.ps1 index a434e78fb..aaba0234a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -16,12 +16,10 @@ $InstallDir = "$env:LOCALAPPDATA\Programs\codebase-memory-mcp" $BinName = "codebase-memory-mcp.exe" $WindowsArchiveNames = @( $BinName, - "cbm-integrations.json", "LICENSE", "install.ps1", "THIRD_PARTY_NOTICES.md" ) -$UiPackPattern = '^cbm-ui-[0-9a-f]{64}\.pack$' $BaseUrl = if ($env:CBM_DOWNLOAD_URL) { $env:CBM_DOWNLOAD_URL } else { "https://github.com/$Repo/releases/latest/download" } try { $BaseUri = [Uri]$BaseUrl } catch { $BaseUri = $null } @@ -89,56 +87,8 @@ function Invoke-CbmDownload { } } -function New-CbmExclusiveSiblingTemp { - param([Parameter(Mandatory=$true)][string]$Destination) - - $directory = [System.IO.Path]::GetDirectoryName($Destination) - $leaf = [System.IO.Path]::GetFileName($Destination) - for ($attempt = 0; $attempt -lt 32; $attempt++) { - $random = [System.IO.Path]::GetRandomFileName() - $candidate = Join-Path $directory ".$leaf.tmp-$random" - try { - $reservation = [System.IO.File]::Open( - $candidate, - [System.IO.FileMode]::CreateNew, - [System.IO.FileAccess]::Write, - [System.IO.FileShare]::None) - $reservation.Dispose() - return $candidate - } catch [System.IO.IOException] { - # A collision belongs to someone else. Never remove it; choose a - # fresh unpredictable sibling and reserve that path exclusively. - } - } - throw "could not reserve an exclusive temporary sibling for $Destination" -} - -function New-CbmExclusiveTempDirectory { - param([Parameter(Mandatory=$true)][string]$ParentDirectory) - - for ($attempt = 0; $attempt -lt 32; $attempt++) { - $candidate = Join-Path $ParentDirectory ( - "cbm-install-" + [guid]::NewGuid().ToString("N") - ) - try { - # Without -Force, an existing path is never adopted. Only return a - # directory successfully created by this installer invocation. - New-Item -ItemType Directory -Path $candidate -ErrorAction Stop | Out-Null - return $candidate - } catch [System.IO.IOException] { - # A collision belongs to someone else. Never remove it; choose a - # fresh unpredictable name and try again within the fixed bound. - } - } - throw "could not reserve an exclusive installer temporary directory" -} - -# Detect variant from args (--ui or --standard) -$Variant = "standard" $SkipConfig = $false foreach ($arg in $args) { - if ($arg -eq "--ui") { $Variant = "ui" } - if ($arg -eq "--standard") { $Variant = "standard" } if ($arg -eq "--skip-config") { $SkipConfig = $true } if ($arg -like "--dir=*") { $InstallDir = $arg.Substring(6) } } @@ -166,21 +116,17 @@ if ($env:CBM_ARCH) { } Write-Host "codebase-memory-mcp installer (Windows)" -Write-Host " variant: $Variant" Write-Host " arch: $Arch" Write-Host " target: $InstallDir\$BinName" Write-Host "" # Build download URL -if ($Variant -eq "ui") { - $Archive = "codebase-memory-mcp-ui-windows-$Arch.zip" -} else { - $Archive = "codebase-memory-mcp-windows-$Arch.zip" -} +$Archive = "codebase-memory-mcp-windows-$Arch.zip" $Url = "$BaseUrl/$Archive" # Download -$TmpDir = New-CbmExclusiveTempDirectory -ParentDirectory ([System.IO.Path]::GetTempPath()) +$TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "cbm-install-$(Get-Random)" +New-Item -ItemType Directory -Path $TmpDir -Force | Out-Null Write-Host "Downloading $Archive..." try { @@ -233,7 +179,7 @@ try { # Validate the zip namespace before extraction. Windows paths are # case-insensitive, so two entries that differ only in case are ambiguous and # must never be allowed to overwrite each other. The official five entries are -# required at the archive root; UI adds exactly one hash-shaped pack. +# required at the archive root with their exact release names. try { Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [System.IO.Compression.ZipFile]::OpenRead("$TmpDir\$Archive") @@ -241,8 +187,6 @@ try { $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) $archiveCounts = @{} foreach ($archiveName in $WindowsArchiveNames) { $archiveCounts[$archiveName] = 0 } - $uiPackName = $null - $uiPackCount = 0 foreach ($entry in $zip.Entries) { $entryName = $entry.FullName.Replace('\', '/') $isDirectory = $entryName.EndsWith('/') @@ -260,28 +204,18 @@ try { if (-not $seen.Add($pathForSegments)) { throw "duplicate or case-conflicting zip entry: $($entry.FullName)" } - if ($isDirectory) { - throw "archive contains an unexpected root entry: $($entry.FullName)" - } - if ($WindowsArchiveNames -ccontains $entryName) { - $archiveCounts[$entryName] = $archiveCounts[$entryName] + 1 - } elseif ($Variant -eq "ui" -and $entryName -cmatch $UiPackPattern) { - $uiPackName = $entryName - $uiPackCount++ - } else { + if (-not ($WindowsArchiveNames -ccontains $entryName) -or $isDirectory) { throw "archive contains an unexpected root entry: $($entry.FullName)" } + $archiveCounts[$entryName] = $archiveCounts[$entryName] + 1 } foreach ($archiveName in $WindowsArchiveNames) { if ($archiveCounts[$archiveName] -ne 1) { throw "archive must contain exactly one $archiveName" } } - $expectedUiPackCount = if ($Variant -eq "ui") { 1 } else { 0 } - $expectedArchiveCount = $WindowsArchiveNames.Count + $expectedUiPackCount - if ($uiPackCount -ne $expectedUiPackCount -or - $seen.Count -ne $expectedArchiveCount) { - throw "archive does not match the exact $Variant Windows release allowlist" + if ($seen.Count -ne $WindowsArchiveNames.Count) { + throw "archive does not match the exact Windows release allowlist" } } finally { $zip.Dispose() @@ -298,21 +232,6 @@ try { Write-Host "Extracting..." Expand-Archive -Path "$TmpDir\$Archive" -DestinationPath $TmpDir -Force -foreach ($archiveName in $WindowsArchiveNames) { - $extractedMember = Join-Path $TmpDir $archiveName - if (-not (Test-Path -LiteralPath $extractedMember -PathType Leaf)) { - Write-Host "error: release member is not a regular file: $archiveName" -ForegroundColor Red - Remove-Item -Recurse -Force $TmpDir - exit 1 - } - $extractedItem = Get-Item -LiteralPath $extractedMember - if ($extractedItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { - Write-Host "error: refusing reparse-point release member: $archiveName" -ForegroundColor Red - Remove-Item -Recurse -Force $TmpDir - exit 1 - } -} - $DownloadedBinary = Join-Path $TmpDir $BinName if (-not (Test-Path -LiteralPath $DownloadedBinary -PathType Leaf)) { Write-Host "error: $BinName not found after extraction" -ForegroundColor Red @@ -326,29 +245,6 @@ if ($binaryItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { exit 1 } -$DownloadedUiPack = $null -if ($Variant -eq "ui") { - $DownloadedUiPack = Join-Path $TmpDir $uiPackName - if (-not (Test-Path -LiteralPath $DownloadedUiPack -PathType Leaf)) { - Write-Host "error: UI asset pack not found after extraction" -ForegroundColor Red - Remove-Item -Recurse -Force $TmpDir - exit 1 - } - $packItem = Get-Item -LiteralPath $DownloadedUiPack - if ($packItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { - Write-Host "error: refusing reparse-point UI asset pack" -ForegroundColor Red - Remove-Item -Recurse -Force $TmpDir - exit 1 - } - $expectedPackDigest = $uiPackName.Substring(7, 64) - $actualPackDigest = (Get-FileHash -LiteralPath $DownloadedUiPack -Algorithm SHA256).Hash.ToLower() - if ($expectedPackDigest -cne $actualPackDigest) { - Write-Host "error: UI asset pack digest does not match its filename" -ForegroundColor Red - Remove-Item -Recurse -Force $TmpDir - exit 1 - } -} - # Prove the downloaded binary runs before touching an existing installation. try { $candidateVersion = & $DownloadedBinary --version 2>&1 @@ -360,12 +256,32 @@ try { exit 1 } -# The candidate publishes the runtime set under one native activation guard, -# with sidecars before the executable and retained per-file backups for -# cooperative rollback. This is recoverable/fail-closed ordering, not a claim -# that several filesystem entries change in one crash-atomic transaction. $Dest = Join-Path $InstallDir $BinName +# Retire the running installation before replacing it. Windows keeps an image +# lock on a running .exe: the file cannot be overwritten, but it CAN be renamed +# out of the way, which is what makes an in-place update possible from here. +if (Test-Path -LiteralPath $Dest -PathType Leaf) { + try { & $Dest daemon stop 2>&1 | Out-Null } catch { } + $retired = "$Dest.retired-$(Get-Date -Format yyyyMMddHHmmss)" + $renamed = $false + foreach ($attempt in 1..10) { + try { Move-Item -LiteralPath $Dest -Destination $retired -Force -ErrorAction Stop; $renamed = $true; break } + catch { Start-Sleep -Milliseconds 500 } + } + if (-not $renamed) { + Write-Host "error: could not retire the existing $BinName - close all running" -ForegroundColor Red + Write-Host " codebase-memory-mcp sessions and coding agents, then re-run." -ForegroundColor Red + Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue + exit 1 + } + # A retired image stays locked until its last process exits; delete it when + # we can, and leave it for the next run when we cannot. Never fail here. + Remove-Item -LiteralPath $retired -Force -ErrorAction SilentlyContinue +} +Get-ChildItem -LiteralPath $InstallDir -Filter "$BinName.retired-*" -ErrorAction SilentlyContinue | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } + $InstallArgs = @("install", "-y", "--force", "--dir=$InstallDir") if ($SkipConfig) { $InstallArgs += "--skip-config" } & $DownloadedBinary @InstallArgs @@ -386,17 +302,13 @@ if ($LASTEXITCODE -ne 0) { $DownloadedInstaller = Join-Path $TmpDir "install.ps1" if (Test-Path -LiteralPath $DownloadedInstaller -PathType Leaf) { $InstallerDest = Join-Path $InstallDir "install.ps1" - $InstallerTmp = $null + $InstallerTmp = "$InstallerDest.new" try { - $InstallerTmp = New-CbmExclusiveSiblingTemp -Destination $InstallerDest Copy-Item -LiteralPath $DownloadedInstaller -Destination $InstallerTmp -Force -ErrorAction Stop Move-Item -LiteralPath $InstallerTmp -Destination $InstallerDest -Force -ErrorAction Stop - $InstallerTmp = $null Write-Host "Installed updater -> $InstallerDest" } catch { - if ($InstallerTmp) { - Remove-Item -LiteralPath $InstallerTmp -Force -ErrorAction SilentlyContinue - } + Remove-Item -LiteralPath $InstallerTmp -Force -ErrorAction SilentlyContinue Write-Host "note: could not place install.ps1 in $InstallDir (update will explain where to find it)" } } diff --git a/install.sh b/install.sh index b9e9cea3f..3b99e7357 100755 --- a/install.sh +++ b/install.sh @@ -5,7 +5,6 @@ set -euo pipefail # # Usage: # curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash -# curl -fsSL ... | bash -s -- --ui # Install the UI variant # curl -fsSL ... | bash -s -- --dir /path # Custom install directory # # Environment: @@ -19,7 +18,6 @@ main() { REPO="DeusData/codebase-memory-mcp" INSTALL_DIR="$HOME/.local/bin" -VARIANT="standard" SKIP_CONFIG=false CBM_DOWNLOAD_URL="${CBM_DOWNLOAD_URL:-https://github.com/${REPO}/releases/latest/download}" @@ -83,14 +81,10 @@ download_file() { for arg in "$@"; do case "$arg" in - --ui) VARIANT="ui" ;; - --standard) VARIANT="standard" ;; --dir=*) INSTALL_DIR="${arg#--dir=}" ;; --skip-config) SKIP_CONFIG=true ;; --help|-h) - echo "Usage: install.sh [--ui] [--dir=] [--skip-config]" - echo " --ui Install the UI variant (with graph visualization)" - echo " --standard Install the standard variant (default)" + echo "Usage: install.sh [--dir=] [--skip-config]" echo " --dir PATH Install directory (default: ~/.local/bin)" echo " --skip-config Skip automatic agent configuration" exit 0 @@ -138,7 +132,6 @@ ARCH=$(detect_arch) echo "codebase-memory-mcp installer" echo " os: $OS" echo " arch: $ARCH" -echo " variant: $VARIANT" echo " target: $INSTALL_DIR/codebase-memory-mcp" echo "" @@ -155,11 +148,7 @@ fi PORTABLE="" [ "$OS" = "linux" ] && PORTABLE="-portable" -if [ "$VARIANT" = "ui" ]; then - ARCHIVE="codebase-memory-mcp-ui-${OS}-${ARCH}${PORTABLE}.${EXT}" -else - ARCHIVE="codebase-memory-mcp-${OS}-${ARCH}${PORTABLE}.${EXT}" -fi +ARCHIVE="codebase-memory-mcp-${OS}-${ARCH}${PORTABLE}.${EXT}" URL="${CBM_DOWNLOAD_URL}/${ARCHIVE}" @@ -231,71 +220,6 @@ if [ "$EXPECTED" != "$ACTUAL" ]; then fi echo "Checksum verified." -# Validate the complete archive namespace before extraction. Standard releases -# are the canonical five files; UI releases add exactly one root-level, -# content-addressed pack. Anything else is a release-integrity failure, not a -# sidecar to ignore. -if [ "$OS" = "windows" ]; then - ARCHIVE_BINARY="codebase-memory-mcp.exe" - ARCHIVE_INSTALLER="install.ps1" -else - ARCHIVE_BINARY="codebase-memory-mcp" - ARCHIVE_INSTALLER="install.sh" -fi -ARCHIVE_MEMBERS_FILE="$DLDIR/archive-members.txt" -if [ "$EXT" = "zip" ]; then - if ! unzip -Z1 "$DLDIR/$ARCHIVE" > "$ARCHIVE_MEMBERS_FILE"; then - echo "error: could not enumerate release archive" >&2 - exit 1 - fi -else - if ! tar -tzf "$DLDIR/$ARCHIVE" > "$ARCHIVE_MEMBERS_FILE"; then - echo "error: could not enumerate release archive" >&2 - exit 1 - fi -fi - -BINARY_MEMBERS=0 -INTEGRATION_MEMBERS=0 -LICENSE_MEMBERS=0 -INSTALLER_MEMBERS=0 -NOTICE_MEMBERS=0 -UI_PACK_MEMBERS=0 -ARCHIVE_MEMBER_COUNT=0 -UI_PACK_NAME="" -while IFS= read -r member || [ -n "$member" ]; do - ARCHIVE_MEMBER_COUNT=$((ARCHIVE_MEMBER_COUNT + 1)) - case "$member" in - "$ARCHIVE_BINARY") BINARY_MEMBERS=$((BINARY_MEMBERS + 1)) ;; - cbm-integrations.json) INTEGRATION_MEMBERS=$((INTEGRATION_MEMBERS + 1)) ;; - LICENSE) LICENSE_MEMBERS=$((LICENSE_MEMBERS + 1)) ;; - "$ARCHIVE_INSTALLER") INSTALLER_MEMBERS=$((INSTALLER_MEMBERS + 1)) ;; - THIRD_PARTY_NOTICES.md) NOTICE_MEMBERS=$((NOTICE_MEMBERS + 1)) ;; - *) - if [ "$VARIANT" = "ui" ] && - [[ "$member" =~ ^cbm-ui-[0-9a-f]{64}\.pack$ ]]; then - UI_PACK_MEMBERS=$((UI_PACK_MEMBERS + 1)) - UI_PACK_NAME="$member" - else - echo "error: release archive contains unexpected member: $member" >&2 - exit 1 - fi - ;; - esac -done < "$ARCHIVE_MEMBERS_FILE" - -EXPECTED_MEMBER_COUNT=5 -if [ "$VARIANT" = "ui" ]; then - EXPECTED_MEMBER_COUNT=6 -fi -if [ "$BINARY_MEMBERS" -ne 1 ] || [ "$INTEGRATION_MEMBERS" -ne 1 ] || - [ "$LICENSE_MEMBERS" -ne 1 ] || [ "$INSTALLER_MEMBERS" -ne 1 ] || - [ "$NOTICE_MEMBERS" -ne 1 ] || [ "$UI_PACK_MEMBERS" -ne $((EXPECTED_MEMBER_COUNT - 5)) ] || - [ "$ARCHIVE_MEMBER_COUNT" -ne "$EXPECTED_MEMBER_COUNT" ]; then - echo "error: release archive does not match the exact $VARIANT member set" >&2 - exit 1 -fi - # Extract echo "Extracting..." if [ "$EXT" = "zip" ]; then @@ -304,41 +228,12 @@ else tar -xzf "$DLDIR/$ARCHIVE" -C "$DLDIR" fi -for extracted_member in "$ARCHIVE_BINARY" cbm-integrations.json LICENSE \ - "$ARCHIVE_INSTALLER" THIRD_PARTY_NOTICES.md; do - if [ ! -f "$DLDIR/$extracted_member" ] || [ -L "$DLDIR/$extracted_member" ]; then - echo "error: release member is not a regular file: $extracted_member" >&2 - exit 1 - fi -done - -DLBIN="$DLDIR/$ARCHIVE_BINARY" -if [ ! -f "$DLBIN" ] || [ -L "$DLBIN" ]; then +DLBIN="$DLDIR/codebase-memory-mcp" +if [ ! -f "$DLBIN" ]; then echo "error: binary not found after extraction" >&2 exit 1 fi -DL_UI_PACK="" -if [ "$VARIANT" = "ui" ]; then - DL_UI_PACK="$DLDIR/$UI_PACK_NAME" - if [ ! -f "$DL_UI_PACK" ] || [ -L "$DL_UI_PACK" ]; then - echo "error: UI asset pack not found after extraction" >&2 - exit 1 - fi - PACK_EXPECTED="${UI_PACK_NAME#cbm-ui-}" - PACK_EXPECTED="${PACK_EXPECTED%.pack}" - if command -v sha256sum &>/dev/null; then - PACK_ACTUAL=$(sha256sum "$DL_UI_PACK" | awk '{print $1}') - else - PACK_ACTUAL=$(shasum -a 256 "$DL_UI_PACK" | awk '{print $1}') - fi - PACK_ACTUAL=$(printf '%s' "$PACK_ACTUAL" | tr 'A-F' 'a-f') - if [ "$PACK_EXPECTED" != "$PACK_ACTUAL" ]; then - echo "error: UI asset pack digest does not match its filename" >&2 - exit 1 - fi -fi - # macOS: fix signing if [ "$OS" = "darwin" ]; then echo "Fixing macOS code signing..." @@ -355,20 +250,7 @@ if ! CANDIDATE_VERSION=$("$DLBIN" --version 2>&1); then fi echo "Verified candidate: $CANDIDATE_VERSION" -# The candidate publishes the runtime set under one native activation guard, -# with sidecars before the executable and retained per-file backups for -# cooperative rollback. The set is intentionally recoverable/fail-closed, not -# claimed to be a crash-atomic multi-file filesystem transaction. -new_exclusive_sibling_temp() { - local destination="$1" - local directory basename - directory="$(dirname "$destination")" - basename="$(basename "$destination")" - mktemp "$directory/.${basename}.tmp.XXXXXX" -} - DEST="$INSTALL_DIR/codebase-memory-mcp" -[ "$OS" = "windows" ] && DEST="$INSTALL_DIR/codebase-memory-mcp.exe" INSTALL_ARGS=(-y --force "--dir=$INSTALL_DIR") if [ "$SKIP_CONFIG" = true ]; then INSTALL_ARGS+=(--skip-config) @@ -390,16 +272,13 @@ fi # working install, and `update` falls back to explaining where to find it. DL_INSTALLER="$DLDIR/install.sh" if [ -f "$DL_INSTALLER" ]; then - INSTALLER_TMP="" - if INSTALLER_TMP="$(new_exclusive_sibling_temp "$INSTALL_DIR/install.sh" 2>/dev/null)" && - cp "$DL_INSTALLER" "$INSTALLER_TMP" 2>/dev/null && + INSTALLER_TMP="$INSTALL_DIR/.install.sh.$$" + if cp "$DL_INSTALLER" "$INSTALLER_TMP" 2>/dev/null && chmod 755 "$INSTALLER_TMP" 2>/dev/null && mv -f "$INSTALLER_TMP" "$INSTALL_DIR/install.sh" 2>/dev/null; then echo "Installed updater -> $INSTALL_DIR/install.sh" else - # A non-empty path was exclusively created by mktemp above. If - # reservation failed, leave every pre-existing sibling untouched. - [ -z "$INSTALLER_TMP" ] || rm -f "$INSTALLER_TMP" 2>/dev/null || true + rm -f "$INSTALLER_TMP" 2>/dev/null || true echo "note: could not place install.sh in $INSTALL_DIR (update will explain where to find it)" fi fi diff --git a/pkg/aur/PKGBUILD b/pkg/aur/PKGBUILD index a28f6a7d9..957c682b1 100644 --- a/pkg/aur/PKGBUILD +++ b/pkg/aur/PKGBUILD @@ -2,7 +2,7 @@ pkgname=codebase-memory-mcp pkgver=0.8.1 pkgrel=1 -pkgdesc="Fast code intelligence engine — native executable with verified integration asset" +pkgdesc="Fast code intelligence engine for AI coding agents — single static binary MCP server" arch=('x86_64' 'aarch64') url="https://github.com/DeusData/codebase-memory-mcp" license=('MIT') @@ -18,8 +18,6 @@ sha256sums_aarch64=('d2f842d1365da5c35d9c5796f57a821c9745267350994346735e1e6e04d package() { install -Dm755 "${srcdir}/codebase-memory-mcp" \ "${pkgdir}/usr/bin/codebase-memory-mcp" - install -Dm644 "${srcdir}/cbm-integrations.json" \ - "${pkgdir}/usr/share/codebase-memory-mcp/cbm-integrations.json" install -Dm644 "${srcdir}/LICENSE" \ "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" # Third-party attribution bundle (present in archives since v0.8.1) diff --git a/pkg/glama/Dockerfile b/pkg/glama/Dockerfile index 23f0fa702..2b06f7f58 100644 --- a/pkg/glama/Dockerfile +++ b/pkg/glama/Dockerfile @@ -15,13 +15,12 @@ ARG TARGETARCH RUN apt-get update \ && apt-get install -y --no-install-recommends curl ca-certificates \ && curl -fsSL "https://github.com/DeusData/codebase-memory-mcp/releases/latest/download/codebase-memory-mcp-linux-${TARGETARCH}-portable.tar.gz" \ - | tar -xz -C /tmp codebase-memory-mcp cbm-integrations.json LICENSE THIRD_PARTY_NOTICES.md \ + | tar -xz -C /tmp codebase-memory-mcp LICENSE THIRD_PARTY_NOTICES.md \ && chmod +x /tmp/codebase-memory-mcp \ && rm -rf /var/lib/apt/lists/* FROM debian:bookworm-slim COPY --from=fetch /tmp/codebase-memory-mcp /usr/local/bin/codebase-memory-mcp -COPY --from=fetch /tmp/cbm-integrations.json /usr/local/share/codebase-memory-mcp/cbm-integrations.json COPY --from=fetch /tmp/LICENSE /tmp/THIRD_PARTY_NOTICES.md /usr/share/doc/codebase-memory-mcp/ ENV CBM_CACHE_DIR=/tmp/cbm ENTRYPOINT ["codebase-memory-mcp"] diff --git a/pkg/go/cmd/codebase-memory-mcp/main_test.go b/pkg/go/cmd/codebase-memory-mcp/main_test.go index 514a7f8a3..083a92479 100644 --- a/pkg/go/cmd/codebase-memory-mcp/main_test.go +++ b/pkg/go/cmd/codebase-memory-mcp/main_test.go @@ -1,2210 +1,197 @@ package main import ( - "archive/tar" - "archive/zip" - "bytes" - "compress/gzip" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" "fmt" - "io" - "net/http" "os" - "os/exec" "path/filepath" - "reflect" - "runtime" - "strconv" "strings" "sync" "testing" - "time" ) -type archiveTestRoundTripper func(*http.Request) (*http.Response, error) +func TestExecutionPathForOSUsesAdjacentWindowsLauncher(t *testing.T) { + payload := filepath.Join("cache", version, windowsPayloadName) + want := filepath.Join("cache", version, windowsLauncherName) -func (transport archiveTestRoundTripper) RoundTrip( - request *http.Request, -) (*http.Response, error) { - return transport(request) -} - -type archiveCountingBody struct { - reader io.Reader - read int64 -} - -func (body *archiveCountingBody) Read(buffer []byte) (int, error) { - count, err := body.reader.Read(buffer) - body.read += int64(count) - return count, err -} - -func (*archiveCountingBody) Close() error { return nil } - -func TestStandardAndUICachePathsDoNotCollide(t *testing.T) { - t.Setenv("CBM_CACHE_DIR", t.TempDir()) - t.Setenv("CBM_VARIANT", "standard") - standard := binPath() - t.Setenv("CBM_VARIANT", "ui") - ui := binPath() - if standard == ui { - t.Fatalf("standard and UI cache paths collide at %q", standard) + if got := executionPathForOS(payload, "windows"); got != want { + t.Fatalf("Windows execution path = %q, want adjacent launcher %q", got, want) } - if filepath.Base(filepath.Dir(standard)) != "standard" || - filepath.Base(filepath.Dir(ui)) != "ui" { - t.Fatalf("variant cache paths = %q and %q", standard, ui) + if got := executionPathForOS(payload, "linux"); got != payload { + t.Fatalf("non-Windows execution path = %q, want payload %q", got, payload) } } -func TestWindowsUsesOneDirectBinary(t *testing.T) { - binary := filepath.Join("cache", version, "ui", windowsBinaryName) - if got := executionPathForOS(binary, "windows"); got != binary { - t.Fatalf("Windows execution path = %q, want direct binary %q", got, binary) - } - if got := binaryNameForOS("windows"); got != "codebase-memory-mcp.exe" { - t.Fatalf("Windows binary name = %q", got) - } - source, err := os.ReadFile("main.go") - if err != nil { - t.Fatal(err) - } - for _, obsolete := range []string{"payload", "launcher"} { - if strings.Contains(strings.ToLower(string(source)), obsolete) { - t.Fatalf("Go wrapper still contains obsolete %q model", obsolete) - } - } -} - -func TestCacheSensitiveMutationActionIsExplicit(t *testing.T) { +func TestPortableMutationActionPreservesPackageManagerRefusal(t *testing.T) { cases := []struct { args []string want string }{ {args: []string{"update", "--yes"}, want: "update"}, {args: []string{"uninstall", "--yes"}, want: "uninstall"}, - {args: []string{"install", "--yes"}, want: "install"}, + {args: []string{"install", "--yes"}, want: ""}, {args: []string{"cli", "update"}, want: ""}, - {args: []string{"daemon", "update"}, want: ""}, } + for _, testCase := range cases { - if got := runtimeMutationAction(testCase.args); got != testCase.want { - t.Errorf( - "runtimeMutationAction(%q) = %q, want %q", - testCase.args, - got, - testCase.want, - ) + if got := portableMutationAction(testCase.args); got != testCase.want { + t.Errorf("portableMutationAction(%q) = %q, want %q", testCase.args, got, testCase.want) } } } -func TestWrapperUninstallNeverDefaultsToItsCacheBinary(t *testing.T) { - custom := []string{"uninstall", "--dir", filepath.Join("custom", "bin")} - if got := nativeArgs(custom); !reflect.DeepEqual(got, custom) { - t.Fatalf("custom uninstall args = %q, want %q", got, custom) - } - got := nativeArgs([]string{"uninstall", "--yes"}) - wantBase := "bin" - if runtime.GOOS == "windows" { - wantBase = "codebase-memory-mcp" - } - if len(got) != 4 || got[2] != "--dir" || - filepath.Base(got[3]) != wantBase { - t.Fatalf("default uninstall args do not target managed install: %q", got) - } -} - -func TestArchiveNamespaceIsExactAndUIHasOnePack(t *testing.T) { - binary := "codebase-memory-mcp" - base := archiveNamesForOS("linux", binary) - pack := "cbm-ui-" + strings.Repeat("a", 64) + ".pack" - uiNames := append(append([]string(nil), base...), pack) - got, err := validateArchiveMemberNames(uiNames, base, "ui", false) - if err != nil { - t.Fatal(err) - } - if got != pack { - t.Fatalf("UI pack = %q, want %q", got, pack) - } - badCases := [][]string{ - append(append([]string(nil), uiNames...), "unexpected-root-file"), - append(append([]string(nil), uiNames...), - "cbm-ui-"+strings.Repeat("b", 64)+".pack"), - base, - } - for _, names := range badCases { - if _, err := validateArchiveMemberNames(names, base, "ui", false); err == nil { - t.Fatalf("UI archive namespace accepted invalid members: %q", names) - } - } - if _, err := validateArchiveMemberNames(uiNames, base, "standard", false); err == nil { - t.Fatal("standard archive accepted a UI pack") - } -} - -func writeTarGz(t *testing.T, archivePath string, names []string) { - t.Helper() - file, err := os.Create(archivePath) - if err != nil { - t.Fatal(err) - } - gz := gzip.NewWriter(file) - tw := tar.NewWriter(gz) - for _, name := range names { - contents := []byte("contents:" + name) - header := &tar.Header{ - Name: name, - Mode: 0600, - Size: int64(len(contents)), - Typeflag: tar.TypeReg, - } - if err := tw.WriteHeader(header); err != nil { - t.Fatal(err) - } - if _, err := tw.Write(contents); err != nil { - t.Fatal(err) - } - } - if err := tw.Close(); err != nil { - t.Fatal(err) - } - if err := gz.Close(); err != nil { - t.Fatal(err) - } - if err := file.Close(); err != nil { - t.Fatal(err) - } -} - -func TestTarExtractionValidatesNamespaceAndExtractsRuntimeSet(t *testing.T) { - root := t.TempDir() - archivePath := filepath.Join(root, "release.tar.gz") - destination := filepath.Join(root, "extract") - if err := os.Mkdir(destination, 0755); err != nil { - t.Fatal(err) - } - binary := "codebase-memory-mcp" - archiveNames := archiveNamesForOS("linux", binary) - writeTarGz(t, archivePath, archiveNames) - runtimeNames, err := extractTarGz( - archivePath, - destination, - archiveNames, - []string{binary, integrationsFileName}, - "standard", - ) - if err != nil { - t.Fatal(err) - } - want := []string{binary, integrationsFileName} - if !reflect.DeepEqual(runtimeNames, want) { - t.Fatalf("extracted runtime names = %q, want %q", runtimeNames, want) - } - if _, err := os.Stat(filepath.Join(destination, "LICENSE")); !os.IsNotExist(err) { - t.Fatal("non-runtime LICENSE member was extracted") - } - - badArchive := filepath.Join(root, "bad.tar.gz") - writeTarGz(t, badArchive, append(archiveNames, "unexpected-root-file")) - badDestination := filepath.Join(root, "bad-extract") - if err := os.Mkdir(badDestination, 0755); err != nil { - t.Fatal(err) - } - if _, err := extractTarGz( - badArchive, - badDestination, - archiveNames, - []string{binary, integrationsFileName}, - "standard", - ); err == nil { - t.Fatal("tar extraction accepted an unexpected root member") - } -} - -func TestTarExtractionRejectsHardlinkMember(t *testing.T) { - root := t.TempDir() - archivePath := filepath.Join(root, "hardlink.tar.gz") - file, err := os.Create(archivePath) - if err != nil { - t.Fatal(err) - } - gz := gzip.NewWriter(file) - tw := tar.NewWriter(gz) - if err := tw.WriteHeader(&tar.Header{ - Name: "codebase-memory-mcp", Typeflag: tar.TypeLink, - Linkname: "cbm-integrations.json", Mode: 0755, - }); err != nil { - t.Fatal(err) - } - if err := tw.Close(); err != nil { - t.Fatal(err) - } - if err := gz.Close(); err != nil { - t.Fatal(err) - } - if err := file.Close(); err != nil { - t.Fatal(err) - } - if _, err := tarGzMemberNames(archivePath); err == nil { - t.Fatal("tar namespace accepted a hardlink member") - } -} - -func TestCompressedArchiveDownloadRejectsDeclaredAndActualOverflow(t *testing.T) { - const maxBytes = int64(8) - priorClient := httpsOnlyClient - defer func() { httpsOnlyClient = priorClient }() - - t.Run("declared", func(t *testing.T) { - httpsOnlyClient = &http.Client{Transport: archiveTestRoundTripper( - func(request *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader("")), - ContentLength: maxBytes + 1, - Header: make(http.Header), - Request: request, - }, nil - }, - )} - destination := filepath.Join(t.TempDir(), "release.tar.gz") - err := httpGetWithLimit( - "https://example.invalid/release.tar.gz", destination, maxBytes, - ) - if err == nil || !strings.Contains(err.Error(), "compressed safety limit") { - t.Fatalf("compressed declared overflow error = %v", err) - } - if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) { - t.Fatal("declared oversized archive created a download") - } - }) - - t.Run("actual", func(t *testing.T) { - body := &archiveCountingBody{reader: bytes.NewReader( - bytes.Repeat([]byte("x"), 1024), - )} - httpsOnlyClient = &http.Client{Transport: archiveTestRoundTripper( - func(request *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Body: body, - ContentLength: -1, - Header: make(http.Header), - Request: request, - }, nil - }, - )} - destination := filepath.Join(t.TempDir(), "release.tar.gz") - err := httpGetWithLimit( - "https://example.invalid/release.tar.gz", destination, maxBytes, - ) - if err == nil || !strings.Contains(err.Error(), "compressed safety limit") { - t.Fatalf("compressed actual overflow error = %v", err) - } - if body.read != maxBytes+1 { - t.Fatalf("oversized response bytes consumed = %d, want %d", body.read, maxBytes+1) - } - if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) { - t.Fatal("rejected compressed archive left a partial download") - } - }) -} - -func testArchiveLimits( - members int, memberBytes, expandedBytes int64, -) archiveResourceLimits { - return archiveResourceLimits{ - compressedBytes: 1024 * 1024, - members: members, - memberBytes: memberBytes, - expandedBytes: expandedBytes, - } -} - -func TestTarArchiveRejectsMemberAndExpandedResourceOverflow(t *testing.T) { - root := t.TempDir() - tests := []struct { - name string - names []string - limits archiveResourceLimits - wantError string - }{ - { - name: "member count", - names: []string{"one", "two", "three"}, - limits: testArchiveLimits(2, 1024, 4096), - wantError: "member safety limit", - }, - { - name: "declared member bytes", - names: []string{"oversized"}, - limits: testArchiveLimits(4, 4, 4096), - wantError: "expanded safety limit", - }, - { - name: "declared aggregate bytes", - names: []string{"one", "two"}, - limits: testArchiveLimits(4, 1024, 15), - wantError: "aggregate expanded safety limit", - }, - } - for _, testCase := range tests { - t.Run(testCase.name, func(t *testing.T) { - archivePath := filepath.Join(root, strings.ReplaceAll(testCase.name, " ", "-")+".tar.gz") - writeTarGz(t, archivePath, testCase.names) - _, err := tarGzMemberNamesWithLimits(archivePath, testCase.limits) - if err == nil || !strings.Contains(err.Error(), testCase.wantError) { - t.Fatalf("tar resource overflow error = %v", err) - } - }) - } -} - -func TestArchiveCopyRejectsActualMemberAggregateAndMetadataMismatch(t *testing.T) { - tests := []struct { - name string - contents string - declared int64 - actual int64 - limits archiveResourceLimits - wantError string - }{ - { - name: "actual member bytes", - contents: "12345", - declared: 5, - limits: testArchiveLimits(1, 4, 10), - wantError: "actual expanded safety limit", - }, - { - name: "actual aggregate bytes", - contents: "12", - declared: 2, - actual: 4, - limits: testArchiveLimits(1, 10, 5), - wantError: "aggregate actual expanded safety limit", - }, - { - name: "declared actual mismatch", - contents: "123", - declared: 2, - limits: testArchiveLimits(1, 10, 10), - wantError: "does not match declared size", - }, - } - for _, testCase := range tests { - t.Run(testCase.name, func(t *testing.T) { - actual := testCase.actual - var output bytes.Buffer - err := copyArchiveMemberWithLimits( - &output, - strings.NewReader(testCase.contents), - "member", - testCase.declared, - &actual, - testCase.limits, - ) - if err == nil || !strings.Contains(err.Error(), testCase.wantError) { - t.Fatalf("actual archive overflow error = %v", err) - } - }) - } -} - -func writeZip(t *testing.T, archivePath string, names []string) { - t.Helper() - file, err := os.Create(archivePath) - if err != nil { - t.Fatal(err) - } - zw := zip.NewWriter(file) - for _, name := range names { - member, err := zw.Create(name) - if err != nil { - t.Fatal(err) - } - if _, err := member.Write([]byte("contents:" + name)); err != nil { - t.Fatal(err) - } - } - if err := zw.Close(); err != nil { - t.Fatal(err) - } - if err := file.Close(); err != nil { - t.Fatal(err) - } -} - -func TestUIZipExtractionIncludesOneContentAddressedPack(t *testing.T) { - root := t.TempDir() - archivePath := filepath.Join(root, "release.zip") - destination := filepath.Join(root, "extract") - if err := os.Mkdir(destination, 0755); err != nil { - t.Fatal(err) - } - pack := "cbm-ui-" + strings.Repeat("a", 64) + ".pack" - archiveNames := archiveNamesForOS("windows", windowsBinaryName) - writeZip(t, archivePath, append(archiveNames, pack)) - runtimeNames, err := extractZip( - archivePath, - destination, - archiveNames, - []string{windowsBinaryName, integrationsFileName}, - "ui", - ) - if err != nil { - t.Fatal(err) - } - want := []string{windowsBinaryName, integrationsFileName, pack} - if !reflect.DeepEqual(runtimeNames, want) { - t.Fatalf("extracted runtime names = %q, want %q", runtimeNames, want) - } - for _, name := range want { - if !regularRuntimeFile(filepath.Join(destination, name)) { - t.Fatalf("runtime member %q was not extracted", name) - } - } -} - -func TestZipArchiveRejectsMemberAndExpandedResourceOverflow(t *testing.T) { - root := t.TempDir() - tests := []struct { - name string - names []string - limits archiveResourceLimits - wantError string - }{ - { - name: "member count", - names: []string{"one", "two", "three"}, - limits: testArchiveLimits(2, 1024, 4096), - wantError: "member safety limit", - }, - { - name: "declared member bytes", - names: []string{"oversized"}, - limits: testArchiveLimits(4, 4, 4096), - wantError: "expanded safety limit", - }, - { - name: "declared aggregate bytes", - names: []string{"one", "two"}, - limits: testArchiveLimits(4, 1024, 15), - wantError: "aggregate expanded safety limit", - }, - } - for _, testCase := range tests { - t.Run(testCase.name, func(t *testing.T) { - archivePath := filepath.Join(root, strings.ReplaceAll(testCase.name, " ", "-")+".zip") - writeZip(t, archivePath, testCase.names) - destination := filepath.Join(root, strings.ReplaceAll(testCase.name, " ", "-")+"-extract") - if err := os.Mkdir(destination, 0755); err != nil { - t.Fatal(err) - } - _, err := extractZipWithLimits( - archivePath, - destination, - testCase.names, - testCase.names[:1], - "standard", - testCase.limits, - ) - if err == nil || !strings.Contains(err.Error(), testCase.wantError) { - t.Fatalf("zip resource overflow error = %v", err) - } - }) - } -} - -func writeTestRuntimeSet( - t *testing.T, - directory, binaryName, tag, variant string, -) string { +func writeTestWindowsPair(t *testing.T, directory, tag string) { t.Helper() if err := os.MkdirAll(directory, 0755); err != nil { t.Fatal(err) } if err := os.WriteFile( - filepath.Join(directory, binaryName), []byte("binary:"+tag), 0755, - ); err != nil { + filepath.Join(directory, windowsLauncherName), + []byte("launcher:"+tag), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile( - filepath.Join(directory, integrationsFileName), - []byte("integrations:"+tag), - 0644, - ); err != nil { + filepath.Join(directory, windowsPayloadName), + []byte("payload:"+tag), 0755); err != nil { t.Fatal(err) } - pack := "" - if variant == "ui" { - packContents := []byte("pack:" + tag) - digest := sha256.Sum256(packContents) - pack = "cbm-ui-" + hex.EncodeToString(digest[:]) + ".pack" - if err := os.WriteFile( - filepath.Join(directory, pack), packContents, 0644, - ); err != nil { - t.Fatal(err) - } - } - return pack } -func verifyTestBinary(path string) error { - contents, err := os.ReadFile(path) +func verifyTestWindowsPair(launcherPath string) error { + launcher, err := os.ReadFile(launcherPath) if err != nil { return err } - if !strings.HasPrefix(string(contents), "binary:") { - return fmt.Errorf("invalid test binary") + payload, err := os.ReadFile( + filepath.Join(filepath.Dir(launcherPath), windowsPayloadName)) + if err != nil { + return err + } + const prefix = "launcher:" + if !strings.HasPrefix(string(launcher), prefix) { + return fmt.Errorf("invalid launcher test fixture") + } + tag := strings.TrimPrefix(string(launcher), prefix) + if string(payload) != "payload:"+tag { + return fmt.Errorf("launcher/payload test fixture mismatch") } return nil } -func TestRuntimeReadinessRequiresTheCompleteSelectedVariant(t *testing.T) { - binary := "codebase-memory-mcp" - standard := filepath.Join(t.TempDir(), "standard") - writeTestRuntimeSet(t, standard, binary, "standard", "standard") - if !runtimeSetReady(standard, binary, "standard", verifyTestBinary) { - t.Fatal("complete standard runtime set is not ready") - } - if err := os.Remove(filepath.Join(standard, integrationsFileName)); err != nil { - t.Fatal(err) - } - if runtimeSetReady(standard, binary, "standard", verifyTestBinary) { - t.Fatal("runtime set without integrations sidecar is ready") - } - - ui := filepath.Join(t.TempDir(), "ui") - writeTestRuntimeSet(t, ui, binary, "ui", "ui") - if !runtimeSetReady(ui, binary, "ui", verifyTestBinary) { - t.Fatal("complete UI runtime set is not ready") - } - uiNames, ok := runtimeSetNames(ui, binary, "ui") - if !ok { - t.Fatal("complete UI runtime set has no member list") - } - pack := uiNames[1] - if err := os.WriteFile(filepath.Join(ui, pack), []byte("corrupt"), 0644); err != nil { - t.Fatal(err) - } - if runtimeSetReady(ui, binary, "ui", verifyTestBinary) { - t.Fatal("UI runtime set accepts pack bytes that do not match the filename digest") - } - writeTestRuntimeSet(t, ui, binary, "ui", "ui") - secondPack := "cbm-ui-" + strings.Repeat("b", 64) + ".pack" - if err := os.WriteFile(filepath.Join(ui, secondPack), []byte("extra"), 0644); err != nil { - t.Fatal(err) - } - if runtimeSetReady(ui, binary, "ui", verifyTestBinary) { - t.Fatal("UI runtime set with two packs is ready") - } -} - -func assertRuntimeTag( - t *testing.T, - directory, binaryName, tag, variant string, -) { +func assertTestWindowsPair(t *testing.T, directory, tag string) { t.Helper() - checks := map[string]string{ - binaryName: "binary:" + tag, - integrationsFileName: "integrations:" + tag, + launcher, err := os.ReadFile(filepath.Join(directory, windowsLauncherName)) + if err != nil { + t.Fatal(err) } - if variant == "ui" { - packContents := []byte("pack:" + tag) - digest := sha256.Sum256(packContents) - checks["cbm-ui-"+hex.EncodeToString(digest[:])+".pack"] = "pack:" + tag + payload, err := os.ReadFile(filepath.Join(directory, windowsPayloadName)) + if err != nil { + t.Fatal(err) } - for name, want := range checks { - contents, err := os.ReadFile(filepath.Join(directory, name)) - if err != nil { - t.Fatal(err) - } - if string(contents) != want { - t.Fatalf("%s = %q, want %q", name, contents, want) - } + if string(launcher) != "launcher:"+tag || string(payload) != "payload:"+tag { + t.Fatalf("pair = %q / %q, want coherent tag %q", launcher, payload, tag) } } -func TestRuntimePublicationRepairsPartialCache(t *testing.T) { - root := t.TempDir() - source := filepath.Join(root, "source") - destination := filepath.Join(root, "destination") - binary := "codebase-memory-mcp" - writeTestRuntimeSet(t, source, binary, "candidate", "ui") - if err := os.Mkdir(destination, 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile( - filepath.Join(destination, binary), []byte("corrupt"), 0755, - ); err != nil { - t.Fatal(err) - } - stalePack := "cbm-ui-" + strings.Repeat("b", 64) + ".pack" - if err := os.WriteFile( - filepath.Join(destination, stalePack), []byte("stale"), 0644, - ); err != nil { - t.Fatal(err) - } - - if err := publishRuntimeSetWithRecovery( - source, destination, binary, "ui", verifyTestBinary, - ); err != nil { - t.Fatal(err) - } - assertRuntimeTag(t, destination, binary, "candidate", "ui") - if _, err := os.Stat(filepath.Join(destination, stalePack)); !os.IsNotExist(err) { - t.Fatal("stale UI pack survived runtime publication") - } -} - -func TestRuntimePublicationRepairsDigestMismatchedPack(t *testing.T) { - root := t.TempDir() - source := filepath.Join(root, "source") - destination := filepath.Join(root, "destination") - binary := "codebase-memory-mcp" - writeTestRuntimeSet(t, source, binary, "candidate", "ui") - oldPack := writeTestRuntimeSet(t, destination, binary, "old", "ui") - if err := os.WriteFile( - filepath.Join(destination, oldPack), []byte("corrupt pack"), 0644, - ); err != nil { - t.Fatal(err) - } - if runtimeSetReady(destination, binary, "ui", verifyTestBinary) { - t.Fatal("digest-mismatched destination pack was accepted before repair") - } - - if err := publishRuntimeSetWithRecovery( - source, destination, binary, "ui", verifyTestBinary, - ); err != nil { - t.Fatal(err) - } - assertRuntimeTag(t, destination, binary, "candidate", "ui") - if _, err := os.Stat(filepath.Join(destination, oldPack)); !os.IsNotExist(err) { - t.Fatal("digest-mismatched UI pack survived runtime publication") - } -} - -func TestRuntimePublicationCommitsSidecarsBeforeBinary(t *testing.T) { - root := t.TempDir() - source := filepath.Join(root, "source") - destination := filepath.Join(root, "destination") - binary := "codebase-memory-mcp" - pack := writeTestRuntimeSet(t, source, binary, "candidate", "ui") - committed := []string{} - renameFile := func(sourcePath, destinationPath string) error { - name := filepath.Base(destinationPath) - if filepath.Dir(destinationPath) == destination && - (name == binary || name == integrationsFileName || name == pack) { - committed = append(committed, name) - } - return os.Rename(sourcePath, destinationPath) - } - if err := publishRuntimeSetWithRecoveryAndRenamer( - source, - destination, - binary, - "ui", - verifyTestBinary, - renameFile, - ); err != nil { - t.Fatal(err) - } - want := []string{integrationsFileName, pack, binary} - if !reflect.DeepEqual(committed, want) { - t.Fatalf("runtime commit order = %q, want %q", committed, want) - } -} - -func TestRuntimePublicationCrashHelper(t *testing.T) { - if os.Getenv("CBM_TEST_RUNTIME_CRASH_HELPER") != "1" { - return - } - source := os.Getenv("CBM_TEST_RUNTIME_CRASH_SOURCE") - destination := os.Getenv("CBM_TEST_RUNTIME_CRASH_DESTINATION") - marker := os.Getenv("CBM_TEST_RUNTIME_CRASH_MARKER") - crashPhase := os.Getenv("CBM_TEST_RUNTIME_CRASH_PHASE") - binary := "codebase-memory-mcp" - reachCrashGate := func() error { - if err := os.WriteFile(marker, []byte("reached\n"), 0600); err != nil { - return err - } - for { - time.Sleep(time.Hour) - } - } - priorCrashObserver := runtimeBackupCrashObserver - defer func() { runtimeBackupCrashObserver = priorCrashObserver }() - runtimeBackupCrashObserver = func(event, _ string) error { - if event == crashPhase { - return reachCrashGate() - } - return nil - } - renameFile := func(sourcePath, destinationPath string) error { - if err := os.Rename(sourcePath, destinationPath); err != nil { - return err - } - backupParent := filepath.Base(filepath.Dir(destinationPath)) - if crashPhase == "retired-executable" && - runtimeBackupDirectoryName(backupParent) && - filepath.Base(destinationPath) == binary { - return reachCrashGate() - } - if crashPhase == "published-integrations" && - destinationPath == filepath.Join(destination, integrationsFileName) { - return reachCrashGate() - } - if crashPhase == "published-binary" && - destinationPath == filepath.Join(destination, binary) { - return reachCrashGate() - } - return nil - } - if err := publishRuntimeSetWithRecoveryAndRenamer( - source, - destination, - binary, - "standard", - verifyTestBinary, - renameFile, - ); err != nil { - t.Fatal(err) - } -} - -func TestKilledRuntimePublisherIsReconciledByLockedReadiness(t *testing.T) { - for _, testCase := range []struct { - name string - crashPhase string - expectedReady bool - expectedBackupMembers int - expectRetiredMarker bool - expectCleanupMarker bool +func TestWindowsPairPublicationRepairsCorruptAndPartialCaches(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, destination string) }{ { - name: "executable retired before other leaves", - crashPhase: "retired-executable", - expectedReady: false, - expectedBackupMembers: 1, + name: "corrupt launcher", + setup: func(t *testing.T, destination string) { + writeTestWindowsPair(t, destination, "old") + if err := os.WriteFile( + filepath.Join(destination, windowsLauncherName), + []byte("corrupt"), 0755); err != nil { + t.Fatal(err) + } + }, }, { - name: "all leaves retired before retirement marker", - crashPhase: runtimeBackupBeforeMarkerEvent, - expectedReady: false, - expectedBackupMembers: 2, + name: "corrupt payload", + setup: func(t *testing.T, destination string) { + writeTestWindowsPair(t, destination, "old") + if err := os.WriteFile( + filepath.Join(destination, windowsPayloadName), + []byte("corrupt"), 0755); err != nil { + t.Fatal(err) + } + }, }, { - name: "partial sidecar publish", - crashPhase: "published-integrations", - expectedReady: false, - expectedBackupMembers: 2, - expectRetiredMarker: true, + name: "launcher only", + setup: func(t *testing.T, destination string) { + if err := os.MkdirAll(destination, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(destination, windowsLauncherName), + []byte("launcher:partial"), 0755); err != nil { + t.Fatal(err) + } + }, }, - { - name: "complete publish before cleanup", - crashPhase: "published-binary", - expectedReady: true, - expectedBackupMembers: 2, - expectRetiredMarker: true, - }, - { - name: "cleanup interrupted after one retired member", - crashPhase: runtimeBackupCleanupRemovedEvent, - expectedReady: true, - expectedBackupMembers: 1, - expectRetiredMarker: true, - expectCleanupMarker: true, - }, - } { + } + + for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { root := t.TempDir() source := filepath.Join(root, "source") destination := filepath.Join(root, "destination") - marker := filepath.Join(root, "crash-reached") - binary := "codebase-memory-mcp" - writeTestRuntimeSet(t, source, binary, "candidate", "standard") - writeTestRuntimeSet(t, destination, binary, "old", "standard") - if err := os.WriteFile( - filepath.Join(destination, binary), []byte("corrupt:old"), 0755, - ); err != nil { - t.Fatal(err) - } + writeTestWindowsPair(t, source, "candidate") + testCase.setup(t, destination) - command := exec.Command( - os.Args[0], "-test.run=^TestRuntimePublicationCrashHelper$", - ) - command.Env = append( - os.Environ(), - "CBM_TEST_RUNTIME_CRASH_HELPER=1", - "CBM_TEST_RUNTIME_CRASH_SOURCE="+source, - "CBM_TEST_RUNTIME_CRASH_DESTINATION="+destination, - "CBM_TEST_RUNTIME_CRASH_MARKER="+marker, - "CBM_TEST_RUNTIME_CRASH_PHASE="+testCase.crashPhase, - ) - var stderr bytes.Buffer - command.Stderr = &stderr - if err := command.Start(); err != nil { + if err := installWindowsPairAtomicallyWithVerifier( + source, destination, verifyTestWindowsPair); err != nil { t.Fatal(err) } - waitResult := make(chan error, 1) - go func() { waitResult <- command.Wait() }() - finished := false - defer func() { - if !finished { - _ = command.Process.Kill() - <-waitResult - } - }() - - deadline := time.Now().Add(10 * time.Second) - for { - if _, err := os.Stat(marker); err == nil { - break - } - select { - case err := <-waitResult: - finished = true - t.Fatalf( - "publication helper exited before crash gate: %v: %s", - err, stderr.String(), - ) - default: - } - if time.Now().After(deadline) { - t.Fatalf( - "publication helper did not reach crash gate: %s", - stderr.String(), - ) - } - time.Sleep(10 * time.Millisecond) - } - - entries, err := os.ReadDir(destination) - if err != nil { - t.Fatal(err) - } - backupCount := 0 - for _, entry := range entries { - if runtimeBackupDirectoryName(entry.Name()) { - backupCount++ - if !entry.IsDir() { - t.Fatal("killed publisher backup transaction is not a directory") - } - backupPath := filepath.Join(destination, entry.Name()) - backupEntries, err := os.ReadDir(backupPath) - if err != nil { - t.Fatal(err) - } - memberCount := 0 - for _, backupEntry := range backupEntries { - if runtimeBackupTargetName(backupEntry.Name(), binary) { - memberCount++ - } - } - if memberCount != testCase.expectedBackupMembers { - t.Fatalf( - "backup member count at crash gate = %d, want %d", - memberCount, testCase.expectedBackupMembers, - ) - } - for markerName, expected := range map[string]bool{ - runtimeBackupRetired: testCase.expectRetiredMarker, - runtimeBackupCleanupOnly: testCase.expectCleanupMarker, - } { - _, markerErr := os.Stat(filepath.Join(backupPath, markerName)) - if expected && markerErr != nil { - t.Fatalf("expected backup marker %s is missing: %v", markerName, markerErr) - } - if !expected && !os.IsNotExist(markerErr) { - t.Fatalf("unexpected backup marker %s exists", markerName) - } - } - } - } - if backupCount != 1 { - t.Fatalf("killed publisher backup count = %d, want 1", backupCount) - } - - if err := command.Process.Kill(); err != nil { - t.Fatal(err) - } - if err := <-waitResult; err == nil { - t.Fatal("publication helper was not killed") - } - finished = true - - ready, err := runtimeSetReadyLocked( - destination, binary, "standard", verifyTestBinary, - ) - if err != nil { - t.Fatal(err) - } - if ready != testCase.expectedReady { - t.Fatalf( - "reconciled readiness = %v, want %v", - ready, testCase.expectedReady, - ) - } - if !ready { - contents, err := os.ReadFile(filepath.Join(destination, binary)) - if err != nil { - t.Fatal(err) - } - if string(contents) != "corrupt:old" { - t.Fatalf("recovered prior binary = %q", contents) - } - contents, err = os.ReadFile( - filepath.Join(destination, integrationsFileName), - ) - if err != nil { - t.Fatal(err) - } - if string(contents) != "integrations:old" { - t.Fatalf("recovered prior integrations = %q", contents) - } - if err := publishRuntimeSetWithRecovery( - source, destination, binary, "standard", verifyTestBinary, - ); err != nil { - t.Fatal(err) - } - } - assertRuntimeTag( - t, destination, binary, "candidate", "standard", - ) - entries, err = os.ReadDir(destination) - if err != nil { - t.Fatal(err) - } - for _, entry := range entries { - if strings.HasPrefix(entry.Name(), runtimeBackupPrefix) { - t.Fatalf("orphan backup survived recovery: %s", entry.Name()) - } - } - if _, err := os.Stat(filepath.Join( - destination, runtimeSetLockName, - )); !os.IsNotExist(err) { - t.Fatal("runtime-set lock survived killed-process recovery") - } + assertTestWindowsPair(t, destination, "candidate") }) } } -func TestConcurrentRuntimePublishersAreSerialized(t *testing.T) { +func TestWindowsPairPublicationSerializesConcurrentCandidates(t *testing.T) { root := t.TempDir() - firstSource := filepath.Join(root, "source-first") - secondSource := filepath.Join(root, "source-second") destination := filepath.Join(root, "destination") - binary := "codebase-memory-mcp" - firstPack := writeTestRuntimeSet( - t, firstSource, binary, "first", "ui", - ) - secondPack := writeTestRuntimeSet( - t, secondSource, binary, "second", "ui", - ) - - firstPaused := make(chan struct{}) - releaseFirst := make(chan struct{}) - secondWaiting := make(chan struct{}) - secondEnteredPublication := make(chan struct{}) - firstResult := make(chan error, 1) - secondResult := make(chan error, 1) - var pauseOnce sync.Once - var waitOnce sync.Once - var enteredOnce sync.Once - priorObserver := runtimeSetLockWaitObserver - runtimeSetLockWaitObserver = func() { - waitOnce.Do(func() { close(secondWaiting) }) - } - defer func() { runtimeSetLockWaitObserver = priorObserver }() - - firstRenamer := func(sourcePath, destinationPath string) error { - if err := os.Rename(sourcePath, destinationPath); err != nil { - return err - } - if destinationPath == filepath.Join(destination, integrationsFileName) { - pauseOnce.Do(func() { - close(firstPaused) - <-releaseFirst - }) - } - return nil - } - secondRenamer := func(sourcePath, destinationPath string) error { - name := filepath.Base(destinationPath) - if filepath.Dir(destinationPath) == destination && - (name == binary || name == integrationsFileName || name == secondPack) { - enteredOnce.Do(func() { close(secondEnteredPublication) }) - } - return os.Rename(sourcePath, destinationPath) - } - - go func() { - firstResult <- publishRuntimeSetWithRecoveryAndRenamer( - firstSource, - destination, - binary, - "ui", - verifyTestBinary, - firstRenamer, - ) - }() - select { - case <-firstPaused: - case <-time.After(5 * time.Second): - t.Fatal("first publisher did not reach the held publication gate") - } - - go func() { - secondResult <- publishRuntimeSetWithRecoveryAndRenamer( - secondSource, - destination, - binary, - "ui", - verifyTestBinary, - secondRenamer, - ) - }() - - concurrentPublication := false - var secondErr error - select { - case <-secondWaiting: - // The serialized implementation reaches this branch while the first - // publisher still owns the held publication gate. - case <-secondEnteredPublication: - concurrentPublication = true - select { - case secondErr = <-secondResult: - case <-time.After(5 * time.Second): - close(releaseFirst) - t.Fatal("concurrent second publisher did not finish") - } - case <-time.After(5 * time.Second): - close(releaseFirst) - t.Fatal("second publisher neither waited nor entered publication") - } - close(releaseFirst) - - var firstErr error - select { - case firstErr = <-firstResult: - case <-time.After(5 * time.Second): - t.Fatal("first publisher did not finish after its gate was released") - } - if !concurrentPublication { - select { - case secondErr = <-secondResult: - case <-time.After(5 * time.Second): - t.Fatal("serialized second publisher did not finish") - } - } - if concurrentPublication { - t.Fatal("second publisher entered publication while the first sequence was held") - } - if firstErr != nil { - t.Fatalf("first publisher failed: %v", firstErr) - } - if secondErr != nil { - t.Fatalf("serialized second publisher failed: %v", secondErr) - } - assertRuntimeTag(t, destination, binary, "first", "ui") - if _, err := os.Stat(filepath.Join(destination, secondPack)); !os.IsNotExist(err) { - t.Fatal("serialized losing publisher damaged the winning runtime set") - } - if _, err := os.Stat(filepath.Join(destination, firstPack)); err != nil { - t.Fatalf("winning UI pack is missing: %v", err) - } -} - -func TestConcurrentPublisherWaitsForRollbackBeforePublishing(t *testing.T) { - root := t.TempDir() - firstSource := filepath.Join(root, "source-first") - secondSource := filepath.Join(root, "source-second") - destination := filepath.Join(root, "destination") - binary := "codebase-memory-mcp" - firstPack := writeTestRuntimeSet( - t, firstSource, binary, "first", "ui", - ) - secondPack := writeTestRuntimeSet( - t, secondSource, binary, "second", "ui", - ) - oldPack := writeTestRuntimeSet(t, destination, binary, "old", "ui") - verifier := func(path string) error { - contents, err := os.ReadFile(path) - if err != nil { - return err - } - if string(contents) == "binary:old" { - return fmt.Errorf("old runtime set requires repair") - } - return verifyTestBinary(path) - } - - firstPaused := make(chan struct{}) - releaseFirst := make(chan struct{}) - secondWaiting := make(chan struct{}) - secondEnteredPublication := make(chan struct{}) - firstResult := make(chan error, 1) - secondResult := make(chan error, 1) - var pauseOnce sync.Once - var waitOnce sync.Once - var enteredOnce sync.Once - priorObserver := runtimeSetLockWaitObserver - runtimeSetLockWaitObserver = func() { - waitOnce.Do(func() { close(secondWaiting) }) - } - defer func() { runtimeSetLockWaitObserver = priorObserver }() - - failedBinary := false - firstRenamer := func(sourcePath, destinationPath string) error { - if destinationPath == filepath.Join(destination, binary) && !failedBinary { - failedBinary = true - return fmt.Errorf("injected binary publication failure") - } - if err := os.Rename(sourcePath, destinationPath); err != nil { - return err - } - if destinationPath == filepath.Join(destination, integrationsFileName) { - pauseOnce.Do(func() { - close(firstPaused) - <-releaseFirst - }) - } - return nil - } - secondRenamer := func(sourcePath, destinationPath string) error { - name := filepath.Base(destinationPath) - if filepath.Dir(destinationPath) == destination && - (name == binary || name == integrationsFileName || name == secondPack) { - enteredOnce.Do(func() { close(secondEnteredPublication) }) - } - return os.Rename(sourcePath, destinationPath) - } - - go func() { - firstResult <- publishRuntimeSetWithRecoveryAndRenamer( - firstSource, - destination, - binary, - "ui", - verifier, - firstRenamer, - ) - }() - select { - case <-firstPaused: - case <-time.After(5 * time.Second): - t.Fatal("failing publisher did not reach the held publication gate") - } - - go func() { - secondResult <- publishRuntimeSetWithRecoveryAndRenamer( - secondSource, - destination, - binary, - "ui", - verifier, - secondRenamer, - ) - }() - - concurrentPublication := false - var secondErr error - select { - case <-secondWaiting: - case <-secondEnteredPublication: - concurrentPublication = true - select { - case secondErr = <-secondResult: - case <-time.After(5 * time.Second): - close(releaseFirst) - t.Fatal("concurrent second publisher did not finish") - } - case <-time.After(5 * time.Second): - close(releaseFirst) - t.Fatal("second publisher neither waited nor entered failing publication") - } - close(releaseFirst) - - var firstErr error - select { - case firstErr = <-firstResult: - case <-time.After(5 * time.Second): - t.Fatal("failing publisher did not finish rollback") - } - if !concurrentPublication { - select { - case secondErr = <-secondResult: - case <-time.After(5 * time.Second): - t.Fatal("publisher waiting for rollback did not finish") - } - } - if concurrentPublication { - t.Fatal("second publisher entered publication before rollback completed") - } - if firstErr == nil || !failedBinary { - t.Fatal("first publisher did not report the injected publication failure") - } - if secondErr != nil { - t.Fatalf("publisher after rollback failed: %v", secondErr) - } - assertRuntimeTag(t, destination, binary, "second", "ui") - for _, obsoletePack := range []string{firstPack, oldPack} { - if _, err := os.Stat(filepath.Join(destination, obsoletePack)); !os.IsNotExist(err) { - t.Fatalf("obsolete UI pack survived serialized rollback: %s", obsoletePack) - } - } - if _, err := os.Stat(filepath.Join(destination, secondPack)); err != nil { - t.Fatalf("post-rollback winner's UI pack is missing: %v", err) - } - if !runtimeSetReady(destination, binary, "ui", verifier) { - t.Fatal("post-rollback winner is incomplete") - } -} - -func TestRuntimePublicationFailureRestoresPriorCompleteSet(t *testing.T) { - root := t.TempDir() - source := filepath.Join(root, "source") - destination := filepath.Join(root, "destination") - binary := "codebase-memory-mcp" - candidatePack := writeTestRuntimeSet( - t, source, binary, "candidate", "ui", - ) - oldPack := writeTestRuntimeSet(t, destination, binary, "old", "ui") - verifier := func(path string) error { - contents, err := os.ReadFile(path) - if err != nil { - return err - } - if string(contents) == "binary:old" { - return fmt.Errorf("old runtime set requires repair") - } - return verifyTestBinary(path) - } - failed := false - renameFile := func(sourcePath, destinationPath string) error { - if destinationPath == filepath.Join(destination, binary) && !failed { - failed = true - return fmt.Errorf("injected binary publication failure") - } - return os.Rename(sourcePath, destinationPath) - } - - err := publishRuntimeSetWithRecoveryAndRenamer( - source, - destination, - binary, - "ui", - verifier, - renameFile, - ) - if err == nil { - t.Fatal("injected publication failure unexpectedly succeeded") - } - if !failed { - t.Fatal("test did not reach the injected binary publication failure") - } - assertRuntimeTag(t, destination, binary, "old", "ui") - if !runtimeSetReady(destination, binary, "ui", nil) { - t.Fatal("rollback did not restore a structurally complete prior set") - } - if _, err := os.Stat(filepath.Join(destination, candidatePack)); !os.IsNotExist(err) { - t.Fatal("failed candidate UI pack survived rollback") - } - if _, err := os.Stat(filepath.Join(destination, oldPack)); err != nil { - t.Fatalf("rolled-back UI pack is missing: %v", err) - } - if _, err := os.Stat(filepath.Join(destination, runtimeSetLockName)); !os.IsNotExist(err) { - t.Fatal("runtime-set lock survived rollback") - } -} - -func TestMutationSnapshotReleasesCacheLockAndCleansAfterLaunchFailure(t *testing.T) { - directory := t.TempDir() - binary := "codebase-memory-mcp" - executable := filepath.Join(directory, binary) - writeTestRuntimeSet(t, directory, binary, "cached", "standard") - var snapshotExecutable string - runnerCalled := false - err := execBinaryWithRuntimeLockAndRunner( - executable, - []string{"install", "--yes"}, - "standard", - verifyTestBinary, - func(candidate string, args []string) error { - runnerCalled = true - snapshotExecutable = candidate - if reflect.DeepEqual(args, []string{"install", "--yes"}) == false { - t.Fatalf("snapshot mutation args = %q", args) - } - if filepath.Dir(candidate) == directory { - t.Fatal("mutation launched from the shared package cache") - } - status, err := os.Lstat(filepath.Dir(candidate)) - if err != nil { - t.Fatal(err) - } - if !status.IsDir() || - (runtime.GOOS != "windows" && status.Mode().Perm()&0077 != 0) { - t.Fatalf("mutation snapshot mode = %v, want owner-private", status.Mode()) - } - assertRuntimeTag( - t, filepath.Dir(candidate), binary, "cached", "standard", - ) - if _, err := os.Stat(filepath.Join( - directory, runtimeSetLockName, - )); !os.IsNotExist(err) { - t.Fatal("cache lock remained held when snapshot runner started") - } - if err := os.WriteFile( - executable, []byte("binary:successor"), 0755, - ); err != nil { - t.Fatal(err) - } - contents, err := os.ReadFile(candidate) - if err != nil { - t.Fatal(err) - } - if string(contents) != "binary:cached" { - t.Fatalf("cache mutation changed private snapshot: %q", contents) - } - return fmt.Errorf("injected snapshot launch failure") - }, - ) - if err == nil || !strings.Contains(err.Error(), "injected snapshot launch failure") { - t.Fatalf("mutation launch failure = %v", err) - } - if !runnerCalled { - t.Fatal("verified mutation snapshot was not launched") - } - if _, err := os.Stat(filepath.Dir(snapshotExecutable)); !os.IsNotExist(err) { - t.Fatal("failed mutation left its private runtime snapshot") - } - if _, err := os.Stat(filepath.Join( - directory, runtimeSetLockName, - )); !os.IsNotExist(err) { - t.Fatal("failed mutation left the package-cache lock") - } -} - -func TestMutationSnapshotEnvironmentIgnoresExternalAssetOverrides(t *testing.T) { - t.Setenv("CBM_ASSETS_DIR", filepath.Join(t.TempDir(), "integrations")) - t.Setenv("CBM_UI_ASSETS_DIR", filepath.Join(t.TempDir(), "ui")) - for _, entry := range mutationSnapshotEnvironment() { - name := entry - if separator := strings.IndexByte(entry, '='); separator >= 0 { - name = entry[:separator] - } - if strings.EqualFold(name, "CBM_ASSETS_DIR") || - strings.EqualFold(name, "CBM_UI_ASSETS_DIR") { - t.Fatalf("mutation snapshot environment retained %q", entry) - } - } -} - -func TestMutationSnapshotRejectsExplicitTargetOverlappingCache(t *testing.T) { - directory := t.TempDir() - executable := filepath.Join(directory, "codebase-memory-mcp") - for _, args := range [][]string{ - {"install", "--dir", directory}, - {"uninstall", "--dir=" + directory}, - } { - runnerCalled := false - err := execBinaryWithRuntimeLockAndRunner( - executable, - args, - "standard", - nil, - func(string, []string) error { - runnerCalled = true - return nil - }, - ) - if err == nil || !strings.Contains(err.Error(), "overlaps the shared package cache") { - t.Fatalf("overlapping mutation target error = %v", err) - } - if runnerCalled { - t.Fatal("overlapping cache mutation target was launched") - } - } -} - -func TestMutationSnapshotVerifierFailureNeverLaunches(t *testing.T) { - directory := t.TempDir() - binary := "codebase-memory-mcp" - executable := filepath.Join(directory, binary) - writeTestRuntimeSet(t, directory, binary, "cached", "standard") - verifierCalls := 0 - verifier := func(path string) error { - verifierCalls++ - if filepath.Dir(path) != directory { - return fmt.Errorf("injected private snapshot verification failure") - } - return verifyTestBinary(path) - } - runnerCalled := false - err := execBinaryWithRuntimeLockAndRunner( - executable, - []string{"install", "--yes"}, - "standard", - verifier, - func(string, []string) error { - runnerCalled = true - return nil - }, - ) - if err == nil || !strings.Contains(err.Error(), "failed verification") { - t.Fatalf("snapshot verifier failure = %v", err) - } - if verifierCalls != 2 { - t.Fatalf("snapshot verifier calls = %d, want source and snapshot", verifierCalls) - } - if runnerCalled { - t.Fatal("mutation runner was invoked after snapshot verification failure") - } - if _, err := os.Stat(filepath.Join( - directory, runtimeSetLockName, - )); !os.IsNotExist(err) { - t.Fatal("snapshot verification failure left the package-cache lock") - } -} - -func TestMutationSnapshotPreservesCompleteUIRuntimeSet(t *testing.T) { - directory := t.TempDir() - binary := "codebase-memory-mcp" - executable := filepath.Join(directory, binary) - pack := writeTestRuntimeSet(t, directory, binary, "ui-cached", "ui") - var snapshotDirectory string - err := execBinaryWithRuntimeLockAndRunner( - executable, - []string{"install", "--yes"}, - "ui", - verifyTestBinary, - func(candidate string, _ []string) error { - snapshotDirectory = filepath.Dir(candidate) - names, ok := runtimeSetNames(snapshotDirectory, binary, "ui") - want := []string{integrationsFileName, pack, binary} - if !ok || !reflect.DeepEqual(names, want) { - t.Fatalf("UI mutation snapshot names = %q, want %q", names, want) - } - assertRuntimeTag( - t, snapshotDirectory, binary, "ui-cached", "ui", - ) - return nil - }, - ) - if err != nil { + if err := os.MkdirAll(destination, 0755); err != nil { t.Fatal(err) } - if _, err := os.Stat(snapshotDirectory); !os.IsNotExist(err) { - t.Fatal("successful UI mutation left its private runtime snapshot") - } -} + sources := []string{filepath.Join(root, "source-a"), filepath.Join(root, "source-b")} + writeTestWindowsPair(t, sources[0], "a") + writeTestWindowsPair(t, sources[1], "b") -func TestMutationSnapshotCleanupFailurePreservesNativeResult(t *testing.T) { - const exitHelper = "CBM_GO_MUTATION_EXIT_HELPER" - if os.Getenv(exitHelper) == "1" { - os.Exit(7) + start := make(chan struct{}) + errors := make(chan error, len(sources)) + var wait sync.WaitGroup + for _, source := range sources { + wait.Add(1) + go func(candidate string) { + defer wait.Done() + <-start + errors <- installWindowsPairAtomicallyWithVerifier( + candidate, destination, verifyTestWindowsPair) + }(source) } - exitCommand := exec.Command( - os.Args[0], - "-test.run=^TestMutationSnapshotCleanupFailurePreservesNativeResult$", - ) - exitCommand.Env = append(os.Environ(), exitHelper+"=1") - nativeErr := exitCommand.Run() - exitErr, ok := nativeErr.(*exec.ExitError) - if !ok { - t.Fatalf("exit helper error = %T %v", nativeErr, nativeErr) - } - - priorCleanup := runtimeMutationSnapshotCleanup - defer func() { runtimeMutationSnapshotCleanup = priorCleanup }() - runtimeMutationSnapshotCleanup = func(path string) error { - if err := os.RemoveAll(path); err != nil { - return err - } - return errors.New("injected private snapshot cleanup failure") - } - for _, testCase := range []struct { - name string - runnerErr error - wantResult error - }{ - {name: "native success remains success"}, - {name: "native exit error identity survives", runnerErr: exitErr, wantResult: exitErr}, - } { - t.Run(testCase.name, func(t *testing.T) { - directory := t.TempDir() - binary := "codebase-memory-mcp" - writeTestRuntimeSet(t, directory, binary, "cached", "standard") - result := execBinaryWithRuntimeLockAndRunner( - filepath.Join(directory, binary), - []string{"install", "--yes"}, - "standard", - verifyTestBinary, - func(string, []string) error { return testCase.runnerErr }, - ) - if result != testCase.wantResult { - t.Fatalf("native result identity changed: got %v, want %v", result, testCase.wantResult) - } - if exitErr != nil && testCase.wantResult != nil { - var recovered *exec.ExitError - if !errors.As(result, &recovered) || recovered != exitErr { - t.Fatal("native ExitError was not preserved through cleanup failure") - } - } - }) - } -} - -func TestMutationSnapshotParentDeathLeavesCacheRecoverable(t *testing.T) { - const roleEnvironment = "CBM_GO_MUTATION_SNAPSHOT_ROLE" - role := os.Getenv(roleEnvironment) - if role == "child" { - executable, err := os.Executable() + close(start) + wait.Wait() + close(errors) + for err := range errors { if err != nil { t.Fatal(err) } - info := fmt.Sprintf("%d\n%s\n", os.Getpid(), executable) - childInfo := os.Getenv("CBM_GO_MUTATION_CHILD_INFO") - stagedChildInfo := childInfo + ".tmp" - if err := os.WriteFile(stagedChildInfo, []byte(info), 0600); err != nil { - t.Fatal(err) - } - if err := os.Rename(stagedChildInfo, childInfo); err != nil { - t.Fatal(err) - } - continuePath := os.Getenv("CBM_GO_MUTATION_CHILD_CONTINUE") - deadline := time.Now().Add(20 * time.Second) - for { - if _, err := os.Stat(continuePath); err == nil { - break - } else if !os.IsNotExist(err) { - t.Fatal(err) - } - if !time.Now().Before(deadline) { - t.Fatal("mutation child timed out waiting for successor publication") - } - time.Sleep(10 * time.Millisecond) - } - integrationBytes, err := os.ReadFile(filepath.Join( - filepath.Dir(executable), integrationsFileName, - )) - if err != nil { - t.Fatal(err) - } - if string(integrationBytes) != "integrations:parent-death" { - t.Fatalf("mutation child sidecar changed: %q", integrationBytes) - } - if err := os.WriteFile( - os.Getenv("CBM_GO_MUTATION_CHILD_SURVIVED"), - []byte("survived\n"), - 0600, - ); err != nil { - t.Fatal(err) - } - return } - if role == "parent" { - directory := os.Getenv("CBM_GO_MUTATION_CACHE") - executable := filepath.Join( - directory, binaryNameForOS(runtime.GOOS), - ) - runner := func(candidate string, args []string) error { - command := exec.Command(candidate, args...) - childEnvironment := make([]string, 0, len(os.Environ())+1) - for _, entry := range os.Environ() { - if !strings.HasPrefix(entry, roleEnvironment+"=") { - childEnvironment = append(childEnvironment, entry) - } - } - command.Env = append(childEnvironment, roleEnvironment+"=child") - return command.Run() - } - if err := execBinaryWithRuntimeLockAndRunner( - executable, - []string{ - "-test.run=^TestMutationSnapshotParentDeathLeavesCacheRecoverable$", - }, - "standard", - nil, - runner, - ); err != nil { - t.Fatal(err) - } - t.Fatal("mutation parent returned before the child was released") - } - - root := t.TempDir() - directory := filepath.Join(root, "cache") - if err := os.Mkdir(directory, 0755); err != nil { + if err := verifyTestWindowsPair( + filepath.Join(destination, windowsLauncherName)); err != nil { t.Fatal(err) } - binary := binaryNameForOS(runtime.GOOS) - stagedExecutable, err := copyRuntimeStage( - os.Args[0], directory, true, nil, - ) + launcher, err := os.ReadFile(filepath.Join(destination, windowsLauncherName)) if err != nil { t.Fatal(err) } - cacheExecutable := filepath.Join(directory, binary) - if err := os.Rename(stagedExecutable, cacheExecutable); err != nil { - t.Fatal(err) - } - if err := os.WriteFile( - filepath.Join(directory, integrationsFileName), - []byte("integrations:parent-death"), - 0644, - ); err != nil { - t.Fatal(err) - } - childInfo := filepath.Join(root, "child-info") - childContinue := filepath.Join(root, "child-continue") - childSurvived := filepath.Join(root, "child-survived") - command := exec.Command( - os.Args[0], - "-test.run=^TestMutationSnapshotParentDeathLeavesCacheRecoverable$", - ) - command.Env = append( - os.Environ(), - roleEnvironment+"=parent", - "CBM_GO_MUTATION_CACHE="+directory, - "CBM_GO_MUTATION_CHILD_INFO="+childInfo, - "CBM_GO_MUTATION_CHILD_CONTINUE="+childContinue, - "CBM_GO_MUTATION_CHILD_SURVIVED="+childSurvived, - ) - var stderr bytes.Buffer - command.Stderr = &stderr - if err := command.Start(); err != nil { - t.Fatal(err) - } - waitResult := make(chan error, 1) - go func() { waitResult <- command.Wait() }() - parentFinished := false - childPID := 0 - snapshotExecutable := "" - defer func() { - if !parentFinished { - _ = command.Process.Kill() - <-waitResult - } - if childPID > 0 && runtimeSetProcessAlive(childPID) { - if child, err := os.FindProcess(childPID); err == nil { - _ = child.Kill() - } - } - if snapshotExecutable != "" { - _ = os.RemoveAll(filepath.Dir(snapshotExecutable)) - } - }() - - deadline := time.Now().Add(10 * time.Second) - for { - contents, err := os.ReadFile(childInfo) - if err == nil { - lines := strings.Split(strings.TrimSpace(string(contents)), "\n") - if len(lines) != 2 { - t.Fatalf("mutation child info = %q", contents) - } - childPID, err = strconv.Atoi(lines[0]) - if err != nil { - t.Fatal(err) - } - snapshotExecutable = lines[1] - break - } - if !os.IsNotExist(err) { - t.Fatal(err) - } - select { - case err := <-waitResult: - parentFinished = true - t.Fatalf("mutation parent exited before child start: %v: %s", err, stderr.String()) - default: - } - if !time.Now().Before(deadline) { - t.Fatalf("mutation child did not start: %s", stderr.String()) - } - time.Sleep(10 * time.Millisecond) - } - if filepath.Dir(snapshotExecutable) == directory { - t.Fatal("mutation child executed from the shared package cache") - } - if _, err := os.Stat(filepath.Join( - directory, runtimeSetLockName, - )); !os.IsNotExist(err) { - t.Fatal("mutation parent retained the cache lock while child was running") - } - if err := command.Process.Kill(); err != nil { - t.Fatal(err) - } - if err := <-waitResult; err == nil { - t.Fatal("mutation parent was not hard-killed") - } - parentFinished = true - if !runtimeSetProcessAlive(childPID) { - t.Fatal("hard-killing the wrapper also stopped the mutation child") - } - - source := filepath.Join(root, "successor") - writeTestRuntimeSet(t, source, binary, "successor", "standard") - if err := publishRuntimeSetWithRecovery( - source, directory, binary, "standard", verifyTestBinary, - ); err != nil { - t.Fatalf("successor publication after parent death failed: %v", err) - } - assertRuntimeTag(t, directory, binary, "successor", "standard") - if err := os.WriteFile(childContinue, []byte("continue\n"), 0600); err != nil { - t.Fatal(err) - } - deadline = time.Now().Add(10 * time.Second) - for { - if _, err := os.Stat(childSurvived); err == nil { - break - } else if !os.IsNotExist(err) { - t.Fatal(err) - } - if !time.Now().Before(deadline) { - t.Fatal("snapshot child did not survive successor cache publication") - } - time.Sleep(10 * time.Millisecond) - } - if _, err := os.Stat(filepath.Join( - directory, runtimeSetLockName, - )); !os.IsNotExist(err) { - t.Fatal("successor recovery left the package-cache lock") - } -} - -func TestRuntimeSetLockReclaimsOnlyDefinitelyDeadOwner(t *testing.T) { - destination := t.TempDir() - lockPath := filepath.Join(destination, runtimeSetLockName) - if err := os.Mkdir(lockPath, 0700); err != nil { - t.Fatal(err) - } - ownerToken := strings.Repeat("a", runtimeSetLockTokenSize*2) - if err := writeRuntimeSetLockOwner(lockPath, ownerToken); err != nil { - t.Fatal(err) - } - contenderToken := strings.Repeat("b", runtimeSetLockTokenSize*2) - priorProcessAlive := runtimeSetProcessAlive - defer func() { runtimeSetProcessAlive = priorProcessAlive }() - - runtimeSetProcessAlive = func(int) bool { return true } - if runtimeSetTryReclaimLock(lockPath, contenderToken) { - t.Fatal("runtime-set lock reclaimed an owner that may still be live") - } - if _, err := os.Stat(lockPath); err != nil { - t.Fatalf("live owner's runtime-set lock was damaged: %v", err) - } - - runtimeSetProcessAlive = func(int) bool { return false } - if !runtimeSetTryReclaimLock(lockPath, contenderToken) { - t.Fatal("runtime-set lock did not reclaim a definitely dead owner") - } - if _, err := os.Stat(lockPath); !os.IsNotExist(err) { - t.Fatal("reclaimed dead-owner runtime-set lock still exists") - } -} - -func TestRuntimeSetLiveOwnerSkipsIdentityCapture(t *testing.T) { - destination := t.TempDir() - lock, err := acquireRuntimeSetLock(destination) - if err != nil { - t.Fatal(err) - } - released := false - defer func() { - if !released { - if err := releaseRuntimeSetLock(lock); err != nil { - t.Errorf("release live runtime-set lock: %v", err) - } - } - }() - - priorObserver := runtimeSetLockCaptureObserver - priorProcessAlive := runtimeSetProcessAlive - defer func() { - runtimeSetLockCaptureObserver = priorObserver - runtimeSetProcessAlive = priorProcessAlive - }() - captures := 0 - runtimeSetLockCaptureObserver = func() { captures++ } - runtimeSetProcessAlive = func(int) bool { return true } - - contenderToken := strings.Repeat("b", runtimeSetLockTokenSize*2) - if runtimeSetTryReclaimLock(lock.path, contenderToken) { - t.Fatal("runtime-set lock reclaimed a proven-live owner") - } - if captures != 0 { - t.Fatalf("live runtime-set lock identity was captured %d times", captures) - } - if err := releaseRuntimeSetLock(lock); err != nil { - t.Fatalf("release live runtime-set lock: %v", err) - } - released = true -} - -func TestRuntimeSetLockReclaimsOnlyStaleOwnerlessDirectory(t *testing.T) { - destination := t.TempDir() - lockPath := filepath.Join(destination, runtimeSetLockName) - if err := os.Mkdir(lockPath, 0700); err != nil { - t.Fatal(err) - } - contenderToken := strings.Repeat("c", runtimeSetLockTokenSize*2) - if runtimeSetTryReclaimLock(lockPath, contenderToken) { - t.Fatal("fresh ownerless runtime-set lock was reclaimed") - } - staleTime := time.Now().Add(-runtimeSetOwnerlessStale - time.Second) - if err := os.Chtimes(lockPath, staleTime, staleTime); err != nil { - t.Fatal(err) - } - if !runtimeSetTryReclaimLock(lockPath, contenderToken) { - t.Fatal("stale ownerless runtime-set lock was not reclaimed") - } - if _, err := os.Stat(lockPath); !os.IsNotExist(err) { - t.Fatal("reclaimed ownerless runtime-set lock still exists") - } -} - -func TestExpiredLeaseNeverStealsProvenLiveOwner(t *testing.T) { - destination := t.TempDir() - lockPath := filepath.Join(destination, runtimeSetLockName) - record := runtimeSetLockOwnerRecord{ - PID: os.Getpid(), Token: strings.Repeat("a", runtimeSetLockTokenSize*2), - LeaseExpires: time.Now().Add(-time.Second).UnixMilli(), - } - contents, err := json.Marshal(record) - if err != nil { - t.Fatal(err) - } - contents = append(contents, '\n') - if err := os.WriteFile(lockPath, contents, 0600); err != nil { - t.Fatal(err) - } - before, err := os.Lstat(lockPath) - if err != nil { - t.Fatal(err) - } - priorProcessAlive := runtimeSetProcessAlive - defer func() { runtimeSetProcessAlive = priorProcessAlive }() - runtimeSetProcessAlive = func(int) bool { return true } - contender := strings.Repeat("b", runtimeSetLockTokenSize*2) - if runtimeSetTryReclaimLock(lockPath, contender) { - t.Fatal("runtime-set lock stole an expired lease from a proven-live owner") - } - after, err := os.Lstat(lockPath) - if err != nil { - t.Fatalf("live owner's expired lock was removed: %v", err) - } - if !runtimeSetSameLockObject(before, after) { - t.Fatal("live owner's expired lock identity changed") - } - afterContents, err := os.ReadFile(lockPath) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(afterContents, contents) { - t.Fatalf("live owner's expired lock contents changed: %q", afterContents) - } -} - -func TestLiveLegacyLockIsNotReclaimedSolelyByAge(t *testing.T) { - destination := t.TempDir() - lockPath := filepath.Join(destination, runtimeSetLockName) - if err := os.Mkdir(lockPath, 0700); err != nil { - t.Fatal(err) - } - if err := writeRuntimeSetLockOwner( - lockPath, strings.Repeat("a", runtimeSetLockTokenSize*2), - ); err != nil { - t.Fatal(err) - } - stale := time.Now().Add(-runtimeSetLegacyStale - time.Second) - if err := os.Chtimes(lockPath, stale, stale); err != nil { - t.Fatal(err) - } - priorProcessAlive := runtimeSetProcessAlive - defer func() { runtimeSetProcessAlive = priorProcessAlive }() - runtimeSetProcessAlive = func(int) bool { return true } - if runtimeSetTryReclaimLock( - lockPath, strings.Repeat("b", runtimeSetLockTokenSize*2), - ) { - t.Fatal("live legacy runtime-set lock was reclaimed solely by age") - } - if _, err := os.Stat(lockPath); err != nil { - t.Fatalf("live legacy runtime-set lock was damaged: %v", err) - } -} - -func TestStalledRuntimeLockCreatorNeverDeletesSuccessor(t *testing.T) { - destination := t.TempDir() - var successor *runtimeSetLock - var successorErr error - priorObserver := runtimeSetLockClaimObserver - defer func() { runtimeSetLockClaimObserver = priorObserver }() - runtimeSetLockClaimObserver = func() error { - runtimeSetLockClaimObserver = nil - successor, successorErr = acquireRuntimeSetLock(destination) - return fmt.Errorf("injected stalled creator abort") - } - - if first, err := acquireRuntimeSetLock(destination); err == nil { - _ = releaseRuntimeSetLock(first) - t.Fatal("stalled creator unexpectedly acquired over its successor") - } else if !strings.Contains(err.Error(), "stalled creator abort") { - t.Fatalf("stalled creator failed for wrong reason: %v", err) - } - if successorErr != nil || successor == nil { - t.Fatalf("successor did not acquire reclaimed lock: %v", successorErr) - } - if _, err := os.Stat(successor.path); err != nil { - t.Fatalf("failed creator deleted successor lock: %v", err) - } - if err := releaseRuntimeSetLock(successor); err != nil { - t.Fatal(err) - } -} - -func TestRuntimeSetLockReleaseRetiresDescriptor(t *testing.T) { - destination := t.TempDir() - lock, err := acquireRuntimeSetLock(destination) - if err != nil { - t.Fatal(err) - } - if lock.file == nil { - t.Fatal("acquired runtime-set lock has no writable descriptor") - } - if err := releaseRuntimeSetLock(lock); err != nil { - t.Fatal(err) - } - if lock.file != nil { - t.Fatal("released runtime-set lock retained its closed descriptor") - } - if _, err := os.Lstat(lock.path); !os.IsNotExist(err) { - t.Fatalf("released runtime-set lock remained at its canonical path: %v", err) - } -} - -func TestRuntimeReadinessRejectsMultiplyLinkedLeaves(t *testing.T) { - directory := t.TempDir() - binary := "codebase-memory-mcp" - writeTestRuntimeSet(t, directory, binary, "linked", "standard") - if err := os.Link( - filepath.Join(directory, integrationsFileName), - filepath.Join(directory, "integrations-hardlink"), - ); err != nil { - t.Skipf("filesystem does not support hard links: %v", err) - } - if runtimeSetReady(directory, binary, "standard", verifyTestBinary) { - t.Fatal("runtime set accepted a multiply-linked integrations leaf") - } -} - -func TestWindowsLongRuntimePathPublishesAndBecomesReady(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip("Windows long-path regression") - } - root := t.TempDir() - source := filepath.Join(root, "source") - destination := filepath.Join(root, "runtime") - for len(destination) <= 300 { - destination = filepath.Join( - destination, "long-runtime-directory-segment", - ) - } - if len(destination) <= 260 { - t.Fatalf("test runtime path is not long: %d", len(destination)) - } - binary := windowsBinaryName - writeTestRuntimeSet(t, source, binary, "long-path", "standard") - if err := publishRuntimeSetWithRecovery( - source, destination, binary, "standard", verifyTestBinary, - ); err != nil { - t.Fatalf("publication under a long Windows runtime path failed: %v", err) - } - ready, err := runtimeSetReadyLocked( - destination, binary, "standard", verifyTestBinary, - ) - if err != nil { - t.Fatalf("locked readiness under a long Windows runtime path failed: %v", err) - } - if !ready { - t.Fatal("published long-path Windows runtime set was not ready") - } -} - -func TestOrphanReconciliationRejectsMultiplyLinkedBackupMembers(t *testing.T) { - directory := t.TempDir() - binary := "codebase-memory-mcp" - backup := filepath.Join( - directory, runtimeBackupPrefix+strings.Repeat("a", 32), - ) - if err := os.Mkdir(backup, 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile( - filepath.Join(backup, runtimeBackupRetired), nil, 0600, - ); err != nil { - t.Fatal(err) - } - member := filepath.Join(backup, binary) - if err := os.WriteFile(member, []byte("binary:old"), 0755); err != nil { - t.Fatal(err) - } - if err := os.Link( - member, filepath.Join(directory, "backup-hardlink-copy"), - ); err != nil { - t.Skipf("filesystem does not support hard links: %v", err) - } - - ready, err := runtimeSetReadyLocked( - directory, binary, "standard", verifyTestBinary, - ) - if err == nil || !strings.Contains( - err.Error(), "unsafe package-cache backup member", - ) { - t.Fatalf("unsafe orphan reconciliation = (%v, %v)", ready, err) - } - if _, err := os.Stat(backup); err != nil { - t.Fatalf("unsafe backup was mutated: %v", err) - } - if _, err := os.Stat(filepath.Join( - directory, runtimeSetLockName, - )); !os.IsNotExist(err) { - t.Fatal("runtime-set lock survived rejected orphan reconciliation") - } -} - -func TestRuntimeSetLockSerializesProcesses(t *testing.T) { - const helperEnv = "CBM_GO_RUNTIME_LOCK_HELPER" - if os.Getenv(helperEnv) == "1" { - destination := os.Getenv("CBM_GO_RUNTIME_LOCK_DIRECTORY") - waitingPath := os.Getenv("CBM_GO_RUNTIME_LOCK_WAITING") - acquiredPath := os.Getenv("CBM_GO_RUNTIME_LOCK_ACQUIRED") - var waitOnce sync.Once - runtimeSetLockWaitObserver = func() { - waitOnce.Do(func() { - if err := os.WriteFile(waitingPath, []byte("waiting"), 0600); err != nil { - t.Fatal(err) - } - }) - } - lock, err := acquireRuntimeSetLock(destination) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(acquiredPath, []byte("acquired"), 0600); err != nil { - _ = releaseRuntimeSetLock(lock) - t.Fatal(err) - } - if err := releaseRuntimeSetLock(lock); err != nil { - t.Fatal(err) - } - return - } - - destination := t.TempDir() - waitingPath := filepath.Join(t.TempDir(), "waiting") - acquiredPath := filepath.Join(t.TempDir(), "acquired") - lock, err := acquireRuntimeSetLock(destination) - if err != nil { - t.Fatal(err) - } - released := false - defer func() { - if !released { - _ = releaseRuntimeSetLock(lock) - } - }() - - cmd := exec.Command(os.Args[0], "-test.run=^TestRuntimeSetLockSerializesProcesses$") - cmd.Env = append( - os.Environ(), - helperEnv+"=1", - "CBM_GO_RUNTIME_LOCK_DIRECTORY="+destination, - "CBM_GO_RUNTIME_LOCK_WAITING="+waitingPath, - "CBM_GO_RUNTIME_LOCK_ACQUIRED="+acquiredPath, - ) - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - waitResult := make(chan error, 1) - go func() { waitResult <- cmd.Wait() }() - childFinished := false - defer func() { - if !childFinished { - _ = cmd.Process.Kill() - <-waitResult - } - }() - - deadline := time.Now().Add(5 * time.Second) - for { - select { - case err := <-waitResult: - childFinished = true - t.Fatalf( - "runtime-set lock helper exited before waiting: %v\nstdout: %s\nstderr: %s", - err, - stdout.String(), - stderr.String(), - ) - default: - } - if _, err := os.Stat(waitingPath); err == nil { - break - } else if !os.IsNotExist(err) { - t.Fatal(err) - } - if !time.Now().Before(deadline) { - _ = cmd.Process.Kill() - <-waitResult - childFinished = true - t.Fatalf( - "child did not report waiting on parent lock\nstdout: %s\nstderr: %s", - stdout.String(), - stderr.String(), - ) - } - time.Sleep(5 * time.Millisecond) - } - if _, err := os.Stat(acquiredPath); err == nil { - t.Fatal("child acquired the runtime-set lock while the parent owned it") - } else if !os.IsNotExist(err) { - t.Fatal(err) - } - if err := releaseRuntimeSetLock(lock); err != nil { - t.Fatal(err) - } - released = true - select { - case err := <-waitResult: - childFinished = true - if err != nil { - t.Fatalf( - "runtime-set lock helper failed: %v\nstdout: %s\nstderr: %s", - err, - stdout.String(), - stderr.String(), - ) - } - case <-time.After(5 * time.Second): - _ = cmd.Process.Kill() - <-waitResult - childFinished = true - t.Fatal("child did not acquire the released runtime-set lock") - } - if _, err := os.Stat(acquiredPath); err != nil { - t.Fatalf("child never acquired the released runtime-set lock: %v", err) + tag := strings.TrimPrefix(string(launcher), "launcher:") + if tag != "a" && tag != "b" { + t.Fatalf("unexpected concurrent winner tag %q", tag) } + assertTestWindowsPair(t, destination, tag) } diff --git a/pkg/homebrew/Formula/codebase-memory-mcp.rb b/pkg/homebrew/Formula/codebase-memory-mcp.rb index 715bd00d8..98736781c 100644 --- a/pkg/homebrew/Formula/codebase-memory-mcp.rb +++ b/pkg/homebrew/Formula/codebase-memory-mcp.rb @@ -28,7 +28,6 @@ class CodebaseMemoryMcp < Formula def install bin.install "codebase-memory-mcp" - (share/"codebase-memory-mcp").install "cbm-integrations.json" # Third-party attribution bundle (present in archives since v0.8.1) doc.install "THIRD_PARTY_NOTICES.md" if File.exist?("THIRD_PARTY_NOTICES.md") end @@ -46,6 +45,5 @@ class CodebaseMemoryMcp < Formula test do assert_match "codebase-memory-mcp", shell_output("#{bin}/codebase-memory-mcp --version") - assert_path_exists share/"codebase-memory-mcp/cbm-integrations.json" end end diff --git a/pkg/npm/install.js b/pkg/npm/install.js index 892a6d314..2c3ea9b6c 100644 --- a/pkg/npm/install.js +++ b/pkg/npm/install.js @@ -13,39 +13,28 @@ const { pipeline } = require('stream'); const REPO = 'DeusData/codebase-memory-mcp'; const VERSION = require('./package.json').version; -const BIN_ROOT = path.join(__dirname, 'bin'); +const BIN_DIR = path.join(__dirname, 'bin'); const MAX_REDIRECTS = 5; const DOWNLOAD_HOP_TIMEOUT_MS = 120_000; const CANDIDATE_TIMEOUT_MS = 15_000; const MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024; const WINDOWS_BINARY_NAME = 'codebase-memory-mcp.exe'; -const INTEGRATIONS_NAME = 'cbm-integrations.json'; -const UI_PACK_PATTERN = /^cbm-ui-[0-9a-f]{64}\.pack$/; const UNIX_ARCHIVE_NAMES = [ 'codebase-memory-mcp', - INTEGRATIONS_NAME, 'LICENSE', 'install.sh', 'THIRD_PARTY_NOTICES.md', ]; const WINDOWS_ARCHIVE_NAMES = [ WINDOWS_BINARY_NAME, - INTEGRATIONS_NAME, 'LICENSE', 'install.ps1', 'THIRD_PARTY_NOTICES.md', ]; -const RUNTIME_LOCK_NAME = '.codebase-memory-mcp-runtime.lock'; -const RUNTIME_LOCK_WAIT_MS = 45_000; -const RUNTIME_OWNERLESS_STALE_MS = 30_000; -const RUNTIME_LOCK_LEASE_MS = 300_000; -const RUNTIME_LOCK_HEARTBEAT_MS = 20_000; -const RUNTIME_LEGACY_STALE_MS = 3_600_000; -const RUNTIME_LOCK_POLL_MS = 25; -const RUNTIME_BACKUP_PREFIX = '.cbm-runtime-backup-'; -const RUNTIME_BACKUP_DIRECTORY_PATTERN = /^\.cbm-runtime-backup-[0-9a-f]{32}$/; -const RUNTIME_BACKUP_RETIRED_MARKER = '.retirement-complete'; -const RUNTIME_BACKUP_CLEANUP_MARKER = '.cleanup-only'; +const WINDOWS_PAIR_LOCK_NAME = '.codebase-memory-mcp-pair.lock'; +const WINDOWS_PAIR_LOCK_WAIT_MS = 45_000; +const WINDOWS_PAIR_OWNERLESS_STALE_MS = 30_000; +const WINDOWS_PAIR_LOCK_POLL_MS = 25; const SLEEP_WORD = new Int32Array(new SharedArrayBuffer(4)); function getPlatform() { @@ -80,7 +69,7 @@ function validateUrl(rawUrl) { return parsed.href; } -function validateExactTarMemberListing(listing, expectedNames, ui = false) { +function validateExactTarMemberListing(listing, expectedNames) { if (listing.includes('\0')) { throw new Error('tar member listing contains a NUL byte'); } @@ -88,61 +77,34 @@ function validateExactTarMemberListing(listing, expectedNames, ui = false) { if (members[members.length - 1] === '') members.pop(); const expected = new Set(expectedNames); const seen = new Set(); - let uiPackName = null; for (const member of members) { - const isUiPack = ui && UI_PACK_PATTERN.test(member); - if (!member || (!expected.has(member) && !isUiPack) || seen.has(member) || - (isUiPack && uiPackName !== null)) { + if (!member || !expected.has(member) || seen.has(member)) { throw new Error(`archive contains an unexpected or duplicate root member: ${JSON.stringify(member)}`); } seen.add(member); - if (isUiPack) uiPackName = member; } - const expectedCount = expectedNames.length + (ui ? 1 : 0); - if (members.length !== expectedCount || seen.size !== expectedCount || - expectedNames.some((name) => !seen.has(name)) || (ui && uiPackName === null)) { + if (members.length !== expectedNames.length || seen.size !== expected.size) { throw new Error( - `archive must contain exactly these root files${ui ? ' plus one content-addressed UI pack' : ''}: ${expectedNames.join(', ')}`, + `archive must contain exactly these root files: ${expectedNames.join(', ')}`, ); } - return uiPackName; -} - -function validateRegularTarMemberListing(listing, expectedCount) { - const entries = listing.split(/\r?\n/).filter((line) => line.length > 0); - if (entries.length !== expectedCount || - entries.some((line) => line[0] !== '-')) { - throw new Error('archive members must be independent regular files (no links)'); - } } function extractExactTarArchive( - archivePath, destPath, expectedNames, targetNames, ui = false, - runFile = execFileSync, + archivePath, destPath, expectedNames, targetName, runFile = execFileSync, ) { const listing = runFile('tar', ['-tzf', archivePath], { encoding: 'utf8', maxBuffer: MAX_CHECKSUM_MANIFEST_BYTES, windowsHide: true, }); - const uiPackName = validateExactTarMemberListing(listing, expectedNames, ui); - const verboseListing = runFile('tar', ['-tvzf', archivePath], { - encoding: 'utf8', - maxBuffer: MAX_CHECKSUM_MANIFEST_BYTES, - windowsHide: true, - }); - validateRegularTarMemberListing( - verboseListing, expectedNames.length + (ui ? 1 : 0), - ); - const names = Array.isArray(targetNames) ? [...targetNames] : [targetNames]; - if (uiPackName !== null) names.push(uiPackName); - // Extract only the authenticated runtime set. Even a malformed tar - // implementation cannot write an unvalidated non-runtime member. - runFile('tar', ['-xzf', archivePath, '-C', destPath, ...names], { + validateExactTarMemberListing(listing, expectedNames); + // Extract only the fixed root executable. Even a malformed tar implementation + // cannot write an unvalidated companion member outside the private temp tree. + runFile('tar', ['-xzf', archivePath, '-C', destPath, targetName], { stdio: 'inherit', windowsHide: true, }); - return names; } function downloadHop(url, dest, maxBytes) { @@ -242,7 +204,7 @@ function parseExpectedChecksum(manifest, archiveName) { } function verifyCandidate(candidatePath) { - execFileSync(candidatePath, ['--verify-runtime-assets'], { + execFileSync(candidatePath, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], timeout: CANDIDATE_TIMEOUT_MS, windowsHide: true, @@ -265,103 +227,23 @@ function fileSha256(candidatePath) { return digest.digest('hex'); } +function filesEqualSha256(leftPath, rightPath) { + try { + const left = fs.statSync(leftPath); + const right = fs.statSync(rightPath); + return left.isFile() + && right.isFile() + && left.size === right.size + && fileSha256(leftPath) === fileSha256(rightPath); + } catch (_) { + return false; + } +} + function sleepSync(milliseconds) { Atomics.wait(SLEEP_WORD, 0, 0, milliseconds); } -function runtimeVariant(env = process.env) { - return (env.CBM_VARIANT || '').toLowerCase() === 'ui' ? 'ui' : 'standard'; -} - -function cacheDirForVariant(variant) { - return path.join(BIN_ROOT, variant); -} - -function requireSafeRuntimeDirectory(directory) { - const status = fs.lstatSync(directory); - if (!status.isDirectory() || status.isSymbolicLink()) { - throw new Error(`refusing unsafe package-cache directory: ${directory}`); - } -} - -function regularFile(pathname) { - try { - const status = fs.lstatSync(pathname); - return status.isFile() && !status.isSymbolicLink() && status.nlink === 1; - } catch (_) { - return false; - } -} - -function uiPackMatchesDigest(directory, name) { - try { - const expected = name.slice('cbm-ui-'.length, -'.pack'.length); - return fileSha256(path.join(directory, name)) === expected; - } catch (_) { - return false; - } -} - -function runtimeSetNames(directory, binaryName, variant) { - if (!regularFile(path.join(directory, binaryName)) || - !regularFile(path.join(directory, INTEGRATIONS_NAME))) { - return null; - } - let packLikeNames; - try { - packLikeNames = fs.readdirSync(directory) - .filter((name) => name.startsWith('cbm-ui-') && name.endsWith('.pack')); - } catch (_) { - return null; - } - const validPackNames = packLikeNames.filter( - (name) => UI_PACK_PATTERN.test(name) && - regularFile(path.join(directory, name)) && - uiPackMatchesDigest(directory, name), - ); - if (variant === 'ui') { - if (packLikeNames.length !== 1 || validPackNames.length !== 1) { - return null; - } - } else if (packLikeNames.length !== 0) { - return null; - } - return [ - INTEGRATIONS_NAME, - ...(variant === 'ui' ? validPackNames : []), - binaryName, - ]; -} - -function runtimeSetReady( - directory, binaryName, variant, verifier = verifyCandidate, -) { - if (runtimeSetNames(directory, binaryName, variant) === null) return false; - try { - verifier(path.join(directory, binaryName)); - return true; - } catch (_) { - return false; - } -} - -function runtimeSetReadyLocked( - directory, binaryName, variant, verifier = verifyCandidate, -) { - fs.mkdirSync(directory, { recursive: true, mode: 0o755 }); - requireSafeRuntimeDirectory(directory); - const lock = acquireRuntimeLock(directory); - try { - refreshRuntimeLock(lock); - reconcileRuntimeBackups( - directory, binaryName, variant, verifier, lock, - ); - return runtimeSetReady(directory, binaryName, variant, verifier); - } finally { - releaseRuntimeLock(lock); - } -} - function processIsAlive(pid) { if (!Number.isSafeInteger(pid) || pid <= 0) return false; try { @@ -374,69 +256,33 @@ function processIsAlive(pid) { } } -function lockPathStatus(lockPath) { +function readBinaryLockOwner(lockPath) { try { - return fs.lstatSync(lockPath, { bigint: true }); - } catch (_) { - return null; - } -} - -function sameLockObject(left, right) { - return left !== null && right !== null && - left.dev === right.dev && left.ino === right.ino && - left.isFile() === right.isFile() && left.isDirectory() === right.isDirectory(); -} - -function readRuntimeLockOwner(lockPath) { - try { - const status = fs.lstatSync(lockPath); - if (status.isSymbolicLink() || (!status.isFile() && !status.isDirectory())) { - return null; - } - const ownerPath = status.isDirectory() - ? path.join(lockPath, 'owner.json') - : lockPath; const owner = JSON.parse( - fs.readFileSync(ownerPath, 'utf8'), + fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'), ); if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0 || typeof owner.token !== 'string' || !/^[0-9a-f]{32}$/.test(owner.token)) { return null; } - const leaseExpiresMs = Number.isSafeInteger(owner.lease_expires_ms) - ? owner.lease_expires_ms - : null; - return { ...owner, lease_expires_ms: leaseExpiresMs }; + return owner; } catch (_) { return null; } } -function restoreDisplacedRuntimeLock(reclaimed, lockPath, status) { - if (!status || !status.isFile()) return; - try { - fs.linkSync(reclaimed, lockPath); - fs.unlinkSync(reclaimed); - } catch (_) { - // A new owner already holds the canonical name. Preserve the displaced - // object for its owner to diagnose; never delete an unverified successor. - } -} - -function tryReclaimRuntimeLock(lockPath, contenderToken) { - const observedStatus = lockPathStatus(lockPath); - if (observedStatus === null) return true; - if (observedStatus.isSymbolicLink() || - (!observedStatus.isFile() && !observedStatus.isDirectory())) { - return false; - } - let reclaim; - const owner = readRuntimeLockOwner(lockPath); +function tryReclaimBinaryLock(lockPath, contenderToken) { + let reclaim = false; + const owner = readBinaryLockOwner(lockPath); if (owner) { reclaim = !processIsAlive(owner.pid); } else { - reclaim = Date.now() - Number(observedStatus.mtimeMs) >= RUNTIME_OWNERLESS_STALE_MS; + try { + const age = Date.now() - fs.statSync(lockPath).mtimeMs; + reclaim = age >= WINDOWS_PAIR_OWNERLESS_STALE_MS; + } catch (_) { + return true; // The owner released it between observation and inspection. + } } if (!reclaim) return false; @@ -447,467 +293,152 @@ function tryReclaimRuntimeLock(lockPath, contenderToken) { if (err.code === 'ENOENT') return true; return false; } - const movedStatus = lockPathStatus(reclaimed); - if (!sameLockObject(observedStatus, movedStatus)) { - restoreDisplacedRuntimeLock(reclaimed, lockPath, movedStatus); - return false; - } - const movedOwner = readRuntimeLockOwner(reclaimed); - const stillReclaimable = movedOwner === null - ? Date.now() - Number(movedStatus.mtimeMs) >= RUNTIME_OWNERLESS_STALE_MS - : !processIsAlive(movedOwner.pid); - if (!stillReclaimable) { - restoreDisplacedRuntimeLock(reclaimed, lockPath, movedStatus); - return false; - } - if (movedStatus.isDirectory()) { - // Legacy directory locks are accepted only as real, non-symlink - // directories, then quarantined and removed after identity revalidation. - fs.rmSync(reclaimed, { recursive: true, force: true }); - } else { - fs.unlinkSync(reclaimed); - } + fs.rmSync(reclaimed, { recursive: true, force: true }); return true; } -function writeRuntimeLockOwner(fd, token) { - const record = Buffer.from(`${JSON.stringify({ - pid: process.pid, - token, - lease_expires_ms: Date.now() + RUNTIME_LOCK_LEASE_MS, - })}\n`, 'utf8'); - fs.ftruncateSync(fd, 0); - let offset = 0; - while (offset < record.length) { - offset += fs.writeSync(fd, record, offset, record.length - offset, offset); - } - fs.fsyncSync(fd); -} - -function assertRuntimeLockOwner(lock) { - const descriptorStatus = fs.fstatSync(lock.fd, { bigint: true }); - const canonicalStatus = lockPathStatus(lock.lockPath); - const owner = readRuntimeLockOwner(lock.lockPath); - if (!sameLockObject(descriptorStatus, canonicalStatus) || !owner || - owner.token !== lock.token || owner.pid !== process.pid) { - throw new Error('package-cache publication lock ownership changed'); - } -} - -function refreshRuntimeLock(lock) { - assertRuntimeLockOwner(lock); - writeRuntimeLockOwner(lock.fd, lock.token); - assertRuntimeLockOwner(lock); -} - -function acquireRuntimeLock(destDir, beforeClaimPublish = null) { - const lockPath = path.join(destDir, RUNTIME_LOCK_NAME); +function acquireWindowsBinaryLock(destDir) { + const lockPath = path.join(destDir, WINDOWS_PAIR_LOCK_NAME); const token = crypto.randomBytes(16).toString('hex'); - const claimPath = `${lockPath}.claim-${token}`; - const deadline = Date.now() + RUNTIME_LOCK_WAIT_MS; + const deadline = Date.now() + WINDOWS_PAIR_LOCK_WAIT_MS; for (;;) { - let fd = null; try { - fd = fs.openSync(claimPath, 'wx', 0o600); + fs.mkdirSync(lockPath, { mode: 0o700 }); try { - writeRuntimeLockOwner(fd, token); - if (beforeClaimPublish) beforeClaimPublish(lockPath); - fs.linkSync(claimPath, lockPath); - fs.unlinkSync(claimPath); - const descriptorStatus = fs.fstatSync(fd, { bigint: true }); - if (!sameLockObject(descriptorStatus, lockPathStatus(lockPath))) { - throw new Error('package-cache publication lock ownership changed'); - } + fs.writeFileSync( + path.join(lockPath, 'owner.json'), + `${JSON.stringify({ pid: process.pid, token })}\n`, + { encoding: 'utf8', flag: 'wx', mode: 0o600 }, + ); } catch (err) { - fs.closeSync(fd); - fd = null; - try { fs.unlinkSync(claimPath); } catch (_) { /* unique claim is gone */ } + fs.rmSync(lockPath, { recursive: true, force: true }); throw err; } - return { lockPath, token, fd }; + return { lockPath, token }; } catch (err) { - if (fd !== null) { - try { fs.closeSync(fd); } catch (_) { /* already closed */ } - } if (err.code !== 'EEXIST') throw err; - if (tryReclaimRuntimeLock(lockPath, token)) continue; + if (tryReclaimBinaryLock(lockPath, token)) continue; if (Date.now() >= deadline) { - throw new Error('timed out waiting for package-cache publication lock'); + throw new Error('timed out waiting for Windows package-cache publication lock'); } - sleepSync(RUNTIME_LOCK_POLL_MS); + sleepSync(WINDOWS_PAIR_LOCK_POLL_MS); } } } -function releaseRuntimeLock(lock) { - try { - assertRuntimeLockOwner(lock); - } catch (error) { - try { fs.closeSync(lock.fd); } catch (_) { /* already closed */ } - throw error; +function releaseWindowsBinaryLock(lock) { + const owner = readBinaryLockOwner(lock.lockPath); + if (!owner || owner.token !== lock.token || owner.pid !== process.pid) { + throw new Error('Windows package-cache publication lock ownership changed'); } - const released = `${lock.lockPath}.released-${lock.token}`; + fs.unlinkSync(path.join(lock.lockPath, 'owner.json')); + fs.rmdirSync(lock.lockPath); +} + +function windowsBinaryReady(destDir, verifier = verifyCandidate) { + const binary = path.join(destDir, WINDOWS_BINARY_NAME); try { - fs.renameSync(lock.lockPath, released); - } catch (error) { - fs.closeSync(lock.fd); - throw error; - } - const descriptorStatus = fs.fstatSync(lock.fd, { bigint: true }); - const releasedStatus = lockPathStatus(released); - const owner = readRuntimeLockOwner(released); - if (!sameLockObject(descriptorStatus, releasedStatus) || !owner || - owner.token !== lock.token || owner.pid !== process.pid) { - restoreDisplacedRuntimeLock(released, lock.lockPath, releasedStatus); - fs.closeSync(lock.fd); - throw new Error('package-cache publication lock ownership changed'); - } - try { - fs.unlinkSync(released); - } finally { - fs.closeSync(lock.fd); + const status = fs.lstatSync(binary); + if (!status.isFile() || status.isSymbolicLink()) { + return false; + } + verifier(binary); + return true; + } catch (_) { + return false; } } function pathMatchesDigest(candidatePath, expectedDigest) { try { const status = fs.lstatSync(candidatePath); - return status.isFile() && !status.isSymbolicLink() && status.nlink === 1 && + return status.isFile() && !status.isSymbolicLink() && fileSha256(candidatePath) === expectedDigest; } catch (_) { return false; } } -function copyRuntimeStageWithLease(source, staged, lock) { - const input = fs.openSync(source, 'r'); - let output = null; - const buffer = Buffer.allocUnsafe(1024 * 1024); - let lastRefresh = Date.now(); - try { - output = fs.openSync(staged, 'wx', 0o600); - for (;;) { - const count = fs.readSync(input, buffer, 0, buffer.length, null); - if (count === 0) break; - let offset = 0; - while (offset < count) { - offset += fs.writeSync(output, buffer, offset, count - offset, null); - } - if (Date.now() - lastRefresh >= RUNTIME_LOCK_HEARTBEAT_MS) { - refreshRuntimeLock(lock); - lastRefresh = Date.now(); - } - } - fs.fsyncSync(output); - } catch (error) { - try { fs.unlinkSync(staged); } catch (_) { /* never published */ } - throw error; - } finally { - fs.closeSync(input); - if (output !== null) fs.closeSync(output); - } -} - -// A grouped backup is a tiny crash journal. Before the retired marker, only -// moved members are restored; after it, partial published targets are replaced. -// cleanup-only is written only after the destination is accepted or restored, -// so a second crash can finish deletion without interpreting a partial journal. -function runtimeBackupTargetName(name, binaryName) { - return name === binaryName || name === INTEGRATIONS_NAME || - (name.startsWith('cbm-ui-') && name.endsWith('.pack')); -} - -function createRuntimeBackupDirectory(destDir) { - for (let attempt = 0; attempt < 128; attempt += 1) { - const token = crypto.randomBytes(16).toString('hex'); - const backupDir = path.join(destDir, `${RUNTIME_BACKUP_PREFIX}${token}`); - try { - fs.mkdirSync(backupDir, { mode: 0o700 }); - return backupDir; - } catch (err) { - if (!err || err.code !== 'EEXIST') throw err; - } - } - throw new Error('could not reserve a package-cache backup transaction'); -} - -function createRuntimeBackupMarker(backupDir, markerName) { - const marker = path.join(backupDir, markerName); - const fd = fs.openSync(marker, 'wx', 0o600); - try { - fs.fsyncSync(fd); - } finally { - fs.closeSync(fd); - } -} - -function inspectRuntimeBackupDirectory(destDir, entryName, binaryName) { - const backupDir = path.join(destDir, entryName); - const directoryStatus = fs.lstatSync(backupDir); - if (!directoryStatus.isDirectory() || directoryStatus.isSymbolicLink()) { - throw new Error(`refusing unsafe package-cache backup: ${backupDir}`); - } - const backup = { - path: backupDir, - retired: false, - cleanupOnly: false, - files: new Map(), - }; - for (const name of fs.readdirSync(backupDir).sort()) { - const candidate = path.join(backupDir, name); - const status = fs.lstatSync(candidate); - if (name === RUNTIME_BACKUP_RETIRED_MARKER || - name === RUNTIME_BACKUP_CLEANUP_MARKER) { - if (!status.isFile() || status.isSymbolicLink() || - status.nlink !== 1 || status.size !== 0) { - throw new Error(`refusing unsafe package-cache backup marker: ${candidate}`); - } - if (name === RUNTIME_BACKUP_RETIRED_MARKER) backup.retired = true; - if (name === RUNTIME_BACKUP_CLEANUP_MARKER) backup.cleanupOnly = true; - continue; - } - if (!runtimeBackupTargetName(name, binaryName) || - !status.isFile() || status.isSymbolicLink() || status.nlink !== 1) { - throw new Error(`refusing unsafe package-cache backup member: ${candidate}`); - } - backup.files.set(name, { - path: candidate, - digest: fileSha256(candidate), - }); - } - return backup; -} - -function listRuntimeBackupDirectories(destDir, binaryName) { - return fs.readdirSync(destDir) - .filter((name) => RUNTIME_BACKUP_DIRECTORY_PATTERN.test(name)) - .sort() - .map((name) => inspectRuntimeBackupDirectory(destDir, name, binaryName)); -} - -function currentRuntimeTargets(destDir, binaryName) { - const targets = new Map(); - const names = fs.readdirSync(destDir) - .filter((name) => runtimeBackupTargetName(name, binaryName)) - .sort(); - for (const name of names) { +function removeOwnedPublishedBinary(destDir, publishedDigests) { + for (const name of [WINDOWS_BINARY_NAME]) { + const digest = publishedDigests.get(name); const target = path.join(destDir, name); - const status = fs.lstatSync(target); - if (!status.isFile() || status.isSymbolicLink() || status.nlink !== 1) { - throw new Error(`refusing unsafe package-cache target: ${target}`); + if (digest && pathMatchesDigest(target, digest)) { + try { fs.unlinkSync(target); } catch (_) { /* recovery continues below */ } } - targets.set(name, target); - } - return targets; -} - -function cleanupRuntimeBackup(backup, lock) { - assertRuntimeLockOwner(lock); - if (!backup.cleanupOnly) { - createRuntimeBackupMarker(backup.path, RUNTIME_BACKUP_CLEANUP_MARKER); - backup.cleanupOnly = true; - } - for (const name of [...backup.files.keys()].sort()) { - assertRuntimeLockOwner(lock); - const member = backup.files.get(name); - if (!member || !pathMatchesDigest(member.path, member.digest)) { - throw new Error(`package-cache backup member changed during cleanup: ${name}`); - } - assertRuntimeLockOwner(lock); - fs.unlinkSync(member.path); - } - if (backup.retired) { - assertRuntimeLockOwner(lock); - fs.unlinkSync(path.join(backup.path, RUNTIME_BACKUP_RETIRED_MARKER)); - } - assertRuntimeLockOwner(lock); - fs.unlinkSync(path.join(backup.path, RUNTIME_BACKUP_CLEANUP_MARKER)); - fs.rmdirSync(backup.path); -} - -function copyRuntimeBackupFile(member, target, executable, lock) { - const staged = path.join( - path.dirname(target), - `.cbm-runtime-recovery-${crypto.randomBytes(16).toString('hex')}`, - ); - try { - copyRuntimeStageWithLease(member.path, staged, lock); - fs.chmodSync(staged, executable ? 0o755 : 0o644); - if (fileSha256(staged) !== member.digest) { - throw new Error('package-cache backup changed while it was being restored'); - } - assertRuntimeLockOwner(lock); - fs.renameSync(staged, target); - } finally { - try { fs.unlinkSync(staged); } catch (_) { /* renamed or never completed */ } } } -function reconcileRuntimeBackups( - destDir, binaryName, variant, verifier, lock, -) { - refreshRuntimeLock(lock); - let backups = listRuntimeBackupDirectories(destDir, binaryName); - for (const backup of backups.filter((candidate) => candidate.cleanupOnly)) { - cleanupRuntimeBackup(backup, lock); - } - backups = backups.filter((candidate) => !candidate.cleanupOnly); - if (backups.length === 0) return; - - if (runtimeSetReady(destDir, binaryName, variant, verifier)) { - for (const backup of backups) cleanupRuntimeBackup(backup, lock); - return; - } - if (backups.length === 0) return; - - for (const backup of backups.filter( - (candidate) => !candidate.retired && candidate.files.size === 0, - )) { - cleanupRuntimeBackup(backup, lock); - } - backups = backups.filter( - (candidate) => candidate.retired || candidate.files.size !== 0, - ); - if (backups.length === 0) return; - if (backups.length !== 1) { - throw new Error('multiple package-cache backup transactions require manual recovery'); - } - - const backup = backups[0]; - const targets = currentRuntimeTargets(destDir, binaryName); - if (!backup.retired) { - for (const [name, member] of backup.files.entries()) { - const target = targets.get(name); - if (target && !pathMatchesDigest(target, member.digest)) { - throw new Error(`package-cache backup conflicts with current target: ${name}`); - } - } - } else { - const binaryTarget = targets.get(binaryName); - if (binaryTarget) { - assertRuntimeLockOwner(lock); - fs.unlinkSync(binaryTarget); - targets.delete(binaryName); - } - for (const name of [...targets.keys()].sort()) { - assertRuntimeLockOwner(lock); - fs.unlinkSync(targets.get(name)); - targets.delete(name); - } - } - - const restoreNames = [...backup.files.keys()] - .filter((name) => name !== binaryName) - .sort(); - if (backup.files.has(binaryName)) restoreNames.push(binaryName); - for (const name of restoreNames) { - const member = backup.files.get(name); +function restoreBinaryBackups(destDir, backups) { + for (const name of [WINDOWS_BINARY_NAME]) { + const backup = backups.get(name); const target = path.join(destDir, name); - if (!pathMatchesDigest(target, member.digest)) { - copyRuntimeBackupFile(member, target, name === binaryName, lock); - } + if (!backup || fs.existsSync(target)) continue; + try { fs.renameSync(backup, target); } catch (_) { /* preserve backup */ } } - for (const [name, member] of backup.files.entries()) { - if (!pathMatchesDigest(path.join(destDir, name), member.digest)) { - throw new Error(`package-cache backup restoration failed: ${name}`); - } - } - cleanupRuntimeBackup(backup, lock); } -function publishRuntimeSetWithRecovery( - sourceDir, destDir, binaryName, variant, verifier = verifyCandidate, -) { - const sourceNames = runtimeSetNames(sourceDir, binaryName, variant); - if (sourceNames === null) { - throw new Error('source release does not contain a complete runtime set'); - } +function installWindowsBinaryAtomically(sourceDir, destDir, verifier = verifyCandidate) { // Authenticate the source binary before it can contend for the cache. - verifier(path.join(sourceDir, binaryName)); - fs.mkdirSync(destDir, { recursive: true }); - requireSafeRuntimeDirectory(destDir); - const lock = acquireRuntimeLock(destDir); + verifier(path.join(sourceDir, WINDOWS_BINARY_NAME)); + const lock = acquireWindowsBinaryLock(destDir); let operationError = null; try { - // A contender may have completed while this process waited. Its complete - // runtime set wins and is never moved or deleted. - refreshRuntimeLock(lock); - reconcileRuntimeBackups(destDir, binaryName, variant, verifier, lock); - if (runtimeSetReady(destDir, binaryName, variant, verifier)) return; + // A contender may have completed while this process waited. Its executable + // wins and is never moved or deleted. + if (windowsBinaryReady(destDir, verifier)) return; - const publicationToken = crypto.randomBytes(16).toString('hex'); + const transaction = crypto.randomBytes(16).toString('hex'); const stagedPaths = new Map(); - let backupDirectory = null; + const stagedDigests = new Map(); + const backups = new Map(); + const publishedDigests = new Map(); try { - for (const name of sourceNames) { - const staged = path.join(destDir, `.cbm-runtime-stage-${publicationToken}-${name}`); - copyRuntimeStageWithLease(path.join(sourceDir, name), staged, lock); - fs.chmodSync(staged, name === binaryName ? 0o755 : 0o644); + for (const name of [WINDOWS_BINARY_NAME]) { + const staged = path.join(destDir, `.cbm-pair-stage-${transaction}-${name}`); + fs.copyFileSync(path.join(sourceDir, name), staged, fs.constants.COPYFILE_EXCL); + fs.chmodSync(staged, 0o755); stagedPaths.set(name, staged); + stagedDigests.set(name, fileSha256(staged)); } - refreshRuntimeLock(lock); - backupDirectory = createRuntimeBackupDirectory(destDir); - const stalePackNames = fs.readdirSync(destDir) - .filter((name) => name.startsWith('cbm-ui-') && name.endsWith('.pack')); - const retireNames = [...new Set([ - binaryName, - INTEGRATIONS_NAME, - ...stalePackNames, - ])]; - // Retire the binary first so an incomplete publication is never ready. - for (const name of retireNames) { - assertRuntimeLockOwner(lock); + for (const name of [WINDOWS_BINARY_NAME]) { const target = path.join(destDir, name); const status = fs.lstatSync(target, { throwIfNoEntry: false }); if (!status) continue; - if (!status.isFile() || status.isSymbolicLink() || status.nlink !== 1) { - throw new Error(`refusing unsafe package-cache target: ${target}`); + if (!status.isFile() || status.isSymbolicLink()) { + throw new Error(`refusing unsafe Windows package-cache target: ${target}`); } - const backup = path.join(backupDirectory, name); + const backup = path.join(destDir, `.cbm-pair-backup-${transaction}-${name}`); fs.renameSync(target, backup); + backups.set(name, backup); } - assertRuntimeLockOwner(lock); - createRuntimeBackupMarker( - backupDirectory, RUNTIME_BACKUP_RETIRED_MARKER, - ); - // Sidecars are committed first. The executable is the final readiness - // signal, so no observer can accept a newly published partial set. - for (const name of sourceNames) { - assertRuntimeLockOwner(lock); + for (const name of [WINDOWS_BINARY_NAME]) { const target = path.join(destDir, name); fs.renameSync(stagedPaths.get(name), target); stagedPaths.delete(name); + publishedDigests.set(name, stagedDigests.get(name)); } - if (!runtimeSetReady(destDir, binaryName, variant, verifier)) { - throw new Error('published package-cache runtime set failed verification'); + if (!windowsBinaryReady(destDir, verifier)) { + throw new Error('published Windows package-cache binary failed verification'); + } + for (const backup of backups.values()) { + try { fs.unlinkSync(backup); } catch (_) { /* valid binary is already committed */ } } - assertRuntimeLockOwner(lock); - cleanupRuntimeBackup( - inspectRuntimeBackupDirectory( - destDir, path.basename(backupDirectory), binaryName, - ), - lock, - ); return; } catch (err) { - assertRuntimeLockOwner(lock); - const preserveWinner = runtimeSetReady( - destDir, binaryName, variant, verifier, - ); - if (backupDirectory !== null) { - try { - reconcileRuntimeBackups( - destDir, binaryName, variant, verifier, lock, - ); - } catch (recoveryError) { - throw new Error( - `${err.message}; package-cache recovery failed: ${recoveryError.message}`, - ); + // If a non-cooperating contender nevertheless published a valid binary, + // preserve that winner. Otherwise remove only our exact staged bytes and + // restore the prior files when their target names are still absent. + if (windowsBinaryReady(destDir, verifier)) { + for (const backup of backups.values()) { + try { fs.unlinkSync(backup); } catch (_) { /* winner remains authoritative */ } } + return; } - if (preserveWinner) return; + removeOwnedPublishedBinary(destDir, publishedDigests); + restoreBinaryBackups(destDir, backups); throw err; } finally { for (const staged of stagedPaths.values()) { @@ -919,16 +450,14 @@ function publishRuntimeSetWithRecovery( throw err; } finally { try { - releaseRuntimeLock(lock); + releaseWindowsBinaryLock(lock); } catch (releaseError) { if (!operationError) throw releaseError; } } } -function extractZipOnWindows( - archivePath, destPath, requiredNames, extractNames, ui = false, -) { +function extractZipOnWindows(archivePath, destPath, requiredNames, extractNames) { // -EncodedCommand is a constant program. Paths travel only through the child // environment and are consumed with -LiteralPath, so PowerShell never parses // user/TEMP path bytes as source code or wildcard syntax. @@ -938,8 +467,6 @@ function extractZipOnWindows( '$zip = [System.IO.Compression.ZipFile]::OpenRead($env:CBM_NPM_ARCHIVE_PATH)', "$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)", "$requiredNames = @($env:CBM_NPM_REQUIRED_NAMES.Split('|'))", - "$uiRequired = $env:CBM_NPM_UI_REQUIRED -eq '1'", - '$uiPackName = $null', '$targetCounts = @{}', 'foreach ($requiredName in $requiredNames) { $targetCounts[$requiredName] = 0 }', 'try { foreach ($entry in $zip.Entries) { ' + @@ -947,36 +474,23 @@ function extractZipOnWindows( "$directory = $name.EndsWith('/'); " + "$segmentsPath = if ($directory) { $name.TrimEnd('/') } else { $name }; " + "$segments = @($segmentsPath.Split('/')); " + - '$unixType = (($entry.ExternalAttributes -shr 16) -band 0xF000); ' + "if ([string]::IsNullOrEmpty($segmentsPath) -or $name.StartsWith('/') -or " + "$name.Contains(':') -or $segments -contains '' -or " + "$segments -contains '.' -or $segments -contains '..' -or " + - "@($segments | Where-Object { $_.EndsWith('.') -or $_.EndsWith(' ') }).Count -gt 0 -or " + - '($unixType -ne 0 -and $unixType -ne 0x8000)) { ' + + "@($segments | Where-Object { $_.EndsWith('.') -or $_.EndsWith(' ') }).Count -gt 0) { " + "throw \"unsafe zip entry path: $($entry.FullName)\" }; " + 'if (-not $seen.Add($segmentsPath)) { ' + "throw \"duplicate or case-conflicting zip entry: $($entry.FullName)\" }; " + - "$isUiPack = $uiRequired -and ($name -cmatch '^cbm-ui-[0-9a-f]{64}\\.pack$'); " + - '$isRequired = $false; ' + 'foreach ($requiredName in $requiredNames) { ' + 'if ($name -ceq $requiredName) { ' + - '$isRequired = $true; ' + - '$targetCounts[$requiredName] = $targetCounts[$requiredName] + 1 } }; ' + - 'if ($isUiPack) { if ($null -ne $uiPackName) { ' + - 'throw "archive contains more than one UI pack" }; $uiPackName = $name }; ' + - 'if ($directory -or (-not $isRequired -and -not $isUiPack)) { ' + - 'throw "archive contains an unexpected root member: $($entry.FullName)" } ' + + '$targetCounts[$requiredName] = $targetCounts[$requiredName] + 1 } } ' + '} } finally { $zip.Dispose() }', 'foreach ($requiredName in $requiredNames) { ' + 'if ($targetCounts[$requiredName] -ne 1) { ' + 'throw "archive must contain exactly one $requiredName" } }', - 'if ($uiRequired -and $null -eq $uiPackName) { ' + - 'throw "archive must contain exactly one content-addressed UI pack" }', - '$expectedCount = $requiredNames.Count; if ($uiRequired) { $expectedCount++ }', - 'if ($seen.Count -ne $expectedCount) { ' + + 'if ($seen.Count -ne $requiredNames.Count) { ' + 'throw "archive does not match the exact release root-file allowlist" }', "$extractNames = @($env:CBM_NPM_EXTRACT_NAMES.Split('|'))", - 'if ($uiRequired) { $extractNames += $uiPackName }', '$extractZip = [System.IO.Compression.ZipFile]::OpenRead($env:CBM_NPM_ARCHIVE_PATH)', 'try { foreach ($extractName in $extractNames) { ' + '$entry = @($extractZip.Entries | Where-Object { $_.FullName -ceq $extractName })[0]; ' + @@ -994,7 +508,6 @@ function extractZipOnWindows( CBM_NPM_DEST_PATH: destPath, CBM_NPM_REQUIRED_NAMES: requiredNames.join('|'), CBM_NPM_EXTRACT_NAMES: extractNames.join('|'), - CBM_NPM_UI_REQUIRED: ui ? '1' : '0', }, stdio: 'inherit', windowsHide: true, @@ -1033,29 +546,43 @@ async function main() { const platform = getPlatform(); const arch = getArch(); const ext = platform === 'windows' ? 'zip' : 'tar.gz'; - const selectedVariant = runtimeVariant(); - const cacheDir = cacheDirForVariant(selectedVariant); // Package-manager shims are portable one-shot instances: one cached binary // per platform, entered directly. const binName = platform === 'windows' ? WINDOWS_BINARY_NAME : 'codebase-memory-mcp'; - const extractedNames = [binName, INTEGRATIONS_NAME]; + const binPath = path.join(BIN_DIR, binName); + const cacheNames = platform === 'windows' + ? [WINDOWS_BINARY_NAME] + : [binName]; + const extractedNames = platform === 'windows' + ? [WINDOWS_BINARY_NAME] + : [binName]; const archiveNames = platform === 'windows' ? WINDOWS_ARCHIVE_NAMES : UNIX_ARCHIVE_NAMES; + const cachePaths = cacheNames.map((name) => path.join(BIN_DIR, name)); - if (runtimeSetReadyLocked(cacheDir, binName, selectedVariant)) return; + if (platform === 'windows' && windowsBinaryReady(BIN_DIR)) return; + if (platform !== 'windows' && + cachePaths.every((candidate) => fs.existsSync(candidate))) { + try { + verifyCandidate(binPath); + return; // already installed and runnable, nothing to do + } catch (_) { + // Fall through and atomically replace the invalid binary. + } + } - fs.mkdirSync(cacheDir, { recursive: true }); + fs.mkdirSync(BIN_DIR, { recursive: true }); // Linux ships a fully-static "-portable" build; the standard linux binary // dynamically links glibc 2.38+ and fails on older distros. macOS/Windows // have no such variant. Keep in sync with install.sh / pypi _cli.py / cli.c. const variant = platform === 'linux' ? '-portable' : ''; - // Opt into the UI build, whose verified asset pack is published beside the - // binary, with CBM_VARIANT=ui. Default is the standard (headless) build. - const ui = selectedVariant === 'ui' ? 'ui-' : ''; + // Opt into the UI build (embedded graph visualization) with CBM_VARIANT=ui. + // Default is the standard (headless) build. Mirrors install.sh --ui. + const ui = (process.env.CBM_VARIANT || '').toLowerCase() === 'ui' ? 'ui-' : ''; const archive = `codebase-memory-mcp-${ui}${platform}-${arch}${variant}.${ext}`; const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${archive}`; @@ -1069,29 +596,19 @@ async function main() { await download(url, tmpArchive); await verifyChecksum(tmpArchive, archive); - // Validate the complete archive namespace, then extract only the runtime set. - let runtimeNames; + // Validate the complete archive namespace, then extract only executables. if (ext === 'tar.gz') { - runtimeNames = extractExactTarArchive( - tmpArchive, tmpDir, archiveNames, extractedNames, - selectedVariant === 'ui', + extractExactTarArchive( + tmpArchive, tmpDir, archiveNames, binName, ); } else { - extractZipOnWindows( - tmpArchive, tmpDir, archiveNames, extractedNames, - selectedVariant === 'ui', - ); - const packNames = fs.readdirSync(tmpDir).filter((name) => UI_PACK_PATTERN.test(name)); - runtimeNames = [ - ...extractedNames, - ...(selectedVariant === 'ui' ? packNames : []), - ]; + extractZipOnWindows(tmpArchive, tmpDir, archiveNames, extractedNames); } // Validate extracted paths don't escape tmpDir (tar-slip defense). const resolvedTmpDir = path.resolve(tmpDir); const extractedPaths = new Map(); - for (const name of runtimeNames) { + for (const name of extractedNames) { const extracted = path.join(tmpDir, name); const resolvedExtracted = path.resolve(extracted); if (!resolvedExtracted.startsWith(resolvedTmpDir + path.sep)) { @@ -1099,22 +616,46 @@ async function main() { } const status = fs.lstatSync(extracted, { throwIfNoEntry: false }); if (!status || !status.isFile() || status.isSymbolicLink()) { - throw new Error(`Required runtime member not found as a regular file: ${extracted}`); + throw new Error(`Required binary not found as a regular file: ${extracted}`); } - fs.chmodSync(extracted, name === binName ? 0o755 : 0o644); + fs.chmodSync(extracted, 0o755); extractedPaths.set(name, extracted); } - verifyCandidate(extractedPaths.get(binName)); - publishRuntimeSetWithRecovery( - tmpDir, cacheDir, binName, selectedVariant, - ); + if (platform === 'windows') { + // The launcher resolves the adjacent portable payload in this directory. + verifyCandidate(extractedPaths.get(WINDOWS_BINARY_NAME)); + } else { + verifyCandidate(extractedPaths.get(binName)); + } + + if (platform === 'windows') { + installWindowsBinaryAtomically(tmpDir, BIN_DIR); + } else { + const staged = path.join( + BIN_DIR, + `.${binName}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`, + ); + try { + fs.copyFileSync(extractedPaths.get(binName), staged, fs.constants.COPYFILE_EXCL); + fs.chmodSync(staged, 0o755); + verifyCandidate(staged); + try { + fs.renameSync(staged, binPath); + } catch (publishError) { + if (!filesEqualSha256(staged, binPath)) throw publishError; + } + verifyCandidate(binPath); + } finally { + try { fs.unlinkSync(staged); } catch (_) { /* renamed or already absent */ } + } + } process.stdout.write('codebase-memory-mcp: ready.\n'); if (platform === 'windows') { process.stdout.write( 'Windows package cache is portable. Run "codebase-memory-mcp install --yes" ' + - 'to create the managed installation (use "npx codebase-memory-mcp install --yes" ' + + 'to create the managed launcher (use "npx codebase-memory-mcp install --yes" ' + 'for a local npm install). Package updates/removal remain ' + '"npm install codebase-memory-mcp@latest" and ' + '"npm uninstall codebase-memory-mcp" (add -g for a global install).\n', @@ -1134,21 +675,10 @@ if (require.main === module || module.parent == null) { } module.exports = { - INTEGRATIONS_NAME, UNIX_ARCHIVE_NAMES, - UI_PACK_PATTERN, WINDOWS_BINARY_NAME, - acquireRuntimeLock, - cacheDirForVariant, extractExactTarArchive, - publishRuntimeSetWithRecovery, - releaseRuntimeLock, - refreshRuntimeLock, - runtimeSetNames, - runtimeSetReady, - runtimeSetReadyLocked, - runtimeVariant, - tryReclaimRuntimeLock, + installWindowsBinaryAtomically, validateExactTarMemberListing, - validateRegularTarMemberListing, + windowsBinaryReady, }; diff --git a/pkg/npm/test/package-publication.test.js b/pkg/npm/test/package-publication.test.js index 9e5849ff1..560aec700 100644 --- a/pkg/npm/test/package-publication.test.js +++ b/pkg/npm/test/package-publication.test.js @@ -1,27 +1,17 @@ 'use strict'; const assert = require('node:assert/strict'); -const crypto = require('node:crypto'); -const { spawn } = require('node:child_process'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const test = require('node:test'); const { - INTEGRATIONS_NAME, UNIX_ARCHIVE_NAMES, WINDOWS_BINARY_NAME, - acquireRuntimeLock, - cacheDirForVariant, extractExactTarArchive, - publishRuntimeSetWithRecovery, - runtimeSetReady, - runtimeSetReadyLocked, - releaseRuntimeLock, - tryReclaimRuntimeLock, + installWindowsBinaryAtomically, validateExactTarMemberListing, - validateRegularTarMemberListing, } = require('../install.js'); function exactUnixListing(extra = []) { @@ -43,81 +33,28 @@ test('Unix archive validation rejects traversal and unexpected members', () => { ); }); -test('release archives require the integrations sidecar', () => { - assert.ok( - UNIX_ARCHIVE_NAMES.includes(INTEGRATIONS_NAME), - 'the authenticated archive namespace must include the runtime integrations sidecar', - ); -}); - -test('UI Unix archive validation accepts exactly one content-addressed pack', () => { - const pack = `cbm-ui-${'a'.repeat(64)}.pack`; - assert.doesNotThrow(() => validateExactTarMemberListing( - exactUnixListing([pack]), UNIX_ARCHIVE_NAMES, true, - )); - assert.throws( - () => validateExactTarMemberListing( - exactUnixListing([pack, `cbm-ui-${'b'.repeat(64)}.pack`]), - UNIX_ARCHIVE_NAMES, - true, - ), - /exactly one|unexpected or duplicate/, - ); -}); - -test('Unix extraction requests only the validated runtime set', () => { +test('Unix extraction requests only the validated root executable', () => { const calls = []; const runner = (command, args) => { calls.push({ command, args: [...args] }); - if (args[0] === '-tzf') return exactUnixListing(); - if (args[0] === '-tvzf') { - return UNIX_ARCHIVE_NAMES.map( - (name) => `-rw-r--r-- 0 owner group 1 Jan 1 00:00 ${name}`, - ).join('\n') + '\n'; - } - return Buffer.alloc(0); + return calls.length === 1 ? exactUnixListing() : Buffer.alloc(0); }; extractExactTarArchive( '/tmp/release.tar.gz', '/tmp/extract', UNIX_ARCHIVE_NAMES, - ['codebase-memory-mcp', INTEGRATIONS_NAME], false, runner, + 'codebase-memory-mcp', runner, ); assert.deepEqual(calls[0].args, ['-tzf', '/tmp/release.tar.gz']); - assert.deepEqual(calls[1].args, ['-tvzf', '/tmp/release.tar.gz']); assert.deepEqual( - calls[2].args, - [ - '-xzf', '/tmp/release.tar.gz', '-C', '/tmp/extract', - 'codebase-memory-mcp', INTEGRATIONS_NAME, - ], + calls[1].args, + ['-xzf', '/tmp/release.tar.gz', '-C', '/tmp/extract', 'codebase-memory-mcp'], ); }); -test('Unix archive validation rejects hardlink members', () => { - const listing = UNIX_ARCHIVE_NAMES.map( - (name, index) => `${index === 0 ? 'h' : '-'}rw-r--r-- ${name}`, - ).join('\n') + '\n'; - assert.throws( - () => validateRegularTarMemberListing(listing, UNIX_ARCHIVE_NAMES.length), - /independent regular files/, - ); -}); - -function writeRuntimeSet(directory, tag, variant = 'standard') { +function writeBinary(directory, tag) { fs.mkdirSync(directory, { recursive: true }); fs.writeFileSync(path.join(directory, WINDOWS_BINARY_NAME), `binary:${tag}`); - fs.writeFileSync(path.join(directory, INTEGRATIONS_NAME), `integrations:${tag}`); - if (variant === 'ui') { - const contents = Buffer.from(`pack:${tag}`); - const digest = crypto.createHash('sha256').update(contents).digest('hex'); - fs.writeFileSync( - path.join(directory, `cbm-ui-${digest}.pack`), - contents, - ); - return `cbm-ui-${digest}.pack`; - } - return null; } function fakeBinaryVerifier(binaryPath) { @@ -144,39 +81,15 @@ function assertBinary(directory, tag) { fs.readFileSync(path.join(directory, WINDOWS_BINARY_NAME), 'utf8'), `binary:${tag}`, ); - assert.equal( - fs.readFileSync(path.join(directory, INTEGRATIONS_NAME), 'utf8'), - `integrations:${tag}`, - ); -} - -async function waitForCrashMarker(marker, child, stderr) { - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - if (fs.existsSync(marker)) return; - if (child.exitCode !== null || child.signalCode !== null) { - throw new Error(`publication helper exited before crash gate: ${stderr()}`); - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error(`publication helper did not reach crash gate: ${stderr()}`); -} - -function waitForChildExit(child) { - return new Promise((resolve) => { - child.once('exit', (code, signal) => resolve({ code, signal })); - }); } test('Windows publication repairs a corrupt cached binary', () => { withBinaryDirectories(({ source, destination }) => { - writeRuntimeSet(source, 'candidate'); - writeRuntimeSet(destination, 'old'); + writeBinary(source, 'candidate'); + writeBinary(destination, 'old'); fs.writeFileSync(path.join(destination, WINDOWS_BINARY_NAME), 'corrupt'); - publishRuntimeSetWithRecovery( - source, destination, WINDOWS_BINARY_NAME, 'standard', fakeBinaryVerifier, - ); + installWindowsBinaryAtomically(source, destination, fakeBinaryVerifier); assertBinary(destination, 'candidate'); }); @@ -184,11 +97,9 @@ test('Windows publication repairs a corrupt cached binary', () => { test('Windows publication installs into an empty cache', () => { withBinaryDirectories(({ source, destination }) => { - writeRuntimeSet(source, 'candidate'); + writeBinary(source, 'candidate'); - publishRuntimeSetWithRecovery( - source, destination, WINDOWS_BINARY_NAME, 'standard', fakeBinaryVerifier, - ); + installWindowsBinaryAtomically(source, destination, fakeBinaryVerifier); assertBinary(destination, 'candidate'); }); @@ -196,334 +107,11 @@ test('Windows publication installs into an empty cache', () => { test('Windows publication preserves a valid concurrent winner', () => { withBinaryDirectories(({ source, destination }) => { - writeRuntimeSet(source, 'loser'); - writeRuntimeSet(destination, 'winner'); + writeBinary(source, 'loser'); + writeBinary(destination, 'winner'); - publishRuntimeSetWithRecovery( - source, destination, WINDOWS_BINARY_NAME, 'standard', fakeBinaryVerifier, - ); + installWindowsBinaryAtomically(source, destination, fakeBinaryVerifier); assertBinary(destination, 'winner'); }); }); - -test('runtime readiness requires the complete variant-specific set', () => { - withBinaryDirectories(({ destination }) => { - writeRuntimeSet(destination, 'standard'); - assert.equal( - runtimeSetReady(destination, WINDOWS_BINARY_NAME, 'standard', fakeBinaryVerifier), - true, - ); - fs.unlinkSync(path.join(destination, INTEGRATIONS_NAME)); - assert.equal( - runtimeSetReady(destination, WINDOWS_BINARY_NAME, 'standard', fakeBinaryVerifier), - false, - ); - - writeRuntimeSet(destination, 'ui', 'ui'); - assert.equal( - runtimeSetReady(destination, WINDOWS_BINARY_NAME, 'ui', fakeBinaryVerifier), - true, - ); - fs.writeFileSync( - path.join(destination, `cbm-ui-${'b'.repeat(64)}.pack`), - 'second pack', - ); - assert.equal( - runtimeSetReady(destination, WINDOWS_BINARY_NAME, 'ui', fakeBinaryVerifier), - false, - ); - }); -}); - -test('UI readiness rejects and publication repairs a digest-mismatched pack', () => { - withBinaryDirectories(({ source, destination }) => { - const sourcePack = writeRuntimeSet(source, 'candidate', 'ui'); - const corruptPack = writeRuntimeSet(destination, 'old', 'ui'); - fs.writeFileSync(path.join(destination, corruptPack), 'corrupt pack bytes'); - - assert.equal( - runtimeSetReady(destination, WINDOWS_BINARY_NAME, 'ui', fakeBinaryVerifier), - false, - 'a well-shaped pack filename must not authenticate different bytes', - ); - publishRuntimeSetWithRecovery( - source, destination, WINDOWS_BINARY_NAME, 'ui', fakeBinaryVerifier, - ); - - assertBinary(destination, 'candidate'); - assert.equal(fs.readFileSync(path.join(destination, sourcePack), 'utf8'), 'pack:candidate'); - assert.equal(fs.existsSync(path.join(destination, corruptPack)), false); - }); -}); - -test('standard and UI cache directories do not collide', () => { - assert.notEqual(cacheDirForVariant('standard'), cacheDirForVariant('ui')); -}); - -test('runtime publication commits sidecars before the binary', () => { - withBinaryDirectories(({ source, destination }) => { - writeRuntimeSet(source, 'candidate', 'ui'); - const committed = []; - const renameSync = fs.renameSync; - fs.renameSync = (sourcePath, destinationPath) => { - const name = path.basename(destinationPath); - if (path.dirname(destinationPath) === destination && - (name === WINDOWS_BINARY_NAME || name === INTEGRATIONS_NAME || - /^cbm-ui-[0-9a-f]{64}\.pack$/.test(name))) { - committed.push(name); - } - return renameSync(sourcePath, destinationPath); - }; - try { - publishRuntimeSetWithRecovery( - source, destination, WINDOWS_BINARY_NAME, 'ui', fakeBinaryVerifier, - ); - } finally { - fs.renameSync = renameSync; - } - - assert.equal(committed[0], INTEGRATIONS_NAME); - assert.match(committed[1], /^cbm-ui-[0-9a-f]{64}\.pack$/); - assert.equal(committed[2], WINDOWS_BINARY_NAME); - }); -}); - -test('a killed publisher is reconciled on the next locked readiness check', async () => { - const helperSource = String.raw` - 'use strict'; - const fs = require('node:fs'); - const path = require('node:path'); - const [installPath, source, destination, marker, crashName] = process.argv.slice(1); - const install = require(installPath); - const renameSync = fs.renameSync; - fs.renameSync = (sourcePath, destinationPath) => { - const result = renameSync(sourcePath, destinationPath); - if (destinationPath === path.join(destination, crashName)) { - fs.writeFileSync(marker, 'reached\n'); - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 60_000); - } - return result; - }; - const verifier = (binaryPath) => { - if (!/^binary:(.+)$/.test(fs.readFileSync(binaryPath, 'utf8'))) { - throw new Error('test binary failed verification'); - } - }; - install.publishRuntimeSetWithRecovery( - source, destination, install.WINDOWS_BINARY_NAME, 'standard', verifier, - ); - `; - - for (const [crashName, expectedReady] of [ - [INTEGRATIONS_NAME, false], - [WINDOWS_BINARY_NAME, true], - ]) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cbm-npm-crash-test-')); - const source = path.join(root, 'source'); - const destination = path.join(root, 'destination'); - const marker = path.join(root, 'crash-reached'); - fs.mkdirSync(destination); - writeRuntimeSet(source, 'candidate'); - writeRuntimeSet(destination, 'old'); - fs.writeFileSync( - path.join(destination, WINDOWS_BINARY_NAME), 'corrupt:old', - ); - let stderr = ''; - const child = spawn(process.execPath, [ - '-e', - helperSource, - path.join(__dirname, '..', 'install.js'), - source, - destination, - marker, - crashName, - ], { stdio: ['ignore', 'ignore', 'pipe'] }); - child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); - const exitPromise = waitForChildExit(child); - try { - await waitForCrashMarker(marker, child, () => stderr); - const backups = fs.readdirSync(destination) - .filter((name) => name.startsWith('.cbm-runtime-backup-')); - assert.equal(backups.length, 1, 'killed publisher did not retain one transaction'); - assert.equal( - fs.lstatSync(path.join(destination, backups[0])).isDirectory(), - true, - ); - assert.equal( - fs.existsSync(path.join( - destination, backups[0], '.retirement-complete', - )), - true, - ); - - assert.equal(child.kill('SIGKILL'), true); - const exit = await exitPromise; - assert.ok( - exit.signal !== null || exit.code !== 0, - 'publication helper was not killed', - ); - - const ready = runtimeSetReadyLocked( - destination, - WINDOWS_BINARY_NAME, - 'standard', - fakeBinaryVerifier, - ); - assert.equal(ready, expectedReady); - if (!ready) { - assert.equal( - fs.readFileSync(path.join(destination, WINDOWS_BINARY_NAME), 'utf8'), - 'corrupt:old', - ); - assert.equal( - fs.readFileSync(path.join(destination, INTEGRATIONS_NAME), 'utf8'), - 'integrations:old', - ); - publishRuntimeSetWithRecovery( - source, - destination, - WINDOWS_BINARY_NAME, - 'standard', - fakeBinaryVerifier, - ); - } - assertBinary(destination, 'candidate'); - assert.equal( - fs.readdirSync(destination).some( - (name) => name.startsWith('.cbm-runtime-backup-'), - ), - false, - ); - assert.equal( - fs.existsSync(path.join(destination, '.codebase-memory-mcp-runtime.lock')), - false, - ); - } finally { - if (child.exitCode === null && child.signalCode === null) { - child.kill('SIGKILL'); - await exitPromise; - } - fs.rmSync(root, { recursive: true, force: true }); - } - } -}); - -test('a stalled creator never deletes a successor runtime lock', () => { - withBinaryDirectories(({ destination }) => { - let successor; - let injected = false; - assert.throws( - () => acquireRuntimeLock(destination, () => { - if (injected) return; - injected = true; - successor = acquireRuntimeLock(destination); - throw new Error('injected stalled creator abort'); - }), - /injected stalled creator abort/, - ); - - assert.ok(successor, 'the successor must acquire the reclaimed lock'); - assert.ok( - fs.existsSync(successor.lockPath), - 'the failed creator must not recursively remove its successor', - ); - releaseRuntimeLock(successor); - }); -}); - -test('runtime readiness rejects multiply-linked cache leaves', () => { - withBinaryDirectories(({ destination }) => { - writeRuntimeSet(destination, 'linked'); - fs.linkSync( - path.join(destination, INTEGRATIONS_NAME), - path.join(destination, 'integrations-hardlink'), - ); - assert.equal( - runtimeSetReady(destination, WINDOWS_BINARY_NAME, 'standard', fakeBinaryVerifier), - false, - ); - }); -}); - -test('orphan reconciliation rejects multiply-linked backup members', () => { - withBinaryDirectories(({ destination }) => { - const backup = path.join( - destination, `.cbm-runtime-backup-${'a'.repeat(32)}`, - ); - fs.mkdirSync(backup); - fs.writeFileSync(path.join(backup, '.retirement-complete'), ''); - const member = path.join(backup, WINDOWS_BINARY_NAME); - fs.writeFileSync(member, 'binary:old'); - fs.linkSync(member, path.join(destination, 'backup-hardlink-copy')); - - assert.throws( - () => runtimeSetReadyLocked( - destination, - WINDOWS_BINARY_NAME, - 'standard', - fakeBinaryVerifier, - ), - /unsafe package-cache backup member/, - ); - assert.equal(fs.existsSync(backup), true, 'unsafe backup was mutated'); - assert.equal( - fs.existsSync(path.join(destination, '.codebase-memory-mcp-runtime.lock')), - false, - ); - }); -}); - -test('an expired lease never reclaims a live owner', () => { - withBinaryDirectories(({ destination }) => { - const lockPath = path.join(destination, '.codebase-memory-mcp-runtime.lock'); - const owner = { - pid: process.pid, - token: 'a'.repeat(32), - lease_expires_ms: Date.now() - 1, - }; - const ownerRecord = `${JSON.stringify(owner)}\n`; - fs.writeFileSync(lockPath, ownerRecord); - const before = fs.lstatSync(lockPath, { bigint: true }); - - assert.equal(tryReclaimRuntimeLock(lockPath, 'b'.repeat(32)), false); - const after = fs.lstatSync(lockPath, { bigint: true }); - assert.equal(fs.readFileSync(lockPath, 'utf8'), ownerRecord); - assert.equal(JSON.parse(fs.readFileSync(lockPath, 'utf8')).token, owner.token); - assert.equal(after.dev, before.dev); - assert.equal(after.ino, before.ino); - }); -}); - -test('a stale legacy lock never reclaims a live owner', () => { - withBinaryDirectories(({ destination }) => { - const lockPath = path.join(destination, '.codebase-memory-mcp-runtime.lock'); - const ownerRecord = `${JSON.stringify({ - pid: process.pid, - token: 'a'.repeat(32), - })}\n`; - fs.mkdirSync(lockPath); - fs.writeFileSync(path.join(lockPath, 'owner.json'), ownerRecord); - const stale = new Date(Date.now() - 3_600_001); - fs.utimesSync(lockPath, stale, stale); - const before = fs.lstatSync(lockPath, { bigint: true }); - - assert.equal(tryReclaimRuntimeLock(lockPath, 'b'.repeat(32)), false); - const after = fs.lstatSync(lockPath, { bigint: true }); - assert.equal(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'), ownerRecord); - assert.equal(after.dev, before.dev); - assert.equal(after.ino, before.ino); - }); -}); - -test('a stale ownerless lock is reclaimed', () => { - withBinaryDirectories(({ destination }) => { - const lockPath = path.join(destination, '.codebase-memory-mcp-runtime.lock'); - fs.writeFileSync(lockPath, ''); - const stale = new Date(Date.now() - 30_001); - fs.utimesSync(lockPath, stale, stale); - - assert.equal(tryReclaimRuntimeLock(lockPath, 'b'.repeat(32)), true); - assert.equal(fs.existsSync(lockPath), false); - }); -}); diff --git a/pkg/npm/test/windows-launcher.test.js b/pkg/npm/test/windows-launcher.test.js index 6a63f58a1..756b238cb 100644 --- a/pkg/npm/test/windows-launcher.test.js +++ b/pkg/npm/test/windows-launcher.test.js @@ -1,15 +1,11 @@ 'use strict'; const assert = require('node:assert/strict'); -const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const vm = require('node:vm'); -const UI_PACK_BYTES = Buffer.from('authenticated UI pack'); -const UI_PACK_NAME = `cbm-ui-${crypto.createHash('sha256').update(UI_PACK_BYTES).digest('hex')}.pack`; - class ExitSignal extends Error { constructor(code) { super(`process.exit(${code})`); @@ -17,77 +13,26 @@ class ExitSignal extends Error { } } -function runShim(targetPlatform, arguments_, childStatus, options = {}) { +function runShim(targetPlatform, arguments_, childStatus) { const shimPath = path.join(__dirname, '..', 'bin.js'); const source = fs.readFileSync(shimPath, 'utf8').replace(/^#![^\n]*\n/, ''); const calls = []; - const lockEvents = []; let stderr = ''; - let installed = false; const fakeProcess = { platform: targetPlatform, - env: { - CBM_VARIANT: options.variant || '', - LOCALAPPDATA: 'C:\\Users\\test\\AppData\\Local', - }, argv: ['node.exe', shimPath, ...arguments_], execPath: 'node.exe', stderr: { write: (text) => { stderr += String(text); } }, exit: (code) => { throw new ExitSignal(code); }, }; const fakeFs = { - lstatSync: (candidate) => { - if (options.missingSidecar && !installed && - path.basename(candidate) === 'cbm-integrations.json') { - const error = new Error('missing sidecar'); - error.code = 'ENOENT'; - throw error; - } - return { isFile: () => true, isSymbolicLink: () => false, nlink: 1 }; - }, - readFileSync: () => options.corruptPack && !installed - ? Buffer.from('corrupt UI pack') - : UI_PACK_BYTES, - readdirSync: () => options.variant === 'ui' - ? [UI_PACK_NAME] - : [], + existsSync: () => true, }; const fakeChildProcess = { - spawn: (executable, args, options) => { - calls.push({ executable, args, options }); - return { - on: (event, callback) => { - if (event === 'close') callback(childStatus); - return this; - }, - }; - }, spawnSync: (executable, args, options) => { calls.push({ executable, args, options }); - if (executable === fakeProcess.execPath) { - installed = true; - return { status: 0 }; - } - if (args[0] === '--verify-runtime-assets') return { status: 0 }; - return { status: childStatus }; - }, - }; - const fakeLifecycle = { - acquireRuntimeLock: () => { - lockEvents.push('acquire'); - return { lockPath: 'lock', token: 'token', fd: 1 }; - }, - refreshRuntimeLock: () => { lockEvents.push('refresh'); }, - releaseRuntimeLock: () => { lockEvents.push('release'); }, - runtimeSetReady: () => true, - runtimeSetReadyLocked: (directory, binaryName) => { - const structurallyReady = installed || (!options.missingSidecar && !options.corruptPack); - if (!structurallyReady) return false; - const probe = fakeChildProcess.spawnSync( - path.join(directory, binaryName), ['--verify-runtime-assets'], { stdio: 'ignore' }, - ); - return probe.status === 0; + return { status: calls.length === 1 ? 0 : childStatus }; }, }; const sandbox = { @@ -102,11 +47,8 @@ function runShim(targetPlatform, arguments_, childStatus, options = {}) { require: (specifier) => { if (specifier === 'fs') return fakeFs; if (specifier === 'child_process') return fakeChildProcess; - if (specifier === './install.js') return fakeLifecycle; return require(specifier); }, - clearInterval: () => {}, - setInterval: () => 1, setTimeout, }; @@ -117,10 +59,10 @@ function runShim(targetPlatform, arguments_, childStatus, options = {}) { if (!(error instanceof ExitSignal)) throw error; exitCode = error.code; } - return { calls, exitCode, lockEvents, stderr }; + return { calls, exitCode, stderr }; } -test('Windows npm shim authenticates assets and executes the cached binary', () => { +test('Windows npm shim probes and executes the cached launcher', () => { const observed = runShim('win32', ['--version'], 0); assert.equal(observed.exitCode, 0); @@ -132,86 +74,25 @@ test('Windows npm shim authenticates assets and executes the cached binary', () 'codebase-memory-mcp.payload.exe', ); } - assert.deepEqual(Array.from(observed.calls[0].args), ['--verify-runtime-assets']); assert.deepEqual(Array.from(observed.calls[1].args), ['--version']); assert.equal(observed.calls[1].options.stdio, 'inherit'); }); -test('npm update is routed to the package manager without invoking absent install.sh', () => { +test('Windows npm shim keeps package-manager guidance after launcher refusal', () => { const observed = runShim('win32', ['update'], 1); - assert.equal(observed.exitCode, 2); - assert.equal(observed.calls.length, 0, 'update guidance must not launch cached code'); + assert.equal(observed.exitCode, 1); + assert.equal(path.basename(observed.calls[1].executable), 'codebase-memory-mcp.exe'); assert.match(observed.stderr, /npm install codebase-memory-mcp@latest/); - assert.match(observed.stderr, /codebase-memory-mcp install --yes/); - assert.doesNotMatch(observed.stderr, /install\.sh/); + assert.match(observed.stderr, /install --yes/); }); -test('nested update token is forwarded instead of treated as package update', () => { - const observed = runShim('win32', ['daemon', 'update'], 0); - - assert.equal(observed.exitCode, 0); - assert.equal(observed.calls.length, 2); - assert.deepEqual(Array.from(observed.calls[1].args), ['daemon', 'update']); - assert.doesNotMatch(observed.stderr, /npm install codebase-memory-mcp@latest/); -}); - -test('non-Windows npm shim executes its cached binary directly', () => { +test('non-Windows npm shim keeps its native payload execution path', () => { const observed = runShim('darwin', ['--version'], 0); assert.equal(observed.exitCode, 0); - assert.equal(observed.calls.length, 2); - assert.deepEqual(Array.from(observed.calls[0].args), ['--verify-runtime-assets']); - assert.equal(path.basename(observed.calls[1].executable), 'codebase-memory-mcp'); -}); - -test('npm shim selects a separate UI runtime set', () => { - const observed = runShim('darwin', ['--version'], 0, { variant: 'ui' }); - - assert.equal(observed.exitCode, 0); - assert.equal(path.basename(path.dirname(observed.calls[1].executable)), 'ui'); -}); - -test('npm shim repairs a cache whose integrations sidecar is missing', () => { - const observed = runShim( - 'darwin', ['--version'], 0, { missingSidecar: true }, - ); - - assert.equal(observed.exitCode, 0); - assert.equal(observed.calls[0].executable, 'node.exe'); - assert.equal(path.basename(observed.calls[0].args[0]), 'install.js'); - assert.deepEqual(Array.from(observed.calls[1].args), ['--verify-runtime-assets']); - assert.equal(path.basename(observed.calls[2].executable), 'codebase-memory-mcp'); -}); - -test('npm shim repairs a UI cache whose pack digest does not match its name', () => { - const observed = runShim( - 'darwin', ['--version'], 0, { variant: 'ui', corruptPack: true }, - ); - - assert.equal(observed.exitCode, 0); - assert.equal(observed.calls[0].executable, 'node.exe'); - assert.equal(path.basename(observed.calls[0].args[0]), 'install.js'); - assert.deepEqual(Array.from(observed.calls[1].args), ['--verify-runtime-assets']); - assert.equal(path.basename(observed.calls[2].executable), 'codebase-memory-mcp'); -}); - -test('wrapper uninstall locks the cache and targets the managed install by default', () => { - const observed = runShim('win32', ['uninstall', '--yes'], 0); - - assert.equal(observed.exitCode, 0); - assert.deepEqual(observed.lockEvents, ['acquire', 'refresh', 'release']); - assert.deepEqual( - Array.from(observed.calls[1].args), - [ - 'uninstall', '--yes', '--dir', - path.join( - 'C:\\Users\\test\\AppData\\Local', - 'Programs', - 'codebase-memory-mcp', - ), - ], - ); + assert.equal(observed.calls.length, 1); + assert.equal(path.basename(observed.calls[0].executable), 'codebase-memory-mcp'); }); test('PowerShell install mutation runs through the downloaded binary', () => { @@ -230,12 +111,8 @@ test('PowerShell install mutation runs through the downloaded binary', () => { ); // One binary ships per platform — no launcher/payload pair to resolve. assert.doesNotMatch(installer, /payload/i); - // The candidate owns activation and rollback under the native guard. The - // script must not mutate the installed binary before handing it the request. - const handoff = installer.indexOf('& $DownloadedBinary @InstallArgs'); - assert.ok(handoff > 0, 'downloaded candidate handoff is missing'); - assert.doesNotMatch( - installer.slice(0, handoff), - /(?:Move-Item|Copy-Item|Remove-Item)[^\n]*(?:\$Dest|\$InstallDir[^\n]*\$BinName)/, - ); + // A running .exe cannot be overwritten, so an in-place update has to retire + // the existing binary by renaming it aside first. This script IS the Windows + // update path, so losing that step would silently break every update. + assert.match(installer, /Move-Item[\s\S]{0,80}\$Dest[\s\S]{0,40}\$retired/); }); diff --git a/pkg/pypi/tests/test_cli.py b/pkg/pypi/tests/test_cli.py index 5a3c819bc..4fe297689 100644 --- a/pkg/pypi/tests/test_cli.py +++ b/pkg/pypi/tests/test_cli.py @@ -1,18 +1,6 @@ -import errno -import hashlib -import json -import os -import stat -import subprocess import sys -import tarfile -import tempfile -import threading -import time import unittest -import zipfile -from pathlib import Path, PureWindowsPath -from unittest import mock +from pathlib import Path PACKAGE_ROOT = Path(__file__).resolve().parents[1] @@ -34,1085 +22,14 @@ class BinarySelectionTests(unittest.TestCase): self.assertEqual(_cli._execution_path(binary, "linux"), binary) - def test_cache_sensitive_mutation_classification_is_explicit(self): - self.assertEqual(_cli._runtime_mutation_action(["update"]), "update") + def test_portable_mutation_guidance_classification_is_preserved(self): + self.assertEqual(_cli._portable_mutation_action(["update"]), "update") self.assertEqual( - _cli._runtime_mutation_action(["uninstall", "--yes"]), + _cli._portable_mutation_action(["uninstall", "--yes"]), "uninstall", ) - self.assertEqual( - _cli._runtime_mutation_action(["install", "--yes"]), "install" - ) - self.assertIsNone(_cli._runtime_mutation_action(["cli", "update"])) - self.assertIsNone(_cli._runtime_mutation_action(["daemon", "update"])) - - def test_wrapper_uninstall_never_defaults_to_its_cache_binary(self): - linux_home = Path("fixtures") / "linux-home" - windows_local_app_data = Path("fixtures") / "windows-local-app-data" - defaults = ( - ( - "linux", - linux_home / ".local" / "bin", - mock.patch.object(Path, "home", return_value=linux_home), - ), - ( - "win32", - windows_local_app_data - / "Programs" - / "codebase-memory-mcp", - mock.patch.dict( - os.environ, - {"LOCALAPPDATA": str(windows_local_app_data)}, - ), - ), - ) - for platform_name, expected, path_fixture in defaults: - with self.subTest(platform=platform_name), mock.patch.object( - _cli.sys, "platform", platform_name - ), path_fixture: - self.assertEqual( - _cli._native_args(["uninstall", "--yes"]), - ["uninstall", "--yes", "--dir", str(expected)], - ) - - explicit_dir = Path("fixtures") / "explicit-install" - for platform_name in ("linux", "win32"): - with self.subTest( - platform=platform_name, explicit_dir=True - ), mock.patch.object(_cli.sys, "platform", platform_name): - self.assertEqual( - _cli._native_args( - ["uninstall", "--dir", str(explicit_dir)] - ), - ["uninstall", "--dir", str(explicit_dir)], - ) - - def test_standard_and_ui_cache_paths_do_not_collide(self): - with mock.patch.dict("os.environ", {"CBM_VARIANT": "standard"}): - standard = _cli._bin_path("0.8.1") - with mock.patch.dict("os.environ", {"CBM_VARIANT": "ui"}): - ui = _cli._bin_path("0.8.1") - - self.assertNotEqual(standard, ui) - - def test_release_archives_require_the_integrations_sidecar(self): - self.assertIn("cbm-integrations.json", _cli._WINDOWS_ARCHIVE_NAMES) - - def test_update_guidance_uses_pip_and_names_managed_install(self): - with mock.patch.object(_cli.sys, "argv", ["cbm", "update"]), \ - mock.patch.object(_cli, "_version", return_value="0.8.1"), \ - mock.patch("sys.stderr") as stderr: - with self.assertRaisesRegex(SystemExit, "2"): - _cli.main() - - output = "".join(call.args[0] for call in stderr.write.call_args_list) - self.assertIn( - "python -m pip install --upgrade codebase-memory-mcp", output - ) - self.assertIn("codebase-memory-mcp install --yes", output) - self.assertNotIn("install.sh", output) - - -class ProcessLivenessTests(unittest.TestCase): - def test_windows_probe_never_calls_os_kill(self): - with mock.patch.object(_cli.os, "name", "nt"), mock.patch.object( - _cli, - "_windows_process_is_alive", - return_value=True, - create=True, - ) as windows_probe, mock.patch.object( - _cli.os, - "kill", - side_effect=AssertionError("Windows must not call os.kill"), - ): - self.assertTrue(_cli._process_is_alive(1234)) - - windows_probe.assert_called_once_with(1234) - - def test_windows_wait_result_distinguishes_dead_and_live_processes(self): - cases = ( - (_cli._WINDOWS_WAIT_OBJECT_0, False), - (_cli._WINDOWS_WAIT_TIMEOUT, True), - (0xFFFFFFFF, True), - ) - for wait_result, expected in cases: - with self.subTest(wait_result=wait_result): - kernel32 = mock.Mock() - kernel32.OpenProcess.return_value = 123 - kernel32.WaitForSingleObject.return_value = wait_result - with mock.patch.object( - _cli, - "_windows_process_api", - return_value=(kernel32, mock.Mock()), - ): - self.assertEqual( - _cli._windows_process_is_alive(4567), expected - ) - - kernel32.OpenProcess.assert_called_once_with( - _cli._WINDOWS_SYNCHRONIZE, False, 4567 - ) - kernel32.WaitForSingleObject.assert_called_once_with(123, 0) - kernel32.CloseHandle.assert_called_once_with(123) - - def test_windows_open_failure_only_treats_missing_pid_as_dead(self): - cases = ( - (_cli._WINDOWS_ERROR_INVALID_PARAMETER, False), - (5, True), - ) - for error, expected in cases: - with self.subTest(error=error): - kernel32 = mock.Mock() - kernel32.OpenProcess.return_value = 0 - get_last_error = mock.Mock(return_value=error) - with mock.patch.object( - _cli, - "_windows_process_api", - return_value=(kernel32, get_last_error), - ): - self.assertEqual( - _cli._windows_process_is_alive(4567), expected - ) - - get_last_error.assert_called_once_with() - kernel32.WaitForSingleObject.assert_not_called() - kernel32.CloseHandle.assert_not_called() - - def test_windows_api_failures_are_conservatively_live(self): - kernel32 = mock.Mock() - kernel32.OpenProcess.side_effect = OSError("injected API failure") - with mock.patch.object( - _cli, - "_windows_process_api", - return_value=(kernel32, mock.Mock()), - ): - self.assertTrue(_cli._windows_process_is_alive(4567)) - - def test_posix_probe_behavior_is_unchanged(self): - cases = ( - (None, True), - (ProcessLookupError(), False), - (PermissionError(), True), - (OSError(errno.ESRCH, "missing"), False), - ) - for error, expected in cases: - with self.subTest(error=type(error).__name__): - side_effect = error if error is not None else None - with mock.patch.object(_cli.os, "name", "posix"), \ - mock.patch.object( - _cli.os, "kill", side_effect=side_effect - ) as kill: - self.assertEqual(_cli._process_is_alive(4567), expected) - kill.assert_called_once_with(4567, 0) - - -class RuntimeSetTests(unittest.TestCase): - def test_candidate_probe_authenticates_adjacent_runtime_assets(self): - candidates = ( - Path("/tmp/codebase-memory-mcp"), - PureWindowsPath("/tmp/codebase-memory-mcp"), - ) - - for candidate in candidates: - with self.subTest(candidate=candidate): - with mock.patch.object(_cli.subprocess, "run") as run: - run.return_value.returncode = 0 - _cli._verify_candidate(candidate) - - self.assertEqual( - run.call_args.args[0], - [str(candidate), "--verify-runtime-assets"], - ) - - def test_runtime_readiness_rejects_multiply_linked_cache_leaves(self): - with tempfile.TemporaryDirectory() as root, mock.patch.object( - _cli, "_cache_dir", return_value=Path(root) - ): - binary = _cli._bin_path("0.8.1", "standard") - binary.parent.mkdir(parents=True) - binary.write_text("binary") - integrations = binary.parent / _cli._INTEGRATIONS_NAME - integrations.write_text("integrations") - os.link(integrations, binary.parent / "integrations-hardlink") - - self.assertFalse(_cli._runtime_set_ready("0.8.1")) - - def test_orphan_reconciliation_rejects_multiply_linked_backup_members(self): - with tempfile.TemporaryDirectory() as root: - directory = Path(root) - binary = directory / "codebase-memory-mcp" - backup = directory / f".cbm-runtime-backup-{'a' * 32}" - backup.mkdir() - (backup / ".retirement-complete").write_bytes(b"") - member = backup / binary.name - member.write_text("binary:old") - os.link(member, directory / "backup-hardlink-copy") - - with mock.patch.object( - _cli, "_bin_path", return_value=binary - ), mock.patch.object( - _cli, "_variant", return_value="standard" - ), self.assertRaisesRegex( - RuntimeError, "unsafe package-cache backup member" - ): - _cli._runtime_set_ready_locked("test-version") - - self.assertTrue(backup.exists(), "unsafe backup was mutated") - self.assertFalse((directory / _cli._RUNTIME_LOCK_NAME).exists()) - - def test_stalled_lock_creator_never_deletes_successor(self): - with tempfile.TemporaryDirectory() as root: - directory = Path(root) - successor = None - - def publish_successor_then_abort(): - nonlocal successor - _cli._runtime_lock_claim_observer = None - successor, _ = _cli._acquire_runtime_lock(directory) - raise RuntimeError("injected stalled creator abort") - - with mock.patch.object( - _cli, - "_runtime_lock_claim_observer", - side_effect=publish_successor_then_abort, - ), self.assertRaisesRegex(RuntimeError, "stalled creator abort"): - _cli._acquire_runtime_lock(directory) - - self.assertIsNotNone(successor) - self.assertTrue(successor[0].exists()) - _cli._release_runtime_lock(successor) - - def test_acquire_closes_claim_descriptor_before_unlink_and_reopens(self): - with tempfile.TemporaryDirectory() as root: - directory = Path(root) - captured = {} - write_owner = _cli._write_runtime_lock_owner - unlink = Path.unlink - - def capture_claim_descriptor(owner_fd, token): - captured["claim_fd"] = owner_fd - write_owner(owner_fd, token) - - def require_closed_claim_descriptor(path, *args, **kwargs): - if f"{_cli._RUNTIME_LOCK_NAME}.claim-" in path.name: - with self.assertRaises(OSError): - os.fstat(captured["claim_fd"]) - captured["closed_before_unlink"] = True - return unlink(path, *args, **kwargs) - - with mock.patch.object( - _cli, - "_write_runtime_lock_owner", - side_effect=capture_claim_descriptor, - ), mock.patch.object( - Path, "unlink", new=require_closed_claim_descriptor - ): - lock, _ = _cli._acquire_runtime_lock(directory) - - try: - self.assertTrue(captured["closed_before_unlink"]) - self.assertTrue( - _cli._same_runtime_lock_object( - os.fstat(lock[2]), lock[0].lstat() - ) - ) - finally: - _cli._release_runtime_lock(lock) - - def test_release_closes_descriptor_and_revalidates_each_lock_name(self): - with tempfile.TemporaryDirectory() as root: - directory = Path(root) - lock, _ = _cli._acquire_runtime_lock(directory) - original_fd = lock[2] - events = [] - read_owner = _cli._read_runtime_lock_owner - rename = os.rename - - def record_owner_validation(path): - events.append(("validate", Path(path).name)) - return read_owner(path) - - def require_closed_descriptor(source, destination): - with self.assertRaises(OSError): - os.fstat(original_fd) - events.append(("rename", Path(source).name)) - return rename(source, destination) - - with mock.patch.object( - _cli, - "_read_runtime_lock_owner", - side_effect=record_owner_validation, - ), mock.patch.object( - _cli.os, "rename", side_effect=require_closed_descriptor - ): - _cli._release_runtime_lock(lock) - - self.assertEqual( - events, - [ - ("validate", _cli._RUNTIME_LOCK_NAME), - ("validate", _cli._RUNTIME_LOCK_NAME), - ("rename", _cli._RUNTIME_LOCK_NAME), - ( - "validate", - f"{_cli._RUNTIME_LOCK_NAME}.released-{lock[1]}", - ), - ], - ) - self.assertFalse(lock[0].exists()) - - def test_release_rejects_owner_substitution_before_rename(self): - with tempfile.TemporaryDirectory() as root: - directory = Path(root) - lock, _ = _cli._acquire_runtime_lock(directory) - read_owner = _cli._read_runtime_lock_owner - canonical_reads = 0 - - def substitute_owner_on_reopen(path): - nonlocal canonical_reads - owner = read_owner(path) - if Path(path) == lock[0]: - canonical_reads += 1 - if canonical_reads == 2: - return (os.getpid(), "f" * 32, owner[2]) - return owner - - with mock.patch.object( - _cli, - "_read_runtime_lock_owner", - side_effect=substitute_owner_on_reopen, - ): - with self.assertRaisesRegex(RuntimeError, "ownership changed"): - _cli._release_runtime_lock(lock) - - self.assertTrue(lock[0].exists()) - lock[0].unlink() - - def test_release_rejects_object_substitution_before_rename(self): - with tempfile.TemporaryDirectory() as root: - directory = Path(root) - lock, _ = _cli._acquire_runtime_lock(directory) - close = os.close - substituted = False - - def substitute_object_after_owner_close(owner_fd): - nonlocal substituted - if ( - owner_fd != lock[2] - or substituted - or not lock[0].exists() - ): - close(owner_fd) - return - owner_record = lock[0].read_bytes() - replacement = directory / "replacement-lock" - replacement.write_bytes(owner_record) - try: - close(owner_fd) - os.replace(replacement, lock[0]) - substituted = True - finally: - replacement.unlink(missing_ok=True) - - with mock.patch.object( - _cli.os, - "close", - side_effect=substitute_object_after_owner_close, - ): - with self.assertRaisesRegex(RuntimeError, "ownership changed"): - _cli._release_runtime_lock(lock) - - self.assertTrue(substituted) - self.assertTrue(lock[0].exists()) - lock[0].unlink() - - def test_expired_lease_never_reclaims_live_owner(self): - with tempfile.TemporaryDirectory() as root: - lock_path = Path(root) / _cli._RUNTIME_LOCK_NAME - owner = { - "pid": os.getpid(), - "token": "a" * 32, - "lease_expires_ms": int((time.time() - 1) * 1000), - } - owner_record = f"{json.dumps(owner)}\n" - lock_path.write_text(owner_record) - before = lock_path.stat() - - with mock.patch.object(_cli, "_process_is_alive", return_value=True): - self.assertEqual( - _cli._try_reclaim_runtime_lock(lock_path, "b" * 32), - (False, False), - ) - after = lock_path.stat() - self.assertEqual(lock_path.read_text(), owner_record) - self.assertEqual(json.loads(lock_path.read_text())["token"], owner["token"]) - self.assertEqual(after.st_dev, before.st_dev) - self.assertEqual(after.st_ino, before.st_ino) - - def test_stale_legacy_lock_never_reclaims_live_owner(self): - with tempfile.TemporaryDirectory() as root: - lock_path = Path(root) / _cli._RUNTIME_LOCK_NAME - owner_record = json.dumps( - {"pid": os.getpid(), "token": "a" * 32} - ) + "\n" - lock_path.mkdir() - (lock_path / "owner.json").write_text(owner_record) - stale = time.time() - _cli._RUNTIME_LEGACY_STALE_SECONDS - 1 - os.utime(lock_path, (stale, stale)) - before = lock_path.stat() - - with mock.patch.object(_cli, "_process_is_alive", return_value=True): - self.assertEqual( - _cli._try_reclaim_runtime_lock(lock_path, "b" * 32), - (False, False), - ) - after = lock_path.stat() - self.assertEqual((lock_path / "owner.json").read_text(), owner_record) - self.assertEqual(after.st_dev, before.st_dev) - self.assertEqual(after.st_ino, before.st_ino) - - def test_stale_ownerless_lock_is_reclaimed(self): - with tempfile.TemporaryDirectory() as root: - lock_path = Path(root) / _cli._RUNTIME_LOCK_NAME - lock_path.write_text("") - stale = time.time() - _cli._RUNTIME_OWNERLESS_STALE_SECONDS - 1 - os.utime(lock_path, (stale, stale)) - - self.assertEqual( - _cli._try_reclaim_runtime_lock(lock_path, "b" * 32), - (True, False), - ) - self.assertFalse(lock_path.exists()) - - def test_archive_namespace_is_exact_and_ui_has_one_pack(self): - pack = f"cbm-ui-{'a' * 64}.pack" - names = (*_cli._WINDOWS_ARCHIVE_NAMES, pack) - - self.assertEqual( - _cli._validate_archive_names(names, _cli._WINDOWS_ARCHIVE_NAMES, True), - pack, - ) - with self.assertRaises(SystemExit): - _cli._validate_archive_names( - (*names, "unexpected-root-file"), - _cli._WINDOWS_ARCHIVE_NAMES, - True, - ) - with self.assertRaises(SystemExit): - _cli._validate_archive_names( - (*names, f"cbm-ui-{'b' * 64}.pack"), - _cli._WINDOWS_ARCHIVE_NAMES, - True, - ) - with self.assertRaises(SystemExit): - _cli._validate_archive_names( - names, _cli._WINDOWS_ARCHIVE_NAMES, False - ) - - def test_ui_zip_extracts_only_the_runtime_set(self): - pack = f"cbm-ui-{'a' * 64}.pack" - with tempfile.TemporaryDirectory() as root: - archive = Path(root) / "release.zip" - destination = Path(root) / "extract" - destination.mkdir() - with zipfile.ZipFile(archive, "w") as zf: - for name in (*_cli._WINDOWS_ARCHIVE_NAMES, pack): - zf.writestr(name, name.encode()) - - with zipfile.ZipFile(archive) as zf: - names = _cli._safe_extract_zip( - zf, - str(destination), - _cli._WINDOWS_ARCHIVE_NAMES, - (_cli._WINDOWS_BINARY_NAME, _cli._INTEGRATIONS_NAME), - True, - ) - - self.assertEqual( - names, - (_cli._WINDOWS_BINARY_NAME, _cli._INTEGRATIONS_NAME, pack), - ) - self.assertEqual( - {path.name for path in destination.iterdir()}, set(names) - ) - - def test_unix_tar_rejects_an_unexpected_root_member(self): - with tempfile.TemporaryDirectory() as root: - archive = Path(root) / "release.tar.gz" - with tarfile.open(archive, "w:gz") as tf: - for name in (*_cli._UNIX_ARCHIVE_NAMES, "unexpected-root-file"): - data_path = Path(root) / f"fixture-{len(tf.getmembers())}" - data_path.write_bytes(name.encode()) - tf.add(data_path, arcname=name) - destination = Path(root) / "extract" - destination.mkdir() - with tarfile.open(archive) as tf, self.assertRaises(SystemExit): - _cli._safe_extract_tar( - tf, - str(destination), - _cli._UNIX_ARCHIVE_NAMES, - ("codebase-memory-mcp", _cli._INTEGRATIONS_NAME), - False, - ) - - def test_unix_tar_rejects_hardlink_members(self): - with tempfile.TemporaryDirectory() as root: - archive = Path(root) / "release.tar.gz" - with tarfile.open(archive, "w:gz") as tf: - info = tarfile.TarInfo("codebase-memory-mcp") - info.type = tarfile.LNKTYPE - info.linkname = "cbm-integrations.json" - tf.addfile(info) - destination = Path(root) / "extract" - destination.mkdir() - with tarfile.open(archive) as tf, self.assertRaises(SystemExit): - _cli._safe_extract_tar( - tf, - str(destination), - ("codebase-memory-mcp",), - ("codebase-memory-mcp",), - False, - ) - - def test_windows_zip_rejects_symlink_metadata(self): - with tempfile.TemporaryDirectory() as root: - archive = Path(root) / "release.zip" - with zipfile.ZipFile(archive, "w") as zf: - info = zipfile.ZipInfo(_cli._WINDOWS_BINARY_NAME) - info.create_system = 3 - info.external_attr = (stat.S_IFLNK | 0o777) << 16 - zf.writestr(info, "cbm-integrations.json") - destination = Path(root) / "extract" - destination.mkdir() - with zipfile.ZipFile(archive) as zf, self.assertRaises(SystemExit): - _cli._safe_extract_zip( - zf, - str(destination), - (_cli._WINDOWS_BINARY_NAME,), - (_cli._WINDOWS_BINARY_NAME,), - False, - ) - - def test_readiness_requires_the_complete_selected_variant(self): - with tempfile.TemporaryDirectory() as root, mock.patch.object( - _cli, "_cache_dir", return_value=Path(root) - ): - with mock.patch.dict(os.environ, {"CBM_VARIANT": "standard"}): - standard_binary = _cli._bin_path("0.8.1") - standard_binary.parent.mkdir(parents=True) - standard_binary.write_text("binary") - (standard_binary.parent / _cli._INTEGRATIONS_NAME).write_text( - "integrations" - ) - self.assertTrue(_cli._runtime_set_ready("0.8.1")) - (standard_binary.parent / _cli._INTEGRATIONS_NAME).unlink() - self.assertFalse(_cli._runtime_set_ready("0.8.1")) - - with mock.patch.dict(os.environ, {"CBM_VARIANT": "ui"}): - ui_binary = _cli._bin_path("0.8.1") - ui_binary.parent.mkdir(parents=True) - ui_binary.write_text("binary") - (ui_binary.parent / _cli._INTEGRATIONS_NAME).write_text( - "integrations" - ) - pack_bytes = b"pack" - pack_name = ( - f"cbm-ui-{hashlib.sha256(pack_bytes).hexdigest()}.pack" - ) - (ui_binary.parent / pack_name).write_bytes(pack_bytes) - self.assertTrue(_cli._runtime_set_ready("0.8.1")) - second_pack = f"cbm-ui-{'b' * 64}.pack" - (ui_binary.parent / second_pack).write_text("second pack") - self.assertFalse(_cli._runtime_set_ready("0.8.1")) - - def test_ui_readiness_rejects_digest_mismatched_pack_bytes(self): - with tempfile.TemporaryDirectory() as root, mock.patch.object( - _cli, "_cache_dir", return_value=Path(root) - ), mock.patch.dict(os.environ, {"CBM_VARIANT": "ui"}): - binary = _cli._bin_path("0.8.1") - binary.parent.mkdir(parents=True) - binary.write_text("binary") - (binary.parent / _cli._INTEGRATIONS_NAME).write_text("integrations") - authenticated = b"authenticated pack" - digest = hashlib.sha256(authenticated).hexdigest() - pack = binary.parent / f"cbm-ui-{digest}.pack" - pack.write_bytes(b"corrupt pack") - - self.assertFalse( - _cli._runtime_set_ready("0.8.1"), - "a well-shaped filename must not authenticate different bytes", - ) - - def test_ui_publication_rejects_digest_mismatched_staged_pack(self): - with tempfile.TemporaryDirectory() as root: - directory = Path(root) - binary_name = "codebase-memory-mcp" - authenticated = b"authenticated pack" - pack = ( - f"cbm-ui-{hashlib.sha256(authenticated).hexdigest()}.pack" - ) - staged_paths = {} - for name, contents in ( - (binary_name, b"binary"), - (_cli._INTEGRATIONS_NAME, b"integrations"), - (pack, b"corrupt pack"), - ): - staged = directory / f".stage-{name}" - staged.write_bytes(contents) - staged_paths[name] = staged - - with self.assertRaisesRegex(RuntimeError, "digest"): - _cli._publish_runtime_set( - staged_paths, directory / binary_name, binary_name, "ui" - ) - - self.assertFalse((directory / binary_name).exists()) - self.assertFalse((directory / _cli._RUNTIME_LOCK_NAME).exists()) - - def test_publication_commits_sidecars_before_binary(self): - with tempfile.TemporaryDirectory() as root: - directory = Path(root) - binary_name = "codebase-memory-mcp" - pack_bytes = b"pack" - pack = f"cbm-ui-{hashlib.sha256(pack_bytes).hexdigest()}.pack" - staged_paths = {} - for name, contents in ( - (binary_name, b"binary"), - (_cli._INTEGRATIONS_NAME, b"integrations"), - (pack, pack_bytes), - ): - staged = directory / f"stage-{name}" - staged.write_bytes(contents) - staged_paths[name] = staged - committed = [] - replace = os.replace - - def record_replace(source, destination): - committed.append(Path(destination).name) - replace(source, destination) - - with mock.patch.object(_cli.os, "replace", side_effect=record_replace): - _cli._publish_runtime_set( - staged_paths, directory / binary_name, binary_name, "ui" - ) - - self.assertEqual( - committed, [_cli._INTEGRATIONS_NAME, pack, binary_name] - ) - - def test_killed_publisher_is_reconciled_by_locked_readiness(self): - helper_source = r""" -import os -import sys -import time -from pathlib import Path - -source_root, directory_raw, staged_raw, marker_raw, crash_name = sys.argv[1:] -sys.path.insert(0, source_root) -from codebase_memory_mcp import _cli - -directory = Path(directory_raw) -staged = Path(staged_raw) -marker = Path(marker_raw) -binary_name = "codebase-memory-mcp" -staged_paths = { - _cli._INTEGRATIONS_NAME: staged / _cli._INTEGRATIONS_NAME, - binary_name: staged / binary_name, -} -replace = os.replace - -def pausing_replace(source, destination): - replace(source, destination) - if Path(destination) == directory / crash_name: - marker.write_text("reached\n") - while True: - time.sleep(60) - -def verifier(binary): - if not Path(binary).read_text().startswith("binary:"): - raise RuntimeError("test binary failed verification") - -_cli._publish_runtime_set( - staged_paths, - directory / binary_name, - binary_name, - "standard", - replace_file=pausing_replace, - verifier=verifier, -) -""" - binary_name = "codebase-memory-mcp" - for crash_name, expected_ready in ( - (_cli._INTEGRATIONS_NAME, False), - (binary_name, True), - ): - with self.subTest(crash_name=crash_name), tempfile.TemporaryDirectory() as root: - root_path = Path(root) - directory = root_path / "destination" - staged = root_path / "staged" - marker = root_path / "crash-reached" - directory.mkdir() - staged.mkdir() - (directory / binary_name).write_text("corrupt:old") - (directory / _cli._INTEGRATIONS_NAME).write_text( - "integrations:old" - ) - (staged / binary_name).write_text("binary:candidate") - (staged / _cli._INTEGRATIONS_NAME).write_text( - "integrations:candidate" - ) - environment = os.environ.copy() - environment["PYTHONPYCACHEPREFIX"] = str(root_path / "pycache") - process = subprocess.Popen( - [ - sys.executable, - "-c", - helper_source, - str(PACKAGE_ROOT / "src"), - str(directory), - str(staged), - str(marker), - crash_name, - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - text=True, - env=environment, - ) - try: - deadline = time.monotonic() + 10 - while not marker.exists() and time.monotonic() < deadline: - if process.poll() is not None: - stderr = process.communicate()[1] - self.fail( - "publication helper exited before crash gate: " - f"{stderr}" - ) - time.sleep(0.01) - self.assertTrue( - marker.exists(), - "publication helper did not reach crash gate", - ) - backups = [ - path - for path in directory.iterdir() - if path.name.startswith(".cbm-runtime-backup-") - ] - self.assertEqual(len(backups), 1) - self.assertTrue(backups[0].is_dir()) - self.assertTrue( - (backups[0] / ".retirement-complete").is_file() - ) - - process.kill() - process.wait(5) - self.assertNotEqual(process.returncode, 0) - - def verifier(binary): - if not Path(binary).read_text().startswith("binary:"): - raise RuntimeError("test binary failed verification") - - with mock.patch.object( - _cli, "_bin_path", return_value=directory / binary_name - ), mock.patch.object( - _cli, "_variant", return_value="standard" - ), mock.patch.object( - _cli, "_verify_candidate", side_effect=verifier - ): - ready = _cli._runtime_set_ready_locked("test-version") - self.assertEqual(ready, expected_ready) - if not ready: - self.assertEqual( - (directory / binary_name).read_text(), - "corrupt:old", - ) - self.assertEqual( - (directory / _cli._INTEGRATIONS_NAME).read_text(), - "integrations:old", - ) - retry_staged = root_path / "retry-staged" - retry_staged.mkdir() - retry_paths = { - binary_name: retry_staged / binary_name, - _cli._INTEGRATIONS_NAME: retry_staged - / _cli._INTEGRATIONS_NAME, - } - retry_paths[binary_name].write_text("binary:candidate") - retry_paths[_cli._INTEGRATIONS_NAME].write_text( - "integrations:candidate" - ) - _cli._publish_runtime_set( - retry_paths, - directory / binary_name, - binary_name, - "standard", - verifier=verifier, - ) - - self.assertEqual( - (directory / binary_name).read_text(), "binary:candidate" - ) - self.assertEqual( - (directory / _cli._INTEGRATIONS_NAME).read_text(), - "integrations:candidate", - ) - self.assertFalse( - any( - path.name.startswith(".cbm-runtime-backup-") - for path in directory.iterdir() - ) - ) - self.assertFalse( - (directory / _cli._RUNTIME_LOCK_NAME).exists() - ) - finally: - if process.poll() is None: - process.kill() - process.wait(5) - if process.stderr is not None: - process.stderr.close() - - def test_publication_failure_restores_prior_complete_runtime_set(self): - with tempfile.TemporaryDirectory() as root: - directory = Path(root) - binary_name = "codebase-memory-mcp" - old_pack_bytes = b"old pack" - old_pack = f"cbm-ui-{hashlib.sha256(old_pack_bytes).hexdigest()}.pack" - (directory / binary_name).write_text("binary:old") - (directory / _cli._INTEGRATIONS_NAME).write_text("integrations:old") - (directory / old_pack).write_bytes(old_pack_bytes) - - candidate_pack_bytes = b"candidate pack" - candidate_pack = ( - f"cbm-ui-{hashlib.sha256(candidate_pack_bytes).hexdigest()}.pack" - ) - staged_paths = {} - for name, contents in ( - (binary_name, b"binary:candidate"), - (_cli._INTEGRATIONS_NAME, b"integrations:candidate"), - (candidate_pack, candidate_pack_bytes), - ): - staged = directory / f"stage-{name}" - staged.write_bytes(contents) - staged_paths[name] = staged - - replace = os.replace - failed = False - retired = [] - - def fail_binary_publish(source, destination): - nonlocal failed - source = Path(source) - destination = Path(destination) - if destination.parent.name.startswith(".cbm-runtime-backup-"): - retired.append(source.name) - if destination == directory / binary_name and not failed: - failed = True - raise OSError("injected binary publication failure") - replace(source, destination) - - with self.assertRaises(OSError): - _cli._publish_runtime_set( - staged_paths, - directory / binary_name, - binary_name, - "ui", - replace_file=fail_binary_publish, - ) - - self.assertTrue(failed) - self.assertEqual(retired[0], binary_name) - self.assertEqual((directory / binary_name).read_text(), "binary:old") - self.assertEqual( - (directory / _cli._INTEGRATIONS_NAME).read_text(), - "integrations:old", - ) - self.assertEqual((directory / old_pack).read_bytes(), old_pack_bytes) - self.assertFalse((directory / candidate_pack).exists()) - self.assertFalse( - any( - path.name.startswith(".cbm-runtime-backup-") - for path in directory.iterdir() - ) - ) - - def test_concurrent_publishers_preserve_the_first_complete_winner(self): - with tempfile.TemporaryDirectory() as root: - directory = Path(root) - binary_name = "codebase-memory-mcp" - - def stages(tag): - pack_bytes = f"pack:{tag}".encode() - pack = ( - f"cbm-ui-{hashlib.sha256(pack_bytes).hexdigest()}.pack" - ) - result = {} - for name, contents in ( - (binary_name, f"binary:{tag}".encode()), - (_cli._INTEGRATIONS_NAME, f"integrations:{tag}".encode()), - (pack, pack_bytes), - ): - staged = directory / f".stage-{tag}-{name}" - staged.write_bytes(contents) - result[name] = staged - return result, pack - - first_stages, first_pack = stages("first") - second_stages, second_pack = stages("second") - first_holds_lock = threading.Event() - allow_first = threading.Event() - second_observed_lock = threading.Event() - errors = [] - replace = os.replace - read_lock_owner = _cli._read_runtime_lock_owner - - def pause_first(source, destination): - replace(source, destination) - if Path(destination) == directory / binary_name: - first_holds_lock.set() - if not allow_first.wait(5): - raise RuntimeError("test did not release first publisher") - - def observe_waiter(lock_path): - if threading.current_thread().name == "publisher-second": - second_observed_lock.set() - return read_lock_owner(lock_path) - - def publish(label, staged_paths, replace_file=None): - try: - _cli._publish_runtime_set( - staged_paths, - directory / binary_name, - binary_name, - "ui", - replace_file=replace_file, - ) - except Exception as exc: # surfaced on the test thread below - errors.append((label, exc)) - - first = threading.Thread( - name="publisher-first", - target=publish, - args=("first", first_stages, pause_first), - ) - second = threading.Thread( - name="publisher-second", - target=publish, - args=("second", second_stages), - ) - with mock.patch.object( - _cli, - "_read_runtime_lock_owner", - side_effect=observe_waiter, - ): - first.start() - try: - self.assertTrue(first_holds_lock.wait(2)) - second.start() - self.assertTrue(second_observed_lock.wait(2)) - finally: - allow_first.set() - first.join(5) - if second.ident is not None: - second.join(5) - - self.assertFalse(first.is_alive()) - self.assertFalse(second.is_alive()) - self.assertEqual(errors, []) - self.assertEqual( - (directory / binary_name).read_bytes(), b"binary:first" - ) - self.assertEqual( - (directory / _cli._INTEGRATIONS_NAME).read_bytes(), - b"integrations:first", - ) - self.assertEqual( - (directory / first_pack).read_bytes(), b"pack:first" - ) - self.assertFalse((directory / second_pack).exists()) - self.assertFalse((directory / _cli._RUNTIME_LOCK_NAME).exists()) - - def test_failure_never_deletes_a_complete_foreign_winner(self): - with tempfile.TemporaryDirectory() as root: - directory = Path(root) - binary_name = "codebase-memory-mcp" - old_pack_bytes = b"pack:old" - old_pack = ( - f"cbm-ui-{hashlib.sha256(old_pack_bytes).hexdigest()}.pack" - ) - (directory / binary_name).write_bytes(b"binary:old") - (directory / _cli._INTEGRATIONS_NAME).write_bytes( - b"integrations:old" - ) - (directory / old_pack).write_bytes(old_pack_bytes) - - candidate_pack_bytes = b"pack:candidate" - candidate_pack = ( - f"cbm-ui-{hashlib.sha256(candidate_pack_bytes).hexdigest()}.pack" - ) - staged_paths = {} - for name, contents in ( - (binary_name, b"binary:candidate"), - (_cli._INTEGRATIONS_NAME, b"integrations:candidate"), - (candidate_pack, candidate_pack_bytes), - ): - staged = directory / f".stage-candidate-{name}" - staged.write_bytes(contents) - staged_paths[name] = staged - - foreign_pack_bytes = b"pack:foreign" - foreign_pack = ( - f"cbm-ui-{hashlib.sha256(foreign_pack_bytes).hexdigest()}.pack" - ) - replace = os.replace - injected = False - - def install_foreign_winner(source, destination): - nonlocal injected - source = Path(source) - destination = Path(destination) - if destination == directory / binary_name and not injected: - injected = True - for path in directory.iterdir(): - if path.name.startswith("cbm-ui-") and path.name.endswith( - ".pack" - ): - path.unlink() - (directory / _cli._INTEGRATIONS_NAME).write_bytes( - b"integrations:foreign" - ) - (directory / foreign_pack).write_bytes(foreign_pack_bytes) - (directory / binary_name).write_bytes(b"binary:foreign") - raise OSError("simulated non-cooperating winner") - replace(source, destination) - - _cli._publish_runtime_set( - staged_paths, - directory / binary_name, - binary_name, - "ui", - replace_file=install_foreign_winner, - ) - - self.assertTrue(injected) - self.assertEqual( - (directory / binary_name).read_bytes(), b"binary:foreign" - ) - self.assertEqual( - (directory / _cli._INTEGRATIONS_NAME).read_bytes(), - b"integrations:foreign", - ) - self.assertEqual( - (directory / foreign_pack).read_bytes(), foreign_pack_bytes - ) - self.assertFalse((directory / candidate_pack).exists()) - self.assertFalse((directory / old_pack).exists()) - self.assertFalse( - any( - path.name.startswith(".cbm-runtime-backup-") - for path in directory.iterdir() - ) - ) + self.assertIsNone(_cli._portable_mutation_action(["install", "--yes"])) + self.assertIsNone(_cli._portable_mutation_action(["cli", "update"])) if __name__ == "__main__": diff --git a/scripts/build.sh b/scripts/build.sh index 7b6f9dace..a71029b93 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -3,7 +3,7 @@ # # Usage: # scripts/build.sh # Standard binary -# scripts/build.sh --with-ui # Binary + external UI asset pack +# scripts/build.sh --with-ui # Binary with the UI embedded # scripts/build.sh --help # Full usage # scripts/build.sh --version v0.8.0 # With version stamp # scripts/build.sh --arch x86_64 # Force x86_64 build @@ -156,30 +156,4 @@ else CFLAGS_EXTRA="$CFLAGS_EXTRA" "${EXTRA_MAKE_ARGS[@]+"${EXTRA_MAKE_ARGS[@]}"}" fi -# Stage the integration-template asset next to the binary so the dev/CI build -# mirrors the release archive layout. The binary resolves cbm-integrations.json -# next to itself, in $CBM_ASSETS_DIR, or under ~/.cbm/assets//; a plain -# build/c binary has none of those, so install/uninstall (which render the -# templates to verify hash and ownership) would fail closed exactly as they do -# for a user who deleted the file. Copying it here keeps `install` working -# straight out of a build tree — smoke, local dev, and the release packaging all -# then see the same adjacency. -cp "$ROOT/assets/cbm-integrations.json" "$BUILD_DIR/cbm-integrations.json" - -if $WITH_UI; then - shopt -s nullglob - UI_PACKS=("$BUILD_DIR"/cbm-ui-*.pack) - shopt -u nullglob - if [ "${#UI_PACKS[@]}" -ne 1 ]; then - echo "build.sh: UI build did not produce exactly one content-addressed asset pack" >&2 - exit 1 - fi - UI_PACK_NAME="$(basename "${UI_PACKS[0]}")" - if ! [[ "$UI_PACK_NAME" =~ ^cbm-ui-[0-9a-f]{64}\.pack$ ]]; then - echo "build.sh: invalid UI asset pack name: $UI_PACK_NAME" >&2 - exit 1 - fi - echo "=== UI assets: ${UI_PACKS[0]} ===" -fi - echo "=== Build complete: ${BUILD_DIR}/codebase-memory-mcp ===" diff --git a/scripts/ci/check-binary-composition.sh b/scripts/ci/check-binary-composition.sh index 43864e634..74b07f230 100755 --- a/scripts/ci/check-binary-composition.sh +++ b/scripts/ci/check-binary-composition.sh @@ -20,7 +20,7 @@ # A missing tool is a hard error for the same reason: a skipped assertion must # never look like a satisfied one. # -# Usage: scripts/ci/check-binary-composition.sh [--variant=auto|standard|ui] ... +# Usage: scripts/ci/check-binary-composition.sh ... # Directories are scanned recursively; format (ELF / Mach-O / PE) is detected # per file from its magic bytes and each assertion runs where it is # meaningful. Exit 0 = every assertion passed, 1 = at least one failed, @@ -98,44 +98,15 @@ UI_HTTP_NEEDLES=( '[c]odebase-memory-mcp' ) -# The binary used to embed nine complete shebang'd shell scripts, their -# PowerShell/.cmd twins and two node:child_process client modules — script -# bodies written to disk at 0755. Keeping those programs outside the native -# image narrows executable surface and lets each component be inspected and -# scanned independently. They now ship in -# cbm-integrations.json (the binary embeds only its SHA-256); these needles -# prove the removal stayed removed. Source-level enforcement lives in -# tests/test_no_embedded_scripts_contract.sh; this is the shipped-artifact -# half of the same property. -SCRIPT_NEEDLES=( - '#!/usr/bin/env bash' - '#!/bin/bash' - '#!/bin/sh' - 'node:child_process' -) - -# UI frontend bytes belong exclusively in the independently scannable pack. -# These are structural bytes from the pack header and Vite's emitted index, -# selected to avoid generic HTML grammar strings that legitimately occur in the -# language parsers linked into every binary. -UI_ASSET_BYTE_NEEDLES=( - 'CBMUIPK' - '' - '' - '