diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 0f4dd1a5a3..1048b41897 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/.github/workflows/dry-run.yml b/.github/workflows/dry-run.yml index 14f20df83a..bf7800a537 100644 --- a/.github/workflows/dry-run.yml +++ b/.github/workflows/dry-run.yml @@ -151,11 +151,9 @@ jobs: - name: Extract archive artifacts run: | scripts/ci/extract-release-archives.sh "$RUNNER_TEMP/release-archives" binaries \ - --archive-scope=ui \ --expect-archives=8 \ --expect-binaries=8 \ - --expect-packs=8 \ - --expect-runtime-files=32 + --expect-runtime-files=24 - name: VirusTotal scan uses: crazy-max/ghaction-virustotal@936d8c5c00afe97d3d9a1af26d017cfdf26800a2 # v5.0.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2d806b68c..f806737e86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -216,11 +216,9 @@ jobs: mkdir -p "$ARCHIVE_DIR" binaries gh release download "$VERSION" --dir "$ARCHIVE_DIR" --repo "$GITHUB_REPOSITORY" --pattern '*.tar.gz' --pattern '*.zip' scripts/ci/extract-release-archives.sh "$ARCHIVE_DIR" binaries \ - --archive-scope=ui \ --expect-archives=8 \ --expect-binaries=8 \ - --expect-packs=8 \ - --expect-runtime-files=32 + --expect-runtime-files=24 - name: Security audits on all unique extracted release objects run: | diff --git a/.gitignore b/.gitignore index 05e7b1bfeb..86a218068c 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,9 @@ private/ build/ node_modules/ graph-ui/dist/ +# Generated by scripts/embed-frontend.sh into the source tree (the linker needs +# it beside the other UI sources). Removed by scripts/clean.sh; never committed. +src/ui/embedded_assets.c # Generated reports BENCHMARK_REPORT.md diff --git a/Makefile.cbm b/Makefile.cbm index 246e8f1d23..0b47eaf9fc 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -162,10 +162,18 @@ endif # link), this flag fixes the OUTCOME, and the composition gate proves it on the # shipped artifact. ELF-only: Apple's ld rejects -z noexecstack outright and it # is meaningless for PE, so it is gated rather than made "common". +# +# -z separate-code additionally keeps read-only DATA out of the executable +# segment. GNU ld enables it by default on x86-64 but NOT on aarch64, so the +# arm64 binaries emitted ONE R E PT_LOAD spanning the whole image: .rodata is +# correctly marked A (not AX), but the kernel applies SEGMENT permissions, so +# ~259 MB of tree-sitter parse tables was mapped executable at runtime while the +# amd64 build of the same source mapped them R only. That is a large ROP gadget +# surface for data that is never executed, and it is invisible to section flags. ELF_HARDENING_FLAGS := ifeq ($(IS_LINUX),yes) ifneq ($(IS_MINGW),yes) -ELF_HARDENING_FLAGS := -Wl,-z,noexecstack +ELF_HARDENING_FLAGS := -Wl,-z,noexecstack -Wl,-z,separate-code endif endif @@ -358,19 +366,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 +464,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 +481,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 +696,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 +717,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 +860,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 +888,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 +948,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 +1037,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) $@ - -ui-pack: $(UI_ASSET_MANIFEST) +embed: frontend + scripts/embed-frontend.sh graph-ui/dist $(BUILD_DIR)/embedded -# $(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 +1092,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/README.md b/README.md index 520b95b3b9..87cf17920d 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-si

Graph visualization UI showing the codebase-memory-mcp knowledge graph
- Built-in 3D graph visualization (UI variant) — explore your knowledge graph at localhost:9749 + Built-in 3D graph visualization — explore your knowledge graph at localhost:9749

