codebase-memory-mcp 0.11.0__tar.gz → 0.12.1__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.11.0 → codebase_memory_mcp-0.12.1}/.gitignore +3 -0
- {codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.1}/PKG-INFO +3 -1
- {codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.1}/README.md +2 -0
- {codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.1}/pyproject.toml +1 -1
- {codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.1}/requirements-publish.txt +3 -3
- {codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.1}/src/codebase_memory_mcp/_cli.py +174 -42
- {codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.1}/tests/test_cli.py +361 -0
- {codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.1}/src/codebase_memory_mcp/__init__.py +0 -0
- {codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.1}/src/codebase_memory_mcp/__main__.py +0 -0
|
@@ -51,6 +51,9 @@ graph-ui/dist/
|
|
|
51
51
|
# Generated by scripts/embed-frontend.sh into the source tree (the linker needs
|
|
52
52
|
# it beside the other UI sources). Removed by scripts/clean.sh; never committed.
|
|
53
53
|
src/ui/embedded_assets.c
|
|
54
|
+
# Raw coverage profiles. A coverage-instrumented binary (make test-runner-cov,
|
|
55
|
+
# cbm-cov) run without LLVM_PROFILE_FILE drops default.profraw in its cwd.
|
|
56
|
+
*.profraw
|
|
54
57
|
|
|
55
58
|
# Generated reports
|
|
56
59
|
BENCHMARK_REPORT.md
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codebase-memory-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.1
|
|
4
4
|
Summary: Fast code intelligence for AI coding agents — installs a verified native runtime set
|
|
5
5
|
Project-URL: Homepage, https://github.com/DeusData/codebase-memory-mcp
|
|
6
6
|
Project-URL: Repository, https://github.com/DeusData/codebase-memory-mcp
|
|
@@ -38,6 +38,8 @@ pipx install codebase-memory-mcp
|
|
|
38
38
|
|
|
39
39
|
There is one composition per platform: the graph UI ships in every build, so no variant selection is needed.
|
|
40
40
|
|
|
41
|
+
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).
|
|
42
|
+
|
|
41
43
|
## Usage
|
|
42
44
|
|
|
43
45
|
```bash
|
|
@@ -16,6 +16,8 @@ pipx install codebase-memory-mcp
|
|
|
16
16
|
|
|
17
17
|
There is one composition per platform: the graph UI ships in every build, so no variant selection is needed.
|
|
18
18
|
|
|
19
|
+
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).
|
|
20
|
+
|
|
19
21
|
## Usage
|
|
20
22
|
|
|
21
23
|
```bash
|
|
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
|
|
|
8
8
|
|
|
9
9
|
[project]
|
|
10
10
|
name = "codebase-memory-mcp"
|
|
11
|
-
version = "0.
|
|
11
|
+
version = "0.12.1"
|
|
12
12
|
description = "Fast code intelligence for AI coding agents — installs a verified native runtime set"
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
license = { text = "MIT" }
|
|
@@ -354,6 +354,6 @@ secretstorage==3.5.0 \
|
|
|
354
354
|
twine==7.0.0 \
|
|
355
355
|
--hash=sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177 \
|
|
356
356
|
--hash=sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7
|
|
357
|
-
urllib3==2.
|
|
358
|
-
--hash=sha256:
|
|
359
|
-
--hash=sha256:
|
|
357
|
+
urllib3==2.8.0 \
|
|
358
|
+
--hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \
|
|
359
|
+
--hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63
|
|
@@ -38,10 +38,19 @@ _WINDOWS_ARCHIVE_NAMES = (
|
|
|
38
38
|
# file://, ftp://, and custom schemes — a redirect or tainted URL source
|
|
39
39
|
# could otherwise turn a download into an arbitrary-local-file read.
|
|
40
40
|
_ALLOWED_SCHEMES = frozenset({"https"})
|
|
41
|
+
# install.sh's loopback pattern: plain HTTP only to an exact loopback
|
|
42
|
+
# authority, for local smoke tests (CBM_DOWNLOAD_URL).
|
|
43
|
+
_LOOPBACK_HTTP_URL = re.compile(
|
|
44
|
+
r"http://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?([/?#].*)?"
|
|
45
|
+
)
|
|
46
|
+
_LOOPBACK_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
41
47
|
_MAX_REDIRECTS = 5
|
|
42
48
|
_NETWORK_TIMEOUT_SECONDS = 120
|
|
43
49
|
_CANDIDATE_TIMEOUT_SECONDS = 15
|
|
44
|
-
|
|
50
|
+
# Entry-count guard for the archive namespace check. Keep in sync with the
|
|
51
|
+
# Go wrapper (pkg/go/cmd/codebase-memory-mcp/main.go) and the npm wrapper.
|
|
52
|
+
_MAX_ARCHIVE_MEMBERS = 64
|
|
53
|
+
_ARCHIVE_COPY_CHUNK_BYTES = 64 * 1024
|
|
45
54
|
_REDIRECT_CODES = frozenset({301, 302, 303, 307, 308})
|
|
46
55
|
_RUNTIME_LOCK_NAME = ".codebase-memory-mcp-runtime.lock"
|
|
47
56
|
_RUNTIME_LOCK_WAIT_SECONDS = 45
|
|
@@ -62,13 +71,50 @@ _runtime_lock_claim_observer = None
|
|
|
62
71
|
|
|
63
72
|
|
|
64
73
|
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
65
|
-
"""Expose redirects to
|
|
74
|
+
"""Expose redirects to _download_file for explicit per-hop validation."""
|
|
66
75
|
|
|
67
76
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
68
77
|
return None
|
|
69
78
|
|
|
70
79
|
|
|
71
80
|
_HTTPS_OPENER = urllib.request.build_opener(_NoRedirectHandler())
|
|
81
|
+
# A loopback fetch never goes through a proxy.
|
|
82
|
+
_LOOPBACK_OPENER = urllib.request.build_opener(
|
|
83
|
+
urllib.request.ProxyHandler({}), _NoRedirectHandler()
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _download_base(version: str, environ=None):
|
|
88
|
+
"""Return (base, loopback) for release downloads.
|
|
89
|
+
|
|
90
|
+
CBM_DOWNLOAD_URL replaces the release download base (for testing), with
|
|
91
|
+
install.sh's policy: HTTPS, or plain HTTP to an exact loopback authority.
|
|
92
|
+
"""
|
|
93
|
+
env = os.environ if environ is None else environ
|
|
94
|
+
override = env.get("CBM_DOWNLOAD_URL")
|
|
95
|
+
if not override:
|
|
96
|
+
return f"https://github.com/{REPO}/releases/download/v{version}", False
|
|
97
|
+
if override.startswith("https://"):
|
|
98
|
+
return override, False
|
|
99
|
+
if _LOOPBACK_HTTP_URL.fullmatch(override):
|
|
100
|
+
return override, True
|
|
101
|
+
sys.exit(
|
|
102
|
+
f"codebase-memory-mcp: refusing non-HTTPS download URL "
|
|
103
|
+
f"(CBM_DOWNLOAD_URL): {override}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _validate_loopback_url(url: str) -> None:
|
|
108
|
+
"""Reject anything but plain HTTP to an exact loopback authority."""
|
|
109
|
+
parsed = urllib.parse.urlparse(url)
|
|
110
|
+
if (
|
|
111
|
+
not _LOOPBACK_HTTP_URL.fullmatch(url)
|
|
112
|
+
or parsed.scheme != "http"
|
|
113
|
+
or parsed.hostname not in _LOOPBACK_HOSTNAMES
|
|
114
|
+
or parsed.username is not None
|
|
115
|
+
or parsed.password is not None
|
|
116
|
+
):
|
|
117
|
+
sys.exit(f"codebase-memory-mcp: refusing non-loopback HTTP URL: {url}")
|
|
72
118
|
|
|
73
119
|
|
|
74
120
|
def _validate_url_scheme(url: str) -> None:
|
|
@@ -86,23 +132,33 @@ def _validate_url_scheme(url: str) -> None:
|
|
|
86
132
|
)
|
|
87
133
|
|
|
88
134
|
|
|
89
|
-
def
|
|
90
|
-
|
|
135
|
+
def _validate_download_url(url: str, loopback: bool) -> None:
|
|
136
|
+
if loopback:
|
|
137
|
+
_validate_loopback_url(url)
|
|
138
|
+
else:
|
|
139
|
+
_validate_url_scheme(url)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _download_file(url: str, dest: str, loopback: bool = False) -> None:
|
|
143
|
+
"""Download into dest: HTTPS with checked redirects, or one loopback hop."""
|
|
144
|
+
opener = _LOOPBACK_OPENER if loopback else _HTTPS_OPENER
|
|
91
145
|
current_url = url
|
|
92
146
|
for redirect_count in range(_MAX_REDIRECTS + 1):
|
|
93
|
-
|
|
147
|
+
_validate_download_url(current_url, loopback)
|
|
94
148
|
request = urllib.request.Request(
|
|
95
149
|
current_url, headers={"User-Agent": "codebase-memory-mcp-installer"}
|
|
96
150
|
)
|
|
97
151
|
try:
|
|
98
|
-
response =
|
|
99
|
-
request, timeout=_NETWORK_TIMEOUT_SECONDS
|
|
100
|
-
)
|
|
152
|
+
response = opener.open(request, timeout=_NETWORK_TIMEOUT_SECONDS)
|
|
101
153
|
except urllib.error.HTTPError as exc:
|
|
102
154
|
if exc.code not in _REDIRECT_CODES:
|
|
103
155
|
raise
|
|
104
156
|
location = exc.headers.get("Location")
|
|
105
157
|
exc.close()
|
|
158
|
+
if loopback:
|
|
159
|
+
raise RuntimeError(
|
|
160
|
+
f"refusing redirect from loopback download: {current_url}"
|
|
161
|
+
)
|
|
106
162
|
if not location:
|
|
107
163
|
raise RuntimeError(f"redirect has no Location: {current_url}")
|
|
108
164
|
if redirect_count == _MAX_REDIRECTS:
|
|
@@ -112,28 +168,35 @@ def _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
|
|
|
112
168
|
continue
|
|
113
169
|
|
|
114
170
|
with response:
|
|
115
|
-
|
|
171
|
+
_validate_download_url(response.geturl(), loopback)
|
|
116
172
|
deadline = time.monotonic() + _NETWORK_TIMEOUT_SECONDS
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
173
|
+
try:
|
|
174
|
+
with open(dest, "wb") as out:
|
|
175
|
+
while True:
|
|
176
|
+
if time.monotonic() >= deadline:
|
|
177
|
+
raise TimeoutError(
|
|
178
|
+
f"download hop timed out: {current_url}"
|
|
179
|
+
)
|
|
180
|
+
chunk = response.read(65536)
|
|
181
|
+
if not chunk:
|
|
182
|
+
return
|
|
183
|
+
out.write(chunk)
|
|
184
|
+
except BaseException:
|
|
185
|
+
# Never leave a partial download behind for a later step to
|
|
186
|
+
# mistake for the complete asset.
|
|
187
|
+
_remove_quietly(dest)
|
|
188
|
+
raise
|
|
133
189
|
|
|
134
190
|
raise RuntimeError("too many redirects")
|
|
135
191
|
|
|
136
192
|
|
|
193
|
+
def _remove_quietly(path) -> None:
|
|
194
|
+
try:
|
|
195
|
+
os.unlink(path)
|
|
196
|
+
except OSError:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
|
|
137
200
|
def _verify_candidate(path: Path) -> None:
|
|
138
201
|
"""Require a staged native candidate to execute successfully."""
|
|
139
202
|
try:
|
|
@@ -196,17 +259,74 @@ def _validate_archive_names(names, archive_names, casefold: bool = False):
|
|
|
196
259
|
)
|
|
197
260
|
|
|
198
261
|
|
|
262
|
+
def _require_regular_archive(archive_path: str) -> None:
|
|
263
|
+
if not stat.S_ISREG(os.stat(archive_path).st_mode):
|
|
264
|
+
sys.exit("codebase-memory-mcp: release archive is not a regular file")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _require_member_count_within_limit(count: int, max_members: int) -> None:
|
|
268
|
+
if count > max_members:
|
|
269
|
+
sys.exit(
|
|
270
|
+
f"codebase-memory-mcp: archive exceeds the {max_members}-member "
|
|
271
|
+
f"safety limit"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _require_declared_size(name: str, size: int) -> None:
|
|
276
|
+
if size < 0:
|
|
277
|
+
sys.exit(
|
|
278
|
+
f"codebase-memory-mcp: archive member has a negative declared "
|
|
279
|
+
f"size: {name!r}"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _copy_archive_member(source, output, name: str, declared_size: int) -> int:
|
|
284
|
+
"""Copy a member, then require the declared size; return the bytes copied.
|
|
285
|
+
|
|
286
|
+
The caller removes the target on failure.
|
|
287
|
+
"""
|
|
288
|
+
copied = 0
|
|
289
|
+
while True:
|
|
290
|
+
chunk = source.read(_ARCHIVE_COPY_CHUNK_BYTES)
|
|
291
|
+
if not chunk:
|
|
292
|
+
break
|
|
293
|
+
copied += len(chunk)
|
|
294
|
+
output.write(chunk)
|
|
295
|
+
if copied != declared_size:
|
|
296
|
+
sys.exit(
|
|
297
|
+
f"codebase-memory-mcp: archive member {name!r} actual size {copied} "
|
|
298
|
+
f"does not match declared size {declared_size}"
|
|
299
|
+
)
|
|
300
|
+
return copied
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _write_archive_member(source, target: str, name: str, declared_size: int) -> int:
|
|
304
|
+
try:
|
|
305
|
+
with open(target, "xb") as output:
|
|
306
|
+
return _copy_archive_member(source, output, name, declared_size)
|
|
307
|
+
except BaseException:
|
|
308
|
+
_remove_quietly(target)
|
|
309
|
+
raise
|
|
310
|
+
|
|
311
|
+
|
|
199
312
|
def _safe_extract_tar(
|
|
200
|
-
tf,
|
|
313
|
+
tf,
|
|
314
|
+
dest: str,
|
|
315
|
+
archive_names=(),
|
|
316
|
+
extract_names=(),
|
|
317
|
+
ui: bool = False,
|
|
318
|
+
max_members: int = _MAX_ARCHIVE_MEMBERS,
|
|
201
319
|
):
|
|
202
320
|
"""Validate a tar namespace, then extract only its authenticated runtime set."""
|
|
203
321
|
members = tf.getmembers()
|
|
322
|
+
_require_member_count_within_limit(len(members), max_members)
|
|
204
323
|
for member in members:
|
|
205
324
|
if not member.isfile():
|
|
206
325
|
sys.exit(
|
|
207
326
|
f"codebase-memory-mcp: refusing unsafe tar entry "
|
|
208
327
|
f"(not a regular root file: {member.name!r})"
|
|
209
328
|
)
|
|
329
|
+
_require_declared_size(member.name, member.size)
|
|
210
330
|
_validate_archive_names([member.name for member in members], archive_names)
|
|
211
331
|
runtime_names = list(extract_names)
|
|
212
332
|
by_name = {member.name: member for member in members}
|
|
@@ -216,13 +336,18 @@ def _safe_extract_tar(
|
|
|
216
336
|
if source is None:
|
|
217
337
|
sys.exit(f"codebase-memory-mcp: could not read archive member: {name}")
|
|
218
338
|
target = os.path.join(dest_abs, name)
|
|
219
|
-
with source
|
|
220
|
-
|
|
339
|
+
with source:
|
|
340
|
+
_write_archive_member(source, target, name, by_name[name].size)
|
|
221
341
|
return tuple(runtime_names)
|
|
222
342
|
|
|
223
343
|
|
|
224
344
|
def _safe_extract_zip(
|
|
225
|
-
zf,
|
|
345
|
+
zf,
|
|
346
|
+
dest: str,
|
|
347
|
+
archive_names=(),
|
|
348
|
+
extract_names=(),
|
|
349
|
+
ui: bool = False,
|
|
350
|
+
max_members: int = _MAX_ARCHIVE_MEMBERS,
|
|
226
351
|
):
|
|
227
352
|
"""Extract a zipfile after validating its Windows-style namespace.
|
|
228
353
|
|
|
@@ -232,7 +357,10 @@ def _safe_extract_zip(
|
|
|
232
357
|
"""
|
|
233
358
|
dest_abs = os.path.abspath(dest)
|
|
234
359
|
names = []
|
|
235
|
-
|
|
360
|
+
infos = zf.infolist()
|
|
361
|
+
_require_member_count_within_limit(len(infos), max_members)
|
|
362
|
+
for info in infos:
|
|
363
|
+
_require_declared_size(info.filename, info.file_size)
|
|
236
364
|
raw_name = info.filename
|
|
237
365
|
name = raw_name.replace("\\", "/")
|
|
238
366
|
segments_name = name[:-1] if name.endswith("/") else name
|
|
@@ -268,20 +396,22 @@ def _safe_extract_zip(
|
|
|
268
396
|
runtime_names = list(extract_names)
|
|
269
397
|
for name in runtime_names:
|
|
270
398
|
target = os.path.join(dest_abs, name)
|
|
271
|
-
with zf.open(name) as source
|
|
272
|
-
|
|
399
|
+
with zf.open(name) as source:
|
|
400
|
+
_write_archive_member(source, target, name, zf.getinfo(name).file_size)
|
|
273
401
|
return tuple(runtime_names)
|
|
274
402
|
|
|
275
403
|
|
|
276
|
-
def _verify_checksum(
|
|
277
|
-
|
|
278
|
-
|
|
404
|
+
def _verify_checksum(
|
|
405
|
+
archive_path: str, archive_name: str, base: str, loopback: bool = False
|
|
406
|
+
) -> None:
|
|
407
|
+
"""Verify SHA256 checksum against checksums.txt from the same base."""
|
|
408
|
+
url = f"{base}/checksums.txt"
|
|
279
409
|
tmp_path = None
|
|
280
410
|
try:
|
|
281
|
-
|
|
411
|
+
_validate_download_url(url, loopback)
|
|
282
412
|
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
|
|
283
413
|
tmp_path = tmp.name
|
|
284
|
-
|
|
414
|
+
_download_file(url, tmp_path, loopback)
|
|
285
415
|
expected = None
|
|
286
416
|
with open(tmp_path, encoding="utf-8") as f:
|
|
287
417
|
for line in f:
|
|
@@ -338,7 +468,7 @@ def _version() -> str:
|
|
|
338
468
|
from importlib.metadata import version
|
|
339
469
|
return version("codebase-memory-mcp")
|
|
340
470
|
except Exception:
|
|
341
|
-
return "0.
|
|
471
|
+
return "0.12.0"
|
|
342
472
|
|
|
343
473
|
|
|
344
474
|
def _os_name() -> str:
|
|
@@ -1165,8 +1295,9 @@ def _download(version: str) -> Path:
|
|
|
1165
1295
|
# have no such variant. Keep in sync with install.sh / install.js / cli.c.
|
|
1166
1296
|
portable = "-portable" if os_name == "linux" else ""
|
|
1167
1297
|
archive = f"codebase-memory-mcp-{os_name}-{arch}{portable}.{ext}"
|
|
1168
|
-
|
|
1169
|
-
|
|
1298
|
+
base, loopback = _download_base(version)
|
|
1299
|
+
url = f"{base}/{archive}"
|
|
1300
|
+
_validate_download_url(url, loopback)
|
|
1170
1301
|
|
|
1171
1302
|
dest = _bin_path(version)
|
|
1172
1303
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -1179,7 +1310,7 @@ def _download(version: str) -> Path:
|
|
|
1179
1310
|
with tempfile.TemporaryDirectory() as tmp:
|
|
1180
1311
|
tmp_archive = os.path.join(tmp, f"cbm.{ext}")
|
|
1181
1312
|
try:
|
|
1182
|
-
|
|
1313
|
+
_download_file(url, tmp_archive, loopback)
|
|
1183
1314
|
except (OSError, RuntimeError, urllib.error.URLError) as e:
|
|
1184
1315
|
sys.exit(
|
|
1185
1316
|
f"codebase-memory-mcp: download failed ({e})\n"
|
|
@@ -1187,7 +1318,8 @@ def _download(version: str) -> Path:
|
|
|
1187
1318
|
f"See https://github.com/{REPO}/releases for available versions."
|
|
1188
1319
|
)
|
|
1189
1320
|
|
|
1190
|
-
_verify_checksum(tmp_archive, archive,
|
|
1321
|
+
_verify_checksum(tmp_archive, archive, base, loopback)
|
|
1322
|
+
_require_regular_archive(tmp_archive)
|
|
1191
1323
|
|
|
1192
1324
|
bin_name = (
|
|
1193
1325
|
_WINDOWS_BINARY_NAME if os_name == "windows" else "codebase-memory-mcp"
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import contextlib
|
|
1
2
|
import errno
|
|
2
3
|
import hashlib
|
|
4
|
+
import http.server
|
|
5
|
+
import io
|
|
3
6
|
import json
|
|
4
7
|
import os
|
|
5
8
|
import stat
|
|
@@ -835,3 +838,361 @@ _cli._publish_runtime_set(
|
|
|
835
838
|
False,
|
|
836
839
|
)
|
|
837
840
|
|
|
841
|
+
|
|
842
|
+
class _FakeHttpsResponse:
|
|
843
|
+
"""Minimal stand-in for the opener's response: headers, geturl, read."""
|
|
844
|
+
|
|
845
|
+
def __init__(self, body: bytes, headers=None, fail_after_first_chunk=False):
|
|
846
|
+
self._body = body
|
|
847
|
+
self._offset = 0
|
|
848
|
+
self._fail_after_first_chunk = fail_after_first_chunk
|
|
849
|
+
self.headers = dict(headers or {})
|
|
850
|
+
|
|
851
|
+
def __enter__(self):
|
|
852
|
+
return self
|
|
853
|
+
|
|
854
|
+
def __exit__(self, *exc_info):
|
|
855
|
+
return False
|
|
856
|
+
|
|
857
|
+
def geturl(self):
|
|
858
|
+
return "https://example.invalid/release.tar.gz"
|
|
859
|
+
|
|
860
|
+
def read(self, size=-1):
|
|
861
|
+
if self._fail_after_first_chunk and self._offset:
|
|
862
|
+
raise OSError("connection reset")
|
|
863
|
+
if size is None or size < 0:
|
|
864
|
+
size = len(self._body) - self._offset
|
|
865
|
+
chunk = self._body[self._offset : self._offset + size]
|
|
866
|
+
self._offset += len(chunk)
|
|
867
|
+
return chunk
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
class _ZeroSource:
|
|
871
|
+
"""Reader that yields `size` zero bytes without holding them."""
|
|
872
|
+
|
|
873
|
+
def __init__(self, size: int):
|
|
874
|
+
self._remaining = size
|
|
875
|
+
|
|
876
|
+
def read(self, size=-1):
|
|
877
|
+
if size is None or size < 0:
|
|
878
|
+
size = self._remaining
|
|
879
|
+
count = min(size, self._remaining)
|
|
880
|
+
self._remaining -= count
|
|
881
|
+
return bytes(count)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
class _CountingSink:
|
|
885
|
+
def __init__(self):
|
|
886
|
+
self.written = 0
|
|
887
|
+
|
|
888
|
+
def write(self, chunk):
|
|
889
|
+
self.written += len(chunk)
|
|
890
|
+
return len(chunk)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
# The size cap the 0.12.0 wrappers enforced, which refused every release.
|
|
894
|
+
_FORMER_MEMBER_CAP_BYTES = 256 * 1024 * 1024
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _write_tar_gz(archive, entries):
|
|
898
|
+
with tarfile.open(archive, "w:gz") as tf:
|
|
899
|
+
for name, data in entries:
|
|
900
|
+
info = tarfile.TarInfo(name)
|
|
901
|
+
info.size = len(data)
|
|
902
|
+
tf.addfile(info, io.BytesIO(data))
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _write_zip(archive, entries):
|
|
906
|
+
with zipfile.ZipFile(archive, "w") as zf:
|
|
907
|
+
for name, data in entries:
|
|
908
|
+
zf.writestr(name, data)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _release_entries(names):
|
|
912
|
+
return [(name, f"payload of {name}".encode()) for name in names]
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
class ArchiveExtractionTests(unittest.TestCase):
|
|
916
|
+
URL = "https://example.invalid/release.tar.gz"
|
|
917
|
+
|
|
918
|
+
def _download(self, response, destination):
|
|
919
|
+
with mock.patch.object(_cli._HTTPS_OPENER, "open", return_value=response):
|
|
920
|
+
_cli._download_file(self.URL, destination)
|
|
921
|
+
|
|
922
|
+
def test_download_stores_a_body_whose_declared_length_exceeds_the_former_cap(
|
|
923
|
+
self,
|
|
924
|
+
):
|
|
925
|
+
with tempfile.TemporaryDirectory() as root:
|
|
926
|
+
destination = os.path.join(root, "release.tar.gz")
|
|
927
|
+
response = _FakeHttpsResponse(
|
|
928
|
+
b"payload", {"Content-Length": str(_FORMER_MEMBER_CAP_BYTES + 1)}
|
|
929
|
+
)
|
|
930
|
+
self._download(response, destination)
|
|
931
|
+
with open(destination, "rb") as stored:
|
|
932
|
+
self.assertEqual(stored.read(), b"payload")
|
|
933
|
+
|
|
934
|
+
def test_download_removes_the_partial_file_when_the_body_fails(self):
|
|
935
|
+
with tempfile.TemporaryDirectory() as root:
|
|
936
|
+
destination = os.path.join(root, "release.tar.gz")
|
|
937
|
+
response = _FakeHttpsResponse(b"x" * 200000, fail_after_first_chunk=True)
|
|
938
|
+
with self.assertRaises(OSError):
|
|
939
|
+
self._download(response, destination)
|
|
940
|
+
self.assertFalse(os.path.exists(destination))
|
|
941
|
+
|
|
942
|
+
def test_unix_tar_rejects_more_members_than_the_limit(self):
|
|
943
|
+
with tempfile.TemporaryDirectory() as root:
|
|
944
|
+
archive = Path(root) / "members.tar.gz"
|
|
945
|
+
_write_tar_gz(archive, [("one", b"a"), ("two", b"b"), ("three", b"c")])
|
|
946
|
+
destination = Path(root) / "extract"
|
|
947
|
+
destination.mkdir()
|
|
948
|
+
with tarfile.open(archive) as tf, self.assertRaises(SystemExit) as raised:
|
|
949
|
+
_cli._safe_extract_tar(
|
|
950
|
+
tf,
|
|
951
|
+
str(destination),
|
|
952
|
+
("one", "two", "three"),
|
|
953
|
+
("one",),
|
|
954
|
+
max_members=2,
|
|
955
|
+
)
|
|
956
|
+
self.assertIn("2-member safety limit", str(raised.exception))
|
|
957
|
+
self.assertFalse((destination / "one").exists())
|
|
958
|
+
|
|
959
|
+
def test_unix_tar_extracts_only_the_executable(self):
|
|
960
|
+
with tempfile.TemporaryDirectory() as root:
|
|
961
|
+
archive = Path(root) / "release.tar.gz"
|
|
962
|
+
_write_tar_gz(archive, _release_entries(_cli._UNIX_ARCHIVE_NAMES))
|
|
963
|
+
destination = Path(root) / "extract"
|
|
964
|
+
destination.mkdir()
|
|
965
|
+
with tarfile.open(archive) as tf:
|
|
966
|
+
extracted = _cli._safe_extract_tar(
|
|
967
|
+
tf,
|
|
968
|
+
str(destination),
|
|
969
|
+
_cli._UNIX_ARCHIVE_NAMES,
|
|
970
|
+
("codebase-memory-mcp",),
|
|
971
|
+
)
|
|
972
|
+
self.assertEqual(extracted, ("codebase-memory-mcp",))
|
|
973
|
+
self.assertEqual(
|
|
974
|
+
(destination / "codebase-memory-mcp").read_bytes(),
|
|
975
|
+
b"payload of codebase-memory-mcp",
|
|
976
|
+
)
|
|
977
|
+
self.assertEqual(os.listdir(destination), ["codebase-memory-mcp"])
|
|
978
|
+
|
|
979
|
+
def test_archive_copy_accepts_a_member_larger_than_the_former_cap(self):
|
|
980
|
+
size = _FORMER_MEMBER_CAP_BYTES + 1024 * 1024
|
|
981
|
+
sink = _CountingSink()
|
|
982
|
+
copied = _cli._copy_archive_member(
|
|
983
|
+
_ZeroSource(size), sink, "codebase-memory-mcp", size
|
|
984
|
+
)
|
|
985
|
+
self.assertEqual(copied, size)
|
|
986
|
+
self.assertEqual(sink.written, size)
|
|
987
|
+
|
|
988
|
+
def test_archive_copy_rejects_a_member_that_yields_more_than_it_declares(self):
|
|
989
|
+
with self.assertRaises(SystemExit) as raised:
|
|
990
|
+
_cli._copy_archive_member(io.BytesIO(b"x" * 100), io.BytesIO(), "member", 10)
|
|
991
|
+
self.assertIn(
|
|
992
|
+
"'member' actual size 100 does not match declared size 10",
|
|
993
|
+
str(raised.exception),
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
def test_archive_member_write_removes_the_partial_file_on_failure(self):
|
|
997
|
+
with tempfile.TemporaryDirectory() as root:
|
|
998
|
+
target = os.path.join(root, "codebase-memory-mcp")
|
|
999
|
+
with self.assertRaises(SystemExit):
|
|
1000
|
+
_cli._write_archive_member(
|
|
1001
|
+
io.BytesIO(b"x" * 100), target, "codebase-memory-mcp", 10
|
|
1002
|
+
)
|
|
1003
|
+
self.assertFalse(os.path.exists(target))
|
|
1004
|
+
|
|
1005
|
+
def test_windows_zip_rejects_more_members_than_the_limit(self):
|
|
1006
|
+
with tempfile.TemporaryDirectory() as root:
|
|
1007
|
+
archive = Path(root) / "members.zip"
|
|
1008
|
+
_write_zip(archive, [("one", b"a"), ("two", b"b"), ("three", b"c")])
|
|
1009
|
+
destination = Path(root) / "extract"
|
|
1010
|
+
destination.mkdir()
|
|
1011
|
+
with zipfile.ZipFile(archive) as zf, self.assertRaises(SystemExit) as raised:
|
|
1012
|
+
_cli._safe_extract_zip(
|
|
1013
|
+
zf,
|
|
1014
|
+
str(destination),
|
|
1015
|
+
("one", "two", "three"),
|
|
1016
|
+
("one",),
|
|
1017
|
+
max_members=2,
|
|
1018
|
+
)
|
|
1019
|
+
self.assertIn("2-member safety limit", str(raised.exception))
|
|
1020
|
+
|
|
1021
|
+
def test_windows_zip_extracts_only_the_executable(self):
|
|
1022
|
+
with tempfile.TemporaryDirectory() as root:
|
|
1023
|
+
archive = Path(root) / "release.zip"
|
|
1024
|
+
_write_zip(archive, _release_entries(_cli._WINDOWS_ARCHIVE_NAMES))
|
|
1025
|
+
destination = Path(root) / "extract"
|
|
1026
|
+
destination.mkdir()
|
|
1027
|
+
with zipfile.ZipFile(archive) as zf:
|
|
1028
|
+
extracted = _cli._safe_extract_zip(
|
|
1029
|
+
zf,
|
|
1030
|
+
str(destination),
|
|
1031
|
+
_cli._WINDOWS_ARCHIVE_NAMES,
|
|
1032
|
+
(_cli._WINDOWS_BINARY_NAME,),
|
|
1033
|
+
)
|
|
1034
|
+
self.assertEqual(extracted, (_cli._WINDOWS_BINARY_NAME,))
|
|
1035
|
+
self.assertEqual(
|
|
1036
|
+
(destination / _cli._WINDOWS_BINARY_NAME).read_bytes(),
|
|
1037
|
+
b"payload of codebase-memory-mcp.exe",
|
|
1038
|
+
)
|
|
1039
|
+
self.assertEqual(os.listdir(destination), [_cli._WINDOWS_BINARY_NAME])
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
@contextlib.contextmanager
|
|
1043
|
+
def _loopback_server(routes):
|
|
1044
|
+
"""Serve `routes` ({path: bytes}) on 127.0.0.1; any other path redirects."""
|
|
1045
|
+
|
|
1046
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
1047
|
+
def do_GET(self):
|
|
1048
|
+
body = routes.get(self.path)
|
|
1049
|
+
if body is None:
|
|
1050
|
+
self.send_response(302)
|
|
1051
|
+
self.send_header("Location", "https://example.invalid/elsewhere")
|
|
1052
|
+
self.send_header("Content-Length", "0")
|
|
1053
|
+
self.end_headers()
|
|
1054
|
+
return
|
|
1055
|
+
self.send_response(200)
|
|
1056
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1057
|
+
self.end_headers()
|
|
1058
|
+
self.wfile.write(body)
|
|
1059
|
+
|
|
1060
|
+
def log_message(self, *args):
|
|
1061
|
+
pass
|
|
1062
|
+
|
|
1063
|
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
1064
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
1065
|
+
thread.start()
|
|
1066
|
+
try:
|
|
1067
|
+
yield f"http://127.0.0.1:{server.server_address[1]}"
|
|
1068
|
+
finally:
|
|
1069
|
+
server.shutdown()
|
|
1070
|
+
server.server_close()
|
|
1071
|
+
thread.join()
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
class DownloadBaseTests(unittest.TestCase):
|
|
1075
|
+
GITHUB_BASE = "https://github.com/DeusData/codebase-memory-mcp/releases/download/v1.2.3"
|
|
1076
|
+
|
|
1077
|
+
def test_unset_or_empty_uses_the_github_release_base(self):
|
|
1078
|
+
self.assertEqual(_cli._download_base("1.2.3", {}), (self.GITHUB_BASE, False))
|
|
1079
|
+
self.assertEqual(
|
|
1080
|
+
_cli._download_base("1.2.3", {"CBM_DOWNLOAD_URL": ""}),
|
|
1081
|
+
(self.GITHUB_BASE, False),
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
def test_https_override_is_accepted(self):
|
|
1085
|
+
for base in (
|
|
1086
|
+
"https://mirror.example/releases/v1",
|
|
1087
|
+
"https://127.0.0.1:8443/fixture",
|
|
1088
|
+
):
|
|
1089
|
+
with self.subTest(base=base):
|
|
1090
|
+
self.assertEqual(
|
|
1091
|
+
_cli._download_base("1.2.3", {"CBM_DOWNLOAD_URL": base}),
|
|
1092
|
+
(base, False),
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
def test_exact_loopback_http_override_is_accepted(self):
|
|
1096
|
+
for base in (
|
|
1097
|
+
"http://localhost",
|
|
1098
|
+
"http://localhost:8080/fixture",
|
|
1099
|
+
"http://127.0.0.1:41234/standard",
|
|
1100
|
+
"http://[::1]:9/x?y#z",
|
|
1101
|
+
):
|
|
1102
|
+
with self.subTest(base=base):
|
|
1103
|
+
self.assertEqual(
|
|
1104
|
+
_cli._download_base("1.2.3", {"CBM_DOWNLOAD_URL": base}),
|
|
1105
|
+
(base, True),
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
def test_non_loopback_http_and_other_schemes_are_refused(self):
|
|
1109
|
+
for base in (
|
|
1110
|
+
"http://example.com/releases",
|
|
1111
|
+
"http://127.0.0.1.example.com/",
|
|
1112
|
+
"http://localhost.example.com:8080/",
|
|
1113
|
+
"http://user@localhost/",
|
|
1114
|
+
"http://127.0.0.2/",
|
|
1115
|
+
"HTTP://localhost/",
|
|
1116
|
+
"file:///tmp/releases",
|
|
1117
|
+
"ftp://localhost/releases",
|
|
1118
|
+
"github.com/DeusData/codebase-memory-mcp",
|
|
1119
|
+
):
|
|
1120
|
+
with self.subTest(base=base), self.assertRaises(SystemExit) as raised:
|
|
1121
|
+
_cli._download_base("1.2.3", {"CBM_DOWNLOAD_URL": base})
|
|
1122
|
+
self.assertIn(
|
|
1123
|
+
"refusing non-HTTPS download URL (CBM_DOWNLOAD_URL)",
|
|
1124
|
+
str(raised.exception),
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
def test_https_download_refuses_plain_http(self):
|
|
1128
|
+
with tempfile.TemporaryDirectory() as root:
|
|
1129
|
+
with self.assertRaises(SystemExit):
|
|
1130
|
+
_cli._download_file(
|
|
1131
|
+
"http://127.0.0.1/release.tar.gz", os.path.join(root, "a")
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
def test_loopback_download_refuses_a_non_loopback_url(self):
|
|
1135
|
+
with tempfile.TemporaryDirectory() as root:
|
|
1136
|
+
with self.assertRaises(SystemExit) as raised:
|
|
1137
|
+
_cli._download_file(
|
|
1138
|
+
"http://example.invalid/release.tar.gz",
|
|
1139
|
+
os.path.join(root, "a"),
|
|
1140
|
+
loopback=True,
|
|
1141
|
+
)
|
|
1142
|
+
self.assertIn("refusing non-loopback HTTP URL", str(raised.exception))
|
|
1143
|
+
|
|
1144
|
+
def test_loopback_download_bypasses_proxies_and_refuses_redirects(self):
|
|
1145
|
+
routes = {"/fixture/archive": b"loopback payload"}
|
|
1146
|
+
# A configured proxy that is not listening: using it would fail.
|
|
1147
|
+
proxy_env = {"http_proxy": "http://127.0.0.1:9", "HTTP_PROXY": "http://127.0.0.1:9"}
|
|
1148
|
+
with _loopback_server(routes) as origin, tempfile.TemporaryDirectory() as root, \
|
|
1149
|
+
mock.patch.dict(os.environ, proxy_env):
|
|
1150
|
+
archive = os.path.join(root, "archive")
|
|
1151
|
+
_cli._download_file(f"{origin}/fixture/archive", archive, loopback=True)
|
|
1152
|
+
with open(archive, "rb") as stored:
|
|
1153
|
+
self.assertEqual(stored.read(), b"loopback payload")
|
|
1154
|
+
|
|
1155
|
+
redirected = os.path.join(root, "redirected")
|
|
1156
|
+
with self.assertRaises(RuntimeError) as raised:
|
|
1157
|
+
_cli._download_file(
|
|
1158
|
+
f"{origin}/fixture/checksums.txt", redirected, loopback=True
|
|
1159
|
+
)
|
|
1160
|
+
self.assertIn("refusing redirect from loopback download", str(raised.exception))
|
|
1161
|
+
self.assertFalse(os.path.exists(redirected))
|
|
1162
|
+
|
|
1163
|
+
def test_download_fetches_archive_and_checksums_from_the_override(self):
|
|
1164
|
+
os_name = _cli._os_name()
|
|
1165
|
+
arch = _cli._arch()
|
|
1166
|
+
if os_name == "windows":
|
|
1167
|
+
ext, names = "zip", _cli._WINDOWS_ARCHIVE_NAMES
|
|
1168
|
+
else:
|
|
1169
|
+
ext, names = "tar.gz", _cli._UNIX_ARCHIVE_NAMES
|
|
1170
|
+
portable = "-portable" if os_name == "linux" else ""
|
|
1171
|
+
archive_name = f"codebase-memory-mcp-{os_name}-{arch}{portable}.{ext}"
|
|
1172
|
+
with tempfile.TemporaryDirectory() as root:
|
|
1173
|
+
fixture = Path(root) / archive_name
|
|
1174
|
+
if ext == "zip":
|
|
1175
|
+
_write_zip(fixture, _release_entries(names))
|
|
1176
|
+
else:
|
|
1177
|
+
_write_tar_gz(fixture, _release_entries(names))
|
|
1178
|
+
archive_bytes = fixture.read_bytes()
|
|
1179
|
+
digest = hashlib.sha256(archive_bytes).hexdigest()
|
|
1180
|
+
routes = {
|
|
1181
|
+
f"/fixture/{archive_name}": archive_bytes,
|
|
1182
|
+
"/fixture/checksums.txt": f"{digest} {archive_name}\n".encode(),
|
|
1183
|
+
}
|
|
1184
|
+
cache = Path(root) / "cache"
|
|
1185
|
+
with _loopback_server(routes) as origin, mock.patch.dict(
|
|
1186
|
+
os.environ, {"CBM_DOWNLOAD_URL": f"{origin}/fixture"}
|
|
1187
|
+
), mock.patch.object(_cli, "_cache_dir", return_value=cache), \
|
|
1188
|
+
mock.patch.object(_cli, "_verify_candidate"), \
|
|
1189
|
+
contextlib.redirect_stderr(io.StringIO()):
|
|
1190
|
+
installed = _cli._download("9.9.9")
|
|
1191
|
+
binary_name = (
|
|
1192
|
+
_cli._WINDOWS_BINARY_NAME
|
|
1193
|
+
if os_name == "windows"
|
|
1194
|
+
else "codebase-memory-mcp"
|
|
1195
|
+
)
|
|
1196
|
+
self.assertEqual(
|
|
1197
|
+
installed.read_bytes(), f"payload of {binary_name}".encode()
|
|
1198
|
+
)
|
{codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.1}/src/codebase_memory_mcp/__init__.py
RENAMED
|
File without changes
|
{codebase_memory_mcp-0.11.0 → codebase_memory_mcp-0.12.1}/src/codebase_memory_mcp/__main__.py
RENAMED
|
File without changes
|