diff --git a/scripts/setup-windows.ps1 b/scripts/setup-windows.ps1 index 592a9cd49..4a836f95e 100644 --- a/scripts/setup-windows.ps1 +++ b/scripts/setup-windows.ps1 @@ -1,5 +1,5 @@ # codebase-memory-mcp setup script (Windows) -# Default: download pre-built native Windows binary +# Default: install the pre-built native Windows binary through install.ps1 # -FromSource: build from source inside WSL (requires Go + gcc in WSL) param( @@ -123,7 +123,7 @@ if ($Help) { Write-Host "" Write-Host "Usage: .\setup-windows.ps1 [-FromSource] [-Help]" Write-Host "" - Write-Host " Default: Download pre-built Windows binary" + Write-Host " Default: Download the pre-built Windows binary through install.ps1" Write-Host " -FromSource: Build from source inside WSL (requires Go 1.23+ and gcc in WSL)" Write-Host "" exit 0 @@ -225,7 +225,7 @@ if ($FromSource) { exit 1 } - # Configure — WSL binary needs wsl.exe wrapper + # Configure -- WSL binary needs wsl.exe wrapper $mcpConfig = [ordered]@{ type = "stdio" command = "wsl.exe" @@ -243,41 +243,105 @@ if ($FromSource) { Write-Host " wsl.exe -- rm -rf ~/.cache/codebase-memory-mcp/" } else { - # --- Download pre-built native Windows binary --- - Write-Host "Fetching latest release..." -ForegroundColor White + # --- Download + install through install.ps1 --- + # + # install.ps1 is the one implementation of "fetch a release and install + # it": it downloads checksums.txt next to the archive, verifies the + # archive's SHA-256 against it, validates the zip layout and only then + # runs the binary's own `install`. This script used to carry a second + # copy of that download, which did not keep up with the installer. It + # now fetches install.ps1 from the same origin and branch it is itself + # served from and hands over to it, so there is exactly one install path. + # + # CBM_DOWNLOAD_URL (the installers' download-base override, for local + # testing) also moves the installer fetch: install.ps1 is then taken from + # "$env:CBM_DOWNLOAD_URL/install.ps1". + $installerUrl = "https://raw.githubusercontent.com/$Repo/main/install.ps1" + if ($env:CBM_DOWNLOAD_URL) { + $installerUrl = $env:CBM_DOWNLOAD_URL.TrimEnd('/') + "/install.ps1" + } - $releaseUrl = "https://api.github.com/repos/$Repo/releases/latest" - $release = Invoke-RestMethod -Uri $releaseUrl -Headers @{ "User-Agent" = "codebase-memory-mcp-setup" } - $tag = $release.tag_name - - if (-not $tag) { - Write-Fail "Could not determine latest release." - Write-Host " Check: https://github.com/$Repo/releases" + # Same transport rule as install.ps1: HTTPS, or plain HTTP for a loopback + # authority only (the local test fixture). No redirects are followed: both + # origins serve the installer directly. + try { $installerUri = [Uri]$installerUrl } catch { $installerUri = $null } + $loopbackHttp = ( + $installerUri -and $installerUri.IsAbsoluteUri -and + $installerUri.Scheme -eq "http" -and $installerUri.IsLoopback -and + [string]::IsNullOrEmpty($installerUri.UserInfo) + ) + if (-not $installerUri -or -not $installerUri.IsAbsoluteUri -or + ($installerUri.Scheme -ne "https" -and -not $loopbackHttp) -or + -not [string]::IsNullOrEmpty($installerUri.UserInfo)) { + Write-Fail "Refusing non-HTTPS installer URL: $installerUrl" exit 1 } - Write-Ok "Latest release: $tag" - $asset = "codebase-memory-mcp-windows-amd64.zip" - $downloadUrl = "https://github.com/$Repo/releases/download/$tag/$asset" + # TLS 1.2+ for the fetch (older Windows PowerShell defaults to TLS 1.0, + # which GitHub rejects). TLS 1.3 only where schannel can negotiate it; see + # the note in install.ps1. + $protocols = [Net.SecurityProtocolType]::Tls12 + if ([Environment]::OSVersion.Version.Build -ge 20348 -and + ([enum]::GetNames([Net.SecurityProtocolType]) -contains 'Tls13')) { + $protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13 + } + [Net.ServicePointManager]::SecurityProtocol = $protocols - Write-Host "Downloading $asset..." -ForegroundColor White - - # Create install directory - if (-not (Test-Path $InstallDir)) { - New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + # A fresh staging directory of its own with an owner-only DACL, the + # way install.ps1 stages its own download. The ACL is best effort: a + # filesystem that cannot carry one must not fail the install. + $stageDir = Join-Path ([System.IO.Path]::GetTempPath()) "cbm-setup-$(Get-Random)" + New-Item -ItemType Directory -Path $stageDir -Force | Out-Null + try { + $stageAcl = New-Object System.Security.AccessControl.DirectorySecurity + $stageAcl.SetAccessRuleProtection($true, $false) + $stageOwner = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User + $stageAcl.SetOwner($stageOwner) + $stageAcl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule( + $stageOwner, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) + Set-Acl -Path $stageDir -AclObject $stageAcl -ErrorAction Stop + } catch { + Write-Host " note: could not harden the staging directory ACL: $($_.Exception.Message)" } - $tmpZip = Join-Path $env:TEMP $asset - Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpZip -UseBasicParsing + $installer = Join-Path $stageDir "install.ps1" + $failure = $null + try { + Write-Host "Fetching install.ps1..." -ForegroundColor White + Invoke-WebRequest -Uri $installerUrl -OutFile $installer -UseBasicParsing -MaximumRedirection 0 + if (-not (Test-Path -LiteralPath $installer -PathType Leaf) -or + (Get-Item -LiteralPath $installer).Length -eq 0) { + throw "fetched an empty install.ps1 from $installerUrl" + } + Write-Ok "install.ps1 fetched" - # Extract - Expand-Archive -Path $tmpZip -DestinationPath $InstallDir -Force - Remove-Item $tmpZip -Force + Write-Host "" + Write-Host "Installing through install.ps1..." -ForegroundColor White + # The installer configures nothing (--skip-config): agent configuration + # stays this script's interactive step below. It runs in a child host + # of the same PowerShell so its exit code is unambiguous and its global + # settings do not leak into this session. The pre-seeded exit code + # keeps a child that fails to start from reading as success. + $hostExe = (Get-Process -Id $PID).Path + $global:LASTEXITCODE = 1 + & $hostExe -NoProfile -ExecutionPolicy Bypass -File $installer "--dir=$InstallDir" --skip-config + if ($LASTEXITCODE -ne 0) { + throw "install.ps1 exited with $LASTEXITCODE" + } + } catch { + $failure = "$_" + } finally { + Remove-Item -Recurse -Force $stageDir -ErrorAction SilentlyContinue + } + if ($failure) { + Write-Fail "Installation through install.ps1 failed: $failure" + exit 1 + } $binaryPath = Join-Path $InstallDir "$BinaryName.exe" if (-not (Test-Path $binaryPath)) { - Write-Fail "Binary not found at $binaryPath after extraction" + Write-Fail "Binary not found at $binaryPath after installation" exit 1 } Write-Ok "Installed to $binaryPath" diff --git a/scripts/setup.sh b/scripts/setup.sh index 81a0508d3..ee1a4d424 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -2,14 +2,14 @@ set -euo pipefail # codebase-memory-mcp setup script (macOS + Linux) -# Default: download pre-built binary from GitHub Release +# Default: install the latest pre-built binary through install.sh # --from-source: build from source (requires Go + C compiler) REPO="DeusData/codebase-memory-mcp" INSTALL_DIR="$HOME/.local/bin" BINARY_NAME="codebase-memory-mcp" SOURCE_DIR="$HOME/.local/share/codebase-memory-mcp" -CLEANUP_DIR="" # set by download_binary for EXIT trap +CLEANUP_DIR="" # set by install_release for EXIT trap # --- Colors --- @@ -43,7 +43,7 @@ for arg in "$@"; do --help|-h) echo "Usage: $0 [--from-source]" echo "" - echo " Default: Download pre-built binary from GitHub Release" + echo " Default: Download the pre-built binary through install.sh" echo " --from-source: Clone and build from source (requires Go 1.23+ and a C compiler)" exit 0 ;; @@ -51,36 +51,6 @@ for arg in "$@"; do esac done -# --- Platform detection --- - -detect_platform() { - local os arch - os=$(uname -s) - arch=$(uname -m) - - case "$os" in - Darwin) os="darwin" ;; - Linux) os="linux" ;; - *) die "Unsupported OS: $os. Use WSL2 on Windows." ;; - esac - - case "$arch" in - arm64|aarch64) arch="arm64" ;; - x86_64|amd64) - # On macOS, uname -m returns x86_64 under Rosetta even on Apple Silicon. - # Check the actual hardware to pick the right binary. - if [ "$os" = "darwin" ] && sysctl -n hw.optional.arm64 2>/dev/null | grep -q '1'; then - arch="arm64" - else - arch="amd64" - fi - ;; - *) die "Unsupported architecture: $arch" ;; - esac - - echo "${os}-${arch}" -} - # --- Prerequisite checks --- check_download_tool() { @@ -133,46 +103,74 @@ check_git() { ok "Git found" } -# --- Download binary --- +# --- Download + install through install.sh --- +# +# install.sh is the one implementation of "fetch a release and install it": it +# downloads checksums.txt next to the archive, verifies the archive's SHA-256 +# against it, checks the archive layout, and only then runs the binary's own +# `install`. This script used to carry a second copy of that download, +# which did not keep up with the installer. It now fetches install.sh from the +# same origin and branch it is itself served from and hands over to it, so +# there is exactly one install path. +# +# CBM_DOWNLOAD_URL (the installers' download-base override, for local testing) +# also moves the installer fetch: install.sh is then taken from +# "$CBM_DOWNLOAD_URL/install.sh". -fetch() { - local url="$1" tool="$2" - if [ "$tool" = "curl" ]; then - curl -fsSL "$url" +INSTALLER_URL="https://raw.githubusercontent.com/${REPO}/main/install.sh" +if [ -n "${CBM_DOWNLOAD_URL:-}" ]; then + INSTALLER_URL="${CBM_DOWNLOAD_URL%/}/install.sh" +fi + +# Same transport rule as install.sh: HTTPS everywhere; plain HTTP only for an +# exact loopback authority (the local test fixture), with redirects disabled +# there so a fixture cannot bounce the fetch to the network. +is_loopback_http_url() { + [[ "$1" =~ ^http://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?([/?\#].*)?$ ]] +} + +fetch_installer() { + local url="$1" destination="$2" tool="$3" + if is_loopback_http_url "$url"; then + if [ "$tool" = "curl" ]; then + curl -fsS --noproxy '*' --proto '=http' -o "$destination" "$url" + else + wget -q --no-proxy --max-redirect=0 -O "$destination" "$url" + fi + elif [[ "$url" == https://* ]]; then + if [ "$tool" = "curl" ]; then + curl -fsSL --max-redirs 5 --proto '=https' --proto-redir '=https' \ + -o "$destination" "$url" + else + wget -q --https-only --max-redirect=5 -O "$destination" "$url" + fi else - wget -qO- "$url" + die "Refusing non-HTTPS installer URL: $url" fi } -download_binary() { - local platform="$1" tool="$2" +install_release() { + local tool="$1" echo "" - echo "${BOLD}Fetching latest release...${RESET}" - local tag - tag=$(fetch "https://api.github.com/repos/${REPO}/releases/latest" "$tool" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"//;s/".*//') - - if [ -z "$tag" ]; then - die "Could not determine latest release. Check https://github.com/${REPO}/releases" - fi - ok "Latest release: $tag" - - local asset="codebase-memory-mcp-${platform}.tar.gz" - local url="https://github.com/${REPO}/releases/download/${tag}/${asset}" - - echo "${BOLD}Downloading ${asset}...${RESET}" + echo "${BOLD}Fetching install.sh...${RESET}" CLEANUP_DIR=$(mktemp -d) trap 'rm -rf "$CLEANUP_DIR"' EXIT - local tmpdir="$CLEANUP_DIR" + local installer="$CLEANUP_DIR/install.sh" - fetch "$url" "$tool" > "${tmpdir}/${asset}" - tar -xzf "${tmpdir}/${asset}" -C "$tmpdir" + fetch_installer "$INSTALLER_URL" "$installer" "$tool" || + die "Could not fetch install.sh from ${INSTALLER_URL}" + [ -s "$installer" ] || die "Fetched an empty install.sh from ${INSTALLER_URL}" + ok "install.sh fetched" - mkdir -p "$INSTALL_DIR" - mv "${tmpdir}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" - chmod +x "${INSTALL_DIR}/${BINARY_NAME}" - - ok "Installed to ${INSTALL_DIR}/${BINARY_NAME}" + echo "" + echo "${BOLD}Installing through install.sh...${RESET}" + # The installer configures nothing (--skip-config): agent configuration + # stays this script's interactive step below. stdin is detached so that, + # when this script itself arrives through `curl | bash`, nothing the + # installer runs can read the rest of this script as its input. + bash "$installer" --dir "$INSTALL_DIR" --skip-config &2 + exit 1 +} + +# ── Static contract: scripts/setup.sh ────────────────────────────────────── +# +# These gate the functional leg on purpose: a setup.sh that ignores +# CBM_DOWNLOAD_URL cannot be pointed at the fixture and would reach for the +# real release. The contract refuses to run that script at all. +grep -q 'CBM_DOWNLOAD_URL' "$SETUP_SH" || + fail "scripts/setup.sh must honour CBM_DOWNLOAD_URL (the installers' download-base override)" +grep -q 'install\.sh' "$SETUP_SH" || + fail "scripts/setup.sh must install through install.sh" +if grep -Eq 'tar +-[a-zA-Z]*x' "$SETUP_SH"; then + fail "scripts/setup.sh must not unpack the release archive itself" +fi +if grep -Eq 'api\.github\.com|releases/download' "$SETUP_SH"; then + fail "scripts/setup.sh must not resolve or download the release itself (install.sh owns that)" +fi + +# ── Static contract: scripts/setup-windows.ps1 ───────────────────────────── +if LC_ALL=C grep -n $'[^ -~\t]' "$SETUP_PS1"; then + fail "scripts/setup-windows.ps1 must be pure ASCII (PS 5.1 reads BOM-less files as ANSI)" +fi +if grep -q $'\r' "$SETUP_PS1"; then + fail "scripts/setup-windows.ps1 must use LF line endings in the repository" +fi +grep -q 'install\.ps1' "$SETUP_PS1" || + fail "scripts/setup-windows.ps1 must install through install.ps1" +grep -q 'CBM_DOWNLOAD_URL' "$SETUP_PS1" || + fail "scripts/setup-windows.ps1 must honour CBM_DOWNLOAD_URL (the installers' download-base override)" +if grep -q 'Expand-Archive' "$SETUP_PS1"; then + fail "scripts/setup-windows.ps1 must not unpack the release archive itself" +fi +if grep -Eq 'api\.github\.com|releases/download' "$SETUP_PS1"; then + fail "scripts/setup-windows.ps1 must not resolve or download the release itself (install.ps1 owns that)" +fi +if grep -q 'env:TEMP' "$SETUP_PS1"; then + fail "scripts/setup-windows.ps1 must stage in a fresh directory of its own, not directly under %TEMP%" +fi +grep -q 'GetTempPath' "$SETUP_PS1" && grep -q 'Get-Random' "$SETUP_PS1" || + fail "scripts/setup-windows.ps1 must stage in a freshly created private directory (as install.ps1 does)" + +# No PowerShell parser is available on every host; a bracket balance over the +# file with comments and quoted strings removed still catches a truncated or +# mis-pasted script before the Windows leg does. +python3 - "$SETUP_PS1" <<'PY' || fail "scripts/setup-windows.ps1 has unbalanced brackets" +import re +import sys + +text = open(sys.argv[1], encoding="ascii").read() +text = re.sub(r"<#.*?#>", "", text, flags=re.S) +text = re.sub(r"#[^\n]*", "", text) +text = re.sub(r"'(?:[^']|'')*'", "''", text) +text = re.sub(r'"(?:[^"\\`]|`.|\\.)*"', '""', text) +for open_ch, close_ch in (("{", "}"), ("(", ")"), ("[", "]")): + if text.count(open_ch) != text.count(close_ch): + print(f"unbalanced {open_ch}{close_ch}: {text.count(open_ch)} vs {text.count(close_ch)}") + sys.exit(1) +PY + +echo "OK: static contract (setup.sh delegates to install.sh, setup-windows.ps1 to install.ps1)" + +# ── Functional leg: setup.sh against a loopback fixture release ──────────── +case "$(uname -s)" in + Darwin) OS=darwin ;; + Linux) OS=linux ;; + *) + # WHY: setup.sh is the macOS/Linux one-liner; on a Windows host it would + # select no archive at all. The Windows setup script's functional + # coverage is the VM leg that serves a fixture to install.ps1 (the + # script it delegates to). Tried: running the POSIX leg under MSYS -- + # install.sh then targets a windows-*.zip, which is not what setup.sh + # ever installs, so the run would prove nothing about this contract. + echo "SKIP: functional leg needs a macOS/Linux host ($(uname -s)); static contract passed" + exit 0 + ;; +esac +case "$(uname -m)" in + arm64 | aarch64) ARCH=arm64 ;; + x86_64 | amd64) + # Match install.sh: a Rosetta shell reports x86_64 on Apple Silicon. + if [ "$OS" = "darwin" ] && + sysctl -n machdep.cpu.brand_string 2>/dev/null | grep -qi apple; then + ARCH=arm64 + else + ARCH=amd64 + fi + ;; + *) fail "unsupported host architecture $(uname -m)" ;; +esac +PORTABLE="" +[ "$OS" = "linux" ] && PORTABLE="-portable" +ARCHIVE="codebase-memory-mcp-${OS}-${ARCH}${PORTABLE}.tar.gz" + +for tool in python3 curl tar shasum; do + command -v "$tool" >/dev/null 2>&1 || command -v sha256sum >/dev/null 2>&1 || + fail "functional leg needs $tool" +done + +WORK=$(mktemp -d "${TMPDIR:-/tmp}/cbm-setup-contract-XXXXXX") +chmod 700 "$WORK" +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" +} +trap cleanup EXIT + +FIXTURE="$WORK/fixture" +STAGE="$WORK/stage" +mkdir -p "$FIXTURE/good" "$FIXTURE/bad" "$FIXTURE/none" "$STAGE" "$WORK/tmp" \ + "$WORK/cache" "$WORK/runtime" "$WORK/trace" "$WORK/cwd" + +# The fixture "binary": a stub that answers --version and performs the minimal +# `install` the installer asks of the product (copy itself into --dir). It +# records every invocation so the failure cases can prove it never ran. +cat > "$STAGE/codebase-memory-mcp" <<'STUB' +#!/bin/sh +set -eu +if [ -n "${CBM_SETUP_STUB_TRACE:-}" ]; then + printf '%s\n' "$*" >> "$CBM_SETUP_STUB_TRACE" +fi +case "${1:-}" in + --version) + echo "codebase-memory-mcp setup-contract-stub 0.0.0" + ;; + install) + dir="" + for arg in "$@"; do + case "$arg" in --dir=*) dir="${arg#--dir=}" ;; esac + done + [ -n "$dir" ] || { echo "stub: install needs --dir=" >&2; exit 2; } + mkdir -p "$dir" + cp "$0" "$dir/codebase-memory-mcp" + chmod 755 "$dir/codebase-memory-mcp" + ;; + *) + echo "stub: unexpected invocation: $*" >&2 + exit 2 + ;; +esac +STUB +chmod 755 "$STAGE/codebase-memory-mcp" +cp "$ROOT/LICENSE" "$ROOT/install.sh" "$STAGE/" +printf 'fixture notices\n' > "$STAGE/THIRD_PARTY_NOTICES.md" + +# Member set and order mirror scripts/package-release.sh; install.sh refuses +# any other inventory. +tar -czf "$STAGE/$ARCHIVE" -C "$STAGE" \ + codebase-memory-mcp LICENSE install.sh THIRD_PARTY_NOTICES.md +if command -v sha256sum >/dev/null 2>&1; then + DIGEST=$(sha256sum "$STAGE/$ARCHIVE" | awk '{print $1}') +else + DIGEST=$(shasum -a 256 "$STAGE/$ARCHIVE" | awk '{print $1}') +fi + +for variant in good bad none; do + cp "$STAGE/$ARCHIVE" "$ROOT/install.sh" "$FIXTURE/$variant/" +done +printf '%s %s\n' "$DIGEST" "$ARCHIVE" > "$FIXTURE/good/checksums.txt" +WRONG_DIGEST=$(printf '%064d' 0) +printf '%s %s\n' "$WRONG_DIGEST" "$ARCHIVE" > "$FIXTURE/bad/checksums.txt" +# "none" ships no checksums.txt at all. + +# The server owns the ephemeral bind and prints its banner only after the +# port is published; reading that banner from a FIFO is the readiness signal +# (no polling, no timer). Request logs go to a file so nothing is written to +# the FIFO after the banner. +mkfifo "$WORK/server.fifo" +python3 "$ROOT/scripts/smoke-fixture-server.py" \ + --directory "$FIXTURE" --port-file "$WORK/port" \ + > "$WORK/server.fifo" 2> "$WORK/server.log" & +SERVER_PID=$! +IFS= read -r SERVER_BANNER < "$WORK/server.fifo" || + fail "fixture server exited before publishing its port" +PORT=$(tr -d '[:space:]' < "$WORK/port") +case "$PORT" in + '' | *[!0-9]*) fail "fixture server published no usable port ($SERVER_BANNER)" ;; +esac + +# Every tool setup.sh, install.sh and the stub may need, EXCEPT a hash tool. +# The no-hash-tool variant runs with PATH restricted to this directory. +mkdir -p "$WORK/tools" +for tool in bash sh env curl wget mktemp uname sysctl grep wc tr awk tar \ + xattr codesign chmod cp mv rm mkdir cat sed head dirname tput; do + if resolved=$(command -v "$tool" 2>/dev/null) && [ -n "$resolved" ]; then + ln -s "$resolved" "$WORK/tools/$tool" + fi +done + +# run_setup