## Why codebase-memory-mcp @@ -35,7 +35,7 @@ High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-si - **158 languages** — vendored tree-sitter grammars compiled into the binary. Nothing to install, nothing that breaks. - **120x fewer tokens** — 5 structural queries: ~3,400 tokens vs ~412,000 via file-by-file search. One graph query replaces dozens of grep/read cycles. - **43 supported automatic/conditional client surfaces** — `install` configures detected clients and safely activates conditional clients only when their documented platform, marker, or explicit existing config path is present. See [Multi-Agent Support](#multi-agent-support) for the complete matrix and manual/UI-only boundaries. -- **Built-in graph visualization** — 3D interactive UI at `localhost:9749` (optional UI binary variant). +- **Built-in graph visualization** — 3D interactive UI at `localhost:9749`, served from the binary itself. - **Infrastructure-as-code indexing** — Dockerfiles, Kubernetes manifests, and Kustomize overlays indexed as graph nodes with cross-references. `Resource` nodes for K8s kinds, `Module` nodes for Kustomize overlays with `IMPORTS` edges to referenced resources. - **15 MCP tools** — search, trace, architecture, impact analysis, targeted index-coverage checks, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more. @@ -48,7 +48,7 @@ curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/i With graph visualization UI: ```bash -curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash -s -- --ui +curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash ``` **Windows** (PowerShell): @@ -69,7 +69,15 @@ Unblock-File .\install.ps1 > **Note:** If you see a script execution policy error, run `Set-ExecutionPolicy -Scope Process Bypass` first, or invoke with `PowerShell -ExecutionPolicy Bypass -File .\install.ps1`. -Options: `--ui` (graph visualization), `--skip-config` (binary only, no agent setup), `--dir=` (custom location). +Options: `--skip-config` (binary only, no agent setup), `--dir=` (custom location). + +> **Antivirus note:** Microsoft Defender may flag a release binary as +> `Trojan:Script/Wacatac.B!ml`. This is a known false positive — typically 61 of +> ~62 engines return clean, and the same detection family hits `gh`, llama.cpp, +> Godot and Microsoft's own Go toolchain. See +> [Antivirus False Positives](SECURITY.md#antivirus-false-positives) for the +> evidence, how to verify the artifacts yourself, and how to report it if you +> think we are wrong. Restart your coding agent. Say **"Index this project"** — done. @@ -77,8 +85,7 @@ Restart your coding agent. Say **"Index this project"** — done. Manual install 1. **Download** the archive for your platform from the [latest release](https://github.com/DeusData/codebase-memory-mcp/releases/latest): - - `codebase-memory-mcp--.tar.gz` (macOS/Linux) or `.zip` (Windows) — standard - - `codebase-memory-mcp-ui--.tar.gz` / `.zip` — with graph visualization + - `codebase-memory-mcp--.tar.gz` (macOS/Linux) or `.zip` (Windows) 2. **Extract and install** (each archive includes `install.sh` or `install.ps1`): @@ -126,15 +133,7 @@ The ordinary `cli` mode is intentionally separate: it runs one command locally a ### Graph Visualization UI -The UI currently ships as a separate `ui` build whose executable embeds only a content-addressed manifest; the deterministic frontend pack is a verified sidecar beside it. The default install on every channel is the lean, headless server; opt into the UI build with: - -- **install.sh:** add `--ui` (see [Quick Start](#quick-start)) -- **npm:** `CBM_VARIANT=ui npm install -g codebase-memory-mcp` -- **PyPI:** `CBM_VARIANT=ui pip install codebase-memory-mcp` -- **Go:** after `go install github.com/DeusData/codebase-memory-mcp/pkg/go/cmd/codebase-memory-mcp@latest`, run the wrapper with `CBM_VARIANT=ui` -- **Manual:** download the `codebase-memory-mcp-ui--` archive - -Then run it: +The graph UI is built into the binary — every install on every channel has it. Then run it: ```bash codebase-memory-mcp --ui=true --port=9749 @@ -311,15 +310,15 @@ When you open a memory/performance issue, **attach the `.ndjson` trajectory** ### Pre-built Binaries -| Platform | Standard | With Graph UI | -|----------|----------|---------------| -| macOS (Apple Silicon) | `codebase-memory-mcp-darwin-arm64.tar.gz` | `codebase-memory-mcp-ui-darwin-arm64.tar.gz` | -| macOS (Intel) | `codebase-memory-mcp-darwin-amd64.tar.gz` | `codebase-memory-mcp-ui-darwin-amd64.tar.gz` | -| Linux (x86_64) | `codebase-memory-mcp-linux-amd64.tar.gz` | `codebase-memory-mcp-ui-linux-amd64.tar.gz` | -| Linux (ARM64) | `codebase-memory-mcp-linux-arm64.tar.gz` | `codebase-memory-mcp-ui-linux-arm64.tar.gz` | -| Windows (x86_64) | `codebase-memory-mcp-windows-amd64.zip` | `codebase-memory-mcp-ui-windows-amd64.zip` | +| Platform | Archive | +|----------|---------| +| macOS (Apple Silicon) | `codebase-memory-mcp-darwin-arm64.tar.gz` | +| macOS (Intel) | `codebase-memory-mcp-darwin-amd64.tar.gz` | +| Linux (x86_64) | `codebase-memory-mcp-linux-amd64.tar.gz` | +| Linux (ARM64) | `codebase-memory-mcp-linux-arm64.tar.gz` | +| Windows (x86_64) | `codebase-memory-mcp-windows-amd64.zip` | -Every release includes `checksums.txt` with SHA-256 hashes. Keep the native executable together with the authenticated `cbm-integrations.json` asset and, for the UI variant, its single content-addressed `cbm-ui-.pack`. Linux `-portable` archives contain the fully static builds; ordinary platform archives use their native system ABI. +Every release includes `checksums.txt` with SHA-256 hashes. The executable is self-contained — no adjacent data file is required. Linux `-portable` archives contain the fully static builds; ordinary platform archives use their native system ABI. > **Windows note**: SmartScreen may show a warning for unsigned software. Click **"More info"** → **"Run anyway"**. Verify integrity with `checksums.txt`. @@ -377,12 +376,12 @@ You: "Install this MCP server: https://github.com/DeusData/codebase-memory-mcp" ```bash git clone https://github.com/DeusData/codebase-memory-mcp.git cd codebase-memory-mcp -scripts/build.sh # standard binary -scripts/build.sh --with-ui # with graph visualization +scripts/build.sh --with-ui # the shipped composition (graph UI embedded) +scripts/build.sh # without the UI (development only) # Binary at: build/c/codebase-memory-mcp (codebase-memory-mcp.exe on Windows) ``` -Every platform builds a **verified runtime set**: the native executable, `cbm-integrations.json`, and—when `--with-ui` is selected—exactly one content-addressed `cbm-ui-.pack`. These files are authenticated and published together; do not separate the executable from its sidecars. +Every platform ships **one self-contained executable**: the graph UI and the agent integration templates are linked into the binary, so an extracted archive is immediately complete. Run the test suite (6,768 tests across 120 suites): @@ -724,7 +723,7 @@ SQLite databases stored at `~/.cache/codebase-memory-mcp/`. Persists across rest | `trace_path` returns 0 results | Use `search_graph(name_pattern=".*PartialName.*")` first to find the exact name. | | Queries return wrong project results | Add `project="name"` parameter. Use `list_projects` to see names. | | Binary not found after install | Add to PATH: `export PATH="$HOME/.local/bin:$PATH"` | -| UI not loading | Ensure you downloaded the `ui` variant and ran `--ui=true`. Check `http://localhost:9749`. | +| UI not loading | Ensure you ran `--ui=true`. Check `http://localhost:9749`. | ## Hybrid LSP diff --git a/SECURITY.md b/SECURITY.md index bb974e7fa0..b7d081331c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -148,9 +148,8 @@ is no exception path in this release gate. - **CORS locked to localhost** — graph UI only accessible from localhost origins - **Path containment** — `realpath()` check prevents reading files outside project root - **Process-kill restriction** — only server-spawned PIDs can be terminated -- **Release-set verification** — installers verify downloaded archives, then the - candidate executable authenticates its adjacent runtime assets before - activation +- **Release-set verification** — installers verify the downloaded archive's exact + member set and SHA-256 before the candidate executable is activated ### Verification @@ -172,6 +171,119 @@ sha256sum -c checksums.txt # https://www.virustotal.com/ ``` +## Antivirus False Positives + +Some release binaries are reported by **one** engine — Microsoft — as +`Trojan:Script/Wacatac.B!ml`. We believe this is a false positive, we do not hide +it, and this section exists so you can check that judgement yourself rather than +take our word for it. + +### What the detection is + +The `!ml` suffix marks a **machine-learning / heuristic** classification, not a +signature match. The `Script` token is a generic bucket in that naming scheme and +says nothing about script content — GitHub's own `gh` CLI and Anthropic's Claude +installer have both carried `Trojan:Script/Wacatac.H!ml` on native binaries. + +Typically 61 of ~62 engines on VirusTotal return clean for the same file. + +### What we measured + +We dissected a full release matrix built from one commit. The verdicts split +across every axis at once: + +| Binary | Link | Verdict | +|---|---|---| +| linux-amd64 | dynamic | flagged | +| linux-amd64-portable | static | clean | +| linux-arm64 | dynamic | clean | +| linux-arm64-portable | static | flagged | +| darwin-amd64 | — | flagged | +| darwin-arm64 | — | clean | +| windows amd64 / arm64 | — | clean | + +The static/dynamic axis **inverts** between architectures, so no build or link +property explains it. The two macOS binaries have identical segment structure and +still split. Sibling artifacts from one build landed in *different* variant +buckets (`.B` vs `.C`). + +We also tested and rejected the obvious structural hypothesis: entropy is low +everywhere (embedded vectors 4.17, parse tables 3.46 bits/byte, against 7.5–8.0 +for genuinely packed payloads), so the binary does not resemble a packed dropper. + +### What we changed, and what we reverted + +We removed every embedded shell script from the binary and moved the UI bundle +and the agent integration templates out into separate verified files. **The +detection count did not drop** — it simply moved between artifacts. We reverted +both changes rather than keep permanent complexity that bought nothing. We are +documenting that here because a negative result is still evidence. + +### This is endemic, not specific to us + +The same `!ml` family repeatedly hits large, unsigned, native open-source +binaries: + +- [llama.cpp #15874](https://github.com/ggml-org/llama.cpp/issues/15874), + [#24487](https://github.com/ggml-org/llama.cpp/issues/24487), + [#24558](https://github.com/ggml-org/llama.cpp/issues/24558) — including one + DLL of many in a single archive, with nothing found on reverse engineering +- [GitHub CLI #13306](https://github.com/cli/cli/issues/13306) +- [Microsoft's own Go toolchain #1255](https://github.com/microsoft/go/issues/1255) +- [Anthropic Claude Code #36796](https://github.com/anthropics/claude-code/issues/36796) +- [yt-dlp #7532](https://github.com/yt-dlp/yt-dlp/issues/7532), + [Godot #110612](https://github.com/godotengine/godot/issues/110612), + [PyInstaller #5854](https://github.com/pyinstaller/pyinstaller/issues/5854), + [Tauri #2486](https://github.com/tauri-apps/tauri/issues/2486), + [OpenAI Codex #2228](https://github.com/openai/codex/issues/2228), + [rust-lang/rust #88297](https://github.com/rust-lang/rust/issues/88297) + +A Microsoft engineer on the Go team [put it plainly](https://github.com/microsoft/go/issues/1255): +*"we're aware of Windows Security/Defender issues with Go apps… we can't exactly +go fix something and solve all Go false positives."* + +### Why we do not obfuscate around it + +Deliberately reshaping a binary to avoid a classifier is what malware does, and +it measurably backfires — the same Microsoft engineer reports that obfuscation +*"increases scrutiny rather than avoiding it."* We would rather be scannable and +explain a false positive than be unreadable and score well. + +### Verify it yourself + +Every release ships the material needed to check our artifacts independently. +Use the commands in [Verification](#verification) above: SLSA Build Level 3 +provenance ties the archive to the workflow run that produced it, Sigstore cosign +verifies the signature, and `checksums.txt` pins the bytes. The release notes +carry a durable per-object VirusTotal link for every scanned file, including any +tolerated detection — we publish those results whether or not they are clean. + +You can also rebuild from source and compare: `scripts/build.sh --with-ui` +produces the shipped composition. + +### Our release policy + +A release may ship with **at most one** detection, and only when the engine is +Microsoft and the label ends in `!ml`. Two or more engines, any signature-based +label, any other vendor, or any "suspicious" verdict blocks the release. That +rule is enforced in `scripts/ci/check-virustotal.sh` and pinned by +`tests/test_vt_gate_policy_contract.sh`. + +### If you find something real + +We would genuinely rather be wrong in public than confidently wrong. If you find +anything that explains or contradicts the assessment above: + +- Open an issue with the **`av-analysis`** label — include the artifact SHA-256, + the engine and label, and what you found. +- If it looks like an actual compromise rather than a classifier artifact, use + the private process in [Reporting a Vulnerability](#reporting-a-vulnerability) + instead. + +A concrete finding changes our position. Signing is on the roadmap and will help +on Windows, but note that no code-signing scheme exists that AV engines honour +for Linux ELF binaries, so it is not a complete answer either. + ## Supported Versions | Version | Supported | diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index f33d0ce834..bbb3559fa2 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -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 35caa19c27..0000000000 --- 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 a434e78fbc..aaba0234a5 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 b9e9cea3f0..3b99e73579 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 a28f6a7d90..957c682b18 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 23f0fa7020..2b06f7f585 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.go b/pkg/go/cmd/codebase-memory-mcp/main.go index 2e7432c87b..93b626a833 100644 --- a/pkg/go/cmd/codebase-memory-mcp/main.go +++ b/pkg/go/cmd/codebase-memory-mcp/main.go @@ -35,10 +35,9 @@ import ( ) const ( - repo = "DeusData/codebase-memory-mcp" - version = "0.8.1" - windowsBinaryName = "codebase-memory-mcp.exe" - integrationsFileName = "cbm-integrations.json" + repo = "DeusData/codebase-memory-mcp" + version = "0.8.1" + windowsBinaryName = "codebase-memory-mcp.exe" maxRedirects = 5 requestTimeout = 2 * time.Minute @@ -154,7 +153,7 @@ func main() { func ensureBinary() (string, error) { binary := binPath() ready, err := runtimeSetReadyLocked( - filepath.Dir(binary), filepath.Base(binary), runtimeVariant(), verifyCandidate, + filepath.Dir(binary), filepath.Base(binary), verifyCandidate, ) if err != nil { return "", err @@ -170,7 +169,7 @@ func ensureBinary() (string, error) { func binPath() string { return filepath.Join( - cacheDir(), version, runtimeVariant(), binaryNameForOS(runtime.GOOS), + cacheDir(), version, binaryNameForOS(runtime.GOOS), ) } @@ -185,13 +184,6 @@ func executionPathForOS(binary, targetOS string) string { return binary } -func runtimeVariant() string { - if strings.EqualFold(os.Getenv("CBM_VARIANT"), "ui") { - return "ui" - } - return "standard" -} - func printPortableMutationGuidance(args []string) { action := runtimeMutationAction(args) if action != "uninstall" { @@ -361,7 +353,6 @@ func archiveNamesForOS(platform, binaryName string) []string { } return []string{ binaryName, - integrationsFileName, "LICENSE", installer, "THIRD_PARTY_NOTICES.md", @@ -371,7 +362,6 @@ func archiveNamesForOS(platform, binaryName string) []string { func download(dest string) error { platform := goos() arch := goarch() - selectedVariant := runtimeVariant() ext := "tar.gz" if platform == "windows" { ext = "zip" @@ -381,13 +371,9 @@ func download(dest string) error { if platform == "linux" { portable = "-portable" } - uiPrefix := "" - if selectedVariant == "ui" { - uiPrefix = "ui-" - } archive := fmt.Sprintf( - "codebase-memory-mcp-%s%s-%s%s.%s", - uiPrefix, platform, arch, portable, ext, + "codebase-memory-mcp-%s-%s%s.%s", + platform, arch, portable, ext, ) url := fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", repo, version, archive) checksumURL := fmt.Sprintf("https://github.com/%s/releases/download/v%s/checksums.txt", repo, version) @@ -421,19 +407,19 @@ func download(dest string) error { binName := binaryNameForOS(platform) archiveNames := archiveNamesForOS(platform, binName) - extractNames := []string{binName, integrationsFileName} + extractNames := []string{binName} var runtimeNames []string if ext == "tar.gz" { runtimeNames, err = extractTarGz( - archivePath, tmp, archiveNames, extractNames, selectedVariant, + archivePath, tmp, archiveNames, extractNames, ) if err != nil { return fmt.Errorf("extraction failed: %w", err) } } else { runtimeNames, err = extractZip( - archivePath, tmp, archiveNames, extractNames, selectedVariant, + archivePath, tmp, archiveNames, extractNames, ) if err != nil { return fmt.Errorf("extraction failed: %w", err) @@ -458,7 +444,7 @@ func download(dest string) error { } if err := publishRuntimeSetWithRecovery( - tmp, filepath.Dir(dest), binName, selectedVariant, verifyCandidate, + tmp, filepath.Dir(dest), binName, verifyCandidate, ); err != nil { return fmt.Errorf("could not install runtime set: %w", err) } @@ -611,81 +597,41 @@ func verifyChecksum(path, expected string) error { return nil } -func isUIPackName(name string) bool { - const prefix = "cbm-ui-" - const suffix = ".pack" - if len(name) != len(prefix)+sha256.Size*2+len(suffix) || - !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, suffix) { - return false - } - digest := strings.TrimSuffix(strings.TrimPrefix(name, prefix), suffix) - if digest != strings.ToLower(digest) { - return false - } - decoded, err := hex.DecodeString(digest) - return err == nil && len(decoded) == sha256.Size -} - func validateArchiveMemberNames( - names, archiveNames []string, variant string, caseInsensitive bool, -) (string, error) { + names, archiveNames []string, caseInsensitive bool, +) error { required := make(map[string]struct{}, len(archiveNames)) for _, name := range archiveNames { required[name] = struct{}{} } seen := make(map[string]struct{}, len(names)) found := make(map[string]struct{}, len(archiveNames)) - uiPack := "" for _, name := range names { key := name if caseInsensitive { key = strings.ToLower(name) } if _, exists := seen[key]; exists { - return "", fmt.Errorf( + return fmt.Errorf( "duplicate or case-conflicting archive entry: %q", name, ) } seen[key] = struct{}{} - _, isRequired := required[name] - isPack := variant == "ui" && isUIPackName(name) - if !isRequired && !isPack { - return "", fmt.Errorf( + if _, isRequired := required[name]; !isRequired { + return fmt.Errorf( "archive must contain only the exact root files: %s", strings.Join(archiveNames, ", "), ) } - if isPack { - if uiPack != "" { - return "", fmt.Errorf( - "archive must contain exactly one content-addressed UI pack", - ) - } - uiPack = name - } else { - found[name] = struct{}{} - } + found[name] = struct{}{} } - expectedCount := len(archiveNames) - if variant == "ui" { - expectedCount++ - } - if len(seen) != expectedCount || len(found) != len(required) || - (variant == "ui" && uiPack == "") { - return "", fmt.Errorf( + if len(seen) != len(archiveNames) || len(found) != len(required) { + return fmt.Errorf( "archive must contain exactly one of each required root file: %s", strings.Join(archiveNames, ", "), ) } - return uiPack, nil -} - -func runtimeArchiveNames(extractNames []string, uiPack string) []string { - result := append([]string(nil), extractNames...) - if uiPack != "" { - result = append(result, uiPack) - } - return result + return nil } func validateArchiveResourceLimits(limits archiveResourceLimits) error { @@ -837,14 +783,12 @@ func tarGzMemberNamesWithLimits( func extractTarGz( archivePath, destDir string, archiveNames, extractNames []string, - variant string, ) ([]string, error) { return extractTarGzWithLimits( archivePath, destDir, archiveNames, extractNames, - variant, defaultArchiveResourceLimits, ) } @@ -852,20 +796,16 @@ func extractTarGz( func extractTarGzWithLimits( archivePath, destDir string, archiveNames, extractNames []string, - variant string, limits archiveResourceLimits, ) ([]string, error) { names, err := tarGzMemberNamesWithLimits(archivePath, limits) if err != nil { return nil, err } - uiPack, err := validateArchiveMemberNames( - names, archiveNames, variant, false, - ) - if err != nil { + if err := validateArchiveMemberNames(names, archiveNames, false); err != nil { return nil, err } - runtimeNames := runtimeArchiveNames(extractNames, uiPack) + runtimeNames := append([]string(nil), extractNames...) targets := make(map[string]struct{}, len(runtimeNames)) for _, name := range runtimeNames { targets[name] = struct{}{} @@ -948,14 +888,12 @@ func extractTarGzWithLimits( func extractZip( archivePath, destDir string, archiveNames, extractNames []string, - variant string, ) ([]string, error) { return extractZipWithLimits( archivePath, destDir, archiveNames, extractNames, - variant, defaultArchiveResourceLimits, ) } @@ -963,7 +901,6 @@ func extractZip( func extractZipWithLimits( archivePath, destDir string, archiveNames, extractNames []string, - variant string, limits archiveResourceLimits, ) ([]string, error) { if err := validateArchiveResourceLimits(limits); err != nil { @@ -1010,13 +947,10 @@ func extractZipWithLimits( names = append(names, name) declaredSizes[name] = declaredSize } - uiPack, err := validateArchiveMemberNames( - names, archiveNames, variant, true, - ) - if err != nil { + if err := validateArchiveMemberNames(names, archiveNames, true); err != nil { return nil, err } - runtimeNames := runtimeArchiveNames(extractNames, uiPack) + runtimeNames := append([]string(nil), extractNames...) targetNames := make(map[string]struct{}, len(runtimeNames)) for _, name := range runtimeNames { targetNames[name] = struct{}{} @@ -1103,7 +1037,7 @@ func validateWindowsZipMember(raw string) (string, error) { func verifyCandidate(path string) error { ctx, cancel := context.WithTimeout(context.Background(), candidateTimeout) defer cancel() - cmd := exec.CommandContext(ctx, path, "--verify-runtime-assets") + cmd := exec.CommandContext(ctx, path, "--version") cmd.Stdin = nil cmd.Stdout = io.Discard cmd.Stderr = io.Discard @@ -1163,58 +1097,17 @@ func regularRuntimeFile(path string) bool { platformRuntimeSetFileLinkCountOne(path, status) } -func uiPackMatchesDigest(directory, name string) bool { - digest, err := fileSHA256(filepath.Join(directory, name)) - if err != nil { - return false - } - expected := strings.TrimSuffix(strings.TrimPrefix(name, "cbm-ui-"), ".pack") - return hex.EncodeToString(digest[:]) == expected -} - -func runtimeSetNames( - directory, binaryName, variant string, -) ([]string, bool) { - if !regularRuntimeFile(filepath.Join(directory, binaryName)) || - !regularRuntimeFile(filepath.Join(directory, integrationsFileName)) { +func runtimeSetNames(directory, binaryName string) ([]string, bool) { + if !regularRuntimeFile(filepath.Join(directory, binaryName)) { return nil, false } - entries, err := os.ReadDir(directory) - if err != nil { - return nil, false - } - var packLike []string - var validPacks []string - for _, entry := range entries { - name := entry.Name() - if strings.HasPrefix(name, "cbm-ui-") && strings.HasSuffix(name, ".pack") { - packLike = append(packLike, name) - if isUIPackName(name) && - regularRuntimeFile(filepath.Join(directory, name)) && - uiPackMatchesDigest(directory, name) { - validPacks = append(validPacks, name) - } - } - } - if variant == "ui" { - if len(packLike) != 1 || len(validPacks) != 1 { - return nil, false - } - } else if len(packLike) != 0 { - return nil, false - } - names := []string{integrationsFileName} - if variant == "ui" { - names = append(names, validPacks[0]) - } - names = append(names, binaryName) - return names, true + return []string{binaryName}, true } func runtimeSetReady( - directory, binaryName, variant string, verifier func(string) error, + directory, binaryName string, verifier func(string) error, ) bool { - if _, ok := runtimeSetNames(directory, binaryName, variant); !ok { + if _, ok := runtimeSetNames(directory, binaryName); !ok { return false } return verifier == nil || verifier(filepath.Join(directory, binaryName)) == nil @@ -1232,7 +1125,7 @@ func requireSafeRuntimeDirectory(directory string) error { } func runtimeSetReadyLocked( - directory, binaryName, variant string, verifier func(string) error, + directory, binaryName string, verifier func(string) error, ) (ready bool, result error) { if err := os.MkdirAll(directory, 0755); err != nil { return false, err @@ -1251,11 +1144,11 @@ func runtimeSetReadyLocked( return false, err } if err := reconcileRuntimeBackups( - directory, binaryName, variant, verifier, lock, + directory, binaryName, verifier, lock, ); err != nil { return false, err } - return runtimeSetReady(directory, binaryName, variant, verifier), nil + return runtimeSetReady(directory, binaryName, verifier), nil } func copyRuntimeStage( @@ -1338,8 +1231,7 @@ func runtimeBackupDirectoryName(name string) bool { } func runtimeBackupTargetName(name, binaryName string) bool { - return name == binaryName || name == integrationsFileName || - (strings.HasPrefix(name, "cbm-ui-") && strings.HasSuffix(name, ".pack")) + return name == binaryName } func createRuntimeBackupDirectory(directory string) (string, error) { @@ -1567,7 +1459,7 @@ func copyRuntimeBackupFile( } func reconcileRuntimeBackups( - directory, binaryName, variant string, + directory, binaryName string, verifier func(string) error, lock *runtimeSetLock, ) error { @@ -1592,7 +1484,7 @@ func reconcileRuntimeBackups( if len(backups) == 0 { return nil } - if runtimeSetReady(directory, binaryName, variant, verifier) { + if runtimeSetReady(directory, binaryName, verifier) { for _, backup := range backups { if err := cleanupRuntimeBackup(backup, lock); err != nil { return err @@ -2051,11 +1943,11 @@ func attachRuntimeLockReleaseError(result *error, releaseErr error) { } func publishRuntimeSetWithRecoveryAndRenamer( - sourceDirectory, destinationDirectory, binaryName, variant string, + sourceDirectory, destinationDirectory, binaryName string, verifier func(string) error, renameFile func(string, string) error, ) (result error) { - sourceNames, ok := runtimeSetNames(sourceDirectory, binaryName, variant) + sourceNames, ok := runtimeSetNames(sourceDirectory, binaryName) if !ok { return fmt.Errorf("source release does not contain a complete runtime set") } @@ -2081,13 +1973,13 @@ func publishRuntimeSetWithRecoveryAndRenamer( return err } if err := reconcileRuntimeBackups( - destinationDirectory, binaryName, variant, verifier, lock, + destinationDirectory, binaryName, verifier, lock, ); err != nil { return err } // A contender may have committed while this process waited. Its complete // runtime set wins and is never retired or mixed with this contender. - if runtimeSetReady(destinationDirectory, binaryName, variant, verifier) { + if runtimeSetReady(destinationDirectory, binaryName, verifier) { return nil } @@ -2121,17 +2013,7 @@ func publishRuntimeSetWithRecoveryAndRenamer( return err } - entries, err := os.ReadDir(destinationDirectory) - if err != nil { - return err - } - retireNames := []string{binaryName, integrationsFileName} - for _, entry := range entries { - name := entry.Name() - if strings.HasPrefix(name, "cbm-ui-") && strings.HasSuffix(name, ".pack") { - retireNames = append(retireNames, name) - } - } + retireNames := []string{binaryName} retired := make(map[string]struct{}, len(retireNames)) // Retire the executable first so a partial publication is never ready. for _, name := range retireNames { @@ -2190,7 +2072,7 @@ func publishRuntimeSetWithRecoveryAndRenamer( } delete(staged, name) } - if !runtimeSetReady(destinationDirectory, binaryName, variant, verifier) { + if !runtimeSetReady(destinationDirectory, binaryName, verifier) { return fmt.Errorf("published package-cache runtime set failed verification") } if err := assertRuntimeSetLockOwner(lock); err != nil { @@ -2217,11 +2099,11 @@ func publishRuntimeSetWithRecoveryAndRenamer( return nil } preserveWinner := runtimeSetReady( - destinationDirectory, binaryName, variant, verifier, + destinationDirectory, binaryName, verifier, ) if backupDirectory != "" { if recoveryErr := reconcileRuntimeBackups( - destinationDirectory, binaryName, variant, verifier, lock, + destinationDirectory, binaryName, verifier, lock, ); recoveryErr != nil { return fmt.Errorf( "%v; package-cache recovery failed: %w", operationErr, recoveryErr, @@ -2235,14 +2117,13 @@ func publishRuntimeSetWithRecoveryAndRenamer( } func publishRuntimeSetWithRecovery( - sourceDirectory, destinationDirectory, binaryName, variant string, + sourceDirectory, destinationDirectory, binaryName string, verifier func(string) error, ) error { return publishRuntimeSetWithRecoveryAndRenamer( sourceDirectory, destinationDirectory, binaryName, - variant, verifier, os.Rename, ) @@ -2260,13 +2141,13 @@ func execBinary(executable string, args []string) error { } func createMutationRuntimeSnapshot( - executable, variant string, + executable string, verifier func(string) error, maintainLease func() error, ) (snapshotDirectory, snapshotExecutable string, result error) { sourceDirectory := filepath.Dir(executable) binaryName := filepath.Base(executable) - names, ok := runtimeSetNames(sourceDirectory, binaryName, variant) + names, ok := runtimeSetNames(sourceDirectory, binaryName) if !ok { return "", "", fmt.Errorf( "cached runtime assets changed before mutation snapshot", @@ -2323,7 +2204,7 @@ func createMutationRuntimeSnapshot( snapshotExecutable = filepath.Join(snapshotDirectory, binaryName) if !runtimeSetReady( - snapshotDirectory, binaryName, variant, verifier, + snapshotDirectory, binaryName, verifier, ) { return "", "", fmt.Errorf( "private mutation runtime snapshot failed verification", @@ -2344,7 +2225,6 @@ func runMutationBinary(executable string, args []string) error { func execBinaryWithRuntimeLockAndRunner( executable string, args []string, - variant string, verifier func(string) error, runner func(string, []string) error, ) (result error) { @@ -2376,13 +2256,12 @@ func execBinaryWithRuntimeLockAndRunner( return err } if !runtimeSetReady( - directory, filepath.Base(executable), variant, verifier, + directory, filepath.Base(executable), verifier, ) { return fmt.Errorf("cached runtime assets changed before mutation launch") } snapshotDirectory, snapshotExecutable, err := createMutationRuntimeSnapshot( executable, - variant, verifier, func() error { return refreshRuntimeSetLock(lock) }, ) @@ -2414,7 +2293,6 @@ func execBinaryWithRuntimeLock(executable string, args []string) error { return execBinaryWithRuntimeLockAndRunner( executable, args, - runtimeVariant(), verifyCandidate, runMutationBinary, ) diff --git a/pkg/go/cmd/codebase-memory-mcp/main_test.go b/pkg/go/cmd/codebase-memory-mcp/main_test.go index 514a7f8a37..9a30c6525e 100644 --- a/pkg/go/cmd/codebase-memory-mcp/main_test.go +++ b/pkg/go/cmd/codebase-memory-mcp/main_test.go @@ -5,8 +5,6 @@ import ( "archive/zip" "bytes" "compress/gzip" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -17,7 +15,6 @@ import ( "path/filepath" "reflect" "runtime" - "strconv" "strings" "sync" "testing" @@ -45,18 +42,26 @@ func (body *archiveCountingBody) Read(buffer []byte) (int, error) { 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 filepath.Base(filepath.Dir(standard)) != "standard" || - filepath.Base(filepath.Dir(ui)) != "ui" { - t.Fatalf("variant cache paths = %q and %q", standard, ui) +func assertRuntimeTag(t *testing.T, directory, binaryName, tag string) { + t.Helper() + contents, err := os.ReadFile(filepath.Join(directory, binaryName)) + if err != nil { + t.Fatal(err) + } + if string(contents) != "binary:"+tag { + t.Fatalf("%s = %q, want %q", binaryName, contents, "binary:"+tag) + } +} + +func writeTestRuntimeSet(t *testing.T, directory, binaryName, 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 { + t.Fatal(err) } } @@ -118,34 +123,6 @@ func TestWrapperUninstallNeverDefaultsToItsCacheBinary(t *testing.T) { } } -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) @@ -180,80 +157,6 @@ func writeTarGz(t *testing.T, archivePath string, names []string) { } } -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 @@ -439,37 +342,6 @@ func writeZip(t *testing.T, archivePath string, names []string) { } } -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 { @@ -510,7 +382,6 @@ func TestZipArchiveRejectsMemberAndExpandedResourceOverflow(t *testing.T) { destination, testCase.names, testCase.names[:1], - "standard", testCase.limits, ) if err == nil || !strings.Contains(err.Error(), testCase.wantError) { @@ -520,40 +391,6 @@ func TestZipArchiveRejectsMemberAndExpandedResourceOverflow(t *testing.T) { } } -func writeTestRuntimeSet( - t *testing.T, - directory, binaryName, tag, variant string, -) 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 { - t.Fatal(err) - } - if err := os.WriteFile( - filepath.Join(directory, integrationsFileName), - []byte("integrations:"+tag), - 0644, - ); 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) if err != nil { @@ -565,216 +402,322 @@ func verifyTestBinary(path string) error { return nil } -func TestRuntimeReadinessRequiresTheCompleteSelectedVariant(t *testing.T) { +func TestMutationSnapshotReleasesCacheLockAndCleansAfterLaunchFailure(t *testing.T) { + directory := t.TempDir() 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) + executable := filepath.Join(directory, binary) + writeTestRuntimeSet(t, directory, binary, "cached") + var snapshotExecutable string + runnerCalled := false + err := execBinaryWithRuntimeLockAndRunner( + executable, + []string{"install", "--yes"}, + 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", + ) + 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 runtimeSetReady(ui, binary, "ui", verifyTestBinary) { - t.Fatal("UI runtime set accepts pack bytes that do not match the filename digest") + if !runnerCalled { + t.Fatal("verified mutation snapshot was not launched") } - 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 _, err := os.Stat(filepath.Dir(snapshotExecutable)); !os.IsNotExist(err) { + t.Fatal("failed mutation left its private runtime snapshot") } - if runtimeSetReady(ui, binary, "ui", verifyTestBinary) { - t.Fatal("UI runtime set with two packs is ready") + if _, err := os.Stat(filepath.Join( + directory, runtimeSetLockName, + )); !os.IsNotExist(err) { + t.Fatal("failed mutation left the package-cache lock") } } -func assertRuntimeTag( - t *testing.T, - directory, binaryName, tag, variant string, -) { - t.Helper() - checks := map[string]string{ - binaryName: "binary:" + tag, - integrationsFileName: "integrations:" + tag, - } - if variant == "ui" { - packContents := []byte("pack:" + tag) - digest := sha256.Sum256(packContents) - checks["cbm-ui-"+hex.EncodeToString(digest[:])+".pack"] = "pack:" + tag +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) + } } - for name, want := range checks { - contents, err := os.ReadFile(filepath.Join(directory, name)) - if err != nil { - t.Fatal(err) +} + +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, + 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 string(contents) != want { - t.Fatalf("%s = %q, want %q", name, contents, want) + if runnerCalled { + t.Fatal("overlapping cache mutation target was launched") } } } -func TestRuntimePublicationRepairsPartialCache(t *testing.T) { - root := t.TempDir() - source := filepath.Join(root, "source") - destination := filepath.Join(root, "destination") +func TestMutationSnapshotVerifierFailureNeverLaunches(t *testing.T) { + directory := t.TempDir() binary := "codebase-memory-mcp" - writeTestRuntimeSet(t, source, binary, "candidate", "ui") - if err := os.Mkdir(destination, 0755); err != nil { - t.Fatal(err) + executable := filepath.Join(directory, binary) + writeTestRuntimeSet(t, directory, binary, "cached") + verifierCalls := 0 + verifier := func(path string) error { + verifierCalls++ + if filepath.Dir(path) != directory { + return fmt.Errorf("injected private snapshot verification failure") + } + return verifyTestBinary(path) } - if err := os.WriteFile( - filepath.Join(destination, binary), []byte("corrupt"), 0755, - ); err != nil { - t.Fatal(err) + runnerCalled := false + err := execBinaryWithRuntimeLockAndRunner( + executable, + []string{"install", "--yes"}, + 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) } - 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 verifierCalls != 2 { + t.Fatalf("snapshot verifier calls = %d, want source and snapshot", verifierCalls) } - - if err := publishRuntimeSetWithRecovery( - source, destination, binary, "ui", verifyTestBinary, - ); err != nil { - t.Fatal(err) + if runnerCalled { + t.Fatal("mutation runner was invoked after snapshot verification failure") } - 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") + if _, err := os.Stat(filepath.Join( + directory, runtimeSetLockName, + )); !os.IsNotExist(err) { + t.Fatal("snapshot verification failure left the package-cache lock") } } -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) +func TestMutationSnapshotCleanupFailurePreservesNativeResult(t *testing.T) { + const exitHelper = "CBM_GO_MUTATION_EXIT_HELPER" + if os.Getenv(exitHelper) == "1" { + os.Exit(7) + } + 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") } - 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") + 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") + result := execBinaryWithRuntimeLockAndRunner( + filepath.Join(directory, binary), + []string{"install", "--yes"}, + 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 TestRuntimePublicationCommitsSidecarsBeforeBinary(t *testing.T) { +func TestConcurrentRuntimePublishersAreSerialized(t *testing.T) { root := t.TempDir() - source := filepath.Join(root, "source") + firstSource := filepath.Join(root, "source-first") + secondSource := filepath.Join(root, "source-second") destination := filepath.Join(root, "destination") binary := "codebase-memory-mcp" - pack := writeTestRuntimeSet(t, source, binary, "candidate", "ui") - committed := []string{} - renameFile := func(sourcePath, destinationPath string) error { + writeTestRuntimeSet(t, firstSource, binary, "first") + writeTestRuntimeSet(t, secondSource, binary, "second") + + 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 + } + // One file publishes now, so the mid-publication pause hooks the binary + // rename itself: the runtime lock is still held until publish returns. + if destinationPath == filepath.Join(destination, binary) { + 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 == pack) { - committed = append(committed, name) + if filepath.Dir(destinationPath) == destination && name == binary { + enteredOnce.Do(func() { close(secondEnteredPublication) }) } return os.Rename(sourcePath, destinationPath) } - if err := publishRuntimeSetWithRecoveryAndRenamer( - source, - destination, - binary, - "ui", - verifyTestBinary, - renameFile, - ); err != nil { - t.Fatal(err) + + go func() { + firstResult <- publishRuntimeSetWithRecoveryAndRenamer( + firstSource, + destination, + binary, + verifyTestBinary, + firstRenamer, + ) + }() + select { + case <-firstPaused: + case <-time.After(5 * time.Second): + t.Fatal("first publisher did not reach the held publication gate") } - want := []string{integrationsFileName, pack, binary} - if !reflect.DeepEqual(committed, want) { - t.Fatalf("runtime commit order = %q, want %q", committed, want) + + go func() { + secondResult <- publishRuntimeSetWithRecoveryAndRenamer( + secondSource, + destination, + binary, + 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) -func TestRuntimePublicationCrashHelper(t *testing.T) { - if os.Getenv("CBM_TEST_RUNTIME_CRASH_HELPER") != "1" { - return + 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") } - 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) + if !concurrentPublication { + select { + case secondErr = <-secondResult: + case <-time.After(5 * time.Second): + t.Fatal("serialized second publisher did not finish") } } - priorCrashObserver := runtimeBackupCrashObserver - defer func() { runtimeBackupCrashObserver = priorCrashObserver }() - runtimeBackupCrashObserver = func(event, _ string) error { - if event == crashPhase { - return reachCrashGate() - } - return nil + if concurrentPublication { + t.Fatal("second publisher entered publication while the first sequence was held") } - 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 firstErr != nil { + t.Fatalf("first publisher failed: %v", firstErr) } - if err := publishRuntimeSetWithRecoveryAndRenamer( - source, - destination, - binary, - "standard", - verifyTestBinary, - renameFile, - ); err != nil { - t.Fatal(err) + if secondErr != nil { + t.Fatalf("serialized second publisher failed: %v", secondErr) } + assertRuntimeTag(t, destination, binary, "first") } func TestKilledRuntimePublisherIsReconciledByLockedReadiness(t *testing.T) { @@ -796,27 +739,20 @@ func TestKilledRuntimePublisherIsReconciledByLockedReadiness(t *testing.T) { name: "all leaves retired before retirement marker", crashPhase: runtimeBackupBeforeMarkerEvent, expectedReady: false, - expectedBackupMembers: 2, - }, - { - name: "partial sidecar publish", - crashPhase: "published-integrations", - expectedReady: false, - expectedBackupMembers: 2, - expectRetiredMarker: true, + expectedBackupMembers: 1, }, { name: "complete publish before cleanup", crashPhase: "published-binary", expectedReady: true, - expectedBackupMembers: 2, + expectedBackupMembers: 1, expectRetiredMarker: true, }, { name: "cleanup interrupted after one retired member", crashPhase: runtimeBackupCleanupRemovedEvent, expectedReady: true, - expectedBackupMembers: 1, + expectedBackupMembers: 0, expectRetiredMarker: true, expectCleanupMarker: true, }, @@ -827,8 +763,8 @@ func TestKilledRuntimePublisherIsReconciledByLockedReadiness(t *testing.T) { 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") + writeTestRuntimeSet(t, source, binary, "candidate") + writeTestRuntimeSet(t, destination, binary, "old") if err := os.WriteFile( filepath.Join(destination, binary), []byte("corrupt:old"), 0755, ); err != nil { @@ -911,873 +847,144 @@ func TestKilledRuntimePublisherIsReconciledByLockedReadiness(t *testing.T) { "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") - } - }) - } -} - -func TestConcurrentRuntimePublishersAreSerialized(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) + } + 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) } - assertRuntimeTag( - t, snapshotDirectory, binary, "ui-cached", "ui", - ) - return nil - }, - ) - if err != nil { - t.Fatal(err) - } - if _, err := os.Stat(snapshotDirectory); !os.IsNotExist(err) { - t.Fatal("successful UI mutation left its private runtime snapshot") - } -} -func TestMutationSnapshotCleanupFailurePreservesNativeResult(t *testing.T) { - const exitHelper = "CBM_GO_MUTATION_EXIT_HELPER" - if os.Getenv(exitHelper) == "1" { - os.Exit(7) - } - 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) - } + if err := command.Process.Kill(); err != nil { + t.Fatal(err) + } + if err := <-waitResult; err == nil { + t.Fatal("publication helper was not killed") + } + finished = true - 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 }, + ready, err := runtimeSetReadyLocked( + destination, binary, verifyTestBinary, ) - if result != testCase.wantResult { - t.Fatalf("native result identity changed: got %v, want %v", result, testCase.wantResult) + if err != nil { + t.Fatal(err) } - 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") + 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) + } + if err := publishRuntimeSetWithRecovery( + source, destination, binary, verifyTestBinary, + ); err != nil { + t.Fatal(err) } } - }) - } -} - -func TestMutationSnapshotParentDeathLeavesCacheRecoverable(t *testing.T) { - const roleEnvironment = "CBM_GO_MUTATION_SNAPSHOT_ROLE" - role := os.Getenv(roleEnvironment) - if role == "child" { - executable, err := os.Executable() - 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) { + assertRuntimeTag( + t, destination, binary, "candidate", + ) + entries, err = os.ReadDir(destination) + if err != nil { 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) + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), runtimeBackupPrefix) { + t.Fatalf("orphan backup survived recovery: %s", entry.Name()) } } - 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") + if _, err := os.Stat(filepath.Join( + destination, runtimeSetLockName, + )); !os.IsNotExist(err) { + t.Fatal("runtime-set lock survived killed-process recovery") + } + }) } +} - root := t.TempDir() - directory := filepath.Join(root, "cache") - if err := os.Mkdir(directory, 0755); err != nil { - t.Fatal(err) - } - binary := binaryNameForOS(runtime.GOOS) - stagedExecutable, err := copyRuntimeStage( - os.Args[0], directory, true, nil, - ) - 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) +func TestRuntimePublicationCrashHelper(t *testing.T) { + if os.Getenv("CBM_TEST_RUNTIME_CRASH_HELPER") != "1" { + return } - 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() - } + 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 } - if snapshotExecutable != "" { - _ = os.RemoveAll(filepath.Dir(snapshotExecutable)) + for { + time.Sleep(time.Hour) } - }() - - 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 + } + priorCrashObserver := runtimeBackupCrashObserver + defer func() { runtimeBackupCrashObserver = priorCrashObserver }() + runtimeBackupCrashObserver = func(event, _ string) error { + if event == crashPhase { + return reachCrashGate() } - if !os.IsNotExist(err) { - t.Fatal(err) + return nil + } + renameFile := func(sourcePath, destinationPath string) error { + if err := os.Rename(sourcePath, destinationPath); err != nil { + return err } - select { - case err := <-waitResult: - parentFinished = true - t.Fatalf("mutation parent exited before child start: %v: %s", err, stderr.String()) - default: + backupParent := filepath.Base(filepath.Dir(destinationPath)) + if crashPhase == "retired-executable" && + runtimeBackupDirectoryName(backupParent) && + filepath.Base(destinationPath) == binary { + return reachCrashGate() } - if !time.Now().Before(deadline) { - t.Fatalf("mutation child did not start: %s", stderr.String()) + if crashPhase == "published-binary" && + destinationPath == filepath.Join(destination, binary) { + return reachCrashGate() } - 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") + return nil } - if err := command.Process.Kill(); err != nil { + if err := publishRuntimeSetWithRecoveryAndRenamer( + source, + destination, + binary, + verifyTestBinary, + renameFile, + ); 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, +func TestRuntimeReadinessRejectsMultiplyLinkedLeaves(t *testing.T) { + directory := t.TempDir() + binary := "codebase-memory-mcp" + writeTestRuntimeSet(t, directory, binary, "linked") + if err := os.Link( + filepath.Join(directory, binary), + filepath.Join(directory, "binary-hardlink"), ); 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) + t.Skipf("filesystem does not support hard links: %v", err) } - if _, err := os.Stat(filepath.Join( - directory, runtimeSetLockName, - )); !os.IsNotExist(err) { - t.Fatal("successor recovery left the package-cache lock") + if runtimeSetReady(directory, binary, verifyTestBinary) { + t.Fatal("runtime set accepted a multiply-linked binary leaf") } } @@ -1991,21 +1198,6 @@ func TestRuntimeSetLockReleaseRetiresDescriptor(t *testing.T) { } } -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") @@ -2022,14 +1214,14 @@ func TestWindowsLongRuntimePathPublishesAndBecomesReady(t *testing.T) { t.Fatalf("test runtime path is not long: %d", len(destination)) } binary := windowsBinaryName - writeTestRuntimeSet(t, source, binary, "long-path", "standard") + writeTestRuntimeSet(t, source, binary, "long-path") if err := publishRuntimeSetWithRecovery( - source, destination, binary, "standard", verifyTestBinary, + source, destination, binary, verifyTestBinary, ); err != nil { t.Fatalf("publication under a long Windows runtime path failed: %v", err) } ready, err := runtimeSetReadyLocked( - destination, binary, "standard", verifyTestBinary, + destination, binary, verifyTestBinary, ) if err != nil { t.Fatalf("locked readiness under a long Windows runtime path failed: %v", err) @@ -2064,7 +1256,7 @@ func TestOrphanReconciliationRejectsMultiplyLinkedBackupMembers(t *testing.T) { } ready, err := runtimeSetReadyLocked( - directory, binary, "standard", verifyTestBinary, + directory, binary, verifyTestBinary, ) if err == nil || !strings.Contains( err.Error(), "unsafe package-cache backup member", diff --git a/pkg/go/codebase-memory-mcp b/pkg/go/codebase-memory-mcp new file mode 100644 index 0000000000..f8ea349b21 Binary files /dev/null and b/pkg/go/codebase-memory-mcp differ diff --git a/pkg/homebrew/Formula/codebase-memory-mcp.rb b/pkg/homebrew/Formula/codebase-memory-mcp.rb index 715bd00d80..98736781c1 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 @@ def caveats 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/bin.js b/pkg/npm/bin.js index e467981be4..8b7d82e095 100644 --- a/pkg/npm/bin.js +++ b/pkg/npm/bin.js @@ -4,34 +4,30 @@ // If the binary is missing (e.g. --ignore-scripts), attempts a one-time download. const path = require('path'); -const os = require('os'); -const { spawn, spawnSync } = require('child_process'); -const { - acquireRuntimeLock, - refreshRuntimeLock, - releaseRuntimeLock, - runtimeSetReady, - runtimeSetReadyLocked, -} = require('./install.js'); +const fs = require('fs'); +const { spawnSync } = require('child_process'); const isWindows = process.platform === 'win32'; -const variant = (process.env.CBM_VARIANT || '').toLowerCase() === 'ui' - ? 'ui' - : 'standard'; -// npm is a portable one-shot wrapper. Every platform ships exactly one binary, -// which is executed directly beside its authenticated runtime sidecars. +// npm is a portable one-shot wrapper. Every platform ships exactly ONE binary, +// so the cached file is the executed file — there is no launcher pair to keep +// in sync and no adjacent payload to validate. const binName = isWindows ? 'codebase-memory-mcp.exe' : 'codebase-memory-mcp'; -const binDir = path.join(__dirname, 'bin', variant); -const binPath = path.join(binDir, binName); +const binPath = path.join(__dirname, 'bin', binName); const executionPath = binPath; -const cacheReady = () => runtimeSetReadyLocked(binDir, binName, variant); +const cacheReady = () => { + if (!fs.existsSync(binPath)) { + return false; + } + if (!isWindows) return true; + // A cached Windows binary that cannot report its own version is not usable. + const probe = spawnSync(binPath, ['--version'], { + stdio: 'ignore', + timeout: 15_000, + windowsHide: true, + }); + return !probe.error && probe.status === 0; +}; -function main() { -const requestedMutation = runtimeMutationAction(process.argv.slice(2)); -if (requestedMutation === 'update') { - printUpdateGuidance(); - process.exit(2); -} if (!cacheReady()) { // Binary missing — try running the install script (handles --ignore-scripts case) process.stderr.write('codebase-memory-mcp: binary not found, downloading...\n'); @@ -47,105 +43,17 @@ if (!cacheReady()) { } } -function runtimeMutationAction(args) { +function portableMutationAction(args) { for (const argument of args) { - if (argument === '--help' || argument === '-h' || argument === '--version') { + if (['cli', 'hook-augment', 'config', 'install', '--help', '-h', '--version'].includes(argument)) { return null; } - if (argument.startsWith('-')) continue; - if (argument === 'install' || argument === 'update' || argument === 'uninstall') { - return argument; - } - if (argument === 'cli' || argument === 'hook-augment' || argument === 'config') { - return null; - } - return null; + if (argument === 'update' || argument === 'uninstall') return argument; } return null; } -function defaultManagedInstallDir() { - if (!isWindows) return path.join(os.homedir(), '.local', 'bin'); - const localAppData = process.env.LOCALAPPDATA || - path.join(os.homedir(), 'AppData', 'Local'); - return path.join(localAppData, 'Programs', 'codebase-memory-mcp'); -} - -function nativeArgs(args) { - const result = [...args]; - if (runtimeMutationAction(result) === 'uninstall' && - !result.some((argument) => argument === '--dir' || argument.startsWith('--dir='))) { - result.push('--dir', defaultManagedInstallDir()); - } - return result; -} - -function printUpdateGuidance() { - process.stderr.write( - 'This npm copy is maintained by npm. Update it with ' + - '"npm install codebase-memory-mcp@latest" (add -g for a global install). ' + - 'For a managed standalone installation, run ' + - '"codebase-memory-mcp install --yes".\n', - ); -} - -const forwardedArgs = nativeArgs(process.argv.slice(2)); -const mutation = requestedMutation; - -if (mutation === 'install' || mutation === 'uninstall') { - const lock = acquireRuntimeLock(binDir); - let heartbeatError = null; - try { - refreshRuntimeLock(lock); - if (!runtimeSetReady(binDir, binName, variant)) { - throw new Error('cached runtime assets changed before mutation launch'); - } - } catch (error) { - try { releaseRuntimeLock(lock); } catch (_) { /* preserve first error */ } - process.stderr.write(`codebase-memory-mcp: ${error.message}\n`); - process.exit(1); - } - let child = null; - const heartbeat = setInterval(() => { - try { - refreshRuntimeLock(lock); - } catch (error) { - heartbeatError = error; - if (child) child.kill(); - } - }, 20_000); - child = spawn(executionPath, forwardedArgs, { - stdio: 'inherit', - windowsHide: false, - }); - child.on('error', (error) => { - clearInterval(heartbeat); - try { releaseRuntimeLock(lock); } catch (_) { /* report spawn error */ } - process.stderr.write(`codebase-memory-mcp: ${error.message}\n`); - process.exit(1); - }); - child.on('close', (status) => { - clearInterval(heartbeat); - let releaseError = heartbeatError; - try { releaseRuntimeLock(lock); } catch (error) { releaseError ||= error; } - if (releaseError) { - process.stderr.write(`codebase-memory-mcp: ${releaseError.message}\n`); - process.exit(1); - } - if (isWindows && status !== 0 && mutation === 'uninstall') { - process.stderr.write( - 'This npm Windows copy is portable. Use "npm uninstall codebase-memory-mcp" ' + - 'for package maintenance (add -g for a global install). ' + - 'To create or repair a managed standalone installation, run ' + - '"codebase-memory-mcp install --yes".\n', - ); - } - process.exit(status ?? 0); - }); - return; -} - -const result = spawnSync(executionPath, forwardedArgs, { +const result = spawnSync(executionPath, process.argv.slice(2), { stdio: 'inherit', windowsHide: false, }); @@ -155,7 +63,19 @@ if (result.error) { process.exit(1); } -process.exit(result.status ?? 0); +const mutation = isWindows && result.status !== 0 + ? portableMutationAction(process.argv.slice(2)) + : null; +if (mutation) { + const packageCommand = mutation === 'update' + ? 'npm install codebase-memory-mcp@latest' + : 'npm uninstall codebase-memory-mcp'; + process.stderr.write( + `This npm Windows copy is portable. Use "${packageCommand}" for package ` + + 'maintenance (add -g for a global install), or run ' + + '"codebase-memory-mcp install --yes" once for a managed install with its ' + + 'own install.ps1 for updates.\n', + ); } -main(); +process.exit(result.status ?? 0); diff --git a/pkg/npm/install.js b/pkg/npm/install.js index 892a6d314e..2c3ea9b6c3 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,101 +227,21 @@ function fileSha256(candidatePath) { return digest.digest('hex'); } -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) { +function filesEqualSha256(leftPath, rightPath) { try { - const expected = name.slice('cbm-ui-'.length, -'.pack'.length); - return fileSha256(path.join(directory, name)) === expected; + 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 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 sleepSync(milliseconds) { + Atomics.wait(SLEEP_WORD, 0, 0, milliseconds); } function processIsAlive(pid) { @@ -374,69 +256,33 @@ function processIsAlive(pid) { } } -function lockPathStatus(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) { +function readBinaryLockOwner(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; - } - const released = `${lock.lockPath}.released-${lock.token}`; - 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'); +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'); } + 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.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}`); - } - 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'); + if (digest && pathMatchesDigest(target, digest)) { + try { fs.unlinkSync(target); } catch (_) { /* recovery continues below */ } } - 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 9e5849ff11..560aec700f 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 6a63f58a12..756b238cb3 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/src/codebase_memory_mcp/_cli.py b/pkg/pypi/src/codebase_memory_mcp/_cli.py index cf15387925..2750248a54 100644 --- a/pkg/pypi/src/codebase_memory_mcp/_cli.py +++ b/pkg/pypi/src/codebase_memory_mcp/_cli.py @@ -21,18 +21,14 @@ REPO = "DeusData/codebase-memory-mcp" _WINDOWS_BINARY_NAME = "codebase-memory-mcp.exe" -_INTEGRATIONS_NAME = "cbm-integrations.json" -_UI_PACK_RE = re.compile(r"cbm-ui-[0-9a-f]{64}\.pack\Z") _UNIX_ARCHIVE_NAMES = ( "codebase-memory-mcp", - _INTEGRATIONS_NAME, "LICENSE", "install.sh", "THIRD_PARTY_NOTICES.md", ) _WINDOWS_ARCHIVE_NAMES = ( _WINDOWS_BINARY_NAME, - _INTEGRATIONS_NAME, "LICENSE", "install.ps1", "THIRD_PARTY_NOTICES.md", @@ -142,7 +138,7 @@ def _verify_candidate(path: Path) -> None: """Require a staged native candidate to execute successfully.""" try: subprocess.run( - [str(path), "--verify-runtime-assets"], + [str(path), "--version"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -173,13 +169,12 @@ def _files_equal_sha256(left: Path, right: Path) -> bool: return False -def _validate_archive_names(names, archive_names, ui: bool, casefold: bool = False): - """Require the exact release root namespace and return its UI pack name.""" +def _validate_archive_names(names, archive_names, casefold: bool = False): + """Require the exact release root namespace.""" required = tuple(archive_names) required_set = set(required) found = set() seen = set() - ui_pack = None for name in names: key = name.casefold() if casefold else name if key in seen: @@ -188,28 +183,17 @@ def _validate_archive_names(names, archive_names, ui: bool, casefold: bool = Fal f"(duplicate or case conflict: {name!r})" ) seen.add(key) - is_ui_pack = ui and _UI_PACK_RE.fullmatch(name) is not None - if name not in required_set and not is_ui_pack: + if name not in required_set: sys.exit( f"codebase-memory-mcp: archive must contain only the exact " f"root files: {', '.join(required)}" ) - if is_ui_pack: - if ui_pack is not None: - sys.exit( - "codebase-memory-mcp: archive must contain exactly one " - "content-addressed UI pack" - ) - ui_pack = name - else: - found.add(name) - expected_count = len(required) + (1 if ui else 0) - if found != required_set or len(seen) != expected_count or (ui and ui_pack is None): + found.add(name) + if found != required_set or len(seen) != len(required): sys.exit( f"codebase-memory-mcp: archive must contain exactly one of each " f"required root file: {', '.join(required)}" ) - return ui_pack def _safe_extract_tar( @@ -223,12 +207,8 @@ def _safe_extract_tar( f"codebase-memory-mcp: refusing unsafe tar entry " f"(not a regular root file: {member.name!r})" ) - ui_pack = _validate_archive_names( - [member.name for member in members], archive_names, ui - ) + _validate_archive_names([member.name for member in members], archive_names) runtime_names = list(extract_names) - if ui_pack is not None: - runtime_names.append(ui_pack) by_name = {member.name: member for member in members} dest_abs = os.path.abspath(dest) for name in runtime_names: @@ -281,13 +261,11 @@ def _safe_extract_zip( f"codebase-memory-mcp: refusing unsafe zip entry " f"(escapes dest: {raw_name!r})" ) - ui_pack = _validate_archive_names(names, archive_names, ui, casefold=True) + _validate_archive_names(names, archive_names, casefold=True) # Extract only the validated runtime files. This avoids relying on # platform-specific zip path rewriting and always creates regular files. runtime_names = list(extract_names) - if ui_pack is not None: - runtime_names.append(ui_pack) for name in runtime_names: target = os.path.join(dest_abs, name) with zf.open(name) as source, open(target, "xb") as output: @@ -393,22 +371,18 @@ def _cache_dir() -> Path: return base / "codebase-memory-mcp" -def _variant() -> str: - return "ui" if os.environ.get("CBM_VARIANT", "").lower() == "ui" else "standard" - +def _runtime_dir(version: str) -> Path: + return _cache_dir() / version -def _runtime_dir(version: str, variant: str = None) -> Path: - return _cache_dir() / version / (variant or _variant()) - -def _bin_path(version: str, variant: str = None) -> Path: +def _bin_path(version: str) -> Path: # One binary per platform: the cached file is the executed file. name = ( _WINDOWS_BINARY_NAME if sys.platform == "win32" else "codebase-memory-mcp" ) - return _runtime_dir(version, variant) / name + return _runtime_dir(version) / name def _execution_path(binary: Path, target_platform: str) -> Path: @@ -428,54 +402,15 @@ def _regular_file(path: Path) -> bool: return False -def _ui_pack_path_matches_name(path: Path, name: str) -> bool: - try: - expected = name[len("cbm-ui-") : -len(".pack")] - return _file_sha256(path) == expected - except OSError: - return False - - -def _ui_pack_matches_digest(path: Path) -> bool: - return _ui_pack_path_matches_name(path, path.name) - - -def _runtime_set_names(directory: Path, binary_name: str, variant: str): - if not _regular_file(directory / binary_name) or not _regular_file( - directory / _INTEGRATIONS_NAME - ): +def _runtime_set_names(directory: Path, binary_name: str): + if not _regular_file(directory / binary_name): return None - try: - pack_like = [ - path - for path in directory.iterdir() - if path.name.startswith("cbm-ui-") and path.name.endswith(".pack") - ] - except OSError: - return None - valid_packs = [ - path - for path in pack_like - if _UI_PACK_RE.fullmatch(path.name) is not None - and _regular_file(path) - and _ui_pack_matches_digest(path) - ] - if variant == "ui": - if len(pack_like) != 1 or len(valid_packs) != 1: - return None - elif pack_like: - return None - return ( - _INTEGRATIONS_NAME, - *((valid_packs[0].name,) if variant == "ui" else ()), - binary_name, - ) + return (binary_name,) def _runtime_set_ready(version: str, verifier=None) -> bool: - variant = _variant() - binary = _bin_path(version, variant) - if _runtime_set_names(binary.parent, binary.name, variant) is None: + binary = _bin_path(version) + if _runtime_set_names(binary.parent, binary.name) is None: return False if verifier is not None: try: @@ -499,16 +434,16 @@ def _runtime_set_ready_locked(version: str) -> bool: try: _refresh_runtime_lock(lock) _reconcile_runtime_backups( - binary.parent, binary.name, _variant(), _verify_candidate, lock + binary.parent, binary.name, _verify_candidate, lock ) return _runtime_set_ready(version, _verify_candidate) finally: _release_runtime_lock(lock) -def _runtime_set_fingerprint(directory: Path, binary_name: str, variant: str): +def _runtime_set_fingerprint(directory: Path, binary_name: str): """Identify one complete set so a lock waiter can preserve its winner.""" - names = _runtime_set_names(directory, binary_name, variant) + names = _runtime_set_names(directory, binary_name) if names is None: return None try: @@ -519,33 +454,12 @@ def _runtime_set_fingerprint(directory: Path, binary_name: str, variant: str): return None -def _staged_runtime_names(staged_paths, binary_name: str, variant: str): +def _staged_runtime_names(staged_paths, binary_name: str): names = set(staged_paths) - required = {binary_name, _INTEGRATIONS_NAME} - pack_names = [ - name - for name in names - if name.startswith("cbm-ui-") and name.endswith(".pack") - ] - if variant == "ui": - if len(pack_names) != 1 or _UI_PACK_RE.fullmatch(pack_names[0]) is None: - raise RuntimeError("staged UI runtime set must contain exactly one pack") - pack_name = pack_names[0] - if not _regular_file(staged_paths[pack_name]) or not _ui_pack_path_matches_name( - staged_paths[pack_name], pack_name - ): - raise RuntimeError("staged UI pack digest does not match its filename") - required.add(pack_name) - runtime_names = (_INTEGRATIONS_NAME, pack_name, binary_name) - else: - if pack_names: - raise RuntimeError("standard runtime set must not contain a UI pack") - runtime_names = (_INTEGRATIONS_NAME, binary_name) - if names != required or any( - not _regular_file(staged_paths[name]) for name in required - ): + required = {binary_name} + if names != required or not _regular_file(staged_paths[binary_name]): raise RuntimeError("staged package cache is incomplete") - return runtime_names + return (binary_name,) def _windows_process_api(): @@ -890,11 +804,7 @@ def _release_runtime_lock(lock): # partial retirement from partial publication; cleanup-only makes deletion # retryable after the destination has been accepted or restored. def _runtime_backup_target_name(name: str, binary_name: str) -> bool: - return ( - name == binary_name - or name == _INTEGRATIONS_NAME - or (name.startswith("cbm-ui-") and name.endswith(".pack")) - ) + return name == binary_name def _create_runtime_backup_directory(directory: Path) -> Path: @@ -994,10 +904,8 @@ def _current_runtime_targets(directory: Path, binary_name: str): return targets -def _runtime_set_ready_at( - directory: Path, binary_name: str, variant: str, verifier=None -) -> bool: - if _runtime_set_names(directory, binary_name, variant) is None: +def _runtime_set_ready_at(directory: Path, binary_name: str, verifier=None) -> bool: + if _runtime_set_names(directory, binary_name) is None: return False if verifier is not None: try: @@ -1068,9 +976,7 @@ def _copy_runtime_backup_file( pass -def _reconcile_runtime_backups( - directory: Path, binary_name: str, variant: str, verifier, lock -): +def _reconcile_runtime_backups(directory: Path, binary_name: str, verifier, lock): _refresh_runtime_lock(lock) backups = _list_runtime_backups(directory, binary_name) for backup in [item for item in backups if item["cleanup_only"]]: @@ -1079,7 +985,7 @@ def _reconcile_runtime_backups( if not backups: return - if _runtime_set_ready_at(directory, binary_name, variant, verifier): + if _runtime_set_ready_at(directory, binary_name, verifier): for backup in backups: _cleanup_runtime_backup(backup, lock) return @@ -1151,31 +1057,24 @@ def _publish_runtime_set( staged_paths, dest: Path, binary_name: str, - variant: str, replace_file=None, verifier=None, ): """Publish sidecars first, retaining exact-byte backups for recovery.""" - runtime_names = _staged_runtime_names(staged_paths, binary_name, variant) + runtime_names = _staged_runtime_names(staged_paths, binary_name) if replace_file is None: replace_file = os.replace _require_safe_runtime_directory(dest.parent) - initial_fingerprint = _runtime_set_fingerprint( - dest.parent, binary_name, variant - ) + initial_fingerprint = _runtime_set_fingerprint(dest.parent, binary_name) lock, contended = _acquire_runtime_lock(dest.parent) operation_error = None try: _refresh_runtime_lock(lock) - _reconcile_runtime_backups( - dest.parent, binary_name, variant, verifier, lock - ) + _reconcile_runtime_backups(dest.parent, binary_name, verifier, lock) # Preserve a complete set that appeared or changed while this publisher # waited. An unchanged pre-existing set may still be repaired/replaced, # with its bytes retained below for rollback. - locked_fingerprint = _runtime_set_fingerprint( - dest.parent, binary_name, variant - ) + locked_fingerprint = _runtime_set_fingerprint(dest.parent, binary_name) if ( locked_fingerprint is not None and (contended or locked_fingerprint != initial_fingerprint) @@ -1185,13 +1084,7 @@ def _publish_runtime_set( backup_directory = None try: backup_directory = _create_runtime_backup_directory(dest.parent) - pack_like = sorted( - path.name - for path in dest.parent.iterdir() - if path.name.startswith("cbm-ui-") - and path.name.endswith(".pack") - ) - retire_names = (binary_name, _INTEGRATIONS_NAME, *pack_like) + retire_names = (binary_name,) retired = set() # Retire the executable first: until the final rename, readiness is false. for name in retire_names: @@ -1227,7 +1120,7 @@ def _publish_runtime_set( except OSError as publish_error: if not _files_equal_sha256(staged_paths[name], target): raise publish_error - if _runtime_set_names(dest.parent, binary_name, variant) is None: + if _runtime_set_names(dest.parent, binary_name) is None: raise RuntimeError("published package cache is incomplete") if verifier is not None: verifier(dest.parent / binary_name) @@ -1235,14 +1128,10 @@ def _publish_runtime_set( except Exception as publication_error: _assert_runtime_lock_owner(lock) # Preserve a complete non-cooperating winner; never delete its bytes. - preserve_winner = _runtime_set_ready_at( - dest.parent, binary_name, variant, verifier - ) + preserve_winner = _runtime_set_ready_at(dest.parent, binary_name, verifier) if backup_directory is not None: try: - _reconcile_runtime_backups( - dest.parent, binary_name, variant, verifier, lock - ) + _reconcile_runtime_backups(dest.parent, binary_name, verifier, lock) except Exception as recovery_error: raise RuntimeError( f"{publication_error}; package-cache recovery failed: " @@ -1271,19 +1160,15 @@ def _download(version: str) -> Path: os_name = _os_name() arch = _arch() ext = "zip" if os_name == "windows" else "tar.gz" - selected_variant = _variant() # 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 / install.js / cli.c. portable = "-portable" if os_name == "linux" else "" - # Opt into the UI build, whose verified asset pack is published beside the - # binary, with CBM_VARIANT=ui. Default is the standard (headless) build. - ui = "ui-" if selected_variant == "ui" else "" - archive = f"codebase-memory-mcp-{ui}{os_name}-{arch}{portable}.{ext}" + archive = f"codebase-memory-mcp-{os_name}-{arch}{portable}.{ext}" url = f"https://github.com/{REPO}/releases/download/v{version}/{archive}" _validate_url_scheme(url) - dest = _bin_path(version, selected_variant) + dest = _bin_path(version) dest.parent.mkdir(parents=True, exist_ok=True) print( @@ -1307,7 +1192,7 @@ def _download(version: str) -> Path: bin_name = ( _WINDOWS_BINARY_NAME if os_name == "windows" else "codebase-memory-mcp" ) - extraction_names = (bin_name, _INTEGRATIONS_NAME) + extraction_names = (bin_name,) archive_names = ( _WINDOWS_ARCHIVE_NAMES if os_name == "windows" @@ -1321,7 +1206,6 @@ def _download(version: str) -> Path: tmp, archive_names, extraction_names, - selected_variant == "ui", ) else: import zipfile @@ -1331,7 +1215,6 @@ def _download(version: str) -> Path: tmp, archive_names, extraction_names, - selected_variant == "ui", ) extracted_paths = {} @@ -1348,7 +1231,7 @@ def _download(version: str) -> Path: current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH ) extracted_paths[name] = extracted_path - if _runtime_set_names(Path(tmp), bin_name, selected_variant) is None: + if _runtime_set_names(Path(tmp), bin_name) is None: sys.exit( "codebase-memory-mcp: extracted runtime set failed content " "verification" @@ -1387,7 +1270,6 @@ def _download(version: str) -> Path: staged_paths, dest, bin_name, - selected_variant, verifier=_verify_candidate, ) _verify_candidate(dest) diff --git a/pkg/pypi/tests/test_cli.py b/pkg/pypi/tests/test_cli.py index 5a3c819bc8..ca66fbdba3 100644 --- a/pkg/pypi/tests/test_cli.py +++ b/pkg/pypi/tests/test_cli.py @@ -87,16 +87,13 @@ def test_wrapper_uninstall_never_defaults_to_its_cache_binary(self): ["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_release_archives_carry_no_sidecars(self): + # The binary is self-contained; a returning sidecar would silently make + # `pip install` reject every archive against this exact-set allowlist. + for names in (_cli._WINDOWS_ARCHIVE_NAMES, _cli._UNIX_ARCHIVE_NAMES): + self.assertNotIn("cbm-integrations.json", names) + self.assertFalse([n for n in names if n.startswith("cbm-ui-")]) + self.assertEqual(len(names), 4) def test_update_guidance_uses_pip_and_names_managed_install(self): with mock.patch.object(_cli.sys, "argv", ["cbm", "update"]), \ @@ -207,7 +204,7 @@ def test_posix_probe_behavior_is_unchanged(self): class RuntimeSetTests(unittest.TestCase): - def test_candidate_probe_authenticates_adjacent_runtime_assets(self): + def test_candidate_probe_requires_the_binary_to_execute(self): candidates = ( Path("/tmp/codebase-memory-mcp"), PureWindowsPath("/tmp/codebase-memory-mcp"), @@ -221,529 +218,126 @@ def test_candidate_probe_authenticates_adjacent_runtime_assets(self): self.assertEqual( run.call_args.args[0], - [str(candidate), "--verify-runtime-assets"], + [str(candidate), "--version"], ) - 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): + def test_concurrent_publishers_preserve_the_first_complete_winner(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) + binary_name = "codebase-memory-mcp" - 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 stages(tag): + staged = directory / f".stage-{tag}-{binary_name}" + staged.write_bytes(f"binary:{tag}".encode()) + return {binary_name: staged} - def capture_claim_descriptor(owner_fd, token): - captured["claim_fd"] = owner_fd - write_owner(owner_fd, token) + first_stages = stages("first") + second_stages = 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 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) + 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") - 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) + def observe_waiter(lock_path): + if threading.current_thread().name == "publisher-second": + second_observed_lock.set() + return read_lock_owner(lock_path) - try: - self.assertTrue(captured["closed_before_unlink"]) - self.assertTrue( - _cli._same_runtime_lock_object( - os.fstat(lock[2]), lock[0].lstat() + def publish(label, staged_paths, replace_file=None): + try: + _cli._publish_runtime_set( + staged_paths, + directory / binary_name, + binary_name, + replace_file=replace_file, ) - ) - 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) + 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=record_owner_validation, - ), mock.patch.object( - _cli.os, "rename", side_effect=require_closed_descriptor + side_effect=observe_waiter, ): - _cli._release_runtime_lock(lock) + 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( - 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]}", - ), - ], + (directory / binary_name).read_bytes(), b"binary:first" ) - 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.assertFalse((directory / _cli._RUNTIME_LOCK_NAME).exists()) - self.assertTrue(lock[0].exists()) - lock[0].unlink() + def test_killed_publisher_is_reconciled_by_locked_readiness(self): + helper_source = r""" +import os +import sys +import time +from pathlib import Path - 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 +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 - 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) +directory = Path(directory_raw) +staged = Path(staged_raw) +marker = Path(marker_raw) +binary_name = "codebase-memory-mcp" +staged_paths = { + binary_name: staged / binary_name, +} +replace = os.replace - 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) +def pausing_replace(source, destination): + replace(source, destination) + if Path(destination) == directory / crash_name: + marker.write_text("reached\n") + while True: + time.sleep(60) - 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") +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: @@ -754,13 +348,7 @@ def verifier(binary): 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( @@ -814,8 +402,6 @@ def verifier(binary): 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 ): @@ -826,36 +412,22 @@ def verifier(binary): (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-") @@ -876,21 +448,10 @@ 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) @@ -916,19 +477,12 @@ def fail_binary_publish(source, destination): 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-") @@ -936,184 +490,348 @@ def fail_binary_publish(source, destination): ) ) - def test_concurrent_publishers_preserve_the_first_complete_winner(self): + def test_failure_never_deletes_a_complete_foreign_winner(self): with tempfile.TemporaryDirectory() as root: directory = Path(root) binary_name = "codebase-memory-mcp" + (directory / binary_name).write_bytes(b"binary:old") + staged_paths = {} + for name, contents in ( + (binary_name, b"binary:candidate"), + ): + staged = directory / f".stage-candidate-{name}" + staged.write_bytes(contents) + staged_paths[name] = staged - 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 + injected = False - def pause_first(source, destination): + def install_foreign_winner(source, destination): + nonlocal injected + source = Path(source) + destination = Path(destination) + if destination == directory / binary_name and not injected: + injected = True + (directory / binary_name).write_bytes(b"binary:foreign") + raise OSError("simulated non-cooperating winner") 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)) + _cli._publish_runtime_set( + staged_paths, + directory / binary_name, + binary_name, + replace_file=install_foreign_winner, + ) - first = threading.Thread( - name="publisher-first", - target=publish, - args=("first", first_stages, pause_first), + self.assertTrue(injected) + self.assertEqual( + (directory / binary_name).read_bytes(), b"binary:foreign" ) - second = threading.Thread( - name="publisher-second", - target=publish, - args=("second", second_stages), + self.assertFalse( + any( + path.name.startswith(".cbm-runtime-backup-") + for path in directory.iterdir() + ) ) + + 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 + ), 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=observe_waiter, + side_effect=record_owner_validation, + ), mock.patch.object( + _cli.os, "rename", side_effect=require_closed_descriptor ): - 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) + _cli._release_runtime_lock(lock) - 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" + 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((directory / second_pack).exists()) - self.assertFalse((directory / _cli._RUNTIME_LOCK_NAME).exists()) + self.assertFalse(lock[0].exists()) - def test_failure_never_deletes_a_complete_foreign_winner(self): + def test_release_rejects_owner_substitution_before_rename(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) + lock, _ = _cli._acquire_runtime_lock(directory) + read_owner = _cli._read_runtime_lock_owner + canonical_reads = 0 - 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), + 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, ): - staged = directory / f".stage-candidate-{name}" - staged.write_bytes(contents) - staged_paths[name] = staged + with self.assertRaisesRegex(RuntimeError, "ownership changed"): + _cli._release_runtime_lock(lock) - foreign_pack_bytes = b"pack:foreign" - foreign_pack = ( - f"cbm-ui-{hashlib.sha256(foreign_pack_bytes).hexdigest()}.pack" - ) - replace = os.replace - injected = False + self.assertTrue(lock[0].exists()) + lock[0].unlink() - 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) + 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 - _cli._publish_runtime_set( - staged_paths, - directory / binary_name, - binary_name, - "ui", - replace_file=install_foreign_winner, - ) + 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.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 + _cli._try_reclaim_runtime_lock(lock_path, "b" * 32), + (True, False), ) - 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.assertFalse(lock_path.exists()) + + 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",), + ) + + 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 = "LICENSE" + 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, "LICENSE") + 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, + ) -if __name__ == "__main__": - unittest.main() diff --git a/scripts/build.sh b/scripts/build.sh index 7b6f9daceb..a71029b933 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/append-vt-notes.sh b/scripts/ci/append-vt-notes.sh index 1897485119..0e5e1342f0 100755 --- a/scripts/ci/append-vt-notes.sh +++ b/scripts/ci/append-vt-notes.sh @@ -131,6 +131,7 @@ executable_paths = { for association in associations: scan_path = association["scan_path"] association_counts[scan_path] = association_counts.get(scan_path, 0) + 1 +tolerated_paths: List[str] = [] for result in results: scan_path = result["scan_path"] if scan_path in results_by_path: @@ -145,8 +146,18 @@ for result in results: ): if not result[numeric].isdigit(): fail(f"malformed numeric result field {numeric}: {scan_path}") - if int(result["malicious"]) != 0 or int(result["suspicious"]) != 0: + # Mirrors the gate's release policy: a single Microsoft machine-learning + # verdict is tolerated and disclosed in the notes; anything else must never + # have reached publication. + tolerated = ( + int(result["malicious"]) == 1 + and int(result["suspicious"]) == 0 + and result["microsoft_category"] == "malicious" + ) + if (int(result["malicious"]) != 0 or int(result["suspicious"]) != 0) and not tolerated: fail(f"detected object reached release-note publication: {scan_path}") + if tolerated: + tolerated_paths.append(scan_path) if ( re.fullmatch(r"[0-9a-f]{64}", result["sha256"]) is None or result["virustotal_url"] @@ -156,8 +167,8 @@ for result in results: if int(result["association_count"]) != association_counts.get(scan_path): fail(f"result association count mismatch: {scan_path}") if scan_path in executable_paths: - if result["microsoft_category"] not in {"undetected", "harmless"}: - fail(f"executable result lacks a decisive clean Microsoft verdict: {scan_path}") + if result["microsoft_category"] not in {"undetected", "harmless", "malicious"}: + fail(f"executable result lacks a decisive Microsoft verdict: {scan_path}") if not result["microsoft_engine_version"] or not result["microsoft_engine_update"]: fail(f"executable result lacks Microsoft version evidence: {scan_path}") results_by_path[scan_path] = result @@ -172,15 +183,14 @@ for association in associations: fail(f"result hash/size differs from association: {association['scan_path']}") member_rows = [row for row in associations if row["association_type"] == "member"] -asset_rows = [row for row in associations if row["association_type"] == "pack_asset"] -if len(member_rows) + len(asset_rows) != len(associations): +if len(member_rows) != len(associations): fail("association manifest contains a non-extracted scan target") archive_hashes: Dict[str, str] = {} for association in associations: archive = association["archive"] archive_sha256 = association["archive_sha256"] if not archive or re.fullmatch(r"[0-9a-f]{64}", archive_sha256) is None: - fail("member/asset association lacks valid archive SHA provenance") + fail("member association lacks valid archive SHA provenance") previous_sha256 = archive_hashes.get(archive) if previous_sha256 is not None and previous_sha256 != archive_sha256: fail(f"conflicting archive SHA provenance: {archive}") @@ -202,9 +212,7 @@ section = [ "", ( f"VirusTotal completed **{len(results)} distinct extracted byte objects** covering " - f"**{len(associations)} exact extracted-file associations**: " - f"{len(member_rows)} archive members and " - f"{len(asset_rows)} independently extracted UI-pack assets." + f"**{len(associations)} exact extracted archive members**." ), ( f"The extraction manifest binds those associations to " @@ -216,10 +224,26 @@ section = [ ( f"Every scanned object returned **0 malicious and 0 suspicious** verdicts with " f"{engine_range} decisive engine results (required minimum: {policy})." + if not tolerated_paths + else ( + f"{len(results) - len(tolerated_paths)} of {len(results)} scanned objects returned " + f"**0 malicious and 0 suspicious** verdicts with {engine_range} decisive engine " + f"results (required minimum: {policy})." + ) ), ( f"Microsoft returned a decisive clean verdict for all " f"{len(executable_paths)} executable objects." + if not tolerated_paths + else ( + f"**{len(tolerated_paths)} object(s) carry a single Microsoft machine-learning " + f"detection** (`!ml`), which this project treats as a known false positive and " + f"publishes rather than hides. Every other engine returned clean. See " + f"[Antivirus False Positives](https://github.com/{repository}" + f"/blob/main/SECURITY.md#antivirus-false-positives) for the evidence and for how to " + f"verify these artifacts yourself: " + + ", ".join(f"`{path}`" for path in sorted(tolerated_paths)) + ) ), "", ( diff --git a/scripts/ci/check-binary-composition.sh b/scripts/ci/check-binary-composition.sh index 43864e634d..9766b854f3 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' - '' - '' - '\n", -} -mime_ids = {".html": 1, ".js": 2, ".css": 3} - +fixtures = pathlib.Path(sys.argv[2]) +bash_executable = sys.argv[3] +extractor = root / "scripts" / "ci" / "extract-release-archives.sh" -def fail(message: str) -> None: - raise AssertionError(message) +UNIX = ("linux-amd64", "linux-arm64", "darwin-amd64", "darwin-arm64", + "linux-amd64-portable", "linux-arm64-portable") +WINDOWS = ("windows-amd64", "windows-arm64") +ARCHIVES = tuple(f"codebase-memory-mcp-{t}.tar.gz" for t in UNIX) + \ + tuple(f"codebase-memory-mcp-{t}.zip" for t in WINDOWS) +# Deliberately byte-IDENTICAL across every archive: the extractor must collapse +# them to one scan object each, or the gate pays to scan the same bytes 8 times +# and the association count stops meaning "distinct bytes". +SHARED_LICENSE = b"shared release license\n" +SHARED_NOTICES = b"shared third-party notices\n" +SHARED_SH = b"#!/bin/sh\n# shared installer\n" +SHARED_PS1 = b"Write-Output 'shared installer'\n" -def is_ui(name: str) -> bool: - return name.startswith("codebase-memory-mcp-ui-") +failures = [] -def make_pack(payloads: Dict[str, bytes]) -> bytes: - entries = sorted(payloads.items()) - paths = [path.encode("ascii") for path, _ in entries] - index_size = len(entries) * 24 - paths_size = sum(len(path) for path in paths) - payload_size = sum(len(data) for _, data in entries) - paths_offset = 80 + index_size - payload_offset = paths_offset + paths_size - total = payload_offset + payload_size - pack = bytearray(total) - struct.pack_into( - "<8sHHIIIQQQQQQQ", - pack, - 0, - b"CBMUIPK\0", - 1, - 80, - 0, - len(entries), - 24, - 80, - index_size, - paths_offset, - paths_size, - payload_offset, - payload_size, - total, - ) - path_cursor = 0 - payload_cursor = 0 - for index, ((path, data), raw_path) in enumerate(zip(entries, paths)): - extension = pathlib.PurePosixPath(path).suffix - struct.pack_into( - " bytes: - data = bytearray(valid_pack) - if kind == "bad_pack_magic": - data[0] = ord("X") - elif kind == "bad_pack_bounds": - struct.pack_into(" Dict[str, object]: - return {"name": name, "kind": "regular", "data": data} - - -def archive_entries(name: str, mutation: Optional[str] = None) -> List[Dict[str, object]]: - marker = name.encode("ascii") - windows = name.endswith(".zip") +def members(archive): + windows = archive.endswith(".zip") binary = "codebase-memory-mcp.exe" if windows else "codebase-memory-mcp" installer = "install.ps1" if windows else "install.sh" - entries = [ - regular(binary, b"binary bytes from " + marker + b"\n"), - regular("cbm-integrations.json", b'{"schema":1,"integrations":[]}\n'), - regular("LICENSE", b"shared release license\n"), - regular( - installer, - b"Write-Output 'shared installer'\n" - if windows - else b"#!/bin/sh\n# shared installer\n", - ), - regular("THIRD_PARTY_NOTICES.md", b"shared third-party notices\n"), - ] - if is_ui(name): - pack = mutate_pack(mutation) if mutation and mutation.startswith("bad_pack_") else valid_pack - entries.append(regular(f"cbm-ui-{hashlib.sha256(pack).hexdigest()}.pack", pack)) - return entries + return { + binary: b"binary bytes of " + archive.encode(), # unique per archive + "LICENSE": SHARED_LICENSE, + installer: SHARED_PS1 if windows else SHARED_SH, + "THIRD_PARTY_NOTICES.md": SHARED_NOTICES, + } -def write_archive(path: pathlib.Path, entries: List[Dict[str, object]]) -> None: - if path.name.endswith(".tar.gz"): - with tarfile.open(path, "w:gz") as archive: - for entry in entries: - name = str(entry["name"]) - kind = str(entry["kind"]) +def write_archive(directory, archive, extra=None, drop=None): + entries = members(archive) + if drop: + entries.pop(drop, None) + if extra: + entries[extra] = b"unexpected\n" + path = directory / archive + if archive.endswith(".zip"): + with zipfile.ZipFile(path, "w") as zf: + for name, data in entries.items(): + zf.writestr(name, data) + else: + with tarfile.open(path, "w:gz") as tf: + for name, data in entries.items(): info = tarfile.TarInfo(name) - info.mtime = 1 - if kind == "regular": - data = bytes(entry["data"]) - info.mode = 0o755 if name == "codebase-memory-mcp" else 0o644 - info.size = len(data) - archive.addfile(info, io.BytesIO(data)) - elif kind == "directory": - info.type = tarfile.DIRTYPE - info.mode = 0o755 - archive.addfile(info) - elif kind == "symlink": - info.type = tarfile.SYMTYPE - info.linkname = str(entry["target"]) - archive.addfile(info) - else: - fail(f"unknown fixture entry kind: {kind}") - return - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: - for entry in entries: - name = str(entry["name"]) - kind = str(entry["kind"]) - info = zipfile.ZipInfo(name) - info.create_system = 3 - if kind == "regular": - mode = 0o755 if name == "codebase-memory-mcp.exe" else 0o644 - info.external_attr = (stat.S_IFREG | mode) << 16 - archive.writestr(info, bytes(entry["data"])) - elif kind == "directory": - if not name.endswith("/"): - info.filename += "/" - info.external_attr = ((stat.S_IFDIR | 0o755) << 16) | 0x10 - archive.writestr(info, b"") - elif kind == "symlink": - info.external_attr = (stat.S_IFLNK | 0o777) << 16 - archive.writestr(info, str(entry["target"]).encode("ascii")) - else: - fail(f"unknown fixture entry kind: {kind}") - + info.size = len(data) + tf.addfile(info, __import__("io").BytesIO(data)) + return path -Association = Tuple[str, str, str, str] - -def build_matrix( - archive_dir: pathlib.Path, - mutation: Optional[str] = None, - selected_names: Tuple[str, ...] = archive_names, -) -> Dict[Association, bytes]: - archive_dir.mkdir(parents=True) - expected: Dict[Association, bytes] = {} - targets = { - "bad_hash": "codebase-memory-mcp-ui-linux-amd64.tar.gz", - "bad_pack_magic": "codebase-memory-mcp-ui-linux-amd64.tar.gz", - "bad_pack_bounds": "codebase-memory-mcp-ui-linux-arm64.tar.gz", - "bad_pack_path": "codebase-memory-mcp-ui-darwin-amd64.tar.gz", - "bad_pack_mime": "codebase-memory-mcp-ui-darwin-arm64.tar.gz", - "directory": "codebase-memory-mcp-linux-amd64.tar.gz", - "extra_pack": "codebase-memory-mcp-ui-linux-arm64-portable.tar.gz", - "missing_pack": "codebase-memory-mcp-ui-windows-arm64.zip", - "nested_member": "codebase-memory-mcp-darwin-amd64.tar.gz", - "tar_symlink": "codebase-memory-mcp-linux-arm64.tar.gz", - "tar_duplicate": "codebase-memory-mcp-ui-linux-amd64-portable.tar.gz", - "zip_symlink": "codebase-memory-mcp-windows-amd64.zip", - "zip_directory": "codebase-memory-mcp-windows-arm64.zip", - "zip_duplicate": "codebase-memory-mcp-ui-windows-amd64.zip", - } - target = targets.get(mutation) - for name in selected_names: - if mutation == "missing_archive" and name == "codebase-memory-mcp-linux-amd64.tar.gz": - continue - pack_mutation = mutation if name == target and mutation and mutation.startswith("bad_pack_") else None - entries = archive_entries(name, pack_mutation) - if name == target: - if mutation == "bad_hash": - entries = [entry for entry in entries if not str(entry["name"]).endswith(".pack")] - entries.append(regular(f"cbm-ui-{'0' * 64}.pack", valid_pack)) - elif mutation in ("directory", "zip_directory"): - entries.append({"name": "nested/", "kind": "directory"}) - elif mutation in ("tar_duplicate", "zip_duplicate"): - entries.append(regular("LICENSE", b"second license body\n")) - elif mutation == "extra_pack": - second = make_pack({ - "/assets/other.js": b"console.log('other')\n", - "/index.html": b"other\n", - }) - entries.append(regular(f"cbm-ui-{hashlib.sha256(second).hexdigest()}.pack", second)) - elif mutation == "missing_pack": - entries = [entry for entry in entries if not str(entry["name"]).endswith(".pack")] - elif mutation == "nested_member": - entries.append(regular("nested/payload", b"unexpected nested member\n")) - elif mutation in ("tar_symlink", "zip_symlink"): - installer = "install.ps1" if name.endswith(".zip") else "install.sh" - entries = [entry for entry in entries if entry["name"] != installer] - entries.append({"name": installer, "kind": "symlink", "target": "LICENSE"}) - path = archive_dir / name - write_archive(path, entries) - if mutation is None: - for entry in entries: - if entry["kind"] != "regular": - continue - member = str(entry["name"]) - data = bytes(entry["data"]) - expected[("member", name, member, "")] = data - if member.endswith(".pack"): - for asset_path, payload in asset_payloads.items(): - expected[("pack_asset", name, member, asset_path)] = payload - if mutation == "unexpected_archive": - unexpected = "codebase-memory-mcp-freebsd-amd64.tar.gz" - write_archive(archive_dir / unexpected, archive_entries("codebase-memory-mcp-linux-amd64.tar.gz")) - if mutation == "oversized_archive": - with (archive_dir / "codebase-memory-mcp-linux-amd64.tar.gz").open("r+b") as handle: - handle.truncate(512 * 1024 * 1024 + 1) - return expected +def build_matrix(directory, **kwargs): + directory.mkdir(parents=True, exist_ok=True) + for archive in ARCHIVES: + write_archive(directory, archive, **kwargs) + return directory -def invoke( - archive_dir: pathlib.Path, - output_dir: pathlib.Path, - args: Optional[List[str]] = None, -) -> subprocess.CompletedProcess: +def run_extractor(archive_dir, out_dir, *args): return subprocess.run( - [ - str(bash_executable), - str(extractor), - str(archive_dir), - str(output_dir), - *(args or count_args), - ], - cwd=root, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - check=False, + [bash_executable, str(extractor), str(archive_dir), str(out_dir), *args], + capture_output=True, text=True, ) -def parse_manifest(path: pathlib.Path, marker: str) -> Tuple[Dict[str, int], List[Dict[str, str]]]: +def read_manifest(path, marker): lines = path.read_text(encoding="utf-8").splitlines() if not lines or lines[0] != f"# {marker}": - fail(f"{path.name} is missing marker {marker}") - metadata: Dict[str, int] = {} - cursor = 1 - while cursor < len(lines) and lines[cursor].startswith("# "): - key, value = lines[cursor][2:].split("=", 1) - metadata[key] = int(value) - cursor += 1 - return metadata, list(csv.DictReader(lines[cursor:], delimiter="\t")) - - -def assert_clean_failure(case_root: pathlib.Path, mutation: str, message: str) -> None: - archive_dir = case_root / "archives" - output_dir = case_root / "scan" - build_matrix(archive_dir, mutation) - result = invoke(archive_dir, output_dir) - if result.returncode == 0 or message not in result.stdout: - fail( - f"{mutation}: extractor must fail with {message!r}; " - f"rc={result.returncode}, output={result.stdout.strip()}" - ) - if output_dir.exists(): - fail(f"{mutation}: rejected archives left a partial atomic scan bundle") - - -if len(archive_names) != 16 or len(ui_archive_names) != 8: - fail("fixture matrix no longer describes 16 canonical archives / eight UI archives") - -with tempfile.TemporaryDirectory(prefix="cbm-release-scan-contract-") as temporary: - work = pathlib.Path(temporary) - valid = work / "valid" - expected = build_matrix(valid / "archives") - output = valid / "scan" - result = invoke(valid / "archives", output) - if result.returncode != 0: - fail(f"canonical release matrix was rejected: {result.stdout.strip()}") - if {path.name for path in output.iterdir()} != {"objects", "associations.tsv", "scan-set.tsv"}: - fail("scan bundle must publish exactly objects/ plus its two manifests") - - association_metadata, rows = parse_manifest( - output / "associations.tsv", "cbm-release-scan-associations-v3" - ) - expected_pack_assets = 8 * len(asset_payloads) - expected_associations = 88 + expected_pack_assets - expected_metadata = { - **expected_base_counts, - "pack_assets": expected_pack_assets, - "associations": expected_associations, - "scan_objects": len(set(expected.values())), - } - if association_metadata != expected_metadata: - fail(f"association metadata mismatch: {association_metadata!r}") - if len(rows) != expected_associations: - fail("association manifest does not contain every extracted member/asset association") - actual_associations = { - (row["association_type"], row["archive"], row["member"], row["asset_path"]): row - for row in rows - } - if any(row["association_type"] == "archive" or row["kind"] == "archive" for row in rows): - fail("archive containers must not appear in the VirusTotal association set") - if set(actual_associations) != set(expected): - fail("association manifest keys differ from exact shipped associations") - archive_hashes = { - name: hashlib.sha256((valid / "archives" / name).read_bytes()).hexdigest() - for name in archive_names - } - for association, data in expected.items(): - row = actual_associations[association] - object_path = output / row["scan_path"] - if object_path.read_bytes() != data: - fail(f"scan object changed bytes for association {association!r}") - if row["object_sha256"] != hashlib.sha256(data).hexdigest() or int(row["size"]) != len(data): - fail(f"association digest/size is wrong for {association!r}") - if row["archive_sha256"] != archive_hashes[row["archive"]]: - fail(f"association archive provenance is wrong for {association!r}") - - object_paths = sorted((output / "objects").iterdir()) - if len(object_paths) != len(set(expected.values())): - fail("byte-identical archive members/assets were not deduplicated") - object_bytes = [path.read_bytes() for path in object_paths] - if len(object_bytes) != len(set(object_bytes)): - fail("scan set contains duplicate byte sequences") - license_rows = [row for row in rows if row["member"] == "LICENSE"] - if len(license_rows) != 16 or len({row["scan_path"] for row in license_rows}) != 1: - fail("shared LICENSE bytes must map 16 associations to one scan object") - pack_rows = [row for row in rows if row["kind"] == "pack"] - if len(pack_rows) != 8 or len({row["scan_path"] for row in pack_rows}) != 1: - fail("shared UI pack bytes must map eight associations to one scan object") - script_rows = [row for row in rows if row["asset_path"] == "/assets/app.js"] - if len(script_rows) != 8 or len({row["scan_path"] for row in script_rows}) != 1: - fail("each pack's JavaScript association must reach one exact deduplicated script object") - - scan_metadata, scan_rows = parse_manifest(output / "scan-set.tsv", "cbm-release-scan-set-v2") - if scan_metadata != { - "scan_objects": expected_metadata["scan_objects"], - "associations": expected_associations, - }: - fail(f"scan-set metadata mismatch: {scan_metadata!r}") - if len(scan_rows) != len(object_paths): - fail("scan-set row count differs from staged distinct objects") - scan_paths = {row["scan_path"] for row in scan_rows} - if scan_paths != {f"objects/{path.name}" for path in object_paths}: - fail("scan-set paths differ from staged object paths") - if sum(int(row["association_count"]) for row in scan_rows) != expected_associations: - fail("scan-set association counts do not cover the association manifest") - for row in scan_rows: - path = output / row["scan_path"] - data = path.read_bytes() - if row["sha256"] != hashlib.sha256(data).hexdigest() or int(row["size"]) != len(data): - fail(f"scan-set digest/size does not bind {row['scan_path']}") - - ui_only = work / "ui-only" - ui_expected = build_matrix(ui_only / "archives", selected_names=ui_archive_names) - ui_output = ui_only / "scan" - ui_result = invoke(ui_only / "archives", ui_output, ui_count_args) - if ui_result.returncode != 0: - fail(f"canonical UI-only matrix was rejected: {ui_result.stdout.strip()}") - ui_metadata, ui_rows = parse_manifest( - ui_output / "associations.tsv", "cbm-release-scan-associations-v3" - ) - ui_associations = 48 + expected_pack_assets - if ui_metadata != { - **ui_expected_base_counts, - "pack_assets": expected_pack_assets, - "associations": ui_associations, - "scan_objects": len(set(ui_expected.values())), - }: - fail(f"UI-only association metadata mismatch: {ui_metadata!r}") - if len(ui_rows) != ui_associations or {row["variant"] for row in ui_rows} != {"ui"}: - fail("UI-only scan scope does not cover exactly the eight UI archives") - ui_as_all_output = ui_only / "scan-as-all" - ui_as_all = invoke(ui_only / "archives", ui_as_all_output, count_args) - if ( - ui_as_all.returncode == 0 - or "expected exact canonical 16 (all)" not in ui_as_all.stdout - or ui_as_all_output.exists() - ): - fail("UI-only archives must require an explicit closed archive scope") - - failure_cases = { - "bad_hash": "UI pack digest does not match its filename", - "bad_pack_magic": "CBMUIPK header/offset contract failed", - "bad_pack_bounds": "CBMUIPK header/offset contract failed", - "bad_pack_path": "CBMUIPK asset path is invalid", - "bad_pack_mime": "CBMUIPK MIME/path contract failed", - "directory": "non-regular archive member", - "extra_pack": "exactly one UI pack", - "missing_archive": "expected exact canonical 16 (all)", - "missing_pack": "exactly one UI pack", - "nested_member": "unexpected archive member", - "oversized_archive": "archive exceeds 536870912 byte ceiling", - "tar_symlink": "non-regular archive member", - "tar_duplicate": "duplicate archive member", - "unexpected_archive": "expected exact canonical 16 (all)", - "zip_directory": "non-regular archive member", - "zip_duplicate": "duplicate archive member", - "zip_symlink": "non-regular archive member", - } - for mutation, message in failure_cases.items(): - case_root = work / mutation - case_root.mkdir() - assert_clean_failure(case_root, mutation, message) - - wrong_count = work / "wrong-count" - wrong_count.mkdir() - build_matrix(wrong_count / "archives") - wrong_args = [arg for arg in count_args if not arg.startswith("--expect-packs=")] - wrong_args.append("--expect-packs=9") - wrong_output = wrong_count / "scan" - wrong = invoke(wrong_count / "archives", wrong_output, wrong_args) - if wrong.returncode == 0 or "packs expected 9, got 8" not in wrong.stdout or wrong_output.exists(): - fail(f"explicit expected-count mismatch did not fail atomically: {wrong.stdout.strip()}") - -source = extractor.read_text(encoding="utf-8") -if "files_equal(candidate, existing.path)" not in source: - fail("deduplication no longer exact-compares bytes after SHA-256+size grouping") -if source.count("archive_object.path,") < 2: - fail("members are not parsed from the same retained archive bytes used for provenance") -for ceiling in ("MAX_ARCHIVE_BYTES", "MAX_MEMBER_BYTES", "MAX_TOTAL_MEMBER_BYTES"): - if ceiling not in source: - fail(f"extractor lost required size ceiling {ceiling}") - -release = (root / ".github/workflows/release.yml").read_text(encoding="utf-8") -dry_run = (root / ".github/workflows/dry-run.yml").read_text(encoding="utf-8") -build = (root / ".github/workflows/_build.yml").read_text(encoding="utf-8") -smoke = (root / ".github/workflows/_smoke.yml").read_text(encoding="utf-8") -if "ui_only" in build or "Build standard binary" in build: - fail("canonical build workflow must not retain a non-UI release path") -if build.count("--variant ui") != 4: - fail("canonical build workflow must package exactly its four UI-enabled build families") -if "ui_only" in smoke or "VARIANTS='[\"ui\"]'" not in smoke or "standard" in smoke.split("VARIANTS=", 1)[1].split("\n", 1)[0]: - fail("release smoke matrix must expose only the UI-enabled runtime") -if "ui_only" in dry_run or "--archive-scope=ui" not in dry_run: - fail("dry-run must use the canonical UI-only build/smoke/extraction path") -if "--archive-scope=ui" not in release: - fail("release verification must accept only the shipped UI-enabled archives") -if ( - "path: ${{ runner.temp }}/release-archives" not in dry_run - or 'extract-release-archives.sh "$RUNNER_TEMP/release-archives" binaries' not in dry_run - or "path: assets" in dry_run - or "extract-release-archives.sh assets binaries" in dry_run + fail(f"{path.name} does not start with its marker {marker!r}") + return {}, [] + meta, rows, header = {}, [], None + for line in lines[1:]: + if line.startswith("# "): + key, _, value = line[2:].partition("=") + meta[key] = int(value) if value.isdigit() else value + elif header is None: + header = line.split("\t") + else: + rows.append(dict(zip(header, line.split("\t")))) + return meta, rows + + +# ── 1. The happy path: exact matrix in, exact bundle out ──────────────────── +good = build_matrix(fixtures / "good" / "archives") +out = fixtures / "good" / "scan" +result = run_extractor(good, out, "--expect-archives=8", "--expect-binaries=8", + "--expect-runtime-files=24") +if result.returncode != 0: + fail(f"exact release matrix was rejected: {result.stdout[-600:]}{result.stderr[-600:]}") +else: + published = sorted(p.name for p in out.iterdir()) + if published != ["associations.tsv", "objects", "scan-set.tsv"]: + fail(f"scan bundle must publish exactly objects/ plus its two manifests: {published}") + + assoc_meta, assoc = read_manifest(out / "associations.tsv", + "cbm-release-scan-associations-v3") + set_meta, scan_set = read_manifest(out / "scan-set.tsv", "cbm-release-scan-set-v2") + + # Every member of every archive is covered — 8 archives x 4 members. + if len(assoc) != 32: + fail(f"association manifest must cover all 32 extracted members, got {len(assoc)}") + + # An archive container must never be scanned as if it were a member. + if any(row["member"] in ARCHIVES for row in assoc): + fail("archive containers must not appear in the association set") + + # Deduplication: identical bytes collapse, unique bytes do not. + objects = {row["scan_path"] for row in assoc} + if len(scan_set) != len(objects): + fail(f"scan-set rows ({len(scan_set)}) differ from distinct objects ({len(objects)})") + licences = {row["scan_path"] for row in assoc if row["member"] == "LICENSE"} + if len(licences) != 1: + fail(f"8 byte-identical LICENSE members must map to ONE scan object, got {len(licences)}") + binaries = {row["scan_path"] for row in assoc if row["kind"] == "binary"} + if len(binaries) != 8: + fail(f"8 distinct binaries must stay 8 scan objects, got {len(binaries)}") + + # The counts the gate reads back must agree with the rows. + if assoc_meta.get("associations") != len(assoc): + fail("association metadata disagrees with its own rows") + if set_meta.get("scan_objects") != len(scan_set): + fail("scan-set metadata disagrees with its own rows") + + # Every staged object must exist with the hash the manifest claims. + for row in scan_set: + staged = out / row["scan_path"] + if not staged.is_file(): + fail(f"scan-set names a missing object: {row['scan_path']}") + elif hashlib.sha256(staged.read_bytes()).hexdigest() != row["sha256"]: + fail(f"staged object does not match its recorded hash: {row['scan_path']}") + +# ── 2. Fail-closed cases — each must be REFUSED, not silently scanned ─────── +for label, kwargs, expect in ( + ("an unexpected extra member", {"extra": "surprise.txt"}, "unexpected archive member"), + ("a missing required member", {"drop": "LICENSE"}, "member namespace mismatch"), ): - fail("dry-run must isolate downloaded archives from tracked checkout assets") -if ( - 'ARCHIVE_DIR="$RUNNER_TEMP/release-archives"' not in release - or '--dir "$ARCHIVE_DIR"' not in release - or 'extract-release-archives.sh "$ARCHIVE_DIR" binaries' not in release - or "--dir assets" in release - or "extract-release-archives.sh assets binaries" in release -): - fail("release verification must isolate downloaded archives from tracked checkout assets") -for workflow_name, workflow, arguments in ( - ("release", release, ui_count_args), - ("dry-run", dry_run, ui_count_args), -): - for argument in arguments: - if argument not in workflow: - fail(f"{workflow_name} workflow does not assert {argument}") - for required in ( - "files: binaries/objects/*", - "request_rate: 4", - "VT_EXPECTED_SCAN_SET: binaries/scan-set.tsv", - "VT_ASSOCIATIONS: binaries/associations.tsv", - "VT_RESULTS_PATH: binaries/vt-results.tsv", - "MIN_ENGINES: 50", - "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", - "virustotal-evidence-", - ): - if required not in workflow: - fail(f"{workflow_name} workflow is missing exact scan/gate contract: {required}") - lowered = workflow.lower() - for forbidden in ("false-positive", "false positive", "submission", "reputation"): - if forbidden in lowered: - fail(f"{workflow_name} workflow prescribes forbidden scan-cleaning policy: {forbidden}") - if "naturally clean" not in lowered: - fail(f"{workflow_name} workflow must state the naturally-clean baseline") - -notes = (root / "scripts/ci/append-vt-notes.sh").read_text(encoding="utf-8") -for required in ( - "cbm-security-verification:start", - "cbm-security-verification:end", - "binaries/associations.tsv", - "binaries/vt-results.tsv", -): - if required not in notes: - fail(f"release-note updater is missing {required}") -if "70+" in notes or "...`" in notes: - fail("release notes must use measured engine counts and full SHA-256 values") - -print("release archive extractor contract: OK") + case = fixtures / label.replace(" ", "_") + bad = build_matrix(case / "archives", **kwargs) + result = run_extractor(bad, case / "scan") + if result.returncode == 0: + fail(f"extractor accepted {label}") + elif expect not in (result.stdout + result.stderr): + fail(f"{label} was rejected without naming the contract: " + f"{(result.stdout + result.stderr)[-300:]}") + +# An incomplete matrix must never satisfy an exact count. +short = fixtures / "short" / "archives" +short.mkdir(parents=True) +write_archive(short, ARCHIVES[0]) +result = run_extractor(short, fixtures / "short" / "scan", "--expect-archives=8") +if result.returncode == 0: + fail("extractor accepted a 1-archive matrix under --expect-archives=8") + +if failures: + print("RELEASE ARCHIVE EXTRACTOR CONTRACT VIOLATED:") + for message in failures: + print(f" - {message}") + sys.exit(1) + +print("release archive extractor contract OK " + "(8-archive matrix, 32 member associations, dedup exact, fail-closed on " + "surplus/missing members and short matrices)") PY diff --git a/tests/test_smoke_fixture_contract.sh b/tests/test_smoke_fixture_contract.sh index 17f35df07e..b202f967d7 100755 --- a/tests/test_smoke_fixture_contract.sh +++ b/tests/test_smoke_fixture_contract.sh @@ -5,23 +5,19 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -python3 - "$ROOT" "$BASH" <<'PY' +python3 - "$ROOT" <<'PY' from __future__ import annotations -import hashlib -import os import pathlib import re import subprocess import sys -import tarfile import tempfile import time import urllib.request root = pathlib.Path(sys.argv[1]) -bash_executable = pathlib.Path(sys.argv[2]) failures: list[str] = [] @@ -48,32 +44,9 @@ local_ci = read("test-infrastructure/run.sh") windows_vm = read("test-infrastructure/vm/win.sh") windows_vm_runner = read("test-infrastructure/vm/vm-run-tests.sh") pr_workflow = read(".github/workflows/pr.yml") -release_workflow = read(".github/workflows/release.yml") -release_extractor = read("scripts/ci/extract-release-archives.sh") test_driver = read("scripts/test.sh") cli_source = read("src/cli/cli.c") helper_source = read(helper_relative) -unix_installer = read("install.sh") -windows_installer = read("install.ps1") - -# Keep the security-boundary archive checks runnable as a focused, network-free -# contract while still making them part of the canonical smoke-fixture gate. -release_archive_contract = subprocess.run( - [ - str(bash_executable), - str(root / "tests/test_release_archive_extractor_contract.sh"), - ], - cwd=root, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - check=False, -) -require( - release_archive_contract.returncode == 0, - "release archive extractor contract failed: " - + release_archive_contract.stdout.strip(), -) # The server owns the ephemeral bind. A parent-side socket probe followed by # python -m http.server would reintroduce the close/rebind race this guards. @@ -161,23 +134,18 @@ for relative, source in ( f"{relative} must pin isolated {variable}", ) -# Unix fixtures mirror the exact release variant surface. +# Unix fixtures mirror the release archive surface and Linux update aliases. for name in ("LICENSE", "install.sh", "THIRD_PARTY_NOTICES.md"): require(name in smoke_local, f"smoke-local.sh archive must include {name}") +# One composition ships, so there is one archive name and no variant alias. require( - "codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}.tar.gz" in smoke_local - and "UI_PACK_NAME" in smoke_local - and "^cbm-ui-[0-9a-f]{64}\\.pack$" in smoke_local, - "smoke-local.sh must create the selected exact variant with a hash-shaped UI pack", + "codebase-memory-mcp-${OS}-${ARCH}.tar.gz" in smoke_local + and "${SUFFIX}" not in smoke_local, + "smoke-local.sh must create the single canonical archive with no variant alias", ) require( - "codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}-portable.tar.gz" in smoke_local - and 'ARCHIVE_MEMBERS+=("$UI_PACK_NAME")' in smoke_local, - "smoke-local.sh must create the Linux portable selected variant with the same member set", -) -require( - '"$FIXTURE_DIR/codebase-memory-mcp-${OS}-${ARCH}.tar.gz"' not in smoke_local, - "smoke-local.sh must not disguise a six-member UI archive under a standard name", + "codebase-memory-mcp-${OS}-${ARCH}-portable.tar.gz" in smoke_local, + "smoke-local.sh must create the Linux portable update alias", ) require( 'CBM_CACHE_DIR="$WORK_DIR/cache"' in smoke_local @@ -195,9 +163,9 @@ require( "fixture checksums must name exact artifact basenames, never ./-prefixed paths", ) -# Native Windows packages and serves the exact five-file standard bundle or -# six-file UI bundle (one native binary), then runs the full smoke from a -# protected profile-rooted directory/cache. +# Native Windows packages and serves the exact four-file release bundle (ONE +# binary, like every other platform), then runs the full smoke from a protected +# profile-rooted directory/cache. for name in ( "codebase-memory-mcp.exe", "LICENSE", @@ -210,16 +178,6 @@ require( "vm-smoke.sh must not stage a Windows launcher/payload pair", ) require("checksums.txt" in vm_smoke, "vm-smoke.sh must generate checksums.txt") -require( - "UI_PACK_NAME" in vm_smoke - and "^cbm-ui-[0-9a-f]{64}\\.pack$" in vm_smoke - and 'ARCHIVE_MEMBERS+=("$UI_PACK_NAME")' in vm_smoke, - "vm-smoke.sh must stage exactly one content-addressed pack only for UI", -) -require( - '"codebase-memory-mcp-windows-${SMOKE_ARCH}.zip"' not in vm_smoke, - "vm-smoke.sh must not disguise a six-member UI zip under a standard name", -) require( "SMOKE_DOWNLOAD_URL=" in vm_smoke and "SMOKE_UPDATE_FIXTURE_DIR=" in vm_smoke @@ -304,7 +262,7 @@ for service in ("smoke-windows:",): require( "wine64 ./build/win-cross/codebase-memory-mcp.exe --version" in compose and "wine64 cmd /c build/win-cross/codebase-memory-mcp.exe --version" in compose, - "docker-compose Windows cross-smoke must execute the runtime set's one executable through Wine and through a " + "docker-compose Windows cross-smoke must execute the single binary through Wine and through a " "Wine Windows parent", ) require( @@ -347,93 +305,6 @@ require( "PR Windows smoke must call vm-smoke.sh with SMOKE_ARCH=amd64", ) smoke_test = read("scripts/smoke-test.sh") -require( - "SMOKE_UI_PACK_CANDIDATES" in smoke_test - and 'cp "$SMOKE_UI_PACK" "$(dirname "$destination")/$(basename "$SMOKE_UI_PACK")"' - in smoke_test, - "smoke-test.sh must stage the UI sidecar with every hand-copied binary", -) -require( - 'UNINSTALL_BINARY="$SELF_PATH"' in smoke_test - and '"$CLEAN_UNINSTALLER" uninstall -y -n' in smoke_test - and '"$DBL_UNINSTALLER" uninstall -y -n' in smoke_test - and '"$DBL_RETRY_UNINSTALLER" uninstall -y -n' in smoke_test, - "smoke uninstall cases must run disposable fixture executables, never delete the " - "build input needed by later phases", -) -require( - 'exec 9< <(sleep 300)' in smoke_test - and 'UI_STDIN_PID=$!' in smoke_test - and smoke_test.count("stop_ui_probe") >= 4 - and 'sleep 300 | "$BINARY"' not in smoke_test, - "the UI readiness probe must own and reap its stdin keeper instead of waiting on a " - "background pipeline", -) -require( - '"$REPO_ROOT/install.sh" "$DL_VARIANT_ARG"' in smoke_test - and '"$WIN_SCRIPT" "$DL_VARIANT_ARG"' in smoke_test - and "install.ps1 did not publish exactly one UI asset pack" in smoke_test, - "Phase 13 must call each direct installer with the downloaded variant and verify its pack", -) -require( - all( - needle in unix_installer - for needle in ( - "ARCHIVE_MEMBER_COUNT", - "EXPECTED_MEMBER_COUNT=5", - "EXPECTED_MEMBER_COUNT=6", - "^cbm-ui-[0-9a-f]{64}\\.pack$", - "release archive contains unexpected member", - ) - ), - "install.sh must validate the exact five-member standard or six-member UI archive", -) -unix_binary_publish = unix_installer.find('"$DLBIN" install "${INSTALL_ARGS[@]}"') -require( - unix_binary_publish >= 0 - and "publish_required_sidecar" not in unix_installer - and 'for stale_pack in "$INSTALL_DIR"/cbm-ui-*.pack' not in unix_installer, - "install.sh must delegate sidecar publication and stale-pack GC to the candidate's " - "guarded native install", -) -require( - "GetRandomFileName" in windows_installer - and "[System.IO.FileMode]::CreateNew" in windows_installer - and windows_installer.count("New-CbmExclusiveSiblingTemp") == 2 - and '$Destination.new-$PID' not in windows_installer - and '$InstallerDest.new' not in windows_installer, - "install.ps1 must reserve an unpredictable sibling temp exclusively for the updater only", -) - -cli_source = read("src/cli/cli.c") -install_start = cli_source.find("static int cli_install_activate(void *opaque)") -install_end = cli_source.find("int cbm_cmd_install(", install_start) -install_body = cli_source[install_start:install_end] if 0 <= install_start < install_end else "" -runtime_stage = install_body.find("cbm_integration_assets_stage_install") -binary_commit = install_body.find("cli_activation_transaction_commit_validated") -runtime_gc = install_body.find("cli_gc_stale_ui_asset_packs") -runtime_finalize = install_body.find("cli_install_runtime_finalize") -require( - 0 <= runtime_stage < binary_commit < runtime_gc < runtime_finalize, - "native install must stage sidecars, activate the binary, GC verified stale packs, and " - "finalize while the activation callback still owns the guard", -) -require( - 'ARCHIVE_DIR="$RUNNER_TEMP/release-archives"' in release_workflow - and 'scripts/ci/extract-release-archives.sh "$ARCHIVE_DIR" binaries' in release_workflow, - "release verification must feed the canonical extractor from an isolated archive directory", -) -require( - "cbm-release-scan-associations-v3" in release_extractor - and "cbm-release-scan-set-v2" in release_extractor - and "files_equal(candidate, existing.path)" in release_extractor - and "parse_pack_assets" in release_extractor - and "PACK_NAME.fullmatch" in release_extractor - and "non-regular archive member" in release_extractor - and "duplicate archive member" in release_extractor, - "release extraction must validate and hash every archive, then retain only exact " - "member/UI-asset associations and one scan object per distinct extracted byte sequence", -) require( "MSYS2_ARG_CONV_EXCL='*'" in smoke_test and 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File' in smoke_test @@ -652,226 +523,6 @@ if helper.is_file(): dns_process.kill() dns_process.wait(timeout=3) -# POSIX installer lifecycle injection. Preserve the wrapper's PID across exec, -# precreate the two formerly predictable temp paths as foreign symlinks, then -# run the real checksum-authenticated installer. The script no longer publishes -# runtime sidecars at all, and its updater publisher never touches the foreign -# predictable sibling. -if os.name != "nt" and helper.is_file(): - with tempfile.TemporaryDirectory(prefix="cbm-installer-temp-contract-") as install_temp: - install_root = pathlib.Path(install_temp) - fixture = install_root / "fixture" - stage = install_root / "stage" - fake_bin = install_root / "fake-bin" - install_dir = install_root / "installed" - fake_home = install_root / "home" - fixture.mkdir() - stage.mkdir() - fake_bin.mkdir() - install_dir.mkdir() - fake_home.mkdir() - - candidate = stage / "codebase-memory-mcp" - candidate.write_text( - "#!/usr/bin/env bash\n" - "set -euo pipefail\n" - "case \"${1:-}\" in\n" - " --version) echo 'codebase-memory-mcp fixture';;\n" - " install)\n" - " install_dir=''\n" - " for arg in \"$@\"; do\n" - " case \"$arg\" in --dir=*) install_dir=${arg#--dir=};; esac\n" - " done\n" - " test -n \"$install_dir\"\n" - " cp \"$0\" \"$install_dir/codebase-memory-mcp\"\n" - " chmod 755 \"$install_dir/codebase-memory-mcp\";;\n" - " *) exit 2;;\n" - "esac\n", - encoding="utf-8", - ) - candidate.chmod(0o755) - (stage / "cbm-integrations.json").write_bytes(b"{}\n") - (stage / "LICENSE").write_bytes(b"fixture license\n") - (stage / "THIRD_PARTY_NOTICES.md").write_bytes(b"fixture notices\n") - - archive_name = "codebase-memory-mcp-linux-amd64-portable.tar.gz" - archive_path = fixture / archive_name - with tarfile.open(archive_path, "w:gz") as archive: - for name in ( - "codebase-memory-mcp", - "cbm-integrations.json", - "LICENSE", - "THIRD_PARTY_NOTICES.md", - ): - archive.add(stage / name, arcname=name) - archive.add(root / "install.sh", arcname="install.sh") - archive_digest = hashlib.sha256(archive_path.read_bytes()).hexdigest() - (fixture / "checksums.txt").write_text( - f"{archive_digest} {archive_name}\n", encoding="ascii" - ) - - fake_uname = fake_bin / "uname" - fake_uname.write_text( - "#!/bin/sh\n" - "case \"${1:-}\" in\n" - " -s) echo Linux;;\n" - " -m) echo x86_64;;\n" - " *) echo Linux;;\n" - "esac\n", - encoding="utf-8", - ) - fake_uname.chmod(0o755) - - foreign_sidecar = install_root / "foreign-sidecar" - foreign_updater = install_root / "foreign-updater" - foreign_sidecar.write_bytes(b"foreign sidecar sentinel\n") - foreign_updater.write_bytes(b"foreign updater sentinel\n") - wrapper_pid_file = install_root / "wrapper-pid" - wrapper = install_root / "run-installer.sh" - wrapper.write_text( - "#!/usr/bin/env bash\n" - "set -euo pipefail\n" - "install_dir=$1\n" - "foreign_sidecar=$2\n" - "foreign_updater=$3\n" - "pid_file=$4\n" - "installer=$5\n" - "ln -s \"$foreign_sidecar\" \"$install_dir/.cbm-integrations.json.$$\"\n" - "ln -s \"$foreign_updater\" \"$install_dir/.install.sh.$$\"\n" - "printf '%s\\n' \"$$\" > \"$pid_file\"\n" - "exec bash \"$installer\" --standard \"--dir=$install_dir\" --skip-config\n", - encoding="utf-8", - ) - wrapper.chmod(0o755) - - port_file = install_root / "port" - server_log = install_root / "server.log" - with server_log.open("wb") as log_handle: - fixture_server = subprocess.Popen( - [ - sys.executable, - str(helper), - "--directory", - str(fixture), - "--port-file", - str(port_file), - ], - stdout=log_handle, - stderr=subprocess.STDOUT, - ) - try: - port = 0 - deadline = time.monotonic() + 30 - while time.monotonic() < deadline: - if port_file.is_file(): - try: - port_text = port_file.read_text(encoding="ascii").strip() - except OSError: - port_text = "" - if port_text: - port = int(port_text) - break - if fixture_server.poll() is not None: - break - time.sleep(0.02) - require(port > 0, "installer temp-path fixture server did not publish a port") - if port > 0: - environment = os.environ.copy() - environment["HOME"] = str(fake_home) - environment["PATH"] = ( - str(fake_bin) + os.pathsep + environment.get("PATH", "") - ) - environment["CBM_DOWNLOAD_URL"] = f"http://127.0.0.1:{port}" - installed = subprocess.run( - [ - str(wrapper), - str(install_dir), - str(foreign_sidecar), - str(foreign_updater), - str(wrapper_pid_file), - str(root / "install.sh"), - ], - env=environment, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - timeout=60, - check=False, - ) - require( - installed.returncode == 0, - f"installer temp-path fixture failed to install: {installed.stdout.strip()}", - ) - if wrapper_pid_file.is_file(): - wrapper_pid = wrapper_pid_file.read_text(encoding="ascii").strip() - predictable_sidecar = ( - install_dir / f".cbm-integrations.json.{wrapper_pid}" - ) - predictable_updater = install_dir / f".install.sh.{wrapper_pid}" - require( - predictable_sidecar.is_symlink() - and foreign_sidecar.read_bytes() - == b"foreign sidecar sentinel\n", - "install.sh deleted or followed a foreign predictable sidecar temp", - ) - require( - predictable_updater.is_symlink() - and foreign_updater.read_bytes() - == b"foreign updater sentinel\n", - "install.sh deleted or followed a foreign predictable updater temp", - ) - else: - failures.append("installer temp-path wrapper did not publish its PID") - finally: - fixture_server.terminate() - try: - fixture_server.wait(timeout=3) - except subprocess.TimeoutExpired: - fixture_server.kill() - fixture_server.wait(timeout=3) - -# The complete canonical-matrix extraction, pack parsing, association retention, -# byte-exact deduplication and fail-closed malformed-pack cases run above via -# test_release_archive_extractor_contract.sh. Keep the packaging-side digest -# check here because it is a separate boundary before an archive exists. -with tempfile.TemporaryDirectory(prefix="cbm-release-extract-") as extract_temp: - extract_root = pathlib.Path(extract_temp) - bad_pack_name = f"cbm-ui-{'0' * 64}.pack" - bad_pack_bytes = b"content does not match the zero digest\n" - # Packaging checks the content address before it touches the product binary. - # No binary is staged deliberately: the digest error must win over the - # later missing-binary guard or this test would accept an incidental red. - package_build = extract_root / "package-build" - package_output = extract_root / "package-output" - package_build.mkdir() - package_output.mkdir() - (package_build / bad_pack_name).write_bytes(bad_pack_bytes) - package_environment = os.environ.copy() - package_environment["BUILD_DIR"] = str(package_build) - packaged = subprocess.run( - [ - str(bash_executable), - str(root / "scripts/package-release.sh"), - "linux", - "amd64", - "--variant", - "ui", - "--out-dir", - str(package_output), - ], - cwd=root, - env=package_environment, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - check=False, - ) - require( - packaged.returncode != 0 - and "UI pack digest does not match its filename" in packaged.stdout, - "release packager accepted a UI pack whose content did not match its filename", - ) - if failures: print("smoke fixture contract: FAIL", file=sys.stderr) for failure in failures: diff --git a/tests/test_ui.c b/tests/test_ui.c index ba22ee5e60..a403dbbbec 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -1,15 +1,14 @@ /* * test_ui.c — Tests for the graph visualization UI module. * - * Covers: config persistence, external asset-pack validation, layout engine. + * Covers: config persistence, embedded asset lookup, layout engine. */ #include "../src/foundation/compat.h" #include "../src/foundation/compat_fs.h" #include "test_framework.h" #include "test_helpers.h" -#include "foundation/sha256.h" -#include "ui/asset_pack.h" #include "ui/config.h" +#include "ui/embedded_assets.h" #include "ui/layout3d.h" #include "store/store.h" #ifdef _WIN32 @@ -125,9 +124,9 @@ TEST(config_save_atomically_replaces_a_complete_generation) { char old_bytes[512] = {0}; #ifdef _WIN32 DWORD old_length = 0; - bool old_read = - ReadFile(old_handle, old_bytes, (DWORD)sizeof(old_bytes) - 1U, &old_length, NULL) != 0 && - old_length > 0; + bool old_read = ReadFile(old_handle, old_bytes, (DWORD)sizeof(old_bytes) - 1U, &old_length, + NULL) != 0 && + old_length > 0; bool old_closed = CloseHandle(old_handle) != 0; #else size_t old_length = fread(old_bytes, 1, sizeof(old_bytes) - 1, old_handle); @@ -260,545 +259,18 @@ TEST(config_missing_fields) { PASS(); } -/* ── External UI asset-pack tests ────────────────────────────── */ +/* ── Embedded asset tests ─────────────────────────────────────── */ -enum { - TEST_UI_PACK_HEADER_BYTES = 80, - TEST_UI_PACK_ENTRY_BYTES = 24, -}; - -static void ui_test_write_u16(unsigned char *bytes, uint16_t value) { - bytes[0] = (unsigned char)value; - bytes[1] = (unsigned char)(value >> 8U); -} - -static void ui_test_write_u32(unsigned char *bytes, uint32_t value) { - for (size_t index = 0U; index < sizeof(value); index++) { - bytes[index] = (unsigned char)(value >> (index * 8U)); - } -} - -static void ui_test_write_u64(unsigned char *bytes, uint64_t value) { - for (size_t index = 0U; index < sizeof(value); index++) { - bytes[index] = (unsigned char)(value >> (index * 8U)); - } -} - -static unsigned char *ui_test_pack(size_t *length_out, bool malformed_index) { - static const char asset_path[] = "/assets/app.js"; - static const char index_path[] = "/index.html"; - static const unsigned char asset_data[] = "console.log(1);"; - static const unsigned char index_data[] = "

