Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,18 @@ Add-Type -AssemblyName System.Net.Http
$Repo = "DeusData/codebase-memory-mcp"
$InstallDir = "$env:LOCALAPPDATA\Programs\codebase-memory-mcp"
$BinName = "codebase-memory-mcp.exe"
$WindowsArchiveNames = @(
# Core members shipped since the single-binary Windows layout. The integration
# manifest was added later (externalized runtime assets); published v0.9.0 zips
# still omit it, so validation accepts either the legacy 4-file core set or the
# current 5-file set. New releases from package-release.sh always ship both.
$WindowsCoreArchiveNames = @(
$BinName,
"cbm-integrations.json",
"LICENSE",
"install.ps1",
"THIRD_PARTY_NOTICES.md"
)
$WindowsIntegrationArchiveName = "cbm-integrations.json"
$WindowsArchiveNames = $WindowsCoreArchiveNames + @($WindowsIntegrationArchiveName)
$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" }

Expand Down Expand Up @@ -232,8 +237,10 @@ 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.
# must never be allowed to overwrite each other. Core members are always
# required; cbm-integrations.json is required when present and accepted as
# absent for legacy published archives (v0.9.0). UI adds exactly one
# hash-shaped pack on top of either layout.
try {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead("$TmpDir\$Archive")
Expand Down Expand Up @@ -272,17 +279,22 @@ try {
throw "archive contains an unexpected root entry: $($entry.FullName)"
}
}
foreach ($archiveName in $WindowsArchiveNames) {
foreach ($archiveName in $WindowsCoreArchiveNames) {
if ($archiveCounts[$archiveName] -ne 1) {
throw "archive must contain exactly one $archiveName"
}
}
$integrationCount = $archiveCounts[$WindowsIntegrationArchiveName]
if ($integrationCount -ne 0 -and $integrationCount -ne 1) {
throw "archive must contain exactly one $WindowsIntegrationArchiveName"
}
$expectedUiPackCount = if ($Variant -eq "ui") { 1 } else { 0 }
$expectedArchiveCount = $WindowsArchiveNames.Count + $expectedUiPackCount
$expectedArchiveCount = $WindowsCoreArchiveNames.Count + $integrationCount + $expectedUiPackCount
if ($uiPackCount -ne $expectedUiPackCount -or
$seen.Count -ne $expectedArchiveCount) {
throw "archive does not match the exact $Variant Windows release allowlist"
}
$script:WindowsHasIntegrationManifest = ($integrationCount -eq 1)
} finally {
$zip.Dispose()
}
Expand All @@ -298,7 +310,11 @@ try {
Write-Host "Extracting..."
Expand-Archive -Path "$TmpDir\$Archive" -DestinationPath $TmpDir -Force

foreach ($archiveName in $WindowsArchiveNames) {
$RequiredExtractedMembers = @($WindowsCoreArchiveNames)
if ($WindowsHasIntegrationManifest) {
$RequiredExtractedMembers += $WindowsIntegrationArchiveName
}
foreach ($archiveName in $RequiredExtractedMembers) {
$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
Expand Down
38 changes: 29 additions & 9 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,10 @@ 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.
# ship either the current five-file set (with cbm-integrations.json) or the
# legacy four-file set still published as v0.9.0. UI releases add exactly one
# root-level, content-addressed pack on top of either layout. 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"
Expand Down Expand Up @@ -284,13 +285,24 @@ while IFS= read -r member || [ -n "$member" ]; do
esac
done < "$ARCHIVE_MEMBERS_FILE"

EXPECTED_MEMBER_COUNT=5
# Current releases: 5 members (+1 UI pack). Legacy published archives (v0.9.0)
# omit cbm-integrations.json and therefore have 4 core members.
if [ "$INTEGRATION_MEMBERS" -eq 1 ]; then
EXPECTED_CORE_COUNT=5
elif [ "$INTEGRATION_MEMBERS" -eq 0 ]; then
EXPECTED_CORE_COUNT=4
else
echo "error: release archive does not match the exact $VARIANT member set" >&2
exit 1
fi
EXPECTED_MEMBER_COUNT=$EXPECTED_CORE_COUNT
if [ "$VARIANT" = "ui" ]; then
EXPECTED_MEMBER_COUNT=6
EXPECTED_MEMBER_COUNT=$((EXPECTED_CORE_COUNT + 1))
fi
if [ "$BINARY_MEMBERS" -ne 1 ] || [ "$INTEGRATION_MEMBERS" -ne 1 ] ||
if [ "$BINARY_MEMBERS" -ne 1 ] ||
[ "$LICENSE_MEMBERS" -ne 1 ] || [ "$INSTALLER_MEMBERS" -ne 1 ] ||
[ "$NOTICE_MEMBERS" -ne 1 ] || [ "$UI_PACK_MEMBERS" -ne $((EXPECTED_MEMBER_COUNT - 5)) ] ||
[ "$NOTICE_MEMBERS" -ne 1 ] ||
[ "$UI_PACK_MEMBERS" -ne $((EXPECTED_MEMBER_COUNT - EXPECTED_CORE_COUNT)) ] ||
[ "$ARCHIVE_MEMBER_COUNT" -ne "$EXPECTED_MEMBER_COUNT" ]; then
echo "error: release archive does not match the exact $VARIANT member set" >&2
exit 1
Expand All @@ -304,8 +316,16 @@ 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
REQUIRED_EXTRACTED_MEMBERS=(
"$ARCHIVE_BINARY"
LICENSE
"$ARCHIVE_INSTALLER"
THIRD_PARTY_NOTICES.md
)
if [ "$INTEGRATION_MEMBERS" -eq 1 ]; then
REQUIRED_EXTRACTED_MEMBERS+=(cbm-integrations.json)
fi
for extracted_member in "${REQUIRED_EXTRACTED_MEMBERS[@]}"; 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
Expand Down
189 changes: 189 additions & 0 deletions tests/test_install_archive_layout_compat.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# Regression for #1499: published v0.9.0 Windows/Unix archives omit
# cbm-integrations.json, while main installers initially required it. Install
# scripts must accept both the legacy four-file core layout and the current
# five-file layout (core + cbm-integrations.json). package-release.sh still
# ships the five-file set for new builds.
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

python3 - "$ROOT" <<'PY'
from __future__ import annotations
import pathlib
import re
import sys
import tempfile
import zipfile
root = pathlib.Path(sys.argv[1])
failures: list[str] = []
def require(condition: bool, message: str) -> None:
if not condition:
failures.append(message)
install_ps1 = (root / "install.ps1").read_text(encoding="utf-8")
install_sh = (root / "install.sh").read_text(encoding="utf-8")
package_release = (root / "scripts/package-release.sh").read_text(encoding="utf-8")
require(
"WindowsCoreArchiveNames" in install_ps1
and "WindowsIntegrationArchiveName" in install_ps1
and "WindowsHasIntegrationManifest" in install_ps1,
"install.ps1 must split core members from the optional integration manifest",
)
require(
"EXPECTED_CORE_COUNT=4" in install_sh and "EXPECTED_CORE_COUNT=5" in install_sh,
"install.sh must accept both the legacy 4-file and current 5-file layouts",
)
require(
"INTEGRATION_MEMBERS\" -eq 0" in install_sh
or '[ "$INTEGRATION_MEMBERS" -eq 0 ]' in install_sh,
"install.sh must treat a missing cbm-integrations.json as the legacy layout",
)
require(
"cbm-integrations.json LICENSE install.ps1" in package_release
and "cbm-integrations.json LICENSE install.sh" in package_release,
"package-release.sh must keep shipping cbm-integrations.json in new archives",
)
# Extract the PowerShell validation core into a tiny harness that mirrors the
# allowlist logic without downloading anything.
core = [
"codebase-memory-mcp.exe",
"LICENSE",
"install.ps1",
"THIRD_PARTY_NOTICES.md",
]
integration = "cbm-integrations.json"
ui_pack = "cbm-ui-" + ("a" * 64) + ".pack"
def validate(names: list[str], variant: str = "standard") -> str | None:
"""Return None on success, else the error string."""
archive_names = core + [integration]
counts = {name: 0 for name in archive_names}
seen: set[str] = set()
ui_pack_count = 0
ui_pattern = re.compile(r"^cbm-ui-[0-9a-f]{64}\.pack$")
for entry_name in names:
if entry_name in seen:
return f"duplicate or case-conflicting zip entry: {entry_name}"
seen.add(entry_name)
if entry_name in counts:
counts[entry_name] += 1
elif variant == "ui" and ui_pattern.match(entry_name):
ui_pack_count += 1
else:
return f"archive contains an unexpected root entry: {entry_name}"
for name in core:
if counts[name] != 1:
return f"archive must contain exactly one {name}"
integration_count = counts[integration]
if integration_count not in (0, 1):
return f"archive must contain exactly one {integration}"
expected_ui = 1 if variant == "ui" else 0
expected = len(core) + integration_count + expected_ui
if ui_pack_count != expected_ui or len(seen) != expected:
return f"archive does not match the exact {variant} Windows release allowlist"
return None
legacy = list(core)
current = list(core) + [integration]
legacy_ui = list(core) + [ui_pack]
current_ui = list(core) + [integration, ui_pack]
require(validate(legacy) is None, f"legacy 4-file layout must pass: {validate(legacy)}")
require(validate(current) is None, f"current 5-file layout must pass: {validate(current)}")
require(
validate(legacy_ui, "ui") is None,
f"legacy UI layout must pass: {validate(legacy_ui, 'ui')}",
)
require(
validate(current_ui, "ui") is None,
f"current UI layout must pass: {validate(current_ui, 'ui')}",
)
require(
validate(core[:3]) is not None,
"incomplete core layout must still fail",
)
require(
validate(current + ["extra.txt"]) is not None,
"unexpected extra root entry must still fail",
)
require(
validate(core + [integration, integration]) is not None,
"duplicate integration manifest must still fail",
)
# install.sh acceptance matrix for INTEGRATION_MEMBERS
sh_cases = [
# binary, integration, license, installer, notice, ui, variant, expect_ok
(1, 1, 1, 1, 1, 0, "standard", True),
(1, 0, 1, 1, 1, 0, "standard", True),
(1, 1, 1, 1, 1, 1, "ui", True),
(1, 0, 1, 1, 1, 1, "ui", True),
(1, 2, 1, 1, 1, 0, "standard", False),
(1, 1, 1, 1, 0, 0, "standard", False),
(1, 0, 1, 1, 1, 0, "ui", False), # ui without pack
]
def sh_ok(
binary: int,
integration: int,
license_n: int,
installer: int,
notice: int,
ui: int,
variant: str,
) -> bool:
if integration == 1:
expected_core = 5
elif integration == 0:
expected_core = 4
else:
return False
expected_member = expected_core + (1 if variant == "ui" else 0)
archive_member = binary + integration + license_n + installer + notice + ui
return (
binary == 1
and license_n == 1
and installer == 1
and notice == 1
and ui == (expected_member - expected_core)
and archive_member == expected_member
)
for case in sh_cases:
*counts, variant, expect = case
got = sh_ok(*counts, variant)
require(
got is expect,
f"install.sh matrix {case} expected {expect}, got {got}",
)
# Prove a real zip with the legacy layout enumerates the way install.ps1 expects.
with tempfile.TemporaryDirectory() as tmp:
path = pathlib.Path(tmp) / "legacy.zip"
with zipfile.ZipFile(path, "w") as zf:
for name in legacy:
zf.writestr(name, b"x")
names = zipfile.ZipFile(path).namelist()
require(validate(names) is None, f"zip legacy layout failed: {validate(names)}")
if failures:
print("test_install_archive_layout_compat FAILED:")
for failure in failures:
print(f" - {failure}")
sys.exit(1)
print("test_install_archive_layout_compat: ok")
PY
13 changes: 8 additions & 5 deletions tests/test_smoke_fixture_contract.sh
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,9 @@ require(
"fixture checksums must name exact artifact basenames, never ./-prefixed paths",
)

# Native Windows packages and serves the exact five-file standard bundle or
# six-file UI bundle (one native binary), then runs the full smoke from a
# Native Windows packages and serves the current five-file standard bundle
# (or legacy four-file / matching UI +1 layouts) with one native binary, then
# runs the full smoke from a
# protected profile-rooted directory/cache.
for name in (
"codebase-memory-mcp.exe",
Expand Down Expand Up @@ -380,13 +381,15 @@ require(
needle in unix_installer
for needle in (
"ARCHIVE_MEMBER_COUNT",
"EXPECTED_MEMBER_COUNT=5",
"EXPECTED_MEMBER_COUNT=6",
"EXPECTED_CORE_COUNT=4",
"EXPECTED_CORE_COUNT=5",
"EXPECTED_MEMBER_COUNT=$EXPECTED_CORE_COUNT",
"^cbm-ui-[0-9a-f]{64}\\.pack$",
"release archive contains unexpected member",
)
),
"install.sh must validate the exact five-member standard or six-member UI archive",
"install.sh must validate legacy 4-file and current 5-file standard archives "
"(plus optional UI pack on either layout)",
)
unix_binary_publish = unix_installer.find('"$DLBIN" install "${INSTALL_ARGS[@]}"')
require(
Expand Down
5 changes: 4 additions & 1 deletion tests/test_windows_bundle_contract.sh
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,8 @@ for relative, patterns in single_binary_contracts.items():

# 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.
# UI by the single hash-shaped pack. install.ps1 also accepts the legacy
# four-file core set (no cbm-integrations.json) still published as v0.9.0.
exact_archive_guards = {
"install.ps1": (
"$seen.Count -ne $expectedArchiveCount",
Expand All @@ -288,6 +289,8 @@ exact_archive_guards = {
'"LICENSE"',
'"install.ps1"',
"THIRD_PARTY_NOTICES.md",
"WindowsCoreArchiveNames",
"WindowsIntegrationArchiveName",
),
"pkg/npm/install.js": (
"seen.size !== expectedCount",
Expand Down
Loading