diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index fd1025e13..1104c4309 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -239,7 +239,9 @@ jobs: - name: Smoke test shell: msys2 {0} - run: scripts/smoke-test.sh ./codebase-memory-mcp.exe + run: | + SMOKE_TEMP_ROOT="$(cygpath -u "$RUNNER_TEMP")" \ + scripts/smoke-test.sh ./codebase-memory-mcp.exe env: SMOKE_DOWNLOAD_URL: http://127.0.0.1:18080 SMOKE_UPDATE_FIXTURE_DIR: /tmp/smoke-server diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e33482003..969170384 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -114,13 +114,18 @@ jobs: shell: msys2 {0} run: | scripts/build.sh CC=clang CXX=clang++ - SMOKE_DIR="$(mktemp -d)" + # MSYS2 /tmp is intentionally shared. The launcher correctly rejects + # a bundle below that writable ancestor, so stage in the runner's + # account-private native temp directory instead. + SMOKE_ROOT="$(cygpath -u "$RUNNER_TEMP")" + SMOKE_DIR="$(mktemp -d "$SMOKE_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" - scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe" + SMOKE_TEMP_ROOT="$SMOKE_ROOT" \ + scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe" ci-ok: # The one required context (besides dco) — fails unless every PR stage diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index be84d1fbc..abc7ae19f 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -19,6 +19,22 @@ if [ -n "$SMOKE_MODE" ] && [ "$SMOKE_MODE" != "--agent-config-only" ]; then fi REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)" +smoke_mktemp_file() { + if [ -n "${SMOKE_TEMP_ROOT:-}" ]; then + mktemp "$SMOKE_TEMP_ROOT/cbm-smoke.XXXXXX" + else + mktemp + fi +} + +smoke_mktemp_dir() { + if [ -n "${SMOKE_TEMP_ROOT:-}" ]; then + mktemp -d "$SMOKE_TEMP_ROOT/cbm-smoke.XXXXXX" + else + mktemp -d + fi +} + # Windows release archives contain a small permanent launcher plus a portable # payload. Whenever a smoke fixture copies the launcher, keep the payload next # to it so the copied fixture remains a complete portable bundle. @@ -39,7 +55,7 @@ copy_smoke_binary() { fi } -TMPDIR=$(mktemp -d) +TMPDIR=$(smoke_mktemp_dir) DRYRUN_HOME="" # On MSYS2/Windows, convert POSIX path to native Windows path for the binary if command -v cygpath &>/dev/null; then @@ -47,12 +63,17 @@ if command -v cygpath &>/dev/null; then fi trap 'rm -rf "$TMPDIR" "${DRYRUN_HOME:-}"' EXIT -CLI_STDERR=$(mktemp) +CLI_STDERR=$(smoke_mktemp_file) cli() { "$BINARY" cli "$@" 2>"$CLI_STDERR"; } echo "=== Phase 1: version ===" -OUTPUT=$("$BINARY" --version 2>&1) +VERSION_STATUS=0 +OUTPUT=$("$BINARY" --version 2>&1) || VERSION_STATUS=$? echo "$OUTPUT" +if [ "$VERSION_STATUS" -ne 0 ]; then + echo "FAIL: --version exited with status $VERSION_STATUS" + exit 1 +fi if ! echo "$OUTPUT" | grep -qE 'v?[0-9]+\.[0-9]+|dev'; then echo "FAIL: unexpected version output" exit 1 @@ -496,7 +517,7 @@ fi echo "OK B4: STDIN input resolves, no deprecation warning" # B5: --args-file — JSON read from a file resolves; must NOT warn deprecated. -IM_ARGS_FILE=$(mktemp) +IM_ARGS_FILE=$(smoke_mktemp_file) echo "{\"project\":\"$PROJECT\"}" > "$IM_ARGS_FILE" if ! IM_AF=$(cli get_graph_schema --args-file "$IM_ARGS_FILE"); then echo "FAIL B5: get_graph_schema --args-file exited non-zero"; cat "$CLI_STDERR"; rm -f "$IM_ARGS_FILE"; exit 1 @@ -613,8 +634,8 @@ mcp_run() { wait "$pid" 2>/dev/null || true } -MCP_INPUT=$(mktemp) -MCP_OUTPUT=$(mktemp) +MCP_INPUT=$(smoke_mktemp_file) +MCP_OUTPUT=$(smoke_mktemp_file) cat > "$MCP_INPUT" << 'MCPEOF' {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke-test","version":"1.0"}}} {"jsonrpc":"2.0","method":"notifications/initialized"} @@ -666,7 +687,7 @@ rm -f "$MCP_INPUT" "$MCP_OUTPUT" # 5e: MCP tool call via JSON-RPC (index + search round-trip) echo "" echo "--- Phase 5e: MCP tool call round-trip ---" -MCP_TOOL_OUTPUT=$(mktemp) +MCP_TOOL_OUTPUT=$(smoke_mktemp_file) if ! python3 "$REPO_ROOT/scripts/test_mcp_interactive.py" \ "$BINARY" --scenario roundtrip --repo-path "$TMPDIR" \ @@ -695,8 +716,8 @@ echo "OK: MCP tool call round-trip (index + search) succeeded" # 5f: Content-Length framing (OpenCode compatibility) echo "" echo "--- Phase 5f: Content-Length framing ---" -MCP_CL_INPUT=$(mktemp) -MCP_CL_OUTPUT=$(mktemp) +MCP_CL_INPUT=$(smoke_mktemp_file) +MCP_CL_OUTPUT=$(smoke_mktemp_file) INIT_MSG='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"cl-test","version":"1.0"}}}' INIT_LEN=${#INIT_MSG} @@ -721,7 +742,7 @@ rm -f "$MCP_CL_INPUT" "$MCP_CL_OUTPUT" "$MCP_TOOL_OUTPUT" echo "" echo "=== Phase 6: CLI subcommands ===" -DRYRUN_HOME=$(mktemp -d) +DRYRUN_HOME=$(smoke_mktemp_dir) DRYRUN_CACHE="$DRYRUN_HOME/.cache/codebase-memory-mcp" mkdir -p "$DRYRUN_CACHE" \ "$DRYRUN_HOME/.local/bin" \ @@ -827,7 +848,7 @@ echo "OK: config set/get/reset round-trip" # Simulates the update command's Steps 3-6: extract, replace, verify. # Uses a copy of the test binary as the "downloaded" version. echo "--- Phase 6e: simulated binary replacement ---" -REPLACE_DIR=$(mktemp -d) +REPLACE_DIR=$(smoke_mktemp_dir) INSTALL_DIR="$REPLACE_DIR/install" mkdir -p "$INSTALL_DIR" @@ -881,7 +902,7 @@ echo "=== Phase 7: MCP advanced tool calls ===" # 7a: search_code via MCP (graph-augmented v2) echo "--- Phase 7a: search_code via MCP ---" -MCP_SC_OUTPUT=$(mktemp) +MCP_SC_OUTPUT=$(smoke_mktemp_file) if ! python3 "$REPO_ROOT/scripts/test_mcp_interactive.py" \ "$BINARY" --scenario advanced --repo-path "$TMPDIR" \ > "$MCP_SC_OUTPUT"; then @@ -914,7 +935,7 @@ echo "=== Phase 8: agent config install E2E ===" # Set up an isolated HOME. Directory-only agents get only the root required for # detection; CLI-detected agents use stubs below so install must create their # config parents from scratch. -FAKE_HOME=$(mktemp -d) +FAKE_HOME=$(smoke_mktemp_dir) mkdir -p "$FAKE_HOME/.claude" mkdir -p "$FAKE_HOME/.codex" mkdir -p "$FAKE_HOME/.gemini/antigravity-cli" @@ -2474,7 +2495,7 @@ echo "--- Phase 9b: adversarial install/uninstall tests ---" # Note: cbm_find_cli searches hardcoded paths (/usr/local/bin, /opt/homebrew/bin) # so PATH-based agents like aider may still be detected. We verify the install # completes without crash and prints "Detected agents:" line. -EMPTY_HOME=$(mktemp -d) +EMPTY_HOME=$(smoke_mktemp_dir) mkdir -p "$EMPTY_HOME/.local/bin" INSTALL_OUT=$(HOME="$EMPTY_HOME" "$BINARY" install -y 2>&1) || true if ! echo "$INSTALL_OUT" | grep -qi 'detected agents'; then @@ -2485,7 +2506,7 @@ echo "OK 9b-1: install with minimal agents exits cleanly" rm -rf "$EMPTY_HOME" # 9b-2: Install twice (idempotent) -IDEM_HOME=$(mktemp -d) +IDEM_HOME=$(smoke_mktemp_dir) mkdir -p "$IDEM_HOME/.claude" "$IDEM_HOME/.local/bin" copy_smoke_binary "$IDEM_HOME/.local/bin/codebase-memory-mcp" HOME="$IDEM_HOME" "$BINARY" install -y 2>&1 > /dev/null || true @@ -2508,14 +2529,14 @@ echo "OK 9b-2: double install is idempotent" rm -rf "$IDEM_HOME" # 9b-3: Uninstall without prior install -CLEAN_HOME=$(mktemp -d) +CLEAN_HOME=$(smoke_mktemp_dir) mkdir -p "$CLEAN_HOME/.claude" "$CLEAN_HOME/.local/bin" UNINSTALL_OUT=$(HOME="$CLEAN_HOME" "$BINARY" uninstall -y -n 2>&1) || true echo "OK 9b-3: uninstall without install doesn't crash" rm -rf "$CLEAN_HOME" # 9b-4: Install over corrupt JSON -CORRUPT_HOME=$(mktemp -d) +CORRUPT_HOME=$(smoke_mktemp_dir) mkdir -p "$CORRUPT_HOME/.claude" "$CORRUPT_HOME/.local/bin" copy_smoke_binary "$CORRUPT_HOME/.local/bin/codebase-memory-mcp" echo '{invalid json here' > "$CORRUPT_HOME/.claude.json" @@ -2525,7 +2546,7 @@ echo "OK 9b-4: install over corrupt JSON doesn't crash" rm -rf "$CORRUPT_HOME" # 9b-8: Double uninstall -DBL_HOME=$(mktemp -d) +DBL_HOME=$(smoke_mktemp_dir) mkdir -p "$DBL_HOME/.claude" "$DBL_HOME/.local/bin" copy_smoke_binary "$DBL_HOME/.local/bin/codebase-memory-mcp" HOME="$DBL_HOME" "$BINARY" install -y 2>&1 > /dev/null || true @@ -2560,7 +2581,7 @@ fi echo "" echo "=== Phase 10: binary security E2E ===" -SECURITY_DIR=$(mktemp -d) +SECURITY_DIR=$(smoke_mktemp_dir) SECURITY_BIN="$SECURITY_DIR/codebase-memory-mcp" copy_smoke_binary "$SECURITY_BIN" chmod 755 "$SECURITY_BIN" @@ -2655,7 +2676,7 @@ echo "" echo "=== Phase 11: process kill E2E ===" # Start MCP server in background -MCP_KILL_INPUT=$(mktemp) +MCP_KILL_INPUT=$(smoke_mktemp_file) echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"kill-test","version":"1.0"}}}' > "$MCP_KILL_INPUT" "$BINARY" < "$MCP_KILL_INPUT" > /dev/null 2>&1 & KILL_PID=$! @@ -2698,7 +2719,7 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then exit 1 fi fi - UPDATE_HOME=$(mktemp -d) + UPDATE_HOME=$(smoke_mktemp_dir) mkdir -p "$UPDATE_HOME/.claude" "$UPDATE_HOME/.local/bin" if [[ "$BINARY" == *.exe ]]; then copy_smoke_binary "$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe" @@ -2801,7 +2822,7 @@ sys.exit(0) else # Local mode: basic binary replacement test (no download) - UPDATE_DIR=$(mktemp -d) + UPDATE_DIR=$(smoke_mktemp_dir) mkdir -p "$UPDATE_DIR/install" copy_smoke_binary "$UPDATE_DIR/install/codebase-memory-mcp" chmod 755 "$UPDATE_DIR/install/codebase-memory-mcp" @@ -2829,7 +2850,7 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then echo "" echo "=== Phase 12: download + checksum + extraction E2E ===" -DL_DIR=$(mktemp -d) +DL_DIR=$(smoke_mktemp_dir) # Detect platform for archive name DL_OS=$(uname -s | tr 'A-Z' 'a-z') @@ -2977,8 +2998,8 @@ echo "=== Phase 13: install script E2E ===" if [ "$DL_OS" != "windows" ] && [ -f "$REPO_ROOT/install.sh" ]; then echo "--- Phase 13: install.sh E2E ---" - INSTALL_TEST_HOME=$(mktemp -d) - INSTALL_TEST_DIR=$(mktemp -d) + INSTALL_TEST_HOME=$(smoke_mktemp_dir) + INSTALL_TEST_DIR=$(smoke_mktemp_dir) mkdir -p "$INSTALL_TEST_HOME/.claude" mkdir -p "$INSTALL_TEST_HOME/.local/bin" @@ -3041,8 +3062,8 @@ if [ "$DL_OS" != "windows" ] && [ -f "$REPO_ROOT/install.sh" ]; then elif [ -f "$REPO_ROOT/install.ps1" ] && command -v powershell.exe &>/dev/null; then echo "--- Phase 13: install.ps1 E2E (Windows) ---" - PS1_TEST_HOME=$(mktemp -d) - PS1_TEST_DIR=$(mktemp -d) + PS1_TEST_HOME=$(smoke_mktemp_dir) + PS1_TEST_DIR=$(smoke_mktemp_dir) mkdir -p "$PS1_TEST_HOME/.claude" # Convert MSYS paths to Windows paths for PowerShell @@ -3100,7 +3121,7 @@ echo "" echo "=== Phase 15: UI HTTP server ===" UI_PORT=19876 -UI_INPUT=$(mktemp) +UI_INPUT=$(smoke_mktemp_file) "$BINARY" --port "$UI_PORT" < "$UI_INPUT" > /dev/null 2>&1 & UI_PID=$! sleep 1 diff --git a/src/daemon/bootstrap.c b/src/daemon/bootstrap.c index 794f0ebd2..8a74aca6b 100644 --- a/src/daemon/bootstrap.c +++ b/src/daemon/bootstrap.c @@ -29,6 +29,10 @@ #include #include #include +#ifdef __APPLE__ +#include +extern char **environ; +#endif #endif enum { @@ -713,6 +717,90 @@ static bool bootstrap_production_spawn(void *context, (void)CloseHandle(child.hProcess); return true; } +#elif defined(__APPLE__) +static bool bootstrap_darwin_spawn_state_init(posix_spawn_file_actions_t *actions, + posix_spawnattr_t *attributes) { + if (posix_spawn_file_actions_init(actions) != 0) { + return false; + } + bool actions_ready = + posix_spawn_file_actions_addopen(actions, STDIN_FILENO, "/dev/null", O_RDWR, 0) == 0 && + posix_spawn_file_actions_addopen(actions, STDOUT_FILENO, "/dev/null", O_RDWR, 0) == 0 && + posix_spawn_file_actions_addopen(actions, STDERR_FILENO, "/dev/null", O_RDWR, 0) == 0; + if (!actions_ready) { + (void)posix_spawn_file_actions_destroy(actions); + return false; + } + if (posix_spawnattr_init(attributes) != 0) { + (void)posix_spawn_file_actions_destroy(actions); + return false; + } + sigset_t empty; + (void)sigemptyset(&empty); + short flags = POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_CLOEXEC_DEFAULT; + if (posix_spawnattr_setsigmask(attributes, &empty) != 0 || + posix_spawnattr_setflags(attributes, flags) != 0) { + (void)posix_spawnattr_destroy(attributes); + (void)posix_spawn_file_actions_destroy(actions); + return false; + } + return true; +} + +static void bootstrap_daemon_grandchild(const cbm_daemon_bootstrap_launch_spec_t *spec, + const posix_spawn_file_actions_t *actions, + const posix_spawnattr_t *attributes) { + (void)umask(077); + pid_t daemon = 0; + int status = posix_spawn(&daemon, spec->executable_path, actions, attributes, + (char *const *)spec->argv, environ); + _exit(status == 0 ? 0 : 127); +} + +static bool bootstrap_production_spawn(void *context, + const cbm_daemon_bootstrap_launch_spec_t *spec) { + (void)context; + if (!spec || !spec->detached || spec->inherit_standard_handles || spec->use_shell) { + return false; + } + posix_spawn_file_actions_t actions; + posix_spawnattr_t attributes; + if (!bootstrap_darwin_spawn_state_init(&actions, &attributes)) { + return false; + } + pid_t first = fork(); + if (first < 0) { + (void)posix_spawnattr_destroy(&attributes); + (void)posix_spawn_file_actions_destroy(&actions); + return false; + } + if (first == 0) { + if (setsid() < 0) { + _exit(127); + } + pid_t grandchild = fork(); + if (grandchild < 0) { + _exit(127); + } + if (grandchild > 0) { + _exit(0); + } + /* Darwin commonly reports OPEN_MAX near one million. Iterating every + * possible descriptor after fork can consume the entire cold-start + * budget. CLOEXEC_DEFAULT closes the actual inherited set in the + * kernel while the double fork still provides daemon detachment. */ + bootstrap_daemon_grandchild(spec, &actions, &attributes); + } + + int status = 0; + pid_t waited; + do { + waited = waitpid(first, &status, 0); + } while (waited < 0 && errno == EINTR); + (void)posix_spawnattr_destroy(&attributes); + (void)posix_spawn_file_actions_destroy(&actions); + return waited == first && WIFEXITED(status) && WEXITSTATUS(status) == 0; +} #else static void bootstrap_child_close_fds(void) { long open_max = sysconf(_SC_OPEN_MAX); diff --git a/src/foundation/lock_registry.c b/src/foundation/lock_registry.c index 1f70f1cba..11439a0d2 100644 --- a/src/foundation/lock_registry.c +++ b/src/foundation/lock_registry.c @@ -198,11 +198,13 @@ static void lock_registry_notify(cbm_lock_registry_t *registry, cbm_private_file return; } cbm_lock_registry_stage_hook_fn hook = registry->stage_hook; + if (!hook) { + lock_registry_unlock(registry); + return; + } void *context = registry->stage_context; lock_registry_unlock(registry); - if (hook) { - hook(context, mode, stage); - } + hook(context, mode, stage); } static lock_registry_entry_t *lock_registry_find_entry(cbm_lock_registry_t *registry, diff --git a/tests/test_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh index 5d69b511d..d85e21978 100644 --- a/tests/test_windows_bundle_contract.sh +++ b/tests/test_windows_bundle_contract.sh @@ -137,15 +137,16 @@ for relative, patterns in portable_cache_contracts.items(): # Portable package shims keep an immutable, exact-version launcher/payload pair. # The launcher resolves its adjacent payload for every native command. -# Concurrent first-run contenders publish launcher first and payload -# last; payload is therefore the readiness signal. They never pre-delete or -# roll back a destination, and may accept a collision only after proving that -# the winner is byte-identical to the authenticated staged source and runnable. +# Concurrent first-run contenders publish launcher first and payload last; +# payload is therefore the readiness signal. npm and Go serialize pair repair, +# preserve any complete runnable winner, and roll back only bytes proven to +# belong to their own transaction. PyPI uses identical-byte collision checks. immutable_pair_cache_contracts = { "pkg/npm/install.js": ( "const cacheNames = platform === 'windows'", - "const publishNames = platform === 'windows'", - "filesEqualSha256", + "const lock = acquireWindowsPairLock(destDir)", + "publishedDigests.set(name, stagedDigests.get(name))", + "if (digest && pathMatchesDigest(target, digest))", ), "pkg/pypi/src/codebase_memory_mcp/_cli.py": ( "cache_names = extraction_names", @@ -154,7 +155,9 @@ immutable_pair_cache_contracts = { ), "pkg/go/cmd/codebase-memory-mcp/main.go": ( "installWindowsPairAtomically(tmp, filepath.Dir(dest))", - "filesEqualSHA256", + "lock, err := acquireWindowsPairLock(dstDir)", + "publishedDigests[name] = stagedDigests[name]", + "if ok && pathMatchesSHA256(target, digest)", ), } for relative, needles in immutable_pair_cache_contracts.items(): @@ -172,14 +175,12 @@ for relative, needles in immutable_pair_cache_contracts.items(): npm_installer = read("pkg/npm/install.js") require( - re.search( - r"const publishNames = platform === 'windows'\s*\?\s*" - r"\[WINDOWS_LAUNCHER_NAME, WINDOWS_PAYLOAD_NAME\]", - npm_installer, - ) is not None - and "filesEqualSha256(stagedPaths.get(name), destination)" in npm_installer - and "fs.unlinkSync(destination)" not in npm_installer, - "npm must publish launcher then payload and only accept an identical collision", + "for (const name of [WINDOWS_LAUNCHER_NAME, WINDOWS_PAYLOAD_NAME])" in npm_installer + and "windowsPairReady(destDir, verifier)" in npm_installer + and "publishedDigests.set(name, stagedDigests.get(name))" in npm_installer + and "if (digest && pathMatchesDigest(target, digest))" in npm_installer, + "npm must publish launcher then payload, preserve a coherent winner, and " + "roll back only transaction-owned bytes", ) python_wrapper = read("pkg/pypi/src/codebase_memory_mcp/_cli.py") require( @@ -194,9 +195,11 @@ require( go_wrapper = read("pkg/go/cmd/codebase-memory-mcp/main.go") require( "names := []string{windowsLauncherName, windowsPayloadName}" in go_wrapper - and "filesEqualSHA256(staged[name], target)" in go_wrapper - and "os.Remove(target)" not in go_wrapper, - "Go must publish launcher then payload and only accept an identical collision", + and "windowsPairReady(dstDir, verifier)" in go_wrapper + and "publishedDigests[name] = stagedDigests[name]" in go_wrapper + and "if ok && pathMatchesSHA256(target, digest)" in go_wrapper, + "Go must publish launcher then payload, preserve a coherent winner, and " + "roll back only transaction-owned bytes", ) npm_shim = read("pkg/npm/bin.js") @@ -214,7 +217,8 @@ require( ) require( "verifyCandidate(extractedPaths.get(WINDOWS_LAUNCHER_NAME))" in npm_installer - and "verifyCandidate(path.join(BIN_DIR, WINDOWS_LAUNCHER_NAME))" in npm_installer, + and "if (platform === 'windows' && windowsPairReady(BIN_DIR)) return;" in npm_installer + and "verifier(launcher)" in npm_installer, "npm must validate extracted and cached Windows pairs through the launcher", ) require( @@ -429,11 +433,28 @@ require( any("zip" in block and launcher in block and payload in block for block in smoke_blocks), "Windows artifact-server smoke archive must contain launcher and payload", ) +windows_release_smoke_blocks = [ + re.sub(r"\s+", " ", re.sub(r"\\\s*\n\s*", " ", block)).strip() + for block in smoke_blocks + if "scripts/smoke-test.sh ./codebase-memory-mcp.exe" in block +] +require( + len(windows_release_smoke_blocks) == 1 + and 'SMOKE_TEMP_ROOT="$(cygpath -u "$RUNNER_TEMP")" ' + 'scripts/smoke-test.sh ./codebase-memory-mcp.exe' in windows_release_smoke_blocks[0], + "Windows release smoke must keep every launcher fixture under runner-private temp", +) # Native update transport remains HTTPS-only in production. Release smoke may # use an explicit file:// CBM_DOWNLOAD_URL only for its local fixture, while the # installer and raw-curl phases continue to exercise the loopback HTTP server. smoke_script = read("scripts/smoke-test.sh") +require( + "smoke_mktemp_file" in smoke_script + and "smoke_mktemp_dir" in smoke_script + and re.search(r"\$\(\s*mktemp(?:\s+-d)?(?:\s|\))", smoke_script) is None, + "smoke-test.sh must route every temporary fixture through its private-root helpers", +) require( 'SMOKE_UPDATE_FIXTURE_DIR' in smoke_script and 'UPDATE_DOWNLOAD_URL="file://$UPDATE_FIXTURE_DIR"' in smoke_script @@ -465,7 +486,7 @@ protocol_match = re.search( file_override = file_override_match.group(0) if file_override_match else "" protocol = protocol_match.group(0) if protocol_match else "" require( - 'cbm_safe_getenv(\n "CBM_DOWNLOAD_URL"' in file_override + re.search(r'cbm_safe_getenv\s*\(\s*"CBM_DOWNLOAD_URL"', file_override) is not None and 'strncmp(override, "file://", 7)' in file_override and "strncmp(url, override, override_length)" in file_override, "file:// downloads must remain restricted to the explicit test override", @@ -511,19 +532,21 @@ 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 = ( - 'SMOKE_DIR="$(mktemp -d)"', + 'SMOKE_ROOT="$(cygpath -u "$RUNNER_TEMP")"', + 'SMOKE_DIR="$(mktemp -d "$SMOKE_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"', + 'SMOKE_TEMP_ROOT="$SMOKE_ROOT" ' '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 " - "and invoke the canonical launcher", + "in the runner-private native temp directory and invoke the canonical launcher", ) require( all(left < right for left, right in zip(positions, positions[1:])),