@@ -8,10 +8,13 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
88python3 - " $ROOT " << 'PY '
99from __future__ import annotations
1010
11+ import hashlib
12+ import os
1113import pathlib
1214import re
1315import subprocess
1416import sys
17+ import tarfile
1518import tempfile
1619import time
1720import urllib.request
@@ -415,6 +418,171 @@ require(
415418 "scripts/test.sh must run the smoke fixture contract",
416419)
417420
421+ # Functional wrapper contract: install.sh must pass its wrapper-only selectors
422+ # to the downloaded candidate, while retaining the existing dir/skip controls.
423+ # Native Windows ships and exercises install.ps1; the Unix wrapper is covered
424+ # on both macOS and Linux venue legs.
425+ if sys.platform != "win32":
426+ with tempfile.TemporaryDirectory(prefix="cbm-install-wrapper-") as temp:
427+ temp_path = pathlib.Path(temp)
428+ fixture = temp_path / "fixture"
429+ payload = temp_path / "payload"
430+ fixture.mkdir()
431+ payload.mkdir()
432+
433+ uname_s = subprocess.check_output(["uname", "-s"], text=True).strip()
434+ uname_m = subprocess.check_output(["uname", "-m"], text=True).strip()
435+ os_name = "darwin" if uname_s == "Darwin" else "linux"
436+ if uname_m in ("arm64", "aarch64"):
437+ arch_name = "arm64"
438+ else:
439+ arch_name = "amd64"
440+ portable = "-portable" if os_name == "linux" else ""
441+ archive_name = (
442+ f"codebase-memory-mcp-{os_name}-{arch_name}{portable}.tar.gz"
443+ )
444+ archive = fixture / archive_name
445+
446+ candidate = payload / "codebase-memory-mcp"
447+ candidate.write_text(
448+ "#!/usr/bin/env bash\n"
449+ "set -euo pipefail\n"
450+ "if [ \"${1:-}\" = --version ]; then echo 'cbm fixture 0'; exit 0; fi\n"
451+ "if [ \"${1:-}\" != install ]; then exit 64; fi\n"
452+ "printf '%s\\n' \"$@\" > \"$CBM_INSTALL_ARG_LOG\"\n"
453+ "target=''\n"
454+ "for arg in \"$@\"; do case \"$arg\" in --dir=*) target=${arg#--dir=} ;; esac; done\n"
455+ "[ -n \"$target\" ] || exit 65\n"
456+ "mkdir -p \"$target\"\n"
457+ "cp \"$0\" \"$target/codebase-memory-mcp\"\n",
458+ encoding="utf-8",
459+ )
460+ candidate.chmod(0o755)
461+ (payload / "LICENSE").write_text("fixture license\n", encoding="utf-8")
462+ (payload / "install.sh").write_text(install_script, encoding="utf-8")
463+ (payload / "THIRD_PARTY_NOTICES.md").write_text(
464+ "fixture notices\n", encoding="utf-8"
465+ )
466+ with tarfile.open(archive, "w:gz") as bundle:
467+ for name in (
468+ "codebase-memory-mcp",
469+ "LICENSE",
470+ "install.sh",
471+ "THIRD_PARTY_NOTICES.md",
472+ ):
473+ bundle.add(payload / name, arcname=name)
474+ digest = hashlib.sha256(archive.read_bytes()).hexdigest()
475+ (fixture / "checksums.txt").write_text(
476+ f"{digest} {archive_name}\n", encoding="ascii"
477+ )
478+
479+ # Replace curl only inside the subprocess environment. The wrapper still
480+ # performs its real archive/checksum/extraction flow, but all bytes come
481+ # from this generated fixture and no socket is opened.
482+ fake_bin = temp_path / "fake-bin"
483+ fake_bin.mkdir()
484+ fake_curl = fake_bin / "curl"
485+ fake_curl.write_text(
486+ "#!/usr/bin/env python3\n"
487+ "import os, pathlib, shutil, sys, urllib.parse\n"
488+ "args = sys.argv[1:]\n"
489+ "target = pathlib.Path(args[args.index('-o') + 1])\n"
490+ "name = pathlib.PurePosixPath(urllib.parse.urlparse(args[-1]).path).name\n"
491+ "shutil.copyfile(pathlib.Path(os.environ['CBM_INSTALL_FIXTURE']) / name, target)\n",
492+ encoding="utf-8",
493+ )
494+ fake_curl.chmod(0o755)
495+
496+ wrapper = root / "install.sh"
497+ help_result = subprocess.run(
498+ [str(wrapper), "--help"],
499+ text=True,
500+ stdout=subprocess.PIPE,
501+ stderr=subprocess.STDOUT,
502+ timeout=10,
503+ check=False,
504+ )
505+ require(
506+ help_result.returncode == 0
507+ and "--dir" in help_result.stdout
508+ and "--skip-config" in help_result.stdout
509+ and "--clients" in help_result.stdout,
510+ "install.sh --help must document dir, skip-config, and clients",
511+ )
512+
513+ base_env = dict(os.environ)
514+ base_env.update(
515+ CBM_DOWNLOAD_URL="http://127.0.0.1:9",
516+ CBM_INSTALL_FIXTURE=str(fixture),
517+ HOME=str(temp_path / "home"),
518+ PATH=f"{fake_bin}{os.pathsep}{base_env['PATH']}",
519+ )
520+ pathlib.Path(base_env["HOME"]).mkdir()
521+
522+ def run_wrapper(
523+ log_name: str, *arguments: str
524+ ) -> tuple[subprocess.CompletedProcess[str], list[str]]:
525+ argument_log = temp_path / log_name
526+ env = dict(base_env)
527+ env["CBM_INSTALL_ARG_LOG"] = str(argument_log)
528+ result = subprocess.run(
529+ [str(wrapper), *arguments],
530+ env=env,
531+ text=True,
532+ stdout=subprocess.PIPE,
533+ stderr=subprocess.STDOUT,
534+ timeout=20,
535+ check=False,
536+ )
537+ logged = (
538+ argument_log.read_text(encoding="utf-8").splitlines()
539+ if argument_log.is_file()
540+ else []
541+ )
542+ return result, logged
543+
544+ unknown, unknown_args = run_wrapper(
545+ "unknown-args.log", "--definitely-not-a-wrapper-flag"
546+ )
547+ require(
548+ unknown.returncode == 2
549+ and "Please consult --help." in unknown.stdout
550+ and not unknown_args,
551+ "install.sh must reject unknown flags before executing the candidate",
552+ )
553+
554+ selected_dir = temp_path / "selected-bin"
555+ selected, selected_args = run_wrapper(
556+ "selected-args.log",
557+ f"--dir={selected_dir}",
558+ "--clients=claude,codex",
559+ "--skip-config",
560+ )
561+ require(
562+ selected.returncode == 0
563+ and selected_args
564+ == [
565+ "install",
566+ "-y",
567+ "--force",
568+ f"--dir={selected_dir}",
569+ "--clients=claude,codex",
570+ "--skip-config",
571+ ],
572+ "install.sh must forward the explicit clients selector with dir and skip-config",
573+ )
574+
575+ ordinary_dir = temp_path / "ordinary-bin"
576+ ordinary, ordinary_args = run_wrapper(
577+ "ordinary-args.log", f"--dir={ordinary_dir}"
578+ )
579+ require(
580+ ordinary.returncode == 0
581+ and ordinary_args
582+ == ["install", "-y", "--force", f"--dir={ordinary_dir}"],
583+ "install.sh without selectors must preserve the ordinary install arguments",
584+ )
585+
418586# Functional check: the helper must publish a live kernel-assigned port and
419587# serve the exact expected artifact. This is intentionally build-free.
420588if helper.is_file():
0 commit comments