fix: harden daemon release paths and verification

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-07-24 16:08:42 +02:00
1 parent 97ce23f982
commit 62821375c4
29 files changed
+3316 -175

No files matched your search

+1
View File
@@ -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
+9 -18
View File
@@ -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
+8 -1
View File
@@ -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
+406
View File
@@ -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<passed>[0-9]+) passed")
FAILED = re.compile(r"(?:^|, )(?P<failed>[0-9]+) failed")
SKIPPED = re.compile(r"(?:^|, )(?P<skipped>[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())
+26 -48
View File
@@ -22,6 +22,8 @@ set -uo pipefail
RUNNER="${1:?usage: run-tests-parallel.sh <path-to-test-runner> [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
+61
View File
@@ -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()
+161
View File
@@ -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 <binary>` skips without an artifact fixture.
#
# Usage: scripts/smoke-local.sh <binary> [ui]
# <binary> 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 <binary> [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"
+70 -19
View File
@@ -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:-<missing>}"
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"
+59 -1
View File
@@ -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
+9 -2
View File
@@ -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
+479 -26
View File
@@ -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 "
+8
View File
@@ -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) ──────────────────────── */
+6 -2
View File
@@ -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:
+6 -4
View File
@@ -56,6 +56,7 @@ vm() { # vm <env> <command...>
"${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
+71
View File
@@ -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 '<source>' "${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"
+167 -8
View File
@@ -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")"
+3
View File
@@ -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"
@@ -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"
+365 -3
View File
@@ -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);
+35 -8
View File
@@ -20,6 +20,9 @@
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#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);
+188 -5
View File
@@ -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 <cli/cli.h>
@@ -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();
}
+403
View File
@@ -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"
+1 -1
View File
@@ -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*)
+31 -2
View File
@@ -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,
+361
View File
@@ -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<body>.*?)(?=^ [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
@@ -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
+32
View File
@@ -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
+38 -26
View File
@@ -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,
+1 -1
View File
@@ -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*)