diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 18485e5ce..70bd67bec 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -253,6 +253,7 @@ jobs: mingw-w64-clang-${{ matrix.pkg }}-clang mingw-w64-clang-${{ matrix.pkg }}-compiler-rt mingw-w64-clang-${{ matrix.pkg }}-zlib + mingw-w64-clang-${{ matrix.pkg }}-python3 mingw-w64-clang-${{ matrix.pkg }}-ccache make git diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 61121b539..3d49534b5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -59,7 +59,7 @@ jobs: # The paginated files endpoint remains filename-only for this gate. FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[].filename') printf '%s\n' "$FILES" - if printf '%s\n' "$FILES" | grep -qE '^(src/|internal/|scripts/build\.sh|scripts/smoke-test\.sh|scripts/env\.sh|Makefile\.cbm)'; then + if printf '%s\n' "$FILES" | grep -qE '^(src/|internal/|install\.(sh|ps1)|scripts/build\.sh|scripts/smoke-test\.sh|scripts/smoke-local\.sh|scripts/smoke-fixture-server\.py|scripts/gen-third-party-notices\.sh|scripts/env\.sh|test-infrastructure/vm/(vm-smoke\.sh|windows-user-path-guard\.ps1)|Makefile\.cbm)'; then echo "product=true" >> "$GITHUB_OUTPUT" else echo "product=false" >> "$GITHUB_OUTPUT" @@ -70,8 +70,8 @@ jobs: # time (e.g. the Windows CreateProcess argv-quoting class that previously # only surfaced in the release dry run). The full broad/emulated smoke stays # in the dry run. Only product-code PRs pay this; every leg gates. - # SMOKE_DOWNLOAD_URL is intentionally unset — the download/update phases of - # smoke-test.sh self-skip; the core index/search/trace phases still run. ── + # The maintained local wrappers add a race-free release fixture so the + # download/checksum/install/update phases run here as well. ── pr-smoke: needs: [changes] if: ${{ !cancelled() && needs.changes.outputs.product == 'true' }} @@ -104,6 +104,9 @@ jobs: mingw-w64-clang-x86_64-ccache make coreutils + curl + zip + unzip # Verified compiler cache — content-keyed, stale hits impossible by # construction (see scripts/env.sh). @@ -119,7 +122,7 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | scripts/build.sh CC=gcc CXX=g++ - scripts/smoke-test.sh "$(pwd)/build/c/codebase-memory-mcp" + scripts/smoke-local.sh "$(pwd)/build/c/codebase-memory-mcp" env: CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 1000M @@ -129,7 +132,7 @@ jobs: run: | scripts/build.sh CC=cc CXX=c++ codesign --sign - --force build/c/codebase-memory-mcp - scripts/smoke-test.sh "$(pwd)/build/c/codebase-memory-mcp" + scripts/smoke-local.sh "$(pwd)/build/c/codebase-memory-mcp" env: CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 1000M @@ -139,19 +142,7 @@ jobs: shell: msys2 {0} run: | scripts/build.sh CC=clang CXX=clang++ - # MSYS2 /tmp is intentionally shared. The launcher correctly rejects - # a bundle below that writable ancestor. RUNNER_TEMP is also below - # the shared D:\a tree, so stage in the runner account's profile. - PROFILE_ROOT="$(cygpath -u "$USERPROFILE")" - SMOKE_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-pr-smoke.XXXXXX")" - trap 'rm -rf "$SMOKE_DIR"' EXIT - cp build/c/codebase-memory-mcp-launcher.exe \ - "$SMOKE_DIR/codebase-memory-mcp.exe" - cp build/c/codebase-memory-mcp.exe \ - "$SMOKE_DIR/codebase-memory-mcp.payload.exe" - CBM_CACHE_DIR="$(cygpath -m "$SMOKE_DIR/cache")" \ - SMOKE_TEMP_ROOT="$SMOKE_DIR" \ - scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe" + SMOKE_ARCH=amd64 bash test-infrastructure/vm/vm-smoke.sh env: CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 1000M diff --git a/Makefile.cbm b/Makefile.cbm index 97d56a8fd..d9330afec 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -750,6 +750,11 @@ test-repro: $(BUILD_DIR)/test-repro-runner TEST_TSAN_SUITES ?= mem slab_alloc parallel worker_pool watcher httpd pipeline \ diagnostics mcp mcp_mutation_guard subprocess daemon daemon_application TSAN_OPTIONS ?= halt_on_error=1 +# A fixed concurrent envelope keeps TSan deterministic across developer hosts +# and GitHub runners. Four workers still exercise real races without turning +# sanitizer-instrumented allocator bookkeeping into a high-core lock convoy. +# Normal ASan/native/soak paths remain uncapped. High-worker TSan diagnostics +# invoke test-runner-tsan directly so release gates cannot drift accidentally. $(BUILD_DIR)/test-runner-tsan: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TSAN) | $(BUILD_DIR) $(CC) $(CFLAGS_TSAN) -Itests -Itests/repro -o $@ \ @@ -759,7 +764,9 @@ $(BUILD_DIR)/test-runner-tsan: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(LDFLAGS_TSAN) test-tsan: $(BUILD_DIR)/test-runner-tsan - cd $(CURDIR) && TSAN_OPTIONS="$(TSAN_OPTIONS)" $(BUILD_DIR)/test-runner-tsan $(TEST_TSAN_SUITES) + @echo "ThreadSanitizer workers: 4" + cd $(CURDIR) && CBM_WORKERS=4 TSAN_OPTIONS="$(TSAN_OPTIONS)" \ + $(BUILD_DIR)/test-runner-tsan $(TEST_TSAN_SUITES) # Real-binary POSIX lifecycle smoke. The endpoint is deliberately account-wide; # an explicit Make invocation requires a clean rendezvous and fails rather than diff --git a/scripts/run-test-wave.py b/scripts/run-test-wave.py new file mode 100755 index 000000000..8faa6e75c --- /dev/null +++ b/scripts/run-test-wave.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python3 +"""Run one wave of C test suites without nested shell worker processes. + +The caller owns suite selection, sharding, and final union/count checks. This +helper owns native child processes directly, writes one result for every suite, +and bounds a child that never exits. Keeping accounting in this single parent +avoids an MSYS2 failure mode where a completed native child left its `bash -c` +worker permanently stuck before the result append. +""" + +from __future__ import annotations + +import argparse +import os +import pathlib +import re +import signal +import subprocess +import sys +import time +from dataclasses import dataclass + + +SUITE_NAME = re.compile(r"^[a-z0-9_]+$") +SUMMARY = re.compile(r"^ (?P[0-9]+) passed") +FAILED = re.compile(r"(?:^|, )(?P[0-9]+) failed") +SKIPPED = re.compile(r"(?:^|, )(?P[0-9]+) skipped") +SLOW_SUITES = frozenset(("incremental", "store_arch", "daemon_runtime")) +POLL_SECONDS = 0.05 + + +@dataclass +class ActiveSuite: + name: str + process: subprocess.Popen[bytes] + log_path: pathlib.Path + log_file: object + started: float + timeout: int + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run a bounded parallel wave of test-runner suites" + ) + parser.add_argument("--suite-file", required=True, type=pathlib.Path) + parser.add_argument("--log-dir", required=True, type=pathlib.Path) + parser.add_argument("--results-file", required=True, type=pathlib.Path) + parser.add_argument("--jobs", required=True, type=int) + parser.add_argument("--timeout", required=True, type=int) + parser.add_argument("--slow-timeout", required=True, type=int) + parser.add_argument("--kill-grace", required=True, type=int) + parser.add_argument( + "--test-post-exit-barrier-dir", + type=pathlib.Path, + help=argparse.SUPPRESS, + ) + parser.add_argument( + "--test-pre-terminate-barrier-dir", + type=pathlib.Path, + help=argparse.SUPPRESS, + ) + parser.add_argument( + "runner_command", + nargs="+", + help="runner executable and any fixed arguments; suite name is appended", + ) + args = parser.parse_args() + for name in ("jobs", "timeout", "slow_timeout", "kill_grace"): + if getattr(args, name) < 1: + parser.error(f"--{name.replace('_', '-')} must be at least 1") + return args + + +def read_suites(path: pathlib.Path) -> list[str]: + try: + suites = path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + raise RuntimeError(f"cannot read suite file {path}: {exc}") from exc + malformed = [suite for suite in suites if SUITE_NAME.fullmatch(suite) is None] + if malformed: + raise RuntimeError(f"malformed suite name in {path}: {malformed[0]!r}") + if len(set(suites)) != len(suites): + raise RuntimeError(f"duplicate suite name in {path}") + return suites + + +def append_log(path: pathlib.Path, message: str) -> None: + with path.open("a", encoding="utf-8", newline="\n") as stream: + stream.write(message) + stream.write("\n") + + +def start_suite( + suite: str, + runner_command: list[str], + log_dir: pathlib.Path, + timeout: int, +) -> ActiveSuite | None: + log_path = log_dir / f"{suite}.log" + log_file = log_path.open("wb") + popen_args: dict[str, object] = { + "stdout": log_file, + "stderr": subprocess.STDOUT, + } + if os.name == "nt": + popen_args["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_args["start_new_session"] = True + try: + process = subprocess.Popen(runner_command + [suite], **popen_args) + except OSError as exc: + log_file.close() + append_log(log_path, f" FAIL: could not start suite {suite!r}: {exc}") + return None + return ActiveSuite( + name=suite, + process=process, + log_path=log_path, + log_file=log_file, + started=time.monotonic(), + timeout=timeout, + ) + + +def terminate_process_tree(active: ActiveSuite, kill_grace: int) -> None: + process = active.process + leader_exited = process.poll() is not None + if os.name == "nt": + if leader_exited: + raise RuntimeError( + f"suite {active.name!r} leader exited before Windows tree cleanup " + "could be proven" + ) + try: + completed = subprocess.run( + [ + "taskkill.exe", + "/PID", + str(process.pid), + "/T", + "/F", + ], + check=False, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=kill_grace, + ) + except (OSError, subprocess.TimeoutExpired): + completed = None + if completed is None or completed.returncode != 0: + if process.poll() is None: + process.kill() + try: + process.wait(timeout=kill_grace) + except subprocess.TimeoutExpired: + pass + raise RuntimeError( + f"suite {active.name!r} taskkill could not prove process-tree cleanup" + ) + try: + process.wait(timeout=kill_grace) + except subprocess.TimeoutExpired as exc: + process.kill() + raise RuntimeError( + f"suite {active.name!r} process tree resisted forced termination" + ) from exc + return + + def group_active() -> bool: + try: + os.killpg(process.pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + + def wait_for_group_exit(deadline: float) -> bool: + while time.monotonic() < deadline: + process.poll() + if not group_active(): + return True + time.sleep(POLL_SECONDS) + process.poll() + return not group_active() + + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + return + if wait_for_group_exit(time.monotonic() + kill_grace): + return + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + return + if wait_for_group_exit(time.monotonic() + kill_grace): + return + raise RuntimeError( + f"suite {active.name!r} process group persisted after forced termination" + ) + + +def wait_for_test_pre_terminate_barrier( + barrier_dir: pathlib.Path | None, + active: ActiveSuite, +) -> None: + if barrier_dir is None: + return + hold = barrier_dir / f"{active.name}.hold" + if not hold.exists(): + return + ready = barrier_dir / f"{active.name}.ready" + leader_exited = barrier_dir / f"{active.name}.leader-exited" + release = barrier_dir / f"{active.name}.release" + ready.write_text(f"{active.process.pid}\n", encoding="utf-8") + deadline = time.monotonic() + 10 + while not release.exists(): + returncode = active.process.poll() + if returncode is not None and not leader_exited.exists(): + leader_exited.write_text(f"{returncode}\n", encoding="utf-8") + if time.monotonic() >= deadline: + raise RuntimeError( + f"test pre-terminate barrier for {active.name!r} was not released" + ) + time.sleep(POLL_SECONDS) + + +def wait_for_test_post_exit_barrier( + barrier_dir: pathlib.Path | None, + suite: str, +) -> None: + if barrier_dir is None: + return + hold = barrier_dir / f"{suite}.hold" + if not hold.exists(): + return + ready = barrier_dir / f"{suite}.ready" + release = barrier_dir / f"{suite}.release" + ready.write_text("child exited; result intentionally not recorded\n", encoding="utf-8") + deadline = time.monotonic() + 10 + while not release.exists(): + if time.monotonic() >= deadline: + raise RuntimeError(f"test post-exit barrier for {suite!r} was not released") + time.sleep(POLL_SECONDS) + + +def parse_summary(log_path: pathlib.Path) -> tuple[int, int, int] | None: + try: + stream = log_path.open(encoding="utf-8", errors="replace") + except OSError as exc: + raise RuntimeError(f"cannot read suite log {log_path}: {exc}") from exc + last_summary = None + with stream: + for line in stream: + match = SUMMARY.match(line) + if match is None: + continue + failed = FAILED.search(line) + skipped = SKIPPED.search(line) + last_summary = ( + int(match.group("passed")), + int(failed.group("failed")) if failed is not None else 0, + int(skipped.group("skipped")) if skipped is not None else 0, + ) + return last_summary + + +def record_result( + active: ActiveSuite, + returncode: int, + results_file: pathlib.Path, + timed_out: bool, +) -> None: + active.log_file.close() + elapsed = max(0, int(time.monotonic() - active.started)) + if timed_out: + returncode = 124 + append_log( + active.log_path, + f" FAIL: suite {active.name!r} exceeded {active.timeout}s wall clock " + "(killed as hung)", + ) + summary = parse_summary(active.log_path) + if returncode == 0 and summary is None: + returncode = 97 + append_log( + active.log_path, + f" FAIL: suite {active.name!r} exited 0 without a completion summary " + "(ran nothing?)", + ) + passed, failed, skipped = summary or (0, 0, 0) + result = ( + f"{active.name} rc={returncode} pass={passed} fail={failed} " + f"skip={skipped} secs={elapsed}" + ) + with results_file.open("a", encoding="utf-8", newline="\n") as stream: + stream.write(result) + stream.write("\n") + print(f" {result}", flush=True) + + +def record_start_failure( + suite: str, + log_dir: pathlib.Path, + results_file: pathlib.Path, +) -> None: + result = f"{suite} rc=98 pass=0 fail=0 skip=0 secs=0" + with results_file.open("a", encoding="utf-8", newline="\n") as stream: + stream.write(result) + stream.write("\n") + print(f" {result}", flush=True) + if not (log_dir / f"{suite}.log").exists(): + append_log(log_dir / f"{suite}.log", f" FAIL: suite {suite!r} did not start") + + +def run_wave(args: argparse.Namespace) -> None: + suites = read_suites(args.suite_file) + args.log_dir.mkdir(parents=True, exist_ok=True) + args.results_file.parent.mkdir(parents=True, exist_ok=True) + args.results_file.touch(exist_ok=True) + pending = list(suites) + active: dict[str, ActiveSuite] = {} + try: + while pending or active: + while pending and len(active) < args.jobs: + suite = pending.pop(0) + timeout = ( + args.slow_timeout if suite in SLOW_SUITES else args.timeout + ) + started = start_suite( + suite, + list(args.runner_command), + args.log_dir, + timeout, + ) + if started is None: + record_start_failure(suite, args.log_dir, args.results_file) + else: + active[suite] = started + + made_progress = False + now = time.monotonic() + for suite, running in list(active.items()): + returncode = running.process.poll() + timed_out = returncode is None and now - running.started >= running.timeout + if returncode is None and not timed_out: + continue + if timed_out: + wait_for_test_pre_terminate_barrier( + args.test_pre_terminate_barrier_dir, + running, + ) + terminate_process_tree(running, args.kill_grace) + returncode = running.process.returncode + wait_for_test_post_exit_barrier( + args.test_post_exit_barrier_dir, + suite, + ) + record_result( + running, + int(returncode if returncode is not None else 124), + args.results_file, + timed_out, + ) + del active[suite] + made_progress = True + if active and not made_progress: + time.sleep(POLL_SECONDS) + finally: + cleanup_errors: list[str] = [] + for running in active.values(): + try: + terminate_process_tree(running, args.kill_grace) + except (OSError, RuntimeError) as exc: + cleanup_errors.append(f"{running.name}: {exc}") + finally: + try: + running.log_file.close() + except OSError as exc: + cleanup_errors.append(f"{running.name} log close: {exc}") + if cleanup_errors: + raise RuntimeError( + "parallel scheduler cleanup failed: " + "; ".join(cleanup_errors) + ) + + +def main() -> int: + args = parse_args() + try: + if os.environ.get("MSYSTEM") and os.name != "nt": + raise RuntimeError( + "Windows/MSYS test runs require the native MinGW Python " + "(os.name must be 'nt')" + ) + run_wave(args) + except (OSError, RuntimeError) as exc: + print(f"FAIL: parallel scheduler infrastructure error: {exc}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run-tests-parallel.sh b/scripts/run-tests-parallel.sh index a9eb0d432..263f40e63 100644 --- a/scripts/run-tests-parallel.sh +++ b/scripts/run-tests-parallel.sh @@ -22,6 +22,8 @@ set -uo pipefail RUNNER="${1:?usage: run-tests-parallel.sh [jobs]}" JOBS="${2:-${CBM_TEST_PAR_JOBS:-}}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCHEDULER="$SCRIPT_DIR/run-test-wave.py" if [ -z "$JOBS" ]; then if command -v nproc >/dev/null 2>&1; then @@ -86,6 +88,7 @@ stamp_windows_build_dir pre-wave SUITES_FILE="$LOGDIR/suites.txt" RESULTS_FILE="$LOGDIR/results.txt" +: > "$RESULTS_FILE" # tr strips the CR that the Windows CRT appends to every stdout line — a # suites file with CRLF endings made the runner reject every name @@ -194,53 +197,30 @@ cat "$PAR_FILE" "$SER_FILE" > "$SHARD_EXPECT" NSHARD=$(wc -l < "$SHARD_EXPECT" | tr -d ' ') echo "=== parallel test run: $NSHARD of $NSUITES suites (shard ${SHARD_INDEX}/${SHARD_TOTAL}, $(wc -l < "$SER_FILE" | tr -d ' ') serial-tail), $JOBS jobs ===" -export RUNNER LOGDIR RESULTS_FILE -run_one() { - s="$1" - t0=$SECONDS - # Per-suite wall-clock ceiling so a wedged suite fails LOUDLY instead of - # blocking the run (and the single local build slot) indefinitely. The - # `incremental` suite legitimately re-indexes large fixtures (minutes) so - # it gets a wider ceiling until the template-DB fixture refactor lands; - # `daemon_runtime` measures ~610s SOLO on arm64 under ASan (10-round - # loop), so on an overloaded 4-job CI runner the 900s default is a - # slowness kill, not a hang detector — it joins the slow tier. - # Uses `timeout` where available (always in the Linux container / CI); on a - # host without it the suite runs uncapped (no regression vs before). - case "$s" in - incremental | store_arch | daemon_runtime) st="${CBM_SUITE_TIMEOUT_SLOW:-3600}" ;; - *) st="${CBM_SUITE_TIMEOUT:-900}" ;; - esac - if command -v timeout >/dev/null 2>&1; then - timeout --kill-after=15 "$st" "$RUNNER" "$s" > "$LOGDIR/$s.log" 2>&1 - rc=$? - if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then - echo " FAIL: suite '$s' exceeded ${st}s wall clock (killed as hung)" \ - >> "$LOGDIR/$s.log" - fi - else - "$RUNNER" "$s" > "$LOGDIR/$s.log" 2>&1 - rc=$? +# Per-suite wall-clock ceilings make a wedged child fail loudly. The +# `incremental` suite legitimately re-indexes large fixtures (minutes), while +# `daemon_runtime` measures ~610s solo on arm64 under ASan; those and +# `store_arch` receive the wider ceiling. Process ownership and result +# accounting live in one Python parent — no exported MSYS bash worker remains +# between a completed native child and its durable result line. +run_wave() { + local suite_file="$1" + local jobs="$2" + if ! python3 "$SCHEDULER" \ + --suite-file "$suite_file" \ + --log-dir "$LOGDIR" \ + --results-file "$RESULTS_FILE" \ + --jobs "$jobs" \ + --timeout "${CBM_SUITE_TIMEOUT:-900}" \ + --slow-timeout "${CBM_SUITE_TIMEOUT_SLOW:-3600}" \ + --kill-grace 15 \ + "$RUNNER"; then + echo "FAIL: parallel suite scheduler infrastructure failed" >&2 + exit 1 fi - secs=$((SECONDS - t0)) - summary=$(grep -E '^ [0-9]+ passed' "$LOGDIR/$s.log" | tail -1) - # A suite that exits 0 WITHOUT printing its completion summary ran zero - # tests as far as anyone can prove (mis-parsed argv, drifted registration - # macro, early return) — that is a failure, not a green with pass=0. - if [ "$rc" -eq 0 ] && [ -z "$summary" ]; then - rc=97 - echo " FAIL: suite '$s' exited 0 without a completion summary (ran nothing?)" \ - >> "$LOGDIR/$s.log" - fi - pass=$(printf '%s' "$summary" | sed -n 's/^ \([0-9]*\) passed.*/\1/p') - failn=$(printf '%s' "$summary" | sed -n 's/.* \([0-9]*\) failed.*/\1/p') - skip=$(printf '%s' "$summary" | sed -n 's/.* \([0-9]*\) skipped.*/\1/p') - # A single short echo line is an atomic append (< PIPE_BUF). - echo "$s rc=$rc pass=${pass:-0} fail=${failn:-0} skip=${skip:-0} secs=$secs" >> "$RESULTS_FILE" } -export -f run_one -xargs -P "$JOBS" -I{} bash -c 'run_one "$@"' _ {} < "$PAR_FILE" +run_wave "$PAR_FILE" "$JOBS" # Tail scheduling in two phases. The FLEX suites are timing-shaped but do # not rendezvous through the shared per-account daemon runtime namespace, @@ -272,10 +252,8 @@ done < "$SER_FILE" # Re-stamp at the tail boundary so the deadline-sensitive tail — which # hosts those suites — always starts from the verified-clean shape. stamp_windows_build_dir pre-tail -xargs -P "${CBM_TAIL_JOBS:-2}" -I{} bash -c 'run_one "$@"' _ {} < "$FLEX_FILE" -while IFS= read -r sname; do - run_one "$sname" -done < "$EXCL_FILE" +run_wave "$FLEX_FILE" "${CBM_TAIL_JOBS:-2}" +run_wave "$EXCL_FILE" 1 # Machine-checkable manifest for CI's cross-shard completeness job: it # proves at runtime that the shards of one leg agree on N and on the full diff --git a/scripts/smoke-fixture-server.py b/scripts/smoke-fixture-server.py new file mode 100755 index 000000000..5fbfedf31 --- /dev/null +++ b/scripts/smoke-fixture-server.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Serve smoke-test release artifacts from a race-free ephemeral port.""" + +from __future__ import annotations + +import argparse +import functools +import http.server +import os +import pathlib +import tempfile + + +def publish_port(port_file: pathlib.Path, port: int) -> None: + """Atomically publish the assigned port after the listening socket exists.""" + port_file.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + encoding="ascii", + dir=port_file.parent, + prefix=f".{port_file.name}.", + delete=False, + ) as temporary: + temporary.write(f"{port}\n") + temporary.flush() + os.fsync(temporary.fileno()) + temporary_path = pathlib.Path(temporary.name) + try: + os.replace(temporary_path, port_file) + except BaseException: + temporary_path.unlink(missing_ok=True) + raise + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--directory", required=True, type=pathlib.Path) + parser.add_argument("--port-file", required=True, type=pathlib.Path) + parser.add_argument("--bind", default="127.0.0.1") + args = parser.parse_args() + + directory = args.directory.resolve(strict=True) + if not directory.is_dir(): + parser.error(f"--directory is not a directory: {directory}") + + handler = functools.partial( + http.server.SimpleHTTPRequestHandler, + directory=str(directory), + ) + with http.server.ThreadingHTTPServer((args.bind, 0), handler) as server: + publish_port(args.port_file, server.server_port) + print( + f"smoke fixture server: http://{args.bind}:{server.server_port} " + f"from {directory}", + flush=True, + ) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke-local.sh b/scripts/smoke-local.sh new file mode 100755 index 000000000..523834565 --- /dev/null +++ b/scripts/smoke-local.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Full local smoke, including the release download/install/update phases that +# plain `scripts/smoke-test.sh ` skips without an artifact fixture. +# +# Usage: scripts/smoke-local.sh [ui] +# product binary to smoke (e.g. build/c/codebase-memory-mcp) +# ui optional: mirror the -ui variant asset naming + +BINARY="${1:?Usage: smoke-local.sh [ui]}" +VARIANT="${2:-standard}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BINARY="$(cd "$(dirname "$BINARY")" && pwd)/$(basename "$BINARY")" + +if [ ! -x "$BINARY" ]; then + echo "smoke-local: binary is not executable: $BINARY" >&2 + exit 2 +fi +case "$VARIANT" in +standard) SUFFIX="" ;; +ui) SUFFIX="-ui" ;; +*) echo "smoke-local: variant must be 'standard' or 'ui'" >&2; exit 2 ;; +esac + +case "$(uname -s)" in +Darwin) OS=darwin ;; +Linux) OS=linux ;; +*) echo "smoke-local: unsupported host OS $(uname -s)" >&2; exit 2 ;; +esac +case "$(uname -m)" in +arm64 | aarch64) ARCH=arm64 ;; +x86_64) + # Match install.sh: a Rosetta shell reports x86_64 even though the + # installer correctly selects the native Apple Silicon artifact. + if [ "$OS" = "darwin" ] && + sysctl -n machdep.cpu.brand_string 2>/dev/null | grep -qi apple; then + ARCH=arm64 + else + ARCH=amd64 + fi + ;; +*) echo "smoke-local: unsupported host arch $(uname -m)" >&2; exit 2 ;; +esac + +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/cbm-smoke-server-XXXXXX") +FIXTURE_DIR="$WORK_DIR/artifacts" +SMOKE_TEMP_DIR="$WORK_DIR/temp" +SMOKE_HOME="$WORK_DIR/home" +SMOKE_XDG_CONFIG="$WORK_DIR/xdg-config" +SMOKE_APPDATA="$WORK_DIR/appdata" +SMOKE_LOCALAPPDATA="$WORK_DIR/localappdata" +PORT_FILE="$WORK_DIR/port" +SERVER_LOG="$WORK_DIR/server.log" +SERVER_PID="" +cleanup() { + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +mkdir -p "$FIXTURE_DIR" "$SMOKE_TEMP_DIR" "$SMOKE_HOME" "$SMOKE_XDG_CONFIG" \ + "$SMOKE_APPDATA" "$SMOKE_LOCALAPPDATA" +cp "$BINARY" "$FIXTURE_DIR/codebase-memory-mcp" +cp "$ROOT/LICENSE" "$ROOT/install.sh" "$FIXTURE_DIR/" +"$ROOT/scripts/gen-third-party-notices.sh" "$FIXTURE_DIR/THIRD_PARTY_NOTICES.md" + +EXPECTED_ARTIFACT="codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}.tar.gz" +tar -czf "$FIXTURE_DIR/$EXPECTED_ARTIFACT" -C "$FIXTURE_DIR" \ + codebase-memory-mcp LICENSE install.sh THIRD_PARTY_NOTICES.md +if [ -n "$SUFFIX" ]; then + cp "$FIXTURE_DIR/$EXPECTED_ARTIFACT" \ + "$FIXTURE_DIR/codebase-memory-mcp-${OS}-${ARCH}.tar.gz" +fi + +# Linux install/update resolves the portable release asset even when this local +# smoke started from the dynamic production binary. +if [ "$OS" = "linux" ]; then + cp "$FIXTURE_DIR/$EXPECTED_ARTIFACT" \ + "$FIXTURE_DIR/codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}-portable.tar.gz" + if [ -n "$SUFFIX" ]; then + cp "$FIXTURE_DIR/codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}-portable.tar.gz" \ + "$FIXTURE_DIR/codebase-memory-mcp-${OS}-${ARCH}-portable.tar.gz" + fi +fi +(cd "$FIXTURE_DIR" && { sha256sum *.tar.gz > checksums.txt 2>/dev/null || + shasum -a 256 *.tar.gz > checksums.txt; }) + +python3 "$ROOT/scripts/smoke-fixture-server.py" \ + --directory "$FIXTURE_DIR" --port-file "$PORT_FILE" \ + >"$SERVER_LOG" 2>&1 & +SERVER_PID=$! + +PORT="" +READY=0 +for _ in $(seq 1 100); do + if [ -s "$PORT_FILE" ]; then + PORT=$(tr -d '[:space:]' < "$PORT_FILE") + if curl --noproxy '*' -fsS \ + "http://127.0.0.1:$PORT/$EXPECTED_ARTIFACT" >/dev/null 2>&1; then + READY=1 + break + fi + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + break + fi + sleep 0.1 +done +if [ "$READY" -ne 1 ]; then + echo "smoke-local: fixture server did not serve $EXPECTED_ARTIFACT" >&2 + echo "--- fixture server log ---" >&2 + cat "$SERVER_LOG" >&2 + exit 1 +fi + +# Full smoke deliberately runs real install/update/uninstall paths. Keep every +# supported agent destination override inside this disposable root so an +# ambient developer setting cannot redirect those mutations into live config. +env \ + -u CLAUDE_CONFIG_DIR \ + -u CODEX_HOME \ + -u KIRO_HOME \ + -u HERMES_HOME \ + -u QWEN_HOME \ + -u CLINE_DATA_DIR \ + -u OPENCLAW_HOME \ + -u OPENCLAW_STATE_DIR \ + -u OPENCLAW_PROFILE \ + -u OPENCLAW_CONFIG_PATH \ + -u OPENCLAW_WORKSPACE_DIR \ + -u OPENCODE_CONFIG \ + -u OPENCODE_CONFIG_DIR \ + -u COPILOT_HOME \ + -u CRUSH_GLOBAL_CONFIG \ + -u VIBE_HOME \ + -u GLAB_CONFIG_DIR \ + -u KIMI_CODE_HOME \ + -u CBM_CONTINUE_CONFIG_PATH \ + -u CBM_TRAE_CONFIG_PATH \ + -u CBM_ROO_CONFIG_PATH \ + -u CBM_CODY_CONFIG_PATH \ + -u CBM_TEST_WINDOWS_USER_PATH_RUN_ID \ + HOME="$SMOKE_HOME" \ + USERPROFILE="$SMOKE_HOME" \ + XDG_CONFIG_HOME="$SMOKE_XDG_CONFIG" \ + APPDATA="$SMOKE_APPDATA" \ + LOCALAPPDATA="$SMOKE_LOCALAPPDATA" \ + TMPDIR="$SMOKE_TEMP_DIR" \ + TEMP="$SMOKE_TEMP_DIR" \ + TMP="$SMOKE_TEMP_DIR" \ + SHELL="/bin/sh" \ + CBM_CACHE_DIR="$WORK_DIR/cache" \ + SMOKE_TEMP_ROOT="$SMOKE_TEMP_DIR" \ + SMOKE_DOWNLOAD_URL="http://127.0.0.1:$PORT" \ + SMOKE_UPDATE_FIXTURE_DIR="$FIXTURE_DIR" \ + SMOKE_ARCH="$ARCH" \ + "$ROOT/scripts/smoke-test.sh" "$BINARY" diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index bfa7541e6..16873893d 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -842,6 +842,26 @@ if ! echo "$INSTALL_OUT" | grep -qi 'dry-run'; then fi echo "OK: install --dry-run completed" +# The Windows smoke redirects PATH writes to a pre-created GUID leaf. A +# malformed seam must fail closed instead of silently falling back to the live +# HKCU\Environment\Path. +if [[ "$BINARY" == *.exe ]] && + [ -n "${CBM_TEST_WINDOWS_USER_PATH_RUN_ID:-}" ]; then + if INVALID_PATH_OUT=$( + CBM_TEST_WINDOWS_USER_PATH_RUN_ID=invalid \ + run_dryrun_env "$BINARY" install --dry-run -y 2>&1 + ); then + echo "FAIL: invalid Windows PATH smoke seam fell back to the live registry" + exit 1 + fi + if ! echo "$INVALID_PATH_OUT" | grep -qi 'PATH configuration failed'; then + echo "FAIL: invalid Windows PATH smoke seam did not fail at PATH configuration" + echo "$INVALID_PATH_OUT" + exit 1 + fi + echo "OK: invalid Windows PATH smoke seam fails closed" +fi + # 6b: uninstall --dry-run -y echo "--- Phase 6b: uninstall --dry-run ---" if [[ "$BINARY" == *.exe ]]; then @@ -2871,8 +2891,13 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then else cp "$BINARY" "$UPDATE_HOME/.local/bin/codebase-memory-mcp" chmod 755 "$UPDATE_HOME/.local/bin/codebase-memory-mcp" + mkdir -p "$UPDATE_HOME/retired-install" + cp "$BINARY" "$UPDATE_HOME/retired-install/codebase-memory-mcp" + chmod 755 "$UPDATE_HOME/retired-install/codebase-memory-mcp" if [ "$(uname -s)" = "Darwin" ]; then codesign --sign - --force "$UPDATE_HOME/.local/bin/codebase-memory-mcp" 2>/dev/null || true + codesign --sign - --force "$UPDATE_HOME/retired-install/codebase-memory-mcp" \ + 2>/dev/null || true fi fi @@ -2888,14 +2913,30 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then echo "FAIL 14a: managed Windows launcher missing after install" exit 1 fi + else + RETIRED_DIR=$(cd "$UPDATE_HOME/retired-install" && pwd -P) + UPDATE_DRIVER="$RETIRED_DIR/codebase-memory-mcp" fi - # Pre-install agent config with a WRONG binary path (simulates stale config) - echo '{"mcpServers":{"codebase-memory-mcp":{"command":"/old/stale/path"}}}' > "$UPDATE_HOME/.claude.json" + # Pre-install agent config with positive prior-install identity. POSIX runs + # update from that exact retired CBM image, so refresh requires only string + # equality with OS-reported self identity and never probes config paths. + # Windows retains its fixed-drive missing-path classification coverage. + if [[ "$BINARY" == *.exe ]]; then + STALE_CMD="$UPDATE_HOME/retired-install/codebase-memory-mcp.exe" + else + STALE_CMD="$UPDATE_DRIVER" + fi + if command -v cygpath &>/dev/null; then + STALE_CMD=$(cygpath -m "$STALE_CMD") + fi + STALE_CMD="$STALE_CMD" python3 -c \ + 'import json, os; print(json.dumps({"mcpServers":{"codebase-memory-mcp":{"command":os.environ["STALE_CMD"]}}}))' \ + > "$UPDATE_HOME/.claude.json" # 14a: Run actual update command (detect variant from available archive) UPDATE_VARIANT="--standard" - if curl -sf "$SMOKE_DOWNLOAD_URL/" 2>/dev/null | grep -q "ui-"; then + if curl --noproxy '*' -sf "$SMOKE_DOWNLOAD_URL/" 2>/dev/null | grep -q "ui-"; then UPDATE_VARIANT="--ui" fi HOME="$UPDATE_HOME" CBM_DOWNLOAD_URL="$UPDATE_DOWNLOAD_URL" \ @@ -2920,17 +2961,19 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then fi echo "OK 14b: updated binary runs" - # 14c: Verify agent config was refreshed (stale path replaced) + # 14c: Verify agent config was refreshed to the exact installed binary. UPD_CMD=$(cat "$UPDATE_HOME/.claude.json" 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('mcpServers',{}).get('codebase-memory-mcp',{}).get('command',''))" 2>/dev/null || echo "") - if [ "$UPD_CMD" = "/old/stale/path" ]; then - echo "FAIL 14c: agent config still has stale path after update" + EXPECTED_UPD_CMD="$UPD_BIN" + if command -v cygpath &>/dev/null; then + EXPECTED_UPD_CMD=$(cygpath -w "$UPD_BIN") + fi + if [ "$UPD_CMD" != "$EXPECTED_UPD_CMD" ]; then + echo "FAIL 14c: agent config does not point at the updated binary" + echo " expected: $EXPECTED_UPD_CMD" + echo " actual: ${UPD_CMD:-}" exit 1 fi - if [ -n "$UPD_CMD" ]; then - echo "OK 14c: agent config refreshed (path=$UPD_CMD)" - else - echo "OK 14c: agent config refreshed (no stale path)" - fi + echo "OK 14c: agent config refreshed (path=$UPD_CMD)" # ── 14d-f: Real uninstall with binary removal ── # First verify binary + configs exist @@ -3038,14 +3081,15 @@ echo "--- Phase 12a: curl download ---" # var present on some runners (notably windows-11-arm) made curl fail to reach # 127.0.0.1 while the app's own downloader (WinHTTP) bypassed it. On failure, # surface curl's stderr instead of swallowing it so the reason is visible. -if ! curl -fSL --noproxy '*' -o "$DL_DIR/$DL_ARCHIVE" "$SMOKE_DOWNLOAD_URL/$DL_ARCHIVE" 2>/tmp/cbm-curl12a.err; then +CURL12_ERR="$DL_DIR/curl12a.err" +if ! curl -fSL --noproxy '*' -o "$DL_DIR/$DL_ARCHIVE" "$SMOKE_DOWNLOAD_URL/$DL_ARCHIVE" 2>"$CURL12_ERR"; then # Try UI variant - if curl -fSL --noproxy '*' -o "$DL_DIR/$DL_ARCHIVE_UI" "$SMOKE_DOWNLOAD_URL/$DL_ARCHIVE_UI" 2>>/tmp/cbm-curl12a.err; then + if curl -fSL --noproxy '*' -o "$DL_DIR/$DL_ARCHIVE_UI" "$SMOKE_DOWNLOAD_URL/$DL_ARCHIVE_UI" 2>>"$CURL12_ERR"; then DL_ARCHIVE="$DL_ARCHIVE_UI" else echo "FAIL 12a: curl download failed (tried standard and ui variants)" echo "--- curl stderr (url: $SMOKE_DOWNLOAD_URL/$DL_ARCHIVE) ---" - cat /tmp/cbm-curl12a.err 2>/dev/null || true + cat "$CURL12_ERR" 2>/dev/null || true exit 1 fi fi @@ -3057,7 +3101,8 @@ echo "OK 12a: archive downloaded ($(wc -c < "$DL_DIR/$DL_ARCHIVE") bytes)" # 12b: checksum download echo "--- Phase 12b: checksum verification ---" -if ! curl -fsSL -o "$DL_DIR/checksums.txt" "$SMOKE_DOWNLOAD_URL/checksums.txt"; then +if ! curl --noproxy '*' -fsSL -o "$DL_DIR/checksums.txt" \ + "$SMOKE_DOWNLOAD_URL/checksums.txt"; then echo "FAIL 12b: checksums.txt download failed" exit 1 fi @@ -3226,10 +3271,16 @@ elif [ -f "$REPO_ROOT/install.ps1" ] && command -v powershell.exe &>/dev/null; t # 13f: run install.ps1 # Pass the known-correct arch: powershell runs under x64 emulation on ARM64, so # install.ps1's own detection can't tell it's arm64. DL_ARCH is authoritative here. - HOME="$PS1_TEST_HOME" CBM_DOWNLOAD_URL="$WIN_URL" CBM_ARCH="$DL_ARCH" \ - powershell.exe -NoProfile -ExecutionPolicy ByPass -Command \ - '$env:TEMP=$args[0]; $env:TMP=$args[0]; & $args[1] $args[2]' \ - "$WIN_HOME" "$WIN_SCRIPT" "--dir=$WIN_DIR" 2>&1 || true + # Every path is already in native Windows form. Disable MSYS argv rewriting + # and invoke the script directly: powershell.exe -Command appends native argv + # to the command text instead of reliably exposing it through $args. + if ! HOME="$WIN_HOME" TEMP="$WIN_HOME" TMP="$WIN_HOME" \ + CBM_DOWNLOAD_URL="$WIN_URL" CBM_ARCH="$DL_ARCH" MSYS2_ARG_CONV_EXCL='*' \ + powershell.exe -NoProfile -ExecutionPolicy Bypass -File \ + "$WIN_SCRIPT" "--dir=$WIN_DIR" 2>&1; then + echo "FAIL 13f: install.ps1 execution failed" + exit 1 + fi # 13g: binary placed PS1_BIN="$PS1_TEST_DIR/codebase-memory-mcp.exe" diff --git a/scripts/soak-test.sh b/scripts/soak-test.sh index 6d032b4dc..864cf36cd 100755 --- a/scripts/soak-test.sh +++ b/scripts/soak-test.sh @@ -41,7 +41,60 @@ mkdir -p "$RESULTS_DIR" # Isolate daemon coordination from interactive CBM sessions and give this run # a deterministic host-side daemon log. Wine needs a Windows-form cache path # in the child environment while this Bash harness retains the host path. -SOAK_CACHE_DIR_HOST=$(mktemp -d "${TMPDIR:-/tmp}/cbm-soak-cache.XXXXXX") +# +# On native Windows the cache CANNOT live under msys /tmp: the server's +# cache-private executable-identity check walks the cache path's ancestors +# and fails closed on C:\msys64, whose DACL grants mutation to Authenticated +# Users. Place it under the user profile and stamp a reset-first strict DACL. +soak_stamp_windows_dir() { + local dir_w me output + dir_w="$(cygpath -w "$1")" + me="$(whoami | tr -d '\r')" + if ! output=$(MSYS2_ARG_CONV_EXCL='*' icacls "$dir_w" /reset /Q 2>&1); then + echo "FAIL: soak DACL normalize failed (dir=$dir_w user=$me)" >&2 + printf '%s\n' "$output" >&2 + return 1 + fi + if ! output=$(MSYS2_ARG_CONV_EXCL='*' icacls "$dir_w" /inheritance:r \ + /grant:r "${me}:(OI)(CI)F" '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' \ + /Q 2>&1); then + echo "FAIL: soak DACL stamp failed (dir=$dir_w user=$me)" >&2 + printf '%s\n' "$output" >&2 + return 1 + fi +} + +SOAK_WIN_ROOT="" +if [[ "$BINARY" == *.exe ]] && command -v cygpath >/dev/null 2>&1 && + ! command -v winepath >/dev/null 2>&1; then + # The server's cache-private hardening walks the FULL ancestor chain of + # both the executable and the cache dir and fails (or silently disables + # diagnostics) on any ancestor granting mutation to untrusted SIDs. The + # msys /tmp tree and repo checkouts both carry such ACEs, so neither can + # host the soak. The user profile is the one ancestry that is clean on + # runners and dev machines alike — the Windows guard suites already run + # their binaries from copies there for the same reason. Copy the binary + # into a stamped root under USERPROFILE and cache beside it. + SOAK_WIN_ROOT=$(mktemp -d "$(cygpath "$USERPROFILE")/cbm-soak.XXXXXX") + if ! soak_stamp_windows_dir "$SOAK_WIN_ROOT"; then + rm -rf -- "$SOAK_WIN_ROOT" + exit 1 + fi + cp "$BINARY" "$SOAK_WIN_ROOT/codebase-memory-mcp.exe" + BINARY="$SOAK_WIN_ROOT/codebase-memory-mcp.exe" + SOAK_CACHE_DIR_HOST="$SOAK_WIN_ROOT/cache" + mkdir -p "$SOAK_CACHE_DIR_HOST" + SOAK_WIN_ROOT_W="$(cygpath -w "$SOAK_WIN_ROOT")" + if ! SOAK_DACL_OUTPUT=$(MSYS2_ARG_CONV_EXCL='*' icacls "${SOAK_WIN_ROOT_W}\\*" \ + /reset /T /C /Q 2>&1); then + echo "FAIL: soak child DACL reset failed (dir=$SOAK_WIN_ROOT_W)" >&2 + printf '%s\n' "$SOAK_DACL_OUTPUT" >&2 + rm -rf -- "$SOAK_WIN_ROOT" + exit 1 + fi +else + SOAK_CACHE_DIR_HOST=$(mktemp -d "${TMPDIR:-/tmp}/cbm-soak-cache.XXXXXX") +fi SOAK_CACHE_DIR_VALUE="$SOAK_CACHE_DIR_HOST" if [[ "$BINARY" == *.exe ]] && command -v winepath >/dev/null 2>&1; then SOAK_CACHE_DIR_VALUE=$(winepath -w "$SOAK_CACHE_DIR_HOST") @@ -101,6 +154,7 @@ soak_cleanup() { cp "$DAEMON_LOG" "$RESULTS_DIR/cbm-daemon.log" 2>/dev/null || true fi rm -rf -- "$SOAK_PROJECT" "$SOAK_CACHE_DIR_HOST" + [ -z "${SOAK_WIN_ROOT:-}" ] || rm -rf -- "$SOAK_WIN_ROOT" } trap soak_cleanup EXIT @@ -488,6 +542,8 @@ sleep 3 if ! kill -0 "$SERVER_PID" 2>/dev/null; then echo "FAIL: server did not start" + echo "--- server stderr (tail) ---" + tail -40 "$RESULTS_DIR/server-stderr.log" 2>/dev/null || echo "(no stderr captured)" exec 3>&- 4<&- rm -f "$SERVER_IN" "$SERVER_OUT" exit 1 @@ -496,6 +552,8 @@ echo "OK: server running (pid=$SERVER_PID)" if ! wait_for_diagnostics_snapshot; then echo "FAIL: daemon did not emit a usable diagnostics.start path" + echo "--- server stderr (tail) ---" + tail -40 "$RESULTS_DIR/server-stderr.log" 2>/dev/null || echo "(no stderr captured)" exec 3>&- 4<&- kill "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true diff --git a/scripts/test.sh b/scripts/test.sh index 5155e4e20..cabdf3c3d 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -85,6 +85,12 @@ bash "$ROOT/tests/test_makefile_ts_runtime_dependencies.sh" echo "=== Step 0g: security fuzz harness self-test ===" bash "$ROOT/tests/test_security_fuzz_harness.sh" +echo "=== Step 0h: smoke release-fixture contract ===" +bash "$ROOT/tests/test_smoke_fixture_contract.sh" + +echo "=== Step 0i: parallel suite scheduler contract ===" +bash "$ROOT/tests/test_parallel_harness_contract.sh" + # Verify compiler supports target arch verify_compiler "$CC" @@ -116,13 +122,14 @@ fi # binary and verifies it self-exits when its launching parent is killed. echo "=== Step 5: parent-death watchdog regression (#406/#407) ===" make -j"$NPROC" -f Makefile.cbm cbm ${MAKE_ARGS[@]+"${MAKE_ARGS[@]}"} -bash "$ROOT/tests/test_parent_watchdog.sh" +WATCHDOG_BINARY="$ROOT/$BUILD_DIR/codebase-memory-mcp" +CBM_TEST_BINARY="$WATCHDOG_BINARY" bash "$ROOT/tests/test_parent_watchdog.sh" # Step 5b: worker-mode parent-death watchdog (#845). A supervised index worker # (`cli --index-worker …`) whose supervisor dies must self-exit instead of # indexing on as an orphan. Reuses the prod binary built in Step 5. echo "=== Step 5b: worker-mode watchdog regression (#845) ===" -bash "$ROOT/tests/test_worker_watchdog.sh" +CBM_TEST_BINARY="$WATCHDOG_BINARY" bash "$ROOT/tests/test_worker_watchdog.sh" # Step 6: security-strings URL allow-list regression. The MSYS2 CLANG64 toolchain # bakes its package-tracker URL into the static Windows .exe; the binary string diff --git a/src/cli/cli.c b/src/cli/cli.c index 7162394ba..f792c4a32 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -1762,29 +1762,328 @@ static size_t cbm_json_mcp_ownership_fields(cbm_json_mcp_schema_t schema, const return 3U; } -static bool cbm_json_mcp_owned_command(const char *command, const char *expected_binary) { +/* The running executable is trustworthy identity evidence: install/update can + * replace an exact-shape entry that still names the binary which initiated the + * coordinated activation. It is scoped to that synchronous refresh and never + * populated from config content. */ +static CBM_TLS const char *g_previous_managed_mcp_command = NULL; + +static bool cbm_json_mcp_owned_command(const char *command, const char *expected_binary, + const char *previous_managed_binary) { if (!command || command[0] == '\0') { return false; } if (expected_binary && expected_binary[0] && strcmp(command, expected_binary) == 0) { return true; } + if (previous_managed_binary && previous_managed_binary[0] && + strcmp(command, previous_managed_binary) == 0) { + return true; + } return strcmp(command, "codebase-memory-mcp") == 0 || strcmp(command, "codebase-memory-mcp.exe") == 0; } +/* Ownership state beyond the config_json_like enum: the entry has exactly + * OUR schema shape under OUR reserved name, but its command is a concrete + * absolute Windows path that the local fixed-drive walk conclusively reports + * missing. Upsert repairs it; remove leaves it alone like any other non-owned + * entry. POSIX never probes config-supplied paths: only the running executable + * identity above can authorize a moved-install refresh. */ +enum { CBM_JSON_MCP_OWNERSHIP_STALE = 100 }; + +#ifdef _WIN32 +typedef enum { + CBM_JSON_MCP_COMMAND_UNKNOWN = 0, + CBM_JSON_MCP_COMMAND_PRESENT, + CBM_JSON_MCP_COMMAND_MISSING, +} cbm_json_mcp_command_availability_t; +#endif + +#if defined(_WIN32) || defined(CBM_CLI_ENABLE_TEST_API) +static bool cbm_json_mcp_command_path_probe_safe_for_platform(const char *command, bool windows) { + if (!command || !command[0]) { + return false; + } + if (windows) { + size_t length = strlen(command); + /* Never probe UNC or Win32 device namespaces from config content. + * GetFileAttributesW on those paths can perform outbound SMB + * authentication or block on an attacker-controlled endpoint. */ + return length >= 3U && isalpha((unsigned char)command[0]) && command[1] == ':' && + (command[2] == '/' || command[2] == '\\'); + } + /* A slash-absolute POSIX path can cross autofs, NFS, FUSE, or a symlink + * before stat returns. Config bytes therefore never authorize a POSIX + * filesystem probe. */ + return false; +} +#endif + +#ifdef _WIN32 +static bool cbm_json_mcp_command_path_probe_safe(const char *command) { + return cbm_json_mcp_command_path_probe_safe_for_platform(command, true); +} +#endif + +#ifdef CBM_CLI_ENABLE_TEST_API +static CBM_TLS int *g_mcp_command_path_probe_counter = NULL; + +bool cbm_mcp_command_path_probe_safe_for_testing(const char *command, bool windows) { + return cbm_json_mcp_command_path_probe_safe_for_platform(command, windows); +} + +void cbm_set_mcp_command_path_probe_counter_for_testing(int *counter) { + g_mcp_command_path_probe_counter = counter; +} +#endif + +#ifdef _WIN32 +static bool cbm_json_mcp_windows_path_character_forbidden(wchar_t character) { + return character < L' ' || character == L':' || character == L'*' || character == L'?' || + character == L'"' || character == L'<' || character == L'>' || character == L'|'; +} + +static bool cbm_json_mcp_windows_path_syntax_valid(const wchar_t *path) { + size_t length = path ? wcslen(path) : 0U; + bool ascii_drive = length >= 1U && ((path[0] >= L'A' && path[0] <= L'Z') || + (path[0] >= L'a' && path[0] <= L'z')); + if (length < 3U || !ascii_drive || path[1] != L':' || path[2] != L'\\') { + return false; + } + size_t component_start = 3U; + for (size_t index = component_start; index <= length; index++) { + if (index < length && path[index] != L'\\') { + if (cbm_json_mcp_windows_path_character_forbidden(path[index])) { + return false; + } + continue; + } + if (index == component_start) { + return length == 3U && index == length; + } + size_t component_length = index - component_start; + const wchar_t *component = path + component_start; + if ((component_length == 1U && component[0] == L'.') || + (component_length == 2U && component[0] == L'.' && component[1] == L'.') || + component[component_length - 1U] == L'.' || component[component_length - 1U] == L' ') { + return false; + } + component_start = index + 1U; + } + return true; +} + +static wchar_t *cbm_json_mcp_windows_path_from_utf8(const char *path) { + int needed = path ? MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, NULL, 0) : 0; + if (needed <= 0 || needed > MAX_PATH) { + return NULL; + } + wchar_t *wide = malloc((size_t)needed * sizeof(*wide)); + if (!wide || MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, wide, needed) <= 0) { + free(wide); + return NULL; + } + for (int index = 0; index < needed - 1; index++) { + if (wide[index] == L'/') { + wide[index] = L'\\'; + } + } + if (!cbm_json_mcp_windows_path_syntax_valid(wide)) { + free(wide); + return NULL; + } + return wide; +} + +static bool cbm_json_mcp_windows_handle_is_local(HANDLE handle, wchar_t expected_drive, + BY_HANDLE_FILE_INFORMATION *information_out) { + BY_HANDLE_FILE_INFORMATION information; + if (handle == INVALID_HANDLE_VALUE || GetFileType(handle) != FILE_TYPE_DISK || + !GetFileInformationByHandle(handle, &information) || + (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + return false; + } + DWORD capacity = + GetFinalPathNameByHandleW(handle, NULL, 0, FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); + if (capacity < 8U || capacity > 32768U) { + return false; + } + wchar_t *resolved = malloc((size_t)capacity * sizeof(*resolved)); + if (!resolved) { + return false; + } + DWORD length = GetFinalPathNameByHandleW(handle, resolved, capacity, + FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); + wchar_t actual_drive = length >= 7U ? resolved[4] : L'\0'; + if (actual_drive >= L'a' && actual_drive <= L'z') { + actual_drive -= L'a' - L'A'; + } + if (expected_drive >= L'a' && expected_drive <= L'z') { + expected_drive -= L'a' - L'A'; + } + bool valid = length >= 7U && length < capacity && resolved[0] == L'\\' && + resolved[1] == L'\\' && resolved[2] == L'?' && resolved[3] == L'\\' && + resolved[5] == L':' && resolved[6] == L'\\' && actual_drive == expected_drive; + free(resolved); + if (valid && information_out) { + *information_out = information; + } + return valid; +} + +static cbm_json_mcp_command_availability_t cbm_json_mcp_probe_windows_command_path( + const char *path) { + wchar_t *wide = cbm_json_mcp_windows_path_from_utf8(path); + if (!wide) { + return CBM_JSON_MCP_COMMAND_UNKNOWN; + } + wchar_t volume_root[4] = {wide[0], L':', L'\\', L'\0'}; + UINT drive_type = GetDriveTypeW(volume_root); + if (drive_type != DRIVE_FIXED && drive_type != DRIVE_RAMDISK) { + free(wide); + return CBM_JSON_MCP_COMMAND_UNKNOWN; + } + + size_t length = wcslen(wide); + if (length == 3U) { + free(wide); + return CBM_JSON_MCP_COMMAND_PRESENT; + } + wchar_t *partial = malloc((length + 1U) * sizeof(*partial)); + HANDLE *component_handles = calloc(length + 1U, sizeof(*component_handles)); + if (!partial || !component_handles) { + free(component_handles); + free(partial); + free(wide); + return CBM_JSON_MCP_COMMAND_UNKNOWN; + } + memcpy(partial, wide, (length + 1U) * sizeof(*partial)); + cbm_json_mcp_command_availability_t availability = CBM_JSON_MCP_COMMAND_UNKNOWN; + size_t component_handle_count = 0U; + + /* Retain every validated ancestor without write/delete sharing until the + * classification is complete. Otherwise a same-account concurrent writer + * could replace a checked directory with a junction before the next + * absolute-path open, causing that open to follow an SMB target. */ + HANDLE root_handle = + CreateFileW(volume_root, FILE_READ_ATTRIBUTES, FILE_SHARE_READ, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + BY_HANDLE_FILE_INFORMATION root_information; + if (!cbm_json_mcp_windows_handle_is_local(root_handle, wide[0], &root_information) || + (root_information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0) { + if (root_handle != INVALID_HANDLE_VALUE) { + (void)CloseHandle(root_handle); + } + free(component_handles); + free(partial); + free(wide); + return CBM_JSON_MCP_COMMAND_UNKNOWN; + } + component_handles[component_handle_count++] = root_handle; + + for (size_t index = 3U; index <= length; index++) { + if (index < length && partial[index] != L'\\') { + continue; + } + bool final_component = index == length; + wchar_t saved = partial[index]; + partial[index] = L'\0'; + HANDLE component = + CreateFileW(partial, FILE_READ_ATTRIBUTES, FILE_SHARE_READ, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + DWORD error = component == INVALID_HANDLE_VALUE ? GetLastError() : ERROR_SUCCESS; + BY_HANDLE_FILE_INFORMATION information; + bool local = cbm_json_mcp_windows_handle_is_local(component, wide[0], &information); + if (local) { + component_handles[component_handle_count++] = component; + } else if (component != INVALID_HANDLE_VALUE) { + (void)CloseHandle(component); + } + partial[index] = saved; + + if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) { + availability = CBM_JSON_MCP_COMMAND_MISSING; + break; + } + if (error != ERROR_SUCCESS || !local || + (!final_component && (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0)) { + availability = CBM_JSON_MCP_COMMAND_UNKNOWN; + break; + } + if (final_component) { + availability = CBM_JSON_MCP_COMMAND_PRESENT; + break; + } + } + for (size_t handle_index = 0U; handle_index < component_handle_count; handle_index++) { + (void)CloseHandle(component_handles[handle_index]); + } + free(component_handles); + free(partial); + free(wide); + return availability; +} +#endif + +#ifdef _WIN32 +static cbm_json_mcp_command_availability_t cbm_json_mcp_probe_command_path(const char *path) { +#ifdef CBM_CLI_ENABLE_TEST_API + if (g_mcp_command_path_probe_counter) { + (*g_mcp_command_path_probe_counter)++; + } +#endif + return cbm_json_mcp_probe_windows_command_path(path); +} + +/* Repair only a concrete fixed-drive path with a conclusive local "not found" + * result. Bare/relative/templated commands and filesystem errors remain + * fail-closed. POSIX never enters this classifier. */ +static cbm_json_mcp_command_availability_t cbm_json_mcp_command_availability(const char *command) { + if (!cbm_json_mcp_command_path_probe_safe(command) || strchr(command, '$') || + strchr(command, '%')) { + return CBM_JSON_MCP_COMMAND_UNKNOWN; + } + cbm_json_mcp_command_availability_t availability = cbm_json_mcp_probe_command_path(command); + if (availability != CBM_JSON_MCP_COMMAND_MISSING) { + return availability; + } + const char *slash = strrchr(command, '/'); + const char *backslash = strrchr(command, '\\'); + const char *basename = slash && backslash + ? (slash > backslash ? slash + 1 : backslash + 1) + : (slash ? slash + 1 : (backslash ? backslash + 1 : command)); + if (!basename[0] || strchr(basename, '.')) { + return CBM_JSON_MCP_COMMAND_MISSING; + } + /* An extensionless command can resolve through a client-specific or + * custom PATHEXT suffix that is not present in the installer's process. + * Absence of the literal file therefore never proves absence of the + * command. Preserve it unless positive prior-image identity matched. */ + availability = CBM_JSON_MCP_COMMAND_UNKNOWN; + return availability; +} +#endif + static int cbm_json_mcp_snapshot_ownership(const char *document, size_t document_length, const char *const *object_path, size_t path_len, cbm_json_mcp_schema_t schema, const char *entry_name, - const char *argument, const char *expected_binary) { + const char *argument, const char *expected_binary, + const char *previous_managed_binary) { cbm_json_like_object_field_t fields[3]; size_t field_count = cbm_json_mcp_ownership_fields(schema, argument, fields); char *command = NULL; int result = cbm_json_like_match_object_entry(document, document_length, object_path, path_len, entry_name, fields, field_count, &command); if (result == CBM_JSON_LIKE_OBJECT_MATCH && - !cbm_json_mcp_owned_command(command, expected_binary)) { + !cbm_json_mcp_owned_command(command, expected_binary, previous_managed_binary)) { +#ifdef _WIN32 + result = cbm_json_mcp_command_availability(command) == CBM_JSON_MCP_COMMAND_MISSING + ? CBM_JSON_MCP_OWNERSHIP_STALE + : CBM_JSON_LIKE_OBJECT_MISMATCH; +#else result = CBM_JSON_LIKE_OBJECT_MISMATCH; +#endif } free(command); return result; @@ -1804,10 +2103,13 @@ static int cbm_upsert_json_named_mcp(const char *binary_path, const char *config return CLI_ERR; } if (read_result == 0) { - int ownership = - cbm_json_mcp_snapshot_ownership(document, document_length, object_path, path_len, - schema, entry_name, argument, binary_path); - if (ownership != CBM_JSON_LIKE_OBJECT_MATCH && ownership != CBM_JSON_LIKE_OBJECT_MISSING) { + int ownership = cbm_json_mcp_snapshot_ownership( + document, document_length, object_path, path_len, schema, entry_name, argument, + binary_path, g_previous_managed_mcp_command); + /* STALE (our exact shape, dead binary path) is repairable — that is + * the update contract. Only a genuinely foreign shape refuses. */ + if (ownership != CBM_JSON_LIKE_OBJECT_MATCH && ownership != CBM_JSON_LIKE_OBJECT_MISSING && + ownership != CBM_JSON_MCP_OWNERSHIP_STALE) { free(document); return CLI_ERR; } @@ -1853,8 +2155,9 @@ static int cbm_remove_json_named_mcp(const char *config_path, const char *const } int ownership = cbm_json_mcp_snapshot_ownership(document, document_length, object_path, path_len, schema, - entry_name, argument, expected_binary); - if (ownership == CBM_JSON_LIKE_OBJECT_MISSING || ownership == CBM_JSON_LIKE_OBJECT_MISMATCH) { + entry_name, argument, expected_binary, NULL); + if (ownership == CBM_JSON_LIKE_OBJECT_MISSING || ownership == CBM_JSON_LIKE_OBJECT_MISMATCH || + ownership == CBM_JSON_MCP_OWNERSHIP_STALE) { free(document); return CLI_OK; } @@ -1882,6 +2185,18 @@ int cbm_install_editor_mcp(const char *binary_path, const char *config_path) { return cbm_upsert_json_mcp(binary_path, config_path, path, 1U, CBM_JSON_MCP_STANDARD); } +#ifdef CBM_CLI_ENABLE_TEST_API +int cbm_install_editor_mcp_with_previous_for_testing(const char *binary_path, + const char *previous_binary_path, + const char *config_path) { + const char *saved = g_previous_managed_mcp_command; + g_previous_managed_mcp_command = previous_binary_path; + int result = cbm_install_editor_mcp(binary_path, config_path); + g_previous_managed_mcp_command = saved; + return result; +} +#endif + int cbm_remove_editor_mcp(const char *config_path) { static const char *const path[] = {"mcpServers"}; return cbm_remove_json_mcp(config_path, path, 1U, CBM_JSON_MCP_STANDARD, NULL); @@ -3552,10 +3867,11 @@ static int cbm_junie_mcp_preflight(const char *binary_path, const char *config_p } int result = CLI_OK; for (size_t i = 0U; i < sizeof(entries) / sizeof(entries[0]); i++) { - int ownership = cbm_json_mcp_snapshot_ownership(document, document_length, path, 1U, - CBM_JSON_MCP_STANDARD, entries[i].name, - entries[i].argument, binary_path); - if (ownership != CBM_JSON_LIKE_OBJECT_MATCH && ownership != CBM_JSON_LIKE_OBJECT_MISSING) { + int ownership = cbm_json_mcp_snapshot_ownership( + document, document_length, path, 1U, CBM_JSON_MCP_STANDARD, entries[i].name, + entries[i].argument, binary_path, g_previous_managed_mcp_command); + if (ownership != CBM_JSON_LIKE_OBJECT_MATCH && ownership != CBM_JSON_LIKE_OBJECT_MISSING && + ownership != CBM_JSON_MCP_OWNERSHIP_STALE) { result = CLI_ERR; break; } @@ -3580,6 +3896,18 @@ int cbm_upsert_junie_mcp(const char *binary_path, const char *config_path) { return CLI_OK; } +#ifdef CBM_CLI_ENABLE_TEST_API +int cbm_upsert_junie_mcp_with_previous_for_testing(const char *binary_path, + const char *previous_binary_path, + const char *config_path) { + const char *saved = g_previous_managed_mcp_command; + g_previous_managed_mcp_command = previous_binary_path; + int result = cbm_upsert_junie_mcp(binary_path, config_path); + g_previous_managed_mcp_command = saved; + return result; +} +#endif + int cbm_remove_junie_mcp(const char *config_path) { static const char *const path[] = {"mcpServers"}; int result = CLI_OK; @@ -5600,15 +5928,104 @@ static bool cli_windows_path_segment_equal(const wchar_t *segment, size_t segmen return segment_length == directory_length && _wcsnicmp(segment, directory, segment_length) == 0; } +static bool cli_windows_smoke_download_url_valid(void) { + wchar_t url[CLI_BUF_128]; + DWORD length = + GetEnvironmentVariableW(L"SMOKE_DOWNLOAD_URL", url, (DWORD)(sizeof(url) / sizeof(url[0]))); + if (length == 0U || length >= (DWORD)(sizeof(url) / sizeof(url[0]))) { + return false; + } + static const wchar_t prefix[] = L"http://127.0.0.1:"; + if (wcsncmp(url, prefix, (sizeof(prefix) / sizeof(prefix[0])) - 1U) != 0) { + return false; + } + const wchar_t *cursor = url + (sizeof(prefix) / sizeof(prefix[0])) - 1U; + unsigned long port = 0UL; + if (*cursor < L'0' || *cursor > L'9') { + return false; + } + while (*cursor >= L'0' && *cursor <= L'9') { + unsigned long digit = (unsigned long)(*cursor - L'0'); + if (port > (65535UL - digit) / 10UL) { + return false; + } + port = port * 10UL + digit; + cursor++; + } + return *cursor == L'\0' && port > 0UL; +} + +/* The release smoke must exercise the production registry query/append/write + * path without ever mutating the developer's live HKCU\Environment\Path. + * A pre-created, GUID-named leaf is accepted only under the complete local + * loopback smoke contract. Presence with any invalid gate fails closed. */ +static int cli_windows_open_user_path_key(HKEY *environment) { + if (!environment) { + return CLI_ERR; + } + *environment = NULL; + + SetLastError(ERROR_SUCCESS); + DWORD needed = GetEnvironmentVariableW(L"CBM_TEST_WINDOWS_USER_PATH_RUN_ID", NULL, 0U); + DWORD run_id_error = GetLastError(); + if (needed == 0U && run_id_error == ERROR_ENVVAR_NOT_FOUND) { + return RegOpenKeyExW(HKEY_CURRENT_USER, L"Environment", 0, KEY_QUERY_VALUE | KEY_SET_VALUE, + environment) == ERROR_SUCCESS + ? CLI_OK + : CLI_ERR; + } + + wchar_t run_id[CLI_BUF_32 + 1U]; + if (needed != CLI_BUF_32 + 1U || + GetEnvironmentVariableW(L"CBM_TEST_WINDOWS_USER_PATH_RUN_ID", run_id, + (DWORD)(sizeof(run_id) / sizeof(run_id[0]))) != CLI_BUF_32) { + return CLI_ERR; + } + for (size_t index = 0; index < CLI_BUF_32; index++) { + wchar_t value = run_id[index]; + if (!((value >= L'0' && value <= L'9') || (value >= L'a' && value <= L'f') || + (value >= L'A' && value <= L'F'))) { + return CLI_ERR; + } + } + + SetLastError(ERROR_SUCCESS); + DWORD smoke_root_needed = GetEnvironmentVariableW(L"SMOKE_TEMP_ROOT", NULL, 0U); + if (smoke_root_needed <= 1U || !cli_windows_smoke_download_url_valid()) { + return CLI_ERR; + } + + wchar_t key_path[CLI_BUF_128]; + int key_length = swprintf(key_path, sizeof(key_path) / sizeof(key_path[0]), + L"Software\\CodebaseMemoryMCP\\Smoke\\%ls", run_id); + if (key_length <= 0 || (size_t)key_length >= sizeof(key_path) / sizeof(key_path[0]) || + RegOpenKeyExW(HKEY_CURRENT_USER, key_path, 0, + KEY_QUERY_VALUE | KEY_SET_VALUE | KEY_WOW64_64KEY, + environment) != ERROR_SUCCESS) { + return CLI_ERR; + } + + wchar_t sentinel[CLI_BUF_32 + 1U] = {0}; + DWORD sentinel_type = 0U; + DWORD sentinel_bytes = (DWORD)sizeof(sentinel); + LONG sentinel_status = RegQueryValueExW(*environment, L"CbmSmokeRunId", NULL, &sentinel_type, + (BYTE *)sentinel, &sentinel_bytes); + if (sentinel_status != ERROR_SUCCESS || sentinel_type != REG_SZ || + sentinel_bytes != (CLI_BUF_32 + 1U) * sizeof(wchar_t) || wcscmp(sentinel, run_id) != 0) { + RegCloseKey(*environment); + *environment = NULL; + return CLI_ERR; + } + return CLI_OK; +} + /* Persist the current-user PATH while the activation lease is held. The * installer script may update only its process-local PATH after this returns; * it must not perform a second persistent mutation outside coordination. */ static int cli_ensure_windows_user_path(const char *bin_dir, bool dry_run) { wchar_t *wide_dir = cli_windows_utf8_to_wide(bin_dir); HKEY environment = NULL; - if (!wide_dir || - RegOpenKeyExW(HKEY_CURRENT_USER, L"Environment", 0, KEY_QUERY_VALUE | KEY_SET_VALUE, - &environment) != ERROR_SUCCESS) { + if (!wide_dir || cli_windows_open_user_path_key(&environment) != CLI_OK) { free(wide_dir); return CLI_ERR; } @@ -9259,6 +9676,18 @@ int cbm_install_agent_configs(const char *home, const char *binary_path, bool fo return g_agent_install_errors == 0 ? CLI_OK : CLI_ERR; } +#if !defined(_WIN32) || defined(CBM_CLI_ENABLE_TEST_API) +static int cbm_install_agent_configs_with_previous(const char *home, const char *binary_path, + const char *previous_managed_binary_path, + bool force, bool dry_run) { + const char *saved = g_previous_managed_mcp_command; + g_previous_managed_mcp_command = previous_managed_binary_path; + int result = cbm_install_agent_configs(home, binary_path, force, dry_run); + g_previous_managed_mcp_command = saved; + return result; +} +#endif + /* Count .db files in the cache directory. */ static int count_db_indexes(const char *home) { const char *cache_dir = get_cache_dir(home); @@ -9356,21 +9785,33 @@ int cbm_install_handle_existing_indexes(const char *home, bool reset, bool dry_r * activation/agent-config semantics, while release Windows binaries compile * exclusively the managed launcher transaction. */ #if !defined(_WIN32) || defined(CBM_CLI_ENABLE_TEST_API) -/* Detect the running binary's path at runtime. Falls back to ~/.local/bin/. */ -static void cbm_detect_self_path(char *buf, size_t buf_sz, const char *home) { +/* Detect the running binary's path at runtime. The return value distinguishes + * OS-reported identity (safe ownership evidence) from the ~/.local/bin fallback + * used only as an install copy source. */ +static bool cbm_detect_self_path(char *buf, size_t buf_sz, const char *home) { buf[0] = '\0'; + bool exact = false; #ifdef _WIN32 - GetModuleFileNameA(NULL, buf, (DWORD)buf_sz); - cbm_normalize_path_sep(buf); + DWORD length = GetModuleFileNameA(NULL, buf, (DWORD)buf_sz); + exact = length > 0 && (size_t)length < buf_sz; + if (exact) { + cbm_normalize_path_sep(buf); + } else { + buf[0] = '\0'; + } #elif defined(__APPLE__) uint32_t sp_sz = (uint32_t)buf_sz; - if (_NSGetExecutablePath(buf, &sp_sz) != 0) { + exact = _NSGetExecutablePath(buf, &sp_sz) == 0; + if (!exact) { buf[0] = '\0'; } #else ssize_t sp_len = readlink("/proc/self/exe", buf, buf_sz - SKIP_ONE); - if (sp_len > 0) { + exact = sp_len > 0 && (size_t)sp_len < buf_sz - SKIP_ONE; + if (exact) { buf[sp_len] = '\0'; + } else { + buf[0] = '\0'; } #endif if (!buf[0]) { @@ -9380,6 +9821,7 @@ static void cbm_detect_self_path(char *buf, size_t buf_sz, const char *home) { snprintf(buf, buf_sz, "%s/.local/bin/codebase-memory-mcp", home); #endif } + return exact; } #endif /* !defined(_WIN32) || defined(CBM_CLI_ENABLE_TEST_API) */ @@ -9526,6 +9968,9 @@ static int cli_install_activate(void *opaque) { !activation->shell_rc) { return CLI_TRUE; } + char previous_managed_binary[CLI_BUF_1K] = {0}; + bool previous_managed_binary_exact = cbm_detect_self_path( + previous_managed_binary, sizeof(previous_managed_binary), activation->home); if (activation->copy_binary) { if (activation->dry_run) { printf("Would install binary -> %s\n\n", activation->bin_target); @@ -9571,8 +10016,10 @@ static int cli_install_activate(void *opaque) { * including same-binary and non-force installs. */ int agent_config_rc = CLI_OK; if (!activation->skip_config) { - agent_config_rc = cbm_install_agent_configs(activation->home, activation->bin_target, - activation->force, activation->dry_run); + agent_config_rc = cbm_install_agent_configs_with_previous( + activation->home, activation->bin_target, + previous_managed_binary_exact ? previous_managed_binary : NULL, activation->force, + activation->dry_run); } if (agent_config_rc != CLI_OK) { cli_activation_transaction_finalize_committed_or_fail_stop( @@ -10539,7 +10986,7 @@ int cbm_cmd_install(int argc, char **argv) { #if !defined(_WIN32) || defined(CBM_CLI_ENABLE_TEST_API) char self_path[CLI_BUF_1K] = {0}; - cbm_detect_self_path(self_path, sizeof(self_path), home); + (void)cbm_detect_self_path(self_path, sizeof(self_path), home); struct stat target_status; bool target_exists = (stat(bin_target, &target_status) == 0); @@ -12553,6 +13000,9 @@ static int cli_update_activate_binary(void *opaque) { if (!activation || !activation->bin_dest || !activation->binary_transaction) { return CLI_TRUE; } + char previous_managed_binary[CLI_BUF_1K] = {0}; + bool previous_managed_binary_exact = cbm_detect_self_path( + previous_managed_binary, sizeof(previous_managed_binary), activation->home); if (cli_activation_transaction_commit_validated(activation->binary_transaction, &activation->binary_validator, CLI_OCTAL_PERM) != CLI_OK) { @@ -12565,7 +13015,10 @@ static int cli_update_activate_binary(void *opaque) { * exact-build activation window. Otherwise another CBM process can start * after the binary swap but before its MCP/hook entries are refreshed. */ printf("Refreshing agent configurations...\n"); - if (cbm_install_agent_configs(activation->home, activation->bin_dest, true, false) != CLI_OK) { + if (cbm_install_agent_configs_with_previous( + activation->home, activation->bin_dest, + previous_managed_binary_exact ? previous_managed_binary : NULL, true, + false) != CLI_OK) { cli_activation_transaction_finalize_committed_or_fail_stop( &activation->binary_transaction, "update_transaction_config_failure_finalize"); (void)fprintf(stderr, "error: one or more agent configurations failed; the " diff --git a/src/cli/cli.h b/src/cli/cli.h index 661871f2c..73069166e 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -220,6 +220,14 @@ bool cbm_hook_augment_invocation_supported_for_testing(const char *dialect, bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, bool case_insensitive); const char *cbm_hook_no_project_index_guidance_for_testing(const char *event); +bool cbm_mcp_command_path_probe_safe_for_testing(const char *command, bool windows); +void cbm_set_mcp_command_path_probe_counter_for_testing(int *counter); +int cbm_install_editor_mcp_with_previous_for_testing(const char *binary_path, + const char *previous_binary_path, + const char *config_path); +int cbm_upsert_junie_mcp_with_previous_for_testing(const char *binary_path, + const char *previous_binary_path, + const char *config_path); #endif /* ── Agent MCP config upsert (per agent) ──────────────────────── */ diff --git a/test-infrastructure/docker-compose.yml b/test-infrastructure/docker-compose.yml index c8f6f0ccb..5ed91df6a 100644 --- a/test-infrastructure/docker-compose.yml +++ b/test-infrastructure/docker-compose.yml @@ -52,6 +52,9 @@ services: context: .. dockerfile: test-infrastructure/Dockerfile platform: linux/arm64 + # make is PID 1 for this service. Give orphaned subprocess-test descendants + # a real reaper so killed zombies do not keep process groups appearing live. + init: true volumes: - ..:/src - cbm-build:/src/build @@ -75,6 +78,7 @@ services: context: .. dockerfile: test-infrastructure/Dockerfile platform: linux/amd64 + init: true volumes: - ..:/src - cbm-build:/src/build @@ -146,7 +150,7 @@ services: command: - | scripts/build.sh CC=gcc CXX=g++ BUILD_DIR=build/linux-arm64 && - scripts/smoke-test.sh ./build/linux-arm64/codebase-memory-mcp + scripts/smoke-local.sh ./build/linux-arm64/codebase-memory-mcp smoke-amd64: build: @@ -159,7 +163,7 @@ services: command: - | scripts/build.sh CC=gcc CXX=g++ BUILD_DIR=build/linux-amd64 && - scripts/smoke-test.sh ./build/linux-amd64/codebase-memory-mcp + scripts/smoke-local.sh ./build/linux-amd64/codebase-memory-mcp # ── Windows smoke (cross-compile + Wine) ────────────────── smoke-windows: diff --git a/test-infrastructure/vm/provision-windows.sh b/test-infrastructure/vm/provision-windows.sh index dc50986cc..81cee00f5 100755 --- a/test-infrastructure/vm/provision-windows.sh +++ b/test-infrastructure/vm/provision-windows.sh @@ -56,6 +56,7 @@ vm() { # vm "${SSH[@]}" "C:\\msys64\\msys2_shell.cmd -defterm -no-start -${env} -c \"set -e -o pipefail; $*\"" } vm_cmd() { "${SSH[@]}" "$@"; } # plain cmd.exe +vm_powershell() { cbm_vm_run_powershell "$1" "${SSH[@]}"; } step() { printf '\n\033[1m== %s ==\033[0m\n' "$*"; } @@ -65,11 +66,12 @@ vm_cmd "echo VM_OK & ver" | grep -q VM_OK || { echo "Run windows-bootstrap.ps1 inside the VM first (see README.md)." >&2 exit 1 } +cbm_vm_sync_windows_clock "${SSH[@]}" if [ "${1:-}" != "--update" ]; then step "1/6 msys2 base (skip if present)" if ! vm_cmd "if exist C:\\msys64\\usr\\bin\\bash.exe echo MSYS2_PRESENT" | grep -q MSYS2_PRESENT; then - vm_cmd "powershell -NoProfile -Command \"\$ProgressPreference='SilentlyContinue'; [Net.ServicePointManager]::SecurityProtocol='Tls12'; Invoke-WebRequest -Uri '${MSYS2_SFX_URL}' -OutFile C:\\msys2.sfx.exe; \$actual=(Get-FileHash -LiteralPath C:\\msys2.sfx.exe -Algorithm SHA256).Hash.ToLowerInvariant(); if (\$actual -ne '${MSYS2_SFX_SHA256}') { Remove-Item -LiteralPath C:\\msys2.sfx.exe -Force; throw 'MSYS2 installer SHA-256 mismatch' }\"" + vm_powershell "\$ProgressPreference='SilentlyContinue'; [Net.ServicePointManager]::SecurityProtocol='Tls12'; Invoke-WebRequest -Uri '${MSYS2_SFX_URL}' -OutFile C:\\msys2.sfx.exe; \$actual=(Get-FileHash -LiteralPath C:\\msys2.sfx.exe -Algorithm SHA256).Hash.ToLowerInvariant(); if (\$actual -ne '${MSYS2_SFX_SHA256}') { Remove-Item -LiteralPath C:\\msys2.sfx.exe -Force; throw 'MSYS2 installer SHA-256 mismatch' }" vm_cmd "C:\\msys2.sfx.exe -y -oC:\\" >/dev/null vm_cmd "del C:\\msys2.sfx.exe" vm "msys" "pacman-key --init && pacman-key --populate msys2" @@ -81,7 +83,7 @@ if [ "${1:-}" != "--update" ]; then step "3/6 toolchains: CLANGARM64 (native, fast) + CLANG64 (x86_64 = CI arch, ASan)" vm "msys" "pacman -S --noconfirm --noprogressbar --needed \ - git make coreutils \ + git make coreutils curl zip unzip \ mingw-w64-clang-aarch64-clang mingw-w64-clang-aarch64-zlib \ mingw-w64-clang-aarch64-python mingw-w64-clang-aarch64-ccache \ mingw-w64-clang-x86_64-clang mingw-w64-clang-x86_64-compiler-rt \ @@ -94,10 +96,10 @@ if [ "${1:-}" != "--update" ]; then NODE_VERSION="v22.23.1" NODE_SHA256="b470fdfe3502c05151656e06d495e3f47544f2ee8b1d9c8705090f2dd5996bd0" if ! vm_cmd "if exist C:\\node\\node.exe echo NODE_PRESENT" | grep -q NODE_PRESENT; then - vm_cmd "powershell -NoProfile -Command \"\$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri 'https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-win-arm64.zip' -OutFile C:\\node.zip; \$actual=(Get-FileHash -LiteralPath C:\\node.zip -Algorithm SHA256).Hash.ToLowerInvariant(); if (\$actual -ne '${NODE_SHA256}') { Remove-Item C:\\node.zip; throw 'node SHA-256 mismatch' }; Expand-Archive -LiteralPath C:\\node.zip -DestinationPath C:\\; Move-Item C:\\node-${NODE_VERSION}-win-arm64 C:\\node; Remove-Item C:\\node.zip\"" + vm_powershell "\$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri 'https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-win-arm64.zip' -OutFile C:\\node.zip; \$actual=(Get-FileHash -LiteralPath C:\\node.zip -Algorithm SHA256).Hash.ToLowerInvariant(); if (\$actual -ne '${NODE_SHA256}') { Remove-Item C:\\node.zip; throw 'node SHA-256 mismatch' }; Expand-Archive -LiteralPath C:\\node.zip -DestinationPath C:\\; Move-Item C:\\node-${NODE_VERSION}-win-arm64 C:\\node; Remove-Item C:\\node.zip" fi if ! vm_cmd "if exist C:\\Windows\\System32\\vcruntime140.dll echo VCR_PRESENT" | grep -q VCR_PRESENT; then - vm_cmd "powershell -NoProfile -Command \"\$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vc_redist.arm64.exe' -OutFile C:\\vc_redist.arm64.exe; \$sig = Get-AuthenticodeSignature C:\\vc_redist.arm64.exe; if (\$sig.Status -ne 'Valid') { throw 'vc_redist unsigned' }; Start-Process C:\\vc_redist.arm64.exe -ArgumentList '/install','/quiet','/norestart' -Wait; Remove-Item C:\\vc_redist.arm64.exe\"" + vm_powershell "\$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vc_redist.arm64.exe' -OutFile C:\\vc_redist.arm64.exe; \$sig = Get-AuthenticodeSignature C:\\vc_redist.arm64.exe; if (\$sig.Status -ne 'Valid') { throw 'vc_redist unsigned' }; Start-Process C:\\vc_redist.arm64.exe -ArgumentList '/install','/quiet','/norestart' -Wait; Remove-Item C:\\vc_redist.arm64.exe" fi fi diff --git a/test-infrastructure/vm/ssh-common.sh b/test-infrastructure/vm/ssh-common.sh index 8977e2b4e..5ad070945 100644 --- a/test-infrastructure/vm/ssh-common.sh +++ b/test-infrastructure/vm/ssh-common.sh @@ -46,6 +46,77 @@ cbm_vm_cleanup_known_hosts() { fi } +# Send PowerShell source without exposing its metacharacters to the remote +# account shell. OpenSSH joins remote argv into one command string; raw +# parentheses, pipes, and semicolons can therefore be reinterpreted before +# PowerShell sees them. Windows PowerShell's encoded-command contract is +# UTF-16LE base64, produced locally from trusted repository-owned source. +# Usage: cbm_vm_run_powershell '' "${SSH[@]}" +cbm_vm_run_powershell() { + local source="${1-}" + shift || true + if [ "$#" -eq 0 ]; then + echo "FATAL: encoded PowerShell requires an SSH command" >&2 + return 1 + fi + local encoded + if ! encoded="$(printf '%s' "$source" | iconv -f UTF-8 -t UTF-16LE | base64 | tr -d '\r\n')"; then + echo "FATAL: could not encode PowerShell source for the Windows VM." >&2 + return 1 + fi + "$@" "powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand $encoded" +} + +# UTM guests can resume with their wall clock tens of minutes behind the host. +# Besides invalidating TLS and timeout evidence, that makes freshly mirrored +# source files appear "from the future", so make can repeatedly rebuild or +# incorrectly reason about dependencies. Set the dedicated test VM from the +# trusted local host clock, then fail unless the observed UTC epoch is close. +# Arguments are the complete pinned SSH command array. +cbm_vm_sync_windows_clock() { + if [ "$#" -eq 0 ]; then + echo "FATAL: Windows VM clock sync requires an SSH command" >&2 + return 1 + fi + local host_utc + local host_epoch + local guest_output + local guest_epoch + local skew + local powershell_source + local attempt + + # A host sleep can pause QEMU after host_utc is captured but before the + # guest is set and validated. Retry from a fresh host timestamp instead of + # accepting the stale clock or requiring a manual VM restart. + for attempt in 1 2 3; do + host_utc="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + powershell_source="Set-Date -Date ([DateTimeOffset]::Parse('${host_utc}', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal).LocalDateTime) | Out-Null; [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()" + if ! guest_output="$(cbm_vm_run_powershell "$powershell_source" "$@")"; then + echo "FATAL: could not synchronize the Windows VM clock from the host." >&2 + return 1 + fi + guest_epoch="$(printf '%s\n' "$guest_output" | tr -d '\r' | tail -n 1)" + case "$guest_epoch" in + '' | *[!0-9]*) + echo "FATAL: Windows VM clock sync returned an invalid epoch: $guest_epoch" >&2 + return 1 + ;; + esac + host_epoch="$(date -u '+%s')" + skew=$((host_epoch - guest_epoch)) + if [ "$skew" -lt 0 ]; then + skew=$((-skew)) + fi + if [ "$skew" -le 15 ]; then + echo "WINDOWS_CLOCK_OK skew=${skew}s" + return 0 + fi + done + echo "FATAL: Windows VM clock remains ${skew}s from the host after synchronization." >&2 + return 1 +} + cbm_vm_write_untracked_manifest() { local root="$1" local manifest="$2" diff --git a/test-infrastructure/vm/vm-smoke.sh b/test-infrastructure/vm/vm-smoke.sh index ae93affe6..c4030d4b1 100755 --- a/test-infrastructure/vm/vm-smoke.sh +++ b/test-infrastructure/vm/vm-smoke.sh @@ -1,11 +1,9 @@ #!/usr/bin/env bash # vm-smoke.sh — run the PR smoke inside the Windows VM exactly as CI does. # -# Mirrors .github/workflows/pr.yml's windows pr-smoke step: stage the release -# pair (launcher as codebase-memory-mcp.exe + CLI as .payload.exe) in a -# profile-rooted temp dir — MSYS2 /tmp is intentionally rejected by the -# launcher (shared writable ancestor), and so is anything under the msys -# install tree — then run scripts/smoke-test.sh against the staged launcher. +# Stages and serves a complete Windows release fixture from a profile-rooted +# directory. MSYS2 /tmp and the shared runner workspace are intentionally not +# valid launcher bundle roots. # # Run inside the VM's CLANGARM64 shell from the repo root: # bash test-infrastructure/vm/vm-smoke.sh @@ -13,15 +11,176 @@ set -euo pipefail cd "$(dirname "$0")/../.." -[ -x build/c/codebase-memory-mcp.exe ] || { echo "build first (win.sh build)" >&2; exit 2; } +ROOT="$PWD" +for binary in build/c/codebase-memory-mcp-launcher.exe build/c/codebase-memory-mcp.exe; do + [ -x "$binary" ] || { echo "build first; missing $binary" >&2; exit 2; } +done + +SMOKE_ARCH="${SMOKE_ARCH:-arm64}" +SMOKE_VARIANT="${SMOKE_VARIANT:-standard}" +case "$SMOKE_ARCH" in +arm64 | amd64) ;; +*) echo "vm-smoke: SMOKE_ARCH must be arm64 or amd64" >&2; exit 2 ;; +esac +case "$SMOKE_VARIANT" in +standard) SUFFIX="" ;; +ui) SUFFIX="-ui" ;; +*) echo "vm-smoke: SMOKE_VARIANT must be standard or ui" >&2; exit 2 ;; +esac PROFILE_ROOT="$(cygpath -u "$USERPROFILE")" SMOKE_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-vm-smoke.XXXXXX")" -trap 'rm -rf "$SMOKE_DIR"' EXIT +FIXTURE_DIR="$SMOKE_DIR/artifacts" +SMOKE_HOME="$SMOKE_DIR/home" +SMOKE_XDG_CONFIG="$SMOKE_DIR/xdg-config" +SMOKE_APPDATA="$SMOKE_DIR/appdata" +SMOKE_LOCALAPPDATA="$SMOKE_DIR/localappdata" +SMOKE_TEMP_DIR="$SMOKE_DIR/temp" +PORT_FILE="$SMOKE_DIR/port" +SERVER_LOG="$SMOKE_DIR/server.log" +SERVER_PID="" +PATH_GUARD="$ROOT/test-infrastructure/vm/windows-user-path-guard.ps1" +PATH_SNAPSHOT="$SMOKE_DIR/user-path-snapshot.json" +PATH_RUN_ID="" +PATH_GUARD_READY=0 +cleanup() { + local status=$? + trap - EXIT + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + if [ "$PATH_GUARD_READY" -eq 1 ]; then + if ! MSYS2_ARG_CONV_EXCL='*' powershell.exe -NoProfile -ExecutionPolicy Bypass \ + -File "$(cygpath -w "$PATH_GUARD")" \ + -Mode cleanup \ + -RunId "$PATH_RUN_ID" \ + -SnapshotPath "$(cygpath -w "$PATH_SNAPSHOT")" \ + -SmokeRoot "$(cygpath -w "$SMOKE_DIR")"; then + echo "vm-smoke: isolated registry cleanup failed" >&2 + status=1 + fi + fi + if [ "$status" -eq 0 ]; then + rm -rf "$SMOKE_DIR" + else + echo "vm-smoke: preserved failed smoke root at $SMOKE_DIR" >&2 + fi + exit "$status" +} +trap cleanup EXIT +mkdir -p "$FIXTURE_DIR" "$SMOKE_HOME" "$SMOKE_XDG_CONFIG" "$SMOKE_APPDATA" \ + "$SMOKE_LOCALAPPDATA" "$SMOKE_TEMP_DIR" +PATH_RUN_ID=$( + powershell.exe -NoProfile -Command \ + "[Guid]::NewGuid().ToString('N')" | tr -d '\r\n' +) +if ! printf '%s' "$PATH_RUN_ID" | grep -qE '^[0-9A-Fa-f]{32}$'; then + echo "vm-smoke: could not generate an isolated registry run ID" >&2 + exit 1 +fi +PATH_GUARD_READY=1 +MSYS2_ARG_CONV_EXCL='*' powershell.exe -NoProfile -ExecutionPolicy Bypass \ + -File "$(cygpath -w "$PATH_GUARD")" \ + -Mode prepare \ + -RunId "$PATH_RUN_ID" \ + -SnapshotPath "$(cygpath -w "$PATH_SNAPSHOT")" \ + -SmokeRoot "$(cygpath -w "$SMOKE_DIR")" cp build/c/codebase-memory-mcp-launcher.exe "$SMOKE_DIR/codebase-memory-mcp.exe" cp build/c/codebase-memory-mcp.exe "$SMOKE_DIR/codebase-memory-mcp.payload.exe" +cp "$SMOKE_DIR/codebase-memory-mcp.exe" \ + "$SMOKE_DIR/codebase-memory-mcp.payload.exe" \ + LICENSE install.ps1 "$FIXTURE_DIR/" +scripts/gen-third-party-notices.sh "$FIXTURE_DIR/THIRD_PARTY_NOTICES.md" -CBM_CACHE_DIR="$(cygpath -m "$SMOKE_DIR/cache")" \ +EXPECTED_ARTIFACT="codebase-memory-mcp${SUFFIX}-windows-${SMOKE_ARCH}.zip" +( + cd "$FIXTURE_DIR" + zip -q "$EXPECTED_ARTIFACT" \ + codebase-memory-mcp.exe codebase-memory-mcp.payload.exe \ + LICENSE install.ps1 THIRD_PARTY_NOTICES.md + if [ -n "$SUFFIX" ]; then + cp "$EXPECTED_ARTIFACT" "codebase-memory-mcp-windows-${SMOKE_ARCH}.zip" + fi + sha256sum *.zip > checksums.txt +) + +python3 "$ROOT/scripts/smoke-fixture-server.py" \ + --directory "$FIXTURE_DIR" --port-file "$PORT_FILE" \ + >"$SERVER_LOG" 2>&1 & +SERVER_PID=$! + +PORT="" +READY=0 +for _ in $(seq 1 100); do + if [ -s "$PORT_FILE" ]; then + PORT=$(tr -d '[:space:]' < "$PORT_FILE") + if curl --noproxy '*' -fsS \ + "http://127.0.0.1:$PORT/$EXPECTED_ARTIFACT" >/dev/null 2>&1; then + READY=1 + break + fi + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + break + fi + sleep 0.1 +done +if [ "$READY" -ne 1 ]; then + echo "vm-smoke: fixture server did not serve $EXPECTED_ARTIFACT" >&2 + echo "--- fixture server log ---" >&2 + cat "$SERVER_LOG" >&2 + exit 1 +fi + +# The release smoke performs real agent config mutations. Neutralize every +# supported destination override before entering it; individual phases may set +# an intentional override of their own beneath SMOKE_DIR. +env \ + -u CLAUDE_CONFIG_DIR \ + -u CODEX_HOME \ + -u KIRO_HOME \ + -u HERMES_HOME \ + -u QWEN_HOME \ + -u CLINE_DATA_DIR \ + -u OPENCLAW_HOME \ + -u OPENCLAW_STATE_DIR \ + -u OPENCLAW_PROFILE \ + -u OPENCLAW_CONFIG_PATH \ + -u OPENCLAW_WORKSPACE_DIR \ + -u OPENCODE_CONFIG \ + -u OPENCODE_CONFIG_DIR \ + -u COPILOT_HOME \ + -u CRUSH_GLOBAL_CONFIG \ + -u VIBE_HOME \ + -u GLAB_CONFIG_DIR \ + -u KIMI_CODE_HOME \ + -u CBM_CONTINUE_CONFIG_PATH \ + -u CBM_TRAE_CONFIG_PATH \ + -u CBM_ROO_CONFIG_PATH \ + -u CBM_CODY_CONFIG_PATH \ + -u CBM_TEST_WINDOWS_USER_PATH_RUN_ID \ + HOME="$(cygpath -m "$SMOKE_HOME")" \ + USERPROFILE="$(cygpath -m "$SMOKE_HOME")" \ + XDG_CONFIG_HOME="$(cygpath -m "$SMOKE_XDG_CONFIG")" \ + APPDATA="$(cygpath -m "$SMOKE_APPDATA")" \ + LOCALAPPDATA="$(cygpath -m "$SMOKE_LOCALAPPDATA")" \ + TMPDIR="$SMOKE_TEMP_DIR" \ + TEMP="$(cygpath -m "$SMOKE_TEMP_DIR")" \ + TMP="$(cygpath -m "$SMOKE_TEMP_DIR")" \ + SHELL="/usr/bin/bash" \ + CBM_CACHE_DIR="$(cygpath -m "$SMOKE_DIR/cache")" \ + CBM_TEST_WINDOWS_USER_PATH_RUN_ID="$PATH_RUN_ID" \ SMOKE_TEMP_ROOT="$SMOKE_DIR" \ + SMOKE_DOWNLOAD_URL="http://127.0.0.1:$PORT" \ + SMOKE_UPDATE_FIXTURE_DIR="$FIXTURE_DIR" \ + SMOKE_ARCH="$SMOKE_ARCH" \ scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe" + +MSYS2_ARG_CONV_EXCL='*' powershell.exe -NoProfile -ExecutionPolicy Bypass \ + -File "$(cygpath -w "$PATH_GUARD")" \ + -Mode verify \ + -RunId "$PATH_RUN_ID" \ + -SnapshotPath "$(cygpath -w "$PATH_SNAPSHOT")" \ + -SmokeRoot "$(cygpath -w "$SMOKE_DIR")" diff --git a/test-infrastructure/vm/win.sh b/test-infrastructure/vm/win.sh index 5e499bea0..46f99268b 100755 --- a/test-infrastructure/vm/win.sh +++ b/test-infrastructure/vm/win.sh @@ -62,6 +62,9 @@ vm() { local env="$1"; shift vm_cmd() { "${SSH[@]}" "$@"; } # plain cmd.exe (CI-shaped environment) cmd="${1:-status}"; shift || true +if [ "$cmd" != "status" ]; then + cbm_vm_sync_windows_clock "${SSH[@]}" +fi case "$cmd" in status) "${SSH[@]}" "echo VM_REACHABLE & ver" diff --git a/test-infrastructure/vm/windows-user-path-guard.ps1 b/test-infrastructure/vm/windows-user-path-guard.ps1 new file mode 100644 index 000000000..4e0c00c52 --- /dev/null +++ b/test-infrastructure/vm/windows-user-path-guard.ps1 @@ -0,0 +1,303 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet("prepare", "verify", "cleanup")] + [string]$Mode, + + [Parameter(Mandatory = $true)] + [ValidatePattern("^[0-9A-Fa-f]{32}$")] + [string]$RunId, + + [Parameter(Mandatory = $true)] + [string]$SnapshotPath, + + [Parameter(Mandatory = $true)] + [string]$SmokeRoot +) + +$ErrorActionPreference = "Stop" +$TestKeyPath = "Software\CodebaseMemoryMCP\Smoke\$RunId" +$SentinelName = "CbmSmokeRunId" +$RegistryView = [Microsoft.Win32.RegistryView]::Registry64 + +function Open-CurrentUser { + return [Microsoft.Win32.RegistryKey]::OpenBaseKey( + [Microsoft.Win32.RegistryHive]::CurrentUser, + $RegistryView + ) +} + +function Read-PathValue { + param([string]$KeyPath) + + $base = Open-CurrentUser + try { + $key = $base.OpenSubKey($KeyPath, $false) + if ($null -eq $key) { + return [pscustomobject]@{ Exists = $false; Kind = $null; Value = $null } + } + try { + $pathName = @( + $key.GetValueNames() | Where-Object { + [string]::Equals( + $_, + "Path", + [System.StringComparison]::OrdinalIgnoreCase + ) + } + ) | Select-Object -First 1 + if ($null -eq $pathName) { + return [pscustomobject]@{ Exists = $false; Kind = $null; Value = $null } + } + $kind = $key.GetValueKind($pathName) + if ( + $kind -ne [Microsoft.Win32.RegistryValueKind]::String -and + $kind -ne [Microsoft.Win32.RegistryValueKind]::ExpandString + ) { + throw "$KeyPath\Path has unsupported registry kind $kind" + } + $value = $key.GetValue( + $pathName, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + if ($null -eq $value) { + $value = "" + } + return [pscustomobject]@{ + Exists = $true + Kind = $kind.ToString() + Value = [string]$value + } + } + finally { + $key.Dispose() + } + } + finally { + $base.Dispose() + } +} + +function Get-StateHash { + param($State) + + $serialized = ( + [string][int][bool]$State.Exists + "`0" + + [string]$State.Kind + "`0" + + [string]$State.Value + ) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $digest = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($serialized)) + return -join ($digest | ForEach-Object { $_.ToString("x2") }) + } + finally { + $sha.Dispose() + } +} + +function Assert-StateEqual { + param($Expected, $Actual) + + if ( + [bool]$Expected.Exists -ne [bool]$Actual.Exists -or + -not [string]::Equals( + [string]$Expected.Kind, + [string]$Actual.Kind, + [System.StringComparison]::Ordinal + ) -or + -not [string]::Equals( + [string]$Expected.Value, + [string]$Actual.Value, + [System.StringComparison]::Ordinal + ) + ) { + throw ( + "HKCU\Environment\Path changed while the isolated smoke ran; " + + "before=$(Get-StateHash $Expected), after=$(Get-StateHash $Actual)" + ) + } +} + +function Assert-SmokePathValue { + param($State) + + if (-not $State.Exists) { + throw "isolated smoke registry Path was not written" + } + $canonicalRoot = [System.IO.Path]::GetFullPath($SmokeRoot).TrimEnd( + [char[]]@('\', '/') + ) + $rootPrefix = $canonicalRoot + [System.IO.Path]::DirectorySeparatorChar + $segments = @(([string]$State.Value).Split(";")) + if ($segments.Count -eq 0) { + throw "isolated smoke registry Path is empty" + } + foreach ($segment in $segments) { + $trimmed = $segment.Trim() + if ($trimmed.Length -eq 0) { + throw "isolated smoke registry Path contains an empty segment" + } + $canonicalSegment = [System.IO.Path]::GetFullPath($trimmed).TrimEnd( + [char[]]@('\', '/') + ) + if ( + -not [string]::Equals( + $canonicalSegment, + $canonicalRoot, + [System.StringComparison]::OrdinalIgnoreCase + ) -and + -not $canonicalSegment.StartsWith( + $rootPrefix, + [System.StringComparison]::OrdinalIgnoreCase + ) + ) { + throw "isolated smoke registry Path escaped the smoke root" + } + } +} + +function Assert-TestKeySentinel { + $base = Open-CurrentUser + try { + $key = $base.OpenSubKey($TestKeyPath, $false) + if ($null -eq $key) { + throw "isolated smoke registry key is missing" + } + try { + $sentinel = $key.GetValue( + $SentinelName, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + if ( + $key.GetValueKind($SentinelName) -ne + [Microsoft.Win32.RegistryValueKind]::String -or + -not [string]::Equals( + [string]$sentinel, + $RunId, + [System.StringComparison]::Ordinal + ) + ) { + throw "isolated smoke registry sentinel is invalid" + } + } + finally { + $key.Dispose() + } + } + finally { + $base.Dispose() + } +} + +if ($Mode -eq "prepare") { + $base = Open-CurrentUser + try { + $existing = $base.OpenSubKey($TestKeyPath, $false) + if ($null -ne $existing) { + $existing.Dispose() + throw "isolated smoke registry key already exists" + } + $key = $base.CreateSubKey($TestKeyPath, $true) + if ($null -eq $key) { + throw "could not create isolated smoke registry key" + } + try { + $key.SetValue( + $SentinelName, + $RunId, + [Microsoft.Win32.RegistryValueKind]::String + ) + } + finally { + $key.Dispose() + } + } + finally { + $base.Dispose() + } + + $state = Read-PathValue "Environment" + $record = [pscustomobject]@{ + Version = 2 + RunId = $RunId + Exists = $state.Exists + Kind = $state.Kind + Value = $state.Value + } + $parent = Split-Path -Parent $SnapshotPath + [System.IO.Directory]::CreateDirectory($parent) | Out-Null + $temporary = "$SnapshotPath.new" + $json = $record | ConvertTo-Json -Compress + [System.IO.File]::WriteAllText( + $temporary, + $json, + (New-Object System.Text.UTF8Encoding($false)) + ) + Move-Item -LiteralPath $temporary -Destination $SnapshotPath -Force + Write-Host "Prepared isolated Windows PATH smoke (live PATH hash: $(Get-StateHash $state))" + exit 0 +} + +if ($Mode -eq "verify") { + if (-not [System.IO.File]::Exists($SnapshotPath)) { + throw "Windows user PATH snapshot is missing" + } + $json = [System.IO.File]::ReadAllText( + $SnapshotPath, + [System.Text.Encoding]::UTF8 + ) + $record = $json | ConvertFrom-Json + if ( + $record.Version -ne 2 -or + $null -eq $record.Exists -or + -not [string]::Equals( + [string]$record.RunId, + $RunId, + [System.StringComparison]::Ordinal + ) + ) { + throw "Windows user PATH snapshot is invalid" + } + + Assert-TestKeySentinel + $current = Read-PathValue "Environment" + Assert-StateEqual $record $current + Assert-SmokePathValue (Read-PathValue $TestKeyPath) + Write-Host "Verified isolated Windows PATH smoke; live user PATH was not mutated" + exit 0 +} + +$base = Open-CurrentUser +try { + $key = $base.OpenSubKey($TestKeyPath, $false) + if ($null -ne $key) { + try { + $sentinel = $key.GetValue( + $SentinelName, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + if ( + $key.GetValueKind($SentinelName) -ne + [Microsoft.Win32.RegistryValueKind]::String -or + -not [string]::Equals( + [string]$sentinel, + $RunId, + [System.StringComparison]::Ordinal + ) + ) { + throw "refusing to delete an isolated registry key with the wrong sentinel" + } + } + finally { + $key.Dispose() + } + $base.DeleteSubKeyTree($TestKeyPath, $false) + } +} +finally { + $base.Dispose() +} +Write-Host "Removed isolated Windows PATH smoke registry key" diff --git a/tests/test_cli.c b/tests/test_cli.c index a0def04e1..647efe24a 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -2361,6 +2361,306 @@ TEST(cli_editor_mcp_idempotent) { PASS(); } +/* The OS-reported running image is positive prior-install identity. An update + * may replace that exact command without probing it or granting the same + * authority to uninstall/remove paths. */ +TEST(cli_editor_mcp_repairs_known_previous_managed_entry) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char configpath[512]; + snprintf(configpath, sizeof(configpath), "%s/.claude.json", tmpdir); + char stale_command[512]; + snprintf(stale_command, sizeof(stale_command), "%s/retired-install/codebase-memory-mcp", + tmpdir); + char original[1024]; + snprintf(original, sizeof(original), + "{\"mcpServers\":{\"codebase-memory-mcp\":{\"command\":\"%s\"}}}", stale_command); + ASSERT_EQ(write_test_file(configpath, original), 0); + + int probes = 0; + cbm_set_mcp_command_path_probe_counter_for_testing(&probes); + int rc = cbm_install_editor_mcp_with_previous_for_testing("/usr/local/bin/codebase-memory-mcp", + stale_command, configpath); + cbm_set_mcp_command_path_probe_counter_for_testing(NULL); + ASSERT_EQ(rc, 0); + ASSERT_EQ(probes, 0); + + const char *data = read_test_file(configpath); + ASSERT_NOT_NULL(data); + ASSERT(strstr(data, "/usr/local/bin/codebase-memory-mcp") != NULL); + ASSERT(strstr(data, stale_command) == NULL); + + ASSERT_EQ(write_test_file(configpath, original), 0); + ASSERT_EQ(cbm_remove_editor_mcp_owned("/usr/local/bin/codebase-memory-mcp", configpath), 0); + ASSERT_STR_EQ(read_test_file(configpath), original); + + test_rmdir_r(tmpdir); + PASS(); +} + +#ifndef _WIN32 +/* Config bytes must never drive a POSIX filesystem lookup. Unknown missing, + * remote-mount, and symlink-traversal spellings are all preserved byte-for- + * byte unless trusted running-image identity matched first. */ +TEST(cli_editor_mcp_preserves_unrecorded_posix_absolute_entries_without_probe) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char configpath[512]; + char missing_command[512]; + char symlink_path[512]; + char symlink_command[640]; + snprintf(configpath, sizeof(configpath), "%s/.claude.json", tmpdir); + snprintf(missing_command, sizeof(missing_command), "%s/missing/codebase-memory-mcp", tmpdir); + snprintf(symlink_path, sizeof(symlink_path), "%s/remote-link", tmpdir); + snprintf(symlink_command, sizeof(symlink_command), "%s/codebase-memory-mcp", symlink_path); + ASSERT_EQ(symlink("/net/cbm-audit-remote", symlink_path), 0); + + const char *commands[] = { + missing_command, + "/net/attacker/share/codebase-memory-mcp", + "/Volumes/remote/codebase-memory-mcp", + symlink_command, + }; + bool preserved = true; + int probes = 0; + cbm_set_mcp_command_path_probe_counter_for_testing(&probes); + for (size_t index = 0; index < sizeof(commands) / sizeof(commands[0]); index++) { + char original[1024]; + int written = snprintf(original, sizeof(original), + "{\"mcpServers\":{\"codebase-memory-mcp\":{\"command\":\"%s\"}}}", + commands[index]); + if (written <= 0 || (size_t)written >= sizeof(original) || + write_test_file(configpath, original) != 0 || + cbm_install_editor_mcp("/usr/local/bin/codebase-memory-mcp", configpath) == 0) { + preserved = false; + break; + } + const char *after = read_test_file(configpath); + if (!after || strcmp(after, original) != 0) { + preserved = false; + break; + } + } + cbm_set_mcp_command_path_probe_counter_for_testing(NULL); + + ASSERT_TRUE(preserved); + ASSERT_EQ(probes, 0); + ASSERT_EQ(unlink(symlink_path), 0); + test_rmdir_r(tmpdir); + PASS(); +} +#endif + +/* A relative command is resolved by the client from its own runtime context, + * not from the installer process's cwd. Failure to find it here is therefore + * inconclusive: preserve it fail-closed instead of treating it as a dead + * install footprint. */ +TEST(cli_editor_mcp_preserves_unresolved_relative_entry) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char configpath[512]; + snprintf(configpath, sizeof(configpath), "%s/.claude.json", tmpdir); + const char *commands[] = {"./custom-tool", "subdir/custom-tool", "${HOME}/custom-tool", + "/opt/${CBM_HOME}/custom-tool", "C:/%CBM_HOME%/custom-tool"}; + for (size_t i = 0; i < sizeof(commands) / sizeof(commands[0]); i++) { + char original[512]; + snprintf(original, sizeof(original), + "{\"mcpServers\":{\"codebase-memory-mcp\":{\"command\":\"%s\"}}}", commands[i]); + ASSERT_EQ(write_test_file(configpath, original), 0); + + int rc = cbm_install_editor_mcp("/usr/local/bin/codebase-memory-mcp", configpath); + ASSERT(rc != 0); + + const char *data = read_test_file(configpath); + ASSERT_NOT_NULL(data); + ASSERT_STR_EQ(data, original); + } + + char overlong_command[5000]; + memset(overlong_command, 'x', sizeof(overlong_command)); + overlong_command[0] = '/'; + overlong_command[sizeof(overlong_command) - 1U] = '\0'; + char overlong_json[5200]; + snprintf(overlong_json, sizeof(overlong_json), + "{\"mcpServers\":{\"codebase-memory-mcp\":{\"command\":\"%s\"}}}", overlong_command); + ASSERT_EQ(write_test_file(configpath, overlong_json), 0); + ASSERT(cbm_install_editor_mcp("/usr/local/bin/codebase-memory-mcp", configpath) != 0); + const char *overlong_data = read_test_file(configpath); + ASSERT_NOT_NULL(overlong_data); + ASSERT_STR_EQ(overlong_data, overlong_json); + + test_rmdir_r(tmpdir); + PASS(); +} + +/* Config content must never make install/update probe a Windows network or + * device namespace. Besides unbounded I/O, a UNC stat can disclose the + * account's SMB credentials to an attacker-controlled host. */ +TEST(cli_editor_mcp_rejects_unsafe_windows_probe_namespaces) { + ASSERT_FALSE( + cbm_mcp_command_path_probe_safe_for_testing("/mnt/remote/codebase-memory-mcp", false)); + ASSERT_FALSE(cbm_mcp_command_path_probe_safe_for_testing("/tmp/codebase-memory-mcp", false)); + ASSERT_TRUE(cbm_mcp_command_path_probe_safe_for_testing("C:/local/codebase-memory-mcp", true)); + ASSERT_TRUE( + cbm_mcp_command_path_probe_safe_for_testing("D:\\local\\codebase-memory-mcp", true)); + ASSERT_FALSE( + cbm_mcp_command_path_probe_safe_for_testing("//server/share/codebase-memory-mcp", true)); + ASSERT_FALSE(cbm_mcp_command_path_probe_safe_for_testing( + "\\\\server\\share\\codebase-memory-mcp", true)); + ASSERT_FALSE(cbm_mcp_command_path_probe_safe_for_testing("//?/C:/codebase-memory-mcp", true)); + ASSERT_FALSE( + cbm_mcp_command_path_probe_safe_for_testing("\\\\.\\pipe\\codebase-memory-mcp", true)); + PASS(); +} + +#ifdef _WIN32 +/* A drive-letter spelling is not enough to prove a local probe: mapped drives + * are remote, and local paths can cross a junction into a remote namespace. + * Preserve both as indeterminate without following them. */ +TEST(cli_editor_mcp_preserves_unsafe_windows_drive_probe) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char configpath[512]; + snprintf(configpath, sizeof(configpath), "%s/.claude.json", tmpdir); + + char unused_drive = '\0'; + for (char drive = 'Z'; drive >= 'D'; drive--) { + char root[] = {drive, ':', '\\', '\0'}; + if (GetDriveTypeA(root) == DRIVE_NO_ROOT_DIR) { + unused_drive = drive; + break; + } + } + ASSERT(unused_drive != '\0'); + char missing_drive_command[128]; + snprintf(missing_drive_command, sizeof(missing_drive_command), + "%c:/cbm-missing/codebase-memory-mcp", unused_drive); + char missing_drive_json[512]; + snprintf(missing_drive_json, sizeof(missing_drive_json), + "{\"mcpServers\":{\"codebase-memory-mcp\":{\"command\":\"%s\"}}}", + missing_drive_command); + ASSERT_EQ(write_test_file(configpath, missing_drive_json), 0); + ASSERT(cbm_install_editor_mcp("C:/installed/codebase-memory-mcp.exe", configpath) != 0); + ASSERT_STR_EQ(read_test_file(configpath), missing_drive_json); + + char outside[256]; + snprintf(outside, sizeof(outside), "/tmp/cli-mcp-outside-XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(outside)); + char junction[512]; + snprintf(junction, sizeof(junction), "%s/escape", tmpdir); + char junction_native[sizeof(junction)]; + char outside_native[sizeof(outside)]; + snprintf(junction_native, sizeof(junction_native), "%s", junction); + snprintf(outside_native, sizeof(outside_native), "%s", outside); + for (char *cursor = junction_native; *cursor; cursor++) { + if (*cursor == '/') { + *cursor = '\\'; + } + } + for (char *cursor = outside_native; *cursor; cursor++) { + if (*cursor == '/') { + *cursor = '\\'; + } + } + const char *junction_argv[] = {"cmd.exe", "/d", "/c", "mklink", "/J", + junction_native, outside_native, NULL}; + ASSERT_EQ(cbm_exec_no_shell(junction_argv), 0); + + char junction_command[700]; + snprintf(junction_command, sizeof(junction_command), "%s/codebase-memory-mcp", junction); + char junction_json[1024]; + snprintf(junction_json, sizeof(junction_json), + "{\"mcpServers\":{\"codebase-memory-mcp\":{\"command\":\"%s\"}}}", junction_command); + ASSERT_EQ(write_test_file(configpath, junction_json), 0); + ASSERT(cbm_install_editor_mcp("C:/installed/codebase-memory-mcp.exe", configpath) != 0); + ASSERT_STR_EQ(read_test_file(configpath), junction_json); + + cbm_rmdir(junction); + cbm_rmdir(outside); + test_rmdir_r(tmpdir); + PASS(); +} + +/* Windows clients commonly omit the executable suffix. Even when the literal + * path and common suffixes are absent, another client or PATHEXT can resolve a + * custom suffix. Preserve the entry unless positive prior-image identity + * establishes ownership. */ +TEST(cli_editor_mcp_preserves_windows_extensionless_commands) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char command[512]; + char configpath[512]; + snprintf(command, sizeof(command), "%s/custom-tool", tmpdir); + snprintf(configpath, sizeof(configpath), "%s/.claude.json", tmpdir); + + char original[1024]; + snprintf(original, sizeof(original), + "{\"mcpServers\":{\"codebase-memory-mcp\":{\"command\":\"%s\"}}}", command); + const char *suffixes[] = {".com", ".exe", ".bat", ".cmd", ".ps1", ".vbs", ".cbmshim"}; + for (size_t index = 0; index < sizeof(suffixes) / sizeof(suffixes[0]); index++) { + char executable[640]; + snprintf(executable, sizeof(executable), "%s%s", command, suffixes[index]); + ASSERT_EQ(write_test_file(executable, "live"), 0); + ASSERT_EQ(write_test_file(configpath, original), 0); + ASSERT(cbm_install_editor_mcp("C:/installed/codebase-memory-mcp.exe", configpath) != 0); + + const char *data = read_test_file(configpath); + ASSERT_NOT_NULL(data); + ASSERT_STR_EQ(data, original); + ASSERT_EQ(cbm_unlink(executable), 0); + } + + ASSERT_EQ(write_test_file(configpath, original), 0); + ASSERT(cbm_install_editor_mcp("C:/installed/codebase-memory-mcp.exe", configpath) != 0); + ASSERT_STR_EQ(read_test_file(configpath), original); + + test_rmdir_r(tmpdir); + PASS(); +} +#endif + +/* A same-named entry with a FOREIGN shape (members we never write, e.g. env) + * is somebody's deliberate configuration: upsert must keep refusing to touch + * it. Pins the protective half of the ownership check. */ +TEST(cli_editor_mcp_refuses_foreign_shaped_entry) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char configpath[512]; + snprintf(configpath, sizeof(configpath), "%s/.claude.json", tmpdir); + ASSERT_EQ(write_test_file(configpath, "{\"mcpServers\":{\"codebase-memory-mcp\":" + "{\"command\":\"/custom\",\"env\":{\"FOO\":\"1\"}}}}"), + 0); + + int rc = cbm_install_editor_mcp("/usr/local/bin/codebase-memory-mcp", configpath); + ASSERT(rc != 0); + + const char *data = read_test_file(configpath); + ASSERT_NOT_NULL(data); + ASSERT(strstr(data, "\"/custom\"") != NULL); + ASSERT(strstr(data, "FOO") != NULL); + + test_rmdir_r(tmpdir); + PASS(); +} + TEST(cli_editor_mcp_preserves_others) { /* Port of TestEditorMCPPreservesOtherServers */ char tmpdir[256]; @@ -2493,6 +2793,48 @@ TEST(cli_junie_mcp_install_issue651) { PASS(); } +TEST(cli_junie_mcp_repairs_all_known_previous_aliases_atomically) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char configpath[512]; + char previous[512]; + char original[4096]; + snprintf(configpath, sizeof(configpath), "%s/mcp.json", tmpdir); + snprintf(previous, sizeof(previous), "%s/retired-install/codebase-memory-mcp", tmpdir); + snprintf(original, sizeof(original), + "{\"mcpServers\":{" + "\"codebase-memory-mcp\":{\"command\":\"%s\"}," + "\"codebase-memory-scout\":{\"command\":\"%s\"," + "\"args\":[\"--tool-profile=scout\"]}," + "\"codebase-memory-analysis\":{\"command\":\"%s\"," + "\"args\":[\"--tool-profile=analysis\"]}}}", + previous, previous, previous); + ASSERT_EQ(write_test_file(configpath, original), 0); + + int probes = 0; + cbm_set_mcp_command_path_probe_counter_for_testing(&probes); + int rc = cbm_upsert_junie_mcp_with_previous_for_testing("/usr/local/bin/codebase-memory-mcp", + previous, configpath); + cbm_set_mcp_command_path_probe_counter_for_testing(NULL); + + const char *data = read_test_file(configpath); + ASSERT_EQ(rc, 0); + ASSERT_EQ(probes, 0); + ASSERT_NOT_NULL(data); + ASSERT(strstr(data, previous) == NULL); + ASSERT(strstr(data, "\"codebase-memory-mcp\"") != NULL); + ASSERT(strstr(data, "\"codebase-memory-scout\"") != NULL); + ASSERT(strstr(data, "\"codebase-memory-analysis\"") != NULL); + ASSERT(strstr(data, "--tool-profile=scout") != NULL); + ASSERT(strstr(data, "--tool-profile=analysis") != NULL); + + test_rmdir_r(tmpdir); + PASS(); +} + TEST(cli_gemini_mcp_install) { /* Port of TestGeminiMCPInstall */ char tmpdir[256]; @@ -8388,9 +8730,17 @@ TEST(cli_mcp_installers_preserve_foreign_same_name_entries) { char toml_path[512]; snprintf(json_path, sizeof(json_path), "%s/settings.json", tmpdir); snprintf(toml_path, sizeof(toml_path), "%s/config.toml", tmpdir); - const char *foreign_json = - "{\"mcpServers\":{\"codebase-memory-mcp\":{\"command\":" - "\"/opt/custom/codebase-memory-mcp\",\"args\":[]}},\"theme\":\"dark\"}\n"; + /* The custom same-name binary EXISTS on disk — a live foreign tool, not + * a dead install footprint — so installers must fail closed on it. + * (A non-existent command path is the repairable stale case instead.) */ + char custom_tool[512]; + snprintf(custom_tool, sizeof(custom_tool), "%s/custom-tool", tmpdir); + ASSERT_EQ(write_test_file(custom_tool, "#!/bin/sh\nexit 0\n"), 0); + char foreign_json[1024]; + snprintf(foreign_json, sizeof(foreign_json), + "{\"mcpServers\":{\"codebase-memory-mcp\":{\"command\":" + "\"%s\",\"args\":[]}},\"theme\":\"dark\"}\n", + custom_tool); const char *foreign_toml = "[mcp_servers.codebase-memory-mcp]\n" "command = \"/opt/user-tool\"\n" "args = [\"--private\"]\n" @@ -11759,9 +12109,21 @@ SUITE(cli) { /* Editor MCP: Cursor/Windsurf/Gemini (5 tests — install_test.go) */ RUN_TEST(cli_editor_mcp_install); RUN_TEST(cli_editor_mcp_idempotent); + RUN_TEST(cli_editor_mcp_repairs_known_previous_managed_entry); +#ifndef _WIN32 + RUN_TEST(cli_editor_mcp_preserves_unrecorded_posix_absolute_entries_without_probe); +#endif + RUN_TEST(cli_editor_mcp_preserves_unresolved_relative_entry); + RUN_TEST(cli_editor_mcp_rejects_unsafe_windows_probe_namespaces); +#ifdef _WIN32 + RUN_TEST(cli_editor_mcp_preserves_unsafe_windows_drive_probe); + RUN_TEST(cli_editor_mcp_preserves_windows_extensionless_commands); +#endif + RUN_TEST(cli_editor_mcp_refuses_foreign_shaped_entry); RUN_TEST(cli_editor_mcp_preserves_others); RUN_TEST(cli_editor_mcp_uninstall); RUN_TEST(cli_junie_mcp_install_issue651); + RUN_TEST(cli_junie_mcp_repairs_all_known_previous_aliases_atomically); RUN_TEST(cli_gemini_mcp_install); RUN_TEST(cli_openclaw_mcp_install_uses_nested_servers); RUN_TEST(cli_openclaw_mcp_preserves_existing_config); diff --git a/tests/test_helpers.h b/tests/test_helpers.h index 24da1f8b0..4ed67867d 100644 --- a/tests/test_helpers.h +++ b/tests/test_helpers.h @@ -20,6 +20,9 @@ #include #include #include +#ifdef _WIN32 +#include "../src/foundation/win_utf8.h" +#endif /* ── Path building ────────────────────────────────────────────── */ @@ -88,15 +91,35 @@ static inline int th_mkdir_p(const char *path) { /* ── Recursive directory removal ──────────────────────────────── */ +/* Git for Windows marks loose objects read-only. Match rm -rf semantics in + * test cleanup without broadening production cbm_unlink behavior. */ +static inline int th_unlink_force(const char *path) { + int result = cbm_unlink(path); +#ifdef _WIN32 + if (result != 0) { + wchar_t *wide = cbm_path_to_wide(path); + DWORD attributes = wide ? GetFileAttributesW(wide) : INVALID_FILE_ATTRIBUTES; + if (attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_READONLY) != 0U && + SetFileAttributesW(wide, attributes & ~((DWORD)FILE_ATTRIBUTE_READONLY))) { + result = cbm_unlink(path); + } + free(wide); + } +#endif + return result; +} + /* Remove a file or directory tree recursively. Cross-platform rm -rf. */ static inline int th_rmtree(const char *path) { - struct stat st; - if (stat(path, &st) != 0) { + /* The platform wrappers preserve UTF-8 and add the extended-length prefix + * on Windows; narrow CRT stat() silently treats paths beyond MAX_PATH as + * absent and leaves their parents nonempty. */ + if (!cbm_file_exists(path)) { return 0; /* doesn't exist — success */ } - if (!S_ISDIR(st.st_mode)) { - return cbm_unlink(path); + if (!cbm_is_dir(path)) { + return th_unlink_force(path); } /* Directory — recurse into children */ @@ -118,13 +141,15 @@ static inline int th_rmtree(const char *path) { rc = -1; } } else { - if (cbm_unlink(child) != 0) { + if (th_unlink_force(child) != 0) { rc = -1; } } } cbm_closedir(d); - cbm_rmdir(path); + if (cbm_rmdir(path) != 0) { + rc = -1; + } return rc; } @@ -136,8 +161,10 @@ static inline char *th_mktempdir(const char *prefix) { static char buf[256]; #ifdef _WIN32 const char *tmp = getenv("TEMP"); - if (!tmp) tmp = getenv("TMP"); - if (!tmp) tmp = "C:\\Temp"; + if (!tmp) + tmp = getenv("TMP"); + if (!tmp) + tmp = "C:\\Temp"; snprintf(buf, sizeof(buf), "%s\\%s_XXXXXX", tmp, prefix); #else snprintf(buf, sizeof(buf), "/tmp/%s_XXXXXX", prefix); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index f1809dccd..34d00b2c0 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -9,6 +9,7 @@ #include "../src/foundation/constants.h" #include "../src/foundation/log.h" #include "../src/foundation/platform.h" /* cbm_file_size */ +#include "../src/foundation/subprocess.h" #include "test_framework.h" #include "test_helpers.h" #include @@ -182,6 +183,101 @@ static bool mcp_command_hook_probe(void *context, const char *command) { return true; } +typedef struct { + const char *name; + char *value; + bool present; +} mcp_test_env_backup_t; + +static void mcp_test_restore_env(mcp_test_env_backup_t *backups, size_t count) { + for (size_t index = 0; index < count; index++) { + if (backups[index].present) { + (void)cbm_setenv(backups[index].name, backups[index].value, 1); + } else { + (void)cbm_unsetenv(backups[index].name); + } + free(backups[index].value); + } +} + +static int mcp_test_git(const char *root, const char *const *arguments) { + char empty_config[CBM_SZ_4K]; + int config_length = + snprintf(empty_config, sizeof(empty_config), "%s/.cbm-empty-gitconfig", root); + if (config_length <= 0 || (size_t)config_length >= sizeof(empty_config)) { + return -1; + } + FILE *config = cbm_fopen(empty_config, "wb"); + if (!config) { + return -1; + } + if (fclose(config) != 0) { + return -1; + } + + mcp_test_env_backup_t backups[] = { + {.name = "GIT_CONFIG_GLOBAL"}, {.name = "GIT_CONFIG_SYSTEM"}, + {.name = "GIT_CONFIG_NOSYSTEM"}, {.name = "GIT_CONFIG_COUNT"}, + {.name = "GIT_CONFIG_PARAMETERS"}, + }; + bool snapshot_ok = true; + for (size_t index = 0; index < sizeof(backups) / sizeof(backups[0]); index++) { + const char *value = getenv(backups[index].name); + backups[index].present = value != NULL; + backups[index].value = value ? strdup(value) : NULL; + snapshot_ok = snapshot_ok && (!value || backups[index].value); + } + if (!snapshot_ok) { + for (size_t index = 0; index < sizeof(backups) / sizeof(backups[0]); index++) { + free(backups[index].value); + } + return -1; + } + bool environment_ok = cbm_setenv("GIT_CONFIG_GLOBAL", empty_config, 1) == 0 && + cbm_setenv("GIT_CONFIG_SYSTEM", empty_config, 1) == 0 && + cbm_setenv("GIT_CONFIG_NOSYSTEM", "1", 1) == 0 && + cbm_setenv("GIT_CONFIG_COUNT", "0", 1) == 0 && + cbm_unsetenv("GIT_CONFIG_PARAMETERS") == 0; + if (!environment_ok) { + mcp_test_restore_env(backups, sizeof(backups) / sizeof(backups[0])); + return -1; + } + + const char *git = "git"; +#ifdef _WIN32 + char git_executable[CBM_SZ_4K]; + const char *resolved = cbm_find_cli("git", cbm_get_home_dir()); + int resolved_length = + resolved ? snprintf(git_executable, sizeof(git_executable), "%s", resolved) : -1; + if (resolved_length <= 0 || (size_t)resolved_length >= sizeof(git_executable)) { + mcp_test_restore_env(backups, sizeof(backups) / sizeof(backups[0])); + return -1; + } + git = git_executable; +#endif + const char *argv[24] = {git, "-C", root}; + size_t index = 3; + while (arguments && *arguments && index + 1 < sizeof(argv) / sizeof(argv[0])) { + argv[index++] = *arguments++; + } + if ((arguments && *arguments) || index >= sizeof(argv) / sizeof(argv[0])) { + mcp_test_restore_env(backups, sizeof(backups) / sizeof(backups[0])); + return -1; + } + argv[index] = NULL; + + cbm_proc_opts_t options = { + .bin = git, + .argv = argv, + .quiet_timeout_ms = 10000, + }; + cbm_proc_result_t result = {0}; + int run_result = + cbm_subprocess_run(&options, &result) == 0 && result.outcome == CBM_PROC_CLEAN ? 0 : -1; + mcp_test_restore_env(backups, sizeof(backups) / sizeof(backups[0])); + return run_result; +} + static bool mcp_mutation_guard_probe_begin(void *context, const char *project) { mcp_mutation_guard_probe_t *probe = context; if (!probe) { @@ -5654,17 +5750,99 @@ TEST(tool_detect_changes_contained_commands_clean_up_error_and_success) { char cache[512]; (void)snprintf(cache, sizeof(cache), "%s/cbm-detect-contained-XXXXXX", cbm_tmpdir()); bool cache_created = cbm_mkdtemp(cache) != NULL; + char repo[CBM_SZ_4K]; + (void)snprintf(repo, sizeof(repo), "%s/cbm-detect-repo-XXXXXX", cbm_tmpdir()); + bool repo_created = cbm_mkdtemp(repo) != NULL; + char empty_template[CBM_SZ_4K]; + char empty_hooks[CBM_SZ_4K]; + char template_argument[CBM_SZ_4K]; + char hooks_config[CBM_SZ_4K]; + char hostile_template[CBM_SZ_4K]; + char hostile_template_hooks[CBM_SZ_4K]; + char hostile_hooks[CBM_SZ_4K]; + char hostile_hook[CBM_SZ_4K]; + char hostile_config[CBM_SZ_4K]; + int template_length = + snprintf(empty_template, sizeof(empty_template), "%s/.cbm-empty-template", repo); + int hooks_length = snprintf(empty_hooks, sizeof(empty_hooks), "%s/.cbm-empty-hooks", repo); + int template_argument_length = + snprintf(template_argument, sizeof(template_argument), "--template=%s", empty_template); + int hooks_config_length = + snprintf(hooks_config, sizeof(hooks_config), "core.hooksPath=%s", empty_hooks); + int hostile_template_length = + snprintf(hostile_template, sizeof(hostile_template), "%s/.cbm-hostile-template", repo); + int hostile_template_hooks_length = snprintf( + hostile_template_hooks, sizeof(hostile_template_hooks), "%s/hooks", hostile_template); + int hostile_hooks_length = + snprintf(hostile_hooks, sizeof(hostile_hooks), "%s/.cbm-hostile-hooks", repo); + int hostile_hook_length = + snprintf(hostile_hook, sizeof(hostile_hook), "%s/pre-commit", hostile_hooks); + int hostile_config_length = + snprintf(hostile_config, sizeof(hostile_config), "%s/.cbm-hostile-gitconfig", repo); + bool git_isolation_ready = + repo_created && template_length > 0 && (size_t)template_length < sizeof(empty_template) && + hooks_length > 0 && (size_t)hooks_length < sizeof(empty_hooks) && + template_argument_length > 0 && + (size_t)template_argument_length < sizeof(template_argument) && hooks_config_length > 0 && + (size_t)hooks_config_length < sizeof(hooks_config) && cbm_mkdir(empty_template) == 0 && + cbm_mkdir(empty_hooks) == 0; + bool hostile_paths_ready = + git_isolation_ready && hostile_template_length > 0 && + (size_t)hostile_template_length < sizeof(hostile_template) && + hostile_template_hooks_length > 0 && + (size_t)hostile_template_hooks_length < sizeof(hostile_template_hooks) && + hostile_hooks_length > 0 && (size_t)hostile_hooks_length < sizeof(hostile_hooks) && + hostile_hook_length > 0 && (size_t)hostile_hook_length < sizeof(hostile_hook) && + hostile_config_length > 0 && (size_t)hostile_config_length < sizeof(hostile_config) && + cbm_mkdir(hostile_template) == 0 && cbm_mkdir(hostile_template_hooks) == 0 && + cbm_mkdir(hostile_hooks) == 0; + FILE *hostile_hook_file = hostile_paths_ready ? cbm_fopen(hostile_hook, "wb") : NULL; + bool hostile_hook_ready = false; + if (hostile_hook_file) { + bool hook_written = fputs("#!/bin/sh\nexit 91\n", hostile_hook_file) >= 0; + bool hook_closed = fclose(hostile_hook_file) == 0; + hostile_hook_ready = hook_written && hook_closed && chmod(hostile_hook, 0700) == 0; + } + FILE *hostile_config_file = hostile_hook_ready ? cbm_fopen(hostile_config, "wb") : NULL; + bool hostile_config_ready = false; + if (hostile_config_file) { + bool config_written = + fprintf(hostile_config_file, "[init]\n\ttemplateDir = %s\n[core]\n\thooksPath = %s\n", + hostile_template, hostile_hooks) > 0; + bool config_closed = fclose(hostile_config_file) == 0; + hostile_config_ready = config_written && config_closed; + } + mcp_test_env_backup_t ambient_git = {.name = "GIT_CONFIG_GLOBAL"}; + const char *ambient_git_value = getenv(ambient_git.name); + ambient_git.present = ambient_git_value != NULL; + ambient_git.value = ambient_git_value ? strdup(ambient_git_value) : NULL; + bool ambient_git_saved = !ambient_git_value || ambient_git.value; + bool hostile_environment_ready = hostile_config_ready && ambient_git_saved && + cbm_setenv("GIT_CONFIG_GLOBAL", hostile_config, 1) == 0; + const char *const init_args[] = {"init", "-q", template_argument, NULL}; + const char *const commit_args[] = { + "-c", "user.name=cbm-test", + "-c", "user.email=cbm-test@example.invalid", + "-c", "commit.gpgsign=false", + "-c", hooks_config, + "commit", "--allow-empty", + "-q", "-m", + "fixture", NULL, + }; + bool repo_ready = hostile_environment_ready && mcp_test_git(repo, init_args) == 0 && + mcp_test_git(repo, commit_args) == 0; + if (ambient_git_saved) { + mcp_test_restore_env(&ambient_git, 1U); + } const char *saved_cache = getenv("CBM_CACHE_DIR"); char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; bool environment_ready = cache_created && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; - char root[CBM_SZ_4K] = {0}; - bool root_ready = cbm_getcwd(root, sizeof(root)) != NULL; const char *project = "detect-contained-project"; - cbm_mcp_server_t *srv = environment_ready && root_ready ? cbm_mcp_server_new(NULL) : NULL; + cbm_mcp_server_t *srv = environment_ready && repo_ready ? cbm_mcp_server_new(NULL) : NULL; bool server_ready = srv != NULL; cbm_store_t *store = srv ? cbm_mcp_server_store(srv) : NULL; - bool project_ready = store && cbm_store_upsert_project(store, project, root) == CBM_STORE_OK; + bool project_ready = store && cbm_store_upsert_project(store, project, repo) == CBM_STORE_OK; mcp_command_hook_probe_t command_probe = {.reject_merge_base = true}; if (project_ready) { cbm_mcp_server_set_project(srv, project); @@ -5710,10 +5888,14 @@ TEST(tool_detect_changes_contained_commands_clean_up_error_and_success) { restore_cache_dir(saved_cache_copy); free(saved_cache_copy); bool cleaned = !cache_created || th_rmtree(cache) == 0; + /* Git for Windows makes loose objects read-only; the shared test cleanup + * must still remove the entire self-contained fixture. */ + bool repo_cleaned = !repo_created || th_rmtree(repo) == 0; ASSERT_TRUE(cache_created); + ASSERT_TRUE(repo_created); + ASSERT_TRUE(repo_ready); ASSERT_TRUE(environment_ready); - ASSERT_TRUE(root_ready); ASSERT_TRUE(server_ready); ASSERT_TRUE(project_ready); ASSERT_TRUE(invalid_rejected); @@ -5725,6 +5907,7 @@ TEST(tool_detect_changes_contained_commands_clean_up_error_and_success) { ASSERT_EQ(command_probe.diff_calls, 3); ASSERT_EQ(command_probe.merge_base_calls, 2); ASSERT_TRUE(cleaned); + ASSERT_TRUE(repo_cleaned); PASS(); } diff --git a/tests/test_parallel_harness_contract.sh b/tests/test_parallel_harness_contract.sh new file mode 100755 index 000000000..b97d2de7a --- /dev/null +++ b/tests/test_parallel_harness_contract.sh @@ -0,0 +1,403 @@ +#!/usr/bin/env bash +# Regression contract for the cross-platform parallel suite scheduler. +# +# On real Windows/MSYS2, a native suite completed and printed its green +# summary, but the exported `run_one` function's nested `bash -c` worker never +# returned to xargs or appended its result. The native process was gone; the +# orphaned MSYS shell could not be terminated, so the whole gate waited +# forever. Keep process ownership in one Python parent and forbid that nested +# shell-worker shape from returning. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +driver="$ROOT/scripts/run-tests-parallel.sh" +scheduler="$ROOT/scripts/run-test-wave.py" +fixture="$(mktemp -d "${TMPDIR:-/tmp}/cbm-parallel-harness.XXXXXX")" +trap 'rm -rf -- "$fixture"' EXIT + +if grep -Eq '(^|[[:space:]])xargs([[:space:]]|$)|export[[:space:]]+-f|bash[[:space:]]+-c' \ + "$driver"; then + echo "FAIL: parallel harness must not put native runners behind nested MSYS bash workers" >&2 + exit 1 +fi +if [ ! -f "$scheduler" ]; then + echo "FAIL: parent-owned parallel scheduler is missing: $scheduler" >&2 + exit 1 +fi +if ! grep -Fq 'run-test-wave.py' "$driver"; then + echo "FAIL: parallel harness is not wired to the parent-owned scheduler" >&2 + exit 1 +fi + +cat >"$fixture/fake_runner.py" <<'PY' +from __future__ import annotations + +import os +import pathlib +import signal +import subprocess +import sys +import time + + +pid_path = pathlib.Path(sys.argv[1]) +suite = sys.argv[-1] +if suite == "hang_after_summary": + print(" 1 passed", flush=True) + time.sleep(30) +elif suite in ("stubborn_tree", "timeout_exit_race"): + child = subprocess.Popen( + [ + sys.executable, + "-c", + ( + "import os,signal,time;" + "signal.signal(signal.SIGTERM,signal.SIG_IGN) " + "if os.name != 'nt' else None;" + "time.sleep(30)" + ), + ] + ) + pid_path.write_text(str(child.pid), encoding="utf-8") + print(" 1 passed", flush=True) + time.sleep(30) +elif suite == "no_summary": + pass +else: + print(" 1 passed", flush=True) +PY + +seq 1 32 | sed 's/^/pass_/' >"$fixture/suites.txt" +: >"$fixture/results.txt" +python3 "$scheduler" \ + --suite-file "$fixture/suites.txt" \ + --log-dir "$fixture/logs" \ + --results-file "$fixture/results.txt" \ + --jobs 8 \ + --timeout 5 \ + --slow-timeout 5 \ + --kill-grace 1 \ + "$(command -v python3)" "$fixture/fake_runner.py" "$fixture/descendant.pid" + +if [ "$(wc -l <"$fixture/results.txt" | tr -d ' ')" -ne 32 ] || + grep -qvE '^pass_[0-9]+ rc=0 pass=1 fail=0 skip=0 secs=[0-9]+$' \ + "$fixture/results.txt"; then + echo "FAIL: scheduler lost or corrupted a completed child's result" >&2 + cat "$fixture/results.txt" >&2 + exit 1 +fi + +printf '%s\n' hang_after_summary no_summary pass_after >"$fixture/suites.txt" +: >"$fixture/results.txt" +python3 "$scheduler" \ + --suite-file "$fixture/suites.txt" \ + --log-dir "$fixture/logs" \ + --results-file "$fixture/results.txt" \ + --jobs 1 \ + --timeout 1 \ + --slow-timeout 1 \ + --kill-grace 1 \ + "$(command -v python3)" "$fixture/fake_runner.py" "$fixture/descendant.pid" + +if ! grep -qE '^hang_after_summary rc=124 pass=1 fail=0 skip=0 secs=[0-9]+$' \ + "$fixture/results.txt"; then + echo "FAIL: scheduler did not bound and record a child that stayed alive after its summary" >&2 + cat "$fixture/results.txt" >&2 + exit 1 +fi +if ! grep -qE '^no_summary rc=97 pass=0 fail=0 skip=0 secs=[0-9]+$' \ + "$fixture/results.txt"; then + echo "FAIL: scheduler accepted a zero-test child" >&2 + cat "$fixture/results.txt" >&2 + exit 1 +fi +if ! grep -qE '^pass_after rc=0 pass=1 fail=0 skip=0 secs=[0-9]+$' \ + "$fixture/results.txt"; then + echo "FAIL: scheduler did not continue after a bounded child failure" >&2 + cat "$fixture/results.txt" >&2 + exit 1 +fi + +printf '%s\n' stubborn_tree >"$fixture/suites.txt" +: >"$fixture/results.txt" +python3 "$scheduler" \ + --suite-file "$fixture/suites.txt" \ + --log-dir "$fixture/logs" \ + --results-file "$fixture/results.txt" \ + --jobs 1 \ + --timeout 1 \ + --slow-timeout 1 \ + --kill-grace 1 \ + "$(command -v python3)" "$fixture/fake_runner.py" "$fixture/descendant.pid" + +python3 - "$fixture/descendant.pid" <<'PY' +import ctypes +import os +import pathlib +import signal +import subprocess +import sys + + +pid = int(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +alive = False +if os.name == "nt": + process = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid) + if process: + code = ctypes.c_ulong() + if ctypes.windll.kernel32.GetExitCodeProcess(process, ctypes.byref(code)): + alive = code.value == 259 + ctypes.windll.kernel32.CloseHandle(process) +else: + try: + os.kill(pid, 0) + alive = True + except ProcessLookupError: + pass + if alive: + stat_path = pathlib.Path("/proc") / str(pid) / "stat" + state = "" + if stat_path.exists(): + state = stat_path.read_text(encoding="utf-8", errors="replace").rsplit( + ")", 1 + )[-1].strip() + if state.startswith("Z "): + alive = False + +if alive: + if os.name == "nt": + subprocess.run( + ["taskkill.exe", "/PID", str(pid), "/T", "/F"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + else: + os.kill(pid, signal.SIGKILL) + raise SystemExit("FAIL: timed-out suite left a stubborn descendant alive") +PY + +printf '%s\n' pass_after >"$fixture/suites.txt" +: >"$fixture/results.txt" +mkdir "$fixture/barrier" +: >"$fixture/barrier/pass_after.hold" +python3 - "$scheduler" "$fixture" "$(command -v python3)" <<'PY' +from __future__ import annotations + +import pathlib +import subprocess +import sys +import time + + +scheduler = sys.argv[1] +fixture = pathlib.Path(sys.argv[2]) +python = sys.argv[3] +results = fixture / "results.txt" +barrier = fixture / "barrier" +process = subprocess.Popen( + [ + python, + scheduler, + "--suite-file", + str(fixture / "suites.txt"), + "--log-dir", + str(fixture / "logs"), + "--results-file", + str(results), + "--jobs", + "1", + "--timeout", + "5", + "--slow-timeout", + "5", + "--kill-grace", + "1", + "--test-post-exit-barrier-dir", + str(barrier), + python, + str(fixture / "fake_runner.py"), + str(fixture / "descendant.pid"), + ] +) +try: + deadline = time.monotonic() + 5 + while not (barrier / "pass_after.ready").exists(): + if process.poll() is not None: + raise SystemExit( + f"FAIL: scheduler exited before exposing the post-exit barrier " + f"(rc={process.returncode})" + ) + if time.monotonic() >= deadline: + raise SystemExit("FAIL: scheduler never reached the post-exit barrier") + time.sleep(0.02) + if results.read_text(encoding="utf-8"): + raise SystemExit("FAIL: scheduler recorded a result before the forced barrier released") + (barrier / "pass_after.release").write_text("release\n", encoding="utf-8") + returncode = process.wait(timeout=5) + if returncode != 0: + raise SystemExit(f"FAIL: scheduler failed after the post-exit release (rc={returncode})") +finally: + if process.poll() is None: + process.kill() + process.wait() + +expected = "pass_after rc=0 pass=1 fail=0 skip=0 secs=" +if not results.read_text(encoding="utf-8").startswith(expected): + raise SystemExit("FAIL: exited child result was lost after the forced barrier") +PY + +printf '%s\n' timeout_exit_race >"$fixture/suites.txt" +: >"$fixture/results.txt" +rm -f "$fixture/descendant.pid" +: >"$fixture/barrier/timeout_exit_race.hold" +python3 - "$scheduler" "$fixture" "$(command -v python3)" <<'PY' +from __future__ import annotations + +import ctypes +import os +import pathlib +import signal +import subprocess +import sys +import time + + +scheduler = sys.argv[1] +fixture = pathlib.Path(sys.argv[2]) +python = sys.argv[3] +results = fixture / "results.txt" +barrier = fixture / "barrier" +ready = barrier / "timeout_exit_race.ready" +leader_exited = barrier / "timeout_exit_race.leader-exited" +release = barrier / "timeout_exit_race.release" +descendant_path = fixture / "descendant.pid" + + +def process_state(pid: int) -> str: + if os.name == "nt": + handle = ctypes.windll.kernel32.OpenProcess(0x101000, False, pid) + if not handle: + return "gone" + code = ctypes.c_ulong() + active = ( + ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(code)) + and code.value == 259 + ) + ctypes.windll.kernel32.CloseHandle(handle) + return "live" if active else "gone" + try: + os.kill(pid, 0) + except ProcessLookupError: + return "gone" + stat_path = pathlib.Path("/proc") / str(pid) / "stat" + if stat_path.exists(): + state = stat_path.read_text(encoding="utf-8", errors="replace").rsplit( + ")", 1 + )[-1].strip() + if state.startswith("Z "): + return "zombie" + return "live" + + +def force_cleanup(pid: int) -> None: + if process_state(pid) != "live": + return + if os.name == "nt": + subprocess.run( + ["taskkill.exe", "/PID", str(pid), "/T", "/F"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + else: + os.kill(pid, signal.SIGKILL) + + +process = subprocess.Popen( + [ + python, + scheduler, + "--suite-file", + str(fixture / "suites.txt"), + "--log-dir", + str(fixture / "logs"), + "--results-file", + str(results), + "--jobs", + "1", + "--timeout", + "1", + "--slow-timeout", + "1", + "--kill-grace", + "1", + "--test-pre-terminate-barrier-dir", + str(barrier), + python, + str(fixture / "fake_runner.py"), + str(descendant_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, +) +try: + deadline = time.monotonic() + 5 + while not ready.exists(): + if process.poll() is not None: + stdout, stderr = process.communicate() + raise SystemExit( + f"FAIL: scheduler exited before the timeout-race barrier " + f"(rc={process.returncode}, stdout={stdout!r}, stderr={stderr!r})" + ) + if time.monotonic() >= deadline: + raise SystemExit("FAIL: scheduler never reached the timeout-race barrier") + time.sleep(0.02) + + leader_pid = int(ready.read_text(encoding="utf-8")) + os.kill(leader_pid, signal.SIGTERM) + deadline = time.monotonic() + 3 + while not leader_exited.exists(): + if process.poll() is not None: + stdout, stderr = process.communicate() + raise SystemExit( + f"FAIL: scheduler exited before observing the forced leader exit " + f"(rc={process.returncode}, stdout={stdout!r}, stderr={stderr!r})" + ) + if time.monotonic() >= deadline: + raise SystemExit("FAIL: scheduler did not observe the forced leader exit") + time.sleep(0.02) + release.write_text("release\n", encoding="utf-8") + stdout, stderr = process.communicate(timeout=8) + + if os.name == "nt": + if process.returncode != 2 or "tree cleanup" not in stderr: + raise SystemExit( + f"FAIL: Windows timeout race did not fail closed " + f"(rc={process.returncode}, stdout={stdout!r}, stderr={stderr!r})" + ) + else: + if process.returncode != 0: + raise SystemExit( + f"FAIL: POSIX timeout race cleanup failed " + f"(rc={process.returncode}, stdout={stdout!r}, stderr={stderr!r})" + ) + if not results.read_text(encoding="utf-8").startswith( + "timeout_exit_race rc=124 pass=1 fail=0 skip=0 secs=" + ): + raise SystemExit("FAIL: POSIX timeout race lost its bounded result") + descendant_pid = int(descendant_path.read_text(encoding="utf-8")) + if process_state(descendant_pid) == "live": + raise SystemExit("FAIL: POSIX timeout race leaked the surviving descendant") +finally: + release.write_text("release\n", encoding="utf-8") + if process.poll() is None: + process.kill() + process.wait() + if descendant_path.exists(): + force_cleanup(int(descendant_path.read_text(encoding="utf-8"))) +PY + +echo "Parallel harness contract passed" diff --git a/tests/test_parent_watchdog.sh b/tests/test_parent_watchdog.sh index 55632f0a7..87f938d6e 100755 --- a/tests/test_parent_watchdog.sh +++ b/tests/test_parent_watchdog.sh @@ -11,7 +11,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -BINARY="${ROOT}/build/c/codebase-memory-mcp" +BINARY="${CBM_TEST_BINARY:-${ROOT}/build/c/codebase-memory-mcp}" case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 6cb2a6150..2685b425d 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -7153,7 +7153,9 @@ TEST(pipeline_committed_counts_match_persisted) { * FIXED: the first futile cycle flips a shared flag; later pulls proceed with * the designed soft overshoot. Cycle count then can't exceed one cycle per * worker (workers already inside the gate when the flag flips) plus re-probes. - * RED on the unfixed gate: cycles == file count (64) > cores+2. + * Four workers keep this semantic regression deterministic under TSan while + * still exercising the parallel path. RED on the unfixed gate: cycles == file + * count (64) > workers+2. * The counter (cbm_pp_bp_nap_cycles) makes this deterministic — no timing. * * The gate lives ONLY in the parallel extract path, so the fixture MUST exceed @@ -7196,14 +7198,41 @@ TEST(pipeline_backpressure_futile_nap_disengages) { snprintf(db_path, sizeof(db_path), "%s/backpressure.db", g_tmpdir); cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL); ASSERT_NOT_NULL(p); + + /* This test measures the shared futility latch, not allocator scalability. + * High-core TSan runs can turn unrelated slab bookkeeping into an 18-way + * lock convoy. Four workers preserve the parallel semantic while making its + * bound host-independent. cbm_pipeline_run joins its workers before return, + * so restoring the process environment after freeing p cannot race them. */ + enum { TEST_WORKERS = 4 }; + const char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + if (old_workers && !saved_workers) { + cbm_mem_set_budget_for_tests(saved_budget); + cbm_pipeline_free(p); + teardown_test_repo(); + FAIL("failed to save CBM_WORKERS"); + } + if (cbm_setenv("CBM_WORKERS", "4", 1) != 0) { + free(saved_workers); + cbm_mem_set_budget_for_tests(saved_budget); + cbm_pipeline_free(p); + teardown_test_repo(); + FAIL("failed to set CBM_WORKERS"); + } + int rc = cbm_pipeline_run(p); long cycles = cbm_pp_bp_nap_cycles(); /* Restore the caller-visible budget BEFORE asserting. */ cbm_mem_set_budget_for_tests(saved_budget); cbm_pipeline_free(p); + int restore_workers_rc = + saved_workers ? cbm_setenv("CBM_WORKERS", saved_workers, 1) : cbm_unsetenv("CBM_WORKERS"); + free(saved_workers); teardown_test_repo(); + ASSERT_EQ(restore_workers_rc, 0); ASSERT_EQ(rc, 0); /* Engagement guard (anti-vacuous): the gate must have actually run — the * parallel path taken and the 1 MB budget exceeded on every pull. cycles==0 @@ -7214,7 +7243,7 @@ TEST(pipeline_backpressure_futile_nap_disengages) { } /* Futile napping must disengage: at most one in-flight cycle per worker * plus a small margin, never one per file (64). */ - long bound = (long)cbm_system_info().total_cores + 2; + long bound = TEST_WORKERS + 2; if (cycles > bound) { char msg[128]; snprintf(msg, sizeof(msg), "nap cycles %ld > bound %ld (gate re-paid per pull)", cycles, diff --git a/tests/test_smoke_fixture_contract.sh b/tests/test_smoke_fixture_contract.sh new file mode 100755 index 000000000..cb93ec05a --- /dev/null +++ b/tests/test_smoke_fixture_contract.sh @@ -0,0 +1,361 @@ +#!/usr/bin/env bash +# Static + light functional contract for the local/PR release-fixture smoke. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +python3 - "$ROOT" <<'PY' +from __future__ import annotations + +import pathlib +import re +import subprocess +import sys +import tempfile +import time +import urllib.request + + +root = pathlib.Path(sys.argv[1]) +failures: list[str] = [] + + +def read(relative: str) -> str: + path = root / relative + if not path.is_file(): + failures.append(f"{relative} must exist") + return "" + return path.read_text(encoding="utf-8") + + +def require(condition: bool, message: str) -> None: + if not condition: + failures.append(message) + + +helper_relative = "scripts/smoke-fixture-server.py" +helper = root / helper_relative +smoke_local = read("scripts/smoke-local.sh") +vm_smoke = read("test-infrastructure/vm/vm-smoke.sh") +windows_path_guard = read("test-infrastructure/vm/windows-user-path-guard.ps1") +compose = read("test-infrastructure/docker-compose.yml") +pr_workflow = read(".github/workflows/pr.yml") +test_driver = read("scripts/test.sh") +cli_source = read("src/cli/cli.c") +helper_source = read(helper_relative) + +# The server owns the ephemeral bind. A parent-side socket probe followed by +# python -m http.server would reintroduce the close/rebind race this guards. +require( + "ThreadingHTTPServer((args.bind, 0)" in helper_source, + "fixture server must bind port 0 itself and retain the listening socket", +) +require( + "--port-file" in helper_source and "os.replace" in helper_source, + "fixture server must publish its assigned port atomically through a file", +) +require( + "socket.socket" not in smoke_local and "python3 -m http.server" not in smoke_local, + "smoke-local.sh must not reserve/release a port or launch a separate http.server", +) + +for relative, source in ( + ("scripts/smoke-local.sh", smoke_local), + ("test-infrastructure/vm/vm-smoke.sh", vm_smoke), +): + require( + helper_relative in source and "--port-file" in source, + f"{relative} must use the shared race-free fixture server", + ) + require( + "EXPECTED_ARTIFACT" in source and "SERVER_LOG" in source, + f"{relative} must poll its specific expected artifact and retain a server log", + ) + require( + 'cat "$SERVER_LOG"' in source, + f"{relative} must print the server log on readiness failure", + ) + require( + 'wait "$SERVER_PID"' in source, + f"{relative} cleanup must reap the fixture-server process", + ) + for variable in ( + "CLAUDE_CONFIG_DIR", + "CODEX_HOME", + "KIRO_HOME", + "HERMES_HOME", + "QWEN_HOME", + "CLINE_DATA_DIR", + "OPENCLAW_HOME", + "OPENCLAW_STATE_DIR", + "OPENCLAW_PROFILE", + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_WORKSPACE_DIR", + "OPENCODE_CONFIG", + "OPENCODE_CONFIG_DIR", + "COPILOT_HOME", + "CRUSH_GLOBAL_CONFIG", + "VIBE_HOME", + "GLAB_CONFIG_DIR", + "KIMI_CODE_HOME", + "CBM_CONTINUE_CONFIG_PATH", + "CBM_TRAE_CONFIG_PATH", + "CBM_ROO_CONFIG_PATH", + "CBM_CODY_CONFIG_PATH", + "CBM_TEST_WINDOWS_USER_PATH_RUN_ID", + ): + require( + f"-u {variable}" in source, + f"{relative} must neutralize ambient {variable}", + ) + for variable in ( + "HOME", + "USERPROFILE", + "XDG_CONFIG_HOME", + "APPDATA", + "LOCALAPPDATA", + "TMPDIR", + "TEMP", + "TMP", + "SHELL", + ): + require( + f"{variable}=" in source, + f"{relative} must pin isolated {variable}", + ) + +# Unix fixtures mirror the release archive surface and Linux update aliases. +for name in ("LICENSE", "install.sh", "THIRD_PARTY_NOTICES.md"): + require(name in smoke_local, f"smoke-local.sh archive must include {name}") +require( + "codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}.tar.gz" in smoke_local + and "codebase-memory-mcp-${OS}-${ARCH}.tar.gz" in smoke_local, + "smoke-local.sh must create the selected variant and standard alias", +) +require( + "codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}-portable.tar.gz" in smoke_local + and "codebase-memory-mcp-${OS}-${ARCH}-portable.tar.gz" in smoke_local, + "smoke-local.sh must create Linux portable selected-variant and standard aliases", +) +require( + 'CBM_CACHE_DIR="$WORK_DIR/cache"' in smoke_local + and 'SMOKE_TEMP_ROOT="$SMOKE_TEMP_DIR"' in smoke_local, + "smoke-local.sh must isolate daemon/cache and temporary state from live user sessions", +) +require( + "machdep.cpu.brand_string" in smoke_local, + "smoke-local.sh must select arm64 artifacts when running under Rosetta", +) +require( + "sha256sum ./*.tar.gz" not in smoke_local + and "shasum -a 256 ./*.tar.gz" not in smoke_local + and "sha256sum ./*.zip" not in vm_smoke, + "fixture checksums must name exact artifact basenames, never ./-prefixed paths", +) + +# Native Windows packages and serves the exact five-file release bundle, then +# runs the full smoke from a protected profile-rooted directory/cache. +for name in ( + "codebase-memory-mcp.exe", + "codebase-memory-mcp.payload.exe", + "LICENSE", + "install.ps1", + "THIRD_PARTY_NOTICES.md", +): + require(name in vm_smoke, f"vm-smoke.sh archive must include {name}") +require("checksums.txt" in vm_smoke, "vm-smoke.sh must generate checksums.txt") +require( + "SMOKE_DOWNLOAD_URL=" in vm_smoke + and "SMOKE_UPDATE_FIXTURE_DIR=" in vm_smoke + and "SMOKE_ARCH=" in vm_smoke, + "vm-smoke.sh must enable Phase 12-14 fixture semantics with an explicit arch", +) +require( + "PROFILE_ROOT=" in vm_smoke + and 'SMOKE_TEMP_ROOT="$SMOKE_DIR"' in vm_smoke + and 'CBM_CACHE_DIR="$(cygpath -m "$SMOKE_DIR/cache")"' in vm_smoke, + "vm-smoke.sh must isolate smoke temp/cache below the protected user profile", +) +require( + "--agent-config-only" not in vm_smoke, + "vm-smoke.sh must run the full smoke, not a reduced mode", +) +require( + "windows-user-path-guard.ps1" in vm_smoke + and "-Mode prepare" in vm_smoke + and "-Mode verify" in vm_smoke + and "-Mode cleanup" in vm_smoke, + "vm-smoke.sh must prepare, verify, and clean up an isolated Windows PATH key", +) +require( + 'CBM_TEST_WINDOWS_USER_PATH_RUN_ID="$PATH_RUN_ID"' in vm_smoke + and 'SMOKE_DOWNLOAD_URL="http://127.0.0.1:$PORT"' in vm_smoke + and vm_smoke.find("-Mode verify") > vm_smoke.find("scripts/smoke-test.sh"), + "vm-smoke.sh must pass the run ID and loopback gate through the full smoke, then verify it", +) +require( + "DoNotExpandEnvironmentNames" in windows_path_guard + and "Assert-StateEqual" in windows_path_guard + and 'Read-PathValue "Environment"' in windows_path_guard, + "Windows PATH guard must compare the live raw value and registry kind without expanding it", +) +require( + 'Software\\CodebaseMemoryMCP\\Smoke\\$RunId' in windows_path_guard + and "Assert-SmokePathValue" in windows_path_guard + and "DeleteSubKeyTree" in windows_path_guard + and "restore" not in windows_path_guard.lower(), + "Windows PATH smoke must mutate and delete only its GUID-scoped scratch registry leaf", +) +require( + "CBM_TEST_WINDOWS_USER_PATH_RUN_ID" in cli_source + and 'L"SMOKE_DOWNLOAD_URL"' in cli_source + and 'L"Software\\\\CodebaseMemoryMCP\\\\Smoke\\\\%ls"' in cli_source + and "cli_windows_smoke_download_url_valid" in cli_source + and "CbmSmokeRunId" in cli_source, + "the Windows PATH test seam must be run-ID-only, sentinel-bound, and loopback-gated", +) + +# Every maintained local/PR native leg enters through the fixture wrapper. +for service in ("smoke:", "smoke-amd64:"): + match = re.search( + rf"^ {re.escape(service)}\n(?P.*?)(?=^ [A-Za-z0-9_-]+:|\Z)", + compose, + re.MULTILINE | re.DOTALL, + ) + section = match.group("body") if match else "" + require( + "scripts/smoke-local.sh" in section, + f"docker-compose {service[:-1]} service must run smoke-local.sh", + ) +require( + pr_workflow.count("scripts/smoke-local.sh") >= 2, + "PR Ubuntu and macOS smoke steps must run smoke-local.sh", +) +require( + "SMOKE_ARCH=amd64" in pr_workflow + and "test-infrastructure/vm/vm-smoke.sh" in pr_workflow, + "PR Windows smoke must call vm-smoke.sh with SMOKE_ARCH=amd64", +) +smoke_test = read("scripts/smoke-test.sh") +require( + "MSYS2_ARG_CONV_EXCL='*'" in smoke_test + and 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File' in smoke_test + and "& $args[1]" not in smoke_test, + "Windows Phase 13 must execute install.ps1 directly with native paths", +) +require( + smoke_test.count("--noproxy '*'") >= 4, + "all loopback release-fixture curl requests must bypass ambient proxies", +) +require( + "/tmp/cbm-curl12a.err" not in smoke_test + and 'CURL12_ERR="$DL_DIR/curl12a.err"' in smoke_test, + "curl diagnostics must stay inside the per-smoke download directory", +) +require( + "CBM_TEST_WINDOWS_USER_PATH_RUN_ID=invalid" in smoke_test + and "invalid Windows PATH smoke seam fell back" in smoke_test, + "Windows release smoke must prove malformed PATH-test gating fails closed", +) +require( + 'if [ "$UPD_CMD" != "$EXPECTED_UPD_CMD" ]' in smoke_test, + "Phase 14 must require the refreshed MCP command to equal the updated binary", +) +require( + 'UPDATE_DRIVER="$RETIRED_DIR/codebase-memory-mcp"' in smoke_test + and 'STALE_CMD="$UPDATE_DRIVER"' in smoke_test, + "POSIX Phase 14 must refresh from positive running-image identity without probing config paths", +) +require( + 'STALE_CMD="$UPDATE_HOME/retired-install/codebase-memory-mcp.exe"' in smoke_test, + "Windows Phase 14 must test a literal missing executable, not an ambiguous extensionless command", +) +for changed_path in ( + "install\\.(sh|ps1)", + "scripts/smoke-local", + "scripts/smoke-fixture-server", + "scripts/gen-third-party-notices", + "test-infrastructure/vm/vm-smoke", + "windows-user-path-guard", +): + require( + changed_path in pr_workflow, + f"PR product-change detector must include {changed_path}", + ) +require( + "tests/test_smoke_fixture_contract.sh" in test_driver, + "scripts/test.sh must run the smoke fixture contract", +) + +# Functional check: the helper must publish a live kernel-assigned port and +# serve the exact expected artifact. This is intentionally build-free. +if helper.is_file(): + with tempfile.TemporaryDirectory(prefix="cbm-fixture-contract-") as temp: + temp_path = pathlib.Path(temp) + fixture = temp_path / "fixture" + fixture.mkdir() + expected = fixture / "expected-artifact.txt" + expected.write_bytes(b"fixture-ok\n") + port_file = temp_path / "port" + log_file = temp_path / "server.log" + with log_file.open("wb") as log: + process = subprocess.Popen( + [ + sys.executable, + str(helper), + "--directory", + str(fixture), + "--port-file", + str(port_file), + ], + stdout=log, + stderr=subprocess.STDOUT, + ) + try: + port = 0 + # This is a hang detector, not a Python-startup latency + # assertion. Under a concurrent three-platform local gate the + # interpreter can take more than two seconds to reach main; + # keep the same bounded readiness semantics without turning + # ordinary host scheduling into a false red. + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + if port_file.is_file(): + text = port_file.read_text(encoding="ascii").strip() + if text: + port = int(text) + break + if process.poll() is not None: + break + time.sleep(0.02) + require(port > 0, "fixture server must publish a nonzero assigned port") + if port == 0 and log_file.is_file(): + details = log_file.read_text(encoding="utf-8", errors="replace").strip() + if details: + failures.append(f"fixture server startup log: {details}") + if port > 0: + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}) + ) + body = opener.open( + f"http://127.0.0.1:{port}/{expected.name}", timeout=2 + ).read() + require(body == b"fixture-ok\n", "fixture server returned wrong artifact") + finally: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) + failures.append("fixture server did not terminate promptly") + +if failures: + print("smoke fixture contract: FAIL", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + raise SystemExit(1) + +print("smoke fixture contract: OK") +PY diff --git a/tests/test_soak_daemon_recovery_contract.sh b/tests/test_soak_daemon_recovery_contract.sh index 8bbba5302..6908d398c 100644 --- a/tests/test_soak_daemon_recovery_contract.sh +++ b/tests/test_soak_daemon_recovery_contract.sh @@ -19,6 +19,9 @@ for required in \ 'SOAK_PROJECT_JSON=$(python3 -c' \ 'mcp_response_project()' \ 'PROJ_NAME=$(mcp_response_project "$MCP_LAST_RESPONSE")' \ + 'FAIL: soak DACL normalize' \ + 'FAIL: soak DACL stamp' \ + 'FAIL: soak child DACL reset' \ 'def handle_${i}(request):' \ 'trace_path "{\"project\":\"$PROJ_NAME\",\"function_name\":\"handle_1\",\"direction\":\"both\"}"' \ 'wait_for_daemon_stop "$DAEMON_STOP_COUNT"' \ @@ -31,6 +34,11 @@ for required in \ fi done +if grep -Fq 'WARN: soak DACL stamp failed' "$soak"; then + echo "FAIL: native-Windows soak must fail closed when its trusted-root DACL cannot be set" >&2 + exit 1 +fi + if grep -Fq '"repo_path":"$SOAK_PROJECT"' "$soak"; then echo "FAIL: soak must not send an unconverted host/MSYS project path to a Windows binary" >&2 exit 1 diff --git a/tests/test_vm_worktree_manifest.sh b/tests/test_vm_worktree_manifest.sh index 0711b6222..5bda13e22 100644 --- a/tests/test_vm_worktree_manifest.sh +++ b/tests/test_vm_worktree_manifest.sh @@ -78,6 +78,38 @@ for entrypoint in "$driver" "$provisioner"; do exit 1 fi done +if ! grep -Fq 'cbm_vm_sync_windows_clock()' \ + "$ROOT/test-infrastructure/vm/ssh-common.sh"; then + echo "FAIL: Windows VM drivers need a shared, verified host-to-guest clock sync" >&2 + exit 1 +fi +clock_helper=$(sed -n '/^cbm_vm_sync_windows_clock() {$/,/^}$/p' \ + "$ROOT/test-infrastructure/vm/ssh-common.sh") +clock_retry_line=$(printf '%s\n' "$clock_helper" | grep -nF 'for attempt in 1 2 3; do' | + cut -d: -f1) +clock_capture_line=$(printf '%s\n' "$clock_helper" | + grep -nF "host_utc=\"\$(date -u '+%Y-%m-%dT%H:%M:%SZ')\"" | cut -d: -f1) +if [ -z "$clock_retry_line" ] || [ -z "$clock_capture_line" ] || + [ "$clock_capture_line" -le "$clock_retry_line" ]; then + echo "FAIL: Windows VM clock sync must retry a bounded three times with a fresh host timestamp" >&2 + exit 1 +fi +for entrypoint in "$driver" "$provisioner"; do + if ! grep -Fq 'cbm_vm_sync_windows_clock "${SSH[@]}"' "$entrypoint"; then + echo "FAIL: Windows VM entry point must repair suspended-guest clock drift: $entrypoint" >&2 + exit 1 + fi +done +if ! grep -Fq 'cbm_vm_run_powershell()' \ + "$ROOT/test-infrastructure/vm/ssh-common.sh" || + [ "$(grep -c 'vm_powershell ' "$provisioner")" -lt 3 ]; then + echo "FAIL: Windows provisioning must transport PowerShell as UTF-16LE encoded commands" >&2 + exit 1 +fi +if grep -Fq 'powershell -NoProfile -Command' "$provisioner"; then + echo "FAIL: Windows provisioning must not expose PowerShell metacharacters to remote shell parsing" >&2 + exit 1 +fi if ! grep -Fq "JOBS='\$(nproc)'" "$provisioner" || ! grep -Fq 'make -j${JOBS}' "$provisioner"; then echo "FAIL: Windows VM provisioner must expand the remote core count exactly once" >&2 diff --git a/tests/test_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh index e1cf53793..0a04064d2 100644 --- a/tests/test_windows_bundle_contract.sh +++ b/tests/test_windows_bundle_contract.sh @@ -977,9 +977,12 @@ require( "Phase 14 native update must use an explicit file:// fixture override", ) require( - "'$env:TEMP=$args[0]; $env:TMP=$args[0]; & $args[1] $args[2]'" in smoke_script - and '"$WIN_HOME" "$WIN_SCRIPT" "--dir=$WIN_DIR"' in smoke_script, - "Windows install.ps1 smoke must set native TEMP/TMP inside PowerShell", + 'HOME="$WIN_HOME" TEMP="$WIN_HOME" TMP="$WIN_HOME"' in smoke_script + and "MSYS2_ARG_CONV_EXCL='*'" in smoke_script + and "powershell.exe -NoProfile -ExecutionPolicy Bypass -File" in smoke_script + and '"$WIN_SCRIPT" "--dir=$WIN_DIR"' in smoke_script + and "& $args[1]" not in smoke_script, + "Windows install.ps1 smoke must pass native HOME/TEMP/TMP and execute the script directly", ) require( 'CBM_DOWNLOAD_URL="$SMOKE_DOWNLOAD_URL"' in smoke_script @@ -992,6 +995,22 @@ require( ) cli_source = read("src/cli/cli.c") +probe_start = cli_source.find("cbm_json_mcp_probe_windows_command_path(") +probe_end = cli_source.find("#endif", probe_start) +safe_command_probe = ( + cli_source[probe_start:probe_end] + if probe_start >= 0 and probe_end > probe_start + else "" +) +require( + "HANDLE *component_handles" in safe_command_probe + and "component_handle_count" in safe_command_probe + and "FILE_SHARE_WRITE" not in safe_command_probe + and "FILE_SHARE_DELETE" not in safe_command_probe + and "CloseHandle(component_handles[handle_index])" in safe_command_probe, + "Windows stale-command probing must retain validated ancestor handles and deny " + "write/delete sharing until the complete local path has been classified", +) file_override_match = re.search( r"static bool cli_download_is_explicit_file_override\(.*?\n}\n", cli_source, @@ -1028,9 +1047,10 @@ require( "native curl invocations must pin both initial and redirected protocols", ) -# PR smoke builds artifacts in-place rather than downloading a release archive. -# It must therefore stage the build payload and permanent launcher under their -# release names in a disposable directory, then exercise the canonical launcher. +# PR smoke builds artifacts in-place, then delegates all release-name staging, +# fixture packaging, and full launcher smoke semantics to the maintained native +# Windows harness. Keeping those mechanics out of workflow YAML prevents the PR +# and local-VM paths from silently drifting apart. pr_workflow = read(".github/workflows/pr.yml") pr_smoke_match = re.search( r"(?ms)^ pr-smoke:\s*(.*?)(?=^ [A-Za-z0-9_-]+:\s*$|\Z)", @@ -1050,31 +1070,23 @@ require( if pr_windows_blocks: pr_windows_block = re.sub(r"\\\s*\n\s*", " ", pr_windows_blocks[0]) pr_windows_block = re.sub(r"\s+", " ", pr_windows_block).strip() - staging_steps = ( - 'PROFILE_ROOT="$(cygpath -u "$USERPROFILE")"', - 'SMOKE_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-pr-smoke.XXXXXX")"', - 'trap \'rm -rf "$SMOKE_DIR"\' EXIT', - 'cp build/c/codebase-memory-mcp-launcher.exe ' - '"$SMOKE_DIR/codebase-memory-mcp.exe"', - 'cp build/c/codebase-memory-mcp.exe ' - '"$SMOKE_DIR/codebase-memory-mcp.payload.exe"', - 'CBM_CACHE_DIR="$(cygpath -m "$SMOKE_DIR/cache")" ' - 'SMOKE_TEMP_ROOT="$SMOKE_DIR" ' - 'scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe"', - ) - positions = [pr_windows_block.find(step) for step in staging_steps] require( - all(position >= 0 for position in positions), - "Windows PR smoke must stage launcher and payload under release names " - "beneath the current account profile and invoke the canonical launcher", + "SMOKE_ARCH=amd64 bash test-infrastructure/vm/vm-smoke.sh" + in pr_windows_block, + "Windows PR smoke must invoke the maintained native harness with the " + "authoritative amd64 artifact architecture", ) require( - all(left < right for left, right in zip(positions, positions[1:])), - "Windows PR smoke must create the temporary bundle before invoking it", + pr_windows_block.find("scripts/build.sh CC=clang CXX=clang++") + < pr_windows_block.find( + "SMOKE_ARCH=amd64 bash test-infrastructure/vm/vm-smoke.sh" + ), + "Windows PR smoke must build before invoking the maintained harness", ) require( - pr_windows_block.count("scripts/smoke-test.sh") == 1, - "Windows PR smoke must invoke smoke-test exactly once through the launcher", + "scripts/smoke-test.sh" not in pr_windows_block + and "codebase-memory-mcp.payload.exe" not in pr_windows_block, + "Windows PR workflow must not duplicate vm-smoke staging or smoke-test logic", ) require( "$RUNNER_TEMP" not in pr_windows_block, diff --git a/tests/test_worker_watchdog.sh b/tests/test_worker_watchdog.sh index 44b165a75..e90547ba4 100755 --- a/tests/test_worker_watchdog.sh +++ b/tests/test_worker_watchdog.sh @@ -22,7 +22,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -BINARY="${ROOT}/build/c/codebase-memory-mcp" +BINARY="${CBM_TEST_BINARY:-${ROOT}/build/c/codebase-memory-mcp}" case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*)