codebase-memory-mcp 0.8.1__tar.gz → 0.9.1rc1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {codebase_memory_mcp-0.8.1 → codebase_memory_mcp-0.9.1rc1}/.gitignore +14 -0
- {codebase_memory_mcp-0.8.1 → codebase_memory_mcp-0.9.1rc1}/PKG-INFO +1 -1
- {codebase_memory_mcp-0.8.1 → codebase_memory_mcp-0.9.1rc1}/pyproject.toml +1 -1
- codebase_memory_mcp-0.9.1rc1/src/codebase_memory_mcp/_cli.py +535 -0
- codebase_memory_mcp-0.9.1rc1/tests/test_cli.py +36 -0
- codebase_memory_mcp-0.8.1/src/codebase_memory_mcp/_cli.py +0 -224
- {codebase_memory_mcp-0.8.1 → codebase_memory_mcp-0.9.1rc1}/README.md +0 -0
- {codebase_memory_mcp-0.8.1 → codebase_memory_mcp-0.9.1rc1}/src/codebase_memory_mcp/__init__.py +0 -0
- {codebase_memory_mcp-0.8.1 → codebase_memory_mcp-0.9.1rc1}/src/codebase_memory_mcp/__main__.py +0 -0
|
@@ -56,3 +56,17 @@ CHANGELOG.md
|
|
|
56
56
|
|
|
57
57
|
# Soak test output
|
|
58
58
|
soak-results/
|
|
59
|
+
|
|
60
|
+
# LSP originality-check reference cache (scripts/check-lsp-originality.sh)
|
|
61
|
+
.lsp-refs/
|
|
62
|
+
|
|
63
|
+
# Local npm cache
|
|
64
|
+
graph-ui/.npm-cache-local/
|
|
65
|
+
|
|
66
|
+
# Python bytecode from tests/windows/ harness
|
|
67
|
+
__pycache__/
|
|
68
|
+
*.pyc
|
|
69
|
+
|
|
70
|
+
# Local soak-leg outputs (uploaded as CI artifacts, never committed)
|
|
71
|
+
soak-results/
|
|
72
|
+
soak-results-query-leak/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codebase-memory-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.1rc1
|
|
4
4
|
Summary: Fast code intelligence engine for AI coding agents — single static binary MCP server
|
|
5
5
|
Project-URL: Homepage, https://github.com/DeusData/codebase-memory-mcp
|
|
6
6
|
Project-URL: Repository, https://github.com/DeusData/codebase-memory-mcp
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "codebase-memory-mcp"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.1-rc.1"
|
|
8
8
|
description = "Fast code intelligence engine for AI coding agents — single static binary MCP server"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""Downloads codebase-memory-mcp on first run, then runs its native entry point."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
REPO = "DeusData/codebase-memory-mcp"
|
|
18
|
+
_WINDOWS_BINARY_NAME = "codebase-memory-mcp.exe"
|
|
19
|
+
_WINDOWS_ARCHIVE_NAMES = (
|
|
20
|
+
_WINDOWS_BINARY_NAME,
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"install.ps1",
|
|
23
|
+
"THIRD_PARTY_NOTICES.md",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Security: only permit https fetches. urllib's default handlers accept
|
|
27
|
+
# file://, ftp://, and custom schemes — a redirect or tainted URL source
|
|
28
|
+
# could otherwise turn a download into an arbitrary-local-file read.
|
|
29
|
+
_ALLOWED_SCHEMES = frozenset({"https"})
|
|
30
|
+
_MAX_REDIRECTS = 5
|
|
31
|
+
_NETWORK_TIMEOUT_SECONDS = 120
|
|
32
|
+
_CANDIDATE_TIMEOUT_SECONDS = 15
|
|
33
|
+
_MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024
|
|
34
|
+
_REDIRECT_CODES = frozenset({301, 302, 303, 307, 308})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
38
|
+
"""Expose redirects to _download_https for explicit per-hop validation."""
|
|
39
|
+
|
|
40
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_HTTPS_OPENER = urllib.request.build_opener(_NoRedirectHandler())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validate_url_scheme(url: str) -> None:
|
|
48
|
+
"""Reject non-https URLs before any network fetch."""
|
|
49
|
+
parsed = urllib.parse.urlparse(url)
|
|
50
|
+
if (
|
|
51
|
+
parsed.scheme not in _ALLOWED_SCHEMES
|
|
52
|
+
or not parsed.hostname
|
|
53
|
+
or parsed.username is not None
|
|
54
|
+
or parsed.password is not None
|
|
55
|
+
):
|
|
56
|
+
sys.exit(
|
|
57
|
+
f"codebase-memory-mcp: refusing to fetch invalid, credentialed, or "
|
|
58
|
+
f"non-https URL: {url}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
|
|
63
|
+
"""Download through a bounded HTTPS-only opener into dest."""
|
|
64
|
+
current_url = url
|
|
65
|
+
for redirect_count in range(_MAX_REDIRECTS + 1):
|
|
66
|
+
_validate_url_scheme(current_url)
|
|
67
|
+
request = urllib.request.Request(
|
|
68
|
+
current_url, headers={"User-Agent": "codebase-memory-mcp-installer"}
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
response = _HTTPS_OPENER.open(
|
|
72
|
+
request, timeout=_NETWORK_TIMEOUT_SECONDS
|
|
73
|
+
)
|
|
74
|
+
except urllib.error.HTTPError as exc:
|
|
75
|
+
if exc.code not in _REDIRECT_CODES:
|
|
76
|
+
raise
|
|
77
|
+
location = exc.headers.get("Location")
|
|
78
|
+
exc.close()
|
|
79
|
+
if not location:
|
|
80
|
+
raise RuntimeError(f"redirect has no Location: {current_url}")
|
|
81
|
+
if redirect_count == _MAX_REDIRECTS:
|
|
82
|
+
raise RuntimeError("too many redirects")
|
|
83
|
+
current_url = urllib.parse.urljoin(current_url, location)
|
|
84
|
+
_validate_url_scheme(current_url)
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
with response:
|
|
88
|
+
_validate_url_scheme(response.geturl())
|
|
89
|
+
deadline = time.monotonic() + _NETWORK_TIMEOUT_SECONDS
|
|
90
|
+
total = 0
|
|
91
|
+
with open(dest, "wb") as out:
|
|
92
|
+
while True:
|
|
93
|
+
if time.monotonic() >= deadline:
|
|
94
|
+
raise TimeoutError(
|
|
95
|
+
f"download hop timed out: {current_url}"
|
|
96
|
+
)
|
|
97
|
+
chunk = response.read(65536)
|
|
98
|
+
if not chunk:
|
|
99
|
+
return
|
|
100
|
+
total += len(chunk)
|
|
101
|
+
if max_bytes and total > max_bytes:
|
|
102
|
+
raise RuntimeError(
|
|
103
|
+
f"download exceeds the {max_bytes}-byte safety limit"
|
|
104
|
+
)
|
|
105
|
+
out.write(chunk)
|
|
106
|
+
|
|
107
|
+
raise RuntimeError("too many redirects")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _verify_candidate(path: Path) -> None:
|
|
111
|
+
"""Require a staged native candidate to execute successfully."""
|
|
112
|
+
try:
|
|
113
|
+
subprocess.run(
|
|
114
|
+
[str(path), "--version"],
|
|
115
|
+
stdin=subprocess.DEVNULL,
|
|
116
|
+
stdout=subprocess.DEVNULL,
|
|
117
|
+
stderr=subprocess.DEVNULL,
|
|
118
|
+
check=True,
|
|
119
|
+
timeout=_CANDIDATE_TIMEOUT_SECONDS,
|
|
120
|
+
)
|
|
121
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
122
|
+
raise RuntimeError(f"downloaded binary failed to run: {exc}") from exc
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _file_sha256(path: Path) -> str:
|
|
126
|
+
digest = hashlib.sha256()
|
|
127
|
+
with path.open("rb") as source:
|
|
128
|
+
for chunk in iter(lambda: source.read(65536), b""):
|
|
129
|
+
digest.update(chunk)
|
|
130
|
+
return digest.hexdigest()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _files_equal_sha256(left: Path, right: Path) -> bool:
|
|
134
|
+
try:
|
|
135
|
+
return (
|
|
136
|
+
left.is_file()
|
|
137
|
+
and right.is_file()
|
|
138
|
+
and left.stat().st_size == right.stat().st_size
|
|
139
|
+
and _file_sha256(left) == _file_sha256(right)
|
|
140
|
+
)
|
|
141
|
+
except OSError:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _safe_extract_tar(tf, dest: str) -> None:
|
|
146
|
+
"""Extract a tarfile to dest, rejecting path-traversal entries.
|
|
147
|
+
|
|
148
|
+
Uses the tarfile data filter on Python >=3.12 (PEP 706), falls back to
|
|
149
|
+
manual per-member path validation on older Pythons. Mitigates the
|
|
150
|
+
classic tar-slip / Zip Slip vulnerability (CWE-22).
|
|
151
|
+
"""
|
|
152
|
+
if hasattr(tf, "extraction_filter") or sys.version_info >= (3, 12):
|
|
153
|
+
tf.extractall(dest, filter="data")
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
dest_abs = os.path.abspath(dest)
|
|
157
|
+
for member in tf.getmembers():
|
|
158
|
+
if member.issym() or member.islnk():
|
|
159
|
+
sys.exit(
|
|
160
|
+
f"codebase-memory-mcp: refusing unsafe tar entry "
|
|
161
|
+
f"(link: {member.name!r})"
|
|
162
|
+
)
|
|
163
|
+
member_abs = os.path.abspath(os.path.join(dest_abs, member.name))
|
|
164
|
+
if not (member_abs == dest_abs or member_abs.startswith(dest_abs + os.sep)):
|
|
165
|
+
sys.exit(
|
|
166
|
+
f"codebase-memory-mcp: refusing unsafe tar entry "
|
|
167
|
+
f"(escapes dest: {member.name!r})"
|
|
168
|
+
)
|
|
169
|
+
tf.extractall(dest)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _safe_extract_zip(zf, dest: str, archive_names=(), extract_names=()) -> None:
|
|
173
|
+
"""Extract a zipfile after validating its Windows-style namespace.
|
|
174
|
+
|
|
175
|
+
Windows treats paths case-insensitively. Rejecting duplicate/case-conflicting
|
|
176
|
+
members before extraction prevents archive order from selecting the binary
|
|
177
|
+
that a portable package-manager shim eventually executes.
|
|
178
|
+
"""
|
|
179
|
+
dest_abs = os.path.abspath(dest)
|
|
180
|
+
seen = set()
|
|
181
|
+
required = tuple(archive_names)
|
|
182
|
+
required_set = set(required)
|
|
183
|
+
found = set()
|
|
184
|
+
for info in zf.infolist():
|
|
185
|
+
raw_name = info.filename
|
|
186
|
+
name = raw_name.replace("\\", "/")
|
|
187
|
+
segments_name = name[:-1] if name.endswith("/") else name
|
|
188
|
+
segments = segments_name.split("/")
|
|
189
|
+
if (
|
|
190
|
+
not segments_name
|
|
191
|
+
or name.startswith("/")
|
|
192
|
+
or ":" in name
|
|
193
|
+
or any(segment in ("", ".", "..") for segment in segments)
|
|
194
|
+
or any(segment.endswith((".", " ")) for segment in segments)
|
|
195
|
+
):
|
|
196
|
+
sys.exit(
|
|
197
|
+
f"codebase-memory-mcp: refusing unsafe zip entry "
|
|
198
|
+
f"(invalid path: {raw_name!r})"
|
|
199
|
+
)
|
|
200
|
+
folded = segments_name.casefold()
|
|
201
|
+
if folded in seen:
|
|
202
|
+
sys.exit(
|
|
203
|
+
f"codebase-memory-mcp: refusing unsafe zip entry "
|
|
204
|
+
f"(duplicate or case conflict: {raw_name!r})"
|
|
205
|
+
)
|
|
206
|
+
seen.add(folded)
|
|
207
|
+
if name not in required_set or info.is_dir():
|
|
208
|
+
sys.exit(
|
|
209
|
+
f"codebase-memory-mcp: archive must contain only the exact "
|
|
210
|
+
f"root files: {', '.join(required)}"
|
|
211
|
+
)
|
|
212
|
+
found.add(name)
|
|
213
|
+
member_abs = os.path.abspath(os.path.join(dest_abs, *segments))
|
|
214
|
+
if not (member_abs == dest_abs or member_abs.startswith(dest_abs + os.sep)):
|
|
215
|
+
sys.exit(
|
|
216
|
+
f"codebase-memory-mcp: refusing unsafe zip entry "
|
|
217
|
+
f"(escapes dest: {raw_name!r})"
|
|
218
|
+
)
|
|
219
|
+
missing = required_set - found
|
|
220
|
+
if missing or len(seen) != len(required):
|
|
221
|
+
sys.exit(
|
|
222
|
+
f"codebase-memory-mcp: archive must contain exactly one of each "
|
|
223
|
+
f"required root file: {', '.join(required)}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Extract only the two validated executable files. This avoids relying on
|
|
227
|
+
# platform-specific zip path rewriting and always creates regular files.
|
|
228
|
+
for name in extract_names:
|
|
229
|
+
target = os.path.join(dest_abs, name)
|
|
230
|
+
with zf.open(name) as source, open(target, "xb") as output:
|
|
231
|
+
shutil.copyfileobj(source, output)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _verify_checksum(archive_path: str, archive_name: str, version: str) -> None:
|
|
235
|
+
"""Verify SHA256 checksum against checksums.txt from the release."""
|
|
236
|
+
url = f"https://github.com/{REPO}/releases/download/v{version}/checksums.txt"
|
|
237
|
+
tmp_path = None
|
|
238
|
+
try:
|
|
239
|
+
_validate_url_scheme(url)
|
|
240
|
+
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
|
|
241
|
+
tmp_path = tmp.name
|
|
242
|
+
_download_https(url, tmp_path, _MAX_CHECKSUM_MANIFEST_BYTES)
|
|
243
|
+
expected = None
|
|
244
|
+
with open(tmp_path, encoding="utf-8") as f:
|
|
245
|
+
for line in f:
|
|
246
|
+
fields = line.split()
|
|
247
|
+
if len(fields) < 2 or fields[1] not in (
|
|
248
|
+
archive_name,
|
|
249
|
+
f"*{archive_name}",
|
|
250
|
+
):
|
|
251
|
+
continue
|
|
252
|
+
digest = fields[0].lower()
|
|
253
|
+
if len(digest) != 64 or any(
|
|
254
|
+
ch not in "0123456789abcdef" for ch in digest
|
|
255
|
+
):
|
|
256
|
+
sys.exit(
|
|
257
|
+
f"codebase-memory-mcp: invalid SHA256 checksum for "
|
|
258
|
+
f"{archive_name}"
|
|
259
|
+
)
|
|
260
|
+
if expected is not None and expected != digest:
|
|
261
|
+
sys.exit(
|
|
262
|
+
f"codebase-memory-mcp: conflicting SHA256 checksums for "
|
|
263
|
+
f"{archive_name}"
|
|
264
|
+
)
|
|
265
|
+
expected = digest
|
|
266
|
+
if expected is None:
|
|
267
|
+
sys.exit(
|
|
268
|
+
f"codebase-memory-mcp: no checksum for {archive_name} in checksums.txt"
|
|
269
|
+
)
|
|
270
|
+
h = hashlib.sha256()
|
|
271
|
+
with open(archive_path, "rb") as af:
|
|
272
|
+
for chunk in iter(lambda: af.read(65536), b""):
|
|
273
|
+
h.update(chunk)
|
|
274
|
+
actual = h.hexdigest()
|
|
275
|
+
if expected != actual:
|
|
276
|
+
sys.exit(
|
|
277
|
+
f"codebase-memory-mcp: CHECKSUM MISMATCH for {archive_name}\n"
|
|
278
|
+
f" expected: {expected}\n"
|
|
279
|
+
f" actual: {actual}"
|
|
280
|
+
)
|
|
281
|
+
print("codebase-memory-mcp: checksum verified.", file=sys.stderr)
|
|
282
|
+
except SystemExit:
|
|
283
|
+
raise
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
sys.exit(f"codebase-memory-mcp: checksum verification failed: {exc}")
|
|
286
|
+
finally:
|
|
287
|
+
if tmp_path is not None:
|
|
288
|
+
try:
|
|
289
|
+
os.unlink(tmp_path)
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _version() -> str:
|
|
295
|
+
try:
|
|
296
|
+
from importlib.metadata import version
|
|
297
|
+
return version("codebase-memory-mcp")
|
|
298
|
+
except Exception:
|
|
299
|
+
return "0.8.1"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _os_name() -> str:
|
|
303
|
+
p = sys.platform
|
|
304
|
+
if p == "linux":
|
|
305
|
+
return "linux"
|
|
306
|
+
if p == "darwin":
|
|
307
|
+
return "darwin"
|
|
308
|
+
if p == "win32":
|
|
309
|
+
return "windows"
|
|
310
|
+
sys.exit(f"codebase-memory-mcp: unsupported platform: {p}")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _arch() -> str:
|
|
314
|
+
m = platform.machine().lower()
|
|
315
|
+
if m in ("arm64", "aarch64"):
|
|
316
|
+
return "arm64"
|
|
317
|
+
if m in ("x86_64", "amd64"):
|
|
318
|
+
return "amd64"
|
|
319
|
+
sys.exit(f"codebase-memory-mcp: unsupported architecture: {m}")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _cache_dir() -> Path:
|
|
323
|
+
if sys.platform == "win32":
|
|
324
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
325
|
+
elif sys.platform == "darwin":
|
|
326
|
+
base = Path.home() / "Library" / "Caches"
|
|
327
|
+
else:
|
|
328
|
+
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
329
|
+
return base / "codebase-memory-mcp"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _bin_path(version: str) -> Path:
|
|
333
|
+
# One binary per platform: the cached file is the executed file.
|
|
334
|
+
name = (
|
|
335
|
+
_WINDOWS_BINARY_NAME
|
|
336
|
+
if sys.platform == "win32"
|
|
337
|
+
else "codebase-memory-mcp"
|
|
338
|
+
)
|
|
339
|
+
return _cache_dir() / version / name
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _execution_path(binary: Path, target_platform: str) -> Path:
|
|
343
|
+
"""The cached binary is executed directly on every platform."""
|
|
344
|
+
return binary
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _windows_binary_ready(version: str) -> bool:
|
|
348
|
+
binary = _cache_dir() / version / _WINDOWS_BINARY_NAME
|
|
349
|
+
if not binary.is_file():
|
|
350
|
+
return False
|
|
351
|
+
try:
|
|
352
|
+
_verify_candidate(binary)
|
|
353
|
+
except RuntimeError:
|
|
354
|
+
return False
|
|
355
|
+
return True
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _download(version: str) -> Path:
|
|
359
|
+
os_name = _os_name()
|
|
360
|
+
arch = _arch()
|
|
361
|
+
ext = "zip" if os_name == "windows" else "tar.gz"
|
|
362
|
+
# Linux ships a fully-static "-portable" build; the standard linux binary
|
|
363
|
+
# dynamically links glibc 2.38+ and fails on older distros. macOS/Windows
|
|
364
|
+
# have no such variant. Keep in sync with install.sh / install.js / cli.c.
|
|
365
|
+
variant = "-portable" if os_name == "linux" else ""
|
|
366
|
+
# Opt into the UI build (embedded graph visualization) with CBM_VARIANT=ui.
|
|
367
|
+
# Default is the standard (headless) build. Mirrors install.sh --ui.
|
|
368
|
+
ui = "ui-" if os.environ.get("CBM_VARIANT", "").lower() == "ui" else ""
|
|
369
|
+
archive = f"codebase-memory-mcp-{ui}{os_name}-{arch}{variant}.{ext}"
|
|
370
|
+
url = f"https://github.com/{REPO}/releases/download/v{version}/{archive}"
|
|
371
|
+
_validate_url_scheme(url)
|
|
372
|
+
|
|
373
|
+
dest = _bin_path(version)
|
|
374
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
375
|
+
|
|
376
|
+
print(
|
|
377
|
+
f"codebase-memory-mcp: downloading v{version} for {os_name}/{arch}...",
|
|
378
|
+
file=sys.stderr,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
382
|
+
tmp_archive = os.path.join(tmp, f"cbm.{ext}")
|
|
383
|
+
try:
|
|
384
|
+
_download_https(url, tmp_archive)
|
|
385
|
+
except (OSError, RuntimeError, urllib.error.URLError) as e:
|
|
386
|
+
sys.exit(
|
|
387
|
+
f"codebase-memory-mcp: download failed ({e})\n"
|
|
388
|
+
f"URL: {url}\n"
|
|
389
|
+
f"See https://github.com/{REPO}/releases for available versions."
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
_verify_checksum(tmp_archive, archive, version)
|
|
393
|
+
|
|
394
|
+
bin_name = (
|
|
395
|
+
_WINDOWS_BINARY_NAME if os_name == "windows" else "codebase-memory-mcp"
|
|
396
|
+
)
|
|
397
|
+
extraction_names = (bin_name,)
|
|
398
|
+
cache_names = extraction_names
|
|
399
|
+
publish_names = cache_names
|
|
400
|
+
if ext == "tar.gz":
|
|
401
|
+
import tarfile
|
|
402
|
+
with tarfile.open(tmp_archive) as tf:
|
|
403
|
+
_safe_extract_tar(tf, tmp)
|
|
404
|
+
else:
|
|
405
|
+
import zipfile
|
|
406
|
+
with zipfile.ZipFile(tmp_archive) as zf:
|
|
407
|
+
_safe_extract_zip(
|
|
408
|
+
zf,
|
|
409
|
+
tmp,
|
|
410
|
+
_WINDOWS_ARCHIVE_NAMES,
|
|
411
|
+
extraction_names,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
extracted_paths = {}
|
|
415
|
+
for name in extraction_names:
|
|
416
|
+
extracted_path = Path(tmp) / name
|
|
417
|
+
if not extracted_path.is_file():
|
|
418
|
+
sys.exit(
|
|
419
|
+
f"codebase-memory-mcp: required binary not found after "
|
|
420
|
+
f"extraction: {name}"
|
|
421
|
+
)
|
|
422
|
+
current = extracted_path.stat().st_mode
|
|
423
|
+
extracted_path.chmod(
|
|
424
|
+
current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
425
|
+
)
|
|
426
|
+
extracted_paths[name] = extracted_path
|
|
427
|
+
try:
|
|
428
|
+
_verify_candidate(extracted_paths[bin_name])
|
|
429
|
+
except RuntimeError as exc:
|
|
430
|
+
sys.exit(f"codebase-memory-mcp: {exc}")
|
|
431
|
+
|
|
432
|
+
staged_paths = {}
|
|
433
|
+
try:
|
|
434
|
+
for name in cache_names:
|
|
435
|
+
staged_suffix = ".tmp.exe" if sys.platform == "win32" else ".tmp"
|
|
436
|
+
with tempfile.NamedTemporaryFile(
|
|
437
|
+
dir=str(dest.parent),
|
|
438
|
+
prefix=f".{name}.",
|
|
439
|
+
suffix=staged_suffix,
|
|
440
|
+
delete=False,
|
|
441
|
+
) as staged:
|
|
442
|
+
staged_path = Path(staged.name)
|
|
443
|
+
shutil.copy2(extracted_paths[name], staged_path)
|
|
444
|
+
staged_mode = staged_path.stat().st_mode
|
|
445
|
+
staged_path.chmod(
|
|
446
|
+
staged_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
447
|
+
)
|
|
448
|
+
staged_paths[name] = staged_path
|
|
449
|
+
|
|
450
|
+
if os_name != "windows":
|
|
451
|
+
_verify_candidate(staged_paths[bin_name])
|
|
452
|
+
for name in publish_names:
|
|
453
|
+
target = dest.parent / name
|
|
454
|
+
try:
|
|
455
|
+
os.replace(staged_paths[name], target)
|
|
456
|
+
staged_paths.pop(name)
|
|
457
|
+
except OSError as publish_error:
|
|
458
|
+
# Never unlink a destination here: it may belong to a
|
|
459
|
+
# concurrent contender. A collision remains eligible only
|
|
460
|
+
# when its bytes match this authenticated stage; pair
|
|
461
|
+
# execution is checked after payload is published last.
|
|
462
|
+
if not _files_equal_sha256(staged_paths[name], target):
|
|
463
|
+
raise publish_error
|
|
464
|
+
if os_name == "windows":
|
|
465
|
+
_verify_candidate(dest.parent / _WINDOWS_BINARY_NAME)
|
|
466
|
+
else:
|
|
467
|
+
_verify_candidate(dest)
|
|
468
|
+
except RuntimeError as exc:
|
|
469
|
+
sys.exit(f"codebase-memory-mcp: {exc}")
|
|
470
|
+
finally:
|
|
471
|
+
for staged_path in staged_paths.values():
|
|
472
|
+
try:
|
|
473
|
+
staged_path.unlink()
|
|
474
|
+
except OSError:
|
|
475
|
+
pass
|
|
476
|
+
|
|
477
|
+
return dest
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def main() -> None:
|
|
481
|
+
version = _version()
|
|
482
|
+
bin_path = _bin_path(version)
|
|
483
|
+
|
|
484
|
+
cache_ready = (
|
|
485
|
+
_windows_binary_ready(version)
|
|
486
|
+
if sys.platform == "win32"
|
|
487
|
+
else bin_path.is_file()
|
|
488
|
+
)
|
|
489
|
+
if not cache_ready:
|
|
490
|
+
bin_path = _download(version)
|
|
491
|
+
|
|
492
|
+
execution_path = _execution_path(bin_path, sys.platform)
|
|
493
|
+
|
|
494
|
+
# args is a list (not a shell string), so exec/subprocess treat each
|
|
495
|
+
# element as a discrete argv entry — no shell interpretation, no
|
|
496
|
+
# injection vector. sys.argv forwarding is the whole point of this
|
|
497
|
+
# shim, so tainted-input suppression is intentional.
|
|
498
|
+
args = [str(execution_path)] + sys.argv[1:]
|
|
499
|
+
|
|
500
|
+
if sys.platform != "win32":
|
|
501
|
+
os.execv(str(execution_path), args) # noqa: S606 — list form, no shell
|
|
502
|
+
else:
|
|
503
|
+
result = subprocess.run(args) # noqa: S603 — list form, no shell=True
|
|
504
|
+
mutation = _portable_mutation_action(sys.argv[1:])
|
|
505
|
+
if result.returncode != 0 and mutation is not None:
|
|
506
|
+
package_command = (
|
|
507
|
+
"python -m pip install --upgrade codebase-memory-mcp"
|
|
508
|
+
if mutation == "update"
|
|
509
|
+
else "python -m pip uninstall codebase-memory-mcp"
|
|
510
|
+
)
|
|
511
|
+
print(
|
|
512
|
+
f'This PyPI Windows copy is portable. Use "{package_command}" '
|
|
513
|
+
f'for package maintenance, or run "codebase-memory-mcp '
|
|
514
|
+
f'install --yes" once to create a managed launcher with '
|
|
515
|
+
f'coordinated self-update/uninstall.',
|
|
516
|
+
file=sys.stderr,
|
|
517
|
+
)
|
|
518
|
+
sys.exit(result.returncode)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _portable_mutation_action(args):
|
|
522
|
+
for argument in args:
|
|
523
|
+
if argument in (
|
|
524
|
+
"cli",
|
|
525
|
+
"hook-augment",
|
|
526
|
+
"config",
|
|
527
|
+
"install",
|
|
528
|
+
"--help",
|
|
529
|
+
"-h",
|
|
530
|
+
"--version",
|
|
531
|
+
):
|
|
532
|
+
return None
|
|
533
|
+
if argument in ("update", "uninstall"):
|
|
534
|
+
return argument
|
|
535
|
+
return None
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import unittest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
|
|
7
|
+
sys.path.insert(0, str(PACKAGE_ROOT / "src"))
|
|
8
|
+
|
|
9
|
+
from codebase_memory_mcp import _cli # noqa: E402
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BinarySelectionTests(unittest.TestCase):
|
|
13
|
+
"""One binary ships per platform: the cached file is the executed file."""
|
|
14
|
+
|
|
15
|
+
def test_windows_executes_the_cached_binary(self):
|
|
16
|
+
binary = Path("cache") / "0.8.1" / _cli._WINDOWS_BINARY_NAME
|
|
17
|
+
|
|
18
|
+
self.assertEqual(_cli._execution_path(binary, "win32"), binary)
|
|
19
|
+
|
|
20
|
+
def test_non_windows_executes_the_cached_binary(self):
|
|
21
|
+
binary = Path("cache") / "0.8.1" / "codebase-memory-mcp"
|
|
22
|
+
|
|
23
|
+
self.assertEqual(_cli._execution_path(binary, "linux"), binary)
|
|
24
|
+
|
|
25
|
+
def test_portable_mutation_guidance_classification_is_preserved(self):
|
|
26
|
+
self.assertEqual(_cli._portable_mutation_action(["update"]), "update")
|
|
27
|
+
self.assertEqual(
|
|
28
|
+
_cli._portable_mutation_action(["uninstall", "--yes"]),
|
|
29
|
+
"uninstall",
|
|
30
|
+
)
|
|
31
|
+
self.assertIsNone(_cli._portable_mutation_action(["install", "--yes"]))
|
|
32
|
+
self.assertIsNone(_cli._portable_mutation_action(["cli", "update"]))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
unittest.main()
|
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
"""Downloads the codebase-memory-mcp binary on first run, then exec's it."""
|
|
2
|
-
|
|
3
|
-
import hashlib
|
|
4
|
-
import os
|
|
5
|
-
import sys
|
|
6
|
-
import platform
|
|
7
|
-
import stat
|
|
8
|
-
import shutil
|
|
9
|
-
import tempfile
|
|
10
|
-
import urllib.request
|
|
11
|
-
import urllib.error
|
|
12
|
-
import urllib.parse
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
|
|
15
|
-
REPO = "DeusData/codebase-memory-mcp"
|
|
16
|
-
|
|
17
|
-
# Security: only permit https fetches. urllib's default handlers accept
|
|
18
|
-
# file://, ftp://, and custom schemes — a redirect or tainted URL source
|
|
19
|
-
# could otherwise turn a download into an arbitrary-local-file read.
|
|
20
|
-
_ALLOWED_SCHEMES = frozenset({"https"})
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _validate_url_scheme(url: str) -> None:
|
|
24
|
-
"""Reject non-https URLs before any network fetch."""
|
|
25
|
-
scheme = urllib.parse.urlparse(url).scheme
|
|
26
|
-
if scheme not in _ALLOWED_SCHEMES:
|
|
27
|
-
sys.exit(
|
|
28
|
-
f"codebase-memory-mcp: refusing to fetch non-https URL "
|
|
29
|
-
f"(scheme={scheme!r}): {url}"
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _safe_extract_tar(tf, dest: str) -> None:
|
|
34
|
-
"""Extract a tarfile to dest, rejecting path-traversal entries.
|
|
35
|
-
|
|
36
|
-
Uses the tarfile data filter on Python >=3.12 (PEP 706), falls back to
|
|
37
|
-
manual per-member path validation on older Pythons. Mitigates the
|
|
38
|
-
classic tar-slip / Zip Slip vulnerability (CWE-22).
|
|
39
|
-
"""
|
|
40
|
-
if hasattr(tf, "extraction_filter") or sys.version_info >= (3, 12):
|
|
41
|
-
tf.extractall(dest, filter="data")
|
|
42
|
-
return
|
|
43
|
-
|
|
44
|
-
dest_abs = os.path.abspath(dest)
|
|
45
|
-
for member in tf.getmembers():
|
|
46
|
-
if member.issym() or member.islnk():
|
|
47
|
-
sys.exit(
|
|
48
|
-
f"codebase-memory-mcp: refusing unsafe tar entry "
|
|
49
|
-
f"(link: {member.name!r})"
|
|
50
|
-
)
|
|
51
|
-
member_abs = os.path.abspath(os.path.join(dest_abs, member.name))
|
|
52
|
-
if not (member_abs == dest_abs or member_abs.startswith(dest_abs + os.sep)):
|
|
53
|
-
sys.exit(
|
|
54
|
-
f"codebase-memory-mcp: refusing unsafe tar entry "
|
|
55
|
-
f"(escapes dest: {member.name!r})"
|
|
56
|
-
)
|
|
57
|
-
tf.extractall(dest)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _safe_extract_zip(zf, dest: str) -> None:
|
|
61
|
-
"""Extract a zipfile to dest, rejecting path-traversal entries."""
|
|
62
|
-
dest_abs = os.path.abspath(dest)
|
|
63
|
-
for name in zf.namelist():
|
|
64
|
-
member_abs = os.path.abspath(os.path.join(dest_abs, name))
|
|
65
|
-
if not (member_abs == dest_abs or member_abs.startswith(dest_abs + os.sep)):
|
|
66
|
-
sys.exit(
|
|
67
|
-
f"codebase-memory-mcp: refusing unsafe zip entry "
|
|
68
|
-
f"(escapes dest: {name!r})"
|
|
69
|
-
)
|
|
70
|
-
zf.extractall(dest)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _verify_checksum(archive_path: str, archive_name: str, version: str) -> None:
|
|
74
|
-
"""Verify SHA256 checksum against checksums.txt from the release."""
|
|
75
|
-
url = f"https://github.com/{REPO}/releases/download/v{version}/checksums.txt"
|
|
76
|
-
try:
|
|
77
|
-
_validate_url_scheme(url)
|
|
78
|
-
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
|
|
79
|
-
tmp_path = tmp.name
|
|
80
|
-
urllib.request.urlretrieve(url, tmp_path) # noqa: S310 — scheme validated above
|
|
81
|
-
with open(tmp_path) as f:
|
|
82
|
-
for line in f:
|
|
83
|
-
if archive_name in line:
|
|
84
|
-
expected = line.split()[0]
|
|
85
|
-
h = hashlib.sha256()
|
|
86
|
-
with open(archive_path, "rb") as af:
|
|
87
|
-
for chunk in iter(lambda: af.read(65536), b""):
|
|
88
|
-
h.update(chunk)
|
|
89
|
-
actual = h.hexdigest()
|
|
90
|
-
if expected != actual:
|
|
91
|
-
sys.exit(
|
|
92
|
-
f"codebase-memory-mcp: CHECKSUM MISMATCH for {archive_name}\n"
|
|
93
|
-
f" expected: {expected}\n"
|
|
94
|
-
f" actual: {actual}"
|
|
95
|
-
)
|
|
96
|
-
print("codebase-memory-mcp: checksum verified.", file=sys.stderr)
|
|
97
|
-
break
|
|
98
|
-
except SystemExit:
|
|
99
|
-
raise
|
|
100
|
-
except Exception:
|
|
101
|
-
pass # Non-fatal: checksum unavailable
|
|
102
|
-
finally:
|
|
103
|
-
try:
|
|
104
|
-
os.unlink(tmp_path)
|
|
105
|
-
except Exception:
|
|
106
|
-
pass
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def _version() -> str:
|
|
110
|
-
try:
|
|
111
|
-
from importlib.metadata import version
|
|
112
|
-
return version("codebase-memory-mcp")
|
|
113
|
-
except Exception:
|
|
114
|
-
return "0.6.0"
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _os_name() -> str:
|
|
118
|
-
p = sys.platform
|
|
119
|
-
if p == "linux":
|
|
120
|
-
return "linux"
|
|
121
|
-
if p == "darwin":
|
|
122
|
-
return "darwin"
|
|
123
|
-
if p == "win32":
|
|
124
|
-
return "windows"
|
|
125
|
-
sys.exit(f"codebase-memory-mcp: unsupported platform: {p}")
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def _arch() -> str:
|
|
129
|
-
m = platform.machine().lower()
|
|
130
|
-
if m in ("arm64", "aarch64"):
|
|
131
|
-
return "arm64"
|
|
132
|
-
if m in ("x86_64", "amd64"):
|
|
133
|
-
return "amd64"
|
|
134
|
-
sys.exit(f"codebase-memory-mcp: unsupported architecture: {m}")
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def _cache_dir() -> Path:
|
|
138
|
-
if sys.platform == "win32":
|
|
139
|
-
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
140
|
-
elif sys.platform == "darwin":
|
|
141
|
-
base = Path.home() / "Library" / "Caches"
|
|
142
|
-
else:
|
|
143
|
-
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
144
|
-
return base / "codebase-memory-mcp"
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def _bin_path(version: str) -> Path:
|
|
148
|
-
name = "codebase-memory-mcp.exe" if sys.platform == "win32" else "codebase-memory-mcp"
|
|
149
|
-
return _cache_dir() / version / name
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def _download(version: str) -> Path:
|
|
153
|
-
os_name = _os_name()
|
|
154
|
-
arch = _arch()
|
|
155
|
-
ext = "zip" if os_name == "windows" else "tar.gz"
|
|
156
|
-
# Linux ships a fully-static "-portable" build; the standard linux binary
|
|
157
|
-
# dynamically links glibc 2.38+ and fails on older distros. macOS/Windows
|
|
158
|
-
# have no such variant. Keep in sync with install.sh / install.js / cli.c.
|
|
159
|
-
variant = "-portable" if os_name == "linux" else ""
|
|
160
|
-
archive = f"codebase-memory-mcp-{os_name}-{arch}{variant}.{ext}"
|
|
161
|
-
url = f"https://github.com/{REPO}/releases/download/v{version}/{archive}"
|
|
162
|
-
_validate_url_scheme(url)
|
|
163
|
-
|
|
164
|
-
dest = _bin_path(version)
|
|
165
|
-
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
-
|
|
167
|
-
print(
|
|
168
|
-
f"codebase-memory-mcp: downloading v{version} for {os_name}/{arch}...",
|
|
169
|
-
file=sys.stderr,
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
173
|
-
tmp_archive = os.path.join(tmp, f"cbm.{ext}")
|
|
174
|
-
try:
|
|
175
|
-
urllib.request.urlretrieve(url, tmp_archive) # noqa: S310 — scheme validated above
|
|
176
|
-
except urllib.error.HTTPError as e:
|
|
177
|
-
sys.exit(
|
|
178
|
-
f"codebase-memory-mcp: download failed ({e})\n"
|
|
179
|
-
f"URL: {url}\n"
|
|
180
|
-
f"See https://github.com/{REPO}/releases for available versions."
|
|
181
|
-
)
|
|
182
|
-
|
|
183
|
-
_verify_checksum(tmp_archive, archive, version)
|
|
184
|
-
|
|
185
|
-
if ext == "tar.gz":
|
|
186
|
-
import tarfile
|
|
187
|
-
with tarfile.open(tmp_archive) as tf:
|
|
188
|
-
_safe_extract_tar(tf, tmp)
|
|
189
|
-
else:
|
|
190
|
-
import zipfile
|
|
191
|
-
with zipfile.ZipFile(tmp_archive) as zf:
|
|
192
|
-
_safe_extract_zip(zf, tmp)
|
|
193
|
-
|
|
194
|
-
bin_name = "codebase-memory-mcp.exe" if os_name == "windows" else "codebase-memory-mcp"
|
|
195
|
-
extracted = os.path.join(tmp, bin_name)
|
|
196
|
-
if not os.path.exists(extracted):
|
|
197
|
-
sys.exit("codebase-memory-mcp: binary not found after extraction")
|
|
198
|
-
|
|
199
|
-
shutil.copy2(extracted, dest)
|
|
200
|
-
current = dest.stat().st_mode
|
|
201
|
-
dest.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
202
|
-
|
|
203
|
-
return dest
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def main() -> None:
|
|
207
|
-
version = _version()
|
|
208
|
-
bin_path = _bin_path(version)
|
|
209
|
-
|
|
210
|
-
if not bin_path.exists():
|
|
211
|
-
bin_path = _download(version)
|
|
212
|
-
|
|
213
|
-
# args is a list (not a shell string), so exec/subprocess treat each
|
|
214
|
-
# element as a discrete argv entry — no shell interpretation, no
|
|
215
|
-
# injection vector. sys.argv forwarding is the whole point of this
|
|
216
|
-
# shim, so tainted-input suppression is intentional.
|
|
217
|
-
args = [str(bin_path)] + sys.argv[1:]
|
|
218
|
-
|
|
219
|
-
if sys.platform != "win32":
|
|
220
|
-
os.execv(str(bin_path), args) # noqa: S606 — list form, no shell
|
|
221
|
-
else:
|
|
222
|
-
import subprocess
|
|
223
|
-
result = subprocess.run(args) # noqa: S603 — list form, no shell=True
|
|
224
|
-
sys.exit(result.returncode)
|
|
File without changes
|
{codebase_memory_mcp-0.8.1 → codebase_memory_mcp-0.9.1rc1}/src/codebase_memory_mcp/__init__.py
RENAMED
|
File without changes
|
{codebase_memory_mcp-0.8.1 → codebase_memory_mcp-0.9.1rc1}/src/codebase_memory_mcp/__main__.py
RENAMED
|
File without changes
|