mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 18:58:00 +00:00
fix: harden daemon release paths and verification
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
1 parent
97ce23f982
commit
62821375c4
29 files changed
+3316
-175
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Executable
+406
@@ -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())
|
||||
@@ -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
|
||||
|
||||
Executable
+61
@@ -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()
|
||||
Executable
+161
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 "
|
||||
|
||||
@@ -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) ──────────────────────── */
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Executable
+403
@@ -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"
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Executable
+361
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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*)
|
||||
|
||||
Reference in new issue
Block a user