Break-glass merge PR #2664

This commit is contained in:
Martin Vogel authored and GitHub committed 2026-10-16 18:16:17 +02:00
commit 5966b7f081
20 files changed
+1158 -944

No files matched your search

+3
View File
@@ -118,6 +118,7 @@ jobs:
env:
CBM_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/cbm-artifact
SMOKE_REQUIRE_UI: "1"
SMOKE_REQUIRE_WRAPPERS: "1"
- name: Security audits
run: |
@@ -224,6 +225,7 @@ jobs:
env:
SMOKE_ARCH: ${{ matrix.arch }}
SMOKE_REQUIRE_UI: "1"
SMOKE_REQUIRE_WRAPPERS: "1"
run: |
export CBM_SMOKE_ARTIFACT_DIR="$(cygpath -u "$RUNNER_TEMP")/cbm-artifact"
bash test-infrastructure/vm/vm-smoke.sh
@@ -301,6 +303,7 @@ jobs:
env:
CBM_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/cbm-artifact
SMOKE_REQUIRE_UI: "1"
SMOKE_REQUIRE_WRAPPERS: "1"
# The -portable binary is what all linux install/update paths now deliver;
# it MUST start on old glibc (Debian 11 / RHEL 8 / Ubuntu 20.04). Runs it
+3
View File
@@ -232,6 +232,7 @@ jobs:
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 1000M
SMOKE_REQUIRE_WRAPPERS: "1"
- name: Build prod + smoke (macOS)
if: matrix.os == 'macos-14'
@@ -242,6 +243,7 @@ jobs:
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 1000M
SMOKE_REQUIRE_WRAPPERS: "1"
- name: Build prod + smoke (Windows)
if: matrix.os == 'windows-latest'
@@ -252,6 +254,7 @@ jobs:
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 1000M
SMOKE_REQUIRE_WRAPPERS: "1"
# ── Waste sanitizer, REPORT-ONLY: memory + CPU waste on this repository and
# the per-function scaling lane (Linux). Deliberately NOT in ci-ok's needs:
+12
View File
@@ -283,9 +283,15 @@ jobs:
# move the release tag. (A floating HEAD also broke the 0.8.0 release:
# HEAD had become a commit touching .github/workflows/, and the App
# token may not create refs pointing at workflow-modifying commits.)
# The Go wrapper is a nested module: `go install
# .../pkg/go/cmd/codebase-memory-mcp@vX.Y.Z` and @latest resolve its
# versions from pkg/go/v* tags only (without one, Go fetches the whole
# repository, which is over its 500 MB module limit).
run: |
git tag -f "$VERSION" "$GITHUB_SHA"
git push origin "$VERSION" --force
git tag -f "pkg/go/$VERSION" "$GITHUB_SHA"
git push origin "pkg/go/$VERSION" --force
- uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
@@ -510,6 +516,12 @@ jobs:
else
npm publish --access public --provenance
fi
# `next` is "the newest you can opt into": once a stable release
# exists, an older RC must not stay behind it (0.9.1-rc.1 did until
# v0.12.1). Re-runnable: dist-tag add is idempotent.
if [[ "$RELEASE_VERSION" != *-* ]]; then
npm dist-tag add "codebase-memory-mcp@$V" next
fi
# ── PyPI ─────────────────────────────────────────────────
- name: Setup Python
+2 -2
View File
@@ -63,7 +63,7 @@ These captures show CBM Lens Beta on 12–13 October 2026, using public source f
## Why codebase-memory-mcp
- **Indexing performance**: Linux kernel (28M LOC, 75K files) in 3 minutes. RAM-first pipeline: LZ4 compression, in-memory SQLite, fused Aho-Corasick pattern matching. Memory released after indexing.
- **Plug and play**: native executable plus authenticated release-owned assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (amd64). The native install needs no Docker, language runtime, or API keys. Download → `install` → restart agent → done.
- **Plug and play**: native executable plus authenticated release-owned assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (arm64/amd64). The native install needs no Docker, language runtime, or API keys. Download → `install` → restart agent → done.
- **162 languages**: vendored tree-sitter grammars compiled into the binary. No separate parser installation.
- **About 120× fewer tokens in the reported five-query comparison**: ~3,400 tokens vs ~412,000 via file-by-file search. See [Performance](#performance) for the scope and sources.
- **45 supported automatic/conditional client surfaces**: `install` configures detected clients and safely activates conditional clients only when their documented platform, marker, or explicit existing config path is present. See [Multi-Agent Support](#multi-agent-support) for the complete matrix and manual/UI-only boundaries.
@@ -360,7 +360,7 @@ The install script placed beside the binary is **reported, not deleted**: uninst
- **Auto-sync**: Background watcher detects file changes in the project an MCP session is working in and re-indexes it automatically (other indexed repos: re-run `index_repository`)
- **Route nodes**: REST endpoints are first-class graph entities
- **CLI mode**: `codebase-memory-mcp cli search_graph --project my-project --name-pattern ".*Handler.*"`
- **Available on**: npm, PyPI, Homebrew, Scoop, Winget, Chocolatey, AUR, `go install`
- **Available on**: npm, PyPI, Homebrew, Scoop, AUR, `go install`
### Indexing modes
+2 -2
View File
@@ -540,7 +540,7 @@
Warp MCP, JetBrains AI/ACP, GitHub Copilot coding agent, Jules, CodeRabbit, Replit, BLACKBOX AI,
Plandex, and SWE-agent. Warp is counted above for its detected skill installation; its MCP connection
remains manual. Windows users run <code>install.ps1</code>. Also available via <code>npm</code>,
<code>pip</code>, Homebrew, Scoop, Winget, Chocolatey, AUR, and <code>go install</code>.
<code>pip</code>, Homebrew, Scoop, AUR, and <code>go install</code>.
</p>
<p class="muted">
Lifecycle installation follows documented context contracts: Qoder uses <code>SessionStart</code>,
@@ -805,7 +805,7 @@
<h3>Do I need Docker or a runtime?</h3>
<p>The native install needs no language runtime. It is a native executable with authenticated release-owned
assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (amd64); npm, PyPI, and Go wrapper
assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (arm64/amd64); npm, PyPI, and Go wrapper
installations naturally use their package runtime.</p>
<h3>How does it stay up to date as I edit code?</h3>
+1 -1
View File
@@ -15,7 +15,7 @@
- Supported agents: 45 automatic/conditional client surfaces (39 automatically detected + 6 conditional/explicit): Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, Cursor, Windsurf, Augment / Auggie, OpenClaw, Kiro, Junie, Hermes, OpenHands, Cline, Warp, Qwen Code, GitHub Copilot CLI, Factory Droid, Crush, Goose, Mistral Vibe, Grok Build, Qoder CLI, Kimi Code CLI, GitLab Duo CLI, Rovo Dev CLI, Amp, Devin CLI / Local, Tabnine, Continue / cn, Visual Studio, TRAE, Roo Code, Amazon Q Developer IDE, CodeBuddy Code CLI, IBM Bob IDE, IBM Bob Shell, Pochi, Pi, Oh My Pi (omp), and Sourcegraph Cody.
- Agent profiles: documented custom-agent formats receive Scout (fast/provisional), Verify (default/task-directed), and Auditor (bounded/full verification) definitions. Every direct tier checks exact path/scope coverage with check_index_coverage and falls back to source for flagged gaps; unsafe child-MCP formats use explicit parent handoff. Kiro and Junie use positive-allowlist Scout/Analysis server profiles (7/11 tools); Qoder combines named-server selection with exact tier-specific MCP tool IDs, while Factory uses exact registered IDs without additive whole-server exposure. Foreign Junie aliases are preserved and force parent handoff.
- Performance: Linux kernel (28M LOC, 75K files) full index in 3 minutes → 4.81M nodes, 7.72M edges; Cypher queries in under 1ms.
- Distribution: verified runtime set (native executable plus authenticated release-owned assets); fully static Linux builds are available in the `-portable` archives. Also available through npm, PyPI, Homebrew, Scoop, Winget, Chocolatey, AUR, and `go install`.
- Distribution: verified runtime set (native executable plus authenticated release-owned assets); fully static Linux builds are available in the `-portable` archives. Also available through npm, PyPI, Homebrew, Scoop, AUR, and `go install`.
## Links
+163 -190
View File
@@ -7,6 +7,12 @@
// Install:
//
// go install github.com/DeusData/codebase-memory-mcp/pkg/go/cmd/codebase-memory-mcp@latest
//
// For testing, CBM_DOWNLOAD_URL overrides the download base URL (default
// https://github.com/DeusData/codebase-memory-mcp/releases/download/v<version>):
// the archive and checksums.txt are fetched from <CBM_DOWNLOAD_URL>/<file>. It
// must be https://, or plain http:// to exactly localhost, 127.0.0.1, or [::1]
// (optional port; no proxy, no redirects).
package main
import (
@@ -27,7 +33,9 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"runtime/debug"
"sort"
"strings"
"syscall"
@@ -39,16 +47,12 @@ const (
version = "0.12.0"
windowsBinaryName = "codebase-memory-mcp.exe"
maxRedirects = 5
requestTimeout = 2 * time.Minute
connectTimeout = 15 * time.Second
responseHeaderTimeout = 30 * time.Second
candidateTimeout = 15 * time.Second
maxChecksumManifestSize = 1024 * 1024
maxReleaseArchiveSize = int64(256 * 1024 * 1024)
maxArchiveMembers = 64
maxArchiveMemberSize = int64(256 * 1024 * 1024)
maxArchiveExpandedSize = int64(512 * 1024 * 1024)
maxRedirects = 5
requestTimeout = 2 * time.Minute
connectTimeout = 15 * time.Second
responseHeaderTimeout = 30 * time.Second
candidateTimeout = 15 * time.Second
maxArchiveMembers = 64
runtimeSetLockName = ".codebase-memory-mcp-runtime.lock"
runtimeSetLockWait = 45 * time.Second
@@ -66,20 +70,6 @@ const (
runtimeBackupCleanupRemovedEvent = "cleanup-member-removed"
)
type archiveResourceLimits struct {
compressedBytes int64
members int
memberBytes int64
expandedBytes int64
}
var defaultArchiveResourceLimits = archiveResourceLimits{
compressedBytes: maxReleaseArchiveSize,
members: maxArchiveMembers,
memberBytes: maxArchiveMemberSize,
expandedBytes: maxArchiveExpandedSize,
}
type runtimeSetLock struct {
path string
token string
@@ -169,7 +159,7 @@ func ensureBinary() (string, error) {
func binPath() string {
return filepath.Join(
cacheDir(), version, binaryNameForOS(runtime.GOOS),
cacheDir(), installVersion, binaryNameForOS(runtime.GOOS),
)
}
@@ -375,10 +365,14 @@ func download(dest string) error {
"codebase-memory-mcp-%s-%s%s.%s",
platform, arch, portable, ext,
)
url := fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", repo, version, archive)
checksumURL := fmt.Sprintf("https://github.com/%s/releases/download/v%s/checksums.txt", repo, version)
base, loopback, err := downloadBase(os.Getenv("CBM_DOWNLOAD_URL"))
if err != nil {
return err
}
url := base + "/" + archive
checksumURL := base + "/checksums.txt"
fmt.Fprintf(os.Stderr, "codebase-memory-mcp: downloading v%s for %s/%s...\n", version, platform, arch)
fmt.Fprintf(os.Stderr, "codebase-memory-mcp: downloading v%s for %s/%s...\n", installVersion, platform, arch)
tmp, err := os.MkdirTemp("", "cbm-install-*")
if err != nil {
@@ -387,13 +381,13 @@ func download(dest string) error {
defer os.RemoveAll(tmp)
archivePath := filepath.Join(tmp, "cbm."+ext)
if err := httpGet(url, archivePath); err != nil {
if err := httpGet(url, archivePath, loopback); err != nil {
return fmt.Errorf("download failed: %w", err)
}
// A release binary is executable input, so checksum verification is a
// mandatory precondition rather than a best-effort warning.
checksums, err := fetchChecksums(checksumURL)
checksums, err := fetchChecksums(checksumURL, loopback)
if err != nil {
return fmt.Errorf("checksum manifest unavailable: %w", err)
}
@@ -452,6 +446,64 @@ func download(dest string) error {
return nil
}
// loopbackHTTPURL is install.sh's pattern: plain HTTP only to an exact
// loopback authority, for local smoke tests.
// installVersion is the release this wrapper installs: its own module version
// when Go installed it as a tagged release (`go install
// .../pkg/go/cmd/codebase-memory-mcp@v0.12.1`, or @latest, which resolves to
// the newest pkg/go/v* tag), else the version constant (a local build, or a
// pseudo-version of an untagged commit).
var installVersion = releaseVersionFrom(moduleVersion(), version)
var releaseTag = regexp.MustCompile(`^v([0-9]+\.[0-9]+\.[0-9]+(?:-rc\.[0-9]+)?)$`)
func moduleVersion() string {
if info, ok := debug.ReadBuildInfo(); ok {
return info.Main.Version
}
return ""
}
// releaseVersionFrom maps a module version to a release version: "v0.12.1" is
// "0.12.1"; "(devel)", a pseudo-version or "" fall back.
func releaseVersionFrom(module, fallback string) string {
if m := releaseTag.FindStringSubmatch(module); m != nil {
return m[1]
}
return fallback
}
var loopbackHTTPURL = regexp.MustCompile(
`^http://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?([/?#].*)?$`,
)
var loopbackHostnames = map[string]bool{
"localhost": true,
"127.0.0.1": true,
"::1": true,
}
// downloadBase returns the release download base and whether it is a
// loopback HTTP base. CBM_DOWNLOAD_URL replaces the GitHub release base (for
// testing) with install.sh's policy: HTTPS, or plain HTTP to an exact
// loopback authority.
func downloadBase(override string) (string, bool, error) {
if override == "" {
return fmt.Sprintf(
"https://github.com/%s/releases/download/v%s", repo, installVersion,
), false, nil
}
if strings.HasPrefix(override, "https://") {
return override, false, nil
}
if loopbackHTTPURL.MatchString(override) {
return override, true, nil
}
return "", false, fmt.Errorf(
"refusing non-HTTPS download URL (CBM_DOWNLOAD_URL): %s", override,
)
}
// validateURLScheme rejects non-https URLs before any fetch (defense-in-depth).
func validateURLScheme(rawURL string) error {
parsed, err := url.Parse(rawURL)
@@ -461,6 +513,17 @@ func validateURLScheme(rawURL string) error {
return nil
}
// validateLoopbackURL accepts only plain HTTP to an exact loopback authority.
func validateLoopbackURL(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil || !loopbackHTTPURL.MatchString(rawURL) ||
parsed.Scheme != "http" || parsed.User != nil ||
!loopbackHostnames[parsed.Hostname()] {
return fmt.Errorf("refusing non-loopback HTTP URL: %s", rawURL)
}
return nil
}
// httpsOnlyClient returns an HTTP client that rejects non-HTTPS redirects.
var httpsOnlyClient = &http.Client{
Timeout: requestTimeout,
@@ -483,18 +546,40 @@ var httpsOnlyClient = &http.Client{
},
}
func httpGet(rawURL, dest string) error {
return httpGetWithLimit(rawURL, dest, maxReleaseArchiveSize)
// loopbackClient serves a loopback CBM_DOWNLOAD_URL: it uses no proxy and
// refuses every redirect, so no hop can leave the loopback authority.
var loopbackClient = &http.Client{
Timeout: requestTimeout,
Transport: &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{Timeout: connectTimeout}).DialContext,
ResponseHeaderTimeout: responseHeaderTimeout,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return fmt.Errorf("refusing redirect from loopback download: %s", req.URL)
},
}
func httpGetWithLimit(rawURL, dest string, maxBytes int64) error {
// downloadClient validates rawURL for its mode and returns the client for it.
func downloadClient(rawURL string, loopback bool) (*http.Client, error) {
if loopback {
if err := validateLoopbackURL(rawURL); err != nil {
return nil, err
}
return loopbackClient, nil
}
if err := validateURLScheme(rawURL); err != nil {
return nil, err
}
return httpsOnlyClient, nil
}
func httpGet(rawURL, dest string, loopback bool) error {
client, err := downloadClient(rawURL, loopback)
if err != nil {
return err
}
if maxBytes <= 0 {
return fmt.Errorf("invalid compressed archive safety limit")
}
resp, err := httpsOnlyClient.Get(rawURL) //nolint:gosec
resp, err := client.Get(rawURL) //nolint:gosec
if err != nil {
return err
}
@@ -502,24 +587,12 @@ func httpGetWithLimit(rawURL, dest string, maxBytes int64) error {
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d for %s", resp.StatusCode, rawURL)
}
if resp.ContentLength > maxBytes {
return fmt.Errorf(
"release archive exceeds the %d-byte compressed safety limit",
maxBytes,
)
}
f, err := os.Create(dest)
if err != nil {
return err
}
written, copyErr := io.Copy(f, io.LimitReader(resp.Body, maxBytes+1))
_, copyErr := io.Copy(f, resp.Body)
closeErr := f.Close()
if copyErr == nil && written > maxBytes {
copyErr = fmt.Errorf(
"release archive exceeds the %d-byte compressed safety limit",
maxBytes,
)
}
if copyErr != nil {
_ = os.Remove(dest)
return copyErr
@@ -530,11 +603,12 @@ func httpGetWithLimit(rawURL, dest string, maxBytes int64) error {
return closeErr
}
func fetchChecksums(url string) (map[string]string, error) {
if err := validateURLScheme(url); err != nil {
func fetchChecksums(url string, loopback bool) (map[string]string, error) {
client, err := downloadClient(url, loopback)
if err != nil {
return nil, err
}
resp, err := httpsOnlyClient.Get(url) //nolint:gosec
resp, err := client.Get(url) //nolint:gosec
if err != nil {
return nil, err
}
@@ -542,13 +616,10 @@ func fetchChecksums(url string) (map[string]string, error) {
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxChecksumManifestSize+1))
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if len(body) > maxChecksumManifestSize {
return nil, fmt.Errorf("checksums.txt exceeds the 1 MiB safety limit")
}
result := make(map[string]string)
for _, line := range strings.Split(string(body), "\n") {
parts := strings.Fields(line)
@@ -634,17 +705,7 @@ func validateArchiveMemberNames(
return nil
}
func validateArchiveResourceLimits(limits archiveResourceLimits) error {
if limits.compressedBytes <= 0 || limits.members <= 0 ||
limits.memberBytes <= 0 || limits.expandedBytes <= 0 {
return fmt.Errorf("invalid archive resource safety limits")
}
return nil
}
func requireCompressedArchiveWithinLimit(
archivePath string, limits archiveResourceLimits,
) error {
func requireRegularArchive(archivePath string) error {
status, err := os.Stat(archivePath)
if err != nil {
return err
@@ -652,91 +713,36 @@ func requireCompressedArchiveWithinLimit(
if !status.Mode().IsRegular() {
return fmt.Errorf("release archive is not a regular file")
}
if status.Size() > limits.compressedBytes {
return fmt.Errorf(
"release archive exceeds the %d-byte compressed safety limit",
limits.compressedBytes,
)
}
return nil
}
func accountDeclaredArchiveMember(
name string, size int64, expanded *int64, limits archiveResourceLimits,
// copyArchiveMember copies one member and requires its declared size.
func copyArchiveMember(
destination io.Writer, source io.Reader, name string, declaredSize int64,
) error {
if size < 0 {
return fmt.Errorf("archive member has a negative declared size: %q", name)
}
if size > limits.memberBytes {
return fmt.Errorf(
"archive member %q exceeds the %d-byte expanded safety limit",
name, limits.memberBytes,
)
}
if *expanded > limits.expandedBytes-size {
return fmt.Errorf(
"archive exceeds the %d-byte aggregate expanded safety limit",
limits.expandedBytes,
)
}
*expanded += size
return nil
}
func copyArchiveMemberWithLimits(
destination io.Writer,
source io.Reader,
name string,
declaredSize int64,
actualExpanded *int64,
limits archiveResourceLimits,
) error {
remaining := limits.expandedBytes - *actualExpanded
copyLimit := limits.memberBytes
if remaining < copyLimit {
copyLimit = remaining
}
copied, err := io.Copy(
destination, io.LimitReader(source, copyLimit+1),
)
copied, err := io.Copy(destination, source)
if err != nil {
return err
}
if copied > limits.memberBytes {
return fmt.Errorf(
"archive member %q exceeds the %d-byte actual expanded safety limit",
name, limits.memberBytes,
)
}
if copied > remaining {
return fmt.Errorf(
"archive exceeds the %d-byte aggregate actual expanded safety limit",
limits.expandedBytes,
)
}
if copied != declaredSize {
return fmt.Errorf(
"archive member %q actual size %d does not match declared size %d",
name, copied, declaredSize,
)
}
*actualExpanded += copied
return nil
}
func tarGzMemberNames(archivePath string) ([]string, error) {
return tarGzMemberNamesWithLimits(
archivePath, defaultArchiveResourceLimits,
)
return tarGzMemberNamesWithMemberLimit(archivePath, maxArchiveMembers)
}
func tarGzMemberNamesWithLimits(
archivePath string, limits archiveResourceLimits,
// tarGzMemberNamesWithMemberLimit lists the root members; the entry count
// guard bounds the listing before the exact namespace check.
func tarGzMemberNamesWithMemberLimit(
archivePath string, maxMembers int,
) ([]string, error) {
if err := validateArchiveResourceLimits(limits); err != nil {
return nil, err
}
if err := requireCompressedArchiveWithinLimit(archivePath, limits); err != nil {
if err := requireRegularArchive(archivePath); err != nil {
return nil, err
}
f, err := os.Open(archivePath)
@@ -751,7 +757,6 @@ func tarGzMemberNamesWithLimits(
defer gz.Close()
tr := tar.NewReader(gz)
var names []string
var declaredExpanded int64
for {
hdr, err := tr.Next()
if err == io.EOF {
@@ -765,16 +770,11 @@ func tarGzMemberNamesWithLimits(
"archive member is not a regular root file: %q", hdr.Name,
)
}
if len(names) >= limits.members {
if len(names) >= maxMembers {
return nil, fmt.Errorf(
"archive exceeds the %d-member safety limit", limits.members,
"archive exceeds the %d-member safety limit", maxMembers,
)
}
if err := accountDeclaredArchiveMember(
hdr.Name, hdr.Size, &declaredExpanded, limits,
); err != nil {
return nil, err
}
names = append(names, hdr.Name)
}
return names, nil
@@ -784,21 +784,21 @@ func extractTarGz(
archivePath, destDir string,
archiveNames, extractNames []string,
) ([]string, error) {
return extractTarGzWithLimits(
return extractTarGzWithMemberLimit(
archivePath,
destDir,
archiveNames,
extractNames,
defaultArchiveResourceLimits,
maxArchiveMembers,
)
}
func extractTarGzWithLimits(
func extractTarGzWithMemberLimit(
archivePath, destDir string,
archiveNames, extractNames []string,
limits archiveResourceLimits,
maxMembers int,
) ([]string, error) {
names, err := tarGzMemberNamesWithLimits(archivePath, limits)
names, err := tarGzMemberNamesWithMemberLimit(archivePath, maxMembers)
if err != nil {
return nil, err
}
@@ -824,8 +824,6 @@ func extractTarGzWithLimits(
tr := tar.NewReader(gz)
extracted := make(map[string]struct{}, len(runtimeNames))
memberCount := 0
var declaredExpanded int64
var actualExpanded int64
for {
hdr, err := tr.Next()
if err == io.EOF {
@@ -835,9 +833,9 @@ func extractTarGzWithLimits(
return nil, err
}
memberCount++
if memberCount > limits.members {
if memberCount > maxMembers {
return nil, fmt.Errorf(
"archive exceeds the %d-member safety limit", limits.members,
"archive exceeds the %d-member safety limit", maxMembers,
)
}
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
@@ -845,11 +843,6 @@ func extractTarGzWithLimits(
"archive member is not a regular root file: %q", hdr.Name,
)
}
if err := accountDeclaredArchiveMember(
hdr.Name, hdr.Size, &declaredExpanded, limits,
); err != nil {
return nil, err
}
if _, wanted := targets[hdr.Name]; wanted {
outputPath := filepath.Join(destDir, hdr.Name)
out, err := os.OpenFile(
@@ -860,9 +853,7 @@ func extractTarGzWithLimits(
if err != nil {
return nil, err
}
copyErr := copyArchiveMemberWithLimits(
out, tr, hdr.Name, hdr.Size, &actualExpanded, limits,
)
copyErr := copyArchiveMember(out, tr, hdr.Name, hdr.Size)
closeErr := out.Close()
if copyErr != nil {
_ = os.Remove(outputPath)
@@ -873,8 +864,8 @@ func extractTarGzWithLimits(
return nil, closeErr
}
extracted[hdr.Name] = struct{}{}
} else if err := copyArchiveMemberWithLimits(
io.Discard, tr, hdr.Name, hdr.Size, &actualExpanded, limits,
} else if err := copyArchiveMember(
io.Discard, tr, hdr.Name, hdr.Size,
); err != nil {
return nil, err
}
@@ -889,24 +880,21 @@ func extractZip(
archivePath, destDir string,
archiveNames, extractNames []string,
) ([]string, error) {
return extractZipWithLimits(
return extractZipWithMemberLimit(
archivePath,
destDir,
archiveNames,
extractNames,
defaultArchiveResourceLimits,
maxArchiveMembers,
)
}
func extractZipWithLimits(
func extractZipWithMemberLimit(
archivePath, destDir string,
archiveNames, extractNames []string,
limits archiveResourceLimits,
maxMembers int,
) ([]string, error) {
if err := validateArchiveResourceLimits(limits); err != nil {
return nil, err
}
if err := requireCompressedArchiveWithinLimit(archivePath, limits); err != nil {
if err := requireRegularArchive(archivePath); err != nil {
return nil, err
}
r, err := zip.OpenReader(archivePath)
@@ -914,14 +902,13 @@ func extractZipWithLimits(
return nil, err
}
defer r.Close()
if len(r.File) > limits.members {
if len(r.File) > maxMembers {
return nil, fmt.Errorf(
"archive exceeds the %d-member safety limit", limits.members,
"archive exceeds the %d-member safety limit", maxMembers,
)
}
names := make([]string, 0, len(r.File))
declaredSizes := make(map[string]int64, len(r.File))
var declaredExpanded int64
for _, f := range r.File {
name, err := validateWindowsZipMember(f.Name)
if err != nil {
@@ -932,17 +919,11 @@ func extractZipWithLimits(
"archive member is not a regular root file: %q", f.Name,
)
}
if f.UncompressedSize64 > uint64(limits.memberBytes) {
return nil, fmt.Errorf(
"archive member %q exceeds the %d-byte expanded safety limit",
name, limits.memberBytes,
)
}
declaredSize := int64(f.UncompressedSize64)
if err := accountDeclaredArchiveMember(
name, declaredSize, &declaredExpanded, limits,
); err != nil {
return nil, err
if declaredSize < 0 {
return nil, fmt.Errorf(
"archive member has an invalid declared size: %q", name,
)
}
names = append(names, name)
declaredSizes[name] = declaredSize
@@ -956,7 +937,6 @@ func extractZipWithLimits(
targetNames[name] = struct{}{}
}
extracted := make(map[string]struct{}, len(runtimeNames))
var actualExpanded int64
for index, target := range r.File {
name := names[index]
@@ -980,14 +960,7 @@ func extractZipWithLimits(
}
destination = out
}
copyErr := copyArchiveMemberWithLimits(
destination,
rc,
name,
declaredSizes[name],
&actualExpanded,
limits,
)
copyErr := copyArchiveMember(destination, rc, name, declaredSizes[name])
var closeOutErr error
if out != nil {
closeOutErr = out.Close()
+192 -204
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
@@ -29,18 +30,18 @@ func (transport archiveTestRoundTripper) RoundTrip(
return transport(request)
}
type archiveCountingBody struct {
reader io.Reader
read int64
}
// formerMemberCapBytes is the size cap the 0.12.0 wrappers enforced, which
// refused every release.
const formerMemberCapBytes = int64(256 * 1024 * 1024)
func (body *archiveCountingBody) Read(buffer []byte) (int, error) {
count, err := body.reader.Read(buffer)
body.read += int64(count)
return count, err
}
type zeroReader struct{}
func (*archiveCountingBody) Close() error { return nil }
func (zeroReader) Read(buffer []byte) (int, error) {
for index := range buffer {
buffer[index] = 0
}
return len(buffer), nil
}
func assertRuntimeTag(t *testing.T, directory, binaryName, tag string) {
t.Helper()
@@ -157,164 +158,167 @@ func writeTarGz(t *testing.T, archivePath string, names []string) {
}
}
func TestCompressedArchiveDownloadRejectsDeclaredAndActualOverflow(t *testing.T) {
const maxBytes = int64(8)
func TestDownloadIgnoresADeclaredLengthAboveTheFormerCap(t *testing.T) {
priorClient := httpsOnlyClient
defer func() { httpsOnlyClient = priorClient }()
t.Run("declared", func(t *testing.T) {
httpsOnlyClient = &http.Client{Transport: archiveTestRoundTripper(
func(request *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("")),
ContentLength: maxBytes + 1,
Header: make(http.Header),
Request: request,
}, nil
},
)}
destination := filepath.Join(t.TempDir(), "release.tar.gz")
err := httpGetWithLimit(
"https://example.invalid/release.tar.gz", destination, maxBytes,
)
if err == nil || !strings.Contains(err.Error(), "compressed safety limit") {
t.Fatalf("compressed declared overflow error = %v", err)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatal("declared oversized archive created a download")
}
})
t.Run("actual", func(t *testing.T) {
body := &archiveCountingBody{reader: bytes.NewReader(
bytes.Repeat([]byte("x"), 1024),
)}
httpsOnlyClient = &http.Client{Transport: archiveTestRoundTripper(
func(request *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: body,
ContentLength: -1,
Header: make(http.Header),
Request: request,
}, nil
},
)}
destination := filepath.Join(t.TempDir(), "release.tar.gz")
err := httpGetWithLimit(
"https://example.invalid/release.tar.gz", destination, maxBytes,
)
if err == nil || !strings.Contains(err.Error(), "compressed safety limit") {
t.Fatalf("compressed actual overflow error = %v", err)
}
if body.read != maxBytes+1 {
t.Fatalf("oversized response bytes consumed = %d, want %d", body.read, maxBytes+1)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatal("rejected compressed archive left a partial download")
}
})
}
func testArchiveLimits(
members int, memberBytes, expandedBytes int64,
) archiveResourceLimits {
return archiveResourceLimits{
compressedBytes: 1024 * 1024,
members: members,
memberBytes: memberBytes,
expandedBytes: expandedBytes,
httpsOnlyClient = &http.Client{Transport: archiveTestRoundTripper(
func(request *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("contents")),
ContentLength: formerMemberCapBytes + 1,
Header: make(http.Header),
Request: request,
}, nil
},
)}
destination := filepath.Join(t.TempDir(), "release.tar.gz")
if err := httpGet(
"https://example.invalid/release.tar.gz", destination, false,
); err != nil {
t.Fatalf("download error = %v", err)
}
contents, err := os.ReadFile(destination)
if err != nil {
t.Fatal(err)
}
if string(contents) != "contents" {
t.Fatalf("download stored %q", contents)
}
}
func TestTarArchiveRejectsMemberAndExpandedResourceOverflow(t *testing.T) {
func TestTarArchiveRejectsMoreMembersThanTheLimit(t *testing.T) {
archivePath := filepath.Join(t.TempDir(), "members.tar.gz")
writeTarGz(t, archivePath, []string{"one", "two", "three"})
_, err := tarGzMemberNamesWithMemberLimit(archivePath, 2)
if err == nil || !strings.Contains(err.Error(), "2-member safety limit") {
t.Fatalf("tar member count error = %v", err)
}
}
func TestArchiveCopyAcceptsAMemberLargerThanTheFormerCap(t *testing.T) {
size := formerMemberCapBytes + 1024*1024
if err := copyArchiveMember(
io.Discard, io.LimitReader(zeroReader{}, size), "codebase-memory-mcp", size,
); err != nil {
t.Fatalf("copy of a %d-byte member error = %v", size, err)
}
}
func TestArchiveCopyRequiresTheDeclaredSize(t *testing.T) {
var output bytes.Buffer
err := copyArchiveMember(&output, strings.NewReader("123"), "member", 2)
if err == nil || !strings.Contains(err.Error(), "does not match declared size") {
t.Fatalf("declared size mismatch error = %v", err)
}
}
func TestDownloadBaseFollowsTheInstallShPolicy(t *testing.T) {
githubBase := "https://github.com/DeusData/codebase-memory-mcp/releases/download/v" + version
if base, loopback, err := downloadBase(""); err != nil || base != githubBase || loopback {
t.Fatalf("unset override = (%q, %v, %v), want the GitHub base", base, loopback, err)
}
for _, override := range []string{
"https://mirror.example/releases/v1",
"https://127.0.0.1:8443/fixture",
} {
base, loopback, err := downloadBase(override)
if err != nil || base != override || loopback {
t.Errorf("https override %q = (%q, %v, %v)", override, base, loopback, err)
}
}
for _, override := range []string{
"http://localhost",
"http://localhost:8080/fixture",
"http://127.0.0.1:41234/standard",
"http://[::1]:9/x?y#z",
} {
base, loopback, err := downloadBase(override)
if err != nil || base != override || !loopback {
t.Errorf("loopback override %q = (%q, %v, %v)", override, base, loopback, err)
}
}
for _, override := range []string{
"http://example.com/releases",
"http://127.0.0.1.example.com/",
"http://localhost.example.com:8080/",
"http://user@localhost/",
"http://127.0.0.2/",
"HTTP://localhost/",
"file:///tmp/releases",
"ftp://localhost/releases",
"github.com/DeusData/codebase-memory-mcp",
} {
_, _, err := downloadBase(override)
if err == nil || !strings.Contains(
err.Error(), "refusing non-HTTPS download URL (CBM_DOWNLOAD_URL)",
) {
t.Errorf("override %q error = %v, want a refusal", override, err)
}
}
}
func TestDownloadRefusesURLsOutsideTheirMode(t *testing.T) {
destination := filepath.Join(t.TempDir(), "release.tar.gz")
err := httpGet("http://127.0.0.1/release.tar.gz", destination, false)
if err == nil || !strings.Contains(err.Error(), "refusing non-https URL") {
t.Fatalf("plain HTTP without a loopback base error = %v", err)
}
err = httpGet("http://example.invalid/release.tar.gz", destination, true)
if err == nil || !strings.Contains(err.Error(), "refusing non-loopback HTTP URL") {
t.Fatalf("non-loopback URL with a loopback base error = %v", err)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatal("refused download created a file")
}
}
func TestLoopbackDownloadBypassesProxiesAndRefusesRedirects(t *testing.T) {
transport, ok := loopbackClient.Transport.(*http.Transport)
if !ok || transport.Proxy != nil {
t.Fatal("the loopback client must not use a proxy")
}
digest := strings.Repeat("a", 64)
server := httptest.NewServer(http.HandlerFunc(
func(response http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/fixture/archive":
_, _ = io.WriteString(response, "loopback contents")
case "/fixture/checksums.txt":
_, _ = io.WriteString(response, digest+" archive\n")
default:
http.Redirect(
response, request, "https://example.invalid/elsewhere", http.StatusFound,
)
}
},
))
defer server.Close()
root := t.TempDir()
tests := []struct {
name string
names []string
limits archiveResourceLimits
wantError string
}{
{
name: "member count",
names: []string{"one", "two", "three"},
limits: testArchiveLimits(2, 1024, 4096),
wantError: "member safety limit",
},
{
name: "declared member bytes",
names: []string{"oversized"},
limits: testArchiveLimits(4, 4, 4096),
wantError: "expanded safety limit",
},
{
name: "declared aggregate bytes",
names: []string{"one", "two"},
limits: testArchiveLimits(4, 1024, 15),
wantError: "aggregate expanded safety limit",
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
archivePath := filepath.Join(root, strings.ReplaceAll(testCase.name, " ", "-")+".tar.gz")
writeTarGz(t, archivePath, testCase.names)
_, err := tarGzMemberNamesWithLimits(archivePath, testCase.limits)
if err == nil || !strings.Contains(err.Error(), testCase.wantError) {
t.Fatalf("tar resource overflow error = %v", err)
}
})
}
}
func TestArchiveCopyRejectsActualMemberAggregateAndMetadataMismatch(t *testing.T) {
tests := []struct {
name string
contents string
declared int64
actual int64
limits archiveResourceLimits
wantError string
}{
{
name: "actual member bytes",
contents: "12345",
declared: 5,
limits: testArchiveLimits(1, 4, 10),
wantError: "actual expanded safety limit",
},
{
name: "actual aggregate bytes",
contents: "12",
declared: 2,
actual: 4,
limits: testArchiveLimits(1, 10, 5),
wantError: "aggregate actual expanded safety limit",
},
{
name: "declared actual mismatch",
contents: "123",
declared: 2,
limits: testArchiveLimits(1, 10, 10),
wantError: "does not match declared size",
},
archivePath := filepath.Join(root, "archive")
if err := httpGet(server.URL+"/fixture/archive", archivePath, true); err != nil {
t.Fatalf("loopback download error = %v", err)
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
actual := testCase.actual
var output bytes.Buffer
err := copyArchiveMemberWithLimits(
&output,
strings.NewReader(testCase.contents),
"member",
testCase.declared,
&actual,
testCase.limits,
)
if err == nil || !strings.Contains(err.Error(), testCase.wantError) {
t.Fatalf("actual archive overflow error = %v", err)
}
})
contents, err := os.ReadFile(archivePath)
if err != nil {
t.Fatal(err)
}
if string(contents) != "loopback contents" {
t.Fatalf("loopback download stored %q", contents)
}
checksums, err := fetchChecksums(server.URL+"/fixture/checksums.txt", true)
if err != nil || checksums["archive"] != digest {
t.Fatalf("loopback checksums = %v, %v", checksums, err)
}
redirected := filepath.Join(root, "redirected")
err = httpGet(server.URL+"/fixture/elsewhere", redirected, true)
if err == nil || !strings.Contains(err.Error(), "refusing redirect from loopback download") {
t.Fatalf("loopback redirect error = %v", err)
}
if _, statErr := os.Stat(redirected); !os.IsNotExist(statErr) {
t.Fatal("a refused loopback redirect created a file")
}
}
@@ -342,52 +346,20 @@ func writeZip(t *testing.T, archivePath string, names []string) {
}
}
func TestZipArchiveRejectsMemberAndExpandedResourceOverflow(t *testing.T) {
func TestZipArchiveRejectsMoreMembersThanTheLimit(t *testing.T) {
root := t.TempDir()
tests := []struct {
name string
names []string
limits archiveResourceLimits
wantError string
}{
{
name: "member count",
names: []string{"one", "two", "three"},
limits: testArchiveLimits(2, 1024, 4096),
wantError: "member safety limit",
},
{
name: "declared member bytes",
names: []string{"oversized"},
limits: testArchiveLimits(4, 4, 4096),
wantError: "expanded safety limit",
},
{
name: "declared aggregate bytes",
names: []string{"one", "two"},
limits: testArchiveLimits(4, 1024, 15),
wantError: "aggregate expanded safety limit",
},
names := []string{"one", "two", "three"}
archivePath := filepath.Join(root, "members.zip")
writeZip(t, archivePath, names)
destination := filepath.Join(root, "extract")
if err := os.Mkdir(destination, 0755); err != nil {
t.Fatal(err)
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
archivePath := filepath.Join(root, strings.ReplaceAll(testCase.name, " ", "-")+".zip")
writeZip(t, archivePath, testCase.names)
destination := filepath.Join(root, strings.ReplaceAll(testCase.name, " ", "-")+"-extract")
if err := os.Mkdir(destination, 0755); err != nil {
t.Fatal(err)
}
_, err := extractZipWithLimits(
archivePath,
destination,
testCase.names,
testCase.names[:1],
testCase.limits,
)
if err == nil || !strings.Contains(err.Error(), testCase.wantError) {
t.Fatalf("zip resource overflow error = %v", err)
}
})
_, err := extractZipWithMemberLimit(
archivePath, destination, names, names[:1], 2,
)
if err == nil || !strings.Contains(err.Error(), "2-member safety limit") {
t.Fatalf("zip member count error = %v", err)
}
}
@@ -1400,3 +1372,19 @@ func TestRuntimeSetLockSerializesProcesses(t *testing.T) {
t.Fatalf("child never acquired the released runtime-set lock: %v", err)
}
}
func TestReleaseVersionFromModuleVersion(t *testing.T) {
cases := []struct{ module, want string }{
{"v0.12.1", "0.12.1"},
{"v0.9.1-rc.1", "0.9.1-rc.1"},
{"(devel)", "fallback"},
{"", "fallback"},
{"v0.0.0-20261015165448-ec6427c38176", "fallback"},
{"v0.12.2-0.20261016120000-0123456789ab", "fallback"},
}
for _, c := range cases {
if got := releaseVersionFrom(c.module, "fallback"); got != c.want {
t.Errorf("releaseVersionFrom(%q) = %q, want %q", c.module, got, c.want)
}
}
}
+9 -7
View File
@@ -1,28 +1,30 @@
class CodebaseMemoryMcp < Formula
desc "Fast code intelligence engine for AI coding agents"
homepage "https://github.com/DeusData/codebase-memory-mcp"
version "0.10.3"
version "0.12.0"
license "MIT"
on_macos do
on_arm do
url "https://github.com/DeusData/codebase-memory-mcp/releases/download/v#{version}/codebase-memory-mcp-darwin-arm64.tar.gz"
sha256 "0ebf02328207d4c3d862c837b5e973de5bac808df92b0941737721d467287f7f"
sha256 "1be41a9dd6902e3633eb0b280189117dc3db3be623bdd3dd4c6ea74372b5a50b"
end
on_intel do
url "https://github.com/DeusData/codebase-memory-mcp/releases/download/v#{version}/codebase-memory-mcp-darwin-amd64.tar.gz"
sha256 "1107fea28285823e1436e4f38a4e00a0b472d8a43c379da7dfd200c914a4b9dd"
sha256 "af3beeeaca53204faf350402ddacee04780a53d0fa510dc31941fb5c2202ff35"
end
end
# The static -portable build: the dynamic one needs glibc 2.38, which
# Ubuntu 22.04, Debian 12 and RHEL 9 do not have.
on_linux do
on_arm do
url "https://github.com/DeusData/codebase-memory-mcp/releases/download/v#{version}/codebase-memory-mcp-linux-arm64.tar.gz"
sha256 "967b9eababfdbd2ef1987c571d55bc7c028cd1db7f99279830634c58db311e32"
url "https://github.com/DeusData/codebase-memory-mcp/releases/download/v#{version}/codebase-memory-mcp-linux-arm64-portable.tar.gz"
sha256 "8e603f32c9c84b5dc5a6b4a0d23516817d5de199e30f8dcc9bdb128c39386cd8"
end
on_intel do
url "https://github.com/DeusData/codebase-memory-mcp/releases/download/v#{version}/codebase-memory-mcp-linux-amd64.tar.gz"
sha256 "74997fb0934e70a22f20c2e112fb4d883867dc1f01a7bcdc94cf86d13b5cbd31"
url "https://github.com/DeusData/codebase-memory-mcp/releases/download/v#{version}/codebase-memory-mcp-linux-amd64-portable.tar.gz"
sha256 "7024e585132760f383e3e25237e2b2ac6f5185f7e466f8dc66a085bb25f8d0be"
end
end
+2
View File
@@ -17,6 +17,8 @@ npm install -g codebase-memory-mcp
The runtime set for your platform is downloaded automatically at install time. There is one composition per platform and the graph UI is always included — the former `CBM_VARIANT=ui` opt-in is obsolete.
For testing, `CBM_DOWNLOAD_URL` overrides the download base URL (default `https://github.com/DeusData/codebase-memory-mcp/releases/download/v<version>`): the archive and `checksums.txt` are fetched from `<CBM_DOWNLOAD_URL>/<file>`. It must be `https://`, or plain `http://` to exactly `localhost`, `127.0.0.1`, or `[::1]` (optional port; no proxy, no redirects).
Then configure your coding agents:
```bash
+78 -110
View File
@@ -3,6 +3,7 @@
// Postinstall script: downloads the platform-appropriate binary from GitHub Releases.
// Runs automatically via `postinstall` in package.json.
const http = require('http');
const https = require('https');
const crypto = require('crypto');
const fs = require('fs');
@@ -17,19 +18,14 @@ const BIN_DIR = path.join(__dirname, 'bin');
const MAX_REDIRECTS = 5;
const DOWNLOAD_HOP_TIMEOUT_MS = 120_000;
const CANDIDATE_TIMEOUT_MS = 15_000;
const MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024;
// Release archive resource limits. Keep in sync with the Go wrapper
// (pkg/go/cmd/codebase-memory-mcp/main.go) and the PyPI wrapper (_cli.py).
const MAX_RELEASE_ARCHIVE_BYTES = 256 * 1024 * 1024;
// Entry-count guard for the zip namespace check. Keep in sync with the Go
// wrapper (pkg/go/cmd/codebase-memory-mcp/main.go) and the PyPI wrapper.
const MAX_ARCHIVE_MEMBERS = 64;
const MAX_ARCHIVE_MEMBER_BYTES = 256 * 1024 * 1024;
const MAX_ARCHIVE_EXPANDED_BYTES = 512 * 1024 * 1024;
const DEFAULT_ARCHIVE_LIMITS = Object.freeze({
compressedBytes: MAX_RELEASE_ARCHIVE_BYTES,
members: MAX_ARCHIVE_MEMBERS,
memberBytes: MAX_ARCHIVE_MEMBER_BYTES,
expandedBytes: MAX_ARCHIVE_EXPANDED_BYTES,
});
const DEFAULT_DOWNLOAD_BASE = `https://github.com/${REPO}/releases/download/v${VERSION}`;
// install.sh's loopback pattern: plain HTTP only to an exact loopback
// authority, for local smoke tests.
const LOOPBACK_HTTP_URL = /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?([/?#].*)?$/;
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
// Bytes of tar's own diagnostics kept for the failure message.
const MAX_TAR_STDERR_CHARS = 4096;
const WINDOWS_BINARY_NAME = 'codebase-memory-mcp.exe';
@@ -83,6 +79,32 @@ function validateUrl(rawUrl) {
return parsed.href;
}
// Plain HTTP is accepted only for an exact loopback authority, as in
// install.sh; such a fetch uses no proxy and follows no redirect.
function validateLoopbackUrl(rawUrl) {
let parsed = null;
try {
parsed = new URL(rawUrl);
} catch (_) {
// Refused below.
}
if (!LOOPBACK_HTTP_URL.test(rawUrl) || !parsed || parsed.protocol !== 'http:' ||
!LOOPBACK_HOSTNAMES.has(parsed.hostname) || parsed.username || parsed.password) {
throw new Error(`Refusing non-loopback HTTP URL: ${rawUrl}`);
}
return parsed.href;
}
// CBM_DOWNLOAD_URL replaces the release download base (for testing), with
// install.sh's policy: HTTPS, or plain HTTP to an exact loopback authority.
function resolveDownloadBase(env = process.env) {
const override = env.CBM_DOWNLOAD_URL;
if (!override) return { base: DEFAULT_DOWNLOAD_BASE, loopback: false };
if (override.startsWith('https://')) return { base: override, loopback: false };
if (LOOPBACK_HTTP_URL.test(override)) return { base: override, loopback: true };
throw new Error(`Refusing non-HTTPS download URL (CBM_DOWNLOAD_URL): ${override}`);
}
function validateExactTarMemberListing(listing, expectedNames) {
if (listing.includes('\0')) {
throw new Error('tar member listing contains a NUL byte');
@@ -104,31 +126,16 @@ function validateExactTarMemberListing(listing, expectedNames) {
}
}
function validateArchiveLimits(limits) {
if (!limits || !(limits.compressedBytes > 0) || !(limits.members > 0) ||
!(limits.memberBytes > 0) || !(limits.expandedBytes > 0)) {
throw new Error('invalid archive resource safety limits');
}
}
function requireCompressedArchiveWithinLimit(archivePath, limits) {
const status = fs.statSync(archivePath);
if (!status.isFile()) {
function requireRegularArchive(archivePath) {
if (!fs.statSync(archivePath).isFile()) {
throw new Error('release archive is not a regular file');
}
if (status.size > limits.compressedBytes) {
throw new Error(
`release archive exceeds the ${limits.compressedBytes}-byte compressed safety limit`,
);
}
}
// Streams one member through `tar -xzOf` into a file this call creates,
// counting every byte against the per-member limit. The file is removed only
// when this call created it: a pre-existing target fails untouched.
function extractTarMemberWithLimit(
archivePath, target, memberName, limits, spawnFile = spawn,
) {
// Streams one member through `tar -xzOf` into a file this call creates. The
// file is removed only when this call created it: a pre-existing target
// fails untouched.
function extractTarMember(archivePath, target, memberName, spawnFile = spawn) {
return new Promise((resolve, reject) => {
let fd;
try {
@@ -164,12 +171,6 @@ function extractTarMemberWithLimit(
child.stdout.on('data', (chunk) => {
if (failure) return;
written += chunk.length;
if (written > limits.memberBytes) {
fail(new Error(
`archive member ${JSON.stringify(memberName)} exceeds the ${limits.memberBytes}-byte expanded safety limit`,
));
return;
}
try {
let offset = 0;
while (offset < chunk.length) {
@@ -209,29 +210,24 @@ function extractTarMemberWithLimit(
async function extractExactTarArchive(
archivePath, destPath, expectedNames, targetName,
runFile = execFileSync, limits = DEFAULT_ARCHIVE_LIMITS, spawnFile = spawn,
runFile = execFileSync, spawnFile = spawn,
) {
validateArchiveLimits(limits);
requireCompressedArchiveWithinLimit(archivePath, limits);
// The exact root namespace is the member bound: its four names lie well
// within MAX_ARCHIVE_MEMBERS, which the zip path enforces by count.
if (expectedNames.length > limits.members) {
throw new Error(`archive namespace exceeds the ${limits.members}-member safety limit`);
}
requireRegularArchive(archivePath);
// The exact root namespace is the member bound: the listing must name
// exactly the expected root files.
const listing = runFile('tar', ['-tzf', archivePath], {
encoding: 'utf8',
maxBuffer: MAX_CHECKSUM_MANIFEST_BYTES,
windowsHide: true,
});
validateExactTarMemberListing(listing, expectedNames);
// Only the fixed root executable is ever written, through the counted
// writer; the companions are listed but never extracted.
// Only the fixed root executable is ever written; the companions are
// listed but never extracted.
const target = path.join(destPath, targetName);
await extractTarMemberWithLimit(archivePath, target, targetName, limits, spawnFile);
await extractTarMember(archivePath, target, targetName, spawnFile);
fs.chmodSync(target, 0o755);
}
function downloadHop(url, dest, maxBytes) {
function downloadHop(url, dest, loopback) {
return new Promise((resolve, reject) => {
let settled = false;
let response = null;
@@ -245,7 +241,11 @@ function downloadHop(url, dest, maxBytes) {
else resolve(result);
}
const req = https.get(url, (res) => {
// A loopback hop gets a fresh agent, so no proxy configuration applies.
const get = loopback
? (target, callback) => http.get(target, { agent: false }, callback)
: (target, callback) => https.get(target, callback);
const req = get(url, (res) => {
response = res;
let piped = false;
// Before the body is piped, a response error ends the hop directly;
@@ -255,6 +255,11 @@ function downloadHop(url, dest, maxBytes) {
const redirectCodes = new Set([301, 302, 303, 307, 308]);
if (redirectCodes.has(res.statusCode)) {
const location = res.headers.location;
if (loopback) {
res.destroy();
finish(new Error(`Refusing redirect from loopback download ${url}`));
return;
}
if (!location) {
res.destroy();
finish(new Error(`Redirect with no location for ${url}`));
@@ -277,20 +282,7 @@ function downloadHop(url, dest, maxBytes) {
finish(new Error(`HTTP ${res.statusCode} for ${url}`));
return;
}
const declared = Number(res.headers['content-length']);
if (maxBytes && Number.isFinite(declared) && declared > maxBytes) {
res.destroy();
finish(new Error(`Download exceeds the ${maxBytes}-byte safety limit`));
return;
}
let received = 0;
res.on('data', (chunk) => {
received += chunk.length;
if (maxBytes && received > maxBytes) {
res.destroy(new Error(`Download exceeds the ${maxBytes}-byte safety limit`));
}
});
const file = fs.createWriteStream(dest, { flags: 'w' });
piped = true;
pipeline(res, file, (err) => {
@@ -317,10 +309,10 @@ function downloadHop(url, dest, maxBytes) {
});
}
async function download(rawUrl, dest, maxBytes = 0) {
let url = validateUrl(rawUrl);
async function download(rawUrl, dest, loopback = false) {
let url = loopback ? validateLoopbackUrl(rawUrl) : validateUrl(rawUrl);
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
const result = await downloadHop(url, dest, maxBytes);
const result = await downloadHop(url, dest, loopback);
if (!result.redirect) return;
if (redirects === MAX_REDIRECTS) throw new Error('Too many redirects');
url = result.redirect;
@@ -605,10 +597,9 @@ function installWindowsBinaryAtomically(sourceDir, destDir, verifier = verifyCan
function extractZipOnWindows(
archivePath, destPath, requiredNames, extractNames,
limits = DEFAULT_ARCHIVE_LIMITS, shell = 'powershell',
maxMembers = MAX_ARCHIVE_MEMBERS, shell = 'powershell',
) {
validateArchiveLimits(limits);
requireCompressedArchiveWithinLimit(archivePath, limits);
requireRegularArchive(archivePath);
// -EncodedCommand is a constant program. Paths travel only through the child
// environment and are consumed with -LiteralPath, so PowerShell never parses
// user/TEMP path bytes as source code or wildcard syntax.
@@ -616,10 +607,7 @@ function extractZipOnWindows(
"$ErrorActionPreference = 'Stop'",
'Add-Type -AssemblyName System.IO.Compression.FileSystem',
'$maxMembers = [int]$env:CBM_NPM_MAX_MEMBERS',
'$maxMemberBytes = [long]$env:CBM_NPM_MAX_MEMBER_BYTES',
'$maxExpandedBytes = [long]$env:CBM_NPM_MAX_EXPANDED_BYTES',
'$memberCount = 0',
'$declaredExpanded = [long]0',
'$zip = [System.IO.Compression.ZipFile]::OpenRead($env:CBM_NPM_ARCHIVE_PATH)',
"$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)",
"$requiredNames = @($env:CBM_NPM_REQUIRED_NAMES.Split('|'))",
@@ -629,11 +617,6 @@ function extractZipOnWindows(
'$memberCount = $memberCount + 1; ' +
'if ($memberCount -gt $maxMembers) { ' +
'throw "archive exceeds the $maxMembers-member safety limit" }; ' +
'if ($entry.Length -gt $maxMemberBytes) { ' +
'throw "archive member $($entry.FullName) exceeds the $maxMemberBytes-byte expanded safety limit" }; ' +
'if ($declaredExpanded -gt ($maxExpandedBytes - $entry.Length)) { ' +
'throw "archive exceeds the $maxExpandedBytes-byte aggregate expanded safety limit" }; ' +
'$declaredExpanded = $declaredExpanded + $entry.Length; ' +
"$name = $entry.FullName.Replace('\\', '/'); " +
"$directory = $name.EndsWith('/'); " +
"$segmentsPath = if ($directory) { $name.TrimEnd('/') } else { $name }; " +
@@ -655,15 +638,13 @@ function extractZipOnWindows(
'if ($seen.Count -ne $requiredNames.Count) { ' +
'throw "archive does not match the exact release root-file allowlist" }',
"$extractNames = @($env:CBM_NPM_EXTRACT_NAMES.Split('|'))",
'$actualExpanded = [long]0',
'$buffer = New-Object byte[] 65536',
'$extractZip = [System.IO.Compression.ZipFile]::OpenRead($env:CBM_NPM_ARCHIVE_PATH)',
// Copy by hand with a byte counter: ExtractToFile would write whatever the
// compressed stream yields, regardless of the declared size.
// Copy by hand so the bytes written can be compared with the declared
// size; ExtractToFile would accept whatever the compressed stream yields.
'try { foreach ($extractName in $extractNames) { ' +
'$entry = @($extractZip.Entries | Where-Object { $_.FullName -ceq $extractName })[0]; ' +
'$targetPath = Join-Path $env:CBM_NPM_DEST_PATH $extractName; ' +
'$remaining = $maxExpandedBytes - $actualExpanded; ' +
'$copied = [long]0; ' +
'$source = $entry.Open(); ' +
'$target = [System.IO.File]::Open($targetPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write); ' +
@@ -671,18 +652,13 @@ function extractZipOnWindows(
'$count = $source.Read($buffer, 0, $buffer.Length); ' +
'if ($count -le 0) { break }; ' +
'$copied = $copied + $count; ' +
'if ($copied -gt $maxMemberBytes) { ' +
'throw "archive member $extractName exceeds the $maxMemberBytes-byte actual expanded safety limit" }; ' +
'if ($copied -gt $remaining) { ' +
'throw "archive exceeds the $maxExpandedBytes-byte aggregate actual expanded safety limit" }; ' +
'$target.Write($buffer, 0, $count) } ' +
'} catch { $target.Dispose(); ' +
'Remove-Item -LiteralPath $targetPath -Force -ErrorAction SilentlyContinue; throw ' +
'} finally { $target.Dispose(); $source.Dispose() }; ' +
'if ($copied -ne $entry.Length) { ' +
'Remove-Item -LiteralPath $targetPath -Force -ErrorAction SilentlyContinue; ' +
'throw "archive member $extractName actual size $copied does not match declared size $($entry.Length)" }; ' +
'$actualExpanded = $actualExpanded + $copied ' +
'throw "archive member $extractName actual size $copied does not match declared size $($entry.Length)" } ' +
'} } finally { $extractZip.Dispose() }',
].join('; ');
const encoded = Buffer.from(script, 'utf16le').toString('base64');
@@ -696,39 +672,30 @@ function extractZipOnWindows(
CBM_NPM_DEST_PATH: destPath,
CBM_NPM_REQUIRED_NAMES: requiredNames.join('|'),
CBM_NPM_EXTRACT_NAMES: extractNames.join('|'),
CBM_NPM_MAX_MEMBERS: String(limits.members),
CBM_NPM_MAX_MEMBER_BYTES: String(limits.memberBytes),
CBM_NPM_MAX_EXPANDED_BYTES: String(limits.expandedBytes),
CBM_NPM_MAX_MEMBERS: String(maxMembers),
},
encoding: 'utf8',
stdio: ['ignore', 'inherit', 'pipe'],
windowsHide: true,
});
} catch (err) {
// Surface the script's own reason (for example which limit was hit)
// Surface the script's own reason (for example which check failed)
// instead of a bare "Command failed".
const detail = err && err.stderr ? String(err.stderr).trim() : '';
throw new Error(detail ? `zip extraction failed: ${detail}` : err.message);
}
}
// Fetch checksums.txt and verify the archive hash.
async function verifyChecksum(archivePath, archiveName) {
const url = `https://github.com/${REPO}/releases/download/v${VERSION}/checksums.txt`;
// Fetch checksums.txt from the same download base and verify the archive hash.
async function verifyChecksum(archivePath, archiveName, source) {
const url = `${source.base}/checksums.txt`;
const tmpChecksums = archivePath + '.checksums';
try {
await download(url, tmpChecksums, MAX_CHECKSUM_MANIFEST_BYTES);
const manifestSize = fs.statSync(tmpChecksums).size;
if (manifestSize > MAX_CHECKSUM_MANIFEST_BYTES) {
throw new Error('checksums.txt exceeds the 1 MiB safety limit');
}
await download(url, tmpChecksums, source.loopback);
const expected = parseExpectedChecksum(
fs.readFileSync(tmpChecksums, 'utf-8'), archiveName,
);
const actual = crypto
.createHash('sha256')
.update(fs.readFileSync(archivePath))
.digest('hex');
const actual = fileSha256(archivePath);
if (expected !== actual) {
throw new Error(
`Checksum mismatch for ${archiveName}:\n expected: ${expected}\n actual: ${actual}`,
@@ -772,6 +739,7 @@ async function main() {
}
}
const source = resolveDownloadBase();
fs.mkdirSync(BIN_DIR, { recursive: true });
// Linux ships a fully-static "-portable" build; the standard linux binary
@@ -782,7 +750,7 @@ async function main() {
// always embedded. The old CBM_VARIANT=ui opt-in pointed at ui-* archives
// that no longer exist, so honoring it could only 404 (#1538).
const archive = `codebase-memory-mcp-${platform}-${arch}${variant}.${ext}`;
const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${archive}`;
const url = `${source.base}/${archive}`;
process.stdout.write(`codebase-memory-mcp: downloading v${VERSION} for ${platform}/${arch}...\n`);
@@ -790,8 +758,8 @@ async function main() {
const tmpArchive = path.join(tmpDir, `cbm.${ext}`);
try {
await download(url, tmpArchive, MAX_RELEASE_ARCHIVE_BYTES);
await verifyChecksum(tmpArchive, archive);
await download(url, tmpArchive, source.loopback);
await verifyChecksum(tmpArchive, archive, source);
// Validate the complete archive namespace, then extract only executables.
if (ext === 'tar.gz') {
@@ -872,7 +840,6 @@ if (require.main === module || module.parent == null) {
}
module.exports = {
DEFAULT_ARCHIVE_LIMITS,
UNIX_ARCHIVE_NAMES,
WINDOWS_ARCHIVE_NAMES,
WINDOWS_BINARY_NAME,
@@ -880,6 +847,7 @@ module.exports = {
extractExactTarArchive,
extractZipOnWindows,
installWindowsBinaryAtomically,
resolveDownloadBase,
validateExactTarMemberListing,
windowsBinaryReady,
};
+152 -125
View File
@@ -3,6 +3,7 @@
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const { Readable } = require('node:stream');
@@ -10,20 +11,23 @@ const test = require('node:test');
const vm = require('node:vm');
const {
DEFAULT_ARCHIVE_LIMITS,
UNIX_ARCHIVE_NAMES,
WINDOWS_ARCHIVE_NAMES,
WINDOWS_BINARY_NAME,
download,
extractExactTarArchive,
extractZipOnWindows,
resolveDownloadBase,
} = require('../install.js');
const {
TAR_PATH_SKIP, buildZip, patternBytes, writeTarGz,
} = require('./archive-fixtures.js');
const EXECUTABLE = 'codebase-memory-mcp';
// Spans several pipe chunks, so an over-limit member is cut off mid-stream.
// Spans several pipe chunks, so the counted writer sees a multi-chunk stream.
const LARGE_EXECUTABLE = patternBytes(300 * 1024);
// The size cap the 0.12.0 wrappers enforced, which refused every release.
const FORMER_MEMBER_CAP_BYTES = 256 * 1024 * 1024;
function withScratch(callback) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cbm-npm-archive-test-'));
@@ -32,10 +36,6 @@ function withScratch(callback) {
.finally(() => fs.rmSync(root, { recursive: true, force: true }));
}
function limitsWith(overrides) {
return { ...DEFAULT_ARCHIVE_LIMITS, ...overrides };
}
function releaseEntries(names, executableData) {
return names.map((name) => ({
name,
@@ -61,59 +61,6 @@ function fakeTarListing(names) {
};
}
function neverLists() {
throw new Error('the listing must not run');
}
test('tar extraction refuses an archive larger than the compressed limit', { skip: TAR_PATH_SKIP }, () =>
withScratch(async (root) => {
const archive = writeTarGz(root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES));
const destination = makeDestination(root);
await assert.rejects(
extractExactTarArchive(
archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE,
neverLists, limitsWith({ compressedBytes: 16 }),
),
/16-byte compressed safety limit/,
);
assert.deepEqual(fs.readdirSync(destination), []);
}));
test('tar extraction refuses an exact namespace wider than the member limit', { skip: TAR_PATH_SKIP }, () =>
withScratch(async (root) => {
const archive = writeTarGz(root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES));
const destination = makeDestination(root);
await assert.rejects(
extractExactTarArchive(
archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE,
neverLists, limitsWith({ members: 2 }),
),
/2-member safety limit/,
);
assert.deepEqual(fs.readdirSync(destination), []);
}));
test('tar extraction stops an over-limit member in the counted writer and leaves no file', { skip: TAR_PATH_SKIP }, () =>
withScratch(async (root) => {
const archive = writeTarGz(
root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES, LARGE_EXECUTABLE),
);
const destination = makeDestination(root);
// The real listing passes (names only); the limit trips while tar is
// still streaming the executable into the writer.
await assert.rejects(
extractExactTarArchive(
archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE,
undefined, limitsWith({ memberBytes: 64 * 1024 }),
),
/"codebase-memory-mcp" exceeds the 65536-byte expanded safety limit/,
);
assert.deepEqual(fs.readdirSync(destination), []);
}));
test('tar extraction leaves a pre-existing target untouched', { skip: TAR_PATH_SKIP }, () =>
withScratch(async (root) => {
const archive = writeTarGz(root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES));
@@ -181,7 +128,7 @@ test('tar extraction installs the executable byte-identical with the executable
assert.deepEqual(fs.readdirSync(destination), [EXECUTABLE]);
}));
function loadInstallerWithHttps(fakeHttps) {
function loadInstallerWithTransports({ fakeHttps, fakeHttp }) {
const installPath = path.join(__dirname, '..', 'install.js');
const source = fs.readFileSync(installPath, 'utf8');
const sandbox = {
@@ -197,7 +144,8 @@ function loadInstallerWithHttps(fakeHttps) {
process,
require: (specifier) => {
if (specifier === './package.json') return { version: '0.0.0-test' };
if (specifier === 'https') return fakeHttps;
if (specifier === 'https' && fakeHttps) return fakeHttps;
if (specifier === 'http' && fakeHttp) return fakeHttp;
return require(specifier);
},
setTimeout,
@@ -206,10 +154,13 @@ function loadInstallerWithHttps(fakeHttps) {
return sandbox.module.exports;
}
// Answers every request with one HTTP 200 response shaped by `configure`.
function fakeHttpsServing(configure) {
// Answers every request with one HTTP 200 response shaped by `configure`;
// `requests` records each (url, options) the installer issued.
function fakeTransportServing(configure, requests = []) {
return {
get(url, callback) {
get(url, ...rest) {
const callback = rest[rest.length - 1];
requests.push({ url, options: rest.length > 1 ? rest[0] : undefined });
const request = new EventEmitter();
request.destroy = (err) => { if (err) request.emit('error', err); };
setImmediate(() => {
@@ -224,50 +175,146 @@ function fakeHttpsServing(configure) {
};
}
test('download refuses a response whose declared length exceeds the limit', () =>
function refusingTransport() {
return {
get(url) {
throw new Error(`unexpected request to ${url}`);
},
};
}
test('download stores a body whose declared length exceeds the former 256 MiB cap', () =>
withScratch(async (root) => {
const { download } = loadInstallerWithHttps(fakeHttpsServing((response) => {
response.headers['content-length'] = '9';
response.push(null);
}));
const { download: fakeDownload } = loadInstallerWithTransports({
fakeHttps: fakeTransportServing((response) => {
response.headers['content-length'] = String(FORMER_MEMBER_CAP_BYTES + 1);
response.push('payload');
response.push(null);
}),
});
const destination = path.join(root, 'release.tar.gz');
await assert.rejects(
download('https://example.invalid/release.tar.gz', destination, 8),
/8-byte safety limit/,
);
assert.equal(fs.existsSync(destination), false);
}));
test('download refuses a body that exceeds the limit and removes the partial file', () =>
withScratch(async (root) => {
const { download } = loadInstallerWithHttps(fakeHttpsServing((response) => {
response.push(Buffer.alloc(1024, 0x78));
response.push(null);
}));
const destination = path.join(root, 'release.tar.gz');
await assert.rejects(
download('https://example.invalid/release.tar.gz', destination, 8),
/8-byte safety limit/,
);
assert.equal(fs.existsSync(destination), false);
}));
test('download within the limit stores the body', () =>
withScratch(async (root) => {
const { download } = loadInstallerWithHttps(fakeHttpsServing((response) => {
response.headers['content-length'] = '7';
response.push('payload');
response.push(null);
}));
const destination = path.join(root, 'release.tar.gz');
await download('https://example.invalid/release.tar.gz', destination, 1024);
await fakeDownload('https://example.invalid/release.tar.gz', destination);
assert.equal(fs.readFileSync(destination, 'utf8'), 'payload');
}));
test('download refuses plain HTTP unless the base is a loopback override', () =>
withScratch(async (root) => {
const { download: fakeDownload } = loadInstallerWithTransports({
fakeHttps: refusingTransport(),
fakeHttp: refusingTransport(),
});
const destination = path.join(root, 'release.tar.gz');
await assert.rejects(
fakeDownload('http://127.0.0.1/release.tar.gz', destination),
/Refusing non-HTTPS or credentialed URL/,
);
await assert.rejects(
fakeDownload('http://example.invalid/release.tar.gz', destination, true),
/Refusing non-loopback HTTP URL/,
);
assert.equal(fs.existsSync(destination), false);
}));
test('a loopback download uses a fresh agent, so no proxy applies', () =>
withScratch(async (root) => {
const requests = [];
const { download: fakeDownload } = loadInstallerWithTransports({
fakeHttps: refusingTransport(),
fakeHttp: fakeTransportServing((response) => {
response.push('payload');
response.push(null);
}, requests),
});
const destination = path.join(root, 'release.tar.gz');
await fakeDownload('http://[::1]:8080/release.tar.gz', destination, true);
assert.equal(fs.readFileSync(destination, 'utf8'), 'payload');
assert.equal(requests.length, 1);
assert.equal(requests[0].url, 'http://[::1]:8080/release.tar.gz');
assert.equal(requests[0].options.agent, false);
}));
function withLoopbackServer(handler, callback) {
const server = http.createServer(handler);
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve());
}).then(() => Promise.resolve()
.then(() => callback(`http://127.0.0.1:${server.address().port}`))
.finally(() => new Promise((resolve) => server.close(resolve))));
}
test('a loopback download fetches from a local server and refuses its redirects', () =>
withScratch((root) => withLoopbackServer((request, response) => {
if (request.url === '/release/codebase-memory-mcp.tar.gz') {
response.writeHead(200, { 'content-type': 'application/octet-stream' });
response.end('loopback payload');
return;
}
response.writeHead(302, { location: 'https://example.invalid/elsewhere' });
response.end();
}, async (origin) => {
const archive = path.join(root, 'release.tar.gz');
await download(`${origin}/release/codebase-memory-mcp.tar.gz`, archive, true);
assert.equal(fs.readFileSync(archive, 'utf8'), 'loopback payload');
const redirected = path.join(root, 'redirected.tar.gz');
await assert.rejects(
download(`${origin}/release/checksums.txt`, redirected, true),
/Refusing redirect from loopback download/,
);
assert.equal(fs.existsSync(redirected), false);
})));
test('CBM_DOWNLOAD_URL follows install.sh: HTTPS or exact-loopback HTTP only', () => {
const { version } = require('../package.json');
const githubBase = `https://github.com/DeusData/codebase-memory-mcp/releases/download/v${version}`;
assert.deepEqual(resolveDownloadBase({}), { base: githubBase, loopback: false });
assert.deepEqual(
resolveDownloadBase({ CBM_DOWNLOAD_URL: '' }), { base: githubBase, loopback: false },
);
for (const base of [
'https://mirror.example/releases/v1',
'https://127.0.0.1:8443/fixture',
]) {
assert.deepEqual(
resolveDownloadBase({ CBM_DOWNLOAD_URL: base }), { base, loopback: false }, base,
);
}
for (const base of [
'http://localhost',
'http://localhost:8080/fixture',
'http://127.0.0.1:41234/standard',
'http://[::1]:9/x?y#z',
]) {
assert.deepEqual(
resolveDownloadBase({ CBM_DOWNLOAD_URL: base }), { base, loopback: true }, base,
);
}
for (const base of [
'http://example.com/releases',
'http://127.0.0.1.example.com/',
'http://localhost.example.com:8080/',
'http://user@localhost/',
'http://127.0.0.2/',
'HTTP://localhost/',
'file:///tmp/releases',
'ftp://localhost/releases',
'github.com/DeusData/codebase-memory-mcp',
]) {
assert.throws(
() => resolveDownloadBase({ CBM_DOWNLOAD_URL: base }),
/Refusing non-HTTPS download URL \(CBM_DOWNLOAD_URL\)/,
base,
);
}
});
// The Windows path runs a constant PowerShell program through powershell.exe,
// which only a Windows host provides (pwsh is not a wrapper dependency, and
// no PowerShell is installed on the Unix wrapper hosts), so these cases run
@@ -288,33 +335,14 @@ test('Windows zip extraction refuses an archive with more members than the limit
assert.throws(
() => extractZipOnWindows(
archive, destination, ['one', 'two', 'three'], ['one'],
limitsWith({ members: 2 }),
archive, destination, ['one', 'two', 'three'], ['one'], 2,
),
/2-member safety limit/,
);
assert.deepEqual(fs.readdirSync(destination), []);
}));
test('Windows zip extraction refuses a member whose declared size exceeds the limit', { skip: WINDOWS_ONLY }, () =>
withScratch(async (root) => {
const archive = path.join(root, 'member.zip');
fs.writeFileSync(archive, buildZip([
{ name: WINDOWS_BINARY_NAME, data: Buffer.alloc(65, 0x78) },
]));
const destination = makeDestination(root);
assert.throws(
() => extractZipOnWindows(
archive, destination, [WINDOWS_BINARY_NAME], [WINDOWS_BINARY_NAME],
limitsWith({ memberBytes: 64 }),
),
/64-byte expanded safety limit/,
);
assert.deepEqual(fs.readdirSync(destination), []);
}));
test('Windows zip extraction stops a member that yields more bytes than it declares', { skip: WINDOWS_ONLY }, () =>
test('Windows zip extraction rejects a member that yields more bytes than it declares', { skip: WINDOWS_ONLY }, () =>
withScratch(async (root) => {
const archive = path.join(root, 'longer.zip');
fs.writeFileSync(archive, buildZip([
@@ -325,14 +353,13 @@ test('Windows zip extraction stops a member that yields more bytes than it decla
assert.throws(
() => extractZipOnWindows(
archive, destination, [WINDOWS_BINARY_NAME], [WINDOWS_BINARY_NAME],
limitsWith({ memberBytes: 64 }),
),
/64-byte actual expanded safety limit/,
/actual size \d+ does not match declared size 10/,
);
assert.deepEqual(fs.readdirSync(destination), []);
}));
test('Windows zip extraction within the limits installs only the executable', { skip: WINDOWS_ONLY }, () =>
test('Windows zip extraction installs only the executable', { skip: WINDOWS_ONLY }, () =>
withScratch(async (root) => {
const archive = path.join(root, 'release.zip');
fs.writeFileSync(archive, buildZip(releaseEntries(WINDOWS_ARCHIVE_NAMES)));
+56
View File
@@ -2,6 +2,7 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
@@ -51,3 +52,58 @@ test('postinstall exits nonzero when installation fails', async () => {
'npm postinstall reported success after a deterministic installation failure',
);
});
test('postinstall refuses a non-loopback http CBM_DOWNLOAD_URL before any download', async () => {
const installPath = path.join(__dirname, '..', 'install.js');
const source = fs.readFileSync(installPath, 'utf8');
// A fresh package directory: no cached binary, and nothing may be created.
const packageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cbm-npm-override-test-'));
const output = { stdout: '', stderr: '' };
const observed = { exitCode: undefined };
const fakeProcess = {
platform: 'linux',
arch: 'x64',
env: { CBM_DOWNLOAD_URL: 'http://example.com/releases' },
pid: 12345,
stdout: { write: (text) => { output.stdout += String(text); } },
stderr: { write: (text) => { output.stderr += String(text); } },
exit: (code) => { observed.exitCode = code; },
exitCode: undefined,
};
const refusingTransport = {
get(url) { throw new Error(`unexpected request to ${url}`); },
};
try {
const sandbox = {
Buffer,
URL,
__dirname: packageDir,
__filename: path.join(packageDir, 'install.js'),
clearTimeout,
console,
exports: {},
module: { exports: {} },
process: fakeProcess,
require: (specifier) => {
if (specifier === './package.json') return { version: '0.0.0-test' };
if (specifier === 'https' || specifier === 'http') return refusingTransport;
return require(specifier);
},
setTimeout,
};
vm.runInNewContext(source, sandbox, { filename: installPath });
await new Promise((resolve) => setImmediate(resolve));
assert.match(
output.stderr,
/install failed .*Refusing non-HTTPS download URL \(CBM_DOWNLOAD_URL\): http:\/\/example\.com\/releases/,
);
assert.equal(observed.exitCode, 1);
assert.doesNotMatch(output.stdout, /downloading/);
assert.deepEqual(fs.readdirSync(packageDir), []);
} finally {
fs.rmSync(packageDir, { recursive: true, force: true });
}
});
+2
View File
@@ -16,6 +16,8 @@ pipx install codebase-memory-mcp
There is one composition per platform: the graph UI ships in every build, so no variant selection is needed.
For testing, `CBM_DOWNLOAD_URL` overrides the download base URL (default `https://github.com/DeusData/codebase-memory-mcp/releases/download/v<version>`): the archive and `checksums.txt` are fetched from `<CBM_DOWNLOAD_URL>/<file>`. It must be `https://`, or plain `http://` to exactly `localhost`, `127.0.0.1`, or `[::1]` (optional port; no proxy, no redirects).
## Usage
```bash
+97 -147
View File
@@ -1,6 +1,5 @@
"""Downloads codebase-memory-mcp on first run, then runs its native entry point."""
import collections
import errno
import hashlib
import json
@@ -39,26 +38,19 @@ _WINDOWS_ARCHIVE_NAMES = (
# file://, ftp://, and custom schemes — a redirect or tainted URL source
# could otherwise turn a download into an arbitrary-local-file read.
_ALLOWED_SCHEMES = frozenset({"https"})
# install.sh's loopback pattern: plain HTTP only to an exact loopback
# authority, for local smoke tests (CBM_DOWNLOAD_URL).
_LOOPBACK_HTTP_URL = re.compile(
r"http://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?([/?#].*)?"
)
_LOOPBACK_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "::1"})
_MAX_REDIRECTS = 5
_NETWORK_TIMEOUT_SECONDS = 120
_CANDIDATE_TIMEOUT_SECONDS = 15
_MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024
# Release archive resource limits. Keep in sync with the Go wrapper
# (pkg/go/cmd/codebase-memory-mcp/main.go) and the npm wrapper (install.js).
_MAX_RELEASE_ARCHIVE_BYTES = 256 * 1024 * 1024
# Entry-count guard for the archive namespace check. Keep in sync with the
# Go wrapper (pkg/go/cmd/codebase-memory-mcp/main.go) and the npm wrapper.
_MAX_ARCHIVE_MEMBERS = 64
_MAX_ARCHIVE_MEMBER_BYTES = 256 * 1024 * 1024
_MAX_ARCHIVE_EXPANDED_BYTES = 512 * 1024 * 1024
_ARCHIVE_COPY_CHUNK_BYTES = 64 * 1024
_ArchiveLimits = collections.namedtuple(
"_ArchiveLimits", "compressed_bytes members member_bytes expanded_bytes"
)
_DEFAULT_ARCHIVE_LIMITS = _ArchiveLimits(
compressed_bytes=_MAX_RELEASE_ARCHIVE_BYTES,
members=_MAX_ARCHIVE_MEMBERS,
member_bytes=_MAX_ARCHIVE_MEMBER_BYTES,
expanded_bytes=_MAX_ARCHIVE_EXPANDED_BYTES,
)
_REDIRECT_CODES = frozenset({301, 302, 303, 307, 308})
_RUNTIME_LOCK_NAME = ".codebase-memory-mcp-runtime.lock"
_RUNTIME_LOCK_WAIT_SECONDS = 45
@@ -79,13 +71,50 @@ _runtime_lock_claim_observer = None
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Expose redirects to _download_https for explicit per-hop validation."""
"""Expose redirects to _download_file for explicit per-hop validation."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
_HTTPS_OPENER = urllib.request.build_opener(_NoRedirectHandler())
# A loopback fetch never goes through a proxy.
_LOOPBACK_OPENER = urllib.request.build_opener(
urllib.request.ProxyHandler({}), _NoRedirectHandler()
)
def _download_base(version: str, environ=None):
"""Return (base, loopback) for release downloads.
CBM_DOWNLOAD_URL replaces the release download base (for testing), with
install.sh's policy: HTTPS, or plain HTTP to an exact loopback authority.
"""
env = os.environ if environ is None else environ
override = env.get("CBM_DOWNLOAD_URL")
if not override:
return f"https://github.com/{REPO}/releases/download/v{version}", False
if override.startswith("https://"):
return override, False
if _LOOPBACK_HTTP_URL.fullmatch(override):
return override, True
sys.exit(
f"codebase-memory-mcp: refusing non-HTTPS download URL "
f"(CBM_DOWNLOAD_URL): {override}"
)
def _validate_loopback_url(url: str) -> None:
"""Reject anything but plain HTTP to an exact loopback authority."""
parsed = urllib.parse.urlparse(url)
if (
not _LOOPBACK_HTTP_URL.fullmatch(url)
or parsed.scheme != "http"
or parsed.hostname not in _LOOPBACK_HOSTNAMES
or parsed.username is not None
or parsed.password is not None
):
sys.exit(f"codebase-memory-mcp: refusing non-loopback HTTP URL: {url}")
def _validate_url_scheme(url: str) -> None:
@@ -103,23 +132,33 @@ def _validate_url_scheme(url: str) -> None:
)
def _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
"""Download through a bounded HTTPS-only opener into dest."""
def _validate_download_url(url: str, loopback: bool) -> None:
if loopback:
_validate_loopback_url(url)
else:
_validate_url_scheme(url)
def _download_file(url: str, dest: str, loopback: bool = False) -> None:
"""Download into dest: HTTPS with checked redirects, or one loopback hop."""
opener = _LOOPBACK_OPENER if loopback else _HTTPS_OPENER
current_url = url
for redirect_count in range(_MAX_REDIRECTS + 1):
_validate_url_scheme(current_url)
_validate_download_url(current_url, loopback)
request = urllib.request.Request(
current_url, headers={"User-Agent": "codebase-memory-mcp-installer"}
)
try:
response = _HTTPS_OPENER.open(
request, timeout=_NETWORK_TIMEOUT_SECONDS
)
response = opener.open(request, timeout=_NETWORK_TIMEOUT_SECONDS)
except urllib.error.HTTPError as exc:
if exc.code not in _REDIRECT_CODES:
raise
location = exc.headers.get("Location")
exc.close()
if loopback:
raise RuntimeError(
f"refusing redirect from loopback download: {current_url}"
)
if not location:
raise RuntimeError(f"redirect has no Location: {current_url}")
if redirect_count == _MAX_REDIRECTS:
@@ -129,18 +168,8 @@ def _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
continue
with response:
_validate_url_scheme(response.geturl())
declared = (response.headers.get("Content-Length") or "").strip()
# Only plain ASCII digits are a length (str.isdigit also accepts
# characters that int() rejects); anything else is left to the
# byte counter below.
if max_bytes and re.fullmatch(r"[0-9]+", declared):
if int(declared) > max_bytes:
raise RuntimeError(
f"download exceeds the {max_bytes}-byte safety limit"
)
_validate_download_url(response.geturl(), loopback)
deadline = time.monotonic() + _NETWORK_TIMEOUT_SECONDS
total = 0
try:
with open(dest, "wb") as out:
while True:
@@ -151,12 +180,6 @@ def _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
chunk = response.read(65536)
if not chunk:
return
total += len(chunk)
if max_bytes and total > max_bytes:
raise RuntimeError(
f"download exceeds the {max_bytes}-byte "
f"safety limit"
)
out.write(chunk)
except BaseException:
# Never leave a partial download behind for a later step to
@@ -236,106 +259,51 @@ def _validate_archive_names(names, archive_names, casefold: bool = False):
)
def _validate_archive_limits(limits) -> None:
if (
limits.compressed_bytes <= 0
or limits.members <= 0
or limits.member_bytes <= 0
or limits.expanded_bytes <= 0
):
sys.exit("codebase-memory-mcp: invalid archive resource safety limits")
def _require_compressed_archive_within_limit(archive_path: str, limits) -> None:
status = os.stat(archive_path)
if not stat.S_ISREG(status.st_mode):
def _require_regular_archive(archive_path: str) -> None:
if not stat.S_ISREG(os.stat(archive_path).st_mode):
sys.exit("codebase-memory-mcp: release archive is not a regular file")
if status.st_size > limits.compressed_bytes:
sys.exit(
f"codebase-memory-mcp: release archive exceeds the "
f"{limits.compressed_bytes}-byte compressed safety limit"
)
def _require_member_count_within_limit(count: int, limits) -> None:
if count > limits.members:
def _require_member_count_within_limit(count: int, max_members: int) -> None:
if count > max_members:
sys.exit(
f"codebase-memory-mcp: archive exceeds the {limits.members}-member "
f"codebase-memory-mcp: archive exceeds the {max_members}-member "
f"safety limit"
)
def _account_declared_archive_member(
name: str, size: int, declared_expanded: int, limits
) -> int:
"""Check one member's declared size; return the new declared total."""
def _require_declared_size(name: str, size: int) -> None:
if size < 0:
sys.exit(
f"codebase-memory-mcp: archive member has a negative declared "
f"size: {name!r}"
)
if size > limits.member_bytes:
sys.exit(
f"codebase-memory-mcp: archive member {name!r} exceeds the "
f"{limits.member_bytes}-byte expanded safety limit"
)
if declared_expanded > limits.expanded_bytes - size:
sys.exit(
f"codebase-memory-mcp: archive exceeds the {limits.expanded_bytes}-byte "
f"aggregate expanded safety limit"
)
return declared_expanded + size
def _copy_archive_member_within_limits(
source, output, name: str, declared_size: int, actual_expanded: int, limits
) -> int:
"""Copy a member while counting bytes; return the new actual total.
def _copy_archive_member(source, output, name: str, declared_size: int) -> int:
"""Copy a member, then require the declared size; return the bytes copied.
Reads stop one byte past the smaller of the member and remaining aggregate
limits, so a member that yields more than it declares can never fill the
disk. The caller removes the target on failure.
The caller removes the target on failure.
"""
remaining = limits.expanded_bytes - actual_expanded
copy_limit = min(limits.member_bytes, remaining)
copied = 0
while True:
wanted = min(_ARCHIVE_COPY_CHUNK_BYTES, copy_limit + 1 - copied)
if wanted <= 0:
break
chunk = source.read(wanted)
chunk = source.read(_ARCHIVE_COPY_CHUNK_BYTES)
if not chunk:
break
copied += len(chunk)
if copied > copy_limit:
break
output.write(chunk)
if copied > limits.member_bytes:
sys.exit(
f"codebase-memory-mcp: archive member {name!r} exceeds the "
f"{limits.member_bytes}-byte actual expanded safety limit"
)
if copied > remaining:
sys.exit(
f"codebase-memory-mcp: archive exceeds the {limits.expanded_bytes}-byte "
f"aggregate actual expanded safety limit"
)
if copied != declared_size:
sys.exit(
f"codebase-memory-mcp: archive member {name!r} actual size {copied} "
f"does not match declared size {declared_size}"
)
return actual_expanded + copied
return copied
def _write_archive_member_within_limits(
source, target: str, name: str, declared_size: int, actual_expanded: int, limits
) -> int:
def _write_archive_member(source, target: str, name: str, declared_size: int) -> int:
try:
with open(target, "xb") as output:
return _copy_archive_member_within_limits(
source, output, name, declared_size, actual_expanded, limits
)
return _copy_archive_member(source, output, name, declared_size)
except BaseException:
_remove_quietly(target)
raise
@@ -347,36 +315,29 @@ def _safe_extract_tar(
archive_names=(),
extract_names=(),
ui: bool = False,
limits=_DEFAULT_ARCHIVE_LIMITS,
max_members: int = _MAX_ARCHIVE_MEMBERS,
):
"""Validate a tar namespace, then extract only its authenticated runtime set."""
_validate_archive_limits(limits)
members = tf.getmembers()
_require_member_count_within_limit(len(members), limits)
declared_expanded = 0
_require_member_count_within_limit(len(members), max_members)
for member in members:
if not member.isfile():
sys.exit(
f"codebase-memory-mcp: refusing unsafe tar entry "
f"(not a regular root file: {member.name!r})"
)
declared_expanded = _account_declared_archive_member(
member.name, member.size, declared_expanded, limits
)
_require_declared_size(member.name, member.size)
_validate_archive_names([member.name for member in members], archive_names)
runtime_names = list(extract_names)
by_name = {member.name: member for member in members}
dest_abs = os.path.abspath(dest)
actual_expanded = 0
for name in runtime_names:
source = tf.extractfile(by_name[name])
if source is None:
sys.exit(f"codebase-memory-mcp: could not read archive member: {name}")
target = os.path.join(dest_abs, name)
with source:
actual_expanded = _write_archive_member_within_limits(
source, target, name, by_name[name].size, actual_expanded, limits
)
_write_archive_member(source, target, name, by_name[name].size)
return tuple(runtime_names)
@@ -386,7 +347,7 @@ def _safe_extract_zip(
archive_names=(),
extract_names=(),
ui: bool = False,
limits=_DEFAULT_ARCHIVE_LIMITS,
max_members: int = _MAX_ARCHIVE_MEMBERS,
):
"""Extract a zipfile after validating its Windows-style namespace.
@@ -394,16 +355,12 @@ def _safe_extract_zip(
members before extraction prevents archive order from selecting the binary
that a portable package-manager shim eventually executes.
"""
_validate_archive_limits(limits)
dest_abs = os.path.abspath(dest)
names = []
infos = zf.infolist()
_require_member_count_within_limit(len(infos), limits)
declared_expanded = 0
_require_member_count_within_limit(len(infos), max_members)
for info in infos:
declared_expanded = _account_declared_archive_member(
info.filename, info.file_size, declared_expanded, limits
)
_require_declared_size(info.filename, info.file_size)
raw_name = info.filename
name = raw_name.replace("\\", "/")
segments_name = name[:-1] if name.endswith("/") else name
@@ -437,30 +394,24 @@ def _safe_extract_zip(
# Extract only the validated runtime files. This avoids relying on
# platform-specific zip path rewriting and always creates regular files.
runtime_names = list(extract_names)
actual_expanded = 0
for name in runtime_names:
target = os.path.join(dest_abs, name)
with zf.open(name) as source:
actual_expanded = _write_archive_member_within_limits(
source,
target,
name,
zf.getinfo(name).file_size,
actual_expanded,
limits,
)
_write_archive_member(source, target, name, zf.getinfo(name).file_size)
return tuple(runtime_names)
def _verify_checksum(archive_path: str, archive_name: str, version: str) -> None:
"""Verify SHA256 checksum against checksums.txt from the release."""
url = f"https://github.com/{REPO}/releases/download/v{version}/checksums.txt"
def _verify_checksum(
archive_path: str, archive_name: str, base: str, loopback: bool = False
) -> None:
"""Verify SHA256 checksum against checksums.txt from the same base."""
url = f"{base}/checksums.txt"
tmp_path = None
try:
_validate_url_scheme(url)
_validate_download_url(url, loopback)
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
tmp_path = tmp.name
_download_https(url, tmp_path, _MAX_CHECKSUM_MANIFEST_BYTES)
_download_file(url, tmp_path, loopback)
expected = None
with open(tmp_path, encoding="utf-8") as f:
for line in f:
@@ -1344,8 +1295,9 @@ def _download(version: str) -> Path:
# have no such variant. Keep in sync with install.sh / install.js / cli.c.
portable = "-portable" if os_name == "linux" else ""
archive = f"codebase-memory-mcp-{os_name}-{arch}{portable}.{ext}"
url = f"https://github.com/{REPO}/releases/download/v{version}/{archive}"
_validate_url_scheme(url)
base, loopback = _download_base(version)
url = f"{base}/{archive}"
_validate_download_url(url, loopback)
dest = _bin_path(version)
dest.parent.mkdir(parents=True, exist_ok=True)
@@ -1358,7 +1310,7 @@ def _download(version: str) -> Path:
with tempfile.TemporaryDirectory() as tmp:
tmp_archive = os.path.join(tmp, f"cbm.{ext}")
try:
_download_https(url, tmp_archive, _MAX_RELEASE_ARCHIVE_BYTES)
_download_file(url, tmp_archive, loopback)
except (OSError, RuntimeError, urllib.error.URLError) as e:
sys.exit(
f"codebase-memory-mcp: download failed ({e})\n"
@@ -1366,10 +1318,8 @@ def _download(version: str) -> Path:
f"See https://github.com/{REPO}/releases for available versions."
)
_verify_checksum(tmp_archive, archive, version)
_require_compressed_archive_within_limit(
tmp_archive, _DEFAULT_ARCHIVE_LIMITS
)
_verify_checksum(tmp_archive, archive, base, loopback)
_require_regular_archive(tmp_archive)
bin_name = (
_WINDOWS_BINARY_NAME if os_name == "windows" else "codebase-memory-mcp"
+220 -153
View File
@@ -1,5 +1,7 @@
import contextlib
import errno
import hashlib
import http.server
import io
import json
import os
@@ -840,9 +842,10 @@ _cli._publish_runtime_set(
class _FakeHttpsResponse:
"""Minimal stand-in for the opener's response: headers, geturl, read."""
def __init__(self, body: bytes, headers=None):
def __init__(self, body: bytes, headers=None, fail_after_first_chunk=False):
self._body = body
self._offset = 0
self._fail_after_first_chunk = fail_after_first_chunk
self.headers = dict(headers or {})
def __enter__(self):
@@ -855,6 +858,8 @@ class _FakeHttpsResponse:
return "https://example.invalid/release.tar.gz"
def read(self, size=-1):
if self._fail_after_first_chunk and self._offset:
raise OSError("connection reset")
if size is None or size < 0:
size = len(self._body) - self._offset
chunk = self._body[self._offset : self._offset + size]
@@ -862,8 +867,31 @@ class _FakeHttpsResponse:
return chunk
def _limits_with(**overrides):
return _cli._DEFAULT_ARCHIVE_LIMITS._replace(**overrides)
class _ZeroSource:
"""Reader that yields `size` zero bytes without holding them."""
def __init__(self, size: int):
self._remaining = size
def read(self, size=-1):
if size is None or size < 0:
size = self._remaining
count = min(size, self._remaining)
self._remaining -= count
return bytes(count)
class _CountingSink:
def __init__(self):
self.written = 0
def write(self, chunk):
self.written += len(chunk)
return len(chunk)
# The size cap the 0.12.0 wrappers enforced, which refused every release.
_FORMER_MEMBER_CAP_BYTES = 256 * 1024 * 1024
def _write_tar_gz(archive, entries):
@@ -884,59 +912,33 @@ def _release_entries(names):
return [(name, f"payload of {name}".encode()) for name in names]
class ArchiveLimitTests(unittest.TestCase):
class ArchiveExtractionTests(unittest.TestCase):
URL = "https://example.invalid/release.tar.gz"
def _download(self, response, destination, max_bytes):
def _download(self, response, destination):
with mock.patch.object(_cli._HTTPS_OPENER, "open", return_value=response):
_cli._download_https(self.URL, destination, max_bytes)
_cli._download_file(self.URL, destination)
def test_download_rejects_a_declared_length_over_the_limit(self):
with tempfile.TemporaryDirectory() as root:
destination = os.path.join(root, "release.tar.gz")
response = _FakeHttpsResponse(b"", {"Content-Length": "9"})
with self.assertRaises(RuntimeError) as raised:
self._download(response, destination, 8)
self.assertIn("8-byte safety limit", str(raised.exception))
self.assertFalse(os.path.exists(destination))
def test_download_rejects_a_body_over_the_limit_and_removes_the_partial_file(
def test_download_stores_a_body_whose_declared_length_exceeds_the_former_cap(
self,
):
with tempfile.TemporaryDirectory() as root:
destination = os.path.join(root, "release.tar.gz")
response = _FakeHttpsResponse(b"x" * 1024)
with self.assertRaises(RuntimeError) as raised:
self._download(response, destination, 8)
self.assertIn("8-byte safety limit", str(raised.exception))
response = _FakeHttpsResponse(
b"payload", {"Content-Length": str(_FORMER_MEMBER_CAP_BYTES + 1)}
)
self._download(response, destination)
with open(destination, "rb") as stored:
self.assertEqual(stored.read(), b"payload")
def test_download_removes_the_partial_file_when_the_body_fails(self):
with tempfile.TemporaryDirectory() as root:
destination = os.path.join(root, "release.tar.gz")
response = _FakeHttpsResponse(b"x" * 200000, fail_after_first_chunk=True)
with self.assertRaises(OSError):
self._download(response, destination)
self.assertFalse(os.path.exists(destination))
def test_download_within_the_limit_stores_the_body(self):
with tempfile.TemporaryDirectory() as root:
destination = os.path.join(root, "release.tar.gz")
response = _FakeHttpsResponse(b"payload", {"Content-Length": "7"})
self._download(response, destination, 1024)
with open(destination, "rb") as stored:
self.assertEqual(stored.read(), b"payload")
def test_download_counts_bytes_when_the_declared_length_is_not_a_number(self):
# A superscript two passes str.isdigit but not int(); such values and
# plain junk must fall through to the byte counter, never crash.
for declared in ("²", "abc", "", "1e3", "-1"):
with self.subTest(declared=declared), tempfile.TemporaryDirectory() as root:
destination = os.path.join(root, "release.tar.gz")
response = _FakeHttpsResponse(b"x" * 1024, {"Content-Length": declared})
with self.assertRaises(RuntimeError) as raised:
self._download(response, destination, 8)
self.assertIn("8-byte safety limit", str(raised.exception))
self.assertFalse(os.path.exists(destination))
with tempfile.TemporaryDirectory() as root:
destination = os.path.join(root, "release.tar.gz")
response = _FakeHttpsResponse(b"payload", {"Content-Length": "²"})
self._download(response, destination, 1024)
with open(destination, "rb") as stored:
self.assertEqual(stored.read(), b"payload")
def test_unix_tar_rejects_more_members_than_the_limit(self):
with tempfile.TemporaryDirectory() as root:
archive = Path(root) / "members.tar.gz"
@@ -949,50 +951,12 @@ class ArchiveLimitTests(unittest.TestCase):
str(destination),
("one", "two", "three"),
("one",),
limits=_limits_with(members=2),
max_members=2,
)
self.assertIn("2-member safety limit", str(raised.exception))
self.assertFalse((destination / "one").exists())
def test_unix_tar_rejects_a_member_declared_over_the_limit(self):
with tempfile.TemporaryDirectory() as root:
archive = Path(root) / "member.tar.gz"
_write_tar_gz(archive, [("codebase-memory-mcp", b"x" * 65)])
destination = Path(root) / "extract"
destination.mkdir()
with tarfile.open(archive) as tf, self.assertRaises(SystemExit) as raised:
_cli._safe_extract_tar(
tf,
str(destination),
("codebase-memory-mcp",),
("codebase-memory-mcp",),
limits=_limits_with(member_bytes=64),
)
self.assertIn(
"'codebase-memory-mcp' exceeds the 64-byte expanded safety limit",
str(raised.exception),
)
self.assertFalse((destination / "codebase-memory-mcp").exists())
def test_unix_tar_rejects_a_declared_aggregate_over_the_limit(self):
with tempfile.TemporaryDirectory() as root:
archive = Path(root) / "aggregate.tar.gz"
_write_tar_gz(archive, [("one", b"a" * 40), ("two", b"b" * 40)])
destination = Path(root) / "extract"
destination.mkdir()
with tarfile.open(archive) as tf, self.assertRaises(SystemExit) as raised:
_cli._safe_extract_tar(
tf,
str(destination),
("one", "two"),
("one",),
limits=_limits_with(member_bytes=64, expanded_bytes=64),
)
self.assertIn(
"64-byte aggregate expanded safety limit", str(raised.exception)
)
def test_unix_tar_within_the_limits_extracts_only_the_executable(self):
def test_unix_tar_extracts_only_the_executable(self):
with tempfile.TemporaryDirectory() as root:
archive = Path(root) / "release.tar.gz"
_write_tar_gz(archive, _release_entries(_cli._UNIX_ARCHIVE_NAMES))
@@ -1012,67 +976,29 @@ class ArchiveLimitTests(unittest.TestCase):
)
self.assertEqual(os.listdir(destination), ["codebase-memory-mcp"])
def test_archive_copy_stops_a_member_that_yields_more_than_the_limit(self):
source = io.BytesIO(b"x" * 100)
output = io.BytesIO()
with self.assertRaises(SystemExit) as raised:
_cli._copy_archive_member_within_limits(
source,
output,
"member",
100,
0,
_limits_with(member_bytes=64, expanded_bytes=1024),
)
self.assertIn(
"'member' exceeds the 64-byte actual expanded safety limit",
str(raised.exception),
def test_archive_copy_accepts_a_member_larger_than_the_former_cap(self):
size = _FORMER_MEMBER_CAP_BYTES + 1024 * 1024
sink = _CountingSink()
copied = _cli._copy_archive_member(
_ZeroSource(size), sink, "codebase-memory-mcp", size
)
# Reading stopped one byte past the limit; nothing beyond it was kept.
self.assertEqual(source.tell(), 65)
self.assertLessEqual(len(output.getvalue()), 64)
self.assertEqual(copied, size)
self.assertEqual(sink.written, size)
def test_archive_copy_rejects_a_member_that_yields_more_than_it_declares(self):
source = io.BytesIO(b"x" * 100)
with self.assertRaises(SystemExit) as raised:
_cli._copy_archive_member_within_limits(
source,
io.BytesIO(),
"member",
10,
0,
_limits_with(member_bytes=1024, expanded_bytes=1024),
)
_cli._copy_archive_member(io.BytesIO(b"x" * 100), io.BytesIO(), "member", 10)
self.assertIn(
"'member' actual size 100 does not match declared size 10",
str(raised.exception),
)
def test_archive_copy_rejects_an_aggregate_over_the_actual_limit(self):
with self.assertRaises(SystemExit) as raised:
_cli._copy_archive_member_within_limits(
io.BytesIO(b"abcd"),
io.BytesIO(),
"member",
4,
3,
_limits_with(member_bytes=10, expanded_bytes=5),
)
self.assertIn(
"5-byte aggregate actual expanded safety limit", str(raised.exception)
)
def test_archive_member_write_removes_the_partial_file_on_failure(self):
with tempfile.TemporaryDirectory() as root:
target = os.path.join(root, "codebase-memory-mcp")
with self.assertRaises(SystemExit):
_cli._write_archive_member_within_limits(
io.BytesIO(b"x" * 100),
target,
"codebase-memory-mcp",
10,
0,
_limits_with(member_bytes=1024, expanded_bytes=1024),
_cli._write_archive_member(
io.BytesIO(b"x" * 100), target, "codebase-memory-mcp", 10
)
self.assertFalse(os.path.exists(target))
@@ -1088,28 +1014,11 @@ class ArchiveLimitTests(unittest.TestCase):
str(destination),
("one", "two", "three"),
("one",),
limits=_limits_with(members=2),
max_members=2,
)
self.assertIn("2-member safety limit", str(raised.exception))
def test_windows_zip_rejects_a_member_declared_over_the_limit(self):
with tempfile.TemporaryDirectory() as root:
archive = Path(root) / "member.zip"
_write_zip(archive, [(_cli._WINDOWS_BINARY_NAME, b"x" * 65)])
destination = Path(root) / "extract"
destination.mkdir()
with zipfile.ZipFile(archive) as zf, self.assertRaises(SystemExit) as raised:
_cli._safe_extract_zip(
zf,
str(destination),
(_cli._WINDOWS_BINARY_NAME,),
(_cli._WINDOWS_BINARY_NAME,),
limits=_limits_with(member_bytes=64),
)
self.assertIn("64-byte expanded safety limit", str(raised.exception))
self.assertFalse((destination / _cli._WINDOWS_BINARY_NAME).exists())
def test_windows_zip_within_the_limits_extracts_only_the_executable(self):
def test_windows_zip_extracts_only_the_executable(self):
with tempfile.TemporaryDirectory() as root:
archive = Path(root) / "release.zip"
_write_zip(archive, _release_entries(_cli._WINDOWS_ARCHIVE_NAMES))
@@ -1129,3 +1038,161 @@ class ArchiveLimitTests(unittest.TestCase):
)
self.assertEqual(os.listdir(destination), [_cli._WINDOWS_BINARY_NAME])
@contextlib.contextmanager
def _loopback_server(routes):
"""Serve `routes` ({path: bytes}) on 127.0.0.1; any other path redirects."""
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
body = routes.get(self.path)
if body is None:
self.send_response(302)
self.send_header("Location", "https://example.invalid/elsewhere")
self.send_header("Content-Length", "0")
self.end_headers()
return
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}"
finally:
server.shutdown()
server.server_close()
thread.join()
class DownloadBaseTests(unittest.TestCase):
GITHUB_BASE = "https://github.com/DeusData/codebase-memory-mcp/releases/download/v1.2.3"
def test_unset_or_empty_uses_the_github_release_base(self):
self.assertEqual(_cli._download_base("1.2.3", {}), (self.GITHUB_BASE, False))
self.assertEqual(
_cli._download_base("1.2.3", {"CBM_DOWNLOAD_URL": ""}),
(self.GITHUB_BASE, False),
)
def test_https_override_is_accepted(self):
for base in (
"https://mirror.example/releases/v1",
"https://127.0.0.1:8443/fixture",
):
with self.subTest(base=base):
self.assertEqual(
_cli._download_base("1.2.3", {"CBM_DOWNLOAD_URL": base}),
(base, False),
)
def test_exact_loopback_http_override_is_accepted(self):
for base in (
"http://localhost",
"http://localhost:8080/fixture",
"http://127.0.0.1:41234/standard",
"http://[::1]:9/x?y#z",
):
with self.subTest(base=base):
self.assertEqual(
_cli._download_base("1.2.3", {"CBM_DOWNLOAD_URL": base}),
(base, True),
)
def test_non_loopback_http_and_other_schemes_are_refused(self):
for base in (
"http://example.com/releases",
"http://127.0.0.1.example.com/",
"http://localhost.example.com:8080/",
"http://user@localhost/",
"http://127.0.0.2/",
"HTTP://localhost/",
"file:///tmp/releases",
"ftp://localhost/releases",
"github.com/DeusData/codebase-memory-mcp",
):
with self.subTest(base=base), self.assertRaises(SystemExit) as raised:
_cli._download_base("1.2.3", {"CBM_DOWNLOAD_URL": base})
self.assertIn(
"refusing non-HTTPS download URL (CBM_DOWNLOAD_URL)",
str(raised.exception),
)
def test_https_download_refuses_plain_http(self):
with tempfile.TemporaryDirectory() as root:
with self.assertRaises(SystemExit):
_cli._download_file(
"http://127.0.0.1/release.tar.gz", os.path.join(root, "a")
)
def test_loopback_download_refuses_a_non_loopback_url(self):
with tempfile.TemporaryDirectory() as root:
with self.assertRaises(SystemExit) as raised:
_cli._download_file(
"http://example.invalid/release.tar.gz",
os.path.join(root, "a"),
loopback=True,
)
self.assertIn("refusing non-loopback HTTP URL", str(raised.exception))
def test_loopback_download_bypasses_proxies_and_refuses_redirects(self):
routes = {"/fixture/archive": b"loopback payload"}
# A configured proxy that is not listening: using it would fail.
proxy_env = {"http_proxy": "http://127.0.0.1:9", "HTTP_PROXY": "http://127.0.0.1:9"}
with _loopback_server(routes) as origin, tempfile.TemporaryDirectory() as root, \
mock.patch.dict(os.environ, proxy_env):
archive = os.path.join(root, "archive")
_cli._download_file(f"{origin}/fixture/archive", archive, loopback=True)
with open(archive, "rb") as stored:
self.assertEqual(stored.read(), b"loopback payload")
redirected = os.path.join(root, "redirected")
with self.assertRaises(RuntimeError) as raised:
_cli._download_file(
f"{origin}/fixture/checksums.txt", redirected, loopback=True
)
self.assertIn("refusing redirect from loopback download", str(raised.exception))
self.assertFalse(os.path.exists(redirected))
def test_download_fetches_archive_and_checksums_from_the_override(self):
os_name = _cli._os_name()
arch = _cli._arch()
if os_name == "windows":
ext, names = "zip", _cli._WINDOWS_ARCHIVE_NAMES
else:
ext, names = "tar.gz", _cli._UNIX_ARCHIVE_NAMES
portable = "-portable" if os_name == "linux" else ""
archive_name = f"codebase-memory-mcp-{os_name}-{arch}{portable}.{ext}"
with tempfile.TemporaryDirectory() as root:
fixture = Path(root) / archive_name
if ext == "zip":
_write_zip(fixture, _release_entries(names))
else:
_write_tar_gz(fixture, _release_entries(names))
archive_bytes = fixture.read_bytes()
digest = hashlib.sha256(archive_bytes).hexdigest()
routes = {
f"/fixture/{archive_name}": archive_bytes,
"/fixture/checksums.txt": f"{digest} {archive_name}\n".encode(),
}
cache = Path(root) / "cache"
with _loopback_server(routes) as origin, mock.patch.dict(
os.environ, {"CBM_DOWNLOAD_URL": f"{origin}/fixture"}
), mock.patch.object(_cli, "_cache_dir", return_value=cache), \
mock.patch.object(_cli, "_verify_candidate"), \
contextlib.redirect_stderr(io.StringIO()):
installed = _cli._download("9.9.9")
binary_name = (
_cli._WINDOWS_BINARY_NAME
if os_name == "windows"
else "codebase-memory-mcp"
)
self.assertEqual(
installed.read_bytes(), f"payload of {binary_name}".encode()
)
+1 -1
View File
@@ -128,7 +128,7 @@ SMOKE = {"scripts/smoke-test.sh", "scripts/smoke-local.sh",
"scripts/smoke-fixture-server.py", "scripts/smoke-invariants.sh",
"test-infrastructure/vm/vm-smoke.sh",
"test-infrastructure/vm/windows-user-path-guard.ps1",
"scripts/test-windows.ps1"}
"scripts/test-windows.ps1", "scripts/smoke-wrappers.sh"}
LINT_CONFIG = {"scripts/memory-core-baseline.txt", "scripts/lint-memory-core.py",
"scripts/hooks/pre-commit"}
NON_GATING = {"scripts/package-release.sh", "scripts/benchmark-index.sh",
+12
View File
@@ -3996,6 +3996,18 @@ else
echo "SKIP Phase 13: no install script available for this platform"
fi
# ── Phase 13w: package-wrapper install E2E ──
# The npm, PyPI and Go wrappers install the same release archive as above,
# from this checkout, the way a user's package manager runs them (#2657: a
# wrapper cap refused every shipped binary). SMOKE_REQUIRE_WRAPPERS=1 (CI)
# makes a missing node/python/go toolchain a failure, not a skip.
echo ""
echo "--- Phase 13w: package-wrapper install E2E ---"
if ! bash "$REPO_ROOT/scripts/smoke-wrappers.sh" "$SMOKE_DOWNLOAD_URL"; then
echo "FAIL 13w: a package wrapper could not install the release binary"
exit 1
fi
else
echo ""
echo "=== Phase 12-13: SKIPPED (SMOKE_DOWNLOAD_URL not set) ==="
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# smoke-wrappers.sh — install the npm, PyPI and Go wrappers from this checkout
# against a release fixture and run the binary each one installed.
#
# The wrappers download the release archive themselves. This runs each one the
# way a user's package manager does, against CBM_DOWNLOAD_URL (the test
# override install.sh honours too) serving the real release archives, so a
# wrapper that cannot install what we ship fails here instead of on users
# (#2657: a 256 MiB archive-member cap against 331 MB binaries).
#
# Usage: scripts/smoke-wrappers.sh DOWNLOAD_URL [npm] [pypi] [go]
# DOWNLOAD_URL base URL serving the release archives and checksums.txt:
# https, or plain http to a loopback authority
# (no wrapper names = all three)
# Environment:
# SMOKE_REQUIRE_WRAPPERS=1 a missing toolchain fails instead of skipping (CI)
# Exit: 0 = every wrapper installed and ran its binary (or was skipped) ·
# 1 = a wrapper failed · 2 = usage error.
set -euo pipefail
case "${BASH_SOURCE[0]}" in */*) cd "${BASH_SOURCE[0]%/*}/.." ;; *) cd .. ;; esac
ROOT="$PWD"
URL="${1:-}"
[ -n "$URL" ] || { sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 2; }
shift
WRAPPERS=("$@")
[ ${#WRAPPERS[@]} -gt 0 ] || WRAPPERS=(npm pypi go)
REQUIRE="${SMOKE_REQUIRE_WRAPPERS:-0}"
EXE=""
case "${OSTYPE:-}" in msys* | cygwin* | win32*) EXE=".exe" ;; esac
WORK="${TMPDIR:-/tmp}/cbm-smoke-wrappers.$$.$RANDOM"
mkdir -m 700 "$WORK"
trap 'rm -rf "$WORK"' EXIT
FAILED=0
missing() { # wrapper, what
if [ "$REQUIRE" = "1" ]; then
echo "FAIL $1: SMOKE_REQUIRE_WRAPPERS=1 but $2 is not available"
FAILED=1
else
echo "SKIP $1: $2 not available on this host"
fi
}
# A fresh HOME, cache and profile per wrapper: nothing a wrapper writes leaks
# into the next one, and a binary cached by an earlier run cannot stand in.
isolated() { # wrapper dir, command...
local dir="$1"
shift
mkdir -p "$dir/home" "$dir/cache" "$dir/localappdata"
HOME="$dir/home" USERPROFILE="$dir/home" XDG_CACHE_HOME="$dir/cache" \
LOCALAPPDATA="$dir/localappdata" CBM_CACHE_DIR="$dir/cbm-cache" \
CBM_DOWNLOAD_URL="$URL" "$@"
}
ran() { # wrapper, output file, rc
# The binary's own answer ("codebase-memory-mcp 0.12.0"), not a wrapper
# message ("codebase-memory-mcp: downloading ..."), proves it ran.
if [ "$3" -eq 0 ] && grep -E -q '^codebase-memory-mcp [^:]+$' "$2"; then
echo "OK $1: installed and ran the release binary: $(grep -E '^codebase-memory-mcp [^:]+$' "$2" | tail -1)"
else
echo "FAIL $1: the installed wrapper did not run the release binary (rc=$3):"
sed 's/^/ /' "$2" | tail -20
FAILED=1
fi
}
smoke_npm() {
if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
missing npm "node/npm"
return
fi
local dir="$WORK/npm" tgz="" line modules rc=0
mkdir -p "$dir/prefix"
(cd "$ROOT/pkg/npm" && npm pack --silent --pack-destination "$dir") >"$dir/pack.out"
while IFS= read -r line; do [ -n "$line" ] && tgz="$line"; done <"$dir/pack.out"
# A global install from the packed tarball runs postinstall (install.js)
# exactly as `npm i -g codebase-memory-mcp` does.
isolated "$dir" npm install --global --prefix "$dir/prefix" --cache "$dir/cache/npm" \
--foreground-scripts --no-audit --no-fund "$dir/$tgz" >"$dir/install.log" 2>&1 || rc=$?
if [ "$rc" -ne 0 ]; then
ran npm "$dir/install.log" "$rc"
return
fi
local modules
npm root --global --prefix "$dir/prefix" >"$dir/root.out"
IFS= read -r modules <"$dir/root.out"
isolated "$dir" node "$modules/codebase-memory-mcp/bin.js" --version >"$dir/run.log" 2>&1 ||
rc=$?
ran npm "$dir/run.log" "$rc"
}
smoke_pypi() {
local py=""
if command -v python3 >/dev/null 2>&1; then
py=python3
elif command -v python >/dev/null 2>&1; then
py=python
fi
if [ -z "$py" ]; then
missing pypi "python3"
return
fi
local dir="$WORK/pypi" rc=0
mkdir -p "$dir"
"$py" -m venv "$dir/venv"
local vpy="$dir/venv/bin/python"
[ -x "$vpy" ] || vpy="$dir/venv/Scripts/python.exe"
"$vpy" -m pip install --quiet --disable-pip-version-check "$ROOT/pkg/pypi" \
>"$dir/install.log" 2>&1 || rc=$?
if [ "$rc" -ne 0 ]; then
ran pypi "$dir/install.log" "$rc"
return
fi
# The console script's entry point; the first run downloads the binary.
isolated "$dir" "$vpy" -c \
'import sys, codebase_memory_mcp; sys.argv = ["codebase-memory-mcp", "--version"]; codebase_memory_mcp.main()' \
>"$dir/run.log" 2>&1 || rc=$?
ran pypi "$dir/run.log" "$rc"
}
smoke_go() {
if ! command -v go >/dev/null 2>&1; then
missing go "go"
return
fi
local dir="$WORK/go" rc=0
mkdir -p "$dir"
(cd "$ROOT/pkg/go" && GOTOOLCHAIN="${GOTOOLCHAIN:-auto}" \
go build -o "$dir/codebase-memory-mcp$EXE" ./cmd/codebase-memory-mcp) \
>"$dir/install.log" 2>&1 || rc=$?
if [ "$rc" -ne 0 ]; then
ran go "$dir/install.log" "$rc"
return
fi
# The first run downloads the binary into the wrapper's cache.
isolated "$dir" "$dir/codebase-memory-mcp$EXE" --version >"$dir/run.log" 2>&1 || rc=$?
ran go "$dir/run.log" "$rc"
}
for wrapper in "${WRAPPERS[@]}"; do
case "$wrapper" in
npm) smoke_npm ;;
pypi) smoke_pypi ;;
go) smoke_go ;;
*) echo "unknown wrapper: $wrapper" >&2; exit 2 ;;
esac
done
exit "$FAILED"
+1 -2
View File
@@ -108,7 +108,7 @@ SURFACES=(
"pkg/chocolatey/codebase-memory-mcp.nuspec|release"
"pkg/chocolatey/tools/chocolateyInstall.ps1|release"
"pkg/scoop/codebase-memory-mcp.json|release"
"pkg/homebrew/Formula/codebase-memory-mcp.rb|pin:0.10.3"
"pkg/homebrew/Formula/codebase-memory-mcp.rb|release"
"pkg/aur/PKGBUILD|pin:0.8.1"
"pkg/aur/.SRCINFO|pin:0.8.1"
)
@@ -118,7 +118,6 @@ SURFACES=(
# (gh release download v<X.Y.Z> --pattern checksums.txt), replace the version
# AND every sha256 it pins, then move the entry to "release" above.
PIN_REASONS=(
"pkg/homebrew/Formula/codebase-memory-mcp.rb|pins 4 per-asset sha256 (darwin/linux x arm/intel); last re-pinned for v0.10.3"
"pkg/aur/PKGBUILD|pins sha256sums_x86_64 + sha256sums_aarch64; last re-pinned for v0.8.1"
"pkg/aur/.SRCINFO|generated from PKGBUILD, so it must move with it, not before it"
)