CBM

"; - size_t paths_length = sizeof(asset_path) - 1U + sizeof(index_path) - 1U; - size_t payload_length = sizeof(asset_data) - 1U + sizeof(index_data) - 1U; - size_t index_length = TEST_UI_PACK_ENTRY_BYTES * 2U; - size_t paths_offset = TEST_UI_PACK_HEADER_BYTES + index_length; - size_t payload_offset = paths_offset + paths_length; - size_t total = payload_offset + payload_length; - unsigned char *pack = calloc(total, 1U); - if (!pack) { - return NULL; - } - pack[0] = 'C'; - pack[1] = 'B'; - pack[2] = 'M'; - pack[3] = 'U'; - pack[4] = 'I'; - pack[5] = 'P'; - pack[6] = 'K'; - ui_test_write_u16(pack + 8U, 1U); - ui_test_write_u16(pack + 10U, TEST_UI_PACK_HEADER_BYTES); - ui_test_write_u32(pack + 16U, 2U); - ui_test_write_u32(pack + 20U, TEST_UI_PACK_ENTRY_BYTES); - ui_test_write_u64(pack + 24U, TEST_UI_PACK_HEADER_BYTES); - ui_test_write_u64(pack + 32U, index_length); - ui_test_write_u64(pack + 40U, paths_offset); - ui_test_write_u64(pack + 48U, paths_length); - ui_test_write_u64(pack + 56U, payload_offset); - ui_test_write_u64(pack + 64U, payload_length); - ui_test_write_u64(pack + 72U, total); - - unsigned char *asset_entry = pack + TEST_UI_PACK_HEADER_BYTES; - ui_test_write_u32(asset_entry, malformed_index ? 1U : 0U); - ui_test_write_u16(asset_entry + 4U, sizeof(asset_path) - 1U); - asset_entry[6U] = 2U; - asset_entry[7U] = CBM_UI_ASSET_IMMUTABLE; - ui_test_write_u64(asset_entry + 8U, 0U); - ui_test_write_u64(asset_entry + 16U, sizeof(asset_data) - 1U); - - unsigned char *index_entry = asset_entry + TEST_UI_PACK_ENTRY_BYTES; - ui_test_write_u32(index_entry, sizeof(asset_path) - 1U); - ui_test_write_u16(index_entry + 4U, sizeof(index_path) - 1U); - index_entry[6U] = 1U; - index_entry[7U] = CBM_UI_ASSET_REVALIDATE; - ui_test_write_u64(index_entry + 8U, sizeof(asset_data) - 1U); - ui_test_write_u64(index_entry + 16U, sizeof(index_data) - 1U); - - memcpy(pack + paths_offset, asset_path, sizeof(asset_path) - 1U); - memcpy(pack + paths_offset + sizeof(asset_path) - 1U, index_path, sizeof(index_path) - 1U); - memcpy(pack + payload_offset, asset_data, sizeof(asset_data) - 1U); - memcpy(pack + payload_offset + sizeof(asset_data) - 1U, index_data, sizeof(index_data) - 1U); - *length_out = total; - return pack; -} - -static bool ui_test_publish_pack(const char *directory, unsigned char *pack, size_t length, - char path_out[1024]) { - char hash[CBM_SHA256_HEX_LEN + 1U]; - cbm_sha256_hex(pack, length, hash); - char name[128]; - int name_length = snprintf(name, sizeof(name), "cbm-ui-%s.pack", hash); - int path_length = snprintf(path_out, 1024U, "%s/%s", directory, name); - if (name_length <= 0 || (size_t)name_length >= sizeof(name) || path_length <= 0 || - path_length >= 1024) { - return false; - } - FILE *file = fopen(path_out, "wb"); - bool written = file && fwrite(pack, 1U, length, file) == length; - if (file && fclose(file) != 0) { - written = false; - } - if (written) { - cbm_ui_assets_set_manifest_for_testing(name, hash, length); - } - return written; -} - -static void ui_test_restore_assets_env(char *old_value) { - if (old_value) { - (void)cbm_setenv("CBM_UI_ASSETS_DIR", old_value, 1); - free(old_value); - } else { - (void)cbm_unsetenv("CBM_UI_ASSETS_DIR"); - } -} - -TEST(ui_asset_pack_validates_before_publication) { - char directory[] = "/tmp/cbm_ui_pack_XXXXXX"; - ASSERT_NOT_NULL(cbm_mkdtemp(directory)); - size_t length = 0U; - unsigned char *pack = ui_test_pack(&length, false); - ASSERT_NOT_NULL(pack); - char pack_path[1024]; - cbm_ui_assets_reset_for_testing(); - ASSERT_TRUE(ui_test_publish_pack(directory, pack, length, pack_path)); - free(pack); - char *old_assets = getenv("CBM_UI_ASSETS_DIR") ? strdup(getenv("CBM_UI_ASSETS_DIR")) : NULL; - ASSERT_EQ(cbm_setenv("CBM_UI_ASSETS_DIR", directory, 1), 0); - - char error[512]; - ASSERT_TRUE(cbm_ui_assets_supported()); - ASSERT_EQ(cbm_ui_assets_state(), CBM_UI_ASSETS_COLD); - ASSERT_TRUE(cbm_ui_assets_warm(NULL, error, sizeof(error))); - ASSERT_EQ(cbm_ui_assets_state(), CBM_UI_ASSETS_READY); - const cbm_ui_asset_t *asset = cbm_ui_asset_lookup("/assets/app.js"); - ASSERT_NOT_NULL(asset); - ASSERT_EQ(asset->cache, CBM_UI_ASSET_IMMUTABLE); - ASSERT_STR_EQ(asset->content_type, "application/javascript; charset=utf-8"); - ASSERT_EQ(asset->size, sizeof("console.log(1);") - 1U); - ASSERT_NULL(cbm_ui_asset_lookup("/api/processes")); - - cbm_ui_assets_reset_for_testing(); - ui_test_restore_assets_env(old_assets); - (void)cbm_unlink(pack_path); - (void)cbm_rmdir(directory); - PASS(); -} - -TEST(ui_asset_pack_wrong_hash_fails_closed) { - char directory[] = "/tmp/cbm_ui_hash_XXXXXX"; - ASSERT_NOT_NULL(cbm_mkdtemp(directory)); - size_t length = 0U; - unsigned char *pack = ui_test_pack(&length, false); - ASSERT_NOT_NULL(pack); - char pack_path[1024]; - cbm_ui_assets_reset_for_testing(); - ASSERT_TRUE(ui_test_publish_pack(directory, pack, length, pack_path)); - pack[length - 1U] ^= 1U; - FILE *file = fopen(pack_path, "wb"); - ASSERT_NOT_NULL(file); - ASSERT_EQ(fwrite(pack, 1U, length, file), length); - ASSERT_EQ(fclose(file), 0); - free(pack); - char *old_assets = getenv("CBM_UI_ASSETS_DIR") ? strdup(getenv("CBM_UI_ASSETS_DIR")) : NULL; - ASSERT_EQ(cbm_setenv("CBM_UI_ASSETS_DIR", directory, 1), 0); - - char error[512]; - ASSERT_FALSE(cbm_ui_assets_warm(NULL, error, sizeof(error))); - ASSERT_EQ(cbm_ui_assets_state(), CBM_UI_ASSETS_FAILED); - ASSERT_NULL(cbm_ui_asset_lookup("/index.html")); - - cbm_ui_assets_reset_for_testing(); - ui_test_restore_assets_env(old_assets); - (void)cbm_unlink(pack_path); - (void)cbm_rmdir(directory); - PASS(); -} - -TEST(ui_asset_pack_structural_corruption_fails_after_valid_hash) { - char directory[] = "/tmp/cbm_ui_format_XXXXXX"; - ASSERT_NOT_NULL(cbm_mkdtemp(directory)); - size_t length = 0U; - unsigned char *pack = ui_test_pack(&length, true); - ASSERT_NOT_NULL(pack); - char pack_path[1024]; - cbm_ui_assets_reset_for_testing(); - ASSERT_TRUE(ui_test_publish_pack(directory, pack, length, pack_path)); - free(pack); - char *old_assets = getenv("CBM_UI_ASSETS_DIR") ? strdup(getenv("CBM_UI_ASSETS_DIR")) : NULL; - ASSERT_EQ(cbm_setenv("CBM_UI_ASSETS_DIR", directory, 1), 0); - - char error[512]; - ASSERT_FALSE(cbm_ui_assets_warm(NULL, error, sizeof(error))); - ASSERT_EQ(cbm_ui_assets_state(), CBM_UI_ASSETS_FAILED); - ASSERT_NULL(cbm_ui_asset_lookup("/index.html")); - - cbm_ui_assets_reset_for_testing(); - ui_test_restore_assets_env(old_assets); - (void)cbm_unlink(pack_path); - (void)cbm_rmdir(directory); - PASS(); -} - -/* A daemon can be asked to stop immediately after creating the warmup thread, - * before that worker enters cbm_ui_assets_warm(). The cancellation must latch - * across that scheduling window instead of being cleared by worker startup. */ -TEST(ui_asset_pack_prestart_cancellation_is_not_lost) { - char directory[] = "/tmp/cbm_ui_cancel_XXXXXX"; - ASSERT_NOT_NULL(cbm_mkdtemp(directory)); - size_t length = 0U; - unsigned char *pack = ui_test_pack(&length, false); - ASSERT_NOT_NULL(pack); - char pack_path[1024]; - cbm_ui_assets_reset_for_testing(); - ASSERT_TRUE(ui_test_publish_pack(directory, pack, length, pack_path)); - free(pack); - char *old_assets = getenv("CBM_UI_ASSETS_DIR") ? strdup(getenv("CBM_UI_ASSETS_DIR")) : NULL; - ASSERT_EQ(cbm_setenv("CBM_UI_ASSETS_DIR", directory, 1), 0); - - cbm_ui_assets_request_cancel(); - char error[512]; - bool warmed = cbm_ui_assets_warm(NULL, error, sizeof(error)); - cbm_ui_assets_state_t state = cbm_ui_assets_state(); - - cbm_ui_assets_reset_for_testing(); - ui_test_restore_assets_env(old_assets); - (void)cbm_unlink(pack_path); - (void)cbm_rmdir(directory); - - ASSERT_FALSE(warmed); - ASSERT_EQ(state, CBM_UI_ASSETS_CANCELLED); - PASS(); -} - -TEST(ui_asset_pack_install_never_follows_a_predictable_temp_symlink) { -#ifdef _WIN32 - SKIP_PLATFORM( - "Windows reparse-point creation requires privileges; installer contracts cover it"); -#else - static const char sentinel_content[] = "foreign sentinel must survive\n"; - char source_directory[] = "/tmp/cbm_ui_install_source_XXXXXX"; - char install_directory[] = "/tmp/cbm_ui_install_target_XXXXXX"; - ASSERT_NOT_NULL(cbm_mkdtemp(source_directory)); - ASSERT_NOT_NULL(cbm_mkdtemp(install_directory)); - - size_t pack_length = 0U; - unsigned char *pack = ui_test_pack(&pack_length, false); - ASSERT_NOT_NULL(pack); - char source_pack[1024]; - cbm_ui_assets_reset_for_testing(); - ASSERT_TRUE(ui_test_publish_pack(source_directory, pack, pack_length, source_pack)); - free(pack); - - const char *pack_name = strrchr(source_pack, '/'); - ASSERT_NOT_NULL(pack_name); - pack_name++; - char installed_pack[1024]; - char old_temporary[1024]; - char sentinel_path[1024]; - ASSERT_LT( - snprintf(installed_pack, sizeof(installed_pack), "%s/%s", install_directory, pack_name), - (int)sizeof(installed_pack)); - ASSERT_LT(snprintf(old_temporary, sizeof(old_temporary), "%s.tmp", installed_pack), - (int)sizeof(old_temporary)); - ASSERT_LT(snprintf(sentinel_path, sizeof(sentinel_path), "%s/foreign.txt", install_directory), - (int)sizeof(sentinel_path)); - ASSERT_EQ(th_write_file(sentinel_path, sentinel_content), 0); - ASSERT_EQ(symlink(sentinel_path, old_temporary), 0); - - char *old_assets = getenv("CBM_UI_ASSETS_DIR") ? strdup(getenv("CBM_UI_ASSETS_DIR")) : NULL; - ASSERT_EQ(cbm_setenv("CBM_UI_ASSETS_DIR", source_directory, 1), 0); - char error[512] = {0}; - bool installed = cbm_ui_assets_install(install_directory, false, error, sizeof(error)); - - char sentinel_after[sizeof(sentinel_content)] = {0}; - FILE *sentinel_file = cbm_fopen(sentinel_path, "rb"); - size_t sentinel_length = - sentinel_file ? fread(sentinel_after, 1U, sizeof(sentinel_after), sentinel_file) : 0U; - bool sentinel_closed = !sentinel_file || fclose(sentinel_file) == 0; - struct stat old_temp_info; - bool old_temp_is_still_symlink = - lstat(old_temporary, &old_temp_info) == 0 && S_ISLNK(old_temp_info.st_mode); - cbm_path_info_t installed_info; - bool installed_is_regular = cbm_path_info_utf8(installed_pack, &installed_info) == 0 && - installed_info.is_regular && !installed_info.is_symlink; - bool removed = cbm_ui_assets_remove(install_directory, false, error, sizeof(error)); - - cbm_ui_assets_reset_for_testing(); - ui_test_restore_assets_env(old_assets); - (void)cbm_unlink(old_temporary); - (void)cbm_unlink(installed_pack); - (void)cbm_unlink(sentinel_path); - (void)cbm_unlink(source_pack); - (void)cbm_rmdir(install_directory); - (void)cbm_rmdir(source_directory); - - ASSERT_TRUE(installed); - ASSERT_TRUE(sentinel_closed); - ASSERT_EQ(sentinel_length, sizeof(sentinel_content) - 1U); - ASSERT_MEM_EQ(sentinel_after, sentinel_content, sizeof(sentinel_content) - 1U); - ASSERT_TRUE(old_temp_is_still_symlink); - ASSERT_TRUE(installed_is_regular); - ASSERT_TRUE(removed); - PASS(); -#endif -} - -TEST(ui_asset_pack_install_remove_is_hash_bound_and_dry_run_safe) { - char source_directory[] = "/tmp/cbm_ui_lifecycle_source_XXXXXX"; - char install_directory[] = "/tmp/cbm_ui_lifecycle_target_XXXXXX"; - ASSERT_NOT_NULL(cbm_mkdtemp(source_directory)); - ASSERT_NOT_NULL(cbm_mkdtemp(install_directory)); - - size_t pack_length = 0U; - unsigned char *pack = ui_test_pack(&pack_length, false); - ASSERT_NOT_NULL(pack); - char source_pack[1024]; - cbm_ui_assets_reset_for_testing(); - ASSERT_TRUE(ui_test_publish_pack(source_directory, pack, pack_length, source_pack)); - const char *pack_name = strrchr(source_pack, '/'); - ASSERT_NOT_NULL(pack_name); - pack_name++; - char installed_pack[1024]; - ASSERT_LT( - snprintf(installed_pack, sizeof(installed_pack), "%s/%s", install_directory, pack_name), - (int)sizeof(installed_pack)); - - char *old_assets = getenv("CBM_UI_ASSETS_DIR") ? strdup(getenv("CBM_UI_ASSETS_DIR")) : NULL; - ASSERT_EQ(cbm_setenv("CBM_UI_ASSETS_DIR", source_directory, 1), 0); - char error[512] = {0}; - bool dry_install = cbm_ui_assets_install(install_directory, true, error, sizeof(error)); - cbm_path_info_t info; - bool absent_after_dry_install = cbm_path_info_utf8(installed_pack, &info) != 0; - bool installed = cbm_ui_assets_install(install_directory, false, error, sizeof(error)); - - unsigned char *installed_bytes = malloc(pack_length); - FILE *installed_file = cbm_fopen(installed_pack, "rb"); - size_t installed_length = installed_file && installed_bytes - ? fread(installed_bytes, 1U, pack_length, installed_file) - : 0U; - bool installed_closed = !installed_file || fclose(installed_file) == 0; - bool exact_copy = installed_bytes && installed_length == pack_length && - memcmp(installed_bytes, pack, pack_length) == 0; - free(installed_bytes); - - bool dry_remove = cbm_ui_assets_remove(install_directory, true, error, sizeof(error)); - bool present_after_dry_remove = cbm_path_info_utf8(installed_pack, &info) == 0; - bool removed = cbm_ui_assets_remove(install_directory, false, error, sizeof(error)); - bool absent_after_remove = cbm_path_info_utf8(installed_pack, &info) != 0; - - bool reinstalled = cbm_ui_assets_install(install_directory, false, error, sizeof(error)); - FILE *tampered_file = cbm_fopen(installed_pack, "r+b"); - bool tampered = tampered_file && fseek(tampered_file, -1L, SEEK_END) == 0; - int original_last_byte = tampered ? fgetc(tampered_file) : EOF; - tampered = tampered && original_last_byte != EOF && fseek(tampered_file, -1L, SEEK_END) == 0 && - fputc(original_last_byte ^ 1, tampered_file) != EOF; - if (tampered_file && fclose(tampered_file) != 0) { - tampered = false; - } - bool refused_tampered_remove = - !cbm_ui_assets_remove(install_directory, false, error, sizeof(error)); - bool tampered_preserved = cbm_path_info_utf8(installed_pack, &info) == 0; - bool preservation_explained = strstr(error, "not owned; preserved") != NULL; - - cbm_ui_assets_reset_for_testing(); - ui_test_restore_assets_env(old_assets); - (void)cbm_unlink(installed_pack); - (void)cbm_unlink(source_pack); - (void)cbm_rmdir(install_directory); - (void)cbm_rmdir(source_directory); - free(pack); - - ASSERT_TRUE(dry_install); - ASSERT_TRUE(absent_after_dry_install); - ASSERT_TRUE(installed); - ASSERT_TRUE(installed_closed); - ASSERT_TRUE(exact_copy); - ASSERT_TRUE(dry_remove); - ASSERT_TRUE(present_after_dry_remove); - ASSERT_TRUE(removed); - ASSERT_TRUE(absent_after_remove); - ASSERT_TRUE(reinstalled); - ASSERT_TRUE(tampered); - ASSERT_TRUE(refused_tampered_remove); - ASSERT_TRUE(tampered_preserved); - ASSERT_TRUE(preservation_explained); - PASS(); -} - -TEST(ui_asset_pack_staged_removal_is_owned_and_transactional) { - char source_directory[] = "/tmp/cbm_ui_staged_remove_source_XXXXXX"; - char install_directory[] = "/tmp/cbm_ui_staged_remove_target_XXXXXX"; - ASSERT_NOT_NULL(cbm_mkdtemp(source_directory)); - ASSERT_NOT_NULL(cbm_mkdtemp(install_directory)); - size_t pack_length = 0U; - unsigned char *pack = ui_test_pack(&pack_length, false); - ASSERT_NOT_NULL(pack); - char source_pack[1024]; - cbm_ui_assets_reset_for_testing(); - ASSERT_TRUE(ui_test_publish_pack(source_directory, pack, pack_length, source_pack)); - const char *pack_name = strrchr(source_pack, '/'); - ASSERT_NOT_NULL(pack_name); - pack_name++; - char installed_pack[1024]; - ASSERT_LT( - snprintf(installed_pack, sizeof(installed_pack), "%s/%s", install_directory, pack_name), - (int)sizeof(installed_pack)); - char *old_assets = getenv("CBM_UI_ASSETS_DIR") ? strdup(getenv("CBM_UI_ASSETS_DIR")) : NULL; - ASSERT_EQ(cbm_setenv("CBM_UI_ASSETS_DIR", source_directory, 1), 0); - char error[512] = {0}; - ASSERT_TRUE(cbm_ui_assets_install(install_directory, false, error, sizeof(error))); - - cbm_activation_transaction_t *removal = NULL; - bool foreign = false; - bool staged = - cbm_ui_assets_stage_remove(install_directory, &removal, &foreign, error, sizeof(error)); - ASSERT_TRUE(staged); - ASSERT_FALSE(foreign); - ASSERT_NOT_NULL(removal); - ASSERT_EQ(cbm_activation_transaction_commit(removal, NULL, NULL), - CBM_ACTIVATION_TRANSACTION_OK); - ASSERT_EQ(cbm_activation_transaction_finalize(removal), CBM_ACTIVATION_TRANSACTION_OK); - ASSERT_EQ(cbm_activation_transaction_close(&removal), CBM_ACTIVATION_TRANSACTION_OK); - cbm_path_info_t info; - bool exact_removed = cbm_path_info_utf8(installed_pack, &info) != 0; - - ASSERT_TRUE(cbm_ui_assets_install(install_directory, false, error, sizeof(error))); - FILE *tampered_file = cbm_fopen(installed_pack, "r+b"); - ASSERT_NOT_NULL(tampered_file); - ASSERT_EQ(fseek(tampered_file, -1L, SEEK_END), 0); - int last_byte = fgetc(tampered_file); - ASSERT_TRUE(last_byte != EOF); - ASSERT_EQ(fseek(tampered_file, -1L, SEEK_END), 0); - ASSERT_TRUE(fputc(last_byte ^ 1, tampered_file) != EOF); - ASSERT_EQ(fclose(tampered_file), 0); - removal = NULL; - foreign = false; - bool foreign_staged = - cbm_ui_assets_stage_remove(install_directory, &removal, &foreign, error, sizeof(error)); - bool foreign_preserved = cbm_path_info_utf8(installed_pack, &info) == 0; - - cbm_ui_assets_reset_for_testing(); - ui_test_restore_assets_env(old_assets); - (void)cbm_activation_transaction_close(&removal); - (void)cbm_unlink(installed_pack); - (void)cbm_unlink(source_pack); - (void)cbm_rmdir(install_directory); - (void)cbm_rmdir(source_directory); - free(pack); - - ASSERT_TRUE(exact_removed); - ASSERT_TRUE(foreign_staged); - ASSERT_TRUE(foreign); - ASSERT_NULL(removal); - ASSERT_TRUE(foreign_preserved); - PASS(); -} - -/* File identity alone does not detect a truncate/rewrite through an already - * open handle. Publication must validate the exact bytes at the public name, - * and removal must validate the exact object after moving it to the private - * retained name. Both mutations below preserve the inode on POSIX. */ -TEST(ui_asset_pack_commit_rejects_in_place_rewrite_after_stage) { - char source_directory[] = "/tmp/cbm_ui_commit_source_XXXXXX"; - char install_directory[] = "/tmp/cbm_ui_commit_target_XXXXXX"; - ASSERT_NOT_NULL(cbm_mkdtemp(source_directory)); - ASSERT_NOT_NULL(cbm_mkdtemp(install_directory)); - size_t pack_length = 0U; - unsigned char *pack = ui_test_pack(&pack_length, false); - ASSERT_NOT_NULL(pack); - char source_pack[1024]; - cbm_ui_assets_reset_for_testing(); - ASSERT_TRUE(ui_test_publish_pack(source_directory, pack, pack_length, source_pack)); - const char *pack_name = strrchr(source_pack, '/'); - ASSERT_NOT_NULL(pack_name); - pack_name++; - char installed_pack[1024]; - ASSERT_LT( - snprintf(installed_pack, sizeof(installed_pack), "%s/%s", install_directory, pack_name), - (int)sizeof(installed_pack)); - char *old_assets = getenv("CBM_UI_ASSETS_DIR") ? strdup(getenv("CBM_UI_ASSETS_DIR")) : NULL; - ASSERT_EQ(cbm_setenv("CBM_UI_ASSETS_DIR", source_directory, 1), 0); - char error[512] = {0}; - ASSERT_TRUE(cbm_ui_assets_install(install_directory, false, error, sizeof(error))); - ASSERT_TRUE(cbm_ui_assets_verify_file(installed_pack)); - - cbm_activation_transaction_t *publication = NULL; - ASSERT_TRUE( - cbm_ui_assets_stage_install(install_directory, &publication, error, sizeof(error))); - ASSERT_NOT_NULL(publication); - const char *staged = cbm_activation_transaction_staged_path(publication); - ASSERT_NOT_NULL(staged); - FILE *staged_file = cbm_fopen(staged, "r+b"); - ASSERT_NOT_NULL(staged_file); - ASSERT_EQ(fseek(staged_file, -1L, SEEK_END), 0); - int staged_last = fgetc(staged_file); - ASSERT_TRUE(staged_last != EOF); - ASSERT_EQ(fseek(staged_file, -1L, SEEK_END), 0); - ASSERT_TRUE(fputc(staged_last ^ 1, staged_file) != EOF); - ASSERT_EQ(fclose(staged_file), 0); - ASSERT_EQ(cbm_ui_assets_commit_install(publication), - CBM_ACTIVATION_TRANSACTION_VALIDATION_FAILED); - ASSERT_EQ(cbm_activation_transaction_close(&publication), CBM_ACTIVATION_TRANSACTION_OK); - ASSERT_TRUE(cbm_ui_assets_verify_file(installed_pack)); - - cbm_activation_transaction_t *removal = NULL; - bool foreign = false; - ASSERT_TRUE(cbm_ui_assets_stage_remove(install_directory, &removal, &foreign, error, - sizeof(error))); - ASSERT_FALSE(foreign); - ASSERT_NOT_NULL(removal); - FILE *target_file = cbm_fopen(installed_pack, "r+b"); - ASSERT_NOT_NULL(target_file); - ASSERT_EQ(fseek(target_file, -1L, SEEK_END), 0); - int target_last = fgetc(target_file); - ASSERT_TRUE(target_last != EOF); - ASSERT_EQ(fseek(target_file, -1L, SEEK_END), 0); - ASSERT_TRUE(fputc(target_last ^ 1, target_file) != EOF); - ASSERT_EQ(fclose(target_file), 0); - ASSERT_EQ(cbm_ui_assets_commit_removal(removal), - CBM_ACTIVATION_TRANSACTION_VALIDATION_FAILED); - ASSERT_EQ(cbm_activation_transaction_close(&removal), CBM_ACTIVATION_TRANSACTION_OK); - cbm_path_info_t preserved_info; - ASSERT_EQ(cbm_path_info_utf8(installed_pack, &preserved_info), 0); - ASSERT_TRUE(preserved_info.is_regular); - ASSERT_FALSE(cbm_ui_assets_verify_file(installed_pack)); - - cbm_ui_assets_reset_for_testing(); - ui_test_restore_assets_env(old_assets); - (void)cbm_unlink(installed_pack); - (void)cbm_unlink(source_pack); - (void)cbm_rmdir(install_directory); - (void)cbm_rmdir(source_directory); - free(pack); +TEST(embedded_lookup_not_found) { + /* With stub, everything should return NULL */ + const cbm_embedded_file_t *f = cbm_embedded_lookup("/nonexistent"); + ASSERT_NULL(f); PASS(); } -TEST(ui_asset_standard_manifest_has_no_capability) { - cbm_ui_assets_reset_for_testing(); - ASSERT_FALSE(cbm_ui_assets_supported()); - ASSERT_EQ(cbm_ui_assets_state(), CBM_UI_ASSETS_UNAVAILABLE); - ASSERT_NULL(cbm_ui_asset_lookup("/index.html")); +TEST(embedded_stub_count) { + /* Stub should have 0 files */ + ASSERT_EQ(CBM_EMBEDDED_FILE_COUNT, 0); PASS(); } @@ -1398,16 +870,9 @@ SUITE(ui) { RUN_TEST(config_corrupt_file); RUN_TEST(config_missing_fields); - /* External UI asset pack */ - RUN_TEST(ui_asset_pack_validates_before_publication); - RUN_TEST(ui_asset_pack_wrong_hash_fails_closed); - RUN_TEST(ui_asset_pack_structural_corruption_fails_after_valid_hash); - RUN_TEST(ui_asset_pack_prestart_cancellation_is_not_lost); - RUN_TEST(ui_asset_pack_install_never_follows_a_predictable_temp_symlink); - RUN_TEST(ui_asset_pack_install_remove_is_hash_bound_and_dry_run_safe); - RUN_TEST(ui_asset_pack_staged_removal_is_owned_and_transactional); - RUN_TEST(ui_asset_pack_commit_rejects_in_place_rewrite_after_stage); - RUN_TEST(ui_asset_standard_manifest_has_no_capability); + /* Embedded assets (stub) */ + RUN_TEST(embedded_lookup_not_found); + RUN_TEST(embedded_stub_count); /* Layout engine */ RUN_TEST(layout_empty_graph); diff --git a/tests/test_ui_asset_pack_contract.sh b/tests/test_ui_asset_pack_contract.sh deleted file mode 100644 index 0fdeb3153b..0000000000 --- a/tests/test_ui_asset_pack_contract.sh +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env bash -# Contract for the external UI asset pack. The pack deliberately stays -# uncompressed and independently scannable: this is an asset boundary, not an -# attempt to hide frontend bytes from security tooling. -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -WORK="$(mktemp -d "${TMPDIR:-/tmp}/cbm-ui-pack-test.XXXXXX")" -trap 'rm -rf "$WORK"' EXIT - -make_real_dir_link() { - local target="$1" link="$2" - if MSYS=winsymlinks:nativestrict ln -s -- "$target" "$link" 2>/dev/null && - [ -L "$link" ]; then - return 0 - fi - rm -rf -- "$link" - case "$(uname -s)" in - MINGW* | MSYS* | CYGWIN*) - cmd //c "mklink /J \"$(cygpath -w "$link")\" \"$(cygpath -w "$target")\"" \ - >/dev/null 2>&1 || true - ;; - esac - [ -L "$link" ] -} - -mkdir -p "$WORK/a/assets" "$WORK/b/assets" -printf '%s\n' 'Codebase Memory — Graph' > "$WORK/a/index.html" -printf '%s\n' 'console.log("transparent-ui-pack")' > "$WORK/a/assets/app.js" -printf '%s\n' 'body { color: #fff; }' > "$WORK/a/assets/app.css" - -# Copy in a deliberately different creation order. Filesystem enumeration, -# mtimes, and source-root names must not affect the output. -cp "$WORK/a/assets/app.css" "$WORK/b/assets/app.css" -cp "$WORK/a/index.html" "$WORK/b/index.html" -cp "$WORK/a/assets/app.js" "$WORK/b/assets/app.js" -touch -t 200101010101 "$WORK/a/index.html" -touch -t 203001010101 "$WORK/b/index.html" - -mkdir -p "$WORK/a-out" "$WORK/b-out" -node "$ROOT/scripts/pack-ui-assets.mjs" \ - "$WORK/a" "$WORK/a-out" "$WORK/a-manifest.c" -node "$ROOT/scripts/pack-ui-assets.mjs" \ - "$WORK/b" "$WORK/b-out" "$WORK/b-manifest.c" - -PACK_A="$(find "$WORK/a-out" -maxdepth 1 -type f -name 'cbm-ui-*.pack')" -PACK_B="$(find "$WORK/b-out" -maxdepth 1 -type f -name 'cbm-ui-*.pack')" -[ -n "$PACK_A" ] && [ -n "$PACK_B" ] -[ "$(basename "$PACK_A")" = "$(basename "$PACK_B")" ] -cmp "$PACK_A" "$PACK_B" -cmp "$WORK/a-manifest.c" "$WORK/b-manifest.c" - -python3 - "$PACK_A" "$WORK/a-manifest.c" <<'PY' -from __future__ import annotations - -import hashlib -import pathlib -import re -import struct -import sys - -pack_path = pathlib.Path(sys.argv[1]) -manifest_path = pathlib.Path(sys.argv[2]) -data = pack_path.read_bytes() -manifest = manifest_path.read_text(encoding="utf-8") - -( - magic, - version, - header_bytes, - flags, - count, - entry_bytes, - index_offset, - index_size, - paths_offset, - paths_size, - payload_offset, - payload_size, - total_size, -) = struct.unpack_from("<8sHHIIIQQQQQQQ", data, 0) -assert magic == b"CBMUIPK\0", "wrong pack magic" -assert version == 1 -assert header_bytes == 80 -assert flags == 0 -assert count == 3 -assert entry_bytes == 24 -assert index_offset == 80 -assert index_size == count * entry_bytes -assert paths_offset == index_offset + index_size -assert payload_offset == paths_offset + paths_size -assert payload_size == len(data) - payload_offset -assert total_size == len(data) - -cursor = index_offset -entries = [] -for _ in range(count): - path_offset, path_len, mime_id, cache_id, offset, length = struct.unpack_from( - "" in data - -digest = hashlib.sha256(data).hexdigest() -assert re.search(rf'CBM_UI_ASSET_SHA256\[\]\s*=\s*"{digest}"', manifest) -assert re.search(rf"CBM_UI_ASSET_SIZE\s*=\s*UINT64_C\({len(data)}\)", manifest) -assert re.search( - rf'CBM_UI_ASSET_PACK_NAME\[\]\s*=\s*"cbm-ui-{digest}\.pack"', manifest -) -assert pack_path.name == f"cbm-ui-{digest}.pack" -PY - -# A source symlink makes the build depend on bytes outside the declared dist -# tree. MSYS2's default `ln -s` silently deep-copies its target, so require a -# real native link and fall back to an NTFS junction before testing rejection. -mkdir "$WORK/outside-assets" -printf '%s\n' 'outside the declared UI root' > "$WORK/outside-assets/linked.html" -if ! make_real_dir_link "$WORK/outside-assets" "$WORK/a/assets/linked"; then - echo "FAIL: could not create a real symlink/junction fixture" >&2 - exit 1 -fi -if rejected_output="$(node "$ROOT/scripts/pack-ui-assets.mjs" \ - "$WORK/a" "$WORK/a-out" "$WORK/rejected.c" 2>&1)"; then - echo "FAIL: UI pack generator accepted a symlink" >&2 - exit 1 -fi -case "$rejected_output" in -*"symbolic links are not allowed"*) ;; -*) - echo "FAIL: UI pack generator rejected the symlink for the wrong reason" >&2 - printf '%s\n' "$rejected_output" >&2 - exit 1 - ;; -esac - -echo "PASS: deterministic, transparent, hash-bound UI asset pack" diff --git a/tests/test_venue_parity_contract.sh b/tests/test_venue_parity_contract.sh index d137c01a54..b609a1493b 100755 --- a/tests/test_venue_parity_contract.sh +++ b/tests/test_venue_parity_contract.sh @@ -343,6 +343,22 @@ for path in sorted(shell_surfaces): "under native Windows process lookup" ) +# ── Release archives are extracted from an ISOLATED directory ── +# The release/dry-run verify jobs used to download archives into the tracked +# checkout, so the extractor scanned a directory that also held repo files. +# Both now stage into $RUNNER_TEMP. This assertion lived in the archive-extractor +# contract, which was deleted with the UI-pack architecture it tested; the fix +# itself is unrelated to packs, so its guard moves here rather than vanishing. +for name in ("release.yml", "dry-run.yml"): + text = (workflows / name).read_text(encoding="utf-8") + if "scripts/ci/extract-release-archives.sh" not in text: + continue + if "$RUNNER_TEMP/release-archives" not in text: + failures.append( + f"{name}: release archives must be downloaded into " + "$RUNNER_TEMP/release-archives, never into the tracked checkout" + ) + if failures: print("VENUE PARITY CONTRACT VIOLATED — one harness, every venue:") for failure in failures: diff --git a/tests/test_vt_gate_zero_tolerance_contract.sh b/tests/test_vt_gate_policy_contract.sh similarity index 80% rename from tests/test_vt_gate_zero_tolerance_contract.sh rename to tests/test_vt_gate_policy_contract.sh index 33f3f11f8a..c8e09ca8f6 100644 --- a/tests/test_vt_gate_zero_tolerance_contract.sh +++ b/tests/test_vt_gate_policy_contract.sh @@ -1,7 +1,12 @@ #!/usr/bin/env bash -# Contract: the VT gate is an exact-set, content-bound, minimum-engine, -# zero-tolerance gate. Empty, partial, duplicate, unexpected or malformed -# action output fails before a release can be described as scanned. +# Contract: the VT gate is an exact-set, content-bound, minimum-engine gate. +# Empty, partial, duplicate, unexpected or malformed action output fails before +# a release can be described as scanned. +# +# Detection policy: EXACTLY ONE Microsoft machine-learning (`!ml`) verdict is +# tolerated and disclosed. Two or more engines, any non-`!ml` label, any +# non-Microsoft engine, any suspicious verdict and every infrastructure error +# still block. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -14,9 +19,12 @@ hash_file() { sha256sum "$1" 2>/dev/null | awk '{print $1}' || shasum -a 256 "$1" | awk '{print $1}' } -for needle in defender-endpoint-verification av-endpoint-verify false-positive false_positive; do +# Tripwire for the REVERTED endpoint-verification mechanism specifically. The +# current `!ml` tolerance is a policy branch inside this gate, not a callout to +# an external verification service, and must never become one. +for needle in defender-endpoint-verification av-endpoint-verify; do if grep -q "$needle" "$GATE"; then - fail "gate references reverted/tolerance machinery '$needle'" + fail "gate references reverted endpoint-verification machinery '$needle'" fi done @@ -117,6 +125,43 @@ cat > "$FIX/responses/named-engine.json" < "$FIX/responses/ms-signature.json" < "$FIX/responses/other-engine.json" < "$FIX/responses/two-engines.json" < "$FIX/responses/malformed-stats.json" < "$FIX/work/binaries/objects/probe" [ "$(run_gate "$clean_output")" != "0" ] || fail "tampered staged object must block before polling" grep -q 'changed after extraction' "$FIX/last.log" || fail "tamper failure is not diagnosable" -echo 'PASS: VT gate is exact-set, content-bound, >=50-engine and zero tolerance' +echo 'PASS: VT gate is exact-set, content-bound, >=50-engine; tolerates exactly one Microsoft !ml verdict and nothing else' diff --git a/tests/test_vt_release_notes_contract.sh b/tests/test_vt_release_notes_contract.sh index 6427ce48bf..bdca619eed 100644 --- a/tests/test_vt_release_notes_contract.sh +++ b/tests/test_vt_release_notes_contract.sh @@ -17,14 +17,12 @@ cat > "$FIX/binaries/associations.tsv" < "$FIX/binaries/vt-results.tsv" < "$FIX/binaries/vt-results.tsv" < "$FIX/current.md" <<'EOF' Intro text. @@ -100,7 +98,7 @@ grep -q 'Intro text.' "$FIX/first.md" || fail "content before marked section was grep -q 'Outro text.' "$FIX/first.md" || fail "content after marked section was lost" ! grep -q 'stale data' "$FIX/first.md" || fail "stale marked section was appended instead of replaced" grep -q '2 distinct extracted byte objects' "$FIX/first.md" || fail "unique extracted-object scope missing" -grep -q '2 exact extracted-file associations' "$FIX/first.md" || fail "extracted-file association scope missing" +grep -q '2 exact extracted archive members' "$FIX/first.md" || fail "extracted member scope missing" grep -q '1 downloadable archive' "$FIX/first.md" || fail "archive provenance scope missing" grep -q '59–61 decisive engine results' "$FIX/first.md" || fail "measured engine range missing" grep -q 'Microsoft returned a decisive clean verdict for all 1 executable objects' "$FIX/first.md" || \ @@ -120,7 +118,10 @@ done ! grep -q '70+' "$FIX/first.md" || fail "unmeasured engine claim returned" run_notes "$FIX/first.md" "$FIX/second.md" -cmp -s "$FIX/first.md" "$FIX/second.md" || fail "marked release-note update is not idempotent" +# python3 rather than cmp/diff: this contract runs on the Windows VM's MSYS2 +# shell too, where neither is guaranteed to be on PATH (cmp is not). +python3 -c 'import sys;sys.exit(0 if open(sys.argv[1],"rb").read()==open(sys.argv[2],"rb").read() else 1)' \ + "$FIX/first.md" "$FIX/second.md" || fail "marked release-note update is not idempotent" cp "$FIX/binaries/vt-results.tsv" "$FIX/binaries/vt-results.clean.tsv" printf '\textra-cell\n' >> "$FIX/binaries/vt-results.tsv" @@ -167,7 +168,7 @@ cat > "$FIX/binaries/scan-set.tsv" < list[str]: binary = "codebase-memory-mcp.exe" payload = "codebase-memory-mcp.payload.exe" -# cbm-integrations.json: the integration templates the binary verifies by its -# embedded SHA-256 before installing anything. install.ps1 runs `install` from -# the extract dir, so the file must sit NEXT TO the .exe or every install -# fails closed with "integration assets missing or modified". -windows_archive_names = ( - binary, "cbm-integrations.json", "LICENSE", "install.ps1", "THIRD_PARTY_NOTICES.md", -) -ui_pack_pattern = r"cbm-ui-[0-9a-f]{64}\.pack" +windows_archive_names = (binary, "LICENSE", "install.ps1", "THIRD_PARTY_NOTICES.md") -# ── 1. Standard is five files; UI adds exactly one root pack ──────────────── +# ── 1. The archive is exactly four files, defined in ONE place ─────────────── # Every venue (release build, local artifact-flow smoke) produces archives # through scripts/package-release.sh, so the layout is asserted where it is # defined and cannot fork per venue. package_release = read("scripts/package-release.sh") -member_blocks = re.findall( - r"ARCHIVE_MEMBERS=\(\s*(?P.*?)\s*\)", package_release, re.DOTALL -) -normalized_member_blocks = [block.split() for block in member_blocks] -require( - list(windows_archive_names) in normalized_member_blocks, - "package-release.sh must define the exact five-member Windows standard archive", +zip_call = re.search( + r"zip -q \"\$OUT_DIR/\$NAME\.zip\" \\\n(?P(?:.|\n)*?)\n", package_release ) +require(zip_call is not None, "package-release.sh must build the Windows zip in one zip call") +zip_members = zip_call.group("members").split() if zip_call else [] require( - package_release.count('ARCHIVE_MEMBERS+=("$UI_PACK_NAME")') == 2 - and "^cbm-ui-[0-9a-f]{64}\\.pack$" in package_release, - "package-release.sh must append exactly one hash-shaped root pack only to UI archives", + zip_members == list(windows_archive_names), + "package-release.sh must archive EXACTLY " + f"{' '.join(windows_archive_names)} (found: {' '.join(zip_members) or 'nothing'})", ) # ── 2. No shipped surface may name the payload or build a launcher ─────────── @@ -124,8 +116,8 @@ for relative in shipped_surfaces: for marker in launcher_markers: require( marker not in source, - f"{relative} must not reference '{marker}': Windows ships one executable and the " - "retired launcher/payload split must not return", + f"{relative} must not reference '{marker}': Windows ships ONE binary and the " + "launcher stub is what Defender flagged as Trojan:Win32/Wacatac.B!ml", ) # The stub sources themselves must stay deleted, and the build must not carry a @@ -147,27 +139,25 @@ require( "Makefile.cbm must not expose a Windows launcher target or variable", ) -# The single shipped Windows runtime is UI-enabled and is produced through the -# ONE canonical packaging entry, so the six-file layout above governs it. +# Every archive is produced through the ONE canonical packaging entry, so the +# four-file layout above governs all of them. A reappearing --variant flag would +# mean the retired two-composition split is back. build_workflow = read(".github/workflows/_build.yml") for archive, call in ( - ("codebase-memory-mcp-ui-windows-amd64.zip", - "scripts/package-release.sh windows amd64 --variant ui"), - ("codebase-memory-mcp-ui-windows-arm64.zip", - "scripts/package-release.sh windows arm64 --variant ui"), + ("codebase-memory-mcp-windows-amd64.zip", "scripts/package-release.sh windows amd64"), + ("codebase-memory-mcp-windows-arm64.zip", "scripts/package-release.sh windows arm64"), ): require( - build_workflow.count(call) == 1, + re.search(re.escape(call) + r"(?!\s+--variant)", build_workflow) is not None, f"_build.yml must produce {archive} via the canonical packaging entry ('{call}')", ) -require( - re.search(r"scripts/package-release\.sh windows (?:amd64|arm64)(?!\s+--variant)", build_workflow) is None - and "ui_only" not in build_workflow - and "Build standard binary" not in build_workflow, - "_build.yml must not retain a standard/non-UI Windows release path", -) -# ── 3. install.ps1 delegates every runtime mutation to the candidate ───────── +# ── 3. install.ps1 retires the running binary before publishing ────────────── +# This is THE step that replaces the launcher. Windows keeps an image lock on a +# running .exe: it cannot be overwritten, but it CAN be renamed out of the way. +# install.ps1 runs while CBM is not running, renames the installed binary to +# ".retired-", then installs over the freed name. Without the +# retire step every Windows update fails on a locked destination. installer = read("install.ps1") # Windows PowerShell 5.1 decodes a BOM-less .ps1 as ANSI, so a UTF-8 em-dash # arrives as three cp1252 characters ending in a double quote. Inside a string @@ -182,76 +172,43 @@ require( + ", ".join(f"U+{ord(ch):04X}" for ch in non_ascii) + ")", ) -publish_index = installer.find("& $DownloadedBinary @InstallArgs") require( - publish_index >= 0, - "install.ps1 must invoke the downloaded candidate's native install command", -) -require( - "& $DownloadedBinary --version" in installer - and "& $DownloadedBinary @InstallArgs" in installer, - "install.ps1 must verify and install through the downloaded binary", + "$Dest = Join-Path $InstallDir $BinName" in installer, + "install.ps1 must resolve the canonical install destination as $Dest", ) -require( - "Move-Item -LiteralPath $Dest" not in installer - and ".retired-" not in installer - and "& $Dest daemon stop" not in installer, - "install.ps1 must not retire, delete, or stop the installed executable before candidate install", +retire_match = re.search( + r"if \(Test-Path -LiteralPath \$Dest -PathType Leaf\) \{(?P(?:.|\n)*?)\n\}\n", + installer, ) +retire = retire_match.group("body") if retire_match else "" require( - "Publish-CbmSidecarAtomically" not in installer - and "$AssetDest" not in installer - and "$packDestination" not in installer - and 'Get-ChildItem -LiteralPath $InstallDir -Filter "cbm-ui-*.pack"' not in installer, - "install.ps1 must not publish or garbage-collect runtime sidecars outside the native guard", -) - -# The download workspace is recursively deleted on every exit path, so the -# installer must create it itself and must never adopt an existing name. Bind -# the reservation to this directory specifically; the updater-file publisher -# below has a separate exclusive-sibling helper. -temp_dir_factory = re.search( - r"function\s+New-CbmExclusiveTempDirectory\s*\{(?P.*?)\n\}\s*\n\s*# Detect variant", - installer, - re.DOTALL, + retire_match is not None + and re.search(r"\$retired\s*=\s*\"\$Dest\.retired-", retire) is not None + and "Move-Item -LiteralPath $Dest -Destination $retired" in retire, + "install.ps1 must RETIRE the running binary (Move-Item $Dest -> $retired) before " + "publishing the new one — a running .exe cannot be overwritten in place", ) -temp_dir_body = temp_dir_factory.group("body") if temp_dir_factory else "" require( - temp_dir_factory is not None - and '[guid]::NewGuid().ToString("N")' in temp_dir_body - and "for ($attempt = 0; $attempt -lt 32; $attempt++)" in temp_dir_body - and "New-Item -ItemType Directory -Path $candidate -ErrorAction Stop" in temp_dir_body - and "catch [System.IO.IOException]" in temp_dir_body - and re.search(r"New-Item[^\n]*-Force", temp_dir_body) is None - and "Remove-Item" not in temp_dir_body - and "Test-Path" not in temp_dir_body, - "install.ps1 must reserve a high-entropy temporary directory exclusively with bounded " - "collision retries, never adopt or pre-clean an existing path", + retire_match is not None and "$renamed" in retire and "exit 1" in retire, + "install.ps1 must fail loudly when the running binary cannot be retired", ) +publish_index = installer.find("& $DownloadedBinary @InstallArgs") require( - "$TmpDir = New-CbmExclusiveTempDirectory -ParentDirectory " - "([System.IO.Path]::GetTempPath())" in installer - and '"cbm-install-$(Get-Random)"' not in installer - and "New-Item -ItemType Directory -Path $TmpDir -Force" not in installer, - "install.ps1 must clean up only the temporary directory it successfully reserved", + publish_index > (retire_match.start() if retire_match else -1) >= 0, + "install.ps1 must retire BEFORE it publishes the downloaded binary", ) - -# Bind validation to the opened handle, including its exact 64-bit size. A -# raced replacement with a valid prefix plus trailing data must be rejected. -asset_pack = read("src/ui/asset_pack.c") require( - "handle_info.nFileSizeHigh" in asset_pack - and "handle_info.nFileSizeLow" in asset_pack - and re.search(r"handle_size\s*==\s*expected", asset_pack) is not None, - "Windows UI pack validation must compare the opened handle's 64-bit size to the manifest", + "& $DownloadedBinary --version" in installer + and "& $DownloadedBinary @InstallArgs" in installer, + "install.ps1 must verify and install through the downloaded binary", ) # ── 4. Package-manager shims resolve the single Windows binary ─────────────── single_binary_contracts = { "pkg/npm/install.js": ( r"const\s+WINDOWS_BINARY_NAME\s*=\s*['\"]codebase-memory-mcp\.exe['\"]", - r"publishRuntimeSetWithRecovery\(", - r"runtimeSetReady\(", + r"installWindowsBinaryAtomically\(", + r"windowsBinaryReady\(", ), "pkg/npm/bin.js": ( r"binName\s*=\s*isWindows\s*\?\s*['\"]codebase-memory-mcp\.exe['\"]", @@ -260,7 +217,6 @@ single_binary_contracts = { "pkg/pypi/src/codebase_memory_mcp/_cli.py": ( r"_WINDOWS_BINARY_NAME\s*=\s*['\"]codebase-memory-mcp\.exe['\"]", r"def\s+_runtime_set_ready\(", - r"def\s+_publish_runtime_set\(", ), } for relative, patterns in single_binary_contracts.items(): @@ -274,21 +230,18 @@ for relative, patterns in single_binary_contracts.items(): f"{relative} must remain portable and not own managed launcher state", ) -# All package downloaders parse the Windows archive against an exact official -# root allowlist; direct install.ps1 additionally distinguishes standard from -# UI by the single hash-shaped pack. +# All package downloaders parse the Windows archive against the exact official +# four-root-file allowlist; they may not silently ignore an attacker-controlled +# fifth member. exact_archive_guards = { "install.ps1": ( - "$seen.Count -ne $expectedArchiveCount", - "$uiPackCount -ne $expectedUiPackCount", - "$UiPackPattern", + "$seen.Count -ne $WindowsArchiveNames.Count", '"LICENSE"', '"install.ps1"', "THIRD_PARTY_NOTICES.md", ), "pkg/npm/install.js": ( - "seen.size !== expectedCount", - "UI_PACK_PATTERN", + "$seen.Count -ne $requiredNames.Count", "WINDOWS_BINARY_NAME", "'LICENSE'", "'install.ps1'", @@ -296,8 +249,7 @@ exact_archive_guards = { ), "pkg/pypi/src/codebase_memory_mcp/_cli.py": ( "name not in required_set", - "len(seen) != expected_count", - "_UI_PACK_RE", + "len(seen) != len(required)", "_WINDOWS_BINARY_NAME", '"LICENSE"', '"install.ps1"', @@ -308,7 +260,7 @@ for relative, needles in exact_archive_guards.items(): source = read(relative) require( all(needle in source for needle in needles), - f"{relative} must reject every Windows zip namespace except its official exact " + f"{relative} must reject every Windows zip namespace except the official four-file " "allowlist", ) @@ -593,7 +545,7 @@ require( "cmd.exe", ) -# ── 8. Release smoke stays profile-rooted and one-executable ───────────────── +# ── 8. Release smoke stays profile-rooted and single-binary ────────────────── smoke_workflow = read(".github/workflows/_smoke.yml") windows_match = re.search( r"(?ms)^ smoke-windows:\s*(.*?)(?=^ [A-Za-z0-9_-]+:\s*$|\Z)", smoke_workflow @@ -644,7 +596,7 @@ require( '"$LAUNCH_DIR/codebase-memory-mcp.exe" --version', ) ), - "Windows release version checks must execute the runtime set's one executable beneath the current " + "Windows release version checks must execute the single binary beneath the current " "account profile", ) # RUNNER_TEMP is legitimate ONLY for artifact provisioning/scanning; every @@ -681,7 +633,7 @@ require( 'scripts/security-install.sh "$SECURITY_DIR/codebase-memory-mcp.exe"', ) ), - "Windows release install audit must execute the runtime set's one executable beneath the current " + "Windows release install audit must execute the single binary beneath the current " "account profile", ) @@ -689,11 +641,8 @@ require( smoke_script = read("scripts/smoke-test.sh") require( 'copy_smoke_binary "$FAKE_HOME/.local/bin/codebase-memory-mcp.exe"' not in smoke_script - and 'copy_smoke_binary "$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe"' in smoke_script - and 'cp "$SMOKE_UI_PACK" "$(dirname "$destination")/$(basename "$SMOKE_UI_PACK")"' - in smoke_script, - "Windows smoke must leave the authenticated-install target absent while staging the complete " - "binary-plus-pack runtime set for the explicit update fixture", + and 'copy_smoke_binary "$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe"' not in smoke_script, + "Windows smoke must leave canonical targets absent for an authenticated install", ) require( "smoke_mktemp_file" in smoke_script @@ -722,7 +671,7 @@ require( 'HOME="$WIN_HOME" TEMP="$WIN_HOME" TMP="$WIN_HOME"' in smoke_script and "MSYS2_ARG_CONV_EXCL='*'" in smoke_script and "powershell.exe -NoProfile -ExecutionPolicy Bypass -File" in smoke_script - and '"$WIN_SCRIPT" "$DL_VARIANT_ARG" "--dir=$WIN_DIR"' in smoke_script + and '"$WIN_SCRIPT" "--dir=$WIN_DIR"' in smoke_script and "& $args[1]" not in smoke_script, "Windows install.ps1 smoke must pass native HOME/TEMP/TMP and execute the script directly", ) @@ -850,10 +799,10 @@ if pr_windows_blocks: ) if failures: - print("Windows one-executable runtime-set contract FAILED:", file=sys.stderr) + print("Windows single-binary bundle contract FAILED:", file=sys.stderr) for failure in failures: print(f" - {failure}", file=sys.stderr) raise SystemExit(1) -print("Windows one-executable runtime-set contract passed") +print("Windows single-binary bundle contract passed") PY diff --git a/tests/windows/test_non_ascii_path.py b/tests/windows/test_non_ascii_path.py index e82ea9d643..b0ef06498c 100644 --- a/tests/windows/test_non_ascii_path.py +++ b/tests/windows/test_non_ascii_path.py @@ -94,10 +94,10 @@ def run_product(argv, cwd, env, timeout=120): def verify_relocated_runtime(binary, work): """Probe relocation, then install/probe/uninstall beyond legacy MAX_PATH. - This binds Windows self-discovery to GetModuleFileNameW: both the hidden - probe and a process launched from an extended-length path must find the - adjacent integration/UI assets without an ANSI-code-page fallback or an - environment override. The real install sequence also binds target + This binds Windows self-discovery to GetModuleFileNameW: a process launched + from an extended-length, non-ASCII path must resolve its own location + without an ANSI-code-page fallback. The binary is self-contained, so the + whole runtime is one file. The real install sequence also binds target existence checks: after a secure first install, the target is rewritten in place and a non-force `--no` install must preserve those exact bytes. """ @@ -107,18 +107,6 @@ def verify_relocated_runtime(binary, work): relocated = os.path.join(runtime_dir, os.path.basename(binary)) shutil.copy2(binary, relocated) - integration = os.path.join(source_dir, "cbm-integrations.json") - if not os.path.isfile(integration): - return "setup: source runtime is missing cbm-integrations.json" - shutil.copy2(integration, os.path.join(runtime_dir, "cbm-integrations.json")) - packs = [] - for name in os.listdir(source_dir): - if name.startswith("cbm-ui-") and name.endswith(".pack"): - source = os.path.join(source_dir, name) - if os.path.isfile(source): - shutil.copy2(source, os.path.join(runtime_dir, name)) - packs.append(name) - env = os.environ.copy() env.pop("CBM_ASSETS_DIR", None) env.pop("CBM_UI_ASSETS_DIR", None) @@ -156,10 +144,10 @@ def fail(message): stop_runtime_daemon() return message - probe = run_product([relocated, "--verify-runtime-assets"], runtime_dir, env) + probe = run_product([relocated, "--version"], runtime_dir, env) if probe.returncode != 0: - return fail("runtime probe rc=%d output=%r packs=%r" % ( - probe.returncode, probe.stdout[-800:], packs)) + return fail("runtime probe rc=%d output=%r" % ( + probe.returncode, probe.stdout[-800:])) # Keep every component below the per-component limit while making the # complete UTF-16 path decisively longer than legacy MAX_PATH. The product, @@ -191,15 +179,9 @@ def fail(message): installed_extended = windows_extended_path(installed) if not os.path.isfile(installed_extended): return fail("long-path install did not publish %r" % installed) - integration_installed = os.path.join(target, "cbm-integrations.json") - if not os.path.isfile(windows_extended_path(integration_installed)): - return fail("long-path install omitted cbm-integrations.json") - for name in packs: - if not os.path.isfile(windows_extended_path(os.path.join(target, name))): - return fail("long-path install omitted UI pack %s" % name) installed_probe = run_product( - [installed_extended, "--verify-runtime-assets"], runtime_dir, env) + [installed_extended, "--version"], runtime_dir, env) if installed_probe.returncode != 0: return fail("installed long-path probe rc=%d output=%r" % ( installed_probe.returncode, installed_probe.stdout[-1200:])) @@ -235,7 +217,7 @@ def fail(message): return fail("long-path restore install rc=%d output=%r" % ( restore.returncode, restore.stdout[-1200:])) restored_probe = run_product( - [installed_extended, "--verify-runtime-assets"], runtime_dir, env) + [installed_extended, "--version"], runtime_dir, env) if restored_probe.returncode != 0: return fail("restored long-path probe rc=%d output=%r" % ( restored_probe.returncode, restored_probe.stdout[-1200:])) @@ -252,11 +234,6 @@ def fail(message): uninstall.returncode, uninstall.stdout[-1200:])) if os.path.exists(installed_extended): return fail("long-path uninstall retained the installed executable") - if os.path.exists(windows_extended_path(integration_installed)): - return fail("long-path uninstall retained owned integration assets") - for name in packs: - if os.path.exists(windows_extended_path(os.path.join(target, name))): - return fail("long-path uninstall retained owned UI pack %s" % name) stop_runtime_daemon() return None @@ -275,14 +252,38 @@ def index_and_count(binary, repo, cache): with McpServer(binary, cache_dir=cache) as s: s.initialize() resp = s.call_tool("index_repository", {"repo_path": repo}, timeout=180) - _, err = s.tool_text(resp) + index_txt, err = s.tool_text(resp) if err: return {"error": "index tools/call error: %r" % err} lp = s.call_tool("list_projects", {}, timeout=60) lp_txt, _ = s.tool_text(lp) projects = json.loads(lp_txt).get("projects") or [] if not projects: - return {"error": "no project listed after index"} + # The count summary cannot explain a venue-specific empty listing; + # carry the index response and the cache contents into the log. + try: + cache_entries = sorted(os.listdir(cache)) + except OSError as exc: + cache_entries = ["" % exc] + # The supervisor's worker logs carry the actual pipeline error. + log_tails = [] + logs_dir = os.path.join(cache, "logs") + if os.path.isdir(logs_dir): + for log_name in sorted(os.listdir(logs_dir)): + try: + with open(os.path.join(logs_dir, log_name), "rb") as lf: + tail = lf.read()[-800:].decode("utf-8", "replace") + log_tails.append("%s: %s" % (log_name, tail)) + except OSError as exc: + log_tails.append("%s: " % (log_name, exc)) + try: + repo_entries = sorted(os.listdir(repo)) + except OSError as exc: + repo_entries = ["" % exc] + return {"error": "no project listed after index; index said %r; " + "cache holds %r; repo holds %r; worker logs: %s" + % (index_txt[:400], cache_entries, repo_entries, + " | ".join(log_tails) or "")} p = projects[0] out = {"name": p.get("name"), "nodes": p.get("nodes"), "edges": p.get("edges")} @@ -355,6 +356,54 @@ def main(): got.get("definition_nodes"), base["nodes"], base["edges"], base["definition_nodes"], got.get("name"))) if not ok: + # The counts alone cannot explain a venue-specific failure; + # surface the captured error verbatim so CI logs carry the + # diagnosis instead of swallowing it. + if got.get("error"): + print(" %s error: %s" % (key, got["error"])) + # Discriminate order-dependence from path-dependence: the same + # repo, fresh cache, immediately again. A passing retry means + # the previous case's daemon interfered; a failing retry means + # the path itself is the trigger. + retry = index_and_count(binary, repo, + os.path.join(work, "c2_" + key)) + print(" %s retry: nodes=%s error=%s" % ( + key, retry.get("nodes"), retry.get("error", "")[:200] or None)) + if (not retry.get("error") + and retry.get("nodes") == base["nodes"] + and retry.get("edges") == base["edges"] + and retry.get("definition_nodes") == base["definition_nodes"]): + print(" %s retry matched baseline -- order-dependent, " + "not path-dependent" % key) + else: + # The MCP result hides the pipeline's own diagnostics; the + # CLI entrypoint prints them. Same repo, third fresh cache. + cli_cache = os.path.join(work, "c3_" + key) + cli_env = os.environ.copy() + cli_env["CBM_CACHE_DIR"] = cli_cache + # CBM_PROFILE keeps the supervisor from unlinking the worker + # log on a clean exit (index.supervisor.profile_log), which + # is the only record of the pipeline's own diagnostics. + cli_env["CBM_PROFILE"] = "1" + cli = subprocess.run( + [binary, "cli", "index_repository", + json.dumps({"repo_path": repo})], + capture_output=True, timeout=180, env=cli_env) + cli_out = (cli.stdout or b"").decode("utf-8", "replace") + cli_err = (cli.stderr or b"").decode("utf-8", "replace") + print(" %s cli probe rc=%s\n stdout: %s\n" + " stderr: %s" % (key, cli.returncode, + cli_out[-900:], cli_err[-900:])) + probe_logs = os.path.join(cli_cache, "logs") + if os.path.isdir(probe_logs): + for log_name in sorted(os.listdir(probe_logs)): + try: + with open(os.path.join(probe_logs, log_name), "rb") as lf: + tail = lf.read()[-1500:].decode("utf-8", "replace") + except OSError as exc: + tail = "" % exc + print(" %s worker log %s:\n%s" + % (key, log_name, tail)) failures.append(key) finally: shutil.rmtree(work, ignore_errors=True)