revert(release): re-embed runtime assets into the single shipped binary
Externalizing the integration templates (#1492/#1493) and the UI bundle
(#1501/#1503) was done to reduce the Microsoft `Wacatac.B!ml` surface. It did
not work: across dry runs the flagged artifact count stayed at ~3 and the
detections merely moved between artifacts.
Dissection of run 31286803592 shows there is no structural cause to fix. The
verdicts split across every axis at once — linux-amd64 (dynamic) flagged while
linux-amd64-portable (static) is clean, but linux-arm64 (dynamic) clean while
linux-arm64-portable (static) is flagged. The two macOS binaries have identical
segment structure and split clean/flagged. Siblings from one build landed in
different variant buckets (.B vs .C). Entropy is low everywhere
(code_vectors.bin 4.166, grammar tables 3.464 bits/byte, against 7.5-8.0 for
packed payloads), so the packed-payload hypothesis is excluded too.
So the complexity bought nothing, and installation goes back to being
self-contained: one binary that carries its own UI and agent integration
templates, with no adjacent data file that has to resolve before `install`
works. Only the UI-capable composition ships from now on, under the historical
unsuffixed archive name.
Removed: src/ui/asset_pack.{c,h}, asset_pack_stub.c, asset_manifest_stub.c,
scripts/pack-ui-assets.mjs, src/cli/integration_assets.{c,h},
assets/cbm-integrations.json, scripts/gen-integrations-hash.sh, the
--verify-runtime-assets probe (nothing adjacent left to verify), and the
composition gates A6/A7 whose property is now deliberately inverted.
Restored: scripts/embed-frontend.sh, src/ui/embedded_{assets.h,stub.c}, the
compiled-in hook/adapter template bodies, and the embed/EMBED_OBJS build path.
Kept from the reverted commits, re-applied by hand where a wholesale file
restore would have dropped them:
- cbm_module_path_utf8() in both self-path sites. GetModuleFileNameA renders
through the ANSI code page and mangles non-ASCII install paths.
- the /__cbm/ui-readiness HMAC proof, secure_random and cbm_hmac_sha256, so
`daemon start --open` still waits for a genuine CBM listener.
- X-Content-Type-Options: nosniff on served assets.
- the MinGW noexecstack gate, -lbcrypt, and the cppcheck/zip CI fixes.
Archives are now codebase-memory-mcp-<os>-<arch>[-portable] with exactly four
members (binary, LICENSE, installer, THIRD_PARTY_NOTICES.md). That restores the
names every static package manifest already points at — aur, chocolatey,
homebrew, scoop, winget and glama were all broken by the -ui- rename.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
+23
-53
@@ -358,19 +358,17 @@ GIT_SRCS = src/git/git_context.c
|
||||
|
||||
# CLI module (new)
|
||||
CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c src/cli/client_adapter.c \
|
||||
src/cli/agent_clients.c src/cli/agent_profiles.c src/cli/integration_assets.c \
|
||||
src/cli/agent_clients.c src/cli/agent_profiles.c \
|
||||
src/cli/config_json_like.c src/cli/config_toml_edit.c src/cli/config_yaml_edit.c \
|
||||
src/cli/config_text_edit.c src/cli/activation_transaction.c
|
||||
|
||||
# UI module (graph visualization). Production-standard builds deliberately link
|
||||
# only the no-I/O pack stub. Test binaries substitute the real parser plus the
|
||||
# empty manifest so parser/install behavior remains covered without a UI pack.
|
||||
# UI module (graph visualization)
|
||||
UI_SRCS = \
|
||||
src/ui/config.c \
|
||||
src/ui/http_server.c \
|
||||
src/ui/layout3d.c \
|
||||
src/ui/httpd.c \
|
||||
src/ui/asset_pack_stub.c
|
||||
src/ui/embedded_stub.c
|
||||
|
||||
# mimalloc (vendored, global allocator override)
|
||||
#
|
||||
@@ -458,7 +456,6 @@ YYJSON_SRC = vendored/yyjson/yyjson.c
|
||||
|
||||
# All production sources
|
||||
PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DAEMON_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(GIT_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC)
|
||||
TEST_PROD_SRCS = $(subst src/ui/asset_pack_stub.c,src/ui/asset_pack.c src/ui/asset_manifest_stub.c,$(PROD_SRCS))
|
||||
EXISTING_C_SRCS = $(EXTRACTION_SRCS) $(LSP_SRCS) $(TS_RUNTIME_SRC) \
|
||||
$(GRAMMAR_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC)
|
||||
|
||||
@@ -476,17 +473,6 @@ EXISTING_C_SRCS = $(EXTRACTION_SRCS) $(LSP_SRCS) $(TS_RUNTIME_SRC) \
|
||||
PROJECT_HDRS = $(wildcard src/*.h src/*/*.h $(CBM_DIR)/*.h $(CBM_DIR)/lsp/*.h tests/*.h \
|
||||
tests/repro/*.h)
|
||||
|
||||
# ── Integration-asset integrity constant (generated, never hand-edited) ──
|
||||
# The binary embeds exactly ONE fact about the shipped integration templates:
|
||||
# the SHA-256 of assets/cbm-integrations.json, generated at build time (rule
|
||||
# below the BUILD_DIR definition; target lines expand immediately). Appended
|
||||
# to PROJECT_HDRS so every single-invocation target (test-runner,
|
||||
# test-repro-runner, test-runner-tsan, codebase-memory-mcp) rebuilds when the
|
||||
# asset changes — a stale constant would make every install fail verification.
|
||||
INTEGRATIONS_ASSET = assets/cbm-integrations.json
|
||||
INTEGRATIONS_HASH_HDR = $(BUILD_DIR)/generated/cbm_integrations_hash.h
|
||||
PROJECT_HDRS += $(INTEGRATIONS_HASH_HDR)
|
||||
|
||||
# ── Test sources ─────────────────────────────────────────────────
|
||||
|
||||
TEST_FOUNDATION_SRCS = \
|
||||
@@ -702,11 +688,6 @@ ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS
|
||||
|
||||
BUILD_DIR = build/c
|
||||
|
||||
# Integration-asset hash header (variables defined next to PROJECT_HDRS; the
|
||||
# rule lives here because target lines expand $(BUILD_DIR) immediately).
|
||||
$(INTEGRATIONS_HASH_HDR): $(INTEGRATIONS_ASSET) scripts/gen-integrations-hash.sh | $(BUILD_DIR)
|
||||
sh scripts/gen-integrations-hash.sh $(INTEGRATIONS_ASSET) $@
|
||||
|
||||
# ── Object file compilation (grammars need relaxed warnings) ─────
|
||||
|
||||
# Grammar + tree-sitter runtime: compiled without -Werror (upstream code has warnings)
|
||||
@@ -728,7 +709,7 @@ PP_OBJ_TSAN = $(BUILD_DIR)/tsan_preprocessor.o
|
||||
|
||||
# ── Targets ──────────────────────────────────────────────────────
|
||||
|
||||
.PHONY: test test-par test-repro test-foundation test-tsan test-daemon-smoke cbm cbm-with-ui frontend ui-pack clean-c lint lint-tidy lint-cppcheck lint-format security
|
||||
.PHONY: test test-par test-repro test-foundation test-tsan test-daemon-smoke cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format security
|
||||
|
||||
$(BUILD_DIR):
|
||||
mkdir -p $(BUILD_DIR)
|
||||
@@ -871,9 +852,9 @@ $(UNIXCODER_OBJ): $(UNIXCODER_BLOB_SRC) vendored/nomic/code_vectors.bin | $(BUIL
|
||||
OBJS_VENDORED_TEST = $(MIMALLOC_OBJ_TEST) $(SQLITE3_OBJ_TEST) $(TRE_OBJ_TEST) $(GRAMMAR_OBJS_TEST) $(TS_RUNTIME_OBJ_TEST) $(LSP_OBJ_TEST) $(PP_OBJ_TEST) $(LZ4_OBJ_TEST) $(ZSTD_OBJ_TEST) $(UNIXCODER_OBJ)
|
||||
OBJS_VENDORED_TSAN = $(MIMALLOC_OBJ_TSAN) $(SQLITE3_OBJ_TSAN) $(TRE_OBJ_TSAN) $(GRAMMAR_OBJS_TSAN) $(TS_RUNTIME_OBJ_TSAN) $(LSP_OBJ_TSAN) $(PP_OBJ_TSAN) $(LZ4_OBJ_TSAN) $(ZSTD_OBJ_TSAN) $(UNIXCODER_OBJ)
|
||||
|
||||
$(BUILD_DIR)/test-runner: $(ALL_TEST_SRCS) $(TEST_PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR)
|
||||
$(BUILD_DIR)/test-runner: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR)
|
||||
$(CC) $(CFLAGS_TEST) -Itests -Itests/repro -o $@ \
|
||||
$(ALL_TEST_SRCS) $(TEST_PROD_SRCS) \
|
||||
$(ALL_TEST_SRCS) $(PROD_SRCS) \
|
||||
$(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \
|
||||
$(OBJS_VENDORED_TEST) \
|
||||
$(LDFLAGS_TEST)
|
||||
@@ -899,9 +880,9 @@ test-focused: $(BUILD_DIR)/test-runner
|
||||
# Mirrors test-runner's link line but uses repro_main.c (own main + counters)
|
||||
# and TEST_REPRO_SRCS instead of ALL_TEST_SRCS. Exits non-zero while any bug is
|
||||
# still reproduced (the expected state); bug-repro.yml surfaces it as a board.
|
||||
$(BUILD_DIR)/test-repro-runner: $(TEST_REPRO_SRCS) $(TEST_PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR)
|
||||
$(BUILD_DIR)/test-repro-runner: $(TEST_REPRO_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR)
|
||||
$(CC) $(CFLAGS_TEST) -Itests -o $@ \
|
||||
$(TEST_REPRO_SRCS) $(TEST_PROD_SRCS) \
|
||||
$(TEST_REPRO_SRCS) $(PROD_SRCS) \
|
||||
$(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \
|
||||
$(OBJS_VENDORED_TEST) \
|
||||
$(LDFLAGS_TEST)
|
||||
@@ -959,9 +940,9 @@ TSAN_OPTIONS ?= halt_on_error=1:report_thread_leaks=0
|
||||
# Normal ASan/native/soak paths remain uncapped. High-worker TSan diagnostics
|
||||
# invoke test-runner-tsan directly so release gates cannot drift accidentally.
|
||||
|
||||
$(BUILD_DIR)/test-runner-tsan: $(ALL_TEST_SRCS) $(TEST_PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TSAN) $(PROJECT_HDRS) | $(BUILD_DIR)
|
||||
$(BUILD_DIR)/test-runner-tsan: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TSAN) $(PROJECT_HDRS) | $(BUILD_DIR)
|
||||
$(CC) $(CFLAGS_TSAN) -Itests -Itests/repro -o $@ \
|
||||
$(ALL_TEST_SRCS) $(TEST_PROD_SRCS) \
|
||||
$(ALL_TEST_SRCS) $(PROD_SRCS) \
|
||||
$(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \
|
||||
$(OBJS_VENDORED_TSAN) \
|
||||
$(LDFLAGS_TSAN)
|
||||
@@ -1048,40 +1029,29 @@ $(BUILD_DIR)/codebase-memory-mcp: $(MAIN_SRC) $(PROD_SRCS) $(EXTRACTION_SRCS) $(
|
||||
cbm: $(BUILD_DIR)/codebase-memory-mcp
|
||||
@echo "Built: $(BUILD_DIR)/codebase-memory-mcp"
|
||||
|
||||
# ── Build with external UI asset pack (requires Node.js) ────────
|
||||
# ── Build with embedded UI (requires Node.js) ───────────────────
|
||||
|
||||
# The native image contains only a generated manifest (pack basename, SHA-256
|
||||
# and byte length). Frontend bytes stay in a content-addressed sidecar next to
|
||||
# the binary so release scanners can inspect them independently.
|
||||
UI_ASSET_MANIFEST = $(BUILD_DIR)/generated/ui_asset_manifest.c
|
||||
UI_ASSET_DIST ?= graph-ui/dist
|
||||
UI_ASSET_PREBUILT ?= 0
|
||||
ifeq ($(UI_ASSET_PREBUILT),1)
|
||||
UI_ASSET_BUILD_PREREQ :=
|
||||
else
|
||||
UI_ASSET_BUILD_PREREQ := frontend
|
||||
endif
|
||||
PROD_SRCS_WITH_ASSETS = $(subst src/ui/asset_pack_stub.c,src/ui/asset_pack.c $(UI_ASSET_MANIFEST),$(PROD_SRCS))
|
||||
# Swap embedded_stub.c for the generated embedded_assets.c
|
||||
UI_SRCS_WITH_ASSETS = $(subst src/ui/embedded_stub.c,src/ui/embedded_assets.c,$(UI_SRCS))
|
||||
PROD_SRCS_WITH_ASSETS = $(subst src/ui/embedded_stub.c,src/ui/embedded_assets.c,$(PROD_SRCS))
|
||||
|
||||
# Embedded asset object files (generated by embed script)
|
||||
EMBED_OBJS = $(wildcard $(BUILD_DIR)/embedded/embed_*.o)
|
||||
|
||||
frontend:
|
||||
cd graph-ui && npm ci && npm run build
|
||||
|
||||
$(UI_ASSET_MANIFEST): $(UI_ASSET_BUILD_PREREQ) scripts/pack-ui-assets.mjs | $(BUILD_DIR)
|
||||
node scripts/pack-ui-assets.mjs $(UI_ASSET_DIST) $(BUILD_DIR) $@
|
||||
embed: frontend
|
||||
scripts/embed-frontend.sh graph-ui/dist $(BUILD_DIR)/embedded
|
||||
|
||||
ui-pack: $(UI_ASSET_MANIFEST)
|
||||
|
||||
# $(INTEGRATIONS_HASH_HDR): generated header included by integration_assets.c.
|
||||
# The standard target gets it via $(PROJECT_HDRS); the UI target compiles in one
|
||||
# shot and must list it too, or the build fails with "cbm_integrations_hash.h
|
||||
# file not found" (seen on the Windows UI build).
|
||||
cbm-with-ui: ui-pack $(INTEGRATIONS_HASH_HDR) $(OBJS_VENDORED_PROD)
|
||||
cbm-with-ui: embed $(OBJS_VENDORED_PROD)
|
||||
$(CC) $(CFLAGS_PROD) -o $(BUILD_DIR)/codebase-memory-mcp \
|
||||
$(MAIN_SRC) $(PROD_SRCS_WITH_ASSETS) \
|
||||
$(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \
|
||||
$(OBJS_VENDORED_PROD) \
|
||||
$(wildcard $(BUILD_DIR)/embedded/embed_*.o) \
|
||||
$(LDFLAGS)
|
||||
@echo "Built with UI asset pack: $(BUILD_DIR)/codebase-memory-mcp"
|
||||
@echo "Built with UI: $(BUILD_DIR)/codebase-memory-mcp"
|
||||
|
||||
clean-c: export BUILD_DIR := $(BUILD_DIR)
|
||||
clean-c:
|
||||
@@ -1114,7 +1084,7 @@ lint-tidy:
|
||||
@$(CLANG_TIDY) --quiet $(LINT_SRCS) -- $(CFLAGS_COMMON) $(SYSROOT_FLAG)
|
||||
|
||||
# cppcheck: complementary analysis (config in .cppcheck)
|
||||
lint-cppcheck: $(INTEGRATIONS_HASH_HDR)
|
||||
lint-cppcheck:
|
||||
@echo "=== cppcheck ==="
|
||||
@$(CPPCHECK) --enable=warning,style,performance,portability \
|
||||
--std=c11 --language=c \
|
||||
|
||||
+8
-12
@@ -66,7 +66,7 @@ License summary:
|
||||
- **Copyright:** (c) 2025 InterSystems Corporation
|
||||
- **Vendored at:** `internal/cbm/vendored/grammars/objectscript_udl/`, `internal/cbm/vendored/grammars/objectscript_routine/`
|
||||
- **Pinned commit:** `a7ffcdf`
|
||||
- **Notes:** InterSystems-maintained grammar for the ObjectScript language (InterSystems IRIS / Caché). Vendor-maintained; not in nvim-treesitter or Helix registries. Each `scanner.c`'s upstream `#include "../../common/scanner.h"` is repointed to a per-directory `objectscript_common.h` copied from upstream `common/scanner.h`; two loop counters in that copy are widened from `uint8_t` to `int` as documented in `internal/cbm/vendored/grammars/MANIFEST.md`.
|
||||
- **Notes:** InterSystems-maintained grammar for the ObjectScript language (InterSystems IRIS / Caché). Vendor-maintained; not in nvim-treesitter or Helix registries. Each `scanner.c`'s upstream `#include "../../common/scanner.h"` is repointed to a per-directory `objectscript_common.h` (verbatim copy of upstream `common/scanner.h`).
|
||||
|
||||
## Vendored C/C++ Libraries
|
||||
|
||||
@@ -142,17 +142,13 @@ follows:
|
||||
documentation and language specifications; no upstream source code was
|
||||
extracted or transcribed.
|
||||
|
||||
## External Graph UI asset pack
|
||||
## Embedded Graph UI
|
||||
|
||||
Release builds made with `--with-ui` ship the compiled `graph-ui/` frontend in
|
||||
a deterministic, content-addressed `cbm-ui-<sha256>.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`).
|
||||
|
||||
File diff suppressed because one or more lines are too long
+34
-122
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
+7
-128
@@ -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=<path>] [--skip-config]"
|
||||
echo " --ui Install the UI variant (with graph visualization)"
|
||||
echo " --standard Install the standard variant (default)"
|
||||
echo "Usage: install.sh [--dir=<path>] [--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
|
||||
|
||||
+1
-3
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,6 @@ class CodebaseMemoryMcp < Formula
|
||||
|
||||
def install
|
||||
bin.install "codebase-memory-mcp"
|
||||
(share/"codebase-memory-mcp").install "cbm-integrations.json"
|
||||
# Third-party attribution bundle (present in archives since v0.8.1)
|
||||
doc.install "THIRD_PARTY_NOTICES.md" if File.exist?("THIRD_PARTY_NOTICES.md")
|
||||
end
|
||||
@@ -46,6 +45,5 @@ class CodebaseMemoryMcp < Formula
|
||||
|
||||
test do
|
||||
assert_match "codebase-memory-mcp", shell_output("#{bin}/codebase-memory-mcp --version")
|
||||
assert_path_exists share/"codebase-memory-mcp/cbm-integrations.json"
|
||||
end
|
||||
end
|
||||
|
||||
+185
-655
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
+6
-1089
File diff suppressed because it is too large
Load Diff
+1
-27
@@ -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/<sha256>/; 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 ==="
|
||||
|
||||
@@ -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] <binary-or-dir>...
|
||||
# Usage: scripts/ci/check-binary-composition.sh <binary-or-dir>...
|
||||
# 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'
|
||||
'<!doctype html>'
|
||||
'<html lang="en" class="dark">'
|
||||
'<script type="module" crossorigin'
|
||||
)
|
||||
|
||||
# Canary: proves the needle scan can actually see this file's strings. Without
|
||||
# it, handing the gate a gzip, a stub or a 0-byte file would pass every
|
||||
# absence assertion. 168+ occurrences in a real artifact, 0 in anything else.
|
||||
CANARY_NEEDLE='codebase-memory-mcp'
|
||||
|
||||
# ── Args ────────────────────────────────────────────────────────────
|
||||
VARIANT=auto
|
||||
TARGETS=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--variant=*) VARIANT="${arg#--variant=}" ;;
|
||||
-*)
|
||||
echo "FAIL: unknown flag $arg (see --help)" >&2
|
||||
exit 2
|
||||
@@ -143,13 +114,6 @@ for arg in "$@"; do
|
||||
*) TARGETS+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
case "$VARIANT" in
|
||||
auto | standard | ui) ;;
|
||||
*)
|
||||
echo "FAIL: --variant must be auto, standard or ui (got '$VARIANT')" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
if [ "${#TARGETS[@]}" -eq 0 ]; then
|
||||
echo "FAIL: no binaries or directories given (see --help)" >&2
|
||||
exit 2
|
||||
@@ -249,8 +213,8 @@ skipped_files=0
|
||||
|
||||
check_file() {
|
||||
file="$1"
|
||||
# Two path components: both variants ship a binary literally named
|
||||
# "codebase-memory-mcp", so the parent directory is what tells them apart.
|
||||
# Two path components: the binary is literally named
|
||||
# "codebase-memory-mcp", so the parent directory disambiguates the log.
|
||||
token=$(printf '%s' "$file" | awk -F/ '{ if (NF > 1) print $(NF - 1) "/" $NF; else print $NF }')
|
||||
fmt=$(detect_format "$file")
|
||||
if [ "$fmt" = other ]; then
|
||||
@@ -259,24 +223,7 @@ check_file() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
is_ui=0
|
||||
case "$VARIANT" in
|
||||
ui) is_ui=1 ;;
|
||||
standard) is_ui=0 ;;
|
||||
auto)
|
||||
# The UI archive is codebase-memory-mcp-ui-<os>-<arch>, but the binary
|
||||
# inside it is just "codebase-memory-mcp" — so the whole path decides,
|
||||
# not the basename. Printed below so a misclassified path is visible in
|
||||
# the log instead of silently disarming the UI assertion.
|
||||
case "$file" in
|
||||
*-ui | *-ui.* | *-ui-* | *-ui/*) is_ui=1 ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
variant_label=standard
|
||||
[ "$is_ui" -eq 1 ] && variant_label='UI (A5-no-ui-http not applicable)'
|
||||
printf '\n── %s [%s, %s] ──\n' "$token" "$fmt" "$variant_label"
|
||||
printf '\n── %s [%s] ──\n' "$token" "$fmt"
|
||||
checked_files=$((checked_files + 1))
|
||||
|
||||
# A0 — anti-vacuity canary; every assertion below is an absence check.
|
||||
@@ -362,46 +309,17 @@ check_file() {
|
||||
"sqlite compile-option table absent; A4 rests on absence needles only"
|
||||
fi
|
||||
|
||||
# A5 — UI/HTTP subsystem, standard artifacts only.
|
||||
#
|
||||
# The temporary standard variant still links the UI control/HTTP code but
|
||||
# substitutes the no-I/O asset-pack stub, so it cannot publish a UI. That
|
||||
# variant remains only for before/after VirusTotal verification and is then
|
||||
# deprecated in favor of one UI-capable product composition. Until that
|
||||
# evidence gate is complete, this assertion remains an explicit diagnostic;
|
||||
# CBM_CHECK_UI_ABSENT=1 is available for any future true no-UI composition.
|
||||
if [ "$is_ui" -eq 1 ]; then
|
||||
printf 'n/a %-22s %s: UI artifact, HTTP server ships here by design\n' \
|
||||
A5-no-ui-http "$token"
|
||||
elif [ "${CBM_CHECK_UI_ABSENT:-0}" = "1" ]; then
|
||||
# A5 — UI/HTTP subsystem. One composition ships and it serves the graph UI
|
||||
# from embedded assets, so the HTTP server belongs here by construction.
|
||||
# CBM_CHECK_UI_ABSENT=1 still enforces absence for any future headless build.
|
||||
if [ "${CBM_CHECK_UI_ABSENT:-0}" = "1" ]; then
|
||||
for needle in "${UI_HTTP_NEEDLES[@]}"; do
|
||||
assert_absent "$file" "$token" A5-no-ui-http "$needle"
|
||||
done
|
||||
else
|
||||
ui_hits=0
|
||||
for needle in "${UI_HTTP_NEEDLES[@]}"; do
|
||||
if LC_ALL=C grep -a -q -F -e "$needle" "$file"; then
|
||||
ui_hits=$((ui_hits + 1))
|
||||
fi
|
||||
done
|
||||
printf 'INFO %-22s %s: %d/%d UI/HTTP needles present (temporary standard variant; set CBM_CHECK_UI_ABSENT=1 to enforce a true no-UI composition)\n' \
|
||||
A5-no-ui-http "$token" "$ui_hits" "${#UI_HTTP_NEEDLES[@]}"
|
||||
printf 'n/a %-22s %s: UI-capable artifact, HTTP server ships here by design\n' \
|
||||
A5-no-ui-http "$token"
|
||||
fi
|
||||
|
||||
# A6 — embedded integration-script text. Every variant is now subject to
|
||||
# the same hard gate: the UI frontend is external, so it can no longer mask
|
||||
# a returned shebang or node:child_process body in the native image.
|
||||
for needle in "${SCRIPT_NEEDLES[@]}"; do
|
||||
assert_absent "$file" "$token" A6-no-embedded-scripts "$needle"
|
||||
done
|
||||
|
||||
# A7 — UI pack/header and frontend HTML/script bytes. The pack is scanned as
|
||||
# its own release file; none of its raw frontend bytes belongs in any
|
||||
# ELF/Mach-O/PE variant. Applying this to standard too makes the boundary a
|
||||
# product-wide invariant rather than relying on path classification.
|
||||
for needle in "${UI_ASSET_BYTE_NEEDLES[@]}"; do
|
||||
assert_absent "$file" "$token" A7-no-ui-asset-bytes "$needle"
|
||||
done
|
||||
}
|
||||
|
||||
# ── Walk the targets ────────────────────────────────────────────────
|
||||
|
||||
@@ -26,7 +26,7 @@ artifact through the canonical wrapper, exactly like the release venue:
|
||||
windows: test-infrastructure/vm/vm-smoke.sh with CBM_SMOKE_ARTIFACT_DIR
|
||||
(run inside the VM/CI msys2 shell)
|
||||
|
||||
--variant ui builds --with-ui and smokes the ui archive (Phase 15 external
|
||||
--variant ui builds --with-ui and smokes the ui archive (Phase 15 embedded
|
||||
assets become mandatory — a standard binary cannot pass a ui run).
|
||||
|
||||
Make passthrough (VAR=VAL): CC= CXX= STATIC=1 ... forwarded to build steps.
|
||||
@@ -104,8 +104,8 @@ mkdir -p "$EXTRACT_DIR"
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
unzip -q -o "$WORK_DIR/$NAME.zip" -d "$EXTRACT_DIR"
|
||||
test -s "$EXTRACT_DIR/codebase-memory-mcp.exe"
|
||||
# ONE executable per runtime set: a payload sibling means the removed
|
||||
# launcher split came back.
|
||||
# ONE binary per platform: a payload sibling means the AV-flagged launcher
|
||||
# stub came back.
|
||||
test ! -e "$EXTRACT_DIR/codebase-memory-mcp.payload.exe"
|
||||
echo "=== smoke-artifact: smoking EXTRACTED $NAME.zip via vm-smoke.sh ==="
|
||||
SMOKE_ARCH="$GOARCH" SMOKE_VARIANT="$VARIANT" \
|
||||
|
||||
@@ -30,6 +30,9 @@ rm -rf "$ROOT/graph-ui/node_modules"
|
||||
# Root-level node artifacts (if any)
|
||||
rm -rf "$ROOT/node_modules"
|
||||
|
||||
# Generated embedded assets (regenerated by embed-frontend.sh)
|
||||
rm -f "$ROOT/src/ui/embedded_assets.c"
|
||||
|
||||
# Leftover test fixture dirs (C test suite sometimes creates these in CWD)
|
||||
find "$ROOT" -maxdepth 1 -type d \( -name 'cbm_*' -o -name 'cli-*' \) -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
# embed-frontend.sh — Convert built frontend assets into linkable object files.
|
||||
#
|
||||
# Usage: scripts/embed-frontend.sh <dist_dir> <output_dir>
|
||||
#
|
||||
# For each file in dist_dir, creates:
|
||||
# 1. An object file via `ld -r -b binary` (raw bytes, zero bloat)
|
||||
# 2. A generated embedded_assets.c with a lookup table
|
||||
#
|
||||
# Symbols created per file: _binary_<mangled_name>_start, _binary_<mangled_name>_end
|
||||
# On macOS, these get a leading underscore: __binary_<mangled_name>_start
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DIST_DIR="${1:?Usage: embed-frontend.sh <dist_dir> <output_dir>}"
|
||||
OUTPUT_DIR="${2:?Usage: embed-frontend.sh <dist_dir> <output_dir>}"
|
||||
|
||||
# Clean old embedded objects (asset hashes change on each build)
|
||||
rm -rf "$OUTPUT_DIR"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# Detect platform — Linux uses ld -r -b binary, everything else uses xxd+cc
|
||||
IS_LINUX=false
|
||||
if [[ "$(uname -s)" == "Linux" ]] && ! [[ "$(uname -s)" =~ MINGW|MSYS ]]; then
|
||||
IS_LINUX=true
|
||||
fi
|
||||
|
||||
# Content-type detection
|
||||
content_type_for() {
|
||||
local f="$1"
|
||||
case "$f" in
|
||||
*.html) echo "text/html" ;;
|
||||
*.js) echo "application/javascript" ;;
|
||||
*.css) echo "text/css" ;;
|
||||
*.json) echo "application/json" ;;
|
||||
*.svg) echo "image/svg+xml" ;;
|
||||
*.png) echo "image/png" ;;
|
||||
*.ico) echo "image/x-icon" ;;
|
||||
*.woff2) echo "font/woff2" ;;
|
||||
*.woff) echo "font/woff" ;;
|
||||
*.map) echo "application/json" ;;
|
||||
*) echo "application/octet-stream" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Mangle filename to valid C symbol: replace non-alnum with _
|
||||
mangle() {
|
||||
echo "$1" | sed 's/[^a-zA-Z0-9]/_/g'
|
||||
}
|
||||
|
||||
# Collect all files
|
||||
FILES=()
|
||||
while IFS= read -r -d '' file; do
|
||||
FILES+=("$file")
|
||||
done < <(find "$DIST_DIR" -type f -print0 | sort -z)
|
||||
|
||||
if [[ ${#FILES[@]} -eq 0 ]]; then
|
||||
echo "Error: no files found in $DIST_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Embedding ${#FILES[@]} files from $DIST_DIR"
|
||||
|
||||
# Generate object files
|
||||
OBJ_FILES=()
|
||||
for file in "${FILES[@]}"; do
|
||||
rel="${file#$DIST_DIR/}"
|
||||
mangled=$(mangle "$rel")
|
||||
obj="$OUTPUT_DIR/embed_${mangled}.o"
|
||||
|
||||
if $IS_LINUX; then
|
||||
# Linux: ld -r -b binary (zero bloat, ELF only)
|
||||
abs_obj="$(cd "$(dirname "$0")/.." && pwd)/$obj"
|
||||
(cd "$DIST_DIR" && ld -r -b binary -o "$abs_obj" "$rel")
|
||||
else
|
||||
# macOS/Windows/MSYS2: generate C byte array + cc (no xxd dependency)
|
||||
local_c="$OUTPUT_DIR/embed_${mangled}.c"
|
||||
local_sym="_binary_${mangled}"
|
||||
|
||||
echo "/* Generated from $rel */" > "$local_c"
|
||||
echo "const unsigned char ${local_sym}_data[] = {" >> "$local_c"
|
||||
# Use od (POSIX) to generate hex bytes — works everywhere without xxd/vim
|
||||
od -An -tx1 -v < "$file" | tr -s ' ' '\n' | grep -v '^$' | sed 's/^/0x/; s/$/,/' | paste -sd' ' - | fold -s -w 76 | sed 's/^/ /' >> "$local_c"
|
||||
echo "};" >> "$local_c"
|
||||
echo "const unsigned int ${local_sym}_size = sizeof(${local_sym}_data);" >> "$local_c"
|
||||
|
||||
${CC:-cc} -c -O2 -o "$obj" "$local_c"
|
||||
rm -f "$local_c"
|
||||
fi
|
||||
|
||||
OBJ_FILES+=("$obj")
|
||||
echo " $rel -> $obj"
|
||||
done
|
||||
|
||||
# Generate embedded_assets.c
|
||||
ASSETS_C="src/ui/embedded_assets.c"
|
||||
cat > "$ASSETS_C" <<'HEADER'
|
||||
/*
|
||||
* embedded_assets.c — Generated file mapping URL paths to embedded bytes.
|
||||
* DO NOT EDIT — regenerated by scripts/embed-frontend.sh
|
||||
*/
|
||||
#include "ui/embedded_assets.h"
|
||||
#include <string.h>
|
||||
|
||||
HEADER
|
||||
|
||||
# Declare extern symbols
|
||||
for file in "${FILES[@]}"; do
|
||||
rel="${file#$DIST_DIR/}"
|
||||
mangled=$(mangle "$rel")
|
||||
sym="_binary_${mangled}"
|
||||
|
||||
if ! $IS_LINUX; then
|
||||
echo "extern const unsigned char ${sym}_data[];" >> "$ASSETS_C"
|
||||
echo "extern const unsigned int ${sym}_size;" >> "$ASSETS_C"
|
||||
else
|
||||
echo "extern const unsigned char ${sym}_start[];" >> "$ASSETS_C"
|
||||
echo "extern const unsigned char ${sym}_end[];" >> "$ASSETS_C"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> "$ASSETS_C"
|
||||
echo "cbm_embedded_file_t CBM_EMBEDDED_FILES[] = {" >> "$ASSETS_C"
|
||||
|
||||
for file in "${FILES[@]}"; do
|
||||
rel="${file#$DIST_DIR/}"
|
||||
mangled=$(mangle "$rel")
|
||||
sym="_binary_${mangled}"
|
||||
ct=$(content_type_for "$rel")
|
||||
|
||||
# URL path: /index.html for root, /assets/... for assets
|
||||
url_path="/$rel"
|
||||
|
||||
if ! $IS_LINUX; then
|
||||
echo " {\"$url_path\", ${sym}_data, 0, \"$ct\"}," >> "$ASSETS_C"
|
||||
else
|
||||
echo " {\"$url_path\", ${sym}_start, 0, \"$ct\"}," >> "$ASSETS_C"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "};" >> "$ASSETS_C"
|
||||
echo "const int CBM_EMBEDDED_FILE_COUNT = ${#FILES[@]};" >> "$ASSETS_C"
|
||||
|
||||
# Generate size fixup
|
||||
if $IS_LINUX; then
|
||||
# Linux: compute size from start/end pointers (ld -r -b binary symbols)
|
||||
cat >> "$ASSETS_C" <<'SIZEINIT'
|
||||
|
||||
static void __attribute__((constructor)) init_embedded_sizes(void) {
|
||||
cbm_embedded_file_t *files = CBM_EMBEDDED_FILES;
|
||||
SIZEINIT
|
||||
|
||||
for i in "${!FILES[@]}"; do
|
||||
rel="${FILES[$i]#$DIST_DIR/}"
|
||||
mangled=$(mangle "$rel")
|
||||
sym="_binary_${mangled}"
|
||||
echo " files[$i].size = (unsigned int)(${sym}_end - ${sym}_start);" >> "$ASSETS_C"
|
||||
done
|
||||
|
||||
echo "}" >> "$ASSETS_C"
|
||||
else
|
||||
# macOS/Windows: use explicit _size vars from xxd-generated C arrays
|
||||
cat >> "$ASSETS_C" <<'SIZEINIT'
|
||||
|
||||
static void __attribute__((constructor)) init_embedded_sizes(void) {
|
||||
cbm_embedded_file_t *files = CBM_EMBEDDED_FILES;
|
||||
SIZEINIT
|
||||
|
||||
for i in "${!FILES[@]}"; do
|
||||
rel="${FILES[$i]#$DIST_DIR/}"
|
||||
mangled=$(mangle "$rel")
|
||||
sym="_binary_${mangled}"
|
||||
echo " files[$i].size = ${sym}_size;" >> "$ASSETS_C"
|
||||
done
|
||||
|
||||
echo "}" >> "$ASSETS_C"
|
||||
fi
|
||||
|
||||
# Add lookup function
|
||||
cat >> "$ASSETS_C" <<'LOOKUP'
|
||||
|
||||
const cbm_embedded_file_t *cbm_embedded_lookup(const char *path) {
|
||||
for (int i = 0; i < CBM_EMBEDDED_FILE_COUNT; i++) {
|
||||
if (strcmp(CBM_EMBEDDED_FILES[i].path, path) == 0) {
|
||||
return &CBM_EMBEDDED_FILES[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
LOOKUP
|
||||
|
||||
echo "Generated $ASSETS_C with ${#FILES[@]} embedded files"
|
||||
echo "Object files in $OUTPUT_DIR:"
|
||||
printf ' %s\n' "${OBJ_FILES[@]}"
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/bin/sh
|
||||
# gen-integrations-hash.sh — emit the build-time integrity constant for
|
||||
# assets/cbm-integrations.json.
|
||||
#
|
||||
# The binary embeds exactly ONE fact about the integration templates: the
|
||||
# SHA-256 of the shipped asset file. Verifying the file against this constant
|
||||
# before use preserves the tamper-evidence the templates had while they were
|
||||
# compiled in — moving them out of the binary (AV-surface reduction) must not
|
||||
# turn them into unauthenticated code the installer happily writes to disk.
|
||||
#
|
||||
# Generated, never hand-maintained: a stale constant is exactly the silent
|
||||
# drift this step exists to prevent, so the header is a build product with a
|
||||
# real dependency on the JSON file.
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "usage: gen-integrations-hash.sh <asset.json> <out-header.h>" >&2
|
||||
exit 2
|
||||
fi
|
||||
asset="$1"
|
||||
out="$2"
|
||||
|
||||
[ -f "$asset" ] || { echo "gen-integrations-hash: missing $asset" >&2; exit 1; }
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
hash=$(sha256sum "$asset" | cut -d' ' -f1)
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
hash=$(shasum -a 256 "$asset" | cut -d' ' -f1)
|
||||
else
|
||||
echo "gen-integrations-hash: neither sha256sum nor shasum available" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$hash" in
|
||||
*[!0-9a-f]* | "")
|
||||
echo "gen-integrations-hash: bad digest '$hash'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "${#hash}" -ne 64 ]; then
|
||||
echo "gen-integrations-hash: bad digest length for '$hash'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$out")"
|
||||
tmp="$out.tmp.$$"
|
||||
{
|
||||
echo "/* Generated by scripts/gen-integrations-hash.sh — do not edit. */"
|
||||
echo "#ifndef CBM_INTEGRATIONS_HASH_H"
|
||||
echo "#define CBM_INTEGRATIONS_HASH_H"
|
||||
echo "#define CBM_INTEGRATIONS_SHA256 \"$hash\""
|
||||
echo "#endif"
|
||||
} >"$tmp"
|
||||
mv "$tmp" "$out"
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "License allow-list for the CI license gate (scripts/license-gate.sh). A single detected SPDX id outside this list fails the pipeline. Additions to this list are deliberate, reviewed changes \u2014 never add copyleft (GPL/LGPL/AGPL/EPL/SSPL) without a maintainer decision.",
|
||||
"_comment": "License allow-list for the CI license gate (scripts/license-gate.sh). A single detected SPDX id outside this list fails the pipeline. Additions to this list are deliberate, reviewed changes — never add copyleft (GPL/LGPL/AGPL/EPL/SSPL) without a maintainer decision.",
|
||||
"allowed_spdx_ids": [
|
||||
"MIT",
|
||||
"MIT-0",
|
||||
@@ -16,15 +16,13 @@
|
||||
"LicenseRef-scancode-public-domain",
|
||||
"LicenseRef-scancode-public-domain-disclaimer"
|
||||
],
|
||||
"_ignored_paths_comment": "Path prefixes (relative to the staged scan tree) excluded from the gate. Use ONLY for documented false positives. Justifications: the license tooling itself (gate scripts + this policy file) necessarily names prohibited licenses; gen-third-party-notices.sh echoes license terminology; audit-license-provenance.py names licenses in its verdict maps; src/discover/discover.c contains a license-FILENAME classification list (LICENSE-MIT, LICENSE-APACHE, ...) for file discovery, which ScanCode reads as license references; package-release.sh and smoke-local.sh list the archive members 'cbm-integrations.json LICENSE ...' adjacently (the order the Windows one-executable runtime-set contract locks, mirrored by the smoke fixture), and ScanCode reads the '.json LICENSE' token adjacency as a reference to the SPDX 'JSON' license — first-party MIT build scripts, not vendored code.",
|
||||
"_ignored_paths_comment": "Path prefixes (relative to the staged scan tree) excluded from the gate. Use ONLY for documented false positives. Justifications: the license tooling itself (gate scripts + this policy file) necessarily names prohibited licenses; gen-third-party-notices.sh echoes license terminology; audit-license-provenance.py names licenses in its verdict maps; src/discover/discover.c contains a license-FILENAME classification list (LICENSE-MIT, LICENSE-APACHE, ...) for file discovery, which ScanCode reads as license references, which ScanCode reads as license references.",
|
||||
"ignored_paths": [
|
||||
"tree/scripts/license-policy.json",
|
||||
"tree/scripts/license-gate-check.py",
|
||||
"tree/scripts/license-gate.sh",
|
||||
"tree/scripts/gen-third-party-notices.sh",
|
||||
"tree/src/discover/discover.c",
|
||||
"tree/scripts/audit-license-provenance.py",
|
||||
"tree/scripts/package-release.sh",
|
||||
"tree/scripts/smoke-local.sh"
|
||||
"tree/scripts/audit-license-provenance.py"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Build a deterministic, uncompressed, content-addressed UI asset pack.
|
||||
*
|
||||
* The native binary receives only the pack name, exact byte length and
|
||||
* SHA-256. Frontend bytes stay outside ELF/Mach-O/PE and stay unobscured for
|
||||
* scanners, SBOM tools and human inspection.
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const HEADER_BYTES = 80;
|
||||
const ENTRY_BYTES = 24;
|
||||
const FORMAT_VERSION = 1;
|
||||
const MAX_FILES = 1024;
|
||||
const MAX_PATH_BYTES = 255;
|
||||
const MAX_PACK_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
const MIME_BY_EXTENSION = new Map([
|
||||
['.html', 1],
|
||||
['.js', 2],
|
||||
['.mjs', 2],
|
||||
['.css', 3],
|
||||
['.json', 4],
|
||||
['.svg', 5],
|
||||
['.png', 6],
|
||||
['.jpg', 7],
|
||||
['.jpeg', 7],
|
||||
['.webp', 8],
|
||||
['.avif', 9],
|
||||
['.ico', 10],
|
||||
['.woff2', 11],
|
||||
['.woff', 12],
|
||||
['.wasm', 13],
|
||||
]);
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(`pack-ui-assets: ${message}`);
|
||||
}
|
||||
|
||||
function compareBytewise(left, right) {
|
||||
return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'));
|
||||
}
|
||||
|
||||
function validateUrlPath(urlPath) {
|
||||
const bytes = Buffer.from(urlPath, 'utf8');
|
||||
if (bytes.length < 2 || bytes.length > MAX_PATH_BYTES ||
|
||||
!/^[A-Za-z0-9._/-]+$/.test(urlPath) || urlPath.includes('//') ||
|
||||
urlPath.endsWith('/') || urlPath.includes('/./') || urlPath.includes('/../') ||
|
||||
urlPath.endsWith('/.') || urlPath.endsWith('/..')) {
|
||||
fail(`unsupported frontend path ${JSON.stringify(urlPath)}`);
|
||||
}
|
||||
if (urlPath !== '/index.html' && !urlPath.startsWith('/assets/')) {
|
||||
fail(`frontend output must contain only /index.html and /assets/* (got ${urlPath})`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function collectFiles(root, directory = root, relative = '') {
|
||||
const dirEntries = await fs.readdir(directory, { withFileTypes: true });
|
||||
dirEntries.sort((left, right) => compareBytewise(left.name, right.name));
|
||||
const files = [];
|
||||
for (const entry of dirEntries) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
const childRelative = relative ? path.posix.join(relative, entry.name) : entry.name;
|
||||
if (entry.isSymbolicLink()) {
|
||||
fail(`symbolic links are not allowed (${childRelative})`);
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await collectFiles(root, absolute, childRelative));
|
||||
} else if (entry.isFile()) {
|
||||
const urlPath = `/${childRelative}`;
|
||||
const pathBytes = validateUrlPath(urlPath);
|
||||
const extension = path.posix.extname(childRelative).toLowerCase();
|
||||
const mimeId = MIME_BY_EXTENSION.get(extension);
|
||||
if (!mimeId) {
|
||||
fail(`no closed MIME mapping for ${urlPath}`);
|
||||
}
|
||||
const data = await fs.readFile(absolute);
|
||||
if (data.length === 0) {
|
||||
fail(`zero-length frontend assets are not supported (${urlPath})`);
|
||||
}
|
||||
files.push({
|
||||
urlPath,
|
||||
pathBytes,
|
||||
mimeId,
|
||||
cacheId: urlPath === '/index.html' ? 1 : 2,
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
fail(`unsupported filesystem entry (${childRelative})`);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function writeU64(buffer, value, offset) {
|
||||
if (!Number.isSafeInteger(value) || value < 0) fail('pack size exceeds safe integer range');
|
||||
buffer.writeBigUInt64LE(BigInt(value), offset);
|
||||
}
|
||||
|
||||
function buildPack(files) {
|
||||
files.sort((left, right) => compareBytewise(left.urlPath, right.urlPath));
|
||||
if (files.length === 0 || files.length > MAX_FILES) {
|
||||
fail(`frontend file count must be between 1 and ${MAX_FILES}`);
|
||||
}
|
||||
if (files.filter((file) => file.urlPath === '/index.html').length !== 1) {
|
||||
fail('frontend output must contain exactly one /index.html');
|
||||
}
|
||||
for (let index = 1; index < files.length; index += 1) {
|
||||
if (compareBytewise(files[index - 1].urlPath, files[index].urlPath) >= 0) {
|
||||
fail(`duplicate or unsorted path ${files[index].urlPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const indexBytes = files.length * ENTRY_BYTES;
|
||||
const pathsBytes = files.reduce((total, file) => total + file.pathBytes.length, 0);
|
||||
const payloadBytes = files.reduce((total, file) => total + file.data.length, 0);
|
||||
const indexOffset = HEADER_BYTES;
|
||||
const pathsOffset = indexOffset + indexBytes;
|
||||
const payloadOffset = pathsOffset + pathsBytes;
|
||||
const totalBytes = payloadOffset + payloadBytes;
|
||||
if (totalBytes > MAX_PACK_BYTES) {
|
||||
fail(`pack exceeds ${MAX_PACK_BYTES} bytes`);
|
||||
}
|
||||
|
||||
const pack = Buffer.alloc(totalBytes);
|
||||
pack.write('CBMUIPK', 0, 7, 'ascii');
|
||||
pack[7] = 0;
|
||||
pack.writeUInt16LE(FORMAT_VERSION, 8);
|
||||
pack.writeUInt16LE(HEADER_BYTES, 10);
|
||||
pack.writeUInt32LE(0, 12);
|
||||
pack.writeUInt32LE(files.length, 16);
|
||||
pack.writeUInt32LE(ENTRY_BYTES, 20);
|
||||
writeU64(pack, indexOffset, 24);
|
||||
writeU64(pack, indexBytes, 32);
|
||||
writeU64(pack, pathsOffset, 40);
|
||||
writeU64(pack, pathsBytes, 48);
|
||||
writeU64(pack, payloadOffset, 56);
|
||||
writeU64(pack, payloadBytes, 64);
|
||||
writeU64(pack, totalBytes, 72);
|
||||
|
||||
let pathCursor = 0;
|
||||
let payloadCursor = 0;
|
||||
files.forEach((file, index) => {
|
||||
const entryOffset = indexOffset + index * ENTRY_BYTES;
|
||||
pack.writeUInt32LE(pathCursor, entryOffset);
|
||||
pack.writeUInt16LE(file.pathBytes.length, entryOffset + 4);
|
||||
pack.writeUInt8(file.mimeId, entryOffset + 6);
|
||||
pack.writeUInt8(file.cacheId, entryOffset + 7);
|
||||
writeU64(pack, payloadCursor, entryOffset + 8);
|
||||
writeU64(pack, file.data.length, entryOffset + 16);
|
||||
file.pathBytes.copy(pack, pathsOffset + pathCursor);
|
||||
file.data.copy(pack, payloadOffset + payloadCursor);
|
||||
pathCursor += file.pathBytes.length;
|
||||
payloadCursor += file.data.length;
|
||||
});
|
||||
return pack;
|
||||
}
|
||||
|
||||
async function writeAtomically(destination, bytes) {
|
||||
const temporary = `${destination}.tmp-${process.pid}`;
|
||||
try {
|
||||
await fs.writeFile(temporary, bytes, { flag: 'wx', mode: 0o644 });
|
||||
await fs.rename(temporary, destination);
|
||||
} catch (error) {
|
||||
await fs.rm(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [sourceRoot, outputDirectory, manifestPath, ...extra] = process.argv.slice(2);
|
||||
if (!sourceRoot || !outputDirectory || !manifestPath || extra.length !== 0) {
|
||||
fail('usage: pack-ui-assets.mjs <dist-dir> <output-dir> <manifest.c>');
|
||||
}
|
||||
const rootStat = await fs.lstat(sourceRoot);
|
||||
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
||||
fail('dist-dir must be a real directory, not a symlink');
|
||||
}
|
||||
const files = await collectFiles(sourceRoot);
|
||||
const pack = buildPack(files);
|
||||
const digest = crypto.createHash('sha256').update(pack).digest('hex');
|
||||
const packName = `cbm-ui-${digest}.pack`;
|
||||
const manifest = Buffer.from(
|
||||
'/* Generated by scripts/pack-ui-assets.mjs; do not edit. */\n' +
|
||||
'#include <stdint.h>\n\n' +
|
||||
`const char CBM_UI_ASSET_PACK_NAME[] = "${packName}";\n` +
|
||||
`const char CBM_UI_ASSET_SHA256[] = "${digest}";\n` +
|
||||
`const uint64_t CBM_UI_ASSET_SIZE = UINT64_C(${pack.length});\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await fs.mkdir(outputDirectory, { recursive: true });
|
||||
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
const packPath = path.join(outputDirectory, packName);
|
||||
await writeAtomically(packPath, pack);
|
||||
await writeAtomically(manifestPath, manifest);
|
||||
|
||||
const oldPacks = (await fs.readdir(outputDirectory))
|
||||
.filter((name) => /^cbm-ui-[0-9a-f]{64}\.pack$/.test(name) && name !== packName);
|
||||
await Promise.all(oldPacks.map((name) => fs.rm(path.join(outputDirectory, name))));
|
||||
process.stdout.write(`${packName} ${pack.length} bytes ${files.length} files\n`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+12
-101
@@ -16,8 +16,7 @@ cd "$ROOT"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/package-release.sh <goos> <goarch> [--variant standard|ui]
|
||||
[--out-dir DIR] [VAR=VAL ...]
|
||||
Usage: scripts/package-release.sh <goos> <goarch> [--out-dir DIR] [VAR=VAL ...]
|
||||
|
||||
The canonical release-archive step: identical in the release build and the
|
||||
local artifact-flow smoke lane.
|
||||
@@ -25,9 +24,6 @@ local artifact-flow smoke lane.
|
||||
goos linux | darwin | windows
|
||||
goarch arch label used verbatim in the archive name (amd64, arm64,
|
||||
arm64-portable, ...)
|
||||
--variant standard (default) | ui — selects the archive NAME prefix; the
|
||||
matching binary must already have been built (--with-ui for ui).
|
||||
UI archives add exactly one root-level cbm-ui-<sha256>.pack.
|
||||
--out-dir where to place the archive (default: repository root).
|
||||
|
||||
Make passthrough (VAR=VAL, forwarded to the build):
|
||||
@@ -36,40 +32,27 @@ Make passthrough (VAR=VAL, forwarded to the build):
|
||||
Environment:
|
||||
BUILD_DIR build tree to archive from (default build/c).
|
||||
|
||||
Archive contents (defined here, canonical) — ONE executable per runtime set:
|
||||
unix: codebase-memory-mcp cbm-integrations.json LICENSE install.sh
|
||||
THIRD_PARTY_NOTICES.md [cbm-ui-<sha256>.pack] (.tar.gz)
|
||||
windows: codebase-memory-mcp.exe cbm-integrations.json LICENSE install.ps1
|
||||
THIRD_PARTY_NOTICES.md [cbm-ui-<sha256>.pack] (.zip)
|
||||
Archive contents (defined here, canonical) — ONE executable, no sidecars:
|
||||
unix: codebase-memory-mcp LICENSE install.sh THIRD_PARTY_NOTICES.md (.tar.gz)
|
||||
windows: codebase-memory-mcp.exe LICENSE install.ps1 THIRD_PARTY_NOTICES.md (.zip)
|
||||
|
||||
The bracketed pack is required only for the ui variant and forbidden from the
|
||||
standard archive. The pack FORMAT is uncompressed before archiving; the tar/zip
|
||||
container may compress it. Release extraction retains it as a standalone input
|
||||
so scanners can inspect the frontend independently of the native image.
|
||||
|
||||
cbm-integrations.json is the integration-template data file: the binary
|
||||
embeds only its SHA-256 and refuses to install integrations without a
|
||||
verified copy, so an archive without it produces a binary that cannot
|
||||
install. It ships NEXT TO the binary — the resolution path install.sh /
|
||||
install.ps1 rely on when they run `install` from the extracted archive.
|
||||
Only one build variant ships: the binary carries the graph UI and the agent
|
||||
integration templates inside itself, so an extracted archive is immediately
|
||||
complete — no adjacent data file has to resolve for `install` to work.
|
||||
EOF
|
||||
}
|
||||
|
||||
GOOS=""
|
||||
GOARCH=""
|
||||
VARIANT="standard"
|
||||
OUT_DIR="$ROOT"
|
||||
MAKE_ARGS=()
|
||||
expect_value=""
|
||||
for arg in "$@"; do
|
||||
case "$expect_value" in
|
||||
variant) VARIANT="$arg"; expect_value=""; continue ;;
|
||||
out-dir) OUT_DIR="$arg"; expect_value=""; continue ;;
|
||||
esac
|
||||
case "$arg" in
|
||||
-h | --help) usage; exit 0 ;;
|
||||
--variant) expect_value="variant" ;;
|
||||
--variant=*) VARIANT="${arg#--variant=}" ;;
|
||||
--out-dir) expect_value="out-dir" ;;
|
||||
--out-dir=*) OUT_DIR="${arg#--out-dir=}" ;;
|
||||
-*)
|
||||
@@ -92,66 +75,11 @@ case "$GOOS" in
|
||||
linux | darwin | windows) ;;
|
||||
*) echo "package-release: goos must be linux, darwin or windows." >&2; exit 2 ;;
|
||||
esac
|
||||
case "$VARIANT" in
|
||||
standard) SUFFIX="" ;;
|
||||
ui) SUFFIX="-ui" ;;
|
||||
*) echo "package-release: variant must be 'standard' or 'ui'." >&2; exit 2 ;;
|
||||
esac
|
||||
[ -n "$expect_value" ] && { echo "package-release: --$expect_value needs a value." >&2; exit 2; }
|
||||
|
||||
BUILD_DIR="${BUILD_DIR:-build/c}"
|
||||
OUT_DIR="$(mkdir -p "$OUT_DIR" && cd "$OUT_DIR" && pwd)"
|
||||
NAME="codebase-memory-mcp${SUFFIX}-${GOOS}-${GOARCH}"
|
||||
|
||||
sha256_file() {
|
||||
local path="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$path" | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$path" | awk '{print $1}'
|
||||
else
|
||||
echo "package-release: sha256sum or shasum is required to verify UI packs" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
verify_ui_pack_digest() {
|
||||
local path="$1"
|
||||
local name expected actual
|
||||
name="$(basename "$path")"
|
||||
expected="${name#cbm-ui-}"
|
||||
expected="${expected%.pack}"
|
||||
if ! actual="$(sha256_file "$path")"; then
|
||||
return 1
|
||||
fi
|
||||
actual="$(printf '%s' "$actual" | tr 'A-F' 'a-f')"
|
||||
if ! [[ "$actual" =~ ^[0-9a-f]{64}$ ]] || [ "$actual" != "$expected" ]; then
|
||||
echo "package-release: UI pack digest does not match its filename: $name" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve the content-addressed UI sidecar before touching the binary. A loose
|
||||
# glob is not the contract: every matching candidate must have the exact owned
|
||||
# name shape, and a UI archive must contain exactly one of them.
|
||||
UI_PACK_NAME=""
|
||||
UI_PACK_SOURCE=""
|
||||
if [ "$VARIANT" = "ui" ]; then
|
||||
shopt -s nullglob
|
||||
UI_PACK_CANDIDATES=("$BUILD_DIR"/cbm-ui-*.pack)
|
||||
shopt -u nullglob
|
||||
if [ "${#UI_PACK_CANDIDATES[@]}" -ne 1 ]; then
|
||||
echo "package-release: ui build must contain exactly one cbm-ui-<sha256>.pack" >&2
|
||||
exit 2
|
||||
fi
|
||||
UI_PACK_SOURCE="${UI_PACK_CANDIDATES[0]}"
|
||||
UI_PACK_NAME="$(basename "$UI_PACK_SOURCE")"
|
||||
if ! [[ "$UI_PACK_NAME" =~ ^cbm-ui-[0-9a-f]{64}\.pack$ ]] || [ ! -s "$UI_PACK_SOURCE" ]; then
|
||||
echo "package-release: invalid UI asset pack: $UI_PACK_SOURCE" >&2
|
||||
exit 2
|
||||
fi
|
||||
verify_ui_pack_digest "$UI_PACK_SOURCE" || exit 2
|
||||
fi
|
||||
NAME="codebase-memory-mcp-${GOOS}-${GOARCH}"
|
||||
|
||||
# Ship every release binary stripped. Production already builds without -g, but
|
||||
# the linker still keeps a ~536 KB .symtab, so releases carried their full
|
||||
@@ -246,46 +174,29 @@ strip_release_binary "$STAGED_BINARY" || exit 2
|
||||
|
||||
# Gate the artifact AFTER strip/re-sign: this is the final executable image and
|
||||
# no later step may mutate it.
|
||||
bash scripts/ci/check-binary-composition.sh --variant="$VARIANT" \
|
||||
"$STAGED_BINARY" || exit 2
|
||||
bash scripts/ci/check-binary-composition.sh "$STAGED_BINARY" || exit 2
|
||||
|
||||
# Stage the exact runtime association before any archive is created. Both
|
||||
# sidecars are regular copies in an otherwise empty private directory. The UI
|
||||
# pack's filename is self-authenticating, but only the executable knows whether
|
||||
# that otherwise-valid pack (and integration manifest) belong to THIS build.
|
||||
cp assets/cbm-integrations.json "$PACK_DIR/cbm-integrations.json"
|
||||
if [ "$VARIANT" = "ui" ]; then
|
||||
cp "$UI_PACK_SOURCE" "$PACK_DIR/$UI_PACK_NAME"
|
||||
fi
|
||||
cp LICENSE "$INSTALLER" "$PACK_DIR/"
|
||||
scripts/gen-third-party-notices.sh "$PACK_DIR/THIRD_PARTY_NOTICES.md"
|
||||
|
||||
if ! "$STAGED_BINARY" --verify-runtime-assets; then
|
||||
echo "package-release: staged binary rejected its adjacent runtime assets; refusing archive" >&2
|
||||
exit 2
|
||||
fi
|
||||
echo "=== package-release: staged runtime assets match $STAGED_BINARY_NAME ==="
|
||||
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
(
|
||||
cd "$PACK_DIR"
|
||||
rm -f "$OUT_DIR/$NAME.zip"
|
||||
ARCHIVE_MEMBERS=(
|
||||
codebase-memory-mcp.exe cbm-integrations.json LICENSE install.ps1
|
||||
codebase-memory-mcp.exe LICENSE install.ps1
|
||||
THIRD_PARTY_NOTICES.md
|
||||
)
|
||||
[ "$VARIANT" = "ui" ] && ARCHIVE_MEMBERS+=("$UI_PACK_NAME")
|
||||
zip -q "$OUT_DIR/$NAME.zip" "${ARCHIVE_MEMBERS[@]}"
|
||||
)
|
||||
echo "=== package-release: $OUT_DIR/$NAME.zip ==="
|
||||
else
|
||||
ARCHIVE_MEMBERS=(
|
||||
codebase-memory-mcp cbm-integrations.json LICENSE install.sh
|
||||
codebase-memory-mcp LICENSE install.sh
|
||||
THIRD_PARTY_NOTICES.md
|
||||
)
|
||||
[ "$VARIANT" = "ui" ] && ARCHIVE_MEMBERS+=("$UI_PACK_NAME")
|
||||
# BSD tar otherwise materializes macOS extended attributes as hidden
|
||||
# AppleDouble `._*` members, violating the exact five/six-file inventory.
|
||||
# AppleDouble `._*` members, violating the exact four-file inventory.
|
||||
COPYFILE_DISABLE=1 tar -czf "$OUT_DIR/$NAME.tar.gz" -C "$PACK_DIR" \
|
||||
"${ARCHIVE_MEMBERS[@]}"
|
||||
echo "=== package-release: $OUT_DIR/$NAME.tar.gz ==="
|
||||
|
||||
@@ -213,7 +213,7 @@ if ($FromSource) {
|
||||
Write-Host ""
|
||||
Write-Host "Building binary (this may take a minute)..." -ForegroundColor White
|
||||
$wslBinaryPath = "/home/$wslUser/.local/bin/$BinaryName"
|
||||
Invoke-WSL "mkdir -p /home/$wslUser/.local/bin && cd $sourceDir && scripts/build.sh && cp build/c/codebase-memory-mcp build/c/cbm-integrations.json /home/$wslUser/.local/bin/"
|
||||
Invoke-WSL "mkdir -p /home/$wslUser/.local/bin && cd $sourceDir && scripts/build.sh && cp build/c/codebase-memory-mcp $wslBinaryPath"
|
||||
Write-Ok "Built to $wslBinaryPath (inside WSL)"
|
||||
|
||||
# Verify
|
||||
@@ -238,7 +238,7 @@ if ($FromSource) {
|
||||
Write-Ok "Done! Restart Claude Code and verify with /mcp"
|
||||
Write-Host ""
|
||||
Write-Host " To uninstall:" -ForegroundColor White
|
||||
Write-Host " wsl.exe -- rm $wslBinaryPath /home/$wslUser/.local/bin/cbm-integrations.json"
|
||||
Write-Host " wsl.exe -- rm $wslBinaryPath"
|
||||
Write-Host " wsl.exe -- rm -rf $sourceDir"
|
||||
Write-Host " wsl.exe -- rm -rf ~/.cache/codebase-memory-mcp/"
|
||||
|
||||
|
||||
+1
-18
@@ -144,18 +144,6 @@ fetch() {
|
||||
fi
|
||||
}
|
||||
|
||||
install_integration_asset() {
|
||||
local source_dir="$1"
|
||||
local source_path="${source_dir}/cbm-integrations.json"
|
||||
|
||||
if [ ! -f "$source_path" ]; then
|
||||
die "Runtime asset missing from build or release: cbm-integrations.json"
|
||||
fi
|
||||
|
||||
cp "$source_path" "${INSTALL_DIR}/cbm-integrations.json"
|
||||
chmod 644 "${INSTALL_DIR}/cbm-integrations.json"
|
||||
}
|
||||
|
||||
download_binary() {
|
||||
local platform="$1" tool="$2"
|
||||
|
||||
@@ -183,7 +171,6 @@ download_binary() {
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
mv "${tmpdir}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
install_integration_asset "$tmpdir"
|
||||
|
||||
ok "Installed to ${INSTALL_DIR}/${BINARY_NAME}"
|
||||
}
|
||||
@@ -212,10 +199,7 @@ build_from_source() {
|
||||
echo "${BOLD}Building binary (this may take a minute)...${RESET}"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
(cd "$SOURCE_DIR" && scripts/build.sh)
|
||||
cp "${SOURCE_DIR}/build/c/codebase-memory-mcp" "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
install_integration_asset "${SOURCE_DIR}/build/c"
|
||||
(cd "$SOURCE_DIR" && scripts/build.sh && cp build/c/codebase-memory-mcp "${INSTALL_DIR}/${BINARY_NAME}")
|
||||
|
||||
ok "Built and installed to ${INSTALL_DIR}/${BINARY_NAME}"
|
||||
}
|
||||
@@ -347,6 +331,5 @@ ok "Done! Restart Claude Code and verify with /mcp"
|
||||
echo ""
|
||||
info "To uninstall:"
|
||||
info " rm ${INSTALL_DIR}/${BINARY_NAME}"
|
||||
info " rm ${INSTALL_DIR}/cbm-integrations.json"
|
||||
info " rm -rf ${SOURCE_DIR} # if built from source"
|
||||
info " rm -rf ~/.cache/codebase-memory-mcp/ # graph database"
|
||||
|
||||
+22
-55
@@ -21,15 +21,14 @@ destination override neutralized.
|
||||
|
||||
Arguments:
|
||||
<binary> Product binary to smoke (e.g. build/c/codebase-memory-mcp).
|
||||
ui Mirror the -ui asset naming AND require the external UI pack:
|
||||
ui Mirror the -ui asset naming AND require the embedded UI:
|
||||
Phase 15's "no assets" outcome becomes a FAILURE
|
||||
(SMOKE_REQUIRE_UI=1), so a standard binary cannot pass a ui run.
|
||||
|
||||
Environment:
|
||||
CBM_SMOKE_ARTIFACT_DIR Release mode: an EXTRACTED release artifact
|
||||
directory. Sidecars (LICENSE, install.sh, THIRD_PARTY_NOTICES.md,
|
||||
and the UI pack when selected) are validated and served from
|
||||
THERE instead of regenerated, so
|
||||
directory. Sidecars (LICENSE, install.sh, THIRD_PARTY_NOTICES.md)
|
||||
are validated and served from THERE instead of regenerated, so
|
||||
the release venue smokes exactly the bytes it publishes; an
|
||||
incomplete archive fails the smoke. Unset (default): sidecars
|
||||
come from this checkout (local/PR mode).
|
||||
@@ -57,9 +56,9 @@ if [ ! -x "$BINARY" ]; then
|
||||
echo "smoke-local: binary is not executable: $BINARY" >&2
|
||||
exit 2
|
||||
fi
|
||||
# A ui run must be handed a binary with exactly one external asset pack beside
|
||||
# it; the suffix alone only renames the archive. SMOKE_REQUIRE_UI turns Phase
|
||||
# 15's documented "no UI assets" SKIP into a failure, so asking for ui and
|
||||
# A ui run must be handed a binary that actually carries the embedded assets;
|
||||
# the suffix alone only renames the archive. SMOKE_REQUIRE_UI turns Phase 15's
|
||||
# documented "no embedded assets" SKIP into a failure, so asking for ui and
|
||||
# supplying a standard binary can no longer pass quietly.
|
||||
case "$VARIANT" in
|
||||
standard) SUFFIX="" ; REQUIRE_UI=0 ;;
|
||||
@@ -73,7 +72,7 @@ esac
|
||||
ARTIFACT_DIR="${CBM_SMOKE_ARTIFACT_DIR:-}"
|
||||
if [ -n "$ARTIFACT_DIR" ]; then
|
||||
ARTIFACT_DIR="$(cd "$ARTIFACT_DIR" && pwd)"
|
||||
for required in cbm-integrations.json LICENSE install.sh THIRD_PARTY_NOTICES.md; do
|
||||
for required in LICENSE install.sh THIRD_PARTY_NOTICES.md; do
|
||||
[ -s "$ARTIFACT_DIR/$required" ] || {
|
||||
echo "smoke-local: release artifact is missing $required" >&2
|
||||
exit 2
|
||||
@@ -81,37 +80,6 @@ if [ -n "$ARTIFACT_DIR" ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
UI_PACK_NAME=""
|
||||
UI_PACK_SOURCE=""
|
||||
UI_PACK_COUNT=0
|
||||
if [ -n "$ARTIFACT_DIR" ]; then
|
||||
UI_PACK_SOURCE_DIR="$ARTIFACT_DIR"
|
||||
else
|
||||
UI_PACK_SOURCE_DIR="$(dirname "$BINARY")"
|
||||
fi
|
||||
shopt -s nullglob
|
||||
UI_PACK_CANDIDATES=("$UI_PACK_SOURCE_DIR"/cbm-ui-*.pack)
|
||||
shopt -u nullglob
|
||||
for candidate in "${UI_PACK_CANDIDATES[@]+"${UI_PACK_CANDIDATES[@]}"}"; do
|
||||
UI_PACK_COUNT=$((UI_PACK_COUNT + 1))
|
||||
UI_PACK_SOURCE="$candidate"
|
||||
candidate_name="$(basename "$candidate")"
|
||||
if ! [[ "$candidate_name" =~ ^cbm-ui-[0-9a-f]{64}\.pack$ ]]; then
|
||||
echo "smoke-local: invalid UI pack name beside artifact: $candidate_name" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
if [ "$VARIANT" = "ui" ]; then
|
||||
if [ "$UI_PACK_COUNT" -ne 1 ] || [ ! -s "$UI_PACK_SOURCE" ]; then
|
||||
echo "smoke-local: ui fixture requires exactly one content-addressed UI pack" >&2
|
||||
exit 2
|
||||
fi
|
||||
UI_PACK_NAME="$(basename "$UI_PACK_SOURCE")"
|
||||
elif [ "$UI_PACK_COUNT" -ne 0 ]; then
|
||||
echo "smoke-local: standard fixture must not contain a UI asset pack" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin) OS=darwin ;;
|
||||
Linux) OS=linux ;;
|
||||
@@ -155,34 +123,33 @@ mkdir -p "$FIXTURE_DIR" "$SMOKE_TEMP_DIR" "$SMOKE_HOME" "$SMOKE_XDG_CONFIG" \
|
||||
"$SMOKE_APPDATA" "$SMOKE_LOCALAPPDATA"
|
||||
cp "$BINARY" "$FIXTURE_DIR/codebase-memory-mcp"
|
||||
if [ -n "$ARTIFACT_DIR" ]; then
|
||||
cp "$ARTIFACT_DIR/cbm-integrations.json" "$ARTIFACT_DIR/LICENSE" \
|
||||
cp "$ARTIFACT_DIR/LICENSE" \
|
||||
"$ARTIFACT_DIR/install.sh" "$ARTIFACT_DIR/THIRD_PARTY_NOTICES.md" "$FIXTURE_DIR/"
|
||||
else
|
||||
cp "$ROOT/assets/cbm-integrations.json" "$ROOT/LICENSE" "$ROOT/install.sh" "$FIXTURE_DIR/"
|
||||
cp "$ROOT/LICENSE" "$ROOT/install.sh" "$FIXTURE_DIR/"
|
||||
"$ROOT/scripts/gen-third-party-notices.sh" "$FIXTURE_DIR/THIRD_PARTY_NOTICES.md"
|
||||
fi
|
||||
if [ -n "$UI_PACK_SOURCE" ]; then
|
||||
cp "$UI_PACK_SOURCE" "$FIXTURE_DIR/$UI_PACK_NAME"
|
||||
fi
|
||||
|
||||
# The archive must carry cbm-integrations.json — install verifies it against the
|
||||
# binary's embedded SHA-256 and fails closed without it. Member set and ORDER
|
||||
# mirror scripts/package-release.sh (the Windows one-executable runtime-set
|
||||
# contract locks that order); a fixture that omits it would smoke a release
|
||||
# layout we never ship.
|
||||
# Member set and ORDER mirror scripts/package-release.sh (the Windows
|
||||
# single-binary contract locks that order); a fixture with a different inventory
|
||||
# would smoke a release layout we never ship.
|
||||
EXPECTED_ARTIFACT="codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}.tar.gz"
|
||||
ARCHIVE_MEMBERS=(
|
||||
codebase-memory-mcp cbm-integrations.json LICENSE install.sh
|
||||
THIRD_PARTY_NOTICES.md
|
||||
)
|
||||
[ -n "$UI_PACK_NAME" ] && ARCHIVE_MEMBERS+=("$UI_PACK_NAME")
|
||||
tar -czf "$FIXTURE_DIR/$EXPECTED_ARTIFACT" -C "$FIXTURE_DIR" "${ARCHIVE_MEMBERS[@]}"
|
||||
tar -czf "$FIXTURE_DIR/$EXPECTED_ARTIFACT" -C "$FIXTURE_DIR" \
|
||||
codebase-memory-mcp LICENSE install.sh THIRD_PARTY_NOTICES.md
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
cp "$FIXTURE_DIR/$EXPECTED_ARTIFACT" \
|
||||
"$FIXTURE_DIR/codebase-memory-mcp-${OS}-${ARCH}.tar.gz"
|
||||
fi
|
||||
|
||||
# Linux install/update resolves the portable release asset even when this local
|
||||
# smoke started from the dynamic production binary.
|
||||
if [ "$OS" = "linux" ]; then
|
||||
cp "$FIXTURE_DIR/$EXPECTED_ARTIFACT" \
|
||||
"$FIXTURE_DIR/codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}-portable.tar.gz"
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
cp "$FIXTURE_DIR/codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}-portable.tar.gz" \
|
||||
"$FIXTURE_DIR/codebase-memory-mcp-${OS}-${ARCH}-portable.tar.gz"
|
||||
fi
|
||||
fi
|
||||
(cd "$FIXTURE_DIR" && { sha256sum *.tar.gz > checksums.txt 2>/dev/null ||
|
||||
shasum -a 256 *.tar.gz > checksums.txt; })
|
||||
|
||||
+43
-150
@@ -40,32 +40,6 @@ if [ -n "$SMOKE_MODE" ] && [ "$SMOKE_MODE" != "--agent-config-only" ]; then
|
||||
exit 2
|
||||
fi
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)"
|
||||
SMOKE_REQUIRE_UI="${SMOKE_REQUIRE_UI:-0}"
|
||||
|
||||
# A UI binary is a two-file runtime contract: native image plus exactly one
|
||||
# content-addressed pack beside it. Remember that source pack so every fixture
|
||||
# copy below stages the same sidecar rather than silently testing a binary-only
|
||||
# layout that no UI release ships.
|
||||
SMOKE_UI_PACK=""
|
||||
SMOKE_UI_PACK_COUNT=0
|
||||
shopt -s nullglob
|
||||
SMOKE_UI_PACK_CANDIDATES=("$(dirname "$BINARY")"/cbm-ui-*.pack)
|
||||
shopt -u nullglob
|
||||
for candidate in "${SMOKE_UI_PACK_CANDIDATES[@]+"${SMOKE_UI_PACK_CANDIDATES[@]}"}"; do
|
||||
SMOKE_UI_PACK_COUNT=$((SMOKE_UI_PACK_COUNT + 1))
|
||||
SMOKE_UI_PACK="$candidate"
|
||||
candidate_name="$(basename "$candidate")"
|
||||
if ! [[ "$candidate_name" =~ ^cbm-ui-[0-9a-f]{64}\.pack$ ]]; then
|
||||
echo "FAIL: invalid UI asset pack name beside smoke binary: $candidate_name"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ "$SMOKE_REQUIRE_UI" = "1" ]; then
|
||||
if [ "$SMOKE_UI_PACK_COUNT" -ne 1 ] || [ ! -s "$SMOKE_UI_PACK" ]; then
|
||||
echo "FAIL: SMOKE_REQUIRE_UI=1 requires exactly one UI asset pack beside $BINARY"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
smoke_mktemp_file() {
|
||||
if [ -n "${SMOKE_TEMP_ROOT:-}" ]; then
|
||||
@@ -119,14 +93,11 @@ smoke_rmtree() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Every platform ships one native binary. UI runtime copies also require their
|
||||
# external, content-addressed pack beside that binary.
|
||||
# Every platform ships ONE binary, Windows included: a fixture copy is complete
|
||||
# with nothing beside it.
|
||||
copy_smoke_binary() {
|
||||
local destination="$1"
|
||||
cp "$BINARY" "$destination"
|
||||
if [ -n "$SMOKE_UI_PACK" ]; then
|
||||
cp "$SMOKE_UI_PACK" "$(dirname "$destination")/$(basename "$SMOKE_UI_PACK")"
|
||||
fi
|
||||
}
|
||||
|
||||
# Retire the shared account daemon (if one is running) and wait until it
|
||||
@@ -1090,7 +1061,7 @@ echo "OK: uninstall --dry-run completed"
|
||||
# handoff: it prints the shipped install script's command and exits 0. An
|
||||
# in-process updater is structurally a downloader -- fetch archive, extract,
|
||||
# chmod, exec -- which is impossible on Windows without a second resident
|
||||
# binary, and is unnecessary dropper-like surface in every platform artifact.
|
||||
# binary, and is the shape Defender's ML scores as a dropper everywhere else.
|
||||
echo "--- Phase 6c: update --dry-run ---"
|
||||
if [[ "$BINARY" == *.exe ]]; then
|
||||
UPDATE_SCRIPT="install.ps1"
|
||||
@@ -1369,7 +1340,7 @@ if [[ "$BINARY" == *.exe ]]; then
|
||||
echo "FAIL 8-0: installed binary missing after install"
|
||||
exit 1
|
||||
fi
|
||||
# ONE executable, one link: a second hard link here would mean the retired
|
||||
# ONE binary, one link: a second hard link here would mean the retired
|
||||
# launcher/generation layout came back.
|
||||
PHASE8_LINKS=$(stat -c %h "$PHASE8_CANONICAL" 2>/dev/null || echo 1)
|
||||
if [ "$PHASE8_LINKS" != "1" ]; then
|
||||
@@ -2482,11 +2453,11 @@ echo "OK 8aw: all supported Scout/Verify/Auditor profile sets"
|
||||
echo ""
|
||||
echo "=== Phase 9: agent config uninstall E2E ==="
|
||||
|
||||
# Run uninstall from the installed copy. Uninstall intentionally targets the
|
||||
# exact executable that invoked it; using the build input here removes the
|
||||
# harness binary on POSIX and makes every later adversarial case test a missing
|
||||
# command instead of the product.
|
||||
UNINSTALL_BINARY="$SELF_PATH"
|
||||
# Run uninstall (same FAKE_HOME with all configs present)
|
||||
UNINSTALL_BINARY="$BINARY"
|
||||
if [[ "$BINARY" == *.exe ]]; then
|
||||
UNINSTALL_BINARY="$SELF_PATH"
|
||||
fi
|
||||
HOME="$FAKE_HOME" \
|
||||
XDG_CONFIG_HOME="$FAKE_HOME/.config" \
|
||||
APPDATA="$FAKE_HOME/AppData/Roaming" \
|
||||
@@ -2885,14 +2856,8 @@ smoke_rmtree "$IDEM_HOME"
|
||||
# 9b-3: Uninstall without prior install
|
||||
CLEAN_HOME=$(smoke_mktemp_dir)
|
||||
mkdir -p "$CLEAN_HOME/.claude" "$CLEAN_HOME/.local/bin"
|
||||
CLEAN_UNINSTALLER="$CLEAN_HOME/cbm-uninstall-driver"
|
||||
if [[ "$BINARY" == *.exe ]]; then
|
||||
CLEAN_UNINSTALLER="$CLEAN_UNINSTALLER.exe"
|
||||
fi
|
||||
copy_smoke_binary "$CLEAN_UNINSTALLER"
|
||||
chmod 755 "$CLEAN_UNINSTALLER"
|
||||
UNINSTALL_RC=0
|
||||
UNINSTALL_OUT=$(HOME="$CLEAN_HOME" "$CLEAN_UNINSTALLER" uninstall -y -n 2>&1) || UNINSTALL_RC=$?
|
||||
UNINSTALL_OUT=$(HOME="$CLEAN_HOME" "$BINARY" uninstall -y -n 2>&1) || UNINSTALL_RC=$?
|
||||
if [ "$UNINSTALL_RC" -ge 128 ]; then
|
||||
echo "FAIL 9b-3: uninstall crashed (rc=$UNINSTALL_RC)"
|
||||
exit 1
|
||||
@@ -2915,18 +2880,14 @@ smoke_rmtree "$CORRUPT_HOME"
|
||||
# 9b-8: Double uninstall
|
||||
DBL_HOME=$(smoke_mktemp_dir)
|
||||
mkdir -p "$DBL_HOME/.claude" "$DBL_HOME/.local/bin"
|
||||
DBL_UNINSTALLER="$DBL_HOME/.local/bin/codebase-memory-mcp"
|
||||
DBL_RETRY_UNINSTALLER="$DBL_HOME/cbm-uninstall-retry"
|
||||
if [[ "$BINARY" == *.exe ]]; then
|
||||
DBL_UNINSTALLER="$DBL_UNINSTALLER.exe"
|
||||
DBL_RETRY_UNINSTALLER="$DBL_RETRY_UNINSTALLER.exe"
|
||||
fi
|
||||
copy_smoke_binary "$DBL_UNINSTALLER"
|
||||
copy_smoke_binary "$DBL_RETRY_UNINSTALLER"
|
||||
chmod 755 "$DBL_UNINSTALLER" "$DBL_RETRY_UNINSTALLER"
|
||||
copy_smoke_binary "$DBL_HOME/.local/bin/codebase-memory-mcp"
|
||||
run_no_crash 9b-8-install env HOME="$DBL_HOME" "$BINARY" install -y
|
||||
DBL_UNINSTALLER="$BINARY"
|
||||
if [[ "$BINARY" == *.exe ]]; then
|
||||
DBL_UNINSTALLER="$DBL_HOME/.local/bin/codebase-memory-mcp.exe"
|
||||
fi
|
||||
run_no_crash 9b-8-first env HOME="$DBL_HOME" "$DBL_UNINSTALLER" uninstall -y -n
|
||||
run_no_crash 9b-8-second env HOME="$DBL_HOME" "$DBL_RETRY_UNINSTALLER" uninstall -y -n
|
||||
run_no_crash 9b-8-second env HOME="$DBL_HOME" "$BINARY" uninstall -y -n
|
||||
echo "OK 9b-8: double uninstall doesn't crash"
|
||||
retire_account_daemon "9b-8-cleanup"
|
||||
smoke_rmtree "$DBL_HOME"
|
||||
@@ -3108,23 +3069,18 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then
|
||||
UPDATE_HOME=$(smoke_mktemp_dir)
|
||||
mkdir -p "$UPDATE_HOME/.claude" "$UPDATE_HOME/.local/bin"
|
||||
# This phase stages the binary by hand into a fresh HOME — it does NOT go
|
||||
# through install.sh or `install`, so nothing populates ~/.cbm/assets. The
|
||||
# integration asset ships next to the binary in every real layout (archive,
|
||||
# build tree, installed dir), so stage it next to each staged copy here too;
|
||||
# otherwise the uninstall this phase drives fails closed rendering templates.
|
||||
BINARY_ASSET="$(dirname "$BINARY")/cbm-integrations.json"
|
||||
# through install.sh or `install`. The binary is self-contained, so a staged
|
||||
# copy is immediately able to render and remove its own integrations.
|
||||
if [[ "$BINARY" == *.exe ]]; then
|
||||
copy_smoke_binary "$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe"
|
||||
cp "$BINARY" "$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe"
|
||||
mkdir -p "$UPDATE_HOME/retired-install"
|
||||
copy_smoke_binary "$UPDATE_HOME/retired-install/codebase-memory-mcp.exe"
|
||||
[ -f "$BINARY_ASSET" ] && cp "$BINARY_ASSET" "$UPDATE_HOME/.local/bin/cbm-integrations.json"
|
||||
cp "$BINARY" "$UPDATE_HOME/retired-install/codebase-memory-mcp.exe"
|
||||
else
|
||||
copy_smoke_binary "$UPDATE_HOME/.local/bin/codebase-memory-mcp"
|
||||
cp "$BINARY" "$UPDATE_HOME/.local/bin/codebase-memory-mcp"
|
||||
chmod 755 "$UPDATE_HOME/.local/bin/codebase-memory-mcp"
|
||||
mkdir -p "$UPDATE_HOME/retired-install"
|
||||
copy_smoke_binary "$UPDATE_HOME/retired-install/codebase-memory-mcp"
|
||||
cp "$BINARY" "$UPDATE_HOME/retired-install/codebase-memory-mcp"
|
||||
chmod 755 "$UPDATE_HOME/retired-install/codebase-memory-mcp"
|
||||
[ -f "$BINARY_ASSET" ] && cp "$BINARY_ASSET" "$UPDATE_HOME/.local/bin/cbm-integrations.json"
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
codesign --sign - --force "$UPDATE_HOME/.local/bin/codebase-memory-mcp" 2>/dev/null || true
|
||||
codesign --sign - --force "$UPDATE_HOME/retired-install/codebase-memory-mcp" \
|
||||
@@ -3178,10 +3134,9 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then
|
||||
|
||||
# Contract, every platform: update NEVER replaces the running image in
|
||||
# process. It exits 0 and prints the shipped install script's command. On
|
||||
# Windows regressing this means reintroducing the removed launcher stub;
|
||||
# Windows regressing this means reintroducing the AV-flagged launcher stub;
|
||||
# everywhere else it means putting download -> extract -> chmod -> exec back
|
||||
# into the product binary. Historical scanner results motivated measuring
|
||||
# this boundary but do not attribute a classifier decision to it.
|
||||
# into the product binary.
|
||||
if [ "$UPDATE_RC" -ne 0 ]; then
|
||||
echo "FAIL 14a: update exited rc=$UPDATE_RC (expected 0)"
|
||||
exit 1
|
||||
@@ -3321,7 +3276,6 @@ fi
|
||||
# Try standard name first, fall back to UI variant
|
||||
DL_ARCHIVE="codebase-memory-mcp-${DL_OS}-${DL_ARCH}.${DL_EXT}"
|
||||
DL_ARCHIVE_UI="codebase-memory-mcp-ui-${DL_OS}-${DL_ARCH}.${DL_EXT}"
|
||||
DL_VARIANT_ARG="--standard"
|
||||
|
||||
# 12a: curl download (try standard, then UI variant)
|
||||
echo "--- Phase 12a: curl download ---"
|
||||
@@ -3334,7 +3288,6 @@ if ! curl -fSL --noproxy '*' -o "$DL_DIR/$DL_ARCHIVE" "$SMOKE_DOWNLOAD_URL/$DL_A
|
||||
# Try UI variant
|
||||
if curl -fSL --noproxy '*' -o "$DL_DIR/$DL_ARCHIVE_UI" "$SMOKE_DOWNLOAD_URL/$DL_ARCHIVE_UI" 2>>"$CURL12_ERR"; then
|
||||
DL_ARCHIVE="$DL_ARCHIVE_UI"
|
||||
DL_VARIANT_ARG="--ui"
|
||||
else
|
||||
echo "FAIL 12a: curl download failed (tried standard and ui variants)"
|
||||
echo "--- curl stderr (url: $SMOKE_DOWNLOAD_URL/$DL_ARCHIVE) ---"
|
||||
@@ -3381,8 +3334,8 @@ echo "--- Phase 12d: extraction ---"
|
||||
(cd "$DL_DIR" && if [ "$DL_EXT" = "zip" ]; then unzip -q "$DL_ARCHIVE"; else tar -xzf "$DL_ARCHIVE"; fi)
|
||||
if [ "$DL_OS" = "windows" ]; then
|
||||
DL_BIN="$DL_DIR/codebase-memory-mcp.exe"
|
||||
# ONE executable per runtime set: a second executable in the archive would
|
||||
# mean the removed launcher/payload split came back.
|
||||
# ONE binary per platform: a second executable in the archive would mean the
|
||||
# AV-flagged launcher/payload split came back.
|
||||
if [ -e "$DL_DIR/codebase-memory-mcp.payload.exe" ]; then
|
||||
echo "FAIL 12d: Windows archive still ships a launcher/payload pair"
|
||||
exit 1
|
||||
@@ -3390,20 +3343,6 @@ if [ "$DL_OS" = "windows" ]; then
|
||||
else
|
||||
DL_BIN="$DL_DIR/codebase-memory-mcp"
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
DL_UI_PACKS=("$DL_DIR"/cbm-ui-*.pack)
|
||||
shopt -u nullglob
|
||||
if [ "$DL_VARIANT_ARG" = "--ui" ]; then
|
||||
if [ "${#DL_UI_PACKS[@]}" -ne 1 ] ||
|
||||
! [[ "$(basename "${DL_UI_PACKS[0]}")" =~ ^cbm-ui-[0-9a-f]{64}\.pack$ ]]; then
|
||||
echo "FAIL 12d: UI archive does not contain exactly one content-addressed pack"
|
||||
exit 1
|
||||
fi
|
||||
elif [ "${#DL_UI_PACKS[@]}" -ne 0 ]; then
|
||||
echo "FAIL 12d: standard archive contains a UI asset pack"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$DL_BIN" ]; then
|
||||
echo "FAIL 12d: binary not found after extraction"
|
||||
exit 1
|
||||
@@ -3453,7 +3392,7 @@ if [ "$DL_OS" != "windows" ] && [ -f "$REPO_ROOT/install.sh" ]; then
|
||||
|
||||
# 13a: run install.sh with local URL + isolated HOME
|
||||
HOME="$INSTALL_TEST_HOME" CBM_DOWNLOAD_URL="$SMOKE_DOWNLOAD_URL" \
|
||||
"$REPO_ROOT/install.sh" "$DL_VARIANT_ARG" --dir="$INSTALL_TEST_DIR" 2>&1 || true
|
||||
"$REPO_ROOT/install.sh" --dir="$INSTALL_TEST_DIR" 2>&1 || true
|
||||
|
||||
# 13b: binary placed
|
||||
if [ ! -f "$INSTALL_TEST_DIR/codebase-memory-mcp" ]; then
|
||||
@@ -3462,21 +3401,6 @@ if [ "$DL_OS" != "windows" ] && [ -f "$REPO_ROOT/install.sh" ]; then
|
||||
fi
|
||||
echo "OK 13b: binary placed"
|
||||
|
||||
shopt -s nullglob
|
||||
INSTALLED_UI_PACKS=("$INSTALL_TEST_DIR"/cbm-ui-*.pack)
|
||||
shopt -u nullglob
|
||||
if [ "$DL_VARIANT_ARG" = "--ui" ]; then
|
||||
if [ "${#INSTALLED_UI_PACKS[@]}" -ne 1 ] ||
|
||||
! [[ "$(basename "${INSTALLED_UI_PACKS[0]}")" =~ ^cbm-ui-[0-9a-f]{64}\.pack$ ]]; then
|
||||
echo "FAIL 13b: install.sh did not publish exactly one UI asset pack"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK 13b: UI asset pack placed before binary activation"
|
||||
elif [ "${#INSTALLED_UI_PACKS[@]}" -ne 0 ]; then
|
||||
echo "FAIL 13b: standard install retained a UI asset pack"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 13c: binary runs
|
||||
# Sign if needed on macOS
|
||||
if [ "$DL_OS" = "darwin" ]; then
|
||||
@@ -3551,7 +3475,7 @@ elif [ -f "$REPO_ROOT/install.ps1" ] && command -v powershell.exe &>/dev/null; t
|
||||
if ! HOME="$WIN_HOME" TEMP="$WIN_HOME" TMP="$WIN_HOME" \
|
||||
CBM_DOWNLOAD_URL="$WIN_URL" CBM_ARCH="$DL_ARCH" MSYS2_ARG_CONV_EXCL='*' \
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File \
|
||||
"$WIN_SCRIPT" "$DL_VARIANT_ARG" "--dir=$WIN_DIR" 2>&1; then
|
||||
"$WIN_SCRIPT" "--dir=$WIN_DIR" 2>&1; then
|
||||
echo "FAIL 13f: install.ps1 execution failed"
|
||||
exit 1
|
||||
fi
|
||||
@@ -3568,21 +3492,6 @@ elif [ -f "$REPO_ROOT/install.ps1" ] && command -v powershell.exe &>/dev/null; t
|
||||
exit 1
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
PS1_UI_PACKS=("$PS1_TEST_DIR"/cbm-ui-*.pack)
|
||||
shopt -u nullglob
|
||||
if [ "$DL_VARIANT_ARG" = "--ui" ]; then
|
||||
if [ "${#PS1_UI_PACKS[@]}" -ne 1 ] ||
|
||||
! [[ "$(basename "${PS1_UI_PACKS[0]}")" =~ ^cbm-ui-[0-9a-f]{64}\.pack$ ]]; then
|
||||
echo "FAIL 13g: install.ps1 did not publish exactly one UI asset pack"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK 13g: UI asset pack placed before binary activation"
|
||||
elif [ "${#PS1_UI_PACKS[@]}" -ne 0 ]; then
|
||||
echo "FAIL 13g: standard install retained a UI asset pack"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 13h: binary runs
|
||||
if "$PS1_BIN" --version > /dev/null 2>&1; then
|
||||
echo "OK 13h: binary runs"
|
||||
@@ -3602,38 +3511,22 @@ else
|
||||
fi
|
||||
|
||||
# ── Phase 15: UI HTTP server reachability ──
|
||||
# Only runs if the binary has a verified external UI asset pack.
|
||||
# Only runs if the binary was built with embedded UI assets.
|
||||
#
|
||||
# SMOKE_REQUIRE_UI=1 (set by the wrappers for a -ui variant) makes the
|
||||
# no-assets outcome a FAILURE instead of a SKIP: a ui run that smoked a
|
||||
# standard binary under a ui name would otherwise pass green, and a skip that
|
||||
# cannot fail is not a gate.
|
||||
SMOKE_REQUIRE_UI="${SMOKE_REQUIRE_UI:-0}"
|
||||
smoke_ui_missing() {
|
||||
if [ "$SMOKE_REQUIRE_UI" = "1" ]; then
|
||||
echo "FAIL $1: SMOKE_REQUIRE_UI=1 but this binary serves no external UI assets"
|
||||
stop_ui_probe
|
||||
echo "FAIL $1: SMOKE_REQUIRE_UI=1 but this binary serves no embedded UI assets"
|
||||
kill "$UI_PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "SKIP $1: $2"
|
||||
}
|
||||
|
||||
# Keep stdin open without putting the product in a background pipeline. Bash
|
||||
# waits for every member of such a pipeline, so a headless binary that exits
|
||||
# immediately used to strand the `sleep 300` producer and pause the smoke for
|
||||
# five minutes. A process-substitution producer has its own PID and can always
|
||||
# be reaped explicitly on success, skip, or failure.
|
||||
stop_ui_probe() {
|
||||
if [ -n "${UI_PID:-}" ]; then
|
||||
kill "$UI_PID" 2>/dev/null || true
|
||||
wait "$UI_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [ -n "${UI_STDIN_PID:-}" ]; then
|
||||
kill "$UI_STDIN_PID" 2>/dev/null || true
|
||||
wait "$UI_STDIN_PID" 2>/dev/null || true
|
||||
fi
|
||||
exec 9<&- 2>/dev/null || true
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== Phase 15: UI HTTP server ==="
|
||||
|
||||
@@ -3644,17 +3537,15 @@ UI_PORT=$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0
|
||||
# --ui=true is REQUIRED: the HTTP UI is a persisted, default-off setting, so
|
||||
# on any fresh profile (every CI runner, every smoke HOME) a bare --port
|
||||
# invocation can never serve — the probe then misread "UI disabled" as "no
|
||||
# assets" on binaries with a valid pack (first exposed when the
|
||||
# embedded assets" on binaries that carry them (first exposed when the
|
||||
# ui-variant no-skip guard made Phase 15 mandatory). Stdin must be HELD OPEN:
|
||||
# the UI does not pin the process, so stdio EOF ends it cleanly (rc=0) before
|
||||
# the poll can see it serve — the drive-listing guard holds a pipe for the
|
||||
# same reason. Equals-form flags match that guard's proven invocation.
|
||||
exec 9< <(sleep 300)
|
||||
UI_STDIN_PID=$!
|
||||
"$BINARY" --ui=true --port="$UI_PORT" <&9 > /dev/null 2>&1 &
|
||||
sleep 300 | "$BINARY" --ui=true --port="$UI_PORT" > /dev/null 2>&1 &
|
||||
UI_PID=$!
|
||||
# Readiness poll instead of a fixed sleep: SKIP is legitimate ONLY when the
|
||||
# process exited (the documented no-asset-pack case); a slow start on a
|
||||
# process exited (the documented no-embedded-assets case); a slow start on a
|
||||
# loaded runner must not masquerade as it. The UI binds ~6s after launch even
|
||||
# on a fast host (measured against the release artifact), so the window
|
||||
# matches the drive-listing guard's 25s, not a 10s sprint.
|
||||
@@ -3666,15 +3557,15 @@ for _ in $(seq 1 150); do
|
||||
done
|
||||
|
||||
if [ "$UI_READY" -eq 1 ] || kill -0 "$UI_PID" 2>/dev/null; then
|
||||
# 15a: GET / returns 200 with HTML content from the external pack
|
||||
# 15a: GET / returns 200 with HTML content
|
||||
UI_BODY=$(curl -sf "http://127.0.0.1:$UI_PORT/" 2>/dev/null || echo "")
|
||||
if echo "$UI_BODY" | grep -qi "<html"; then
|
||||
echo "OK 15a: UI serves HTML at /"
|
||||
elif [ -z "$UI_BODY" ]; then
|
||||
smoke_ui_missing "15a" "UI not reachable (binary may not have a valid asset pack)"
|
||||
smoke_ui_missing "15a" "UI not reachable (binary may not have embedded assets)"
|
||||
else
|
||||
echo "FAIL 15a: UI root did not return HTML"
|
||||
stop_ui_probe
|
||||
kill "$UI_PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -3690,13 +3581,15 @@ if [ "$UI_READY" -eq 1 ] || kill -0 "$UI_PID" 2>/dev/null; then
|
||||
smoke_ui_missing "15b" "/api/ui-config not reachable"
|
||||
else
|
||||
echo "FAIL 15b: /api/ui-config did not return JSON"
|
||||
stop_ui_probe
|
||||
kill "$UI_PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
kill "$UI_PID" 2>/dev/null || true
|
||||
wait "$UI_PID" 2>/dev/null || true
|
||||
else
|
||||
smoke_ui_missing "Phase 15" "binary exited immediately (no UI asset pack loaded)"
|
||||
smoke_ui_missing "Phase 15" "binary exited immediately (no UI assets embedded)"
|
||||
fi
|
||||
stop_ui_probe
|
||||
|
||||
echo ""
|
||||
echo "=== Phase 16: stdio server leaves no orphan after shutdown ==="
|
||||
|
||||
+10
-39
@@ -6,8 +6,7 @@
|
||||
Builds the product binary if it is not already present, stages it under its
|
||||
release name, then runs the deterministic Windows integration tests under
|
||||
tests/windows/ against it (real stdio / CLI / HTTP UI, real SQLite DB).
|
||||
Windows ships one native executable inside the same verified runtime-set
|
||||
layout as Linux and macOS.
|
||||
Windows ships ONE binary, exactly like Linux and macOS.
|
||||
|
||||
Two categories of test:
|
||||
|
||||
@@ -22,11 +21,8 @@
|
||||
* test_daemon_stability.py guards the daemon parameter
|
||||
surface, crash recovery, busy-stop refusal, and churn
|
||||
* test_windows_update_handoff.py guards that `update`
|
||||
hands off to install.ps1 instead of replacing its
|
||||
currently running executable
|
||||
* test_daemon_open_readiness.py guards that
|
||||
`daemon start --open` waits for the verified CBM UI
|
||||
endpoint while ordinary daemon startup remains asynchronous
|
||||
hands off to install.ps1 instead of replacing its own
|
||||
running image (the removed launcher stub's only job)
|
||||
|
||||
KNOWN REDS - genuine, still-open Windows bugs reproduced at the product
|
||||
surface. They are EXPECTED to be RED (exit 1) and are opt-in
|
||||
@@ -50,7 +46,7 @@
|
||||
|
||||
.PARAMETER Target
|
||||
Makefile.cbm target used when building: 'cbm-with-ui' (default; needed for the
|
||||
drive-picker guard's UI-enabled HTTP service) or 'cbm' (no UI - the drive guard then
|
||||
drive-picker guard's embedded HTTP UI) or 'cbm' (no UI - the drive guard then
|
||||
reports a precondition and is skipped).
|
||||
|
||||
.PARAMETER GuardsOnly
|
||||
@@ -93,13 +89,9 @@ function Resolve-Binary {
|
||||
param([string]$Explicit)
|
||||
if ($Explicit) { return (Resolve-Path $Explicit).Path }
|
||||
$built = Join-Path $repoRoot "build\c\codebase-memory-mcp.exe"
|
||||
if (Test-Path $built) { return $built }
|
||||
Write-Host "Building $Target via Makefile.cbm ..." -ForegroundColor Cyan
|
||||
$makeArgs = @(
|
||||
"-j", "-f", "Makefile.cbm", $Target, "SANITIZE=",
|
||||
"TMP=$tmp", "TEMP=$tmp", "TMPDIR=$tmp"
|
||||
)
|
||||
if ($Target -eq "cbm-with-ui") { $makeArgs += "TEST_SEAMS=1" }
|
||||
& $Make @makeArgs | Out-Host
|
||||
& $Make "-j" "-f" "Makefile.cbm" $Target "SANITIZE=" "TMP=$tmp" "TEMP=$tmp" "TMPDIR=$tmp" | Out-Host
|
||||
$buildExit = $LASTEXITCODE
|
||||
if ($buildExit -ne 0) { throw "build failed (exit $buildExit)" }
|
||||
if (-not (Test-Path $built)) { throw "binary not produced at $built" }
|
||||
@@ -142,22 +134,12 @@ try {
|
||||
New-Item -ItemType Directory -Path $guardBundle | Out-Null
|
||||
$guardBin = Join-Path $guardBundle "codebase-memory-mcp.exe"
|
||||
Copy-Item -LiteralPath $bin -Destination $guardBin
|
||||
$sourceRuntimeDir = Split-Path -Parent $bin
|
||||
$sourceIntegration = Join-Path $sourceRuntimeDir "cbm-integrations.json"
|
||||
if (-not (Test-Path -LiteralPath $sourceIntegration -PathType Leaf)) {
|
||||
throw "Windows guard source runtime is missing cbm-integrations.json beside $bin"
|
||||
}
|
||||
Copy-Item -LiteralPath $sourceIntegration -Destination $guardBundle
|
||||
Get-ChildItem -LiteralPath $sourceRuntimeDir -File -Filter "cbm-ui-*.pack" |
|
||||
ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination $guardBundle }
|
||||
|
||||
# Ownership is never inherited on Windows: descendants created under the
|
||||
# hardened root by an admin-group token can default to the Administrators
|
||||
# SID, while the exe policy demands the exact current user as owner. Stamp
|
||||
# the current SID explicitly on everything staged here.
|
||||
$stagedRuntime = @($guardBundle) + @(Get-ChildItem -LiteralPath $guardBundle -File |
|
||||
ForEach-Object { $_.FullName })
|
||||
foreach ($staged in $stagedRuntime) {
|
||||
foreach ($staged in @($guardBundle, $guardBin)) {
|
||||
$stagedAcl = Get-Acl -LiteralPath $staged
|
||||
$stagedAcl.SetOwner($currentSid)
|
||||
Set-Acl -LiteralPath $staged -AclObject $stagedAcl
|
||||
@@ -173,9 +155,9 @@ try {
|
||||
$env:PYTHONUTF8 = "1" # encode argv/stdio as UTF-8
|
||||
|
||||
# Green regression guards - must stay GREEN (exit 0). RED (exit 1) = the fix for
|
||||
# the referenced issue regressed. The drive-picker guard needs the UI-enabled
|
||||
# build and its external asset pack (target cbm-with-ui); against a non-UI
|
||||
# binary it reports a precondition (exit 2) and is skipped rather than failed.
|
||||
# the referenced issue regressed. The drive-picker guard needs the embedded HTTP
|
||||
# UI (build target cbm-with-ui); against a non-UI binary it reports a precondition
|
||||
# (exit 2) and is skipped rather than failed.
|
||||
$guards = @(
|
||||
"tests\windows\test_non_ascii_path.py",
|
||||
"tests\windows\test_non_ascii_cache_dump.py",
|
||||
@@ -186,10 +168,6 @@ $guards = @(
|
||||
"tests\windows\test_cli_non_ascii_arg.py",
|
||||
"tests\windows\test_windows_update_handoff.py"
|
||||
)
|
||||
$readinessGuard = "tests\test_daemon_open_readiness.py"
|
||||
if (Get-ChildItem -LiteralPath $guardBundle -File -Filter "cbm-ui-*.pack") {
|
||||
$guards += $readinessGuard
|
||||
}
|
||||
|
||||
# Opt-in known-red repros - EXPECTED red (exit 1); never gate CI. Currently empty:
|
||||
# test_cli_non_ascii_arg.py was promoted to a guard when #423/#20's wide-argv fix landed.
|
||||
@@ -209,13 +187,6 @@ foreach ($t in $guards) {
|
||||
} elseif ($code -eq 1 -or $t -eq "tests\windows\test_windows_update_handoff.py") {
|
||||
Write-Host "RED ($t) - REGRESSION: a fixed Windows bug is broken again" -ForegroundColor Red
|
||||
$guardFailures += $t
|
||||
} elseif ($code -eq 2 -and $t -eq $readinessGuard) {
|
||||
# This guard must run against a UI runtime set built with TEST_SEAMS=1.
|
||||
# Treat a missing pack/seam as a broken gate, never a green precondition
|
||||
# skip; the workflow's guard build deliberately supplies both.
|
||||
Write-Host "FAILED ($t) exit=2 - UI readiness fixture is incomplete or lacks TEST_SEAMS=1" `
|
||||
-ForegroundColor Red
|
||||
$guardFailures += $t
|
||||
} elseif ($code -eq 2) {
|
||||
# Exit 2 is the guards' DOCUMENTED precondition-skip contract; every
|
||||
# other unexpected code (a crashed python, an access-violation status,
|
||||
|
||||
+3
-38
@@ -14,7 +14,7 @@ Usage: scripts/test.sh [--suites LIST] [--arch ARCH] [VAR=VAL ...]
|
||||
|
||||
The canonical test entry: identical in local CI, PR CI, dry run and release.
|
||||
DEFAULT (no --suites) is exactly what CI runs: static contract checks
|
||||
(Step 0a-0r), a CLEAN sanitizer build, every suite via the parallel harness,
|
||||
(Step 0a-0j), a CLEAN sanitizer build, every suite via the parallel harness,
|
||||
then the prod-binary regression guards (Steps 4-6).
|
||||
|
||||
Modes:
|
||||
@@ -198,7 +198,7 @@ if [ -n "$SUITES" ]; then
|
||||
fi
|
||||
|
||||
# Step 0: fast build/security harness regressions run before the compiler-heavy
|
||||
# suite. The Windows package surface is static here; native runtime behavior is
|
||||
# suite. The Windows package surface is static here; native launcher behavior is
|
||||
# exercised by scripts/test-windows.ps1.
|
||||
echo "=== Step 0a: build directory safety contract ==="
|
||||
bash "$ROOT/tests/test_build_dir_safety.sh"
|
||||
@@ -212,7 +212,7 @@ bash "$ROOT/tests/test_ui_dev_proxy_security.sh"
|
||||
echo "=== Step 0d: daemon soak recovery contract ==="
|
||||
bash "$ROOT/tests/test_soak_daemon_recovery_contract.sh"
|
||||
|
||||
echo "=== Step 0e: Windows runtime bundle contract ==="
|
||||
echo "=== Step 0e: Windows launcher bundle contract ==="
|
||||
bash "$ROOT/tests/test_windows_bundle_contract.sh"
|
||||
|
||||
echo "=== Step 0f: tree-sitter runtime Makefile dependencies ==="
|
||||
@@ -233,27 +233,6 @@ bash "$ROOT/tests/test_venue_parity_contract.sh"
|
||||
echo "=== Step 0k: spawn console-window contract (#1427) ==="
|
||||
bash "$ROOT/tests/test_spawn_no_window_contract.sh"
|
||||
|
||||
echo "=== Step 0l: no embedded integration-script text (AV surface) ==="
|
||||
bash "$ROOT/tests/test_no_embedded_scripts_contract.sh"
|
||||
|
||||
echo "=== Step 0m: external UI asset-pack contract ==="
|
||||
bash "$ROOT/tests/test_ui_asset_pack_contract.sh"
|
||||
|
||||
echo "=== Step 0n: complete vendored-integrity contract ==="
|
||||
bash "$ROOT/tests/test_vendored_integrity_contract.sh"
|
||||
|
||||
echo "=== Step 0o: package runtime-asset retention contract ==="
|
||||
bash "$ROOT/tests/test_package_runtime_assets_contract.sh"
|
||||
|
||||
echo "=== Step 0p: exact release archive extraction contract ==="
|
||||
bash "$ROOT/tests/test_release_archive_extractor_contract.sh"
|
||||
|
||||
echo "=== Step 0q: exact-set VirusTotal gate contract ==="
|
||||
bash "$ROOT/tests/test_vt_gate_zero_tolerance_contract.sh"
|
||||
|
||||
echo "=== Step 0r: VirusTotal release-note evidence contract ==="
|
||||
bash "$ROOT/tests/test_vt_release_notes_contract.sh"
|
||||
|
||||
# Verify compiler supports target arch
|
||||
verify_compiler "$CC"
|
||||
|
||||
@@ -319,20 +298,6 @@ CBM_TEST_BINARY="$WATCHDOG_BINARY" bash "$ROOT/tests/test_worker_error_response.
|
||||
# skipping it silently would hide the gap. Run it by hand:
|
||||
# make -f Makefile.cbm cbm TEST_SEAMS=1 && bash tests/test_hook_conflict_notice.sh
|
||||
|
||||
# Step 5e: `daemon start --open` is the only UI startup operation that waits
|
||||
# synchronously. Build the real product against a tiny deterministic external
|
||||
# pack (no npm/network dependency) and prove it waits for the CBM HTTP root,
|
||||
# rejects a foreign occupied port, and fails closed when the pack is missing.
|
||||
# This runs here, after the other seam-bearing product guards, so every native
|
||||
# local-CI leg exercises the exact executable behavior without lengthening the
|
||||
# daemon's asynchronous startup critical path.
|
||||
echo "=== Step 5e: daemon UI --open readiness ==="
|
||||
make -j"$NPROC" -f Makefile.cbm cbm-with-ui TEST_SEAMS=1 \
|
||||
UI_ASSET_PREBUILT=1 UI_ASSET_DIST="$ROOT/tests/fixtures/ui-readiness" \
|
||||
UI_ASSET_MANIFEST="$BUILD_DIR/generated/ui_asset_readiness_manifest.c" \
|
||||
${MAKE_ARGS[@]+"${MAKE_ARGS[@]}"}
|
||||
python3 "$ROOT/tests/test_daemon_open_readiness.py" "$ROOT/$BUILD_DIR/codebase-memory-mcp"
|
||||
|
||||
# Step 6: security-strings URL allow-list regression. The MSYS2 CLANG64 toolchain
|
||||
# bakes its package-tracker URL into the static Windows .exe; the binary string
|
||||
# audit must allow-list it (Windows-only — Linux smoke never saw it).
|
||||
|
||||
+500
-804
File diff suppressed because it is too large
Load Diff
@@ -449,11 +449,6 @@ int cbm_cmd_install(int argc, char **argv);
|
||||
/* uninstall: remove skills, remove editor MCP configs, remove binary. */
|
||||
int cbm_cmd_uninstall(int argc, char **argv);
|
||||
|
||||
/* Hidden package-wrapper health probe. Verifies only immutable sidecars
|
||||
* adjacent to the running executable; never consults fallbacks or mutates. */
|
||||
int cbm_cmd_verify_runtime_assets(void);
|
||||
bool cbm_cli_verify_runtime_assets_at(const char *binary_path, char *err, size_t err_sz);
|
||||
|
||||
/* update: check latest release, prompt for index deletion, prompt for ui/standard,
|
||||
* download and replace binary. */
|
||||
int cbm_cmd_update(int argc, char **argv);
|
||||
|
||||
+105
-79
@@ -1,12 +1,10 @@
|
||||
/*
|
||||
* client_adapter.c — Generate client extension modules from the MCP tool registry.
|
||||
*
|
||||
* See client_adapter.h for how generation is split between the verified
|
||||
* template (module scaffolding) and the live registry (tool list).
|
||||
* See client_adapter.h for why these are generated rather than shipped.
|
||||
*/
|
||||
#include "cli/client_adapter.h"
|
||||
|
||||
#include "cli/integration_assets.h"
|
||||
#include "foundation/constants.h"
|
||||
#include "mcp/mcp.h"
|
||||
|
||||
@@ -24,10 +22,11 @@ typedef struct {
|
||||
bool failed;
|
||||
} adapter_sb_t;
|
||||
|
||||
static void sb_append_len(adapter_sb_t *sb, const char *s, size_t add) {
|
||||
static void sb_append(adapter_sb_t *sb, const char *s) {
|
||||
if (sb->failed || !s) {
|
||||
return;
|
||||
}
|
||||
size_t add = strlen(s);
|
||||
if (sb->len + add + 1 > sb->cap) {
|
||||
size_t want = sb->cap ? sb->cap : CBM_SZ_2K;
|
||||
while (want < sb->len + add + 1) {
|
||||
@@ -50,47 +49,6 @@ static void sb_append_len(adapter_sb_t *sb, const char *s, size_t add) {
|
||||
sb->buf[sb->len] = '\0';
|
||||
}
|
||||
|
||||
static void sb_append(adapter_sb_t *sb, const char *s) {
|
||||
if (!sb->failed && s) {
|
||||
sb_append_len(sb, s, strlen(s));
|
||||
}
|
||||
}
|
||||
|
||||
/* Append template text, substituting each named placeholder with its value.
|
||||
* An unresolved "{{" fails the build: a placeholder must never survive into
|
||||
* a module a client will execute. */
|
||||
static void sb_append_template(adapter_sb_t *sb, const char *text, const char *const names[],
|
||||
const char *const values[], size_t pair_count) {
|
||||
if (sb->failed || !text) {
|
||||
sb->failed = true;
|
||||
return;
|
||||
}
|
||||
const char *cursor = text;
|
||||
while (!sb->failed && *cursor) {
|
||||
const char *brace = strstr(cursor, "{{");
|
||||
if (!brace) {
|
||||
sb_append(sb, cursor);
|
||||
return;
|
||||
}
|
||||
sb_append_len(sb, cursor, (size_t)(brace - cursor));
|
||||
size_t matched = 0U;
|
||||
for (size_t i = 0U; i < pair_count; i++) {
|
||||
size_t name_length = strlen(names[i]);
|
||||
if (strncmp(brace + PAIR_LEN, names[i], name_length) == 0 &&
|
||||
strncmp(brace + PAIR_LEN + name_length, "}}", PAIR_LEN) == 0) {
|
||||
sb_append(sb, values[i]);
|
||||
matched = PAIR_LEN + name_length + PAIR_LEN;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched == 0U) {
|
||||
sb->failed = true;
|
||||
return;
|
||||
}
|
||||
cursor = brace + matched;
|
||||
}
|
||||
}
|
||||
|
||||
bool cbm_client_adapter_escape_js(const char *in, char *out, size_t out_sz) {
|
||||
if (!in || !out || out_sz == 0) {
|
||||
return false;
|
||||
@@ -133,13 +91,20 @@ bool cbm_client_adapter_escape_js(const char *in, char *out, size_t out_sz) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* The module scaffolding (spawn bridge, header note, plugin surface) comes
|
||||
* from the hash-verified cbm-integrations.json — those child-process-spawning
|
||||
* bytes must not live inside the binary (AV surface) — while the tool list is
|
||||
* still generated from the live registry, so the adapter cannot drift from
|
||||
* the tools the server actually serves. No markers in the template: the
|
||||
* managed-block writer adds them and REJECTS content that already carries
|
||||
* them, so the body must stay marker-free. */
|
||||
/* Shared preamble: the ownership markers and a note telling whoever opens the
|
||||
* file that edits inside the block are overwritten. */
|
||||
static void emit_header(adapter_sb_t *sb, const char *client) {
|
||||
/* No markers here: cbm_text_upsert_managed_block adds them and REJECTS
|
||||
* content that already carries them, so the body must stay marker-free. */
|
||||
sb_append(sb, "// Generated by codebase-memory-mcp for ");
|
||||
sb_append(sb, client);
|
||||
sb_append(sb,
|
||||
".\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");
|
||||
}
|
||||
|
||||
char *cbm_client_adapter_pi(const char *binary_path) {
|
||||
if (!binary_path || !binary_path[0]) {
|
||||
return NULL;
|
||||
@@ -152,34 +117,59 @@ char *cbm_client_adapter_pi(const char *binary_path) {
|
||||
if (count <= 0) {
|
||||
return NULL;
|
||||
}
|
||||
const cbm_integration_template_t *tpl = cbm_integration_template("adapter_pi");
|
||||
if (!tpl || !tpl->tool_line || !tpl->current.bin_mode ||
|
||||
strcmp(tpl->current.bin_mode, "js") != 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Register every tool the registry advertises — never a hand-picked
|
||||
* subset, which is the drift this generator exists to prevent. */
|
||||
adapter_sb_t tools = {0};
|
||||
adapter_sb_t sb = {0};
|
||||
emit_header(&sb, "pi");
|
||||
sb_append(&sb, "import { spawn } from 'node:child_process';\n\n");
|
||||
sb_append(&sb, "const BIN = '");
|
||||
sb_append(&sb, bin);
|
||||
sb_append(&sb, "';\n\n");
|
||||
|
||||
/* One shared invoker: the binary prints JSON on stdout, so take the last
|
||||
* line that parses rather than trusting the first — a stray log line on
|
||||
* stdout must not break the bridge. */
|
||||
sb_append(
|
||||
&sb, "async 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\n");
|
||||
|
||||
/* Register every tool the registry advertises — never a hand-picked subset,
|
||||
* which is the drift this generator exists to prevent. */
|
||||
sb_append(&sb, "export function register(pi) {\n");
|
||||
for (int i = 0; i < count; i++) {
|
||||
const char *name = cbm_mcp_tool_name(i);
|
||||
if (!name || !name[0]) {
|
||||
continue;
|
||||
}
|
||||
const char *const tool_names[] = {"TOOL"};
|
||||
const char *const tool_values[] = {name};
|
||||
sb_append_template(&tools, tpl->tool_line, tool_names, tool_values, 1U);
|
||||
}
|
||||
if (tools.failed) {
|
||||
free(tools.buf);
|
||||
return NULL;
|
||||
sb_append(&sb, " pi.registerTool({ name: '");
|
||||
sb_append(&sb, name);
|
||||
sb_append(&sb, "', run: (args, ctx) => call('");
|
||||
sb_append(&sb, name);
|
||||
sb_append(&sb, "', args, ctx?.signal) });\n");
|
||||
}
|
||||
sb_append(&sb, "}\n");
|
||||
|
||||
adapter_sb_t sb = {0};
|
||||
const char *const names[] = {"BIN", "TOOLS"};
|
||||
const char *const values[] = {bin, tools.buf ? tools.buf : ""};
|
||||
sb_append_template(&sb, tpl->current.text, names, values, PAIR_LEN);
|
||||
free(tools.buf);
|
||||
if (sb.failed) {
|
||||
free(sb.buf);
|
||||
return NULL;
|
||||
@@ -195,14 +185,50 @@ char *cbm_client_adapter_opencode(const char *binary_path) {
|
||||
if (!cbm_client_adapter_escape_js(binary_path, bin, sizeof(bin))) {
|
||||
return NULL;
|
||||
}
|
||||
const cbm_integration_template_t *tpl = cbm_integration_template("adapter_opencode");
|
||||
if (!tpl || !tpl->current.bin_mode || strcmp(tpl->current.bin_mode, "js") != 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
adapter_sb_t sb = {0};
|
||||
const char *const names[] = {"BIN"};
|
||||
const char *const values[] = {bin};
|
||||
sb_append_template(&sb, tpl->current.text, names, values, 1U);
|
||||
emit_header(&sb, "OpenCode");
|
||||
sb_append(&sb, "// 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.\n");
|
||||
sb_append(&sb, "import { spawn } from 'node:child_process';\n\n");
|
||||
sb_append(&sb, "const BIN = '");
|
||||
sb_append(&sb, bin);
|
||||
sb_append(&sb, "';\n\n");
|
||||
|
||||
/* hook-augment requires hook_event_name and accepts Grep/Glob only under
|
||||
* PreToolUse; omitting it makes the whole hook a silent no-op. */
|
||||
sb_append(&sb, "function 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\n");
|
||||
|
||||
sb_append(&sb,
|
||||
"export 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");
|
||||
|
||||
if (sb.failed) {
|
||||
free(sb.buf);
|
||||
return NULL;
|
||||
|
||||
@@ -3,22 +3,13 @@
|
||||
*
|
||||
* Some clients have no MCP support (pi), and some have no declarative hook
|
||||
* configuration (OpenCode); their only extension point is a JavaScript/TypeScript
|
||||
* module. Generation splits along what each source is authoritative for:
|
||||
* module. We do not ship such a module as a repository asset — it would be a
|
||||
* second, hand-maintained copy of the tool surface, and hand-maintained copies of
|
||||
* the tool list have already produced defects here.
|
||||
*
|
||||
* - The module SCAFFOLDING (the node:child_process spawn bridge, header note,
|
||||
* plugin surface) comes from the hash-verified cbm-integrations.json via
|
||||
* integration_assets.h. It used to be compiled in as C string literals, but
|
||||
* child-process-spawning script text inside a native binary is unnecessary
|
||||
* mixed-content and attack surface with plausible static-classifier overlap.
|
||||
* It therefore ships as independently inspectable data and is verified
|
||||
* against the binary's embedded SHA-256 before use; this is not a claim of
|
||||
* feature attribution for any vendor verdict.
|
||||
*
|
||||
* - The TOOL LIST is still generated at install time from the live registry
|
||||
* (cbm_mcp_tool_count / cbm_mcp_tool_name), never hand-maintained in the
|
||||
* asset file: hand-maintained copies of the tool surface have already
|
||||
* produced defects here. Adding a tool to TOOLS[] adds it to every
|
||||
* generated adapter with no second edit — that property is preserved.
|
||||
* Instead the module is GENERATED at install time from the live registry
|
||||
* (cbm_mcp_tool_count / cbm_mcp_tool_name), so it cannot drift: adding a tool to
|
||||
* TOOLS[] adds it to every generated adapter with no second edit.
|
||||
*
|
||||
* Generated text is returned heap-allocated; the caller frees it. Nothing here
|
||||
* touches the filesystem — writing is the caller's job, so dry-run and
|
||||
|
||||
@@ -1,652 +0,0 @@
|
||||
/*
|
||||
* integration_assets.c — Verified access to the shipped integration templates.
|
||||
*
|
||||
* See integration_assets.h for the architecture (why the bodies live in a
|
||||
* data file, the integrity model, and the ownership-reference role of the
|
||||
* content-addressed stored copy under <home>/.cbm/assets/<sha256>/).
|
||||
*/
|
||||
#include "cli/integration_assets.h"
|
||||
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/compat_fs.h"
|
||||
#include "foundation/constants.h"
|
||||
#include "foundation/platform.h"
|
||||
#include "foundation/sha256.h"
|
||||
#ifdef _WIN32
|
||||
#include "foundation/win_utf8.h"
|
||||
#endif
|
||||
#include "yyjson/yyjson.h"
|
||||
|
||||
#include "cbm_integrations_hash.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
#ifdef __APPLE__
|
||||
#include <mach-o/dyld.h>
|
||||
#endif
|
||||
|
||||
enum {
|
||||
ASSETS_DIR_PERM = 0755,
|
||||
ASSETS_FILE_PERM = 0644,
|
||||
/* The shipped file is ~8 KB; the cap only bounds a corrupted candidate. */
|
||||
ASSETS_MAX_BYTES = CBM_SZ_64K * CBM_SZ_16,
|
||||
};
|
||||
|
||||
/* The one actionable failure message: every "cannot use the templates" path
|
||||
* reports the same recovery, because the recovery is always the same. */
|
||||
static const char assets_error_message[] =
|
||||
"integration assets missing or modified - reinstall from the release archive "
|
||||
"(expected " CBM_INTEGRATIONS_ASSET_NAME " next to the binary, in its package share "
|
||||
"directory, in $CBM_ASSETS_DIR, or under ~/.cbm/assets/" CBM_INTEGRATIONS_SHA256 ")";
|
||||
|
||||
typedef struct {
|
||||
const char *id;
|
||||
cbm_integration_template_t tpl;
|
||||
cbm_integration_body_t *released_storage;
|
||||
} asset_entry_t;
|
||||
|
||||
/* Success-only cache: a failed resolve is re-attempted on every call so one
|
||||
* bad candidate never poisons a later call that could succeed. */
|
||||
static struct {
|
||||
bool ok;
|
||||
char *bytes;
|
||||
size_t length;
|
||||
yyjson_doc *doc;
|
||||
asset_entry_t *entries;
|
||||
size_t entry_count;
|
||||
} g_assets;
|
||||
|
||||
static void assets_fill_error(char *err, size_t err_sz) {
|
||||
if (err && err_sz > 0U) {
|
||||
(void)snprintf(err, err_sz, "%s", assets_error_message);
|
||||
}
|
||||
}
|
||||
|
||||
static bool assets_exe_dir(char *out, size_t out_sz) {
|
||||
if (out_sz < CBM_SZ_2) {
|
||||
return false;
|
||||
}
|
||||
#ifdef _WIN32
|
||||
char *module_path = cbm_module_path_utf8();
|
||||
size_t length = module_path ? strlen(module_path) : 0U;
|
||||
if (!module_path || length == 0U || length >= out_sz) {
|
||||
free(module_path);
|
||||
return false;
|
||||
}
|
||||
memcpy(out, module_path, length + 1U);
|
||||
free(module_path);
|
||||
cbm_normalize_path_sep(out);
|
||||
#elif defined(__APPLE__)
|
||||
uint32_t self_sz = (uint32_t)out_sz;
|
||||
if (_NSGetExecutablePath(out, &self_sz) != 0) {
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
ssize_t length = readlink("/proc/self/exe", out, out_sz - SKIP_ONE);
|
||||
if (length <= 0 || (size_t)length >= out_sz - SKIP_ONE) {
|
||||
return false;
|
||||
}
|
||||
out[length] = '\0';
|
||||
#endif
|
||||
char *slash = strrchr(out, '/');
|
||||
if (!slash || slash == out) {
|
||||
return false;
|
||||
}
|
||||
*slash = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
bool cbm_integration_assets_ownership_path(const char *home, char *out, size_t out_sz) {
|
||||
if (!home || !home[0]) {
|
||||
return false;
|
||||
}
|
||||
int written = snprintf(out, out_sz, "%s/.cbm/assets/%s/%s", home, CBM_INTEGRATIONS_SHA256,
|
||||
CBM_INTEGRATIONS_ASSET_NAME);
|
||||
return written > 0 && (size_t)written < out_sz;
|
||||
}
|
||||
|
||||
/* Read a candidate fully. Returns false for missing/unreadable/oversized. */
|
||||
static bool assets_read_file(const char *path, char **out_bytes, size_t *out_length) {
|
||||
*out_bytes = NULL;
|
||||
*out_length = 0U;
|
||||
FILE *file = cbm_fopen(path, "rb");
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
if (fseek(file, 0L, SEEK_END) != 0) {
|
||||
(void)fclose(file);
|
||||
return false;
|
||||
}
|
||||
long size = ftell(file);
|
||||
if (size <= 0 || size > (long)ASSETS_MAX_BYTES || fseek(file, 0L, SEEK_SET) != 0) {
|
||||
(void)fclose(file);
|
||||
return false;
|
||||
}
|
||||
char *bytes = malloc((size_t)size + SKIP_ONE);
|
||||
if (!bytes) {
|
||||
(void)fclose(file);
|
||||
return false;
|
||||
}
|
||||
size_t got = fread(bytes, 1U, (size_t)size, file);
|
||||
(void)fclose(file);
|
||||
if (got != (size_t)size) {
|
||||
free(bytes);
|
||||
return false;
|
||||
}
|
||||
bytes[got] = '\0';
|
||||
*out_bytes = bytes;
|
||||
*out_length = got;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool assets_bytes_verified(const char *bytes, size_t length) {
|
||||
char hex[CBM_SHA256_HEX_LEN + SKIP_ONE];
|
||||
cbm_sha256_hex(bytes, length, hex);
|
||||
return strcmp(hex, CBM_INTEGRATIONS_SHA256) == 0;
|
||||
}
|
||||
|
||||
bool cbm_integration_assets_verify_file(const char *path) {
|
||||
char *bytes = NULL;
|
||||
size_t length = 0U;
|
||||
bool verified =
|
||||
path && assets_read_file(path, &bytes, &length) && assets_bytes_verified(bytes, length);
|
||||
free(bytes);
|
||||
return verified;
|
||||
}
|
||||
|
||||
static bool assets_parse_body(yyjson_val *value, cbm_integration_body_t *body) {
|
||||
yyjson_val *text = yyjson_is_obj(value) ? yyjson_obj_get(value, "text") : NULL;
|
||||
yyjson_val *bin = yyjson_is_obj(value) ? yyjson_obj_get(value, "bin") : NULL;
|
||||
if (!text || !yyjson_is_str(text) || (bin && !yyjson_is_str(bin))) {
|
||||
return false;
|
||||
}
|
||||
body->text = yyjson_get_str(text);
|
||||
body->bin_mode = bin ? yyjson_get_str(bin) : NULL;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Parse verified bytes into the template table. The variant picked here is
|
||||
* the platform's ("windows" on _WIN32, else "posix", with "all" accepted for
|
||||
* platform-independent templates such as the JS adapters). */
|
||||
static bool assets_adopt_bytes(char *bytes, size_t length) {
|
||||
yyjson_doc *doc = yyjson_read(bytes, length, 0);
|
||||
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
|
||||
yyjson_val *format = root && yyjson_is_obj(root) ? yyjson_obj_get(root, "format") : NULL;
|
||||
yyjson_val *templates = root && yyjson_is_obj(root) ? yyjson_obj_get(root, "templates") : NULL;
|
||||
if (!format || !yyjson_is_int(format) || yyjson_get_int(format) != 1 || !templates ||
|
||||
!yyjson_is_obj(templates)) {
|
||||
if (doc) {
|
||||
yyjson_doc_free(doc);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
#ifdef _WIN32
|
||||
static const char platform_variant[] = "windows";
|
||||
#else
|
||||
static const char platform_variant[] = "posix";
|
||||
#endif
|
||||
size_t template_count = yyjson_obj_size(templates);
|
||||
asset_entry_t *entries = calloc(template_count ? template_count : 1U, sizeof(*entries));
|
||||
if (!entries) {
|
||||
yyjson_doc_free(doc);
|
||||
return false;
|
||||
}
|
||||
size_t entry_count = 0U;
|
||||
size_t index;
|
||||
size_t max;
|
||||
yyjson_val *key;
|
||||
yyjson_val *value;
|
||||
bool parse_ok = true;
|
||||
yyjson_obj_foreach(templates, index, max, key, value) {
|
||||
if (!yyjson_is_str(key) || !yyjson_is_obj(value)) {
|
||||
parse_ok = false;
|
||||
break;
|
||||
}
|
||||
yyjson_val *variant = yyjson_obj_get(value, platform_variant);
|
||||
if (!variant) {
|
||||
variant = yyjson_obj_get(value, "all");
|
||||
}
|
||||
if (!variant) {
|
||||
/* Not offered for this platform: skip rather than reject, so a
|
||||
* future platform-scoped template does not brick the others. */
|
||||
continue;
|
||||
}
|
||||
asset_entry_t *entry = &entries[entry_count];
|
||||
entry->id = yyjson_get_str(key);
|
||||
if (!assets_parse_body(variant, &entry->tpl.current)) {
|
||||
parse_ok = false;
|
||||
break;
|
||||
}
|
||||
yyjson_val *tool_line = yyjson_obj_get(value, "tool_line");
|
||||
if (tool_line && !yyjson_is_str(tool_line)) {
|
||||
parse_ok = false;
|
||||
break;
|
||||
}
|
||||
entry->tpl.tool_line = tool_line ? yyjson_get_str(tool_line) : NULL;
|
||||
yyjson_val *released = yyjson_obj_get(value, "released");
|
||||
if (released) {
|
||||
if (!yyjson_is_arr(released)) {
|
||||
parse_ok = false;
|
||||
break;
|
||||
}
|
||||
size_t released_count = yyjson_arr_size(released);
|
||||
if (released_count > 0U) {
|
||||
entry->released_storage = calloc(released_count, sizeof(*entry->released_storage));
|
||||
if (!entry->released_storage) {
|
||||
parse_ok = false;
|
||||
break;
|
||||
}
|
||||
size_t released_index;
|
||||
size_t released_max;
|
||||
yyjson_val *released_value;
|
||||
size_t stored = 0U;
|
||||
yyjson_arr_foreach(released, released_index, released_max, released_value) {
|
||||
if (!assets_parse_body(released_value, &entry->released_storage[stored])) {
|
||||
parse_ok = false;
|
||||
break;
|
||||
}
|
||||
stored++;
|
||||
}
|
||||
if (!parse_ok) {
|
||||
entry_count++;
|
||||
break;
|
||||
}
|
||||
entry->tpl.released = entry->released_storage;
|
||||
entry->tpl.released_count = stored;
|
||||
}
|
||||
}
|
||||
entry_count++;
|
||||
}
|
||||
if (!parse_ok) {
|
||||
for (size_t i = 0U; i < entry_count; i++) {
|
||||
free(entries[i].released_storage);
|
||||
}
|
||||
free(entries);
|
||||
yyjson_doc_free(doc);
|
||||
return false;
|
||||
}
|
||||
g_assets.bytes = bytes;
|
||||
g_assets.length = length;
|
||||
g_assets.doc = doc;
|
||||
g_assets.entries = entries;
|
||||
g_assets.entry_count = entry_count;
|
||||
g_assets.ok = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Try one candidate path: read + hash-verify + parse. On success the bytes
|
||||
* are adopted into the cache (ownership transferred). */
|
||||
static bool assets_try_candidate(const char *path) {
|
||||
char *bytes = NULL;
|
||||
size_t length = 0U;
|
||||
if (!assets_read_file(path, &bytes, &length)) {
|
||||
return false;
|
||||
}
|
||||
if (!assets_bytes_verified(bytes, length) || !assets_adopt_bytes(bytes, length)) {
|
||||
free(bytes);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool assets_resolve(const char *home) {
|
||||
if (g_assets.ok) {
|
||||
return true;
|
||||
}
|
||||
/* CBM_ASSETS_DIR set = authoritative: an explicit override that is
|
||||
* missing or modified FAILS; silently falling back would hide exactly
|
||||
* the tampering the hash check exists to surface. */
|
||||
static const char env_missing[] = "\x1f"
|
||||
"CBM_ASSETS_DIR_MISSING"
|
||||
"\x1f";
|
||||
char env_buf[CBM_SZ_4K];
|
||||
char candidate[CBM_SZ_4K];
|
||||
const char *assets_dir =
|
||||
cbm_safe_getenv("CBM_ASSETS_DIR", env_buf, sizeof(env_buf), env_missing);
|
||||
if (!assets_dir) {
|
||||
return false; /* present but unrepresentable: fail, do not fall back */
|
||||
}
|
||||
if (strcmp(assets_dir, env_missing) != 0 && assets_dir[0]) {
|
||||
int written = snprintf(candidate, sizeof(candidate), "%s/%s", assets_dir,
|
||||
CBM_INTEGRATIONS_ASSET_NAME);
|
||||
return written > 0 && (size_t)written < sizeof(candidate) &&
|
||||
assets_try_candidate(candidate);
|
||||
}
|
||||
char exe_dir[CBM_SZ_4K];
|
||||
if (assets_exe_dir(exe_dir, sizeof(exe_dir))) {
|
||||
int written =
|
||||
snprintf(candidate, sizeof(candidate), "%s/%s", exe_dir, CBM_INTEGRATIONS_ASSET_NAME);
|
||||
if (written > 0 && (size_t)written < sizeof(candidate) && assets_try_candidate(candidate)) {
|
||||
return true;
|
||||
}
|
||||
/* System package layout: <prefix>/bin/<binary> with the immutable
|
||||
* runtime asset under <prefix>/share/codebase-memory-mcp/. */
|
||||
written = snprintf(candidate, sizeof(candidate), "%s/../share/codebase-memory-mcp/%s",
|
||||
exe_dir, CBM_INTEGRATIONS_ASSET_NAME);
|
||||
if (written > 0 && (size_t)written < sizeof(candidate) && assets_try_candidate(candidate)) {
|
||||
return true;
|
||||
}
|
||||
/* Source-tree layout: build/c/<binary> with assets/ at the root. */
|
||||
written = snprintf(candidate, sizeof(candidate), "%s/../../assets/%s", exe_dir,
|
||||
CBM_INTEGRATIONS_ASSET_NAME);
|
||||
if (written > 0 && (size_t)written < sizeof(candidate) && assets_try_candidate(candidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return cbm_integration_assets_ownership_path(home, candidate, sizeof(candidate)) &&
|
||||
assets_try_candidate(candidate);
|
||||
}
|
||||
|
||||
bool cbm_integration_assets_require(const char *home, char *err, size_t err_sz) {
|
||||
if (assets_resolve(home)) {
|
||||
return true;
|
||||
}
|
||||
assets_fill_error(err, err_sz);
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool assets_transaction_close(cbm_activation_transaction_t **transaction_io) {
|
||||
return !transaction_io || !*transaction_io ||
|
||||
cbm_activation_transaction_close(transaction_io) == CBM_ACTIVATION_TRANSACTION_OK;
|
||||
}
|
||||
|
||||
static bool assets_stage_target(const char *target, cbm_activation_transaction_t **transaction_out,
|
||||
char *err, size_t err_sz) {
|
||||
cbm_activation_transaction_status_t status = cbm_activation_transaction_stage_bytes(
|
||||
target, g_assets.bytes, g_assets.length, transaction_out);
|
||||
if (status == CBM_ACTIVATION_TRANSACTION_OK && *transaction_out) {
|
||||
return true;
|
||||
}
|
||||
if (err && err_sz > 0U) {
|
||||
(void)snprintf(err, err_sz, "cannot stage the verified %s copy for %s: %s",
|
||||
CBM_INTEGRATIONS_ASSET_NAME, target,
|
||||
cbm_activation_transaction_status_message(status));
|
||||
}
|
||||
(void)assets_transaction_close(transaction_out);
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool assets_install_validator(const char *target_path, void *context) {
|
||||
(void)context;
|
||||
return cbm_integration_assets_verify_file(target_path);
|
||||
}
|
||||
|
||||
cbm_activation_transaction_status_t cbm_integration_assets_commit_install(
|
||||
cbm_activation_transaction_t *transaction) {
|
||||
if (!transaction) {
|
||||
return CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT;
|
||||
}
|
||||
cbm_activation_transaction_status_t status =
|
||||
cbm_activation_transaction_commit(transaction, assets_install_validator, NULL);
|
||||
#ifndef _WIN32
|
||||
if (status == CBM_ACTIVATION_TRANSACTION_OK) {
|
||||
const char *target = cbm_activation_transaction_target_path(transaction);
|
||||
if (!target || chmod(target, ASSETS_FILE_PERM) != 0) {
|
||||
cbm_activation_transaction_status_t rollback =
|
||||
cbm_activation_transaction_rollback(transaction);
|
||||
return rollback == CBM_ACTIVATION_TRANSACTION_OK
|
||||
? CBM_ACTIVATION_TRANSACTION_IO
|
||||
: CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return status;
|
||||
}
|
||||
|
||||
cbm_activation_transaction_status_t cbm_integration_assets_commit_removal(
|
||||
cbm_activation_transaction_t *transaction) {
|
||||
if (!transaction) {
|
||||
return CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT;
|
||||
}
|
||||
cbm_activation_transaction_status_t status =
|
||||
cbm_activation_transaction_commit(transaction, NULL, NULL);
|
||||
const char *retained = cbm_activation_transaction_backup_path(transaction);
|
||||
if (status != CBM_ACTIVATION_TRANSACTION_OK) {
|
||||
return status;
|
||||
}
|
||||
if (retained && cbm_integration_assets_verify_file(retained)) {
|
||||
return CBM_ACTIVATION_TRANSACTION_OK;
|
||||
}
|
||||
cbm_activation_transaction_status_t rollback = cbm_activation_transaction_rollback(transaction);
|
||||
return rollback == CBM_ACTIVATION_TRANSACTION_OK ? CBM_ACTIVATION_TRANSACTION_VALIDATION_FAILED
|
||||
: CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED;
|
||||
}
|
||||
|
||||
bool cbm_integration_assets_stage_install(const char *home, const char *install_dir,
|
||||
cbm_activation_transaction_t **adjacent_transaction_out,
|
||||
cbm_activation_transaction_t **ownership_transaction_out,
|
||||
char *err, size_t err_sz) {
|
||||
if (adjacent_transaction_out) {
|
||||
*adjacent_transaction_out = NULL;
|
||||
}
|
||||
if (ownership_transaction_out) {
|
||||
*ownership_transaction_out = NULL;
|
||||
}
|
||||
if (!home || !home[0] || !install_dir || !install_dir[0] || !adjacent_transaction_out ||
|
||||
!ownership_transaction_out || !cbm_integration_assets_require(home, err, err_sz)) {
|
||||
if ((!home || !home[0] || !install_dir || !install_dir[0]) && err && err_sz > 0U) {
|
||||
(void)snprintf(err, err_sz, "integration asset install paths are unavailable");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
char adjacent[CBM_SZ_4K];
|
||||
char ownership[CBM_SZ_4K];
|
||||
char ownership_parent[CBM_SZ_4K];
|
||||
int adjacent_length =
|
||||
snprintf(adjacent, sizeof(adjacent), "%s/%s", install_dir, CBM_INTEGRATIONS_ASSET_NAME);
|
||||
if (adjacent_length <= 0 || (size_t)adjacent_length >= sizeof(adjacent) ||
|
||||
!cbm_integration_assets_ownership_path(home, ownership, sizeof(ownership))) {
|
||||
assets_fill_error(err, err_sz);
|
||||
return false;
|
||||
}
|
||||
(void)snprintf(ownership_parent, sizeof(ownership_parent), "%s", ownership);
|
||||
char *slash = strrchr(ownership_parent, '/');
|
||||
if (!slash) {
|
||||
assets_fill_error(err, err_sz);
|
||||
return false;
|
||||
}
|
||||
*slash = '\0';
|
||||
if (!cbm_mkdir_p(install_dir, ASSETS_DIR_PERM) ||
|
||||
!cbm_mkdir_p(ownership_parent, ASSETS_DIR_PERM)) {
|
||||
if (err && err_sz > 0U) {
|
||||
(void)snprintf(err, err_sz, "cannot create directories for the verified %s copies",
|
||||
CBM_INTEGRATIONS_ASSET_NAME);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!assets_stage_target(adjacent, adjacent_transaction_out, err, err_sz) ||
|
||||
!assets_stage_target(ownership, ownership_transaction_out, err, err_sz)) {
|
||||
bool owner_closed = assets_transaction_close(ownership_transaction_out);
|
||||
bool adjacent_closed = assets_transaction_close(adjacent_transaction_out);
|
||||
if ((!owner_closed || !adjacent_closed) && err && err_sz > 0U) {
|
||||
(void)snprintf(err, err_sz,
|
||||
"cannot clean up a partial integration asset install transaction");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool assets_stage_owned_removal(const char *path,
|
||||
cbm_activation_transaction_t **transaction_out,
|
||||
bool *foreign_preserved_out, char *err, size_t err_sz) {
|
||||
cbm_path_info_t info;
|
||||
if (cbm_path_info_utf8(path, &info) != 0) {
|
||||
return true;
|
||||
}
|
||||
if (!info.is_regular || info.is_symlink || !cbm_integration_assets_verify_file(path)) {
|
||||
if (foreign_preserved_out) {
|
||||
*foreign_preserved_out = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
cbm_activation_transaction_status_t status =
|
||||
cbm_activation_transaction_stage_removal(path, transaction_out);
|
||||
if (status == CBM_ACTIVATION_TRANSACTION_OK && *transaction_out) {
|
||||
/* stage_removal snapshots the exact file identity; commit revalidates
|
||||
* it, binding this digest check to the file that will be retained. */
|
||||
if (cbm_integration_assets_verify_file(path)) {
|
||||
return true;
|
||||
}
|
||||
if (!assets_transaction_close(transaction_out)) {
|
||||
if (err && err_sz > 0U) {
|
||||
(void)snprintf(err, err_sz,
|
||||
"cannot clean up a changed integration asset removal transaction");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (foreign_preserved_out) {
|
||||
*foreign_preserved_out = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (err && err_sz > 0U) {
|
||||
(void)snprintf(err, err_sz, "cannot stage owned integration asset removal for %s: %s", path,
|
||||
cbm_activation_transaction_status_message(status));
|
||||
}
|
||||
(void)assets_transaction_close(transaction_out);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool cbm_integration_assets_stage_remove(const char *home, const char *install_dir,
|
||||
cbm_activation_transaction_t **adjacent_transaction_out,
|
||||
cbm_activation_transaction_t **ownership_transaction_out,
|
||||
bool *foreign_preserved_out, char *err, size_t err_sz) {
|
||||
if (adjacent_transaction_out) {
|
||||
*adjacent_transaction_out = NULL;
|
||||
}
|
||||
if (ownership_transaction_out) {
|
||||
*ownership_transaction_out = NULL;
|
||||
}
|
||||
if (foreign_preserved_out) {
|
||||
*foreign_preserved_out = false;
|
||||
}
|
||||
if (!home || !home[0] || !install_dir || !install_dir[0] || !adjacent_transaction_out ||
|
||||
!ownership_transaction_out) {
|
||||
if (err && err_sz > 0U) {
|
||||
(void)snprintf(err, err_sz, "integration asset uninstall paths are unavailable");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
char adjacent[CBM_SZ_4K];
|
||||
char ownership[CBM_SZ_4K];
|
||||
int adjacent_length =
|
||||
snprintf(adjacent, sizeof(adjacent), "%s/%s", install_dir, CBM_INTEGRATIONS_ASSET_NAME);
|
||||
if (adjacent_length <= 0 || (size_t)adjacent_length >= sizeof(adjacent) ||
|
||||
!cbm_integration_assets_ownership_path(home, ownership, sizeof(ownership))) {
|
||||
if (err && err_sz > 0U) {
|
||||
(void)snprintf(err, err_sz, "integration asset uninstall paths are too long");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!assets_stage_owned_removal(adjacent, adjacent_transaction_out, foreign_preserved_out, err,
|
||||
err_sz) ||
|
||||
!assets_stage_owned_removal(ownership, ownership_transaction_out, foreign_preserved_out,
|
||||
err, err_sz)) {
|
||||
bool owner_closed = assets_transaction_close(ownership_transaction_out);
|
||||
bool adjacent_closed = assets_transaction_close(adjacent_transaction_out);
|
||||
if ((!owner_closed || !adjacent_closed) && err && err_sz > 0U) {
|
||||
(void)snprintf(err, err_sz,
|
||||
"cannot clean up a partial integration asset removal transaction");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef CBM_CLI_ENABLE_TEST_API
|
||||
bool cbm_integration_assets_install(const char *home, bool dry_run, char *err, size_t err_sz) {
|
||||
if (!home || !home[0] || !cbm_integration_assets_require(home, err, err_sz)) {
|
||||
if (!home || !home[0]) {
|
||||
assets_fill_error(err, err_sz);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (dry_run) {
|
||||
return true;
|
||||
}
|
||||
char stored[CBM_SZ_4K];
|
||||
char parent[CBM_SZ_4K];
|
||||
if (!cbm_integration_assets_ownership_path(home, stored, sizeof(stored))) {
|
||||
assets_fill_error(err, err_sz);
|
||||
return false;
|
||||
}
|
||||
(void)snprintf(parent, sizeof(parent), "%s", stored);
|
||||
char *slash = strrchr(parent, '/');
|
||||
if (!slash) {
|
||||
assets_fill_error(err, err_sz);
|
||||
return false;
|
||||
}
|
||||
*slash = '\0';
|
||||
if (!cbm_mkdir_p(parent, ASSETS_DIR_PERM)) {
|
||||
if (err && err_sz > 0U) {
|
||||
(void)snprintf(err, err_sz, "cannot create %s to store the verified %s copy", parent,
|
||||
CBM_INTEGRATIONS_ASSET_NAME);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
cbm_activation_transaction_t *transaction = NULL;
|
||||
if (!assets_stage_target(stored, &transaction, err, err_sz)) {
|
||||
return false;
|
||||
}
|
||||
cbm_activation_transaction_status_t status = cbm_integration_assets_commit_install(transaction);
|
||||
if (status != CBM_ACTIVATION_TRANSACTION_OK) {
|
||||
(void)assets_transaction_close(&transaction);
|
||||
if (err && err_sz > 0U) {
|
||||
(void)snprintf(err, err_sz, "cannot publish the verified %s copy to %s",
|
||||
CBM_INTEGRATIONS_ASSET_NAME, stored);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
status = cbm_activation_transaction_finalize(transaction);
|
||||
bool finalized =
|
||||
status == CBM_ACTIVATION_TRANSACTION_OK || status == CBM_ACTIVATION_TRANSACTION_DEFERRED;
|
||||
bool closed = cbm_activation_transaction_close(&transaction) == CBM_ACTIVATION_TRANSACTION_OK;
|
||||
if (!finalized || !closed) {
|
||||
if (err && err_sz > 0U) {
|
||||
(void)snprintf(err, err_sz, "cannot finalize the verified %s copy at %s",
|
||||
CBM_INTEGRATIONS_ASSET_NAME, stored);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
const cbm_integration_template_t *cbm_integration_template(const char *id) {
|
||||
if (!id || !assets_resolve(NULL)) {
|
||||
return NULL;
|
||||
}
|
||||
for (size_t i = 0U; i < g_assets.entry_count; i++) {
|
||||
if (strcmp(g_assets.entries[i].id, id) == 0) {
|
||||
return &g_assets.entries[i].tpl;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
#ifdef CBM_CLI_ENABLE_TEST_API
|
||||
void cbm_integration_assets_reset_for_testing(void) {
|
||||
for (size_t i = 0U; i < g_assets.entry_count; i++) {
|
||||
free(g_assets.entries[i].released_storage);
|
||||
}
|
||||
free(g_assets.entries);
|
||||
g_assets.entries = NULL;
|
||||
g_assets.entry_count = 0U;
|
||||
if (g_assets.doc) {
|
||||
yyjson_doc_free(g_assets.doc);
|
||||
g_assets.doc = NULL;
|
||||
}
|
||||
free(g_assets.bytes);
|
||||
g_assets.bytes = NULL;
|
||||
g_assets.length = 0U;
|
||||
g_assets.ok = false;
|
||||
}
|
||||
#endif
|
||||
@@ -1,134 +0,0 @@
|
||||
/*
|
||||
* integration_assets.h — Verified access to the shipped integration templates.
|
||||
*
|
||||
* The binary used to embed every integration artifact it installs — nine
|
||||
* complete shebang'd shell scripts, their PowerShell/.cmd twins, and the
|
||||
* generated JS/TS client modules — as C string literals, written to client
|
||||
* config dirs at 0755. That block of script-shaped bytes inside an unsigned
|
||||
* executable overlaps script-bearing installer/launcher feature families used
|
||||
* by static malware classifiers. That does not identify the cause of any
|
||||
* opaque vendor verdict. The unnecessary bodies now live in ONE independently
|
||||
* inspectable data file, assets/cbm-integrations.json,
|
||||
* and the binary embeds exactly ONE constant about them: the file's SHA-256
|
||||
* (generated at build time into cbm_integrations_hash.h).
|
||||
*
|
||||
* Integrity model: every use verifies the file against that constant first.
|
||||
* This preserves the tamper-evidence the templates had while compiled in;
|
||||
* a verified copy is bit-identical to the shipped one by construction, so
|
||||
* WHICH copy satisfied the check never matters — only availability does.
|
||||
*
|
||||
* Resolution order: $CBM_ASSETS_DIR (authoritative when set — a bad explicit
|
||||
* override fails, it never falls back), else next to the running binary (the
|
||||
* flat release-archive layout install.sh/install.ps1 execute from), else the
|
||||
* source-tree layout <bindir>/../../assets (build/c/ builds and test-runner),
|
||||
* else the stored copy under <home>/.cbm/assets/<sha256>/.
|
||||
*
|
||||
* The stored copy is the OWNERSHIP REFERENCE: `install` persists the verified
|
||||
* bytes to <home>/.cbm/assets/<sha256>/ — a SIBLING of the disposable cache
|
||||
* (~/.cache/codebase-memory-mcp), never inside it — so uninstall can decide
|
||||
* "is this deployed file ours?" by materializing templates from it long after
|
||||
* the release archive is gone. Deciding ownership by materialize-and-compare
|
||||
* against these templates REPLACES the old exact-match against embedded
|
||||
* literals; released[] carries the historical bodies older versions wrote, so
|
||||
* upgrades and uninstalls keep recognising them. Content addressing prevents
|
||||
* same-version rebuilds from overwriting another build's ownership reference.
|
||||
* Foreign or user-modified
|
||||
* files are preserved exactly as before.
|
||||
*
|
||||
* Single-threaded by design: only the CLI install/uninstall paths touch this,
|
||||
* never the MCP server or hook-augment hot paths.
|
||||
*/
|
||||
#ifndef CBM_CLI_INTEGRATION_ASSETS_H
|
||||
#define CBM_CLI_INTEGRATION_ASSETS_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#include "cli/activation_transaction.h"
|
||||
|
||||
#define CBM_INTEGRATIONS_ASSET_NAME "cbm-integrations.json"
|
||||
|
||||
/* One template body. `text` may contain {{BIN}} / {{EVENT}} / {{TOOLS}} /
|
||||
* {{TOOL}} placeholders; `bin_mode` names how {{BIN}} must be encoded when
|
||||
* substituted ("sh", "ps", "cmd", "raw", "js"), or NULL for a body that
|
||||
* takes no binary path at all. */
|
||||
typedef struct {
|
||||
const char *text;
|
||||
const char *bin_mode;
|
||||
} cbm_integration_body_t;
|
||||
|
||||
/* A template resolved for THIS platform (posix/windows/all variant). The
|
||||
* released list is the exact set of historical bodies previous versions
|
||||
* materialized; it exists so ownership checks still recognise files written
|
||||
* before this version — the ONE place old bodies may live. */
|
||||
typedef struct {
|
||||
cbm_integration_body_t current;
|
||||
const cbm_integration_body_t *released;
|
||||
size_t released_count;
|
||||
const char *tool_line; /* adapters only: per-tool registration line */
|
||||
} cbm_integration_template_t;
|
||||
|
||||
/* Locate, read and hash-verify the asset file (cached after first success;
|
||||
* failures are re-resolved on every call so a transient state never sticks).
|
||||
* `home` may be NULL when no stored-copy candidate should be considered.
|
||||
* Returns true on success; on failure fills `err` (when given) with the
|
||||
* actionable message. */
|
||||
bool cbm_integration_assets_require(const char *home, char *err, size_t err_sz);
|
||||
|
||||
/* Verify one explicit file against this binary's compiled digest. This does
|
||||
* not consult fallbacks or populate the template cache. */
|
||||
bool cbm_integration_assets_verify_file(const char *path);
|
||||
|
||||
/* Content-addressed ownership reference used by install/uninstall. */
|
||||
bool cbm_integration_assets_ownership_path(const char *home, char *out, size_t out_sz);
|
||||
|
||||
/* Stage both runtime copies from the verified immutable snapshot: the fixed
|
||||
* sidecar beside the target executable and the content-addressed ownership reference.
|
||||
* No target is changed until the caller commits both transactions. The caller
|
||||
* must hold the native activation guard from this call through rollback or
|
||||
* finalize. A failure closes every partial stage and leaves both outputs NULL. */
|
||||
bool cbm_integration_assets_stage_install(const char *home, const char *install_dir,
|
||||
cbm_activation_transaction_t **adjacent_transaction_out,
|
||||
cbm_activation_transaction_t **ownership_transaction_out,
|
||||
char *err, size_t err_sz);
|
||||
|
||||
/* Publish one staged integration copy and validate the exact post-rename
|
||||
* target against this binary's compiled digest before it can be finalized.
|
||||
* If the staged inode was rewritten after staging, commit rolls the previous
|
||||
* target back and returns VALIDATION_FAILED. */
|
||||
cbm_activation_transaction_status_t cbm_integration_assets_commit_install(
|
||||
cbm_activation_transaction_t *transaction);
|
||||
|
||||
/* Stage removal of exact, hash-owned adjacent and ownership copies. Missing
|
||||
* files are successful no-ops. Foreign or modified files are preserved and
|
||||
* reported through foreign_preserved_out. The caller owns any returned
|
||||
* transactions and must commit/rollback/finalize them under the activation
|
||||
* guard. */
|
||||
bool cbm_integration_assets_stage_remove(const char *home, const char *install_dir,
|
||||
cbm_activation_transaction_t **adjacent_transaction_out,
|
||||
cbm_activation_transaction_t **ownership_transaction_out,
|
||||
bool *foreign_preserved_out, char *err, size_t err_sz);
|
||||
|
||||
/* Move the snapshotted target to the transaction's private retained path,
|
||||
* then hash that exact retained object before permitting final deletion. An
|
||||
* in-place rewrite after stage_remove is restored and reported as
|
||||
* VALIDATION_FAILED instead of being deleted. */
|
||||
cbm_activation_transaction_status_t cbm_integration_assets_commit_removal(
|
||||
cbm_activation_transaction_t *transaction);
|
||||
|
||||
/* Template lookup for this platform. Returns NULL when the assets are
|
||||
* unavailable or the id/variant is unknown. The returned pointers stay valid
|
||||
* for the process lifetime (or until the test-only reset). */
|
||||
const cbm_integration_template_t *cbm_integration_template(const char *id);
|
||||
|
||||
#ifdef CBM_CLI_ENABLE_TEST_API
|
||||
/* Focused lifecycle probe for the ownership-copy publisher. Production uses
|
||||
* cbm_integration_assets_stage_install as part of the guarded runtime set. */
|
||||
bool cbm_integration_assets_install(const char *home, bool dry_run, char *err, size_t err_sz);
|
||||
|
||||
/* Drop the cached document so a test can steer resolution via CBM_ASSETS_DIR
|
||||
* (set = authoritative) and observe verification failures deterministically. */
|
||||
void cbm_integration_assets_reset_for_testing(void);
|
||||
#endif
|
||||
|
||||
#endif /* CBM_CLI_INTEGRATION_ASSETS_H */
|
||||
@@ -159,7 +159,6 @@ cbm_daemon_process_role_t cbm_daemon_process_role(int argc, char *const argv[])
|
||||
}
|
||||
|
||||
static const char *const stateless_commands[] = {
|
||||
"--verify-runtime-assets",
|
||||
"install",
|
||||
"uninstall",
|
||||
"update",
|
||||
|
||||
+13
-73
@@ -21,7 +21,7 @@
|
||||
#include "mcp/index_supervisor.h"
|
||||
#include "store/store.h"
|
||||
#include "ui/config.h"
|
||||
#include "ui/asset_pack.h"
|
||||
#include "ui/embedded_assets.h"
|
||||
#include "ui/http_server.h"
|
||||
#include "watcher/watcher.h"
|
||||
|
||||
@@ -50,7 +50,6 @@ enum {
|
||||
HOST_HTTP_CONFIG_POLL_MS = 1000,
|
||||
HOST_HTTP_RETRY_INITIAL_MS = 1000,
|
||||
HOST_HTTP_RETRY_MAX_MS = 30000,
|
||||
HOST_UI_ASSET_SHUTDOWN_MS = 10000,
|
||||
HOST_WATCH_INTERVAL_MS = 5000,
|
||||
HOST_CONFLICT_LOG_CAP = 1024 * 1024,
|
||||
HOST_OPERATION_LOG_CAP = 5 * 1024 * 1024,
|
||||
@@ -89,13 +88,8 @@ struct host_state {
|
||||
cbm_http_server_t *http;
|
||||
cbm_thread_t watcher_thread;
|
||||
cbm_thread_t http_thread;
|
||||
cbm_thread_t ui_assets_thread;
|
||||
bool watcher_started;
|
||||
bool http_started;
|
||||
bool ui_assets_started;
|
||||
bool ui_assets_start_failed;
|
||||
bool ui_assets_failure_logged;
|
||||
atomic_bool ui_assets_completed;
|
||||
bool http_retiring;
|
||||
bool http_config_loaded;
|
||||
bool http_config_enabled;
|
||||
@@ -298,18 +292,6 @@ static void *host_http_thread(void *opaque) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void *host_ui_assets_thread(void *opaque) {
|
||||
host_state_t *host = opaque;
|
||||
char error[HOST_PATH_CAP];
|
||||
if (cbm_ui_assets_warm(cbm_get_home_dir(), error, sizeof(error))) {
|
||||
cbm_log_info("ui.assets_ready", "pack", CBM_UI_ASSET_PACK_NAME);
|
||||
} else if (cbm_ui_assets_state() != CBM_UI_ASSETS_CANCELLED) {
|
||||
cbm_log_warn("ui.assets_unavailable", "reason", error);
|
||||
}
|
||||
atomic_store_explicit(&host->ui_assets_completed, true, memory_order_release);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int host_watcher_index(const char *project_name, const char *root_path, void *opaque) {
|
||||
host_state_t *host = opaque;
|
||||
return host && host->application
|
||||
@@ -452,31 +434,16 @@ static void host_http_reconcile_at(host_state_t *host, uint64_t now_ms, bool for
|
||||
if (!host || !host->http_ops) {
|
||||
return;
|
||||
}
|
||||
if (host->http_ops == &g_host_http_default_ops) {
|
||||
cbm_ui_assets_state_t state = cbm_ui_assets_state();
|
||||
host->http_assets_available = state == CBM_UI_ASSETS_READY;
|
||||
if ((state == CBM_UI_ASSETS_FAILED || state == CBM_UI_ASSETS_CANCELLED ||
|
||||
host->ui_assets_start_failed) &&
|
||||
!host->ui_assets_failure_logged) {
|
||||
cbm_log_warn("ui.assets_unavailable", "pack", CBM_UI_ASSET_PACK_NAME, "hint",
|
||||
"reinstall the matching UI release");
|
||||
host->ui_assets_failure_logged = true;
|
||||
}
|
||||
}
|
||||
bool load_config =
|
||||
force_config_load || !host->http_config_loaded || now_ms >= host->http_next_config_load_ms;
|
||||
cbm_ui_config_t desired = {
|
||||
.ui_enabled = host->http_config_enabled,
|
||||
.ui_port = host->http_config_port,
|
||||
};
|
||||
bool config_changed = false;
|
||||
if (load_config) {
|
||||
host->http_next_config_load_ms = host_deadline_from(now_ms, HOST_HTTP_CONFIG_POLL_MS);
|
||||
host->http_ops->config_load(host->http_ops->context, &desired);
|
||||
config_changed = !host->http_config_loaded ||
|
||||
desired.ui_enabled != host->http_config_enabled ||
|
||||
desired.ui_port != host->http_config_port;
|
||||
if (!force_config_load && host->http_config_loaded && now_ms < host->http_next_config_load_ms) {
|
||||
return;
|
||||
}
|
||||
host->http_next_config_load_ms = host_deadline_from(now_ms, HOST_HTTP_CONFIG_POLL_MS);
|
||||
|
||||
cbm_ui_config_t desired;
|
||||
host->http_ops->config_load(host->http_ops->context, &desired);
|
||||
bool config_changed = !host->http_config_loaded ||
|
||||
desired.ui_enabled != host->http_config_enabled ||
|
||||
desired.ui_port != host->http_config_port;
|
||||
if (config_changed && !host->http_retiring) {
|
||||
host->http_retiring = true;
|
||||
host->http_retry_at_ms = now_ms;
|
||||
@@ -506,9 +473,10 @@ static void host_http_reconcile_at(host_state_t *host, uint64_t now_ms, bool for
|
||||
return;
|
||||
}
|
||||
if (!host->http_assets_available) {
|
||||
if (config_changed && cbm_ui_assets_state() == CBM_UI_ASSETS_UNAVAILABLE) {
|
||||
if (config_changed) {
|
||||
cbm_log_warn("ui.no_assets", "hint", "rebuild with: make -f Makefile.cbm cbm-with-ui");
|
||||
}
|
||||
host->http_retry_at_ms = UINT64_MAX;
|
||||
return;
|
||||
}
|
||||
if (now_ms < host->http_retry_at_ms) {
|
||||
@@ -542,9 +510,6 @@ static void host_background_stop(host_state_t *host) {
|
||||
if (host->watcher) {
|
||||
cbm_watcher_stop(host->watcher);
|
||||
}
|
||||
if (host->ui_assets_started) {
|
||||
cbm_ui_assets_request_cancel();
|
||||
}
|
||||
}
|
||||
|
||||
static bool host_background_join(host_state_t *host) {
|
||||
@@ -560,18 +525,6 @@ static bool host_background_join(host_state_t *host) {
|
||||
}
|
||||
host->watcher_started = false;
|
||||
}
|
||||
if (host->ui_assets_started) {
|
||||
uint64_t deadline = host_deadline_from(cbm_now_ms(), HOST_UI_ASSET_SHUTDOWN_MS);
|
||||
while (!atomic_load_explicit(&host->ui_assets_completed, memory_order_acquire) &&
|
||||
cbm_now_ms() < deadline) {
|
||||
cbm_usleep(1000);
|
||||
}
|
||||
if (!atomic_load_explicit(&host->ui_assets_completed, memory_order_acquire) ||
|
||||
cbm_thread_join(&host->ui_assets_thread) != 0) {
|
||||
return false;
|
||||
}
|
||||
host->ui_assets_started = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -614,8 +567,7 @@ static void host_state_free(host_state_t *host) {
|
||||
|
||||
static bool host_state_prepare(host_state_t *host, const cbm_daemon_ipc_endpoint_t *endpoint) {
|
||||
host->http_ops = &g_host_http_default_ops;
|
||||
host->http_assets_available = false;
|
||||
atomic_init(&host->ui_assets_completed, false);
|
||||
host->http_assets_available = CBM_EMBEDDED_FILE_COUNT > 0;
|
||||
if (!cbm_secure_random(host->ui_readiness_secret, sizeof(host->ui_readiness_secret))) {
|
||||
cbm_log_error("daemon.readiness_secret_failed", "reason", "system_rng_unavailable");
|
||||
return false;
|
||||
@@ -877,18 +829,6 @@ static bool host_background_start(host_state_t *host) {
|
||||
}
|
||||
host->watcher_started = true;
|
||||
|
||||
/* Runtime publication already succeeded before this function is called.
|
||||
* Pack I/O and hashing therefore run concurrently with normal daemon
|
||||
* service rather than extending its startup critical path. */
|
||||
if (cbm_ui_assets_supported()) {
|
||||
if (cbm_thread_create(&host->ui_assets_thread, 0, host_ui_assets_thread, host) == 0) {
|
||||
host->ui_assets_started = true;
|
||||
} else {
|
||||
host->ui_assets_start_failed = true;
|
||||
cbm_log_warn("ui.assets_unavailable", "reason", "warmup_thread_create_failed");
|
||||
}
|
||||
}
|
||||
|
||||
host_http_reconcile_at(host, cbm_now_ms(), true);
|
||||
return true;
|
||||
}
|
||||
|
||||
+7
-16
@@ -72,7 +72,7 @@ enum {
|
||||
#include <io.h>
|
||||
#endif
|
||||
#include "ui/http_server.h"
|
||||
#include "ui/asset_pack.h"
|
||||
#include "ui/embedded_assets.h"
|
||||
#include "ui/config.h"
|
||||
#include <yyjson/yyjson.h>
|
||||
|
||||
@@ -1040,14 +1040,6 @@ static int handle_subcommand(int argc, char **argv, cbm_project_lock_manager_t *
|
||||
}
|
||||
}
|
||||
for (int i = SKIP_ONE; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--verify-runtime-assets") == 0) {
|
||||
if (i != SKIP_ONE || argc != MAIN_CLI_ARGC) {
|
||||
(void)fprintf(
|
||||
stderr, "codebase-memory-mcp: --verify-runtime-assets accepts no arguments\n");
|
||||
return 2;
|
||||
}
|
||||
return cbm_cmd_verify_runtime_assets();
|
||||
}
|
||||
if (strcmp(argv[i], "--version") == 0) {
|
||||
printf("codebase-memory-mcp %s\n", CBM_VERSION);
|
||||
return 0;
|
||||
@@ -2038,7 +2030,7 @@ static int main_daemon_ctl_finish_ui_open(cbm_daemon_runtime_client_t **client_i
|
||||
bool open_browser);
|
||||
|
||||
static void main_daemon_ctl_print_ui_configuration(void) {
|
||||
if (!cbm_ui_assets_supported()) {
|
||||
if (!(CBM_EMBEDDED_FILE_COUNT > 0)) {
|
||||
return;
|
||||
}
|
||||
cbm_ui_config_t ui_config;
|
||||
@@ -2121,8 +2113,7 @@ static int main_daemon_ctl_finish_ui_open(cbm_daemon_runtime_client_t **client_i
|
||||
"opened\n",
|
||||
timeout_ms);
|
||||
(void)fprintf(stderr,
|
||||
"hint: verify matching runtime assets with `codebase-memory-mcp "
|
||||
"--verify-runtime-assets`, check the daemon log, and if port %d is in use "
|
||||
"hint: check the daemon log, and if port %d is in use "
|
||||
"retry with --port=N\n",
|
||||
port);
|
||||
return EXIT_FAILURE;
|
||||
@@ -2229,7 +2220,7 @@ static int main_run_daemon_ctl(int argc, char **argv, const cbm_daemon_ipc_endpo
|
||||
"last session; run `daemon stop` first if you want a permanent one\n",
|
||||
(unsigned long)status.daemon_pid);
|
||||
}
|
||||
if (!cbm_ui_assets_supported()) {
|
||||
if (!(CBM_EMBEDDED_FILE_COUNT > 0)) {
|
||||
if (requested_port > 0 || open_browser) {
|
||||
(void)fprintf(stderr, "warning: this binary was built without UI support; "
|
||||
"--port/--open have no effect\n");
|
||||
@@ -2294,7 +2285,7 @@ static int main_run_daemon_ctl(int argc, char **argv, const cbm_daemon_ipc_endpo
|
||||
/* The committed control connection satisfied the daemon's no-client
|
||||
* startup window; configure the UI before departing. */
|
||||
int ui_port = 0;
|
||||
if (cbm_ui_assets_supported()) {
|
||||
if ((CBM_EMBEDDED_FILE_COUNT > 0)) {
|
||||
cbm_ui_config_t ui_config;
|
||||
cbm_ui_config_load(&ui_config);
|
||||
ui_port = requested_port > 0 ? requested_port : ui_config.ui_port;
|
||||
@@ -2327,7 +2318,7 @@ static int main_run_daemon_ctl(int argc, char **argv, const cbm_daemon_ipc_endpo
|
||||
printf("It survives idle periods and session ends; `codebase-memory-mcp daemon stop` "
|
||||
"retires it.\n");
|
||||
int ui_result =
|
||||
cbm_ui_assets_supported()
|
||||
(CBM_EMBEDDED_FILE_COUNT > 0)
|
||||
? main_daemon_ctl_finish_ui_open(&start_result.client, ui_port, open_browser)
|
||||
: EXIT_SUCCESS;
|
||||
if (start_result.client) {
|
||||
@@ -2842,7 +2833,7 @@ int main(int argc, char **argv) {
|
||||
(void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (explicitly_enabled && !cbm_ui_assets_supported()) {
|
||||
if (explicitly_enabled && !(CBM_EMBEDDED_FILE_COUNT > 0)) {
|
||||
(void)fprintf(stderr, "codebase-memory-mcp: --ui requested, but this binary was built "
|
||||
"without UI support; rebuild with `make -f Makefile.cbm "
|
||||
"cbm-with-ui`.\n");
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
/* Test runners exercise the real loader with no compiled-in pack by default. */
|
||||
#include <stdint.h>
|
||||
|
||||
const char CBM_UI_ASSET_PACK_NAME[] = "";
|
||||
const char CBM_UI_ASSET_SHA256[] = "";
|
||||
const uint64_t CBM_UI_ASSET_SIZE = UINT64_C(0);
|
||||
@@ -1,892 +0,0 @@
|
||||
/* asset_pack.c — Hash-bound loader for the external UI frontend pack. */
|
||||
#include "ui/asset_pack.h"
|
||||
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/compat_fs.h"
|
||||
#include "foundation/constants.h"
|
||||
#include "foundation/platform.h"
|
||||
#include "foundation/sha256.h"
|
||||
#ifdef _WIN32
|
||||
#include "foundation/win_utf8.h"
|
||||
#endif
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
enum {
|
||||
UI_PACK_HEADER_BYTES = 80,
|
||||
UI_PACK_ENTRY_BYTES = 24,
|
||||
UI_PACK_VERSION = 1,
|
||||
UI_PACK_MAX_FILES = 1024,
|
||||
UI_PACK_MAX_PATH_BYTES = 255,
|
||||
UI_ASSETS_DIR_PERM = 0755,
|
||||
UI_ASSET_FILE_PERM = 0644,
|
||||
UI_PATH_CAP = 4096,
|
||||
};
|
||||
|
||||
#define UI_PACK_MAX_BYTES ((size_t)CBM_SZ_64K * CBM_SZ_1K)
|
||||
|
||||
typedef struct {
|
||||
unsigned char *bytes;
|
||||
size_t length;
|
||||
cbm_ui_asset_t *assets;
|
||||
size_t asset_count;
|
||||
char *paths;
|
||||
} ui_asset_snapshot_t;
|
||||
|
||||
static ui_asset_snapshot_t g_snapshot;
|
||||
static atomic_int g_state = CBM_UI_ASSETS_COLD;
|
||||
static atomic_bool g_cancel_requested = false;
|
||||
static char g_binary_path[UI_PATH_CAP];
|
||||
|
||||
#ifdef CBM_CLI_ENABLE_TEST_API
|
||||
static bool g_test_manifest_set;
|
||||
static char g_test_pack_name[UI_PATH_CAP];
|
||||
static char g_test_sha256[CBM_SHA256_HEX_LEN + 1U];
|
||||
static uint64_t g_test_size;
|
||||
#endif
|
||||
|
||||
static const char *ui_manifest_name(void) {
|
||||
#ifdef CBM_CLI_ENABLE_TEST_API
|
||||
if (g_test_manifest_set) {
|
||||
return g_test_pack_name;
|
||||
}
|
||||
#endif
|
||||
return CBM_UI_ASSET_PACK_NAME;
|
||||
}
|
||||
|
||||
static const char *ui_manifest_sha256(void) {
|
||||
#ifdef CBM_CLI_ENABLE_TEST_API
|
||||
if (g_test_manifest_set) {
|
||||
return g_test_sha256;
|
||||
}
|
||||
#endif
|
||||
return CBM_UI_ASSET_SHA256;
|
||||
}
|
||||
|
||||
static uint64_t ui_manifest_size(void) {
|
||||
#ifdef CBM_CLI_ENABLE_TEST_API
|
||||
if (g_test_manifest_set) {
|
||||
return g_test_size;
|
||||
}
|
||||
#endif
|
||||
return CBM_UI_ASSET_SIZE;
|
||||
}
|
||||
|
||||
static bool ui_hex_sha256(const char *value) {
|
||||
if (!value || strlen(value) != CBM_SHA256_HEX_LEN) {
|
||||
return false;
|
||||
}
|
||||
for (size_t index = 0; index < CBM_SHA256_HEX_LEN; index++) {
|
||||
char byte = value[index];
|
||||
if (!((byte >= '0' && byte <= '9') || (byte >= 'a' && byte <= 'f'))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool ui_content_addressed_name(const char *name, const char *sha256) {
|
||||
static const char prefix[] = "cbm-ui-";
|
||||
static const char suffix[] = ".pack";
|
||||
if (!name || !sha256 || strncmp(name, prefix, sizeof(prefix) - 1U) != 0) {
|
||||
return false;
|
||||
}
|
||||
size_t expected_length = (sizeof(prefix) - 1U) + CBM_SHA256_HEX_LEN + (sizeof(suffix) - 1U);
|
||||
return strlen(name) == expected_length &&
|
||||
strncmp(name + sizeof(prefix) - 1U, sha256, CBM_SHA256_HEX_LEN) == 0 &&
|
||||
strcmp(name + expected_length - (sizeof(suffix) - 1U), suffix) == 0;
|
||||
}
|
||||
|
||||
bool cbm_ui_assets_supported(void) {
|
||||
const char *name = ui_manifest_name();
|
||||
const char *sha256 = ui_manifest_sha256();
|
||||
uint64_t size = ui_manifest_size();
|
||||
return size >= UI_PACK_HEADER_BYTES && size <= UI_PACK_MAX_BYTES && ui_hex_sha256(sha256) &&
|
||||
ui_content_addressed_name(name, sha256);
|
||||
}
|
||||
|
||||
const char *cbm_ui_assets_current_pack_name(void) {
|
||||
return cbm_ui_assets_supported() ? ui_manifest_name() : NULL;
|
||||
}
|
||||
|
||||
void cbm_ui_assets_set_binary_path(const char *path) {
|
||||
if (!path) {
|
||||
g_binary_path[0] = '\0';
|
||||
return;
|
||||
}
|
||||
int written = snprintf(g_binary_path, sizeof(g_binary_path), "%s", path);
|
||||
if (written <= 0 || (size_t)written >= sizeof(g_binary_path)) {
|
||||
g_binary_path[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
static void ui_fill_error(char *err, size_t err_sz, const char *detail) {
|
||||
if (!err || err_sz == 0U) {
|
||||
return;
|
||||
}
|
||||
(void)snprintf(err, err_sz,
|
||||
"UI assets missing or modified - reinstall the matching release "
|
||||
"(expected %s%s%s)",
|
||||
ui_manifest_name(), detail && detail[0] ? ": " : "", detail ? detail : "");
|
||||
}
|
||||
|
||||
static uint16_t ui_read_u16(const unsigned char *bytes) {
|
||||
return (uint16_t)bytes[0] | ((uint16_t)bytes[1] << 8U);
|
||||
}
|
||||
|
||||
static uint32_t ui_read_u32(const unsigned char *bytes) {
|
||||
return (uint32_t)bytes[0] | ((uint32_t)bytes[1] << 8U) | ((uint32_t)bytes[2] << 16U) |
|
||||
((uint32_t)bytes[3] << 24U);
|
||||
}
|
||||
|
||||
static uint64_t ui_read_u64(const unsigned char *bytes) {
|
||||
uint64_t value = 0U;
|
||||
for (size_t index = 0U; index < sizeof(uint64_t); index++) {
|
||||
value |= (uint64_t)bytes[index] << (index * 8U);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static const char *ui_mime_type(unsigned int mime_id) {
|
||||
static const char *const types[] = {
|
||||
NULL,
|
||||
"text/html; charset=utf-8",
|
||||
"application/javascript; charset=utf-8",
|
||||
"text/css; charset=utf-8",
|
||||
"application/json; charset=utf-8",
|
||||
"image/svg+xml",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"image/x-icon",
|
||||
"font/woff2",
|
||||
"font/woff",
|
||||
"application/wasm",
|
||||
};
|
||||
return mime_id < sizeof(types) / sizeof(types[0]) ? types[mime_id] : NULL;
|
||||
}
|
||||
|
||||
static unsigned int ui_expected_mime(const char *path) {
|
||||
const char *dot = strrchr(path, '.');
|
||||
if (!dot) {
|
||||
return 0U;
|
||||
}
|
||||
if (strcmp(dot, ".html") == 0)
|
||||
return 1U;
|
||||
if (strcmp(dot, ".js") == 0 || strcmp(dot, ".mjs") == 0)
|
||||
return 2U;
|
||||
if (strcmp(dot, ".css") == 0)
|
||||
return 3U;
|
||||
if (strcmp(dot, ".json") == 0)
|
||||
return 4U;
|
||||
if (strcmp(dot, ".svg") == 0)
|
||||
return 5U;
|
||||
if (strcmp(dot, ".png") == 0)
|
||||
return 6U;
|
||||
if (strcmp(dot, ".jpg") == 0 || strcmp(dot, ".jpeg") == 0)
|
||||
return 7U;
|
||||
if (strcmp(dot, ".webp") == 0)
|
||||
return 8U;
|
||||
if (strcmp(dot, ".avif") == 0)
|
||||
return 9U;
|
||||
if (strcmp(dot, ".ico") == 0)
|
||||
return 10U;
|
||||
if (strcmp(dot, ".woff2") == 0)
|
||||
return 11U;
|
||||
if (strcmp(dot, ".woff") == 0)
|
||||
return 12U;
|
||||
if (strcmp(dot, ".wasm") == 0)
|
||||
return 13U;
|
||||
return 0U;
|
||||
}
|
||||
|
||||
static bool ui_path_valid(const unsigned char *path, size_t length) {
|
||||
if (length < 2U || length > UI_PACK_MAX_PATH_BYTES || path[0] != '/' ||
|
||||
path[length - 1U] == '/') {
|
||||
return false;
|
||||
}
|
||||
for (size_t index = 0U; index < length; index++) {
|
||||
unsigned char byte = path[index];
|
||||
bool allowed = (byte >= 'A' && byte <= 'Z') || (byte >= 'a' && byte <= 'z') ||
|
||||
(byte >= '0' && byte <= '9') || byte == '.' || byte == '_' || byte == '-' ||
|
||||
byte == '/';
|
||||
if (!allowed || (byte == '/' && index + 1U < length && path[index + 1U] == '/')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (size_t start = 1U; start < length;) {
|
||||
size_t end = start;
|
||||
while (end < length && path[end] != '/') {
|
||||
end++;
|
||||
}
|
||||
size_t segment = end - start;
|
||||
if ((segment == 1U && path[start] == '.') ||
|
||||
(segment == 2U && path[start] == '.' && path[start + 1U] == '.')) {
|
||||
return false;
|
||||
}
|
||||
start = end + 1U;
|
||||
}
|
||||
static const unsigned char index_path[] = "/index.html";
|
||||
static const unsigned char asset_prefix[] = "/assets/";
|
||||
return (length == sizeof(index_path) - 1U &&
|
||||
memcmp(path, index_path, sizeof(index_path) - 1U) == 0) ||
|
||||
(length > sizeof(asset_prefix) - 1U &&
|
||||
memcmp(path, asset_prefix, sizeof(asset_prefix) - 1U) == 0);
|
||||
}
|
||||
|
||||
static int ui_path_compare(const unsigned char *left, size_t left_length,
|
||||
const unsigned char *right, size_t right_length) {
|
||||
size_t common = left_length < right_length ? left_length : right_length;
|
||||
int compared = memcmp(left, right, common);
|
||||
if (compared != 0) {
|
||||
return compared;
|
||||
}
|
||||
return left_length < right_length ? -1 : left_length > right_length ? 1 : 0;
|
||||
}
|
||||
|
||||
static void ui_snapshot_free(ui_asset_snapshot_t *snapshot) {
|
||||
if (!snapshot) {
|
||||
return;
|
||||
}
|
||||
free(snapshot->paths);
|
||||
free(snapshot->assets);
|
||||
free(snapshot->bytes);
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
}
|
||||
|
||||
static bool ui_parse_pack(unsigned char *bytes, size_t length, ui_asset_snapshot_t *out) {
|
||||
/* Keep the seven format bytes as separate comparisons. A contiguous
|
||||
* native-binary copy would make the composition gate unable to
|
||||
* distinguish this parser constant from an accidentally embedded pack. */
|
||||
bool valid_magic = bytes && length >= 8U && bytes[0] == 'C' && bytes[1] == 'B' &&
|
||||
bytes[2] == 'M' && bytes[3] == 'U' && bytes[4] == 'I' && bytes[5] == 'P' &&
|
||||
bytes[6] == 'K' && bytes[7] == 0U;
|
||||
if (!bytes || !out || length < UI_PACK_HEADER_BYTES || !valid_magic ||
|
||||
ui_read_u16(bytes + 8U) != UI_PACK_VERSION ||
|
||||
ui_read_u16(bytes + 10U) != UI_PACK_HEADER_BYTES || ui_read_u32(bytes + 12U) != 0U) {
|
||||
return false;
|
||||
}
|
||||
uint32_t file_count = ui_read_u32(bytes + 16U);
|
||||
uint32_t entry_bytes = ui_read_u32(bytes + 20U);
|
||||
uint64_t index_offset = ui_read_u64(bytes + 24U);
|
||||
uint64_t index_bytes = ui_read_u64(bytes + 32U);
|
||||
uint64_t paths_offset = ui_read_u64(bytes + 40U);
|
||||
uint64_t paths_bytes = ui_read_u64(bytes + 48U);
|
||||
uint64_t payload_offset = ui_read_u64(bytes + 56U);
|
||||
uint64_t payload_bytes = ui_read_u64(bytes + 64U);
|
||||
uint64_t total_bytes = ui_read_u64(bytes + 72U);
|
||||
uint64_t length_u64 = (uint64_t)length;
|
||||
if (file_count == 0U || file_count > UI_PACK_MAX_FILES || entry_bytes != UI_PACK_ENTRY_BYTES ||
|
||||
index_offset != UI_PACK_HEADER_BYTES ||
|
||||
index_bytes != (uint64_t)file_count * UI_PACK_ENTRY_BYTES || index_offset > length_u64 ||
|
||||
index_bytes > length_u64 - index_offset || paths_offset != index_offset + index_bytes ||
|
||||
paths_offset > length_u64 || paths_bytes > length_u64 - paths_offset ||
|
||||
payload_offset != paths_offset + paths_bytes || payload_offset > length_u64 ||
|
||||
payload_bytes > length_u64 - payload_offset ||
|
||||
total_bytes != payload_offset + payload_bytes || total_bytes != length_u64 ||
|
||||
paths_bytes > SIZE_MAX - file_count || payload_bytes > SIZE_MAX) {
|
||||
return false;
|
||||
}
|
||||
cbm_ui_asset_t *assets = calloc(file_count, sizeof(*assets));
|
||||
char *paths = malloc((size_t)paths_bytes + file_count);
|
||||
if (!assets || !paths) {
|
||||
free(assets);
|
||||
free(paths);
|
||||
return false;
|
||||
}
|
||||
uint64_t expected_path_offset = 0U;
|
||||
uint64_t expected_data_offset = 0U;
|
||||
size_t path_storage_offset = 0U;
|
||||
bool has_index = false;
|
||||
const unsigned char *previous_path = NULL;
|
||||
size_t previous_length = 0U;
|
||||
for (uint32_t index = 0U; index < file_count; index++) {
|
||||
const unsigned char *entry =
|
||||
bytes + (size_t)index_offset + (size_t)index * UI_PACK_ENTRY_BYTES;
|
||||
uint32_t path_offset = ui_read_u32(entry);
|
||||
uint16_t path_length = ui_read_u16(entry + 4U);
|
||||
unsigned int mime_id = entry[6U];
|
||||
unsigned int cache_id = entry[7U];
|
||||
uint64_t data_offset = ui_read_u64(entry + 8U);
|
||||
uint64_t data_length = ui_read_u64(entry + 16U);
|
||||
if (path_offset != expected_path_offset || path_length == 0U ||
|
||||
(uint64_t)path_length > paths_bytes - expected_path_offset ||
|
||||
data_offset != expected_data_offset || data_length == 0U ||
|
||||
data_length > payload_bytes - expected_data_offset) {
|
||||
free(assets);
|
||||
free(paths);
|
||||
return false;
|
||||
}
|
||||
const unsigned char *path_bytes = bytes + (size_t)paths_offset + path_offset;
|
||||
if (!ui_path_valid(path_bytes, path_length) ||
|
||||
(previous_path &&
|
||||
ui_path_compare(previous_path, previous_length, path_bytes, path_length) >= 0)) {
|
||||
free(assets);
|
||||
free(paths);
|
||||
return false;
|
||||
}
|
||||
char *stored_path = paths + path_storage_offset;
|
||||
memcpy(stored_path, path_bytes, path_length);
|
||||
stored_path[path_length] = '\0';
|
||||
const char *mime = ui_mime_type(mime_id);
|
||||
bool index_asset = strcmp(stored_path, "/index.html") == 0;
|
||||
if (!mime || ui_expected_mime(stored_path) != mime_id ||
|
||||
(index_asset ? cache_id != CBM_UI_ASSET_REVALIDATE
|
||||
: cache_id != CBM_UI_ASSET_IMMUTABLE) ||
|
||||
(index_asset && has_index)) {
|
||||
free(assets);
|
||||
free(paths);
|
||||
return false;
|
||||
}
|
||||
has_index = has_index || index_asset;
|
||||
assets[index] = (cbm_ui_asset_t){
|
||||
.path = stored_path,
|
||||
.data = bytes + (size_t)payload_offset + (size_t)data_offset,
|
||||
.size = (size_t)data_length,
|
||||
.content_type = mime,
|
||||
.cache = (cbm_ui_asset_cache_t)cache_id,
|
||||
};
|
||||
previous_path = path_bytes;
|
||||
previous_length = path_length;
|
||||
expected_path_offset += path_length;
|
||||
expected_data_offset += data_length;
|
||||
path_storage_offset += path_length + 1U;
|
||||
}
|
||||
if (!has_index || expected_path_offset != paths_bytes ||
|
||||
expected_data_offset != payload_bytes) {
|
||||
free(assets);
|
||||
free(paths);
|
||||
return false;
|
||||
}
|
||||
*out = (ui_asset_snapshot_t){
|
||||
.bytes = bytes,
|
||||
.length = length,
|
||||
.assets = assets,
|
||||
.asset_count = file_count,
|
||||
.paths = paths,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool ui_read_regular_file(const char *path, unsigned char **bytes_out, size_t *length_out) {
|
||||
*bytes_out = NULL;
|
||||
*length_out = 0U;
|
||||
cbm_path_info_t info;
|
||||
uint64_t expected = ui_manifest_size();
|
||||
if (!path || cbm_path_info_utf8(path, &info) != 0 || !info.is_regular || info.is_symlink ||
|
||||
info.size <= 0 || (uint64_t)info.size != expected || expected > UI_PACK_MAX_BYTES) {
|
||||
return false;
|
||||
}
|
||||
unsigned char *bytes = malloc((size_t)expected);
|
||||
if (!bytes) {
|
||||
return false;
|
||||
}
|
||||
#ifdef _WIN32
|
||||
wchar_t *wide = cbm_path_to_wide(path);
|
||||
HANDLE file = wide ? CreateFileW(wide, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE, NULL,
|
||||
OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL)
|
||||
: INVALID_HANDLE_VALUE;
|
||||
free(wide);
|
||||
if (file == INVALID_HANDLE_VALUE) {
|
||||
free(bytes);
|
||||
return false;
|
||||
}
|
||||
BY_HANDLE_FILE_INFORMATION handle_info;
|
||||
bool handle_info_ok = GetFileInformationByHandle(file, &handle_info) != 0;
|
||||
uint64_t handle_size = handle_info_ok ? ((uint64_t)handle_info.nFileSizeHigh << 32U) |
|
||||
(uint64_t)handle_info.nFileSizeLow
|
||||
: UINT64_MAX;
|
||||
bool ok = handle_info_ok && handle_size == expected &&
|
||||
!(handle_info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) &&
|
||||
!(handle_info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY);
|
||||
size_t offset = 0U;
|
||||
while (ok && offset < (size_t)expected &&
|
||||
!atomic_load_explicit(&g_cancel_requested, memory_order_acquire)) {
|
||||
DWORD chunk =
|
||||
(DWORD)(((size_t)expected - offset) > CBM_SZ_64K ? CBM_SZ_64K
|
||||
: ((size_t)expected - offset));
|
||||
DWORD got = 0U;
|
||||
ok = ReadFile(file, bytes + offset, chunk, &got, NULL) && got == chunk;
|
||||
offset += got;
|
||||
}
|
||||
(void)CloseHandle(file);
|
||||
ok = ok && offset == (size_t)expected;
|
||||
#else
|
||||
int flags = O_RDONLY;
|
||||
#ifdef O_CLOEXEC
|
||||
flags |= O_CLOEXEC;
|
||||
#endif
|
||||
#ifdef O_NOFOLLOW
|
||||
flags |= O_NOFOLLOW;
|
||||
#endif
|
||||
int file = open(path, flags);
|
||||
struct stat opened_info;
|
||||
bool ok = file >= 0 && fstat(file, &opened_info) == 0 && S_ISREG(opened_info.st_mode) &&
|
||||
(uint64_t)opened_info.st_size == expected;
|
||||
size_t offset = 0U;
|
||||
while (ok && offset < (size_t)expected &&
|
||||
!atomic_load_explicit(&g_cancel_requested, memory_order_acquire)) {
|
||||
size_t chunk = (size_t)expected - offset;
|
||||
if (chunk > CBM_SZ_64K) {
|
||||
chunk = CBM_SZ_64K;
|
||||
}
|
||||
ssize_t got = read(file, bytes + offset, chunk);
|
||||
if (got < 0 && errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
if (got <= 0) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
offset += (size_t)got;
|
||||
}
|
||||
if (file >= 0) {
|
||||
(void)close(file);
|
||||
}
|
||||
ok = ok && offset == (size_t)expected;
|
||||
#endif
|
||||
if (!ok || atomic_load_explicit(&g_cancel_requested, memory_order_acquire)) {
|
||||
free(bytes);
|
||||
return false;
|
||||
}
|
||||
*bytes_out = bytes;
|
||||
*length_out = (size_t)expected;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool ui_verify_bytes(const unsigned char *bytes, size_t length) {
|
||||
cbm_sha256_ctx hash;
|
||||
cbm_sha256_init(&hash);
|
||||
for (size_t offset = 0U; offset < length; offset += CBM_SZ_64K) {
|
||||
if (atomic_load_explicit(&g_cancel_requested, memory_order_acquire)) {
|
||||
return false;
|
||||
}
|
||||
size_t chunk = length - offset;
|
||||
if (chunk > CBM_SZ_64K) {
|
||||
chunk = CBM_SZ_64K;
|
||||
}
|
||||
cbm_sha256_update(&hash, bytes + offset, chunk);
|
||||
}
|
||||
uint8_t digest[CBM_SHA256_DIGEST_LEN];
|
||||
cbm_sha256_final(&hash, digest);
|
||||
static const char hex_chars[] = "0123456789abcdef";
|
||||
char hex[CBM_SHA256_HEX_LEN + 1U];
|
||||
for (size_t index = 0U; index < sizeof(digest); index++) {
|
||||
hex[index * 2U] = hex_chars[digest[index] >> 4U];
|
||||
hex[index * 2U + 1U] = hex_chars[digest[index] & 0x0fU];
|
||||
}
|
||||
hex[CBM_SHA256_HEX_LEN] = '\0';
|
||||
return strcmp(hex, ui_manifest_sha256()) == 0;
|
||||
}
|
||||
|
||||
static bool ui_candidate_path(const char *directory, char out[UI_PATH_CAP]) {
|
||||
if (!directory || !directory[0]) {
|
||||
return false;
|
||||
}
|
||||
int written = snprintf(out, UI_PATH_CAP, "%s/%s", directory, ui_manifest_name());
|
||||
return written > 0 && written < UI_PATH_CAP;
|
||||
}
|
||||
|
||||
static bool ui_binary_directory(char out[UI_PATH_CAP]) {
|
||||
int written = snprintf(out, UI_PATH_CAP, "%s", g_binary_path);
|
||||
if (written <= 0 || written >= UI_PATH_CAP) {
|
||||
return false;
|
||||
}
|
||||
char *slash = strrchr(out, '/');
|
||||
#ifdef _WIN32
|
||||
char *backslash = strrchr(out, '\\');
|
||||
if (backslash && (!slash || backslash > slash)) {
|
||||
slash = backslash;
|
||||
}
|
||||
#endif
|
||||
if (!slash || slash == out) {
|
||||
return false;
|
||||
}
|
||||
*slash = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool ui_try_path(const char *path, ui_asset_snapshot_t *snapshot) {
|
||||
unsigned char *bytes = NULL;
|
||||
size_t length = 0U;
|
||||
if (!ui_read_regular_file(path, &bytes, &length)) {
|
||||
return false;
|
||||
}
|
||||
if (!ui_verify_bytes(bytes, length) || !ui_parse_pack(bytes, length, snapshot)) {
|
||||
free(bytes);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool cbm_ui_assets_verify_file(const char *path) {
|
||||
if (!cbm_ui_assets_supported() || !path || !path[0]) {
|
||||
return false;
|
||||
}
|
||||
ui_asset_snapshot_t verified = {0};
|
||||
bool ok = ui_try_path(path, &verified);
|
||||
ui_snapshot_free(&verified);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool ui_resolve_snapshot(const char *home, ui_asset_snapshot_t *snapshot) {
|
||||
static const char missing[] = "\x1f"
|
||||
"CBM_UI_ASSETS_DIR_MISSING"
|
||||
"\x1f";
|
||||
char env_buffer[UI_PATH_CAP];
|
||||
char candidate[UI_PATH_CAP];
|
||||
const char *override =
|
||||
cbm_safe_getenv("CBM_UI_ASSETS_DIR", env_buffer, sizeof(env_buffer), missing);
|
||||
if (!override) {
|
||||
return false;
|
||||
}
|
||||
if (strcmp(override, missing) != 0 && override[0]) {
|
||||
return ui_candidate_path(override, candidate) && ui_try_path(candidate, snapshot);
|
||||
}
|
||||
char binary_directory[UI_PATH_CAP];
|
||||
if (ui_binary_directory(binary_directory)) {
|
||||
if (ui_candidate_path(binary_directory, candidate)) {
|
||||
cbm_path_info_t info;
|
||||
if (cbm_path_info_utf8(candidate, &info) == 0) {
|
||||
return ui_try_path(candidate, snapshot);
|
||||
}
|
||||
}
|
||||
int written = snprintf(candidate, sizeof(candidate), "%s/../share/codebase-memory-mcp/%s",
|
||||
binary_directory, ui_manifest_name());
|
||||
if (written > 0 && (size_t)written < sizeof(candidate)) {
|
||||
cbm_path_info_t info;
|
||||
if (cbm_path_info_utf8(candidate, &info) == 0) {
|
||||
return ui_try_path(candidate, snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
(void)home;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool cbm_ui_assets_warm(const char *home, char *err, size_t err_sz) {
|
||||
if (!cbm_ui_assets_supported()) {
|
||||
return true;
|
||||
}
|
||||
int expected = CBM_UI_ASSETS_COLD;
|
||||
if (!atomic_compare_exchange_strong_explicit(&g_state, &expected, CBM_UI_ASSETS_LOADING,
|
||||
memory_order_acq_rel, memory_order_acquire)) {
|
||||
if (expected == CBM_UI_ASSETS_READY) {
|
||||
return true;
|
||||
}
|
||||
const char *detail = expected == CBM_UI_ASSETS_LOADING ? "load already in progress"
|
||||
: expected == CBM_UI_ASSETS_CANCELLED ? "load cancelled"
|
||||
: "load previously failed";
|
||||
ui_fill_error(err, err_sz, detail);
|
||||
return false;
|
||||
}
|
||||
ui_asset_snapshot_t snapshot = {0};
|
||||
bool loaded = ui_resolve_snapshot(home, &snapshot);
|
||||
if (atomic_load_explicit(&g_cancel_requested, memory_order_acquire)) {
|
||||
ui_snapshot_free(&snapshot);
|
||||
atomic_store_explicit(&g_state, CBM_UI_ASSETS_CANCELLED, memory_order_release);
|
||||
ui_fill_error(err, err_sz, "load cancelled");
|
||||
return false;
|
||||
}
|
||||
if (!loaded) {
|
||||
ui_snapshot_free(&snapshot);
|
||||
atomic_store_explicit(&g_state, CBM_UI_ASSETS_FAILED, memory_order_release);
|
||||
ui_fill_error(err, err_sz, "verified pack not found");
|
||||
return false;
|
||||
}
|
||||
g_snapshot = snapshot;
|
||||
atomic_store_explicit(&g_state, CBM_UI_ASSETS_READY, memory_order_release);
|
||||
return true;
|
||||
}
|
||||
|
||||
void cbm_ui_assets_request_cancel(void) {
|
||||
atomic_store_explicit(&g_cancel_requested, true, memory_order_release);
|
||||
int expected = CBM_UI_ASSETS_COLD;
|
||||
(void)atomic_compare_exchange_strong_explicit(&g_state, &expected, CBM_UI_ASSETS_CANCELLED,
|
||||
memory_order_acq_rel, memory_order_acquire);
|
||||
}
|
||||
|
||||
cbm_ui_assets_state_t cbm_ui_assets_state(void) {
|
||||
if (!cbm_ui_assets_supported()) {
|
||||
return CBM_UI_ASSETS_UNAVAILABLE;
|
||||
}
|
||||
return (cbm_ui_assets_state_t)atomic_load_explicit(&g_state, memory_order_acquire);
|
||||
}
|
||||
|
||||
const cbm_ui_asset_t *cbm_ui_asset_lookup(const char *path) {
|
||||
if (!path || cbm_ui_assets_state() != CBM_UI_ASSETS_READY) {
|
||||
return NULL;
|
||||
}
|
||||
size_t low = 0U;
|
||||
size_t high = g_snapshot.asset_count;
|
||||
while (low < high) {
|
||||
size_t middle = low + (high - low) / 2U;
|
||||
int compared = strcmp(path, g_snapshot.assets[middle].path);
|
||||
if (compared == 0) {
|
||||
return &g_snapshot.assets[middle];
|
||||
}
|
||||
if (compared < 0) {
|
||||
high = middle;
|
||||
} else {
|
||||
low = middle + 1U;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
bool cbm_ui_assets_stage_install(const char *install_dir,
|
||||
cbm_activation_transaction_t **transaction_out, char *err,
|
||||
size_t err_sz) {
|
||||
if (transaction_out) {
|
||||
*transaction_out = NULL;
|
||||
}
|
||||
if (!cbm_ui_assets_supported()) {
|
||||
return true;
|
||||
}
|
||||
if (!transaction_out || !install_dir || !install_dir[0] ||
|
||||
!cbm_ui_assets_warm(NULL, err, err_sz)) {
|
||||
if (!install_dir || !install_dir[0]) {
|
||||
ui_fill_error(err, err_sz, "install directory unavailable");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
char stored[UI_PATH_CAP];
|
||||
if (!ui_candidate_path(install_dir, stored)) {
|
||||
ui_fill_error(err, err_sz, "install path is too long");
|
||||
return false;
|
||||
}
|
||||
if (!cbm_mkdir_p(install_dir, UI_ASSETS_DIR_PERM)) {
|
||||
ui_fill_error(err, err_sz, "cannot create the install directory");
|
||||
return false;
|
||||
}
|
||||
cbm_activation_transaction_status_t status = cbm_activation_transaction_stage_bytes(
|
||||
stored, g_snapshot.bytes, g_snapshot.length, transaction_out);
|
||||
if (status != CBM_ACTIVATION_TRANSACTION_OK || !*transaction_out) {
|
||||
if (*transaction_out) {
|
||||
(void)cbm_activation_transaction_close(transaction_out);
|
||||
}
|
||||
ui_fill_error(err, err_sz, "cannot stage the verified pack");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool ui_install_validator(const char *target_path, void *context) {
|
||||
(void)context;
|
||||
return cbm_ui_assets_verify_file(target_path);
|
||||
}
|
||||
|
||||
cbm_activation_transaction_status_t cbm_ui_assets_commit_install(
|
||||
cbm_activation_transaction_t *transaction) {
|
||||
if (!transaction) {
|
||||
return CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT;
|
||||
}
|
||||
cbm_activation_transaction_status_t status =
|
||||
cbm_activation_transaction_commit(transaction, ui_install_validator, NULL);
|
||||
#ifndef _WIN32
|
||||
if (status == CBM_ACTIVATION_TRANSACTION_OK) {
|
||||
const char *target = cbm_activation_transaction_target_path(transaction);
|
||||
if (!target || chmod(target, UI_ASSET_FILE_PERM) != 0) {
|
||||
cbm_activation_transaction_status_t rollback =
|
||||
cbm_activation_transaction_rollback(transaction);
|
||||
return rollback == CBM_ACTIVATION_TRANSACTION_OK
|
||||
? CBM_ACTIVATION_TRANSACTION_IO
|
||||
: CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return status;
|
||||
}
|
||||
|
||||
cbm_activation_transaction_status_t cbm_ui_assets_commit_removal(
|
||||
cbm_activation_transaction_t *transaction) {
|
||||
if (!transaction) {
|
||||
return CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT;
|
||||
}
|
||||
cbm_activation_transaction_status_t status =
|
||||
cbm_activation_transaction_commit(transaction, NULL, NULL);
|
||||
const char *retained = cbm_activation_transaction_backup_path(transaction);
|
||||
if (status != CBM_ACTIVATION_TRANSACTION_OK) {
|
||||
return status;
|
||||
}
|
||||
if (retained && cbm_ui_assets_verify_file(retained)) {
|
||||
return CBM_ACTIVATION_TRANSACTION_OK;
|
||||
}
|
||||
cbm_activation_transaction_status_t rollback =
|
||||
cbm_activation_transaction_rollback(transaction);
|
||||
return rollback == CBM_ACTIVATION_TRANSACTION_OK
|
||||
? CBM_ACTIVATION_TRANSACTION_VALIDATION_FAILED
|
||||
: CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED;
|
||||
}
|
||||
|
||||
#ifdef CBM_CLI_ENABLE_TEST_API
|
||||
bool cbm_ui_assets_install(const char *install_dir, bool dry_run, char *err, size_t err_sz) {
|
||||
if (!cbm_ui_assets_supported()) {
|
||||
return true;
|
||||
}
|
||||
if (!install_dir || !install_dir[0] || !cbm_ui_assets_warm(NULL, err, err_sz)) {
|
||||
if (!install_dir || !install_dir[0]) {
|
||||
ui_fill_error(err, err_sz, "install directory unavailable");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (dry_run) {
|
||||
return true;
|
||||
}
|
||||
cbm_activation_transaction_t *transaction = NULL;
|
||||
if (!cbm_ui_assets_stage_install(install_dir, &transaction, err, err_sz)) {
|
||||
return false;
|
||||
}
|
||||
cbm_activation_transaction_status_t status = cbm_ui_assets_commit_install(transaction);
|
||||
if (status != CBM_ACTIVATION_TRANSACTION_OK) {
|
||||
(void)cbm_activation_transaction_close(&transaction);
|
||||
ui_fill_error(err, err_sz, "cannot publish the verified pack");
|
||||
return false;
|
||||
}
|
||||
status = cbm_activation_transaction_finalize(transaction);
|
||||
bool finalized =
|
||||
status == CBM_ACTIVATION_TRANSACTION_OK || status == CBM_ACTIVATION_TRANSACTION_DEFERRED;
|
||||
bool closed = cbm_activation_transaction_close(&transaction) == CBM_ACTIVATION_TRANSACTION_OK;
|
||||
if (!finalized || !closed) {
|
||||
ui_fill_error(err, err_sz, "cannot finalize the verified pack");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool cbm_ui_assets_remove(const char *install_dir, bool dry_run, char *err, size_t err_sz) {
|
||||
if (!cbm_ui_assets_supported()) {
|
||||
return true;
|
||||
}
|
||||
char path[UI_PATH_CAP];
|
||||
if (!ui_candidate_path(install_dir, path)) {
|
||||
ui_fill_error(err, err_sz, "uninstall path is too long");
|
||||
return false;
|
||||
}
|
||||
cbm_path_info_t info;
|
||||
if (cbm_path_info_utf8(path, &info) != 0) {
|
||||
return true;
|
||||
}
|
||||
ui_asset_snapshot_t verified = {0};
|
||||
if (!ui_try_path(path, &verified)) {
|
||||
ui_fill_error(err, err_sz, "adjacent pack is not owned; preserved");
|
||||
return false;
|
||||
}
|
||||
ui_snapshot_free(&verified);
|
||||
if (dry_run) {
|
||||
return true;
|
||||
}
|
||||
cbm_activation_transaction_t *transaction = NULL;
|
||||
cbm_activation_transaction_status_t status =
|
||||
cbm_activation_transaction_stage_removal(path, &transaction);
|
||||
if (status == CBM_ACTIVATION_TRANSACTION_OK && transaction) {
|
||||
status = cbm_ui_assets_commit_removal(transaction);
|
||||
}
|
||||
if (status != CBM_ACTIVATION_TRANSACTION_OK || !transaction) {
|
||||
(void)cbm_activation_transaction_close(&transaction);
|
||||
ui_fill_error(err, err_sz,
|
||||
status == CBM_ACTIVATION_TRANSACTION_VALIDATION_FAILED
|
||||
? "adjacent pack changed before removal; preserved"
|
||||
: "cannot remove the owned adjacent pack");
|
||||
return false;
|
||||
}
|
||||
status = cbm_activation_transaction_finalize(transaction);
|
||||
bool finalized =
|
||||
status == CBM_ACTIVATION_TRANSACTION_OK || status == CBM_ACTIVATION_TRANSACTION_DEFERRED;
|
||||
bool closed = cbm_activation_transaction_close(&transaction) == CBM_ACTIVATION_TRANSACTION_OK;
|
||||
if (!finalized || !closed) {
|
||||
ui_fill_error(err, err_sz, "cannot finalize the owned adjacent pack removal");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool cbm_ui_assets_stage_remove(const char *install_dir,
|
||||
cbm_activation_transaction_t **transaction_out,
|
||||
bool *foreign_preserved_out, char *err, size_t err_sz) {
|
||||
if (transaction_out) {
|
||||
*transaction_out = NULL;
|
||||
}
|
||||
if (foreign_preserved_out) {
|
||||
*foreign_preserved_out = false;
|
||||
}
|
||||
if (!transaction_out) {
|
||||
ui_fill_error(err, err_sz, "uninstall transaction output unavailable");
|
||||
return false;
|
||||
}
|
||||
if (!cbm_ui_assets_supported()) {
|
||||
return true;
|
||||
}
|
||||
char path[UI_PATH_CAP];
|
||||
if (!install_dir || !install_dir[0] || !ui_candidate_path(install_dir, path)) {
|
||||
ui_fill_error(err, err_sz, "uninstall path is too long");
|
||||
return false;
|
||||
}
|
||||
cbm_path_info_t info;
|
||||
if (cbm_path_info_utf8(path, &info) != 0) {
|
||||
return true;
|
||||
}
|
||||
if (!info.is_regular || info.is_symlink || !cbm_ui_assets_verify_file(path)) {
|
||||
if (foreign_preserved_out) {
|
||||
*foreign_preserved_out = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
cbm_activation_transaction_status_t status =
|
||||
cbm_activation_transaction_stage_removal(path, transaction_out);
|
||||
if (status != CBM_ACTIVATION_TRANSACTION_OK || !*transaction_out) {
|
||||
ui_fill_error(err, err_sz, cbm_activation_transaction_status_message(status));
|
||||
if (*transaction_out) {
|
||||
(void)cbm_activation_transaction_close(transaction_out);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/* Revalidate after the transaction captured target identity. Commit will
|
||||
* refuse if that identity changes before removal. */
|
||||
if (!cbm_ui_assets_verify_file(path)) {
|
||||
if (cbm_activation_transaction_close(transaction_out) != CBM_ACTIVATION_TRANSACTION_OK) {
|
||||
ui_fill_error(err, err_sz, "cannot clean up a changed pack removal transaction");
|
||||
return false;
|
||||
}
|
||||
if (foreign_preserved_out) {
|
||||
*foreign_preserved_out = true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef CBM_CLI_ENABLE_TEST_API
|
||||
void cbm_ui_assets_set_manifest_for_testing(const char *name, const char *sha256, uint64_t size) {
|
||||
int name_written = snprintf(g_test_pack_name, sizeof(g_test_pack_name), "%s", name ? name : "");
|
||||
int hash_written = snprintf(g_test_sha256, sizeof(g_test_sha256), "%s", sha256 ? sha256 : "");
|
||||
g_test_manifest_set = name_written > 0 && (size_t)name_written < sizeof(g_test_pack_name) &&
|
||||
hash_written == CBM_SHA256_HEX_LEN;
|
||||
g_test_size = size;
|
||||
}
|
||||
|
||||
void cbm_ui_assets_reset_for_testing(void) {
|
||||
ui_snapshot_free(&g_snapshot);
|
||||
atomic_store_explicit(&g_state, CBM_UI_ASSETS_COLD, memory_order_release);
|
||||
atomic_store_explicit(&g_cancel_requested, false, memory_order_release);
|
||||
g_binary_path[0] = '\0';
|
||||
g_test_manifest_set = false;
|
||||
g_test_pack_name[0] = '\0';
|
||||
g_test_sha256[0] = '\0';
|
||||
g_test_size = 0U;
|
||||
}
|
||||
#endif
|
||||
@@ -1,111 +0,0 @@
|
||||
/*
|
||||
* asset_pack.h — Verified external frontend assets.
|
||||
*
|
||||
* UI builds embed only a content-addressed pack name, exact size and SHA-256.
|
||||
* The uncompressed pack stays outside the native executable and is read once
|
||||
* into an immutable process-lifetime snapshot before the HTTP listener starts.
|
||||
*/
|
||||
#ifndef CBM_UI_ASSET_PACK_H
|
||||
#define CBM_UI_ASSET_PACK_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "cli/activation_transaction.h"
|
||||
|
||||
typedef enum {
|
||||
CBM_UI_ASSETS_UNAVAILABLE = 0,
|
||||
CBM_UI_ASSETS_COLD,
|
||||
CBM_UI_ASSETS_LOADING,
|
||||
CBM_UI_ASSETS_READY,
|
||||
CBM_UI_ASSETS_FAILED,
|
||||
CBM_UI_ASSETS_CANCELLED,
|
||||
} cbm_ui_assets_state_t;
|
||||
|
||||
typedef enum {
|
||||
CBM_UI_ASSET_REVALIDATE = 1,
|
||||
CBM_UI_ASSET_IMMUTABLE = 2,
|
||||
} cbm_ui_asset_cache_t;
|
||||
|
||||
typedef struct {
|
||||
const char *path;
|
||||
const unsigned char *data;
|
||||
size_t size;
|
||||
const char *content_type;
|
||||
cbm_ui_asset_cache_t cache;
|
||||
} cbm_ui_asset_t;
|
||||
|
||||
/* Defined by asset_pack_stub.c for standard builds, asset_manifest_stub.c for
|
||||
* test runners, or the generated manifest for UI builds. No frontend payload
|
||||
* bytes live in any of them. */
|
||||
extern const char CBM_UI_ASSET_PACK_NAME[];
|
||||
extern const char CBM_UI_ASSET_SHA256[];
|
||||
extern const uint64_t CBM_UI_ASSET_SIZE;
|
||||
|
||||
/* Compile-time capability; performs no filesystem I/O and is true before the
|
||||
* asynchronous warmup starts. */
|
||||
bool cbm_ui_assets_supported(void);
|
||||
const char *cbm_ui_assets_current_pack_name(void);
|
||||
|
||||
/* Exact binary path used to resolve the adjacent and package-share candidates.
|
||||
* Call before starting warmup. */
|
||||
void cbm_ui_assets_set_binary_path(const char *path);
|
||||
|
||||
/* Resolve, hash and structurally validate the complete pack, then publish one
|
||||
* immutable lookup snapshot. A standard build is a successful no-op. */
|
||||
bool cbm_ui_assets_warm(const char *home, char *err, size_t err_sz);
|
||||
void cbm_ui_assets_request_cancel(void);
|
||||
cbm_ui_assets_state_t cbm_ui_assets_state(void);
|
||||
|
||||
/* Returns NULL until the complete snapshot is READY. The returned view remains
|
||||
* valid for the process lifetime. */
|
||||
const cbm_ui_asset_t *cbm_ui_asset_lookup(const char *path);
|
||||
|
||||
/* Direct CLI install publishes the verified content-addressed pack beside the
|
||||
* target executable before that executable becomes active. Uninstall removes
|
||||
* only an exact hash-verified adjacent pack. */
|
||||
bool cbm_ui_assets_remove(const char *install_dir, bool dry_run, char *err, size_t err_sz);
|
||||
|
||||
/* Verify one explicit pack against this binary's compiled name/size/hash and
|
||||
* parser contract, without publishing a global snapshot. Standard builds
|
||||
* accept no pack and return true only through the supported() no-op paths. */
|
||||
bool cbm_ui_assets_verify_file(const char *path);
|
||||
|
||||
/* Stage removal of the exact owned adjacent pack. Missing/standard packs are
|
||||
* successful no-ops; a foreign file is preserved and reported separately. */
|
||||
bool cbm_ui_assets_stage_remove(const char *install_dir,
|
||||
cbm_activation_transaction_t **transaction_out,
|
||||
bool *foreign_preserved_out, char *err, size_t err_sz);
|
||||
|
||||
/* Commit an owned-pack removal only after the exact retained object (moved out
|
||||
* of the public name by the transaction) still verifies against this binary.
|
||||
* A same-inode rewrite after staging is rolled back and preserved. */
|
||||
cbm_activation_transaction_status_t cbm_ui_assets_commit_removal(
|
||||
cbm_activation_transaction_t *transaction);
|
||||
|
||||
/* Stage the current verified content-addressed pack without publishing it.
|
||||
* Standard builds return true with a NULL transaction. UI callers retain the
|
||||
* transaction through the coordinated binary commit, then finalize/rollback
|
||||
* it while still holding the native activation guard. */
|
||||
bool cbm_ui_assets_stage_install(const char *install_dir,
|
||||
cbm_activation_transaction_t **transaction_out, char *err,
|
||||
size_t err_sz);
|
||||
|
||||
/* Publish a staged pack with a post-rename parser/hash validator, binding the
|
||||
* verification to the bytes at the final public name before finalization. */
|
||||
cbm_activation_transaction_status_t cbm_ui_assets_commit_install(
|
||||
cbm_activation_transaction_t *transaction);
|
||||
|
||||
#ifdef CBM_CLI_ENABLE_TEST_API
|
||||
/* Focused lifecycle probe. Production publishes through stage_install as part
|
||||
* of the native guarded runtime-set transaction. */
|
||||
bool cbm_ui_assets_install(const char *install_dir, bool dry_run, char *err, size_t err_sz);
|
||||
|
||||
/* Single-threaded test seams for exercising the parser with caller-created
|
||||
* fixtures whose digest is recomputed after each structural mutation. */
|
||||
void cbm_ui_assets_set_manifest_for_testing(const char *name, const char *sha256, uint64_t size);
|
||||
void cbm_ui_assets_reset_for_testing(void);
|
||||
#endif
|
||||
|
||||
#endif /* CBM_UI_ASSET_PACK_H */
|
||||
@@ -1,98 +0,0 @@
|
||||
/* Standard builds have no frontend pack and perform no UI asset I/O. */
|
||||
#include "ui/asset_pack.h"
|
||||
|
||||
const char CBM_UI_ASSET_PACK_NAME[] = "";
|
||||
const char CBM_UI_ASSET_SHA256[] = "";
|
||||
const uint64_t CBM_UI_ASSET_SIZE = UINT64_C(0);
|
||||
|
||||
bool cbm_ui_assets_supported(void) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const char *cbm_ui_assets_current_pack_name(void) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void cbm_ui_assets_set_binary_path(const char *path) {
|
||||
(void)path;
|
||||
}
|
||||
|
||||
bool cbm_ui_assets_warm(const char *home, char *err, size_t err_sz) {
|
||||
(void)home;
|
||||
(void)err;
|
||||
(void)err_sz;
|
||||
return true;
|
||||
}
|
||||
|
||||
void cbm_ui_assets_request_cancel(void) {}
|
||||
|
||||
cbm_ui_assets_state_t cbm_ui_assets_state(void) {
|
||||
return CBM_UI_ASSETS_UNAVAILABLE;
|
||||
}
|
||||
|
||||
const cbm_ui_asset_t *cbm_ui_asset_lookup(const char *path) {
|
||||
(void)path;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
#ifdef CBM_CLI_ENABLE_TEST_API
|
||||
bool cbm_ui_assets_install(const char *install_dir, bool dry_run, char *err, size_t err_sz) {
|
||||
(void)install_dir;
|
||||
(void)dry_run;
|
||||
(void)err;
|
||||
(void)err_sz;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool cbm_ui_assets_remove(const char *install_dir, bool dry_run, char *err, size_t err_sz) {
|
||||
(void)install_dir;
|
||||
(void)dry_run;
|
||||
(void)err;
|
||||
(void)err_sz;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool cbm_ui_assets_verify_file(const char *path) {
|
||||
(void)path;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool cbm_ui_assets_stage_remove(const char *install_dir,
|
||||
cbm_activation_transaction_t **transaction_out,
|
||||
bool *foreign_preserved_out, char *err, size_t err_sz) {
|
||||
(void)install_dir;
|
||||
(void)err;
|
||||
(void)err_sz;
|
||||
if (transaction_out) {
|
||||
*transaction_out = NULL;
|
||||
}
|
||||
if (foreign_preserved_out) {
|
||||
*foreign_preserved_out = false;
|
||||
}
|
||||
return transaction_out != NULL;
|
||||
}
|
||||
|
||||
bool cbm_ui_assets_stage_install(const char *install_dir,
|
||||
cbm_activation_transaction_t **transaction_out, char *err,
|
||||
size_t err_sz) {
|
||||
(void)install_dir;
|
||||
(void)err;
|
||||
(void)err_sz;
|
||||
if (transaction_out) {
|
||||
*transaction_out = NULL;
|
||||
}
|
||||
return transaction_out != NULL;
|
||||
}
|
||||
|
||||
cbm_activation_transaction_status_t cbm_ui_assets_commit_install(
|
||||
cbm_activation_transaction_t *transaction) {
|
||||
return transaction ? CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT
|
||||
: CBM_ACTIVATION_TRANSACTION_OK;
|
||||
}
|
||||
|
||||
cbm_activation_transaction_status_t cbm_ui_assets_commit_removal(
|
||||
cbm_activation_transaction_t *transaction) {
|
||||
return transaction ? CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT
|
||||
: CBM_ACTIVATION_TRANSACTION_OK;
|
||||
}
|
||||
+3
-4
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
#include "foundation/constants.h"
|
||||
#include "ui/config.h"
|
||||
#include "ui/asset_pack.h"
|
||||
#include "ui/embedded_assets.h"
|
||||
#include "foundation/log.h"
|
||||
#include "foundation/platform.h"
|
||||
#include "foundation/compat_fs.h"
|
||||
@@ -122,9 +122,8 @@ void cbm_ui_config_load(cbm_ui_config_t *cfg) {
|
||||
bool opened = false;
|
||||
char *buffer = config_read_file(path, &length, &opened);
|
||||
if (!opened) {
|
||||
/* Capability does not transiently disappear while the daemon warms
|
||||
* the verified external pack asynchronously. */
|
||||
if (cbm_ui_assets_supported()) {
|
||||
/* No config file — auto-enable UI if binary has embedded assets */
|
||||
if (CBM_EMBEDDED_FILE_COUNT > 0) {
|
||||
cfg->ui_enabled = true;
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* embedded_assets.h — Interface for embedded frontend assets.
|
||||
*
|
||||
* When built with `cbm-with-ui`, the embed script generates embedded_assets.c
|
||||
* with actual file data. Otherwise, embedded_stub.c provides an empty array.
|
||||
*/
|
||||
#ifndef CBM_UI_EMBEDDED_ASSETS_H
|
||||
#define CBM_UI_EMBEDDED_ASSETS_H
|
||||
|
||||
typedef struct {
|
||||
const char *path; /* URL path, e.g. "/index.html" */
|
||||
const unsigned char *data; /* raw file bytes */
|
||||
unsigned int size; /* byte count */
|
||||
const char *content_type; /* MIME type, e.g. "text/html" */
|
||||
} cbm_embedded_file_t;
|
||||
|
||||
/* Array of embedded files + count. Defined in embedded_assets.c or embedded_stub.c.
|
||||
* Not const — sizes are initialized at startup by a constructor function. */
|
||||
extern cbm_embedded_file_t CBM_EMBEDDED_FILES[];
|
||||
extern const int CBM_EMBEDDED_FILE_COUNT;
|
||||
|
||||
/* Look up an embedded file by URL path. Returns NULL if not found. */
|
||||
const cbm_embedded_file_t *cbm_embedded_lookup(const char *path);
|
||||
|
||||
#endif /* CBM_UI_EMBEDDED_ASSETS_H */
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* embedded_stub.c — Empty asset table when built without frontend.
|
||||
*
|
||||
* Used by the standard `cbm` target (no Node.js required).
|
||||
* The `cbm-with-ui` target replaces this with generated embedded_assets.c.
|
||||
*/
|
||||
#include "ui/embedded_assets.h"
|
||||
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
cbm_embedded_file_t CBM_EMBEDDED_FILES[] = {{NULL, NULL, 0, NULL}};
|
||||
const int CBM_EMBEDDED_FILE_COUNT = 0;
|
||||
|
||||
const cbm_embedded_file_t *cbm_embedded_lookup(const char *path) {
|
||||
for (int i = 0; i < CBM_EMBEDDED_FILE_COUNT; i++) {
|
||||
if (strcmp(CBM_EMBEDDED_FILES[i].path, path) == 0) {
|
||||
return &CBM_EMBEDDED_FILES[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
+15
-19
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* Transport (sockets, parsing, limits) lives in httpd.c; this file owns
|
||||
* the routes and their handlers:
|
||||
* GET / → verified external index.html
|
||||
* GET /assets/... → verified external JS/CSS
|
||||
* GET / → embedded index.html
|
||||
* GET /assets/... → embedded JS/CSS
|
||||
* POST /rpc → JSON-RPC dispatch via own cbm_mcp_server_t
|
||||
* OPTIONS /rpc → CORS preflight (for vite dev on :5173)
|
||||
* GET/POST /api/... → UI support endpoints (layout, index, browse, …)
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
#include "ui/http_server.h"
|
||||
#include "ui/httpd.h"
|
||||
#include "ui/asset_pack.h"
|
||||
#include "ui/embedded_assets.h"
|
||||
#include "ui/layout3d.h"
|
||||
#include "mcp/mcp.h"
|
||||
#include "store/store.h"
|
||||
@@ -186,7 +186,7 @@ struct cbm_http_server {
|
||||
bool readiness_secret_set;
|
||||
};
|
||||
|
||||
/* ── Serve verified frontend asset ───────────────────────────── */
|
||||
/* ── Serve embedded asset ─────────────────────────────────────── */
|
||||
|
||||
/* Content-Security-Policy for the served UI. No external host appears in any
|
||||
* directive, so the browser cannot load or connect to anything off-origin —
|
||||
@@ -201,22 +201,20 @@ struct cbm_http_server {
|
||||
"style-src 'self' 'unsafe-inline'; font-src 'self' data:; " \
|
||||
"worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'\r\n"
|
||||
|
||||
static bool serve_frontend_asset(cbm_http_conn_t *c, const char *path) {
|
||||
const cbm_ui_asset_t *f = cbm_ui_asset_lookup(path);
|
||||
static bool serve_embedded(cbm_http_conn_t *c, const char *path) {
|
||||
const cbm_embedded_file_t *f = cbm_embedded_lookup(path);
|
||||
if (!f)
|
||||
return false;
|
||||
|
||||
/* Build headers with correct Content-Type for this asset */
|
||||
char hdrs[1024];
|
||||
const char *cache = f->cache == CBM_UI_ASSET_REVALIDATE
|
||||
? "Cache-Control: no-cache\r\n"
|
||||
: "Cache-Control: public, max-age=31536000, immutable\r\n";
|
||||
snprintf(hdrs, sizeof(hdrs),
|
||||
"%sContent-Type: %s\r\n"
|
||||
"%sX-Content-Type-Options: nosniff\r\n" CBM_UI_CSP,
|
||||
g_cors, f->content_type, cache);
|
||||
"Cache-Control: public, max-age=31536000, immutable\r\n"
|
||||
"X-Content-Type-Options: nosniff\r\n" CBM_UI_CSP,
|
||||
g_cors, f->content_type);
|
||||
|
||||
cbm_http_reply_buf(c, 200, hdrs, f->data, f->size);
|
||||
cbm_http_reply_buf(c, 200, hdrs, f->data, (size_t)f->size);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -992,7 +990,6 @@ void cbm_http_server_set_binary_path(const char *path) {
|
||||
g_binary_path[0] = '\0';
|
||||
}
|
||||
}
|
||||
cbm_ui_assets_set_binary_path(g_binary_path);
|
||||
}
|
||||
|
||||
/* Execute through the daemon's shared job registry. The thread is retained in
|
||||
@@ -1915,23 +1912,22 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c,
|
||||
|
||||
/* GET / → index.html (no-cache so browser always gets latest) */
|
||||
if (cbm_http_path_match(req->path, "/")) {
|
||||
const cbm_ui_asset_t *f = cbm_ui_asset_lookup("/index.html");
|
||||
const cbm_embedded_file_t *f = cbm_embedded_lookup("/index.html");
|
||||
if (f) {
|
||||
char html_hdrs[1024];
|
||||
snprintf(html_hdrs, sizeof(html_hdrs),
|
||||
"%sContent-Type: text/html; charset=utf-8\r\nCache-Control: no-cache\r\n"
|
||||
"X-Content-Type-Options: nosniff\r\n" CBM_UI_CSP,
|
||||
g_cors);
|
||||
cbm_http_reply_buf(c, 200, html_hdrs, f->data, f->size);
|
||||
cbm_http_reply_buf(c, 200, html_hdrs, f->data, (size_t)f->size);
|
||||
return;
|
||||
}
|
||||
cbm_http_replyf(c, 503, "Cache-Control: no-store\r\nRetry-After: 1\r\n",
|
||||
"frontend assets are not ready");
|
||||
cbm_http_replyf(c, 404, g_cors, "no frontend embedded");
|
||||
return;
|
||||
}
|
||||
|
||||
/* GET /assets/... → exact lookup in the immutable verified pack. */
|
||||
if (serve_frontend_asset(c, req->path))
|
||||
/* GET /assets/... → exact lookup in the embedded asset table. */
|
||||
if (serve_embedded(c, req->path))
|
||||
return;
|
||||
|
||||
cbm_http_replyf(c, 404, g_cors, "not found");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* http_server.h — Embedded HTTP server for the graph visualization UI.
|
||||
*
|
||||
* Binds to 127.0.0.1:<port> only (localhost).
|
||||
* Serves the verified external frontend pack and proxies /rpc to a dedicated
|
||||
* Serves embedded frontend assets and proxies /rpc to a dedicated
|
||||
* read-only cbm_mcp_server_t instance.
|
||||
*
|
||||
* Runs in a background pthread, same pattern as the watcher thread.
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ void cbm_http_replyf(cbm_http_conn_t *c, int status, const char *extra_headers,
|
||||
#endif
|
||||
;
|
||||
|
||||
/* Binary-safe variant for verified asset-pack entries. */
|
||||
/* Binary-safe variant for embedded assets. */
|
||||
void cbm_http_reply_buf(cbm_http_conn_t *c, int status, const char *extra_headers, const void *data,
|
||||
size_t len);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Usage: [env] bash test-infrastructure/vm/vm-smoke.sh
|
||||
|
||||
The canonical WINDOWS smoke entry: identical in local CI (win.sh
|
||||
smoke-install), PR CI (pr.yml) and the release venues (_smoke.yml). Stages a
|
||||
complete one-executable runtime-set fixture under a disposable profile root,
|
||||
complete single-binary release fixture under a disposable profile root,
|
||||
serves it on a kernel-assigned port, prepares/verifies/cleans the user-PATH
|
||||
registry via windows-user-path-guard.ps1, neutralizes every agent-config
|
||||
destination override, then runs scripts/smoke-test.sh (all phases).
|
||||
@@ -28,15 +28,14 @@ Run inside the VM's CLANGARM64 shell (or a CI msys2 shell) from the repo root.
|
||||
|
||||
Environment:
|
||||
SMOKE_ARCH arm64 (default) | amd64 — selects the served artifact name.
|
||||
SMOKE_VARIANT standard (default) | ui. ui requires the external UI pack:
|
||||
SMOKE_VARIANT standard (default) | ui. ui requires the embedded UI:
|
||||
Phase 15's "no assets" SKIP becomes a FAILURE, so a standard
|
||||
binary cannot pass a ui run.
|
||||
CBM_SMOKE_ARTIFACT_DIR
|
||||
Release mode: an EXTRACTED windows release artifact
|
||||
(codebase-memory-mcp.exe + cbm-integrations.json + LICENSE +
|
||||
install.ps1 + THIRD_PARTY_NOTICES.md + the UI pack when selected). The
|
||||
exact variant set is served verbatim; an incomplete archive
|
||||
fails the smoke.
|
||||
(codebase-memory-mcp.exe + LICENSE + install.ps1 +
|
||||
THIRD_PARTY_NOTICES.md). All four are required and served
|
||||
verbatim — an incomplete archive fails the smoke.
|
||||
Unset (default): stages the freshly built binary out of
|
||||
build/c and synthesizes the sidecars (local/PR mode).
|
||||
|
||||
@@ -60,7 +59,7 @@ ARTIFACT_DIR="${CBM_SMOKE_ARTIFACT_DIR:-}"
|
||||
if [ -n "$ARTIFACT_DIR" ]; then
|
||||
ARTIFACT_DIR="$(cd "$ARTIFACT_DIR" && pwd)"
|
||||
for required in codebase-memory-mcp.exe \
|
||||
cbm-integrations.json LICENSE install.ps1 THIRD_PARTY_NOTICES.md; do
|
||||
LICENSE install.ps1 THIRD_PARTY_NOTICES.md; do
|
||||
[ -s "$ARTIFACT_DIR/$required" ] ||
|
||||
{ echo "vm-smoke: release artifact is missing $required" >&2; exit 2; }
|
||||
done
|
||||
@@ -76,9 +75,9 @@ case "$SMOKE_ARCH" in
|
||||
arm64 | amd64) ;;
|
||||
*) echo "vm-smoke: SMOKE_ARCH must be arm64 or amd64. Please consult --help." >&2; exit 2 ;;
|
||||
esac
|
||||
# A ui run must be handed a binary with exactly one external asset pack beside
|
||||
# it; the suffix alone only renames the archive. SMOKE_REQUIRE_UI turns Phase
|
||||
# 15's documented "no UI assets" SKIP into a failure, so asking for ui and
|
||||
# A ui run must be handed a binary that actually carries the embedded assets;
|
||||
# the suffix alone only renames the archive. SMOKE_REQUIRE_UI turns Phase 15's
|
||||
# documented "no embedded assets" SKIP into a failure, so asking for ui and
|
||||
# supplying a standard binary can no longer pass quietly.
|
||||
case "$SMOKE_VARIANT" in
|
||||
standard) SUFFIX="" ; REQUIRE_UI=0 ;;
|
||||
@@ -86,37 +85,6 @@ ui) SUFFIX="-ui" ; REQUIRE_UI=1 ;;
|
||||
*) echo "vm-smoke: SMOKE_VARIANT must be standard or ui. Please consult --help." >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
if [ -n "$ARTIFACT_DIR" ]; then
|
||||
UI_PACK_SOURCE_DIR="$ARTIFACT_DIR"
|
||||
else
|
||||
UI_PACK_SOURCE_DIR="$(dirname "$BINARY_SRC")"
|
||||
fi
|
||||
shopt -s nullglob
|
||||
UI_PACK_CANDIDATES=("$UI_PACK_SOURCE_DIR"/cbm-ui-*.pack)
|
||||
shopt -u nullglob
|
||||
UI_PACK_COUNT=0
|
||||
UI_PACK_NAME=""
|
||||
UI_PACK_SOURCE=""
|
||||
for candidate in "${UI_PACK_CANDIDATES[@]+"${UI_PACK_CANDIDATES[@]}"}"; do
|
||||
UI_PACK_COUNT=$((UI_PACK_COUNT + 1))
|
||||
UI_PACK_SOURCE="$candidate"
|
||||
candidate_name="$(basename "$candidate")"
|
||||
if ! [[ "$candidate_name" =~ ^cbm-ui-[0-9a-f]{64}\.pack$ ]]; then
|
||||
echo "vm-smoke: invalid UI pack name beside artifact: $candidate_name" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
if [ "$SMOKE_VARIANT" = "ui" ]; then
|
||||
if [ "$UI_PACK_COUNT" -ne 1 ] || [ ! -s "$UI_PACK_SOURCE" ]; then
|
||||
echo "vm-smoke: ui fixture requires exactly one content-addressed UI pack" >&2
|
||||
exit 2
|
||||
fi
|
||||
UI_PACK_NAME="$(basename "$UI_PACK_SOURCE")"
|
||||
elif [ "$UI_PACK_COUNT" -ne 0 ]; then
|
||||
echo "vm-smoke: standard fixture must not contain a UI asset pack" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
PROFILE_ROOT="$(cygpath -u "$USERPROFILE")"
|
||||
SMOKE_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-vm-smoke.XXXXXX")"
|
||||
FIXTURE_DIR="$SMOKE_DIR/artifacts"
|
||||
@@ -178,45 +146,26 @@ MSYS2_ARG_CONV_EXCL='*' powershell.exe -NoProfile -ExecutionPolicy Bypass \
|
||||
-SmokeRoot "$(cygpath -w "$SMOKE_DIR")"
|
||||
cp "$BINARY_SRC" "$SMOKE_DIR/codebase-memory-mcp.exe"
|
||||
cp "$SMOKE_DIR/codebase-memory-mcp.exe" "$FIXTURE_DIR/"
|
||||
if [ -n "$UI_PACK_SOURCE" ]; then
|
||||
cp "$UI_PACK_SOURCE" "$SMOKE_DIR/$UI_PACK_NAME"
|
||||
cp "$UI_PACK_SOURCE" "$FIXTURE_DIR/$UI_PACK_NAME"
|
||||
fi
|
||||
# The integration asset must sit next to the smoke binary in $SMOKE_DIR: phases
|
||||
# that invoke it directly (Phase 14 stages it by hand and drives an uninstall)
|
||||
# resolve the asset next to the binary, and without it those render-template
|
||||
# operations fail closed. Its own copy lands in the served archive below.
|
||||
if [ -n "$ARTIFACT_DIR" ]; then
|
||||
ASSET_SRC="$ARTIFACT_DIR/cbm-integrations.json"
|
||||
else
|
||||
ASSET_SRC="$(dirname "$BINARY_SRC")/cbm-integrations.json"
|
||||
fi
|
||||
cp "$ASSET_SRC" "$SMOKE_DIR/cbm-integrations.json"
|
||||
# The install/update phases fetch these out of the served archive, so they must
|
||||
# be the artifact's own copies whenever one was supplied — regenerating them
|
||||
# here would smoke a sidecar the release never ships.
|
||||
if [ -n "$ARTIFACT_DIR" ]; then
|
||||
cp "$ARTIFACT_DIR/cbm-integrations.json" "$ARTIFACT_DIR/LICENSE" \
|
||||
cp "$ARTIFACT_DIR/LICENSE" \
|
||||
"$ARTIFACT_DIR/install.ps1" "$ARTIFACT_DIR/THIRD_PARTY_NOTICES.md" "$FIXTURE_DIR/"
|
||||
else
|
||||
# build.sh stages cbm-integrations.json next to the binary; ship it in the
|
||||
# archive so the installed binary can verify and render its templates.
|
||||
cp "$(dirname "$BINARY_SRC")/cbm-integrations.json" LICENSE install.ps1 "$FIXTURE_DIR/"
|
||||
cp LICENSE install.ps1 "$FIXTURE_DIR/"
|
||||
scripts/gen-third-party-notices.sh "$FIXTURE_DIR/THIRD_PARTY_NOTICES.md"
|
||||
fi
|
||||
|
||||
# Member set + ORDER mirror scripts/package-release.sh (Windows): the install
|
||||
# verifies cbm-integrations.json against the binary's embedded SHA-256 and fails
|
||||
# closed without it.
|
||||
# Member set + ORDER mirror scripts/package-release.sh (Windows).
|
||||
EXPECTED_ARTIFACT="codebase-memory-mcp${SUFFIX}-windows-${SMOKE_ARCH}.zip"
|
||||
(
|
||||
cd "$FIXTURE_DIR"
|
||||
ARCHIVE_MEMBERS=(
|
||||
codebase-memory-mcp.exe cbm-integrations.json LICENSE install.ps1
|
||||
THIRD_PARTY_NOTICES.md
|
||||
)
|
||||
[ -n "$UI_PACK_NAME" ] && ARCHIVE_MEMBERS+=("$UI_PACK_NAME")
|
||||
zip -q "$EXPECTED_ARTIFACT" "${ARCHIVE_MEMBERS[@]}"
|
||||
zip -q "$EXPECTED_ARTIFACT" \
|
||||
codebase-memory-mcp.exe LICENSE install.ps1 THIRD_PARTY_NOTICES.md
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
cp "$EXPECTED_ARTIFACT" "codebase-memory-mcp-windows-${SMOKE_ARCH}.zip"
|
||||
fi
|
||||
sha256sum *.zip > checksums.txt
|
||||
)
|
||||
|
||||
|
||||
+23
-1075
File diff suppressed because it is too large
Load Diff
@@ -284,13 +284,11 @@ TEST(daemon_bootstrap_classifies_stateless_commands_without_client) {
|
||||
char *install[] = {"codebase-memory-mcp", "install", "--dry-run", NULL};
|
||||
char *uninstall[] = {"codebase-memory-mcp", "uninstall", NULL};
|
||||
char *update[] = {"codebase-memory-mcp", "update", "-n", NULL};
|
||||
char *verify_runtime_assets[] = {"codebase-memory-mcp", "--verify-runtime-assets", NULL};
|
||||
ASSERT_EQ(classify(2, version), CBM_DAEMON_PROCESS_STATELESS);
|
||||
ASSERT_EQ(classify(3, help), CBM_DAEMON_PROCESS_STATELESS);
|
||||
ASSERT_EQ(classify(3, install), CBM_DAEMON_PROCESS_STATELESS);
|
||||
ASSERT_EQ(classify(2, uninstall), CBM_DAEMON_PROCESS_STATELESS);
|
||||
ASSERT_EQ(classify(3, update), CBM_DAEMON_PROCESS_STATELESS);
|
||||
ASSERT_EQ(classify(2, verify_runtime_assets), CBM_DAEMON_PROCESS_STATELESS);
|
||||
ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_STATELESS));
|
||||
PASS();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
r"""Product guard for ``daemon start --open`` UI readiness.
|
||||
|
||||
The daemon intentionally warms and verifies its external UI pack after the
|
||||
control socket is already available. ``--open`` is the one synchronous user
|
||||
request in that flow: it must wait for the *CBM HTTP endpoint*, not merely for
|
||||
the daemon process, before it reports or opens the URL.
|
||||
The control socket becomes available before the HTTP listener does. ``--open``
|
||||
is the one synchronous user request in that flow: it must wait for the *CBM HTTP
|
||||
endpoint*, not merely for the daemon process, before it reports or opens the URL.
|
||||
|
||||
This guard uses a real UI build and isolated daemon generations:
|
||||
|
||||
@@ -11,8 +10,6 @@ This guard uses a real UI build and isolated daemon generations:
|
||||
the verified UI listener becomes available;
|
||||
* a foreign service returning the exact formerly accepted HTML markers proves
|
||||
public page text is not treated as daemon identity;
|
||||
* a copied binary without its content-addressed pack proves missing/invalid
|
||||
runtime assets never result in an opened or reported UI URL.
|
||||
* a second ``daemon start --open`` proves the already-active daemon path obtains
|
||||
the same generation-bound proof over a fresh authenticated IPC client.
|
||||
|
||||
@@ -330,38 +327,6 @@ def assert_active_daemon_open(binary, work):
|
||||
stop_daemon(binary, env, daemon_pid)
|
||||
|
||||
|
||||
def assert_missing_pack_failure(binary, work):
|
||||
isolated = os.path.join(work, "without-pack")
|
||||
os.makedirs(isolated, exist_ok=True)
|
||||
copied_binary = os.path.join(isolated, os.path.basename(binary))
|
||||
shutil.copy2(binary, copied_binary)
|
||||
|
||||
cache = os.path.join(work, "cache-missing-pack")
|
||||
marker = os.path.join(work, "browser-missing-pack.txt")
|
||||
probe, port = occupied_loopback_port()
|
||||
probe.close() # reserve an unused port number without keeping it occupied
|
||||
process, env = launch_start(copied_binary, work, cache, marker, port, 900)
|
||||
daemon_pid = 0
|
||||
try:
|
||||
result, text = collect(process, timeout=10)
|
||||
daemon_pid = pid_from(text)
|
||||
url = "http://127.0.0.1:%d" % port
|
||||
if (result.returncode == 0 or os.path.exists(marker) or url in text or
|
||||
"UI endpoint did not become ready" not in text or
|
||||
"matching runtime assets" not in text):
|
||||
print("RED: a UI binary without its verified pack must fail --open without "
|
||||
"reporting/opening a URL:\n%s" % text[:700])
|
||||
return False
|
||||
print("PASS: missing verified UI assets fail --open without opening/reporting a URL")
|
||||
return True
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
_, text = collect(process)
|
||||
daemon_pid = daemon_pid or pid_from(text)
|
||||
stop_daemon(copied_binary, env, daemon_pid)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: python3 test_daemon_open_readiness.py <ui-binary>")
|
||||
@@ -370,11 +335,6 @@ def main():
|
||||
if not os.path.isfile(binary):
|
||||
print("SETUP FAIL: binary not found: %s" % binary)
|
||||
return 2
|
||||
binary_dir = os.path.dirname(binary)
|
||||
if not any(name.startswith("cbm-ui-") and name.endswith(".pack")
|
||||
for name in os.listdir(binary_dir)):
|
||||
print("SETUP FAIL: no cbm-ui-*.pack adjacent to UI binary: %s" % binary_dir)
|
||||
return 2
|
||||
if os.name == "nt":
|
||||
with open(binary, "rb") as handle:
|
||||
if OPEN_MARKER_ENV.encode("ascii") not in handle.read():
|
||||
@@ -392,8 +352,6 @@ def main():
|
||||
return 1
|
||||
if not assert_active_daemon_open(binary, work):
|
||||
return 1
|
||||
if not assert_missing_pack_failure(binary, work):
|
||||
return 1
|
||||
print("\nGREEN: daemon --open is bound to verified UI endpoint readiness.")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Contract: the native product must not embed integration or frontend bytes.
|
||||
#
|
||||
# The binary used to carry nine complete shebang'd shell scripts, their
|
||||
# PowerShell/.cmd twins and the node:child_process client modules as C string
|
||||
# literals — written to client config dirs at 0755. A block of script-shaped
|
||||
# bytes inside a native executable is unnecessary mixed-content with plausible
|
||||
# static-classifier overlap. That is a risk-reduction rationale, not feature
|
||||
# attribution for an opaque vendor verdict. Those bodies now live in
|
||||
# assets/cbm-integrations.json (hash-verified; the binary embeds only the
|
||||
# SHA-256), and this contract is the proof the removal STAYS removed: one
|
||||
# revived string literal would silently hand the next release its
|
||||
# malware-shaped surface back, and nothing else in CI would notice.
|
||||
#
|
||||
# Two independent legs:
|
||||
# 1. SOURCE: no production C source under src/ may contain a shebang or a
|
||||
# node:child_process literal. Absence at source level makes absence a
|
||||
# BUILD property of every artifact (standard, ui, all platforms) — not a
|
||||
# reachability assumption about any one binary.
|
||||
# 2. ARTIFACT: when build/c/codebase-memory-mcp exists, its bytes are scanned
|
||||
# directly. scripts/ci/check-binary-composition.sh runs the script needles
|
||||
# on every release variant (A6 and A7), so a
|
||||
# missing local build here skips only a redundant leg, never the property.
|
||||
#
|
||||
# Comment stripping is mandatory, not tidiness: a contract that a COMMENT can
|
||||
# satisfy (or trip) is a false guard — see test_spawn_no_window_contract.sh,
|
||||
# whose first version passed with its fix reverted because prose matched.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
python3 - "$ROOT" <<'PY'
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
|
||||
|
||||
def strip_comments(text):
|
||||
"""Blank out C comments, preserving line count and column positions."""
|
||||
out = []
|
||||
i, n = 0, len(text)
|
||||
while i < n:
|
||||
two = text[i:i + 2]
|
||||
if two == "/*":
|
||||
j = text.find("*/", i + 2)
|
||||
j = n if j < 0 else j + 2
|
||||
out.append("".join(c if c == "\n" else " " for c in text[i:j]))
|
||||
i = j
|
||||
elif two == "//":
|
||||
j = text.find("\n", i)
|
||||
j = n if j < 0 else j
|
||||
out.append(" " * (j - i))
|
||||
i = j
|
||||
else:
|
||||
out.append(text[i])
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
# A file may opt out only with a justified, at-the-case entry (O10): the
|
||||
# exact repo-relative path plus WHY. Empty on purpose — the templates live in
|
||||
# assets/cbm-integrations.json, and no production source needs script text.
|
||||
ALLOWLIST = {
|
||||
# "src/foo/bar.c": "why this file legitimately embeds script text",
|
||||
}
|
||||
|
||||
# "#!" alone covers every shebang, including one assembled from adjacent
|
||||
# string literals ("#!" "/bin/sh"); node:child_process covers the JS bridge.
|
||||
SOURCE_NEEDLES = ["#!", "node:child_process"]
|
||||
|
||||
# Every FIRST-PARTY tree linked into the product binary. Vendored trees are
|
||||
# deliberately out of scope (pinned upstream code we do not author; the
|
||||
# artifact leg and the release gate still cover whatever they contribute).
|
||||
SOURCE_GLOBS = [
|
||||
"src/**/*.c",
|
||||
"internal/cbm/*.c",
|
||||
"internal/cbm/lsp/*.c",
|
||||
"internal/cbm/lsp/generated/*.c",
|
||||
]
|
||||
|
||||
failures = []
|
||||
scanned = 0
|
||||
sources = sorted(p for g in SOURCE_GLOBS for p in root.glob(g))
|
||||
for path in sources:
|
||||
rel = path.relative_to(root).as_posix()
|
||||
scanned += 1
|
||||
if rel in ALLOWLIST:
|
||||
continue
|
||||
text = strip_comments(path.read_text(encoding="utf-8", errors="replace"))
|
||||
for needle in SOURCE_NEEDLES:
|
||||
for idx, line in enumerate(text.splitlines(), start=1):
|
||||
if needle in line:
|
||||
failures.append(f"{rel}:{idx} contains '{needle}': {line.strip()[:80]}")
|
||||
|
||||
# Anti-vacuity: the files that carried the embedded bodies must still be in
|
||||
# scope — if they move, this contract must move with them, loudly.
|
||||
for must_scan in ("src/cli/cli.c", "src/cli/client_adapter.c"):
|
||||
if not (root / must_scan).is_file():
|
||||
failures.append(f"{must_scan} is gone — re-point this contract at the "
|
||||
"file that now owns integration installs")
|
||||
|
||||
# The release gate must enforce UI script absence unconditionally and preserve
|
||||
# the native-vs-pack byte boundary. A debug environment escape hatch here would
|
||||
# make the source assertion true while shipped UI binaries quietly bypass it.
|
||||
composition = (root / "scripts/ci/check-binary-composition.sh").read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
)
|
||||
if "CBM_CHECK_UI_SCRIPTS_ABSENT" in composition:
|
||||
failures.append("binary composition gate still permits UI script-scan bypass")
|
||||
for required in ("A6-no-embedded-scripts", "A7-no-ui-asset-bytes", "CBMUIPK",
|
||||
"<!doctype html>", '<script type="module" crossorigin'):
|
||||
if required not in composition:
|
||||
failures.append(f"binary composition gate is missing {required!r}")
|
||||
a7 = composition.split("# A7 —", 1)
|
||||
if len(a7) != 2:
|
||||
failures.append("binary composition gate has no A7 implementation")
|
||||
else:
|
||||
a7_body = a7[1].split("\n}", 1)[0]
|
||||
if 'if [ "$is_ui"' in a7_body:
|
||||
failures.append("A7 raw frontend-byte check is conditional on UI path classification")
|
||||
if 'for needle in "${UI_ASSET_BYTE_NEEDLES[@]}"' not in a7_body:
|
||||
failures.append("A7 does not scan every native artifact for every frontend-byte needle")
|
||||
|
||||
# Filename-selected abort/busy-loop injectors are useful only in deterministic
|
||||
# supervisor tests. They must be compiled out of ordinary production builds,
|
||||
# while the broad source-matrix smoke explicitly opts into them so coverage is
|
||||
# preserved rather than silently deleted.
|
||||
cbm_source = (root / "internal/cbm/cbm.c").read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
)
|
||||
if "#ifdef CBM_ENABLE_TEST_SEAMS\n/* Deterministic supervisor fault injection" not in cbm_source:
|
||||
failures.append("internal crash/hang fault injector is not behind CBM_ENABLE_TEST_SEAMS")
|
||||
if "#ifdef CBM_ENABLE_TEST_SEAMS\n cbm_test_fault_inject(rel_path);\n#endif" not in cbm_source:
|
||||
failures.append("fault-injector call site is not behind CBM_ENABLE_TEST_SEAMS")
|
||||
for needle in ("CBM_TEST_CRASH_ON", "CBM_TEST_HANG_ON"):
|
||||
if needle not in composition.split("SEAM_NEEDLES=(", 1)[-1].split(")", 1)[0]:
|
||||
failures.append(f"binary composition gate does not forbid {needle}")
|
||||
smoke_workflow = (root / ".github/workflows/smoke.yml").read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
)
|
||||
if smoke_workflow.count("TEST_SEAMS=1") != 3:
|
||||
failures.append("wide smoke matrix must opt into test seams on all three build jobs")
|
||||
|
||||
# Standard production must not acquire the parser's pack/file-I/O surface just
|
||||
# because tests need it. One stub substitution keeps each link topology exact:
|
||||
# standard = no-I/O stub; UI = real parser + generated manifest; test/repro/TSan
|
||||
# = real parser + empty manifest. Linking either manifest twice is an error.
|
||||
makefile = (root / "Makefile.cbm").read_text(encoding="utf-8", errors="replace")
|
||||
for required in (
|
||||
"src/ui/asset_pack_stub.c",
|
||||
"TEST_PROD_SRCS = $(subst src/ui/asset_pack_stub.c,src/ui/asset_pack.c "
|
||||
"src/ui/asset_manifest_stub.c,$(PROD_SRCS))",
|
||||
"PROD_SRCS_WITH_ASSETS = $(subst src/ui/asset_pack_stub.c,src/ui/asset_pack.c "
|
||||
"$(UI_ASSET_MANIFEST),$(PROD_SRCS))",
|
||||
):
|
||||
if required not in makefile:
|
||||
failures.append(f"Makefile UI link topology is missing {required!r}")
|
||||
ui_sources = makefile.split("UI_SRCS =", 1)[-1].split("# mimalloc", 1)[0]
|
||||
for forbidden in ("src/ui/asset_pack.c", "src/ui/asset_manifest_stub.c"):
|
||||
if forbidden in ui_sources:
|
||||
failures.append(f"standard UI_SRCS still links {forbidden}")
|
||||
for target in ("test-runner", "test-repro-runner", "test-runner-tsan"):
|
||||
rule = makefile.split(f"$(BUILD_DIR)/{target}:", 1)
|
||||
if len(rule) != 2 or "$(TEST_PROD_SRCS)" not in rule[1].split("\n\n", 1)[0]:
|
||||
failures.append(f"{target} does not link the full parser test source set")
|
||||
|
||||
if scanned == 0:
|
||||
print("FAIL: scanned no production sources — this contract checks nothing")
|
||||
sys.exit(1)
|
||||
|
||||
if failures:
|
||||
print(f"FAIL: {len(failures)} native-image byte-boundary violation(s):\n")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
print("\nIntegration bodies belong in assets/cbm-integrations.json (verified by "
|
||||
"the embedded SHA-256), and frontend bytes belong in the verified UI pack, "
|
||||
"never in the binary. A justified exception needs "
|
||||
"an ALLOWLIST entry in this file.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"OK: {scanned} production source file(s) free of script literals")
|
||||
PY
|
||||
|
||||
# ── Artifact leg ─────────────────────────────────────────────────────
|
||||
BINARY="build/c/codebase-memory-mcp"
|
||||
if [ ! -f "$BINARY" ]; then
|
||||
# Not a silent green: name exactly why this leg may skip (O10). The source
|
||||
# leg above already proved the build property, and the release gate
|
||||
# (check-binary-composition.sh A6) scans every shipped artifact.
|
||||
echo "skip: $BINARY not built here; artifact leg runs in the release gate (A6)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
fail=0
|
||||
for needle in '#!/usr/bin/env bash' '#!/bin/bash' '#!/bin/sh' 'node:child_process' \
|
||||
'CBMUIPK' '<!doctype html>' '<html lang="en" class="dark">' \
|
||||
'<script type="module" crossorigin'; do
|
||||
if LC_ALL=C grep -a -q -F -e "$needle" "$BINARY"; then
|
||||
echo "FAIL: built binary contains '$needle' — sidecar bytes are embedded again"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
# Anti-vacuity canary, same as the release gate: proves the scan reads a real
|
||||
# artifact rather than a stub/truncated file where absence is meaningless.
|
||||
if ! LC_ALL=C grep -a -q -F -e 'codebase-memory-mcp' "$BINARY"; then
|
||||
echo "FAIL: canary string missing — $BINARY is not a readable product binary"
|
||||
fail=1
|
||||
fi
|
||||
[ "$fail" -eq 0 ] && echo "OK: built binary carries no integration scripts or raw UI-pack bytes"
|
||||
exit "$fail"
|
||||
@@ -1,517 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runtime-sidecar contract for release archives, package managers and source
|
||||
# installers. Release packaging executes the final staged binary against the
|
||||
# exact adjacent files it will archive; downstream packages must retain those
|
||||
# files either beside the executable or in ../share/codebase-memory-mcp.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
python3 - "$ROOT" "$BASH" <<'PY'
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import re
|
||||
import shlex
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
bash_executable = pathlib.Path(sys.argv[2])
|
||||
failures: list[str] = []
|
||||
|
||||
|
||||
def read(relative: str) -> str:
|
||||
return (root / relative).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
package_release = read("scripts/package-release.sh")
|
||||
test_workflow = read(".github/workflows/_test.yml")
|
||||
strip_position = package_release.find('strip_release_binary "$STAGED_BINARY"')
|
||||
probe_position = package_release.find('"$STAGED_BINARY" --verify-runtime-assets')
|
||||
tar_position = package_release.find('tar -czf "$OUT_DIR/$NAME.tar.gz"')
|
||||
zip_position = package_release.find('zip -q "$OUT_DIR/$NAME.zip"')
|
||||
require(
|
||||
'if ! "$STAGED_BINARY" --verify-runtime-assets; then' in package_release,
|
||||
"release packaging must execute the final staged binary's runtime-asset probe",
|
||||
)
|
||||
require(
|
||||
min(strip_position, probe_position, tar_position, zip_position) >= 0
|
||||
and strip_position < probe_position < tar_position
|
||||
and probe_position < zip_position,
|
||||
"Unix release packaging must probe after final binary transformation and before archive",
|
||||
)
|
||||
require(
|
||||
re.search(
|
||||
r'tar\s+-czf\s+"\$OUT_DIR/\$NAME\.tar\.gz"\s+-C\s+"\$PACK_DIR"',
|
||||
package_release,
|
||||
)
|
||||
is not None,
|
||||
"Unix release archive must be created from the same private directory that was probed",
|
||||
)
|
||||
require(
|
||||
'cd "$PACK_DIR"' in package_release
|
||||
and 'zip -q "$OUT_DIR/$NAME.zip" "${ARCHIVE_MEMBERS[@]}"' in package_release,
|
||||
"Windows release archive must be created from the same private directory that was probed",
|
||||
)
|
||||
windows_test_job = re.search(
|
||||
r"(?ms)^ test-windows:\n(?P<body>.*?)(?=^ [a-zA-Z0-9_-]+:\n)",
|
||||
test_workflow,
|
||||
)
|
||||
require(
|
||||
windows_test_job is not None
|
||||
and re.search(r"(?m)^\s+zip\s*$", windows_test_job.group("body")) is not None,
|
||||
"Windows test jobs that exercise package-release.sh must install zip",
|
||||
)
|
||||
|
||||
|
||||
homebrew = read("pkg/homebrew/Formula/codebase-memory-mcp.rb")
|
||||
require(
|
||||
re.search(
|
||||
r'\(share\s*/\s*["\']codebase-memory-mcp["\']\)\.install\s+'
|
||||
r'["\']cbm-integrations\.json["\']',
|
||||
homebrew,
|
||||
)
|
||||
is not None,
|
||||
"Homebrew must install cbm-integrations.json in ../share/codebase-memory-mcp",
|
||||
)
|
||||
require(
|
||||
re.search(
|
||||
r'assert_path_exists\s+share\s*/\s*["\']codebase-memory-mcp/'
|
||||
r'cbm-integrations\.json["\']',
|
||||
homebrew,
|
||||
)
|
||||
is not None,
|
||||
"Homebrew formula test must assert the installed integration asset",
|
||||
)
|
||||
require(
|
||||
"codebase-memory-mcp install" in homebrew,
|
||||
"Homebrew must retain the opt-in agent-configuration caveat",
|
||||
)
|
||||
|
||||
aur = read("pkg/aur/PKGBUILD")
|
||||
require(
|
||||
re.search(
|
||||
r'install\s+-Dm644\s+["\']\$\{srcdir\}/cbm-integrations\.json["\']'
|
||||
r'\s*\\?\s*["\']\$\{pkgdir\}/usr/share/codebase-memory-mcp/'
|
||||
r'cbm-integrations\.json["\']',
|
||||
aur,
|
||||
)
|
||||
is not None,
|
||||
"AUR must install cbm-integrations.json in /usr/share/codebase-memory-mcp",
|
||||
)
|
||||
|
||||
scoop = json.loads(read("pkg/scoop/codebase-memory-mcp.json"))
|
||||
scoop_post_install = scoop.get("post_install", [])
|
||||
if isinstance(scoop_post_install, str):
|
||||
scoop_post_install = [scoop_post_install]
|
||||
require(
|
||||
not any(re.search(r'\binstall(?:\s|$)', command, re.IGNORECASE)
|
||||
for command in scoop_post_install),
|
||||
"Scoop must not invoke the full native install command after extracting the package",
|
||||
)
|
||||
require(
|
||||
scoop["architecture"]["64bit"].get("bin") == "codebase-memory-mcp.exe",
|
||||
"Scoop shim must target the executable inside the extracted package directory",
|
||||
)
|
||||
|
||||
choco_install = read("pkg/chocolatey/tools/chocolateyInstall.ps1")
|
||||
choco_uninstall = read("pkg/chocolatey/tools/chocolateyUninstall.ps1")
|
||||
require(
|
||||
re.search(r'&\s*\$binPath\s+install\b', choco_install, re.IGNORECASE) is None,
|
||||
"Chocolatey must not invoke the full native install command after extraction",
|
||||
)
|
||||
require(
|
||||
re.search(r'-UnzipLocation\s+\$installDir\b', choco_install) is not None
|
||||
and re.search(
|
||||
r'\$binPath\s*=\s*Join-Path\s+\$installDir\s+'
|
||||
r'["\']codebase-memory-mcp\.exe["\']',
|
||||
choco_install,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
is not None,
|
||||
"Chocolatey must shim the executable in the whole extracted package directory",
|
||||
)
|
||||
require(
|
||||
re.search(r'Remove-Item\s+\$installDir\s+-Recurse\s+-Force', choco_uninstall) is not None,
|
||||
"Chocolatey uninstall must remove the package-owned extraction directory",
|
||||
)
|
||||
|
||||
setup_sh = read("scripts/setup.sh")
|
||||
asset_helper = setup_sh.split("install_integration_asset() {", 1)[-1].split(
|
||||
"download_binary() {", 1
|
||||
)[0]
|
||||
download_body = setup_sh.split("download_binary() {", 1)[-1].split(
|
||||
"# --- Build from source ---", 1
|
||||
)[0]
|
||||
source_body = setup_sh.split("build_from_source() {", 1)[-1].split(
|
||||
"# --- MCP auto-configuration ---", 1
|
||||
)[0]
|
||||
require(
|
||||
re.search(
|
||||
r'cp\s+["\']\$source_path["\']\s+'
|
||||
r'["\']\$\{INSTALL_DIR\}/cbm-integrations\.json["\']',
|
||||
asset_helper,
|
||||
)
|
||||
is not None,
|
||||
"Unix integration-asset helper must copy the JSON beside the binary",
|
||||
)
|
||||
require(
|
||||
'install_integration_asset "$tmpdir"' in download_body,
|
||||
"Unix release download must install cbm-integrations.json beside the binary",
|
||||
)
|
||||
require(
|
||||
'install_integration_asset "${SOURCE_DIR}/build/c"' in source_body,
|
||||
"Unix source build must install cbm-integrations.json beside the binary",
|
||||
)
|
||||
|
||||
setup_windows = read("scripts/setup-windows.ps1")
|
||||
windows_source = setup_windows.split("if ($FromSource) {", 1)[-1].split(
|
||||
"} else {", 1
|
||||
)[0]
|
||||
windows_download = setup_windows.split("} else {", 1)[-1]
|
||||
require(
|
||||
re.search(
|
||||
r'cp\s+build/c/codebase-memory-mcp\s+build/c/cbm-integrations\.json\s+'
|
||||
r'/home/\$wslUser/\.local/bin/',
|
||||
windows_source,
|
||||
)
|
||||
is not None,
|
||||
"Windows source build must copy cbm-integrations.json beside the WSL binary",
|
||||
)
|
||||
require(
|
||||
re.search(
|
||||
r'Expand-Archive\s+-Path\s+\$tmpZip\s+-DestinationPath\s+\$InstallDir\s+-Force',
|
||||
windows_download,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
is not None,
|
||||
"Windows release download must retain the complete archive in the install directory",
|
||||
)
|
||||
|
||||
glama = read("pkg/glama/Dockerfile")
|
||||
require(
|
||||
re.search(r'tar\s+-xz\s+-C\s+/tmp[^\n]*cbm-integrations\.json', glama) is not None,
|
||||
"Glama package extraction must retain cbm-integrations.json",
|
||||
)
|
||||
require(
|
||||
re.search(
|
||||
r'COPY\s+--from=fetch\s+/tmp/cbm-integrations\.json\s+'
|
||||
r'/usr/local/share/codebase-memory-mcp/cbm-integrations\.json',
|
||||
glama,
|
||||
)
|
||||
is not None,
|
||||
"Glama must install cbm-integrations.json at the executable-relative share path",
|
||||
)
|
||||
|
||||
|
||||
# Exercise the packager itself with a tiny native executable that implements
|
||||
# the same hidden command-line contract as the product. This is deliberately a
|
||||
# native fixture (rather than a shell script): package-release still has to run
|
||||
# its real strip and binary-composition gates before it reaches the association
|
||||
# probe. The two negative cases distinguish self-integrity from association:
|
||||
# the alternate UI pack is structurally valid and named by its own SHA-256, but
|
||||
# it is not the pack the staged binary declares; likewise, the repository JSON
|
||||
# is a valid manifest but is not the manifest expected by the mismatch fixture.
|
||||
fixture_source_template = r'''
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define EXPECT_INTEGRATION __EXPECT_INTEGRATION__
|
||||
#define EXPECT_UI_NAME __EXPECT_UI_NAME__
|
||||
#define EXPECT_UI_MARKER __EXPECT_UI_MARKER__
|
||||
|
||||
static int file_contains(const char *path, const char *needle) {
|
||||
FILE *stream = fopen(path, "rb");
|
||||
if (!stream) return 0;
|
||||
const size_t needle_length = strlen(needle);
|
||||
size_t matched = 0;
|
||||
int byte = 0;
|
||||
while (needle_length > 0 && (byte = fgetc(stream)) != EOF) {
|
||||
if ((unsigned char)byte == (unsigned char)needle[matched]) {
|
||||
matched++;
|
||||
if (matched == needle_length) {
|
||||
fclose(stream);
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
matched = ((unsigned char)byte == (unsigned char)needle[0]) ? 1U : 0U;
|
||||
}
|
||||
}
|
||||
fclose(stream);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int adjacent_path(const char *argv0, const char *leaf, char *out, size_t out_size) {
|
||||
char directory[4096];
|
||||
const size_t length = strlen(argv0);
|
||||
if (length == 0 || length >= sizeof(directory)) return 0;
|
||||
memcpy(directory, argv0, length + 1U);
|
||||
char *slash = strrchr(directory, '/');
|
||||
char *backslash = strrchr(directory, '\\');
|
||||
if (backslash && (!slash || backslash > slash)) slash = backslash;
|
||||
if (!slash) return 0;
|
||||
*slash = '\0';
|
||||
const int written = snprintf(out, out_size, "%s/%s", directory, leaf);
|
||||
return written > 0 && (size_t)written < out_size;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
static const char product[] = "codebase-memory-mcp";
|
||||
char path[4096];
|
||||
if (argc != 2 || strcmp(argv[1], "--verify-runtime-assets") != 0) {
|
||||
fprintf(stderr, "%s fixture accepts only the runtime-asset probe\n", product);
|
||||
return 64;
|
||||
}
|
||||
if (!adjacent_path(argv[0], "cbm-integrations.json", path, sizeof(path)) ||
|
||||
!file_contains(path, EXPECT_INTEGRATION)) {
|
||||
fprintf(stderr, "%s fixture rejected integration assets\n", product);
|
||||
return 65;
|
||||
}
|
||||
if (EXPECT_UI_NAME[0] != '\0' &&
|
||||
(!adjacent_path(argv[0], EXPECT_UI_NAME, path, sizeof(path)) ||
|
||||
!file_contains(path, EXPECT_UI_MARKER))) {
|
||||
fprintf(stderr, "%s fixture rejected UI assets\n", product);
|
||||
return 66;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def valid_ui_pack(marker: str) -> bytes:
|
||||
path = b"/index.html"
|
||||
payload = ("<title>" + marker + "</title>").encode("ascii")
|
||||
header = bytearray(80)
|
||||
header[:8] = b"CBMUIPK\0"
|
||||
struct.pack_into("<HHIII", header, 8, 1, 80, 0, 1, 24)
|
||||
paths_offset = 80 + 24
|
||||
payload_offset = paths_offset + len(path)
|
||||
total = payload_offset + len(payload)
|
||||
struct.pack_into(
|
||||
"<QQQQQQQ",
|
||||
header,
|
||||
24,
|
||||
80,
|
||||
24,
|
||||
paths_offset,
|
||||
len(path),
|
||||
payload_offset,
|
||||
len(payload),
|
||||
total,
|
||||
)
|
||||
entry = struct.pack("<IHBBQQ", 0, len(path), 1, 1, 0, len(payload))
|
||||
return bytes(header) + entry + path + payload
|
||||
|
||||
|
||||
host_system = platform.system().lower()
|
||||
host_is_windows = (
|
||||
os.name == "nt"
|
||||
or bool(os.environ.get("MSYSTEM"))
|
||||
or host_system.startswith(("windows", "msys", "mingw", "cygwin"))
|
||||
)
|
||||
host_goos = "windows" if host_is_windows else (
|
||||
"darwin" if host_system == "darwin" else "linux"
|
||||
)
|
||||
binary_name = "codebase-memory-mcp.exe" if host_goos == "windows" else "codebase-memory-mcp"
|
||||
archive_suffix = ".zip" if host_goos == "windows" else ".tar.gz"
|
||||
compiler = shlex.split(os.environ.get("CC", "cc"))
|
||||
arch_flags = shlex.split(os.environ.get("ARCHFLAGS", ""))
|
||||
integration_marker = '\"format\":1,\"product\":\"codebase-memory-mcp\"'
|
||||
pack_a = valid_ui_pack("fixture-pack-a")
|
||||
pack_b = valid_ui_pack("fixture-pack-b")
|
||||
pack_a_digest = hashlib.sha256(pack_a).hexdigest()
|
||||
pack_b_digest = hashlib.sha256(pack_b).hexdigest()
|
||||
pack_a_name = f"cbm-ui-{pack_a_digest}.pack"
|
||||
pack_b_name = f"cbm-ui-{pack_b_digest}.pack"
|
||||
|
||||
|
||||
def build_fixture(
|
||||
directory: pathlib.Path,
|
||||
expected_integration: str,
|
||||
expected_ui_name: str = "",
|
||||
expected_ui_marker: str = "",
|
||||
) -> None:
|
||||
source = fixture_source_template
|
||||
source = source.replace("__EXPECT_INTEGRATION__", json.dumps(expected_integration))
|
||||
source = source.replace("__EXPECT_UI_NAME__", json.dumps(expected_ui_name))
|
||||
source = source.replace("__EXPECT_UI_MARKER__", json.dumps(expected_ui_marker))
|
||||
source_path = directory / "runtime_asset_fixture.c"
|
||||
source_path.write_text(source, encoding="utf-8")
|
||||
command = compiler + arch_flags + ["-std=c11", "-O2", str(source_path)]
|
||||
if host_goos == "linux":
|
||||
command.append("-Wl,-z,noexecstack")
|
||||
command += ["-o", str(directory / binary_name)]
|
||||
compiled = subprocess.run(
|
||||
command,
|
||||
cwd=root,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
check=False,
|
||||
)
|
||||
require(
|
||||
compiled.returncode == 0,
|
||||
"could not compile native release-packaging fixture: " + compiled.stdout.strip(),
|
||||
)
|
||||
|
||||
|
||||
def package_fixture(
|
||||
work: pathlib.Path,
|
||||
case: str,
|
||||
variant: str,
|
||||
expected_integration: str,
|
||||
pack_name: str = "",
|
||||
pack_bytes: bytes = b"",
|
||||
expected_ui_name: str = "",
|
||||
expected_ui_marker: str = "",
|
||||
) -> tuple[subprocess.CompletedProcess[str], pathlib.Path]:
|
||||
build_dir = work / f"{case}-build"
|
||||
output_dir = work / f"{case}-output"
|
||||
build_dir.mkdir()
|
||||
output_dir.mkdir()
|
||||
build_fixture(build_dir, expected_integration, expected_ui_name, expected_ui_marker)
|
||||
if pack_name:
|
||||
(build_dir / pack_name).write_bytes(pack_bytes)
|
||||
environment = os.environ.copy()
|
||||
environment["BUILD_DIR"] = str(build_dir)
|
||||
archive_name = f"codebase-memory-mcp{'-ui' if variant == 'ui' else ''}-{host_goos}-fixture{archive_suffix}"
|
||||
packaged = subprocess.run(
|
||||
[
|
||||
str(bash_executable),
|
||||
str(root / "scripts/package-release.sh"),
|
||||
host_goos,
|
||||
"fixture",
|
||||
"--variant",
|
||||
variant,
|
||||
"--out-dir",
|
||||
str(output_dir),
|
||||
],
|
||||
cwd=root,
|
||||
env=environment,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
check=False,
|
||||
)
|
||||
return packaged, output_dir / archive_name
|
||||
|
||||
|
||||
if compiler:
|
||||
with tempfile.TemporaryDirectory(prefix="cbm-package-association-") as temp:
|
||||
work = pathlib.Path(temp)
|
||||
standard, standard_archive = package_fixture(
|
||||
work, "standard-ok", "standard", integration_marker
|
||||
)
|
||||
require(
|
||||
standard.returncode == 0 and standard_archive.is_file(),
|
||||
"release packager rejected a matching standard fixture: " + standard.stdout.strip(),
|
||||
)
|
||||
if standard_archive.is_file():
|
||||
if host_goos == "windows":
|
||||
with zipfile.ZipFile(standard_archive) as archive:
|
||||
standard_members = set(archive.namelist())
|
||||
else:
|
||||
with tarfile.open(standard_archive, "r:gz") as archive:
|
||||
standard_members = set(archive.getnames())
|
||||
require(
|
||||
standard_members
|
||||
== {
|
||||
binary_name,
|
||||
"cbm-integrations.json",
|
||||
"LICENSE",
|
||||
"install.ps1" if host_goos == "windows" else "install.sh",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
},
|
||||
"standard archive must contain exactly the five verified/staged members "
|
||||
f"(got {sorted(standard_members)!r})",
|
||||
)
|
||||
|
||||
ui, ui_archive = package_fixture(
|
||||
work,
|
||||
"ui-ok",
|
||||
"ui",
|
||||
integration_marker,
|
||||
pack_a_name,
|
||||
pack_a,
|
||||
pack_a_name,
|
||||
"fixture-pack-a",
|
||||
)
|
||||
require(
|
||||
ui.returncode == 0 and ui_archive.is_file(),
|
||||
"release packager rejected a matching UI fixture: " + ui.stdout.strip(),
|
||||
)
|
||||
if ui_archive.is_file():
|
||||
if host_goos == "windows":
|
||||
with zipfile.ZipFile(ui_archive) as archive:
|
||||
ui_members = set(archive.namelist())
|
||||
else:
|
||||
with tarfile.open(ui_archive, "r:gz") as archive:
|
||||
ui_members = set(archive.getnames())
|
||||
require(
|
||||
ui_members
|
||||
== {
|
||||
binary_name,
|
||||
"cbm-integrations.json",
|
||||
"LICENSE",
|
||||
"install.ps1" if host_goos == "windows" else "install.sh",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
pack_a_name,
|
||||
},
|
||||
"UI archive must contain exactly the five base members plus its verified pack "
|
||||
f"(got {sorted(ui_members)!r})",
|
||||
)
|
||||
|
||||
wrong_pack, wrong_pack_archive = package_fixture(
|
||||
work,
|
||||
"ui-wrong-pack",
|
||||
"ui",
|
||||
integration_marker,
|
||||
pack_b_name,
|
||||
pack_b,
|
||||
pack_a_name,
|
||||
"fixture-pack-a",
|
||||
)
|
||||
require(
|
||||
wrong_pack.returncode != 0
|
||||
and "staged binary rejected its adjacent runtime assets" in wrong_pack.stdout
|
||||
and not wrong_pack_archive.exists(),
|
||||
"release packager accepted a structurally valid, self-hashed UI pack for a different binary",
|
||||
)
|
||||
|
||||
wrong_manifest, wrong_manifest_archive = package_fixture(
|
||||
work,
|
||||
"standard-wrong-manifest",
|
||||
"standard",
|
||||
"manifest-association-that-is-not-in-the-repository-asset",
|
||||
)
|
||||
require(
|
||||
wrong_manifest.returncode != 0
|
||||
and "staged binary rejected its adjacent runtime assets" in wrong_manifest.stdout
|
||||
and not wrong_manifest_archive.exists(),
|
||||
"release packager accepted a valid integration manifest that did not match the binary",
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: package runtime-asset contract", file=sys.stderr)
|
||||
for failure in failures:
|
||||
print(f" - {failure}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
print("PASS: release and package-manager runtime assets stay bound to their executable")
|
||||
PY
|
||||
+16
-551
@@ -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[] = "<h1>CBM</h1>";
|
||||
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);
|
||||
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_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);
|
||||
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);
|
||||
|
||||
@@ -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' '<!doctype html><title>Codebase Memory — Graph</title>' > "$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(
|
||||
"<IHBBQQ", data, cursor
|
||||
)
|
||||
cursor += 24
|
||||
path = data[
|
||||
paths_offset + path_offset:paths_offset + path_offset + path_len
|
||||
].decode("ascii")
|
||||
entries.append((path, path_offset, path_len, mime_id, cache_id, offset, length))
|
||||
|
||||
assert cursor == paths_offset
|
||||
assert [entry[0] for entry in entries] == [
|
||||
"/assets/app.css", "/assets/app.js", "/index.html"
|
||||
]
|
||||
assert [entry[3] for entry in entries] == [3, 2, 1]
|
||||
assert [entry[4] for entry in entries] == [2, 2, 1]
|
||||
|
||||
path_cursor = 0
|
||||
for path, path_offset, path_len, *_rest in entries:
|
||||
assert path_offset == path_cursor, f"non-contiguous path table for {path}"
|
||||
path_cursor += path_len
|
||||
assert path_cursor == paths_size
|
||||
|
||||
payload_cursor = 0
|
||||
for path, _path_offset, _path_len, _mime, _cache, offset, length in entries:
|
||||
assert offset == payload_cursor, f"non-contiguous payload for {path}"
|
||||
payload_cursor += length
|
||||
assert payload_cursor == payload_size
|
||||
|
||||
# The bytes remain visible to scanners and SBOM/license tooling. Compression
|
||||
# or encoding would make this assertion fail.
|
||||
assert b"transparent-ui-pack" in data
|
||||
assert b"<!doctype html>" 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"
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# INVERTED release-surface contract: Windows ships ONE executable plus its
|
||||
# authenticated runtime assets.
|
||||
# INVERTED release-surface contract: Windows ships ONE binary.
|
||||
#
|
||||
# History this guards against. The Windows release used to be a PAIR — a small
|
||||
# permanent launcher (codebase-memory-mcp.exe) plus the real product binary
|
||||
@@ -8,15 +7,18 @@
|
||||
# reason: a running .exe cannot replace its own image on Windows, so an
|
||||
# in-process self-update needs a second resident binary to do the swap.
|
||||
#
|
||||
# That stub was a small unsigned PE whose whole job was verify-and-execute
|
||||
# another binary: unnecessary loader-like behavior and a second artifact to
|
||||
# audit. Historical variants received Microsoft Wacatac verdicts, but those
|
||||
# observations did not expose a stable feature or prove classifier causation.
|
||||
# That stub is statically indistinguishable from a dropper — a small unsigned
|
||||
# PE whose whole job is verify-and-execute another binary — and Defender's ML
|
||||
# scored it Trojan:Win32/Wacatac.B!ml on x64 regardless of what we changed
|
||||
# (bcrypt-free, stripped, VERSIONINFO'd and even resource-free builds were all
|
||||
# flagged), while the product binary itself scans clean on every platform.
|
||||
#
|
||||
# The fix removed the stub. The downloaded candidate now owns the complete
|
||||
# runtime-set transaction under the native activation guard; install.ps1 only
|
||||
# verifies/extracts and invokes it. In particular, the script must never retire
|
||||
# the old executable or publish sidecars ahead of the guarded transaction.
|
||||
# The fix removed the stub and moved self-update OUT of the process into
|
||||
# install.ps1, which runs while CBM is NOT running. So this file asserts the
|
||||
# ABSENCE of the flagged design, plus the one step that replaces it:
|
||||
# install.ps1 must RETIRE the running binary (rename it out of the way) before
|
||||
# publishing the new one — losing that step silently breaks every Windows
|
||||
# update.
|
||||
#
|
||||
# The non-launcher release/security assertions from the previous contract
|
||||
# (VM-driver hardening, archive allowlists, HTTPS-only downloads, profile-rooted
|
||||
@@ -73,32 +75,22 @@ def yaml_run_blocks(text: str) -> 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<members>.*?)\s*\)", package_release, re.DOTALL
|
||||
zip_call = re.search(
|
||||
r"zip -q \"\$OUT_DIR/\$NAME\.zip\" \\\n(?P<members>(?:.|\n)*?)\n", package_release
|
||||
)
|
||||
normalized_member_blocks = [block.split() for block in member_blocks]
|
||||
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(
|
||||
list(windows_archive_names) in normalized_member_blocks,
|
||||
"package-release.sh must define the exact five-member Windows standard archive",
|
||||
)
|
||||
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,30 @@ 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.
|
||||
# Each archive variant is still produced through the ONE canonical packaging
|
||||
# entry, so the four-file layout above governs all of them.
|
||||
build_workflow = read(".github/workflows/_build.yml")
|
||||
for archive, call in (
|
||||
for archive, call, ui in (
|
||||
("codebase-memory-mcp-windows-amd64.zip", "scripts/package-release.sh windows amd64", False),
|
||||
("codebase-memory-mcp-ui-windows-amd64.zip",
|
||||
"scripts/package-release.sh windows amd64 --variant ui"),
|
||||
"scripts/package-release.sh windows amd64 --variant ui", True),
|
||||
("codebase-memory-mcp-windows-arm64.zip", "scripts/package-release.sh windows arm64", False),
|
||||
("codebase-memory-mcp-ui-windows-arm64.zip",
|
||||
"scripts/package-release.sh windows arm64 --variant ui"),
|
||||
"scripts/package-release.sh windows arm64 --variant ui", True),
|
||||
):
|
||||
# The lookahead keeps a ui call from satisfying a standard check.
|
||||
pattern = re.escape(call) + ("" if ui else r"(?!\s+--variant)")
|
||||
require(
|
||||
build_workflow.count(call) == 1,
|
||||
re.search(pattern, 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
|
||||
# "<dest>.retired-<timestamp>", 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 +177,43 @@ require(
|
||||
+ ", ".join(f"U+{ord(ch):04X}" for ch in non_ascii)
|
||||
+ ")",
|
||||
)
|
||||
require(
|
||||
"$Dest = Join-Path $InstallDir $BinName" in installer,
|
||||
"install.ps1 must resolve the canonical install destination as $Dest",
|
||||
)
|
||||
retire_match = re.search(
|
||||
r"if \(Test-Path -LiteralPath \$Dest -PathType Leaf\) \{(?P<body>(?:.|\n)*?)\n\}\n",
|
||||
installer,
|
||||
)
|
||||
retire = retire_match.group("body") if retire_match else ""
|
||||
require(
|
||||
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",
|
||||
)
|
||||
require(
|
||||
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(
|
||||
publish_index >= 0,
|
||||
"install.ps1 must invoke the downloaded candidate's native install command",
|
||||
publish_index > (retire_match.start() if retire_match else -1) >= 0,
|
||||
"install.ps1 must retire BEFORE it publishes the downloaded binary",
|
||||
)
|
||||
require(
|
||||
"& $DownloadedBinary --version" in installer
|
||||
and "& $DownloadedBinary @InstallArgs" in installer,
|
||||
"install.ps1 must verify and install through the downloaded binary",
|
||||
)
|
||||
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",
|
||||
)
|
||||
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<body>.*?)\n\}\s*\n\s*# Detect variant",
|
||||
installer,
|
||||
re.DOTALL,
|
||||
)
|
||||
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",
|
||||
)
|
||||
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",
|
||||
)
|
||||
|
||||
# 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",
|
||||
)
|
||||
|
||||
# ── 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['\"]",
|
||||
@@ -259,8 +221,7 @@ 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\(",
|
||||
r"def\s+_windows_binary_ready\(",
|
||||
),
|
||||
}
|
||||
for relative, patterns in single_binary_contracts.items():
|
||||
@@ -274,21 +235,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 +254,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 +265,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 +550,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 +601,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 +638,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 +646,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 +676,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 +804,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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""GREEN regression guard — non-ASCII runtime and repo paths work on Windows.
|
||||
"""GREEN regression guard — non-ASCII repo paths keep all definitions on Windows.
|
||||
|
||||
Guards the fix for issue #636 / #357 (landed on main via #700) at the product
|
||||
surface (real codebase-memory-mcp process, real SQLite DB, real stdio). Two
|
||||
@@ -17,8 +17,8 @@ code page, so a non-ASCII path could not be opened and the parser received
|
||||
nothing. #700 routed the per-pass reads through cbm_fopen (→ _wfopen with a wide
|
||||
path, src/foundation/compat_fs.c), so non-ASCII paths now parse identically.
|
||||
|
||||
This Windows guard also exercises native runtime publication beyond legacy
|
||||
MAX_PATH; it fails (red) if either path contract regresses.
|
||||
This guard fails (red) if that fix regresses. It also passes on Linux/macOS
|
||||
(byte-transparent UTF-8 filesystem).
|
||||
|
||||
Exit code: 0 == invariant holds (green), 1 == invariant violated (regression),
|
||||
2 == environment/setup error.
|
||||
@@ -29,7 +29,6 @@ Usage:
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
@@ -63,204 +62,6 @@ NON_ASCII_SEGMENTS = {
|
||||
}
|
||||
|
||||
|
||||
def windows_extended_path(path):
|
||||
"""Return a Python/Win32 path that is independent of LongPathsEnabled."""
|
||||
absolute = os.path.abspath(path).replace("/", "\\")
|
||||
if absolute.startswith("\\\\?\\"):
|
||||
return absolute
|
||||
if absolute.startswith("\\\\"):
|
||||
return "\\\\?\\UNC\\" + absolute[2:]
|
||||
return "\\\\?\\" + absolute
|
||||
|
||||
|
||||
def run_product(argv, cwd, env, timeout=120):
|
||||
options = {
|
||||
"cwd": cwd,
|
||||
"env": env,
|
||||
"stdout": subprocess.PIPE,
|
||||
"stderr": subprocess.STDOUT,
|
||||
"text": True,
|
||||
"timeout": timeout,
|
||||
}
|
||||
# With lpApplicationName=NULL, CreateProcess applies the legacy MAX_PATH
|
||||
# limit while deriving the module name from the command line. Python's
|
||||
# explicit `executable` argument fills lpApplicationName, allowing the
|
||||
# extended path to reach the product exactly as a native launcher would.
|
||||
if os.name == "nt" and argv and argv[0].startswith("\\\\?\\"):
|
||||
options["executable"] = argv[0]
|
||||
return subprocess.run(argv, **options)
|
||||
|
||||
|
||||
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
|
||||
existence checks: after a secure first install, the target is rewritten in
|
||||
place and a non-force `--no` install must preserve those exact bytes.
|
||||
"""
|
||||
source_dir = os.path.dirname(binary)
|
||||
runtime_dir = os.path.join(work, "café_日本語_runtime")
|
||||
os.makedirs(runtime_dir, exist_ok=True)
|
||||
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)
|
||||
env["HOME"] = os.path.join(work, "runtime_home")
|
||||
env["USERPROFILE"] = env["HOME"]
|
||||
env["APPDATA"] = os.path.join(work, "runtime_appdata")
|
||||
env["LOCALAPPDATA"] = os.path.join(work, "runtime_localappdata")
|
||||
env["XDG_CONFIG_HOME"] = os.path.join(work, "runtime_xdg_config")
|
||||
env["XDG_CACHE_HOME"] = os.path.join(work, "runtime_xdg_cache")
|
||||
env["XDG_DATA_HOME"] = os.path.join(work, "runtime_xdg_data")
|
||||
env["CBM_CACHE_DIR"] = os.path.join(work, "runtime_cache")
|
||||
for inherited_config in (
|
||||
"CLAUDE_CONFIG_DIR", "CODEX_HOME", "COPILOT_HOME",
|
||||
"CRUSH_GLOBAL_CONFIG", "OPENCLAW_CONFIG_PATH", "OPENCLAW_HOME",
|
||||
"OPENCLAW_PROFILE", "OPENCLAW_STATE_DIR", "OPENCLAW_WORKSPACE_DIR",
|
||||
"OPENCODE_CONFIG", "OPENCODE_CONFIG_DIR", "VIBE_HOME"):
|
||||
env.pop(inherited_config, None)
|
||||
for directory in (
|
||||
env["HOME"], env["APPDATA"], env["LOCALAPPDATA"],
|
||||
env["XDG_CONFIG_HOME"], env["XDG_CACHE_HOME"],
|
||||
env["XDG_DATA_HOME"], env["CBM_CACHE_DIR"]):
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
def stop_runtime_daemon():
|
||||
try:
|
||||
first = run_product(
|
||||
[relocated, "daemon", "stop"], runtime_dir, env, timeout=30)
|
||||
second = run_product(
|
||||
[relocated, "daemon", "stop"], runtime_dir, env, timeout=30)
|
||||
return first.returncode == 0 and second.returncode == 0
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
def fail(message):
|
||||
stop_runtime_daemon()
|
||||
return message
|
||||
|
||||
probe = run_product([relocated, "--verify-runtime-assets"], runtime_dir, env)
|
||||
if probe.returncode != 0:
|
||||
return fail("runtime probe rc=%d output=%r packs=%r" % (
|
||||
probe.returncode, probe.stdout[-800:], packs))
|
||||
|
||||
# Keep every component below the per-component limit while making the
|
||||
# complete UTF-16 path decisively longer than legacy MAX_PATH. The product,
|
||||
# not Python, creates this tree during the first real install.
|
||||
target = os.path.join(
|
||||
work,
|
||||
"install_Ωμέγα",
|
||||
*("segment_%02d_%s" % (i, "x" * 32) for i in range(7)),
|
||||
"bin",
|
||||
)
|
||||
installed = os.path.join(target, "codebase-memory-mcp.exe")
|
||||
if len(os.path.abspath(installed)) <= 260:
|
||||
return fail("setup: intended long install path is only %d characters" % len(
|
||||
os.path.abspath(installed)))
|
||||
|
||||
first_install = run_product(
|
||||
[relocated, "install", "--force", "--skip-config", "--yes",
|
||||
"--dir=" + target],
|
||||
runtime_dir,
|
||||
env,
|
||||
)
|
||||
if first_install.returncode != 0:
|
||||
return fail("long-path install rc=%d output=%r path_len=%d" % (
|
||||
first_install.returncode,
|
||||
first_install.stdout[-1200:],
|
||||
len(os.path.abspath(installed)),
|
||||
))
|
||||
|
||||
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)
|
||||
if installed_probe.returncode != 0:
|
||||
return fail("installed long-path probe rc=%d output=%r" % (
|
||||
installed_probe.returncode, installed_probe.stdout[-1200:]))
|
||||
|
||||
# Preserve the file identity/owner/DACL created by the native transaction,
|
||||
# but make the bytes visibly foreign. A UTF-8-aware existence check sees
|
||||
# this target and honors --no; the legacy narrow stat() treated it as absent
|
||||
# and silently replaced it.
|
||||
sentinel = b"cbm-long-path-existing-target-must-be-preserved\n"
|
||||
with open(installed_extended, "wb") as stream:
|
||||
stream.write(sentinel)
|
||||
keep = run_product(
|
||||
[relocated, "install", "--skip-config", "--no", "--dir=" + target],
|
||||
runtime_dir,
|
||||
env,
|
||||
)
|
||||
if keep.returncode != 0:
|
||||
return fail("long-path non-force install rc=%d output=%r" % (
|
||||
keep.returncode, keep.stdout[-1200:]))
|
||||
with open(installed_extended, "rb") as stream:
|
||||
retained = stream.read()
|
||||
if retained != sentinel:
|
||||
return fail("long-path existing target was overwritten despite --no "
|
||||
"(target existence probe is not extended-path safe)")
|
||||
|
||||
restore = run_product(
|
||||
[relocated, "install", "--force", "--skip-config", "--yes",
|
||||
"--dir=" + target],
|
||||
runtime_dir,
|
||||
env,
|
||||
)
|
||||
if restore.returncode != 0:
|
||||
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)
|
||||
if restored_probe.returncode != 0:
|
||||
return fail("restored long-path probe rc=%d output=%r" % (
|
||||
restored_probe.returncode, restored_probe.stdout[-1200:]))
|
||||
if not stop_runtime_daemon():
|
||||
return fail("could not retire the isolated daemon before uninstall")
|
||||
|
||||
uninstall = run_product(
|
||||
[installed_extended, "uninstall", "--yes", "--dir=" + target],
|
||||
runtime_dir,
|
||||
env,
|
||||
)
|
||||
if uninstall.returncode != 0:
|
||||
return fail("long-path uninstall rc=%d output=%r" % (
|
||||
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
|
||||
|
||||
|
||||
def make_fixture(root):
|
||||
src = os.path.join(root, "src")
|
||||
os.makedirs(src, exist_ok=True)
|
||||
@@ -318,16 +119,7 @@ def main():
|
||||
|
||||
work = tempfile.mkdtemp(prefix="cbm_win_nonascii_")
|
||||
failures = []
|
||||
runtime_error = None
|
||||
try:
|
||||
runtime_error = verify_relocated_runtime(binary, work)
|
||||
if runtime_error:
|
||||
print("[FAIL] non-ASCII runtime relocation: %s" % runtime_error)
|
||||
print("\nREGRESSION (red): Unicode/extended-length runtime publication "
|
||||
"or exact adjacent-asset resolution failed.")
|
||||
return 1
|
||||
print("[PASS] Unicode relocation + extended-length install/probe/uninstall")
|
||||
|
||||
ascii_repo = os.path.join(work, "ascii_repo")
|
||||
make_fixture(ascii_repo)
|
||||
base = index_and_count(binary, ascii_repo, os.path.join(work, "c_ascii"))
|
||||
@@ -360,15 +152,14 @@ def main():
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
if failures:
|
||||
print("\nREGRESSION (red): %d/%d non-ASCII repo path variants lost "
|
||||
print("\nREGRESSION (red): %d/%d non-ASCII path variants lost "
|
||||
"definitions: %s" %
|
||||
(len(failures), len(NON_ASCII_SEGMENTS), ", ".join(failures)))
|
||||
print("Invariant violated: byte-identical fixtures under non-ASCII paths "
|
||||
"must extract the same definitions as the ASCII baseline (fixed by "
|
||||
"#700 — has the cbm_fopen routing in the pass readers regressed?).")
|
||||
return 1
|
||||
print("\nGREEN: Unicode/extended-length runtime operations passed and all "
|
||||
"non-ASCII repo variants matched the ASCII baseline.")
|
||||
print("\nGREEN: all non-ASCII path variants matched the ASCII baseline.")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user