From 08489ae1d37add9e8bba5163d5cc9ba5a7e9a564 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Tue, 14 Jul 2026 17:44:00 -0400 Subject: [PATCH 1/3] fix: prove native installer outcomes before release --- .github/workflows/release.yaml | 103 +++++- CHANGELOG.md | 12 + .../src/hypervisor/fuse/inode_table.rs | 2 +- crates/capsem-core/src/mcp/server_manager.rs | 4 +- docs/public/install.sh | 241 +++++++++++-- justfile | 9 +- scripts/check-public-binary-release.py | 29 +- scripts/deb-postinst.sh | 26 +- scripts/generate-host-binary-sbom.py | 34 +- scripts/local-release-glowup.py | 3 + scripts/prove-installed-shell.py | 184 ++++++++++ site/public/install.sh | 123 ++++++- skills/release-process/SKILL.md | 53 +++ .../test_install_asset_payload.py | 11 + .../capsem-build-chain/test_release_index.py | 46 ++- ...est_materialize_graph_profile_artifacts.py | 2 +- .../test_public_binary_release_gate.py | 123 ++++--- tests/test_install_sh.py | 335 ++++++++++++++++-- tests/test_prove_installed_shell.py | 102 ++++++ tests/test_release_doctor_contract.py | 302 ++++++++++------ 20 files changed, 1484 insertions(+), 260 deletions(-) create mode 100755 scripts/prove-installed-shell.py create mode 100644 tests/test_prove_installed_shell.py diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d5f50d94a..032b0e731 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -353,9 +353,20 @@ jobs: set -euo pipefail VERSION="${RELEASE_TAG#v}" sudo /usr/sbin/installer -pkg "packages/Capsem-$VERSION.pkg" -target / + test -d "/Applications/Capsem.app" + test -x "/Applications/Capsem.app/Contents/MacOS/capsem-app" test -x "$HOME/.capsem/bin/capsem" "$HOME/.capsem/bin/capsem" --version | grep -F "$VERSION" - "$HOME/.capsem/bin/capsem" status + for bin in capsem capsem-admin capsem-gateway capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-process capsem-service capsem-tray capsem-tui; do + test -x "$HOME/.capsem/bin/$bin" + "$HOME/.capsem/bin/$bin" --version | grep -F "$VERSION" + done + "$HOME/.capsem/bin/capsem" status | tee /tmp/capsem-status.txt + grep -F "Installed: true" /tmp/capsem-status.txt + grep -F "Running: true" /tmp/capsem-status.txt + grep -F "Service: ok" /tmp/capsem-status.txt + grep -F "Gateway: ok" /tmp/capsem-status.txt + pgrep -x capsem-tray - name: Collect macOS artifacts run: | @@ -474,6 +485,16 @@ jobs: echo "=== Verify companion binaries in deb ===" dpkg-deb --contents target/release/bundle/deb/*.deb | grep -E "capsem-service|capsem-tui|capsem-mcp-aggregator|capsem-mcp-builtin|capsem-gateway|capsem-tray|capsem-admin" + - name: Enable KVM for exact-package VM proof + if: matrix.arch == 'x86_64' + run: | + sudo modprobe kvm + sudo chmod 0666 /dev/kvm + sudo modprobe vhost_vsock + sudo chmod 0666 /dev/vhost-vsock + test -r /dev/kvm -a -w /dev/kvm + test -r /dev/vhost-vsock -a -w /dev/vhost-vsock + - name: Install exact release deb run: | set -euo pipefail @@ -481,7 +502,28 @@ jobs: sudo dpkg -i target/release/bundle/deb/*.deb || sudo apt-get install -f -y test -x /usr/bin/capsem /usr/bin/capsem --version | grep -F "$VERSION" - /usr/bin/capsem status + dpkg-query -W -f='${Version}' capsem | grep -Fx "$VERSION" + for bin in capsem capsem-admin capsem-app capsem-gateway capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-process capsem-service capsem-tray capsem-tui; do + test -x "/usr/bin/$bin" + if [ "$bin" != "capsem-app" ]; then + "/usr/bin/$bin" --version | grep -F "$VERSION" + fi + done + /usr/bin/capsem status | tee /tmp/capsem-status.txt + grep -F "Installed: true" /tmp/capsem-status.txt + grep -F "Running: true" /tmp/capsem-status.txt + grep -F "Service: ok" /tmp/capsem-status.txt + grep -F "Gateway: ok" /tmp/capsem-status.txt + + - name: Prove exact-package guest shell execution + if: matrix.arch == 'x86_64' + run: | + set -euo pipefail + python3 scripts/prove-installed-shell.py \ + --capsem /usr/bin/capsem \ + --marker CAPSEM_EXACT_PACKAGE_SHELL_OK \ + --session-name release-exact-shell-x86_64 \ + --timeout 300 - name: Collect Linux artifacts run: | @@ -877,10 +919,9 @@ jobs: ") [ "$fail" = 0 ] || { echo '::error::one or more asset URLs are unreachable or hash-mismatched'; exit 1; } - - name: Glow-up verify public install, channel switch, and upgrade + - name: Verify public release packages and installer contract run: | set -euo pipefail - echo "Docker smoke: curl -fsSL https://capsem.org/install.sh | sh" uv run python3 scripts/check-public-binary-release.py \ --channel "$RELEASE_CHANNEL" \ --manifest-url "$ASSET_MANIFEST_URL" \ @@ -888,7 +929,53 @@ jobs: --nightly-manifest-url https://release.capsem.org/assets/nightly/manifest.json \ --install-script-url https://capsem.org/install.sh \ --site-url https://capsem.org/ \ - --work-dir /tmp/public-binary-release \ - --docker-linux-install \ - --docker-channel-switch \ - --docker-upgrade + --work-dir /tmp/public-binary-release + + - name: Enable KVM for live public-install VM proof + run: | + set -euo pipefail + sudo modprobe kvm + sudo chmod 0666 /dev/kvm + sudo modprobe vhost_vsock + sudo chmod 0666 /dev/vhost-vsock + test -r /dev/kvm -a -w /dev/kvm + test -r /dev/vhost-vsock -a -w /dev/vhost-vsock + + - name: Install live public Linux release and prove guest shell execution + run: | + set -euo pipefail + EXPECTED_VERSION="$(python3 - <<'PY' + import json + + manifest = json.load(open('/tmp/verify/manifest.json')) + matches = [ + package + for package in manifest.get('packages', []) + if package.get('platform') == 'linux' + and package.get('architecture') == 'x86_64' + ] + if len(matches) != 1 or not isinstance(matches[0].get('version'), str): + raise SystemExit('manifest must contain exactly one versioned linux/x86_64 package') + print(matches[0]['version']) + PY + )" + curl -fsSL https://capsem.org/install.sh | CAPSEM_CHANNEL="$RELEASE_CHANNEL" sh + test -x "$HOME/.capsem/bin/capsem" + test -x /usr/bin/capsem-app + "$HOME/.capsem/bin/capsem" --version | grep -F "$EXPECTED_VERSION" + dpkg-query -W -f='${Version}' capsem | grep -Fx "$EXPECTED_VERSION" + for bin in capsem capsem-admin capsem-gateway capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-process capsem-service capsem-tray capsem-tui; do + test -x "$HOME/.capsem/bin/$bin" + "$HOME/.capsem/bin/$bin" --version | grep -F "$EXPECTED_VERSION" + done + grep -F "$ASSET_MANIFEST_URL" "$HOME/.capsem/assets/manifest-origin.json" + "$HOME/.capsem/bin/capsem" status | tee /tmp/capsem-live-status.txt + grep -F "Installed: true" /tmp/capsem-live-status.txt + grep -F "Running: true" /tmp/capsem-live-status.txt + grep -F "Service: ok" /tmp/capsem-live-status.txt + grep -F "Gateway: ok" /tmp/capsem-live-status.txt + python3 scripts/prove-installed-shell.py \ + --capsem "$HOME/.capsem/bin/capsem" \ + --marker CAPSEM_LIVE_PUBLIC_INSTALL_SHELL_OK \ + --session-name release-live-public-shell-x86_64 \ + --timeout 300 diff --git a/CHANGELOG.md b/CHANGELOG.md index fa0b4e381..16954873c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Made the public macOS curl installer synchronously apply the downloaded + package with `/usr/sbin/installer`, so the command completes a real install + instead of handing the package to a generic `open` action. +- Made both macOS and Linux public installers reject package downloads whose + byte size or SHA-256 does not match the release manifest before elevation. +- Strengthened release CI to verify exact installed package versions, complete + binary cohorts, service readiness, and PTY-driven `capsem shell` execution + inside a KVM-backed guest on Linux. +- Made Debian-package SBOM inspection parse the package archive directly, so + release acceptance tests work with both BSD and GNU host toolchains. + ## [1.5.1783894498] - 2026-07-12 ### Changed diff --git a/crates/capsem-core/src/hypervisor/fuse/inode_table.rs b/crates/capsem-core/src/hypervisor/fuse/inode_table.rs index d7c9504e2..deafc793f 100644 --- a/crates/capsem-core/src/hypervisor/fuse/inode_table.rs +++ b/crates/capsem-core/src/hypervisor/fuse/inode_table.rs @@ -168,7 +168,7 @@ mod tests { let dir = std::env::temp_dir().join("capsem-fuse-test").join(name); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + dir.canonicalize().unwrap() } // Inode table operations diff --git a/crates/capsem-core/src/mcp/server_manager.rs b/crates/capsem-core/src/mcp/server_manager.rs index ef4eb04cb..7ec4e4325 100644 --- a/crates/capsem-core/src/mcp/server_manager.rs +++ b/crates/capsem-core/src/mcp/server_manager.rs @@ -189,7 +189,7 @@ impl StreamableHttpClient for CapsemMcpHttpClient { } None => return Err(StreamableHttpError::UnexpectedContentType(None)), } - Ok(SseStream::from_bytes_stream(response.bytes_stream()).boxed()) + Ok(SseStream::from_byte_stream(response.bytes_stream()).boxed()) } async fn delete_session( @@ -319,7 +319,7 @@ impl StreamableHttpClient for CapsemMcpHttpClient { match content_type.as_deref() { Some(ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { Ok(StreamableHttpPostResponse::Sse( - SseStream::from_bytes_stream(response.bytes_stream()).boxed(), + SseStream::from_byte_stream(response.bytes_stream()).boxed(), session_id, )) } diff --git a/docs/public/install.sh b/docs/public/install.sh index 1cce18b62..b229a81e1 100644 --- a/docs/public/install.sh +++ b/docs/public/install.sh @@ -1,11 +1,13 @@ #!/bin/sh -# Capsem installer -- downloads the latest release and installs it. -# macOS: downloads .pkg, opens the native installer GUI +# Capsem installer -- downloads the stable binary package and installs it. +# macOS: downloads .pkg, installs via the native installer command # Linux: downloads .deb, installs via apt # Usage: curl -fsSL https://capsem.org/install.sh | sh set -eu -REPO="google/capsem" +CAPSEM_CHANNEL="${CAPSEM_CHANNEL:-stable}" +CAPSEM_RELEASE_BASE_URL="${CAPSEM_RELEASE_BASE_URL:-https://release.capsem.org}" +CAPSEM_MANIFEST_URL="${CAPSEM_MANIFEST_URL:-${CAPSEM_RELEASE_BASE_URL}/assets/${CAPSEM_CHANNEL}/manifest.json}" # -- Testable functions ------------------------------------------------------ # These functions can be unit-tested by sourcing this script with @@ -49,27 +51,210 @@ detect_arch() { } find_asset_url() { - _release_json="$1" + _manifest_json="$1" _os="$2" _arch="$3" case "$_os" in darwin) - _pattern='\.pkg"' + _platform="macos" + _kind="macos_pkg" + _manifest_arch="arm64" + _name_suffix=".pkg" ;; linux) - _pattern="_${_arch}\.deb\"" + _platform="linux" + _kind="debian_package" + case "$_arch" in + amd64) + _manifest_arch="x86_64" + _name_suffix="_amd64.deb" + ;; + arm64) + _manifest_arch="arm64" + _name_suffix="_arm64.deb" + ;; + *) + echo "Error: no matching asset found for $_os/$_arch in the ${CAPSEM_CHANNEL} release channel." >&2 + return 1 + ;; + esac ;; esac - ASSET_URL="$(echo "$_release_json" | grep '"browser_download_url"' | grep "$_pattern" | head -1 | sed 's/.*"browser_download_url": *"//;s/".*//')" - if [ -z "$ASSET_URL" ]; then - echo "Error: no matching asset found for $_os/$_arch in this release." >&2 + + _asset_record="$(printf '%s\n' "$_manifest_json" | awk \ + -v platform="$_platform" \ + -v kind="$_kind" \ + -v arch="$_manifest_arch" ' + function count_char(text, char, i, n) { + n = 0 + for (i = 1; i <= length(text); i++) { + if (substr(text, i, 1) == char) { + n++ + } + } + return n + } + BEGIN { + platform_re = "\"" "platform" "\"" "[[:space:]]*:[[:space:]]*\"" platform "\"" + kind_re = "\"" "kind" "\"" "[[:space:]]*:[[:space:]]*\"" kind "\"" + arch_re = "\"" "architecture" "\"" "[[:space:]]*:[[:space:]]*\"" arch "\"" + status_re = "\"" "status" "\"" "[[:space:]]*:[[:space:]]*\"current\"" + } + /"packages"[[:space:]]*:/ { + in_packages = 1 + next + } + in_packages { + if (!in_package && $0 ~ /^[[:space:]]*\{/) { + in_package = 1 + depth = 0 + block = "" + } + if (in_package) { + block = block $0 "\n" + depth += count_char($0, "{") - count_char($0, "}") + if (depth == 0) { + if (block ~ platform_re && block ~ kind_re && block ~ arch_re && block ~ status_re) { + printf "%s", block + exit + } + in_package = 0 + block = "" + } + } + } + ')" + + _asset_fields="$(printf '%s\n' "$_asset_record" | awk ' + function count_char(text, char, i, n) { + n = 0 + for (i = 1; i <= length(text); i++) { + if (substr(text, i, 1) == char) n++ + } + return n + } + function string_value(text, parts) { + split(text, parts, "\"") + return parts[4] + } + function sha256_value(text, value) { + value = text + sub(/^.*"sha256"[[:space:]]*:[[:space:]]*"/, "", value) + sub(/".*$/, "", value) + return value + } + { + if (depth == 1 && $0 ~ /"url"[[:space:]]*:/) { + url = string_value($0) + } else if (depth == 1 && $0 ~ /"version"[[:space:]]*:/) { + version = string_value($0) + } else if (depth == 1 && $0 ~ /"name"[[:space:]]*:/) { + name = string_value($0) + } else if (depth == 1 && $0 ~ /"bytes"[[:space:]]*:/) { + bytes = $0 + sub(/^.*"bytes"[[:space:]]*:[[:space:]]*/, "", bytes) + sub(/[[:space:],].*$/, "", bytes) + } else if (depth == 1 && $0 ~ /"digest"[[:space:]]*:[[:space:]]*\{/) { + in_package_digest = 1 + if ($0 ~ /"sha256"[[:space:]]*:/) sha256 = sha256_value($0) + } else if (in_package_digest && depth == 2 && $0 ~ /"sha256"[[:space:]]*:/) { + sha256 = sha256_value($0) + } + + depth += count_char($0, "{") - count_char($0, "}") + if (in_package_digest && depth <= 1) in_package_digest = 0 + } + END { printf "%s|%s|%s|%s|%s", url, version, name, bytes, sha256 } + ')" + ASSET_URL="${_asset_fields%%|*}" + _asset_fields="${_asset_fields#*|}" + ASSET_VERSION="${_asset_fields%%|*}" + _asset_fields="${_asset_fields#*|}" + ASSET_NAME="${_asset_fields%%|*}" + _asset_fields="${_asset_fields#*|}" + ASSET_BYTES="${_asset_fields%%|*}" + ASSET_SHA256="${_asset_fields#*|}" + + if [ -z "$ASSET_URL" ] || [ -z "$ASSET_VERSION" ] || [ -z "$ASSET_NAME" ] \ + || [ -z "$ASSET_BYTES" ] || [ -z "$ASSET_SHA256" ]; then + echo "Error: no matching asset found for $_os/$_arch in the ${CAPSEM_CHANNEL} release channel." >&2 return 1 fi + case "$ASSET_BYTES" in + *[!0-9]*) + echo "Error: release channel package $ASSET_NAME has an invalid byte size: $ASSET_BYTES" >&2 + return 1 + ;; + esac + case "$ASSET_SHA256" in + *[!0-9a-fA-F]*|"") + echo "Error: release channel package $ASSET_NAME has an invalid SHA-256 digest." >&2 + return 1 + ;; + esac + if [ "${#ASSET_SHA256}" -ne 64 ]; then + echo "Error: release channel package $ASSET_NAME has an invalid SHA-256 digest." >&2 + return 1 + fi + case "$ASSET_NAME" in + *"$_name_suffix") ;; + *) + echo "Error: release channel selected unexpected package $ASSET_NAME for $_os/$_arch." >&2 + return 1 + ;; + esac + case "$ASSET_URL" in + http://*|https://*) ;; + *) + echo "Error: release channel package $ASSET_NAME has an invalid URL: $ASSET_URL" >&2 + return 1 + ;; + esac +} + +verify_package() { + _package_path="$1" + _package_name="$2" + _expected_bytes="$3" + _expected_sha256="$4" + + _actual_bytes="$(wc -c < "$_package_path" | tr -d '[:space:]')" + if [ "$_actual_bytes" != "$_expected_bytes" ]; then + echo "Error: package integrity check failed for $_package_name: expected $_expected_bytes bytes, got $_actual_bytes." >&2 + return 1 + fi + + if command -v sha256sum >/dev/null 2>&1; then + _actual_sha256="$(sha256sum "$_package_path" | awk '{ print $1 }')" + elif command -v shasum >/dev/null 2>&1; then + _actual_sha256="$(shasum -a 256 "$_package_path" | awk '{ print $1 }')" + else + echo "Error: package integrity check failed: sha256sum or shasum is required." >&2 + return 1 + fi + _actual_sha256="$(printf '%s' "$_actual_sha256" | tr '[:upper:]' '[:lower:]')" + _expected_sha256="$(printf '%s' "$_expected_sha256" | tr '[:upper:]' '[:lower:]')" + if [ "$_actual_sha256" != "$_expected_sha256" ]; then + echo "Error: package integrity check failed for $_package_name: SHA-256 mismatch." >&2 + return 1 + fi + echo "Verified $_package_name ($_actual_bytes bytes, SHA-256 $_actual_sha256)." +} + +fetch_release_manifest() { + if [ -z "$CAPSEM_MANIFEST_URL" ]; then + echo "Error: CAPSEM_MANIFEST_URL is empty." >&2 + return 1 + fi + curl -fsSL "$CAPSEM_MANIFEST_URL" } install_macos() { _pkg_url="$1" _version="$2" + _expected_bytes="$3" + _expected_sha256="$4" + _package_name="$5" TMPDIR_INSTALL="$(mktemp -d)" PKG_PATH="${TMPDIR_INSTALL}/Capsem.pkg" @@ -81,19 +266,22 @@ install_macos() { echo "Downloading $_pkg_url..." curl -fSL --progress-bar -o "$PKG_PATH" "$_pkg_url" + verify_package "$PKG_PATH" "$_package_name" "$_expected_bytes" "$_expected_sha256" - echo "Opening installer..." - open "$PKG_PATH" + echo "Installing .pkg package (may prompt for sudo password)..." + sudo /usr/sbin/installer -pkg "$PKG_PATH" -target / echo "" - echo "Capsem $_version installer launched." - echo "Follow the installer GUI to complete installation." - echo "After install, open a new terminal and run: capsem shell" + echo "Capsem $_version installed." + echo "Open a new terminal and run: capsem shell" } install_linux() { _deb_url="$1" _version="$2" + _expected_bytes="$3" + _expected_sha256="$4" + _package_name="$5" TMPDIR_INSTALL="$(mktemp -d)" DEB_PATH="${TMPDIR_INSTALL}/capsem.deb" @@ -105,6 +293,7 @@ install_linux() { echo "Downloading $_deb_url..." curl -fSL --progress-bar -o "$DEB_PATH" "$_deb_url" + verify_package "$DEB_PATH" "$_package_name" "$_expected_bytes" "$_expected_sha256" echo "Installing .deb package (may prompt for sudo password)..." sudo apt install -y "$DEB_PATH" @@ -148,24 +337,18 @@ case "$OS" in ;; esac -# Fetch latest release -echo "Fetching latest Capsem release..." -RELEASE_JSON="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest")" - -TAG="$(echo "$RELEASE_JSON" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"//;s/".*//')" -if [ -z "$TAG" ]; then - echo "Error: could not determine latest release tag." >&2 - exit 1 -fi - -VERSION="${TAG#v}" -echo "Installing Capsem $VERSION..." +# Fetch stable release-channel manifest. The asset lane may be newer than the +# binary lane, so do not use GitHub's "latest release" endpoint here. +echo "Fetching Capsem ${CAPSEM_CHANNEL} release channel..." +RELEASE_MANIFEST="$(fetch_release_manifest)" # Find the right asset -find_asset_url "$RELEASE_JSON" "$OS" "$ARCH" +find_asset_url "$RELEASE_MANIFEST" "$OS" "$ARCH" +VERSION="$ASSET_VERSION" +echo "Installing Capsem $VERSION from ${CAPSEM_CHANNEL}..." # Install case "$OS" in - darwin) install_macos "$ASSET_URL" "$VERSION" ;; - linux) install_linux "$ASSET_URL" "$VERSION" ;; + darwin) install_macos "$ASSET_URL" "$VERSION" "$ASSET_BYTES" "$ASSET_SHA256" "$ASSET_NAME" ;; + linux) install_linux "$ASSET_URL" "$VERSION" "$ASSET_BYTES" "$ASSET_SHA256" "$ASSET_NAME" ;; esac diff --git a/justfile b/justfile index 5ae525dfb..47db69bc4 100644 --- a/justfile +++ b/justfile @@ -676,7 +676,7 @@ cross-compile arch="": _clean-stale _check-assets _generate-settings fi fi docker run --rm \ - "${DOCKER_KVM_ARGS[@]}" \ + ${DOCKER_KVM_ARGS[@]+"${DOCKER_KVM_ARGS[@]}"} \ ${SIGNING_ARGS[@]+"${SIGNING_ARGS[@]}"} \ -e "TARGET_ARCH=$TARGET_ARCH" \ -e "RUST_TARGET=$RUST_TARGET" \ @@ -1050,6 +1050,13 @@ test-install: # masked the asset-URL bug for v1.0.1777065213). set -euo pipefail IMAGE="capsem-install-test" + # The derived install-test image is FROM capsem-host-builder. The cleanup + # trap and low-disk recovery may remove both images, so the standalone gate + # must restore the base image before attempting to rebuild the derived one. + if ! docker image inspect capsem-host-builder:latest >/dev/null 2>&1; then + echo "Building missing capsem-host-builder base image..." + just build-host-image + fi # Build the Docker image if needed if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then echo "Building $IMAGE Docker image..." diff --git a/scripts/check-public-binary-release.py b/scripts/check-public-binary-release.py index 9c82fe997..8347399e6 100755 --- a/scripts/check-public-binary-release.py +++ b/scripts/check-public-binary-release.py @@ -158,11 +158,21 @@ def main() -> int: validated_binaries += binary_count if args.docker_linux_install: + package_versions = { + package.get("version") + for package in packages.values() + if isinstance(package.get("version"), str) + } + if len(package_versions) != 1: + raise ValueError( + "public Docker install requires one exact package version" + ) run_docker_install_smoke( release_base_url=args.release_base_url, install_script_url=args.install_script_url, stable_manifest_url=stable_manifest_url, nightly_manifest_url=nightly_manifest_url, + expected_version=next(iter(package_versions)), channel_switch=args.docker_channel_switch, upgrade=args.docker_upgrade, docker_image=args.docker_image, @@ -205,6 +215,16 @@ def check_install_script_defaults( failures.append("install.sh still depends on GitHub latest release metadata") if "releases/tag/assets-" in script or "assets-v" in script: failures.append("install.sh contains an asset-release tag URL") + required_outcomes = { + "ASSET_BYTES": "manifest package byte-size selection", + "ASSET_SHA256": "manifest package SHA-256 selection", + "verify_package": "package integrity verification", + "sudo /usr/sbin/installer -pkg": "synchronous macOS package installation", + "sudo apt install -y": "synchronous Linux package installation", + } + for token, outcome in required_outcomes.items(): + if token not in script: + failures.append(f"install.sh is missing {outcome}") return failures @@ -705,6 +725,7 @@ def run_docker_install_smoke( install_script_url: str, stable_manifest_url: str, nightly_manifest_url: str, + expected_version: str, channel_switch: bool, upgrade: bool, docker_image: str, @@ -741,13 +762,19 @@ def run_docker_install_smoke( chmod 0440 /etc/sudoers.d/capsemtest su capsemtest -c {shlex.quote(install_pipeline)} su capsemtest -c 'test -x "$HOME/.capsem/bin/capsem"' -su capsemtest -c '"$HOME/.capsem/bin/capsem" --version' +su capsemtest -c '"$HOME/.capsem/bin/capsem" --version' | grep -F {shlex.quote(expected_version)} su capsemtest -c 'test -f "$HOME/.capsem/assets/manifest.json"' su capsemtest -c 'grep -F {shlex.quote(stable_manifest_url)} "$HOME/.capsem/assets/manifest-origin.json"' +dpkg-query -W -f='${{Version}}' capsem | grep -Fx {shlex.quote(expected_version)} for bin in {helper_checks}; do su capsemtest -c "test -x \\"\\$HOME/.capsem/bin/$bin\\"" su capsemtest -c "\\"\\$HOME/.capsem/bin/$bin\\" --version" done +su capsemtest -c '"$HOME/.capsem/bin/capsem" status' | tee /home/capsemtest/.capsem/service-status.txt +grep -F "Installed: true" /home/capsemtest/.capsem/service-status.txt +grep -F "Running: true" /home/capsemtest/.capsem/service-status.txt +grep -F "Service: ok" /home/capsemtest/.capsem/service-status.txt +grep -F "Gateway: ok" /home/capsemtest/.capsem/service-status.txt """ if channel_switch: container_script += f""" diff --git a/scripts/deb-postinst.sh b/scripts/deb-postinst.sh index 1f3e40001..65a7cc4be 100755 --- a/scripts/deb-postinst.sh +++ b/scripts/deb-postinst.sh @@ -106,8 +106,32 @@ CAPSEM_INSTALL_PHASE="register_service" TARGET_UID=$(id -u "$TARGET_USER") XDG_DIR="/run/user/$TARGET_UID" if command -v systemctl >/dev/null 2>&1; then - su "$TARGET_USER" -c "XDG_RUNTIME_DIR=$XDG_DIR $CAPSEM_DIR/bin/capsem install" 2>/dev/null || true + if ! su "$TARGET_USER" -c "XDG_RUNTIME_DIR=$XDG_DIR $CAPSEM_DIR/bin/capsem install" 2>/dev/null; then + echo "capsem: service registration failed" >&2 + echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') phase=deb-postinst event=service_registration_failed" + exit 1 + fi echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') phase=deb-postinst event=service_install_invoked" + + READY=0 + STATUS_OUTPUT="" + CAPSEM_INSTALL_PHASE="wait_for_service" + for attempt in $(seq 1 30); do + STATUS_OUTPUT=$(su "$TARGET_USER" -c "XDG_RUNTIME_DIR=$XDG_DIR CAPSEM_HOME=$CAPSEM_DIR CAPSEM_RUN_DIR=$CAPSEM_DIR/run $CAPSEM_DIR/bin/capsem status" 2>/dev/null || true) + echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') phase=deb-postinst event=readiness_poll attempt=$attempt" + if echo "$STATUS_OUTPUT" | grep -q "Service: ok" \ + && echo "$STATUS_OUTPUT" | grep -q "Gateway: ok"; then + READY=1 + break + fi + sleep 1 + done + if [ "$READY" -ne 1 ]; then + echo "capsem: service is not ready after install" >&2 + echo "$STATUS_OUTPUT" >&2 + echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') phase=deb-postinst event=service_not_ready" + exit 1 + fi fi echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') phase=deb-postinst event=complete" diff --git a/scripts/generate-host-binary-sbom.py b/scripts/generate-host-binary-sbom.py index e4216ecc0..705fc4bd6 100644 --- a/scripts/generate-host-binary-sbom.py +++ b/scripts/generate-host-binary-sbom.py @@ -59,18 +59,44 @@ def executable_entries(artifact: Path) -> list[dict[str, object]]: def deb_entries(artifact: Path) -> list[dict[str, object]]: + data_name, data_contents = deb_data_member(artifact) with tempfile.TemporaryDirectory() as raw_tmp: raw = Path(raw_tmp) - subprocess.run(["ar", "x", str(artifact.resolve())], cwd=raw, check=True) - data = next(raw.glob("data.tar.*"), None) - if data is None: - raise SystemExit(f"{artifact} has no data.tar payload") + data = raw / data_name + data.write_bytes(data_contents) payload = raw / "payload" payload.mkdir() subprocess.run(["tar", "xf", str(data.resolve()), "-C", str(payload)], check=True) return entries_from_payload(payload) +def deb_data_member(artifact: Path) -> tuple[str, bytes]: + """Read a Debian package's data archive without platform-specific `ar`.""" + contents = artifact.read_bytes() + if not contents.startswith(b"!\n"): + raise SystemExit(f"{artifact} is not an ar archive") + + offset = 8 + while offset + 60 <= len(contents): + header = contents[offset : offset + 60] + if header[58:60] != b"`\n": + raise SystemExit(f"{artifact} has an invalid ar member header") + name = header[:16].decode("ascii", errors="replace").strip().rstrip("/") + try: + size = int(header[48:58].decode("ascii").strip()) + except ValueError as error: + raise SystemExit(f"{artifact} has an invalid ar member size") from error + data_start = offset + 60 + data_end = data_start + size + if data_end > len(contents): + raise SystemExit(f"{artifact} has a truncated ar member") + if name.startswith("data.tar"): + return name, contents[data_start:data_end] + offset = data_end + (size % 2) + + raise SystemExit(f"{artifact} has no data.tar payload") + + def pkg_entries(artifact: Path) -> list[dict[str, object]]: if shutil.which("pkgutil") is None: return xar_pkg_entries(artifact) diff --git a/scripts/local-release-glowup.py b/scripts/local-release-glowup.py index 762b11eec..3685fa155 100755 --- a/scripts/local-release-glowup.py +++ b/scripts/local-release-glowup.py @@ -416,6 +416,9 @@ def run_installed_glowup( check_service_installed() {{ "$HOME/.capsem/bin/capsem" status | tee "$HOME/.capsem/service-status.txt" grep -F "Installed: true" "$HOME/.capsem/service-status.txt" + grep -F "Running: true" "$HOME/.capsem/service-status.txt" + grep -F "Service: ok" "$HOME/.capsem/service-status.txt" + grep -F "Gateway: ok" "$HOME/.capsem/service-status.txt" }} check_binary_versions() {{ expected="$1" diff --git a/scripts/prove-installed-shell.py b/scripts/prove-installed-shell.py new file mode 100755 index 000000000..059879291 --- /dev/null +++ b/scripts/prove-installed-shell.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Prove that an installed Capsem can open and execute in a guest shell.""" + +from __future__ import annotations + +import argparse +import fcntl +import os +import pty +import re +import select +import signal +import struct +import subprocess +import sys +import termios +import time +from pathlib import Path + + +SAFE_VALUE = re.compile(r"^[A-Za-z0-9_.-]+$") + + +def guest_marker_command(marker: str) -> bytes: + """Build a command whose input bytes do not contain the success marker.""" + octal = "".join(f"\\{byte:03o}" for byte in marker.encode("utf-8")) + return f"printf '{octal}\\n'\r".encode("ascii") + + +def stop_process(process: subprocess.Popen[bytes]) -> None: + if process.poll() is not None: + return + try: + os.killpg(process.pid, signal.SIGTERM) + except OSError: + try: + process.terminate() + except OSError: + pass + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except OSError: + try: + process.kill() + except OSError: + pass + + +def prove_shell( + capsem: Path, + marker: str, + session_name: str, + timeout: float, + startup_delay: float, + retry_interval: float, +) -> None: + create = subprocess.run( + [str(capsem), "create", "--name", session_name], + check=False, + text=True, + capture_output=True, + timeout=timeout, + ) + if create.returncode != 0: + raise RuntimeError(f"failed to create shell-proof session: {create.stdout}{create.stderr}") + + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 120, 0, 0)) + process = subprocess.Popen( + [str(capsem), "shell", "--name", session_name], + stdin=slave, + stdout=slave, + stderr=slave, + start_new_session=True, + ) + os.close(slave) + + marker_bytes = marker.encode("utf-8") + command = guest_marker_command(marker) + output = bytearray() + deadline = time.monotonic() + timeout + next_send = time.monotonic() + startup_delay + observed = False + + try: + while time.monotonic() < deadline: + now = time.monotonic() + if now >= next_send: + os.write(master, command) + next_send = now + retry_interval + + readable, _, _ = select.select([master], [], [], 0.2) + if readable: + try: + chunk = os.read(master, 65536) + except OSError: + chunk = b"" + if chunk: + output.extend(chunk) + sys.stdout.buffer.write(chunk) + sys.stdout.buffer.flush() + if marker_bytes in output: + observed = True + break + if process.poll() is not None: + break + + if not observed: + tail = bytes(output[-4000:]).decode("utf-8", errors="replace") + raise RuntimeError( + "guest shell marker was not observed before timeout; " + f"terminal tail follows:\n{tail}" + ) + + # Exit the guest shell, then use the TUI's global Alt-Q shortcut. + try: + os.write(master, b"exit\r") + except OSError: + pass + time.sleep(0.5) + if process.poll() is None: + try: + os.write(master, b"\x1bq") + except OSError: + pass + finally: + os.close(master) + stop_process(process) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--capsem", type=Path, required=True) + parser.add_argument("--marker", required=True) + parser.add_argument("--session-name", default=f"installed-shell-proof-{os.getpid()}") + parser.add_argument("--timeout", type=float, default=300.0) + parser.add_argument("--startup-delay", type=float, default=2.0) + parser.add_argument("--retry-interval", type=float, default=5.0) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + for label, value in (("marker", args.marker), ("session name", args.session_name)): + if not SAFE_VALUE.fullmatch(value): + raise SystemExit(f"{label} contains unsupported characters: {value!r}") + if args.timeout <= 0 or args.startup_delay < 0 or args.retry_interval <= 0: + raise SystemExit("timeout values must be positive") + if not args.capsem.is_file() or not os.access(args.capsem, os.X_OK): + raise SystemExit(f"capsem executable not found: {args.capsem}") + + try: + prove_shell( + args.capsem, + args.marker, + args.session_name, + args.timeout, + args.startup_delay, + args.retry_interval, + ) + except (OSError, RuntimeError, subprocess.TimeoutExpired) as error: + print(f"installed shell proof failed: {error}", file=sys.stderr) + return 1 + finally: + try: + subprocess.run( + [str(args.capsem), "delete", args.session_name], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=min(args.timeout, 30), + ) + except (OSError, subprocess.TimeoutExpired): + pass + + print(f"installed shell proof passed: {args.marker}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/site/public/install.sh b/site/public/install.sh index f3b32ee3a..b229a81e1 100644 --- a/site/public/install.sh +++ b/site/public/install.sh @@ -1,6 +1,6 @@ #!/bin/sh # Capsem installer -- downloads the stable binary package and installs it. -# macOS: downloads .pkg, opens the native installer GUI +# macOS: downloads .pkg, installs via the native installer command # Linux: downloads .deb, installs via apt # Usage: curl -fsSL https://capsem.org/install.sh | sh set -eu @@ -125,14 +125,77 @@ find_asset_url() { } ')" - ASSET_URL="$(printf '%s\n' "$_asset_record" | awk -F'"' '/"url"[[:space:]]*:/ { value = $4 } END { print value }')" - ASSET_VERSION="$(printf '%s\n' "$_asset_record" | awk -F'"' '/"version"[[:space:]]*:/ { value = $4 } END { print value }')" - ASSET_NAME="$(printf '%s\n' "$_asset_record" | awk -F'"' '/"name"[[:space:]]*:/ { value = $4 } END { print value }')" + _asset_fields="$(printf '%s\n' "$_asset_record" | awk ' + function count_char(text, char, i, n) { + n = 0 + for (i = 1; i <= length(text); i++) { + if (substr(text, i, 1) == char) n++ + } + return n + } + function string_value(text, parts) { + split(text, parts, "\"") + return parts[4] + } + function sha256_value(text, value) { + value = text + sub(/^.*"sha256"[[:space:]]*:[[:space:]]*"/, "", value) + sub(/".*$/, "", value) + return value + } + { + if (depth == 1 && $0 ~ /"url"[[:space:]]*:/) { + url = string_value($0) + } else if (depth == 1 && $0 ~ /"version"[[:space:]]*:/) { + version = string_value($0) + } else if (depth == 1 && $0 ~ /"name"[[:space:]]*:/) { + name = string_value($0) + } else if (depth == 1 && $0 ~ /"bytes"[[:space:]]*:/) { + bytes = $0 + sub(/^.*"bytes"[[:space:]]*:[[:space:]]*/, "", bytes) + sub(/[[:space:],].*$/, "", bytes) + } else if (depth == 1 && $0 ~ /"digest"[[:space:]]*:[[:space:]]*\{/) { + in_package_digest = 1 + if ($0 ~ /"sha256"[[:space:]]*:/) sha256 = sha256_value($0) + } else if (in_package_digest && depth == 2 && $0 ~ /"sha256"[[:space:]]*:/) { + sha256 = sha256_value($0) + } + + depth += count_char($0, "{") - count_char($0, "}") + if (in_package_digest && depth <= 1) in_package_digest = 0 + } + END { printf "%s|%s|%s|%s|%s", url, version, name, bytes, sha256 } + ')" + ASSET_URL="${_asset_fields%%|*}" + _asset_fields="${_asset_fields#*|}" + ASSET_VERSION="${_asset_fields%%|*}" + _asset_fields="${_asset_fields#*|}" + ASSET_NAME="${_asset_fields%%|*}" + _asset_fields="${_asset_fields#*|}" + ASSET_BYTES="${_asset_fields%%|*}" + ASSET_SHA256="${_asset_fields#*|}" - if [ -z "$ASSET_URL" ] || [ -z "$ASSET_VERSION" ] || [ -z "$ASSET_NAME" ]; then + if [ -z "$ASSET_URL" ] || [ -z "$ASSET_VERSION" ] || [ -z "$ASSET_NAME" ] \ + || [ -z "$ASSET_BYTES" ] || [ -z "$ASSET_SHA256" ]; then echo "Error: no matching asset found for $_os/$_arch in the ${CAPSEM_CHANNEL} release channel." >&2 return 1 fi + case "$ASSET_BYTES" in + *[!0-9]*) + echo "Error: release channel package $ASSET_NAME has an invalid byte size: $ASSET_BYTES" >&2 + return 1 + ;; + esac + case "$ASSET_SHA256" in + *[!0-9a-fA-F]*|"") + echo "Error: release channel package $ASSET_NAME has an invalid SHA-256 digest." >&2 + return 1 + ;; + esac + if [ "${#ASSET_SHA256}" -ne 64 ]; then + echo "Error: release channel package $ASSET_NAME has an invalid SHA-256 digest." >&2 + return 1 + fi case "$ASSET_NAME" in *"$_name_suffix") ;; *) @@ -149,6 +212,35 @@ find_asset_url() { esac } +verify_package() { + _package_path="$1" + _package_name="$2" + _expected_bytes="$3" + _expected_sha256="$4" + + _actual_bytes="$(wc -c < "$_package_path" | tr -d '[:space:]')" + if [ "$_actual_bytes" != "$_expected_bytes" ]; then + echo "Error: package integrity check failed for $_package_name: expected $_expected_bytes bytes, got $_actual_bytes." >&2 + return 1 + fi + + if command -v sha256sum >/dev/null 2>&1; then + _actual_sha256="$(sha256sum "$_package_path" | awk '{ print $1 }')" + elif command -v shasum >/dev/null 2>&1; then + _actual_sha256="$(shasum -a 256 "$_package_path" | awk '{ print $1 }')" + else + echo "Error: package integrity check failed: sha256sum or shasum is required." >&2 + return 1 + fi + _actual_sha256="$(printf '%s' "$_actual_sha256" | tr '[:upper:]' '[:lower:]')" + _expected_sha256="$(printf '%s' "$_expected_sha256" | tr '[:upper:]' '[:lower:]')" + if [ "$_actual_sha256" != "$_expected_sha256" ]; then + echo "Error: package integrity check failed for $_package_name: SHA-256 mismatch." >&2 + return 1 + fi + echo "Verified $_package_name ($_actual_bytes bytes, SHA-256 $_actual_sha256)." +} + fetch_release_manifest() { if [ -z "$CAPSEM_MANIFEST_URL" ]; then echo "Error: CAPSEM_MANIFEST_URL is empty." >&2 @@ -160,6 +252,9 @@ fetch_release_manifest() { install_macos() { _pkg_url="$1" _version="$2" + _expected_bytes="$3" + _expected_sha256="$4" + _package_name="$5" TMPDIR_INSTALL="$(mktemp -d)" PKG_PATH="${TMPDIR_INSTALL}/Capsem.pkg" @@ -171,19 +266,22 @@ install_macos() { echo "Downloading $_pkg_url..." curl -fSL --progress-bar -o "$PKG_PATH" "$_pkg_url" + verify_package "$PKG_PATH" "$_package_name" "$_expected_bytes" "$_expected_sha256" - echo "Opening installer..." - open "$PKG_PATH" + echo "Installing .pkg package (may prompt for sudo password)..." + sudo /usr/sbin/installer -pkg "$PKG_PATH" -target / echo "" - echo "Capsem $_version installer launched." - echo "Follow the installer GUI to complete installation." - echo "After install, open a new terminal and run: capsem shell" + echo "Capsem $_version installed." + echo "Open a new terminal and run: capsem shell" } install_linux() { _deb_url="$1" _version="$2" + _expected_bytes="$3" + _expected_sha256="$4" + _package_name="$5" TMPDIR_INSTALL="$(mktemp -d)" DEB_PATH="${TMPDIR_INSTALL}/capsem.deb" @@ -195,6 +293,7 @@ install_linux() { echo "Downloading $_deb_url..." curl -fSL --progress-bar -o "$DEB_PATH" "$_deb_url" + verify_package "$DEB_PATH" "$_package_name" "$_expected_bytes" "$_expected_sha256" echo "Installing .deb package (may prompt for sudo password)..." sudo apt install -y "$DEB_PATH" @@ -250,6 +349,6 @@ echo "Installing Capsem $VERSION from ${CAPSEM_CHANNEL}..." # Install case "$OS" in - darwin) install_macos "$ASSET_URL" "$VERSION" ;; - linux) install_linux "$ASSET_URL" "$VERSION" ;; + darwin) install_macos "$ASSET_URL" "$VERSION" "$ASSET_BYTES" "$ASSET_SHA256" "$ASSET_NAME" ;; + linux) install_linux "$ASSET_URL" "$VERSION" "$ASSET_BYTES" "$ASSET_SHA256" "$ASSET_NAME" ;; esac diff --git a/skills/release-process/SKILL.md b/skills/release-process/SKILL.md index f0e1f7315..a273b0b59 100644 --- a/skills/release-process/SKILL.md +++ b/skills/release-process/SKILL.md @@ -46,6 +46,59 @@ virtualenv leakage and missing runner dependencies in seconds while retaining the complete Docker/systemd install suite later in `just test`; the bootstrap proof is never accepted as a substitute for install E2E. +## Installer outcome gate + +The installer exists to leave a working Capsem installation, not merely to +download a package, launch Installer.app, or return exit code zero. Treat the +following as separate, non-substitutable release gates: + +### CI exact-artifact gates + +- **macOS CI exact-package proof:** build the exact publishable `.pkg`, sign, + notarize, and staple it, then install that same file with + `sudo /usr/sbin/installer -pkg -target /`. Assert the installed app, + complete host-binary cohort, package version, manifest origin, service + registration, and launchable public CLI surfaces before uploading it. +- **Linux CI installed-product proof:** build the exact publishable `.deb` for + every supported release architecture, install that same file on a clean + native runner, and assert package metadata, the complete installed binary + cohort, exact version agreement, service startup, and a functional Capsem + command. Where the CI runner exposes KVM, prove `capsem shell` can start a + guest and execute a deterministic command inside it. +- Publication must depend on both platform jobs. A skipped, optional, + `continue-on-error`, mocked, source-layout, or inspect-only result does not + count as release proof. Signing, notarization, file existence, and package + expansion are necessary checks, but none substitutes for installing the + artifact. + +### Public installer gates + +- The public `curl -fsSL https://capsem.org/install.sh | sh` path must select + the expected package from the release manifest, verify its declared byte + size and SHA-256, synchronously apply it through the native package manager, + and return failure when any step fails. +- After deployment, Linux CI must run that live command in a clean supported + environment and repeat installed version, binary-cohort, service, and + functional-command assertions. Parser tests and command stubs do not count + as this gate. + +### Final local macOS installed-product proof + +GitHub-hosted macOS cannot prove the VM-backed shell path because nested +Virtualization.framework is unavailable. After the immutable release and +public installer are deployed, the agent must download the exact published +`.pkg` on a real supported Mac, verify its release digest/signature/notarization, +install it with `sudo /usr/sbin/installer -pkg -target /`, verify the +installed app, binary cohort, exact version, service, and tray, then prove +`capsem shell` spawns a guest shell and executes a deterministic command. + +This is an agent-executed acceptance gate, not a manual GUI click-through or a +handoff to the user. A manual GUI click-through does not count as release proof. +Do not call the release complete without the recorded Linux CI installed-product +evidence and final local macOS installed-product evidence. If either fails, +fix forward, cut a new immutable version, and run the entire forward-only release +again; never reuse or move the failed tag. + Release asset manifests are generated through `capsem-admin manifest generate`. Do not publish or document alternate manifest writers. Runtime VM asset integrity is BLAKE3 hash verification plus manifest origin/hash reporting; do diff --git a/tests/capsem-build-chain/test_install_asset_payload.py b/tests/capsem-build-chain/test_install_asset_payload.py index 319590b63..f58cb08cc 100644 --- a/tests/capsem-build-chain/test_install_asset_payload.py +++ b/tests/capsem-build-chain/test_install_asset_payload.py @@ -387,6 +387,10 @@ def test_local_release_glowup_installed_path_asserts_channel_round_trip_and_prov assert 'grep -F \'"package_version": "1.5.101"\'' in script assert "check_service_installed" in script assert '"$HOME/.capsem/bin/capsem" status' in script + assert 'grep -F "Installed: true"' in script + assert 'grep -F "Running: true"' in script + assert 'grep -F "Service: ok"' in script + assert 'grep -F "Gateway: ok"' in script assert "service status" not in script assert "compiled_binary_version=1.5.100" in script assert "check_binary_versions 1.5.100" in script @@ -750,6 +754,13 @@ def test_asset_build_recipes_skip_kvm_only_for_build_prereq_doctor() -> None: assert "CAPSEM_SKIP_KVM_CHECK" not in smoke_block +def test_cross_compile_guards_empty_kvm_array_under_bash_nounset() -> None: + justfile = (PROJECT_ROOT / "justfile").read_text() + + assert '${DOCKER_KVM_ARGS[@]+"${DOCKER_KVM_ARGS[@]}"}' in justfile + assert '\n "${DOCKER_KVM_ARGS[@]}" \\\n' not in justfile + + def test_security_event_rows_go_through_security_engine_emitter() -> None: roots = [ PROJECT_ROOT / "crates" / "capsem-core" / "src", diff --git a/tests/capsem-build-chain/test_release_index.py b/tests/capsem-build-chain/test_release_index.py index 84d100315..28fc90fb7 100644 --- a/tests/capsem-build-chain/test_release_index.py +++ b/tests/capsem-build-chain/test_release_index.py @@ -8,6 +8,7 @@ import json import os import subprocess +import sys import tarfile from pathlib import Path @@ -103,6 +104,41 @@ def _write_minimal_deb(path: Path, executable_name: str = "capsem-app") -> bytes return deb +def _write_minimal_pkg(path: Path) -> bytes: + executable = b"#!/bin/sh\nexit 0\n" + installed_path = "Applications/Capsem.app/Contents/MacOS/capsem-app" + if sys.platform == "darwin": + root = path.with_suffix(".root") + payload = root / installed_path + payload.parent.mkdir(parents=True) + payload.write_bytes(executable) + payload.chmod(0o755) + subprocess.run( + [ + "pkgbuild", + "--root", + str(root), + "--identifier", + "org.capsem.test.fixture", + "--version", + "1.4.2234567890", + str(path), + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + else: + with tarfile.open(path, mode="w:gz") as tar: + info = tarfile.TarInfo(f"capsem.pkg/Payload/{installed_path}") + info.mode = 0o755 + info.size = len(executable) + info.mtime = 0 + tar.addfile(info, io.BytesIO(executable)) + return path.read_bytes() + + def _ar_member(name: str, data: bytes) -> bytes: header = ( f"{name + '/':<16}" @@ -1298,7 +1334,7 @@ def test_binary_release_index_records_host_artifacts_without_changing_assets( pkg = artifacts / "Capsem-1.4.2234567890.pkg" deb = artifacts / "Capsem_1.4.2234567890_arm64.deb" sbom = artifacts / "capsem-sbom.spdx.json" - pkg.write_bytes(b"pkg bytes v2") + pkg_bytes = _write_minimal_pkg(pkg) deb_bytes = _write_minimal_deb(deb) sbom.write_bytes(b'{"spdxVersion":"SPDX-2.3","name":"capsem"}') @@ -1333,7 +1369,7 @@ def test_binary_release_index_records_host_artifacts_without_changing_assets( assert release["version"] == "1.4.2234567890" assert release["date"] == "2030-02-03" files = {entry["name"]: entry for entry in release["files"]} - assert files[pkg.name]["sha256"] == hashlib.sha256(b"pkg bytes v2").hexdigest() + assert files[pkg.name]["sha256"] == hashlib.sha256(pkg_bytes).hexdigest() assert files[deb.name]["sha256"] == hashlib.sha256(deb_bytes).hexdigest() assert files[deb.name]["binaries"] assert files[sbom.name]["sha256"] == hashlib.sha256(sbom.read_bytes()).hexdigest() @@ -1345,7 +1381,7 @@ def test_binary_release_index_rejects_bad_spdx_sbom(tmp_path: Path) -> None: artifacts.mkdir() pkg = artifacts / "Capsem-1.4.2234567890.pkg" sbom = artifacts / "capsem-sbom.spdx.json" - pkg.write_bytes(b"pkg bytes v2") + _write_minimal_pkg(pkg) sbom.write_bytes(b'{"spdxVersion":"SPDX-2.2","name":"capsem"}') result = _run_admin( @@ -1465,7 +1501,7 @@ def test_binary_release_index_rejects_package_version_mismatch(tmp_path: Path) - artifacts.mkdir() pkg = artifacts / "Capsem-1.4.0000000000.pkg" sbom = artifacts / "capsem-sbom.spdx.json" - pkg.write_bytes(b"pkg bytes") + _write_minimal_pkg(pkg) sbom.write_bytes(b'{"spdxVersion":"SPDX-2.3","name":"capsem"}') result = _run_admin( @@ -1496,7 +1532,7 @@ def test_binary_release_index_rejects_noncanonical_sbom_artifact(tmp_path: Path) artifacts.mkdir() pkg = artifacts / "Capsem-1.4.2234567890.pkg" sbom = artifacts / "host-sbom.spdx.json" - pkg.write_bytes(b"pkg bytes") + _write_minimal_pkg(pkg) sbom.write_bytes(b'{"spdxVersion":"SPDX-2.3","name":"capsem"}') result = _run_admin( diff --git a/tests/capsem-release/test_materialize_graph_profile_artifacts.py b/tests/capsem-release/test_materialize_graph_profile_artifacts.py index 0fdc46f40..ff3a4ada3 100644 --- a/tests/capsem-release/test_materialize_graph_profile_artifacts.py +++ b/tests/capsem-release/test_materialize_graph_profile_artifacts.py @@ -24,7 +24,7 @@ def test_materializes_profile_config_from_asset_source_tag(tmp_path: Path) -> No _git(repo, "config", "user.email", "test@example.com") _git(repo, "config", "user.name", "Test") _git(repo, "add", "config/profiles/code/apt-packages.txt") - _git(repo, "commit", "-m", "asset source") + _git(repo, "-c", "commit.gpgsign=false", "commit", "-m", "asset source") _git(repo, "tag", "assets-v2030.0101.1") dist = tmp_path / "dist" diff --git a/tests/capsem-release/test_public_binary_release_gate.py b/tests/capsem-release/test_public_binary_release_gate.py index 6eec677ef..e8c86c73e 100644 --- a/tests/capsem-release/test_public_binary_release_gate.py +++ b/tests/capsem-release/test_public_binary_release_gate.py @@ -170,51 +170,20 @@ def test_public_binary_release_gate_rejects_manifest_binary_hash_drift( def test_public_binary_release_gate_rejects_packaged_binary_version_drift( tmp_path: Path, ) -> None: - package_dir = tmp_path / "packages" - package_dir.mkdir() - package = package_dir / "Capsem_9.9.9_amd64.deb" + gate = _load_release_gate() capsem = b"#!/bin/sh\necho capsem 9.9.8\n" - manifest_url = (tmp_path / "manifest.json").resolve().as_uri() - _write_minimal_deb(package, {"usr/bin/capsem": capsem}, manifest_url=manifest_url) - - manifest = _write_manifest( + failures = gate.check_packaged_binary_version( + {"version": "9.9.9"}, + _binary_record("capsem", "/usr/bin/capsem", capsem), + "/usr/bin/capsem", + {"/usr/bin/capsem": capsem}, tmp_path, - [ - _package_record( - "x86_64", - package.name, - package, - [_binary_record("capsem", "/usr/bin/capsem", capsem)], - ) - ], - ) - install_sh = _write_install_sh(tmp_path) - - result = subprocess.run( - [ - "uv", - "run", - "python", - str(SCRIPT), - "--manifest-url", - manifest.resolve().as_uri(), - "--install-script-url", - str(install_sh), - "--package-dir", - str(package_dir), - "--required-package", - "linux:x86_64:debian_package", - ], - cwd=PROJECT_ROOT, - capture_output=True, - text=True, - timeout=60, + "Capsem_9.9.9_amd64.deb", ) - assert result.returncode != 0 - assert "binary /usr/bin/capsem version output does not contain 9.9.9" in ( - result.stderr + result.stdout - ) + assert failures == [ + "binary /usr/bin/capsem version output does not contain 9.9.9: capsem 9.9.8" + ] def test_public_binary_release_gate_does_not_execute_gui_payload_without_deps( @@ -385,38 +354,44 @@ def test_public_binary_release_gate_rejects_manifest_origin_package_version_drif ) -def test_release_workflow_runs_public_package_gate_and_docker_install() -> None: +def test_release_workflow_runs_public_package_gate_and_native_install() -> None: workflow = (PROJECT_ROOT / ".github/workflows/release.yaml").read_text(encoding="utf-8") verify_downloads = workflow.split(" verify-release-downloads:", maxsplit=1)[1] assert "scripts/check-public-binary-release.py" in verify_downloads assert '--channel "$RELEASE_CHANNEL"' in verify_downloads - assert "--manifest-url \"$ASSET_MANIFEST_URL\"" in verify_downloads + assert '--manifest-url "$ASSET_MANIFEST_URL"' in verify_downloads assert ( - "--stable-manifest-url " - "https://release.capsem.org/assets/stable/manifest.json" + "--stable-manifest-url https://release.capsem.org/assets/stable/manifest.json" ) in verify_downloads assert ( - "--nightly-manifest-url " - "https://release.capsem.org/assets/nightly/manifest.json" + "--nightly-manifest-url https://release.capsem.org/assets/nightly/manifest.json" ) in verify_downloads assert "--install-script-url https://capsem.org/install.sh" in verify_downloads assert "--site-url https://capsem.org/" in verify_downloads - assert "--docker-linux-install" in verify_downloads - assert "--docker-channel-switch" in verify_downloads - assert "--docker-upgrade" in verify_downloads - assert "curl -fsSL https://capsem.org/install.sh | sh" in verify_downloads + assert "--docker-linux-install" not in verify_downloads + assert "Enable KVM for live public-install VM proof" in verify_downloads + assert ( + 'curl -fsSL https://capsem.org/install.sh | CAPSEM_CHANNEL="$RELEASE_CHANNEL" sh' + in verify_downloads + ) + assert "scripts/prove-installed-shell.py" in verify_downloads + assert "CAPSEM_LIVE_PUBLIC_INSTALL_SHELL_OK" in verify_downloads + assert '"$HOME/.capsem/bin/capsem" run' not in verify_downloads def test_public_binary_release_gate_keeps_public_installer_default_on_stable() -> None: gate = _load_release_gate() failures = gate.check_install_script_defaults( - '\n'.join( + "\n".join( ( 'CAPSEM_CHANNEL="${CAPSEM_CHANNEL:-stable}"', 'CAPSEM_RELEASE_BASE_URL="${CAPSEM_RELEASE_BASE_URL:-https://release.capsem.org}"', - '/assets/${CAPSEM_CHANNEL}/manifest.json', + "/assets/${CAPSEM_CHANNEL}/manifest.json", + "ASSET_BYTES ASSET_SHA256 verify_package", + 'sudo /usr/sbin/installer -pkg "$PKG_PATH" -target /', + 'sudo apt install -y "$DEB_PATH"', ) ), release_base_url="https://release.capsem.org", @@ -446,6 +421,7 @@ def capture_run(args, **_kwargs): install_script_url="https://capsem.org/install.sh", stable_manifest_url=stable, nightly_manifest_url=nightly, + expected_version="1.5.0", channel_switch=True, upgrade=True, docker_image="ubuntu:24.04", @@ -461,6 +437,11 @@ def capture_run(args, **_kwargs): upgrade_nightly = script.index(f"CAPSEM_RELEASE_MANIFEST_URL={nightly}") assert initial_stable < switch_nightly < verify_nightly < switch_stable assert switch_stable < verify_stable < upgrade_nightly + assert "dpkg-query -W -f='${Version}' capsem | grep -Fx 1.5.0" in script + assert 'grep -F "Installed: true" /home/capsemtest/.capsem/service-status.txt' in script + assert 'grep -F "Running: true" /home/capsemtest/.capsem/service-status.txt' in script + assert 'grep -F "Service: ok" /home/capsemtest/.capsem/service-status.txt' in script + assert 'grep -F "Gateway: ok" /home/capsemtest/.capsem/service-status.txt' in script def test_public_binary_release_gate_runs_install_switch_and_upgrade_paths() -> None: @@ -470,7 +451,7 @@ def test_public_binary_release_gate_runs_install_switch_and_upgrade_paths() -> N assert "--docker-upgrade" in source assert "update --assets --manifest" in source assert "CAPSEM_RELEASE_MANIFEST_URL=" in source - assert 'update --yes' in source + assert "update --yes" in source assert '.capsem/bin/$bin\\\\" --version' in source assert "manifest-origin.json" in source assert "snapshot_sha256" in source @@ -481,6 +462,26 @@ def test_public_binary_release_gate_runs_install_switch_and_upgrade_paths() -> N assert "check_public_site_download_links" in source +def test_public_binary_release_gate_requires_fail_closed_installer_integrity() -> None: + gate = _load_release_gate() + script = (PROJECT_ROOT / "site" / "public" / "install.sh").read_text(encoding="utf-8") + + failures = gate.check_install_script_defaults( + script, + release_base_url="https://release.capsem.org", + ) + + assert failures == [] + for required in ( + "ASSET_BYTES", + "ASSET_SHA256", + "verify_package", + "sudo /usr/sbin/installer -pkg", + "sudo apt install -y", + ): + assert required in script + + def test_public_binary_release_gate_accepts_stable_site_download_entrypoint() -> None: gate = _load_release_gate() @@ -531,6 +532,11 @@ def _write_install_sh(tmp_path: Path) -> Path: 'CAPSEM_CHANNEL="${CAPSEM_CHANNEL:-stable}"', 'CAPSEM_RELEASE_BASE_URL="${CAPSEM_RELEASE_BASE_URL:-https://release.capsem.org}"', 'CAPSEM_MANIFEST_URL="${CAPSEM_MANIFEST_URL:-${CAPSEM_RELEASE_BASE_URL}/assets/${CAPSEM_CHANNEL}/manifest.json}"', + "ASSET_BYTES=1", + "ASSET_SHA256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "verify_package() { :; }", + 'sudo /usr/sbin/installer -pkg "$PKG_PATH" -target /', + 'sudo apt install -y "$DEB_PATH"', "", ] ), @@ -614,12 +620,5 @@ def _write_minimal_deb( def _ar_member(name: str, data: bytes) -> bytes: - header = ( - f"{name + '/':<16}" - f"{0:<12}" - f"{0:<6}" - f"{0:<6}" - f"{100644:<8}" - f"{len(data):<10}`\n" - ).encode("ascii") + header = (f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{100644:<8}{len(data):<10}`\n").encode("ascii") return header + data + (b"\n" if len(data) % 2 else b"") diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py index 3f2e281e5..551685813 100644 --- a/tests/test_install_sh.py +++ b/tests/test_install_sh.py @@ -7,12 +7,14 @@ from __future__ import annotations +import os import subprocess import textwrap from pathlib import Path INSTALL_SH = Path(__file__).parent.parent / "site" / "public" / "install.sh" +DOCS_INSTALL_SH = Path(__file__).parent.parent / "docs" / "public" / "install.sh" def _run_shell(script: str) -> subprocess.CompletedProcess[str]: @@ -35,6 +37,11 @@ def _source_and_run(body: str) -> subprocess.CompletedProcess[str]: return _run_shell(script) +def test_published_installer_copies_cannot_drift() -> None: + """Both publish trees must serve the same fail-closed installer contract.""" + assert DOCS_INSTALL_SH.read_bytes() == INSTALL_SH.read_bytes() + + # --------------------------------------------------------------------------- # detect_os # --------------------------------------------------------------------------- @@ -42,30 +49,22 @@ def _source_and_run(body: str) -> subprocess.CompletedProcess[str]: class TestDetectOS: def test_darwin(self): - r = _source_and_run( - 'uname() { echo "Darwin"; }; detect_os; echo "$OS"' - ) + r = _source_and_run('uname() { echo "Darwin"; }; detect_os; echo "$OS"') assert r.returncode == 0 assert r.stdout.strip() == "darwin" def test_linux(self): - r = _source_and_run( - 'uname() { echo "Linux"; }; detect_os; echo "$OS"' - ) + r = _source_and_run('uname() { echo "Linux"; }; detect_os; echo "$OS"') assert r.returncode == 0 assert r.stdout.strip() == "linux" def test_unsupported_os(self): - r = _source_and_run( - 'uname() { echo "FreeBSD"; }; detect_os' - ) + r = _source_and_run('uname() { echo "FreeBSD"; }; detect_os') assert r.returncode != 0 assert "unsupported operating system" in r.stderr def test_windows_like(self): - r = _source_and_run( - 'uname() { echo "MINGW64_NT"; }; detect_os' - ) + r = _source_and_run('uname() { echo "MINGW64_NT"; }; detect_os') assert r.returncode != 0 assert "unsupported operating system" in r.stderr @@ -77,51 +76,37 @@ def test_windows_like(self): class TestDetectArch: def test_linux_x86_64(self): - r = _source_and_run( - 'OS=linux; uname() { echo "x86_64"; }; detect_arch; echo "$ARCH"' - ) + r = _source_and_run('OS=linux; uname() { echo "x86_64"; }; detect_arch; echo "$ARCH"') assert r.returncode == 0 assert r.stdout.strip() == "amd64" def test_linux_amd64(self): - r = _source_and_run( - 'OS=linux; uname() { echo "amd64"; }; detect_arch; echo "$ARCH"' - ) + r = _source_and_run('OS=linux; uname() { echo "amd64"; }; detect_arch; echo "$ARCH"') assert r.returncode == 0 assert r.stdout.strip() == "amd64" def test_linux_aarch64(self): - r = _source_and_run( - 'OS=linux; uname() { echo "aarch64"; }; detect_arch; echo "$ARCH"' - ) + r = _source_and_run('OS=linux; uname() { echo "aarch64"; }; detect_arch; echo "$ARCH"') assert r.returncode == 0 assert r.stdout.strip() == "arm64" def test_linux_arm64(self): - r = _source_and_run( - 'OS=linux; uname() { echo "arm64"; }; detect_arch; echo "$ARCH"' - ) + r = _source_and_run('OS=linux; uname() { echo "arm64"; }; detect_arch; echo "$ARCH"') assert r.returncode == 0 assert r.stdout.strip() == "arm64" def test_darwin_arm64(self): - r = _source_and_run( - 'OS=darwin; uname() { echo "arm64"; }; detect_arch; echo "$ARCH"' - ) + r = _source_and_run('OS=darwin; uname() { echo "arm64"; }; detect_arch; echo "$ARCH"') assert r.returncode == 0 assert r.stdout.strip() == "arm64" def test_darwin_x86_64_rejected(self): - r = _source_and_run( - 'OS=darwin; uname() { echo "x86_64"; }; detect_arch' - ) + r = _source_and_run('OS=darwin; uname() { echo "x86_64"; }; detect_arch') assert r.returncode != 0 assert "macOS requires Apple Silicon" in r.stderr def test_linux_riscv_rejected(self): - r = _source_and_run( - 'OS=linux; uname() { echo "riscv64"; }; detect_arch' - ) + r = _source_and_run('OS=linux; uname() { echo "riscv64"; }; detect_arch') assert r.returncode != 0 assert "unsupported architecture" in r.stderr @@ -136,7 +121,14 @@ def test_linux_riscv_rejected(self): "packages": [ { "architecture": "arm64", - "binaries": [], + "binaries": [ + { + "digest": {"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + "name": "capsem" + } + ], + "bytes": 20, + "digest": {"sha256": "d2145fea950ac94a3fe793b2d555212e254d9d8863dbccbe1cd07af49a7cae48"}, "kind": "macos_pkg", "name": "Capsem-1.5.0.pkg", "platform": "macos", @@ -147,6 +139,8 @@ def test_linux_riscv_rejected(self): { "architecture": "x86_64", "binaries": [], + "bytes": 17, + "digest": {"sha256": "f46ed56922b881235bea694d06c5e366954607fd25526c9dbf56ffefa67830b9"}, "kind": "debian_package", "name": "Capsem_1.5.0_amd64.deb", "platform": "linux", @@ -157,6 +151,8 @@ def test_linux_riscv_rejected(self): { "architecture": "arm64", "binaries": [], + "bytes": 17, + "digest": {"sha256": "f46ed56922b881235bea694d06c5e366954607fd25526c9dbf56ffefa67830b9"}, "kind": "debian_package", "name": "Capsem_1.5.0_arm64.deb", "platform": "linux", @@ -192,6 +188,26 @@ def test_darwin_pkg(self): assert url.endswith("/Capsem-1.5.0.pkg") assert version == "1.5.0" + def test_package_integrity_comes_from_package_not_nested_binary(self): + script = textwrap.dedent(f"""\ + __INSTALL_SH_SOURCED=1 + . "{INSTALL_SH}" + RELEASE_MANIFEST=$(cat <<'ENDJSON' +{FAKE_RELEASE_MANIFEST} +ENDJSON + ) + find_asset_url "$RELEASE_MANIFEST" darwin arm64 + printf '%s\n%s\n' "$ASSET_BYTES" "$ASSET_SHA256" + """) + + result = _run_shell(script) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip().splitlines() == [ + "20", + "d2145fea950ac94a3fe793b2d555212e254d9d8863dbccbe1cd07af49a7cae48", + ] + def test_linux_amd64_deb(self): r = self._run("linux", "amd64") assert r.returncode == 0 @@ -219,3 +235,254 @@ def test_installer_uses_release_channel_manifest_not_github_latest() -> None: assert "/assets/${CAPSEM_CHANNEL}/manifest.json" in script assert "api.github.com/repos/${REPO}/releases/latest" not in script assert "releases/latest" not in script + + +def test_macos_installers_apply_pkg_before_cleaning_download(tmp_path: Path) -> None: + """The curl installer must synchronously apply the pkg, not hand off a temp file.""" + for install_script in (INSTALL_SH, DOCS_INSTALL_SH): + install_root = tmp_path / install_script.parents[1].name + call_log = install_root / "installer-call" + script = textwrap.dedent(f"""\ + __INSTALL_SH_SOURCED=1 + . "{install_script}" + mktemp() {{ + mkdir -p "{install_root}/download" + printf '%s\\n' "{install_root}/download" + }} + curl() {{ + _output='' + while [ "$#" -gt 0 ]; do + if [ "$1" = '-o' ]; then + shift + _output="$1" + fi + shift + done + test -n "$_output" + printf 'fake signed package\\n' > "$_output" + }} + open() {{ + echo 'unexpected GUI installer handoff' >&2 + return 97 + }} + sudo() {{ + printf '%s\\n' "$*" > "{call_log}" + test "$1" = '/usr/sbin/installer' + test "$2" = '-pkg' + test -f "$3" + test "$4" = '-target' + test "$5" = '/' + }} + install_macos \ + 'https://example.invalid/Capsem.pkg' \ + '1.5.0' \ + '20' \ + 'd2145fea950ac94a3fe793b2d555212e254d9d8863dbccbe1cd07af49a7cae48' \ + 'Capsem-1.5.0.pkg' + """) + + result = _run_shell(script) + + assert result.returncode == 0, result.stderr + assert call_log.read_text(encoding="utf-8").strip() == ( + f"/usr/sbin/installer -pkg {install_root}/download/Capsem.pkg -target /" + ) + assert not (install_root / "download").exists() + assert "Capsem 1.5.0 installed." in result.stdout + + +def test_macos_curl_pipe_entrypoint_installs_pkg_end_to_end(tmp_path: Path) -> None: + """Exercise the public script through stdin, matching `curl ... | sh`.""" + bin_dir = tmp_path / "bin" + temp_dir = tmp_path / "tmp" + bin_dir.mkdir() + temp_dir.mkdir() + manifest = tmp_path / "manifest.json" + manifest.write_text(FAKE_RELEASE_MANIFEST, encoding="utf-8") + installer_log = tmp_path / "installer-call" + + commands = { + "uname": """#!/bin/sh +case "$1" in + -s) echo Darwin ;; + -m) echo arm64 ;; + *) exit 2 ;; +esac +""", + "sw_vers": """#!/bin/sh +test "$1" = '-productVersion' +echo 14.7.5 +""", + "curl": """#!/bin/sh +output='' +while [ "$#" -gt 0 ]; do + if [ "$1" = '-o' ]; then + shift + output="$1" + fi + shift +done +if [ -n "$output" ]; then + printf 'fake signed package\n' > "$output" +else + /bin/cat "$FAKE_MANIFEST" +fi +""", + "open": """#!/bin/sh +echo 'unexpected GUI installer handoff' >&2 +exit 97 +""", + "sudo": """#!/bin/sh +printf '%s\n' "$*" > "$INSTALLER_LOG" +test "$1" = '/usr/sbin/installer' +test "$2" = '-pkg' +test -f "$3" +test "$4" = '-target' +test "$5" = '/' +""", + } + for name, body in commands.items(): + command = bin_dir / name + command.write_text(body, encoding="utf-8") + command.chmod(0o755) + + env = os.environ.copy() + env.update( + { + "FAKE_MANIFEST": str(manifest), + "INSTALLER_LOG": str(installer_log), + "PATH": f"{bin_dir}:/usr/bin:/bin:/usr/sbin", + "TMPDIR": str(temp_dir), + } + ) + result = subprocess.run( + ["/bin/sh"], + input=INSTALL_SH.read_text(encoding="utf-8"), + capture_output=True, + text=True, + timeout=10, + env=env, + ) + + assert result.returncode == 0, result.stderr + assert "Installing Capsem 1.5.0 from stable..." in result.stdout + assert "Capsem 1.5.0 installed." in result.stdout + installer_args = installer_log.read_text(encoding="utf-8").strip().split() + assert installer_args[:2] == ["/usr/sbin/installer", "-pkg"] + assert installer_args[3:] == ["-target", "/"] + assert installer_args[2].endswith("/Capsem.pkg") + assert not Path(installer_args[2]).exists() + assert list(temp_dir.iterdir()) == [] + + +def test_linux_curl_pipe_entrypoint_installs_verified_deb_end_to_end(tmp_path: Path) -> None: + """Linux CI's public path must download, verify, and apt-install the selected deb.""" + bin_dir = tmp_path / "bin" + temp_dir = tmp_path / "tmp" + bin_dir.mkdir() + temp_dir.mkdir() + manifest = tmp_path / "manifest.json" + manifest.write_text(FAKE_RELEASE_MANIFEST, encoding="utf-8") + installer_log = tmp_path / "installer-call" + + commands = { + "uname": """#!/bin/sh +case "$1" in + -s) echo Linux ;; + -m) echo x86_64 ;; + *) exit 2 ;; +esac +""", + "apt": """#!/bin/sh +exit 99 +""", + "curl": """#!/bin/sh +output='' +while [ "$#" -gt 0 ]; do + if [ "$1" = '-o' ]; then + shift + output="$1" + fi + shift +done +if [ -n "$output" ]; then + printf 'fake deb package\n' > "$output" +else + /bin/cat "$FAKE_MANIFEST" +fi +""", + "sudo": """#!/bin/sh +printf '%s\n' "$*" > "$INSTALLER_LOG" +test "$1" = 'apt' +test "$2" = 'install' +test "$3" = '-y' +test -f "$4" +""", + } + for name, body in commands.items(): + command = bin_dir / name + command.write_text(body, encoding="utf-8") + command.chmod(0o755) + + env = os.environ.copy() + env.update( + { + "FAKE_MANIFEST": str(manifest), + "INSTALLER_LOG": str(installer_log), + "PATH": f"{bin_dir}:/usr/bin:/bin:/usr/sbin", + "TMPDIR": str(temp_dir), + } + ) + result = subprocess.run( + ["/bin/sh"], + input=INSTALL_SH.read_text(encoding="utf-8"), + capture_output=True, + text=True, + timeout=10, + env=env, + ) + + assert result.returncode == 0, result.stderr + assert "Installing Capsem 1.5.0 from stable..." in result.stdout + assert "Verified Capsem_1.5.0_amd64.deb" in result.stdout + installer_args = installer_log.read_text(encoding="utf-8").strip().split() + assert installer_args[:3] == ["apt", "install", "-y"] + assert installer_args[3].endswith("/capsem.deb") + assert not Path(installer_args[3]).exists() + assert list(temp_dir.iterdir()) == [] + + +def test_package_integrity_failure_never_invokes_native_installer(tmp_path: Path) -> None: + """Corrupt or truncated package bytes must fail before sudo on both platforms.""" + for function_name, filename in ( + ("install_macos", "Capsem.pkg"), + ("install_linux", "capsem.deb"), + ): + sudo_marker = tmp_path / f"{function_name}-sudo-called" + script = textwrap.dedent(f"""\ + __INSTALL_SH_SOURCED=1 + . "{INSTALL_SH}" + mktemp() {{ + _dir="{tmp_path}/{function_name}-download" + mkdir -p "$_dir" + printf '%s\n' "$_dir" + }} + curl() {{ + _output='' + while [ "$#" -gt 0 ]; do + if [ "$1" = '-o' ]; then shift; _output="$1"; fi + shift + done + printf 'corrupt\n' > "$_output" + }} + sudo() {{ touch "{sudo_marker}"; return 0; }} + {function_name} 'https://example.invalid/{filename}' '1.5.0' '999' \ + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' \ + '{filename}' + """) + + result = _run_shell(script) + + assert result.returncode != 0 + assert "integrity check failed" in result.stderr + assert not sudo_marker.exists() diff --git a/tests/test_prove_installed_shell.py b/tests/test_prove_installed_shell.py new file mode 100644 index 000000000..fa32d4868 --- /dev/null +++ b/tests/test_prove_installed_shell.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).parent.parent +PROOF_SCRIPT = PROJECT_ROOT / "scripts" / "prove-installed-shell.py" + + +def _fake_capsem(tmp_path: Path, *, execute_input: bool) -> tuple[Path, Path]: + log = tmp_path / "calls.log" + binary = tmp_path / "capsem" + shell_body = ( + 'IFS= read -r command\n/bin/sh -c "$command"\nIFS= read -r _ || true\n' + if execute_input + else 'IFS= read -r command\nprintf "%s\\n" "$command"\nsleep 5\n' + ) + binary.write_text( + "#!/bin/sh\n" + 'printf \'%s\\n\' "$*" >> "$CAPSEM_FAKE_LOG"\n' + 'case "$1" in\n' + " create) exit 0 ;;\n" + " delete) exit 0 ;;\n" + " shell)\n" + " printf 'guest shell ready\\n'\n" + f"{shell_body}" + " ;;\n" + " *) exit 2 ;;\n" + "esac\n", + encoding="utf-8", + ) + binary.chmod(0o755) + return binary, log + + +def test_shell_proof_requires_guest_executed_marker(tmp_path: Path) -> None: + binary, log = _fake_capsem(tmp_path, execute_input=True) + env = os.environ.copy() + env["CAPSEM_FAKE_LOG"] = str(log) + + result = subprocess.run( + [ + "python3", + str(PROOF_SCRIPT), + "--capsem", + str(binary), + "--marker", + "CAPSEM_TEST_GUEST_SHELL_OK", + "--session-name", + "proof-session", + "--startup-delay", + "0", + "--timeout", + "5", + ], + cwd=PROJECT_ROOT, + env=env, + capture_output=True, + text=True, + timeout=10, + ) + + assert result.returncode == 0, result.stderr + assert "CAPSEM_TEST_GUEST_SHELL_OK" in result.stdout + calls = log.read_text(encoding="utf-8").splitlines() + assert calls[0] == "create --name proof-session" + assert "shell --name proof-session" in calls + assert calls[-1] == "delete proof-session" + + +def test_shell_proof_rejects_typed_but_unexecuted_command(tmp_path: Path) -> None: + binary, log = _fake_capsem(tmp_path, execute_input=False) + env = os.environ.copy() + env["CAPSEM_FAKE_LOG"] = str(log) + + result = subprocess.run( + [ + "python3", + str(PROOF_SCRIPT), + "--capsem", + str(binary), + "--marker", + "CAPSEM_MUST_NOT_MATCH_ECHOED_INPUT", + "--session-name", + "proof-session", + "--startup-delay", + "0", + "--timeout", + "1", + ], + cwd=PROJECT_ROOT, + env=env, + capture_output=True, + text=True, + timeout=10, + ) + + assert result.returncode != 0 + assert "guest shell marker was not observed" in result.stderr + assert log.read_text(encoding="utf-8").splitlines()[-1] == "delete proof-session" diff --git a/tests/test_release_doctor_contract.py b/tests/test_release_doctor_contract.py index fd9816ed6..e58624e32 100644 --- a/tests/test_release_doctor_contract.py +++ b/tests/test_release_doctor_contract.py @@ -174,13 +174,17 @@ def test_ci_has_stable_pr_gate_over_all_required_jobs() -> None: assert 'test "$RELEASE_SITE_BUILD_RESULT" = success' in gate assert "astral-sh/setup-uv@v5" in release_site_job assert "uv sync --frozen" in release_site_job - assert "uv run python scripts/write-release-site-ci-fixture.py target/release-site-fixture" in release_site_job + assert ( + "uv run python scripts/write-release-site-ci-fixture.py target/release-site-fixture" + in release_site_job + ) assert release_site_job.index( "uv run python scripts/write-release-site-ci-fixture.py target/release-site-fixture" - ) < release_site_job.index( - "cargo run -p capsem-admin -- assets channel build" + ) < release_site_job.index("cargo run -p capsem-admin -- assets channel build") + assert ( + '--manifest "file://$PWD/target/release-site-fixture/assets/manifest.json"' + in release_site_job ) - assert '--manifest "file://$PWD/target/release-site-fixture/assets/manifest.json"' in release_site_job assert "--assets-dir target/release-site-fixture/assets" in release_site_job @@ -227,7 +231,9 @@ def test_pr_gate_blocks_broken_docs_and_marketing_builds() -> None: assert "docs-build" in docs_ci assert "site-build" in docs_ci - assert "`pr-gate` depends on `docs-build`, `site-build`, and `release-site-build`" in docs_ci_text + assert ( + "`pr-gate` depends on `docs-build`, `site-build`, and `release-site-build`" in docs_ci_text + ) def test_ci_test_steps_do_not_mask_failures_with_true() -> None: @@ -330,12 +336,10 @@ def test_vm_asset_release_is_manual_and_deploys_asset_channel() -> None: assert "Dry run: skipping Cloudflare Pages project preflight." in workflow assert "RELEASE_CHANNEL_PROJECT: release" in workflow assert "python scripts/check-cloudflare-pages-project.py" in workflow - assert "--project \"$RELEASE_CHANNEL_PROJECT\"" in workflow + assert '--project "$RELEASE_CHANNEL_PROJECT"' in workflow assert "needs: cloudflare-release-site-preflight" in workflow assert workflow.index("cloudflare-release-site-preflight:") < workflow.index("build-assets:") - assert workflow.index("Cloudflare release site preflight") < workflow.index( - "Build VM assets" - ) + assert workflow.index("Cloudflare release site preflight") < workflow.index("Build VM assets") assert "just build-kernel" in workflow assert "just build-rootfs" in workflow assert "cargo run -p capsem-admin -- manifest generate assets" in workflow @@ -368,7 +372,10 @@ def test_vm_asset_release_is_manual_and_deploys_asset_channel() -> None: assert "asset_changed: ${{ steps.asset-delta.outputs.changed }}" in workflow assert "asset_blobs_changed: ${{ steps.asset-delta.outputs.asset_blobs_changed }}" in workflow assert "if: ${{ steps.asset-delta.outputs.asset_blobs_changed == 'true' }}" in workflow - assert "if: ${{ inputs.dry_run == false && steps.asset-delta.outputs.asset_blobs_changed == 'true' }}" in workflow + assert ( + "if: ${{ inputs.dry_run == false && steps.asset-delta.outputs.asset_blobs_changed == 'true' }}" + in workflow + ) assert "--json-output target/asset-release-delta/delta.json" in workflow assert "name: asset-release-delta" in workflow assert "path: target/asset-release-delta/" in workflow @@ -427,7 +434,7 @@ def test_asset_channel_deploy_consumes_generated_dist_artifact() -> None: assert "Verify Cloudflare Pages project" in workflow assert "RELEASE_CHANNEL_PROJECT: release" in workflow assert "python scripts/check-cloudflare-pages-project.py" in workflow - assert "--project \"$RELEASE_CHANNEL_PROJECT\"" in workflow + assert '--project "$RELEASE_CHANNEL_PROJECT"' in workflow assert workflow.index("Require Cloudflare credentials") < workflow.index( "Verify Cloudflare Pages project" ) @@ -439,10 +446,13 @@ def test_asset_channel_deploy_consumes_generated_dist_artifact() -> None: in workflow ) assert "assets/stable/manifest.json" not in workflow - assert "RELEASE_SITE_URL: ${{ inputs.release_site_url || 'https://release.capsem.org' }}" in workflow + assert ( + "RELEASE_SITE_URL: ${{ inputs.release_site_url || 'https://release.capsem.org' }}" + in workflow + ) assert "Validate deployed asset channel content" in workflow assert "uv run python scripts/check-release-site-contract.py" in workflow - assert "--base-url \"$RELEASE_SITE_URL\"" in workflow + assert '--base-url "$RELEASE_SITE_URL"' in workflow assert "--channel stable" in workflow assert "--channel nightly" in workflow assert "--attempts 30" in workflow @@ -460,10 +470,10 @@ def test_release_channel_deploy_runs_python_contract_validator_after_cloudflare_ assert "Validate deployed asset channel content" in workflow assert "uv run python scripts/check-release-site-contract.py" in validator_step - assert "--base-url \"$RELEASE_SITE_URL\"" in validator_step + assert '--base-url "$RELEASE_SITE_URL"' in validator_step assert "--channel stable" in validator_step assert "--channel nightly" in validator_step - assert "--channel \"$CHANNEL\"" not in validator_step + assert '--channel "$CHANNEL"' not in validator_step assert "--attempts 30" in validator_step assert "--delay-seconds 20" in validator_step assert workflow.index("cloudflare/wrangler-action@v3") < workflow.index( @@ -471,7 +481,9 @@ def test_release_channel_deploy_runs_python_contract_validator_after_cloudflare_ ) -def test_release_channel_staging_workflow_exercises_reusable_deploy_without_release_builds() -> None: +def test_release_channel_staging_workflow_exercises_reusable_deploy_without_release_builds() -> ( + None +): workflow = _workflow_text("release-channel-staging.yaml") reusable = _workflow_text("release-channel.yaml") docs = (PROJECT_ROOT / "docs/src/content/docs/development/ci.md").read_text() @@ -504,7 +516,9 @@ def test_release_channel_staging_workflow_exercises_reusable_deploy_without_rele for text in (docs, release_skill, asset_skill): assert "release-channel-staging.yaml" in text - assert "without invoking `build-assets`" in text or "without invoking VM asset builds" in text + assert ( + "without invoking `build-assets`" in text or "without invoking VM asset builds" in text + ) def test_release_site_contract_script_fails_on_content_drift(capsys) -> None: @@ -526,8 +540,7 @@ def check_release_site_contract(release_site: str, channel: str): ok=False, name="release.capsem.org contract", detail=( - "health asset hash mismatch for " - "/assets/releases/2030.0101.1/arm64-vmlinuz" + "health asset hash mismatch for /assets/releases/2030.0101.1/arm64-vmlinuz" ), ) @@ -545,9 +558,7 @@ def check_release_site_contract(release_site: str, channel: str): assert "arm64-vmlinuz" in captured.err -def test_release_site_contract_cli_validates_each_requested_channel( - monkeypatch, capsys -) -> None: +def test_release_site_contract_cli_validates_each_requested_channel(monkeypatch, capsys) -> None: validator = _release_site_contract_module() checked_channels: list[str] = [] @@ -597,9 +608,7 @@ def check_release_site_contract(release_site: str, channel: str): assert "nightly release-channel contract passed" in captured.out -def test_release_site_contract_cli_retries_requested_channels_as_a_set( - monkeypatch, capsys -) -> None: +def test_release_site_contract_cli_retries_requested_channels_as_a_set(monkeypatch, capsys) -> None: validator = _release_site_contract_module() checks: list[str] = [] sleep_calls: list[float] = [] @@ -659,9 +668,7 @@ def check_release_site_contract(release_site: str, channel: str): assert "nightly release-channel contract passed" in captured.out -def test_release_site_contract_retries_clear_cached_remote_fetches( - monkeypatch, capsys -) -> None: +def test_release_site_contract_retries_clear_cached_remote_fetches(monkeypatch, capsys) -> None: validator = _release_site_contract_module() cache_clears = 0 checks: list[tuple[int, str]] = [] @@ -905,7 +912,7 @@ def test_linux_doctor_installs_musl_c_toolchain_before_building_assets() -> None assert '_reg linux-musl-tools "_doctor_install_linux_musl_tools"' in doctor assert doctor.index("_reg linux-musl-tools") < doctor.index("_reg build-assets") assert "check_linux_musl_toolchain" in doctor - assert "section \"C Toolchain\"" in linux + assert 'section "C Toolchain"' in linux assert "command -v musl-gcc" in linux assert "command -v x86_64-linux-musl-gcc" in linux assert "apt-get install -y musl-tools" in linux @@ -953,11 +960,16 @@ def test_cross_surface_update_smoke_prerequisites_are_covered_locally() -> None: assert "summarizes mixed binary and VM asset updates without profile noise" in frontend assert "treats profile catalog updates as a first-class available track" in frontend - assert "keeps blocked profile dashboard tracks visible beside available asset tracks" in frontend + assert ( + "keeps blocked profile dashboard tracks visible beside available asset tracks" in frontend + ) assert "Binary, VM assets available" in frontend assert "VM assets available for future sessions" in frontend - assert "applies binary/profile and asset update actions through typed confirmed bodies" in frontend_api + assert ( + "applies binary/profile and asset update actions through typed confirmed bodies" + in frontend_api + ) assert "plans update actions without confirmation only through dry runs" in frontend_api assert "applyUpdateAction('binary_profiles'" in frontend_api assert "applyUpdateAction('assets'" in frontend_api @@ -986,7 +998,15 @@ def test_docs_and_marketing_sites_build_on_pr_and_deploy_on_main_only() -> None: ), ] - for workflow_name, directory, ci_job, project_name, smoke_name, site_url, failure in expectations: + for ( + workflow_name, + directory, + ci_job, + project_name, + smoke_name, + site_url, + failure, + ) in expectations: workflow = _workflow_text(workflow_name) trigger = workflow.split("\njobs:", maxsplit=1)[0] push_trigger = trigger.split(" push:", maxsplit=1)[1] @@ -1042,7 +1062,10 @@ def test_binary_release_uses_asset_channel_and_does_not_publish_vm_assets() -> N assert "cancel-in-progress: false" in workflow assert "RELEASE_TAG: ${{ inputs.tag }}" in workflow assert "RELEASE_CHANNEL: ${{ inputs.channel }}" in workflow - assert "ASSET_MANIFEST_URL: https://release.capsem.org/assets/${{ inputs.channel }}/manifest.json" in workflow + assert ( + "ASSET_MANIFEST_URL: https://release.capsem.org/assets/${{ inputs.channel }}/manifest.json" + in workflow + ) assert "Verify immutable dispatch tag and channel" in workflow assert 'test "$GITHUB_REF_TYPE" = tag' in workflow assert 'test "$GITHUB_REF_NAME" = "$RELEASE_TAG"' in workflow @@ -1095,12 +1118,12 @@ def test_binary_release_uses_asset_channel_and_does_not_publish_vm_assets() -> N record_step = assemble_channel.split( "- name: Record binary release metadata in selected channel manifest", maxsplit=1 )[1].split("- name: Prove binary lane did not change VM assets", maxsplit=1)[0] - assert 'target/binary-channel/$RELEASE_CHANNEL/manifest.json' in record_step + assert "target/binary-channel/$RELEASE_CHANNEL/manifest.json" in record_step assert "for channel in" not in record_step build_channels = assemble_channel.split( "- name: Build release channels with existing VM assets", maxsplit=1 )[1].split("- name: Build release site pages", maxsplit=1)[0] - assert 'generated_at="$(date -u +\'%Y-%m-%dT%H:%M:%SZ\')"' in build_channels + assert "generated_at=\"$(date -u +'%Y-%m-%dT%H:%M:%SZ')\"" in build_channels assert '--generated-at "$generated_at"' in build_channels assert build_channels.index('generated_at="$(date -u') < build_channels.index( "for channel in stable nightly" @@ -1208,7 +1231,10 @@ def test_binary_release_does_not_publish_latest_json_updater_metadata() -> None: docs_text = " ".join(docs.split()) assert "binary freshness comes from the selected manifest in the release graph" in docs_text assert "releases do not rebuild or upload profile images, and they do not publish" in docs_text - assert "`latest.json`; binary freshness comes from the selected manifest in the release graph" in docs_text + assert ( + "`latest.json`; binary freshness comes from the selected manifest in the release graph" + in docs_text + ) assert "`latest.json` is absent in the current release rail" in release_skill assert "Do not make release creation depend on `latest.json`" in release_skill @@ -1263,12 +1289,8 @@ def test_binary_release_runs_complete_canonical_gate_in_ci() -> None: assert workflow.count("run: just test") == 1 assert "cargo llvm-cov --workspace --bins --no-cfg-coverage" not in gate assert "Create stub v2 asset manifest for unit tests" not in gate - assert "needs: [preflight, test]" in _workflow_job_block( - "build-app-macos", "release.yaml" - ) - assert "needs: [preflight, test]" in _workflow_job_block( - "build-app-linux", "release.yaml" - ) + assert "needs: [preflight, test]" in _workflow_job_block("build-app-macos", "release.yaml") + assert "needs: [preflight, test]" in _workflow_job_block("build-app-linux", "release.yaml") assert "temporary GitHub-hosted exception" in agents assert "Temporary hosted-CI exception" in testing_skill assert "Temporary hosted-CI exception" in release_skill @@ -1288,18 +1310,41 @@ def test_binary_release_installs_exact_artifacts_before_publication() -> None: assert 'sudo /usr/sbin/installer -pkg "packages/Capsem-$VERSION.pkg" -target /' in macos assert 'test -x "$HOME/.capsem/bin/capsem"' in macos assert '"$HOME/.capsem/bin/capsem" --version | grep -F "$VERSION"' in macos - assert macos.index("Notarize and staple .pkg") < macos.index( - "Install exact notarized package" - ) < macos.index("Collect macOS artifacts") + assert 'test -d "/Applications/Capsem.app"' in macos + assert ( + "for bin in capsem capsem-admin capsem-gateway capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-process capsem-service capsem-tray capsem-tui" + in macos + ) + assert 'grep -F "Installed: true" /tmp/capsem-status.txt' in macos + assert 'grep -F "Running: true" /tmp/capsem-status.txt' in macos + assert ( + macos.index("Notarize and staple .pkg") + < macos.index("Install exact notarized package") + < macos.index("Collect macOS artifacts") + ) assert "Post-install full gate (just test)" not in macos assert "run: just test" not in macos assert "Install exact release deb" in linux assert "sudo dpkg -i target/release/bundle/deb/*.deb" in linux assert "test -x /usr/bin/capsem" in linux assert '/usr/bin/capsem --version | grep -F "$VERSION"' in linux - assert linux.index("Repack .deb with companion binaries") < linux.index( - "Install exact release deb" - ) < linux.index("Collect Linux artifacts") + assert ( + "for bin in capsem capsem-admin capsem-app capsem-gateway capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-process capsem-service capsem-tray capsem-tui" + in linux + ) + assert "dpkg-query -W -f='${Version}' capsem | grep -Fx \"$VERSION\"" in linux + assert 'grep -F "Installed: true" /tmp/capsem-status.txt' in linux + assert 'grep -F "Running: true" /tmp/capsem-status.txt' in linux + assert "Enable KVM for exact-package VM proof" in linux + assert "test -r /dev/kvm -a -w /dev/kvm" in linux + assert "scripts/prove-installed-shell.py" in linux + assert "CAPSEM_EXACT_PACKAGE_SHELL_OK" in linux + assert "/usr/bin/capsem run" not in linux + assert ( + linux.index("Repack .deb with companion binaries") + < linux.index("Install exact release deb") + < linux.index("Collect Linux artifacts") + ) assert "Post-install full gate (just test)" not in linux assert "run: just test" not in linux assert "needs: [test, build-app-macos, build-app-linux]" in create_release @@ -1307,6 +1352,34 @@ def test_binary_release_installs_exact_artifacts_before_publication() -> None: assert "continue-on-error: true" not in create_release +def test_install_gate_rebuilds_missing_base_image_before_derived_image() -> None: + recipe = _recipe_block("test-install:") + + base_check = "docker image inspect capsem-host-builder:latest" + base_build = "just build-host-image" + derived_build = 'docker build -t "$IMAGE" -f docker/Dockerfile.install-test .' + assert base_check in recipe + assert base_build in recipe + assert recipe.index(base_check) < recipe.index(base_build) < recipe.index(derived_build) + + +def test_release_skill_requires_ci_and_local_mac_installer_outcome_proof() -> None: + release_skill = _source_text("skills/release-process/SKILL.md") + + assert "Installer outcome gate" in release_skill + assert "exact publishable `.pkg`" in release_skill + assert "exact publishable `.deb`" in release_skill + assert "Linux CI installed-product proof" in release_skill + assert "macOS CI exact-package proof" in release_skill + assert "Final local macOS installed-product proof" in release_skill + assert "sudo /usr/sbin/installer -pkg" in release_skill + assert "capsem shell" in release_skill + assert "manual GUI click-through" in release_skill + assert "does not count as release proof" in release_skill + assert "forward-only release" in release_skill + assert "Do not call the release complete" in release_skill + + def test_release_recipe_dispatches_one_parameterized_workflow() -> None: recipe = _recipe_block("release") @@ -1335,8 +1408,9 @@ def test_self_update_docs_match_verified_package_execution() -> None: assert "/usr/sbin/installer -pkg {cached} -target /" in install_tests assert "apt-get install --yes {cached}" in install_tests assert "and print the tested package-manager apply command (`sudo" not in install_skill - assert "downloads verified binary installers, prints the package-manager apply command," not in ( - architecture_skill + assert ( + "downloads verified binary installers, prints the package-manager apply command," + not in (architecture_skill) ) assert "prints the\ntested package-manager apply command for the verified package" not in ( service_docs @@ -1794,9 +1868,14 @@ def copy_manifest_to_wrong_url(payloads: dict[str, bytes], _checker) -> None: result = checker.check_release_site_contract(fixture["site"], fixture["channel"]) assert not result.ok - assert "release index stable missing manifest URL /assets/nightly/manifest.json" in result.detail + assert ( + "release index stable missing manifest URL /assets/nightly/manifest.json" in result.detail + ) assert "channel page stable missing manifest URL /assets/nightly/manifest.json" in result.detail - assert "unexpected header fetch https://release.capsem.org/assets/nightly/manifest.json" in result.detail + assert ( + "unexpected header fetch https://release.capsem.org/assets/nightly/manifest.json" + in result.detail + ) def test_remote_readiness_rejects_profile_catalog_artifact_drift() -> None: @@ -1818,8 +1897,7 @@ def stale_rootfs_digest(manifest: dict[str, object]) -> None: assert not result.ok assert ( "profile co-work architecture arm64 image " - "/assets/releases/2030.0101.1/arm64-rootfs.erofs blake3 mismatch" - in result.detail + "/assets/releases/2030.0101.1/arm64-rootfs.erofs blake3 mismatch" in result.detail ) @@ -1907,20 +1985,27 @@ def test_binary_release_verifies_packages_hydrate_vm_assets_from_public_channel( assert "scripts/check-public-binary-release.py" in verify_downloads assert '--channel "$RELEASE_CHANNEL"' in verify_downloads assert ( - "--stable-manifest-url " - "https://release.capsem.org/assets/stable/manifest.json" + "--stable-manifest-url https://release.capsem.org/assets/stable/manifest.json" ) in verify_downloads assert ( - "--nightly-manifest-url " - "https://release.capsem.org/assets/nightly/manifest.json" + "--nightly-manifest-url https://release.capsem.org/assets/nightly/manifest.json" ) in verify_downloads assert '--manifest-url "$ASSET_MANIFEST_URL"' in verify_downloads assert "--install-script-url https://capsem.org/install.sh" in verify_downloads - assert "--docker-linux-install" in verify_downloads - assert "--docker-channel-switch" in verify_downloads - assert "--docker-upgrade" in verify_downloads - assert "Glow-up verify public install, channel switch, and upgrade" in verify_downloads - assert "curl -fsSL https://capsem.org/install.sh | sh" in verify_downloads + assert "--docker-linux-install" not in verify_downloads + assert "Enable KVM for live public-install VM proof" in verify_downloads + assert "Install live public Linux release and prove guest shell execution" in verify_downloads + assert ( + 'curl -fsSL https://capsem.org/install.sh | CAPSEM_CHANNEL="$RELEASE_CHANNEL" sh' + in verify_downloads + ) + assert "dpkg-query -W -f='${Version}' capsem" in verify_downloads + assert 'grep -F "Running: true" /tmp/capsem-live-status.txt' in verify_downloads + assert 'grep -F "Service: ok" /tmp/capsem-live-status.txt' in verify_downloads + assert 'grep -F "Gateway: ok" /tmp/capsem-live-status.txt' in verify_downloads + assert "scripts/prove-installed-shell.py" in verify_downloads + assert "CAPSEM_LIVE_PUBLIC_INSTALL_SHELL_OK" in verify_downloads + assert '"$HOME/.capsem/bin/capsem" run' not in verify_downloads assert "skipping binary e2e" not in verify_downloads assert "::warning::no .deb" not in verify_downloads assert "::warning::no 'capsem' CLI" not in verify_downloads @@ -2004,8 +2089,7 @@ def test_asset_channel_documented_as_assets_manifest_url_not_release_index_json( assert "per-channel manifest JSON" in release_skill assert "package artifacts separately from the per-binary inventory" in release_skill_text assert ( - "Profiles own their config files, profile images, ABOM/OBOM evidence" - in release_skill_text + "Profiles own their config files, profile images, ABOM/OBOM evidence" in release_skill_text ) assert "channels/stable/index.json" not in release_skill @@ -2032,7 +2116,10 @@ def test_release_skill_keeps_binary_and_asset_verification_decoupled() -> None: assert "gh release download vX.Y.Z --pattern manifest.json" not in release_skill assert "VM asset manifests" in release_skill assert "root channel catalog live on" in release_skill - assert "`ci.yaml` runs `docs-build`, `site-build`, and `release-site-build` under `pr-gate`" in release_skill_text + assert ( + "`ci.yaml` runs `docs-build`, `site-build`, and `release-site-build` under `pr-gate`" + in release_skill_text + ) assert "`docs.yaml` and `site.yaml` deploy and smoke only on" in release_skill assert "`https://docs.capsem.org/` plus `/getting-started/`" in release_skill assert "`https://capsem.org/` for marketing" in release_skill @@ -2249,7 +2336,9 @@ def test_ci_docs_describes_three_independent_publication_rails() -> None: assert "`https://release.capsem.org/` index" in docs assert "`/channels.json`, and" in docs assert "`/assets//manifest.json` before the workflow can pass" in docs - assert "`docs.yaml` and `site.yaml` are independent from binary and profile image release" in docs + assert ( + "`docs.yaml` and `site.yaml` are independent from binary and profile image release" in docs + ) assert "`https://docs.capsem.org/`, content type `text/html`" in docs assert "`https://capsem.org/`, content type `text/html`" in docs @@ -2306,8 +2395,8 @@ def test_remote_release_readiness_checker_is_read_only_and_covers_live_gates() - docs_text = " ".join(docs.split()) assert "Read-only remote release readiness checks" in script - assert "git\", \"rev-list\", \"--left-right\", \"--count\"" in script - assert "gh\", \"workflow\", \"view\", \"ci.yaml\"" in script + assert 'git", "rev-list", "--left-right", "--count"' in script + assert 'gh", "workflow", "view", "ci.yaml"' in script assert "branches/{branch}/protection" in script assert "repos/{repo}/rules/branches/{branch}" in script assert "socket.getaddrinfo" in script @@ -2338,8 +2427,9 @@ def test_remote_release_readiness_checker_is_read_only_and_covers_live_gates() - assert "scripts/check-remote-release-readiness.py" in docs assert "read-only" in docs assert "remote `ci.yaml` exposes `pr-gate`" in docs_text - assert "aggregates `test-linux`, `test`, `test-install`, `docs-build`, `site-build`, and `release-site-build`" in ( - docs_text + assert ( + "aggregates `test-linux`, `test`, `test-install`, `docs-build`, `site-build`, and `release-site-build`" + in (docs_text) ) assert "runs with `if: ${{ always() }}` and asserts every dependency result" in docs_text assert "branch protection or active branch rulesets require `pr-gate`" in docs_text @@ -2443,10 +2533,7 @@ def test_live_release_activation_order_is_documented() -> None: "provision the `release.capsem.org` cloudflare pages project and dns" in normalized_lower ) - assert ( - "run `uv run python scripts/check-remote-release-readiness.py`" - in normalized_lower - ) + assert "run `uv run python scripts/check-remote-release-readiness.py`" in normalized_lower assert ( "manual VM asset workflow as a dry run" in normalized or "manual profile image workflow as a dry run" in normalized @@ -2472,9 +2559,7 @@ def test_live_release_activation_order_is_documented() -> None: assert "installed update smokes" in normalized assert normalized_lower.index( "merge the release-rail commits to `main` only after" - ) < normalized_lower.index( - "require only `pr-gate` in branch protection or active rulesets" - ) + ) < normalized_lower.index("require only `pr-gate` in branch protection or active rulesets") assert normalized_lower.index( "provision the `release.capsem.org` cloudflare pages project and dns" ) < min( @@ -2524,7 +2609,9 @@ def test_remote_release_readiness_requires_expanded_pr_gate() -> None: """.strip() stale = inline.replace(", docs-build, site-build, release-site-build", "") non_failing = inline + "\n steps:\n - run: echo ok\n" - fail_closed = inline + """ + fail_closed = ( + inline + + """ if: ${{ always() }} steps: - name: Require all CI jobs @@ -2543,6 +2630,7 @@ def test_remote_release_readiness_requires_expanded_pr_gate() -> None: test "$SITE_BUILD_RESULT" = success test "$RELEASE_SITE_BUILD_RESULT" = success """ + ) assert module.workflow_job_needs(module.workflow_job_block(inline, "pr-gate")) == { "test-linux", @@ -2565,12 +2653,8 @@ def test_remote_release_readiness_requires_expanded_pr_gate() -> None: "site-build", "release-site-build", }.issubset(module.workflow_job_needs(module.workflow_job_block(stale, "pr-gate"))) - assert module.pr_gate_contract_failures( - module.workflow_job_block(fail_closed, "pr-gate") - ) == [] - assert module.pr_gate_contract_failures( - module.workflow_job_block(non_failing, "pr-gate") - ) == [ + assert module.pr_gate_contract_failures(module.workflow_job_block(fail_closed, "pr-gate")) == [] + assert module.pr_gate_contract_failures(module.workflow_job_block(non_failing, "pr-gate")) == [ "pr-gate does not run with if: ${{ always() }}", "pr-gate does not assert test-linux result", "pr-gate does not assert test result", @@ -2659,8 +2743,8 @@ def test_remote_release_readiness_requires_active_pr_gate_rule() -> None: assert not module.active_branch_rules_require_pr_gate( [{"type": "pull_request", "parameters": {"message": "mention pr-gate only"}}] ) - assert 'repos/{repo}/rules/branches/{branch}' in script - assert 'repos/{repo}/rulesets/{ruleset_id}' not in script + assert "repos/{repo}/rules/branches/{branch}" in script + assert "repos/{repo}/rulesets/{ruleset_id}" not in script assert "active branch rules" in script assert "branch protection or active branch rulesets require `pr-gate`" in docs_text @@ -2753,7 +2837,10 @@ def fake_fetch_bytes(url: str): corrupted = json.loads(json.dumps(health)) corrupted["evidence"]["vm_oboms"][0]["hash"] = "0" * 64 failures = module.check_release_evidence("https://release.capsem.test", corrupted) - assert "VM OBOM evidence /assets/releases/2030.0101.1/arm64-obom.cdx.json blake3 mismatch" in failures + assert ( + "VM OBOM evidence /assets/releases/2030.0101.1/arm64-obom.cdx.json blake3 mismatch" + in failures + ) assert "def check_release_evidence" in script assert '"sha256", "host SBOM evidence", "spdx"' in script @@ -2774,8 +2861,7 @@ def test_remote_release_readiness_checker_verifies_vm_asset_file_content() -> No script = (PROJECT_ROOT / "scripts/check-remote-release-readiness.py").read_text() workflow = _workflow_text("release-channel.yaml") rootfs_url = ( - "https://github.com/google/capsem/releases/download/" - "assets-v2030.0101.1/arm64-rootfs.erofs" + "https://github.com/google/capsem/releases/download/assets-v2030.0101.1/arm64-rootfs.erofs" ) rootfs_bytes = b"rootfs-content" @@ -2804,7 +2890,7 @@ def test_remote_release_readiness_checker_verifies_vm_asset_file_content() -> No "https://release.capsem.org", item, "blake3", "VM asset file" ) ) - assert 'fetch_and_verify_evidence_artifact(' in script + assert "fetch_and_verify_evidence_artifact(" in script assert '"VM asset file"' in script assert "uv run python scripts/check-release-site-contract.py" in workflow @@ -3006,7 +3092,9 @@ def fake_fetch_bytes(url: str): assert "health evidence host_sboms missing for published binary files" in failures -def test_release_channel_smoke_and_remote_readiness_validate_matching_attestation_predicate_evidence() -> None: +def test_release_channel_smoke_and_remote_readiness_validate_matching_attestation_predicate_evidence() -> ( + None +): module = _readiness_checker_module() script = (PROJECT_ROOT / "scripts/check-remote-release-readiness.py").read_text() workflow = _workflow_text("release-channel.yaml") @@ -3335,9 +3423,7 @@ def fake_fetch_headers(url: str): } ] assert ( - module.check_release_cache_headers( - "https://release.capsem.test", "stable", asset_files - ) + module.check_release_cache_headers("https://release.capsem.test", "stable", asset_files) == [] ) assert calls == list(headers) @@ -3354,7 +3440,7 @@ def fake_fetch_headers(url: str): ) in failures assert "def check_release_cache_headers" in script - assert "release_site_request(url, method=\"HEAD\")" in script + assert 'release_site_request(url, method="HEAD")' in script assert "RELEASE_VALIDATOR_USER_AGENT" in script assert "Cache-Control must contain {directive}" in script assert "max-age=31536000" in script @@ -3691,6 +3777,12 @@ def test_binary_update_installer_scripts_replace_and_restart_full_helper_cohort( deb_postinst ) assert "event=service_install_invoked" in deb_postinst + assert 'capsem install" 2>/dev/null || true' not in deb_postinst + assert "event=service_registration_failed" in deb_postinst + assert "event=readiness_poll" in deb_postinst + assert 'grep -q "Service: ok"' in deb_postinst + assert 'grep -q "Gateway: ok"' in deb_postinst + assert "event=service_not_ready" in deb_postinst def test_helper_version_surfaces_support_installed_update_smoke() -> None: @@ -4339,8 +4431,17 @@ def test_guest_init_persists_boot_diagnostics_before_agent_launch() -> None: assert launch_log_pos != -1, "init must mark the exact agent launch boundary" assert chroot_pos != -1 assert launch_pos != -1 - assert exit_status_pos != -1, "init must report early agent exits instead of silently idling PID 1" - assert helper_pos < workspace_log_pos < agent_stdio_pos < launch_log_pos < launch_pos < exit_status_pos + assert exit_status_pos != -1, ( + "init must report early agent exits instead of silently idling PID 1" + ) + assert ( + helper_pos + < workspace_log_pos + < agent_stdio_pos + < launch_log_pos + < launch_pos + < exit_status_pos + ) def test_guest_init_publishes_rootfs_binaries_into_run_contract() -> None: @@ -4447,7 +4548,10 @@ def test_linux_vm_launch_preformats_system_overlay_before_boot() -> None: assert "prewarm_system_overlay_templates(&run_dir, &profile_cache)" in service assert "fn prewarm_vm_asset_hash_cache" in service assert "capsem_core::VmConfig::verify_hash(path, hash)" in service - assert "prewarm_vm_asset_hash_cache(&assets_base_dir, manifest.as_deref(), ¤t_version)" in service + assert ( + "prewarm_vm_asset_hash_cache(&assets_base_dir, manifest.as_deref(), ¤t_version)" + in service + ) assert "mke2fs unavailable; guest will format system overlay at first boot" in process assert "lazy_itable_init=1,lazy_journal_init=1" in init assert "-J size=4" in init From 0d8ce43e29c7107f0e9931c731112ccbeafb302f Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Tue, 14 Jul 2026 17:50:13 -0400 Subject: [PATCH 2/3] fix: pin clean-build SSE stream API --- CHANGELOG.md | 2 ++ Cargo.toml | 2 +- crates/capsem-core/src/mcp/server_manager.rs | 4 ++-- tests/test_release_doctor_contract.py | 9 +++++++++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16954873c..d40e3c55a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 inside a KVM-backed guest on Linux. - Made Debian-package SBOM inspection parse the package archive directly, so release acceptance tests work with both BSD and GNU host toolchains. +- Pinned the clean-build SSE stream dependency to its supported patch API so + untracked local resolution state cannot mask release CI compilation drift. ## [1.5.1783894498] - 2026-07-12 diff --git a/Cargo.toml b/Cargo.toml index 385b937a2..3a9e7d373 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -130,7 +130,7 @@ futures = "0.3" tokio-stream = { version = "0.1", features = ["net"] } async-compression = { version = "0.4", features = ["tokio", "gzip"] } tokio-util = { version = "0.7", features = ["io"] } -sse-stream = "0.2" +sse-stream = "=0.2.4" base64 = "0.22" bytes = "1" regex = "1" diff --git a/crates/capsem-core/src/mcp/server_manager.rs b/crates/capsem-core/src/mcp/server_manager.rs index 7ec4e4325..ef4eb04cb 100644 --- a/crates/capsem-core/src/mcp/server_manager.rs +++ b/crates/capsem-core/src/mcp/server_manager.rs @@ -189,7 +189,7 @@ impl StreamableHttpClient for CapsemMcpHttpClient { } None => return Err(StreamableHttpError::UnexpectedContentType(None)), } - Ok(SseStream::from_byte_stream(response.bytes_stream()).boxed()) + Ok(SseStream::from_bytes_stream(response.bytes_stream()).boxed()) } async fn delete_session( @@ -319,7 +319,7 @@ impl StreamableHttpClient for CapsemMcpHttpClient { match content_type.as_deref() { Some(ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { Ok(StreamableHttpPostResponse::Sse( - SseStream::from_byte_stream(response.bytes_stream()).boxed(), + SseStream::from_bytes_stream(response.bytes_stream()).boxed(), session_id, )) } diff --git a/tests/test_release_doctor_contract.py b/tests/test_release_doctor_contract.py index e58624e32..b846b1d7d 100644 --- a/tests/test_release_doctor_contract.py +++ b/tests/test_release_doctor_contract.py @@ -1296,6 +1296,15 @@ def test_binary_release_runs_complete_canonical_gate_in_ci() -> None: assert "Temporary hosted-CI exception" in release_skill +def test_clean_build_pins_sse_stream_api() -> None: + workspace = _source_text("Cargo.toml") + server_manager = _source_text("crates/capsem-core/src/mcp/server_manager.rs") + + assert 'sse-stream = "=0.2.4"' in workspace + assert "SseStream::from_bytes_stream" in server_manager + assert "SseStream::from_byte_stream" not in server_manager + + def test_binary_release_installs_exact_artifacts_before_publication() -> None: workflow = _workflow_text("release.yaml") macos = _workflow_job_block("build-app-macos", "release.yaml") From e94068e252077e315a71cd13331d2260662ed4c7 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Tue, 14 Jul 2026 18:15:29 -0400 Subject: [PATCH 3/3] fix: install release-site deps in macOS CI --- .github/workflows/ci.yaml | 5 ++++- CHANGELOG.md | 2 ++ tests/test_release_doctor_contract.py | 11 +++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 93be004f3..ad9ec436b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -120,7 +120,9 @@ jobs: with: node-version: 24 cache: pnpm - cache-dependency-path: frontend/pnpm-lock.yaml + cache-dependency-path: | + frontend/pnpm-lock.yaml + release-site/pnpm-lock.yaml - uses: astral-sh/setup-uv@v5 - run: uv sync - run: bash scripts/generate-settings.sh @@ -128,6 +130,7 @@ jobs: - run: bash scripts/materialize-config.sh - run: cd frontend && pnpm install --frozen-lockfile - run: cd frontend && pnpm run build + - run: cd release-site && pnpm install --frozen-lockfile - name: Dependency audit run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index d40e3c55a..dd257b882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 release acceptance tests work with both BSD and GNU host toolchains. - Pinned the clean-build SSE stream dependency to its supported patch API so untracked local resolution state cannot mask release CI compilation drift. +- Made macOS CI install the release-site lockfile before integration tests so + cached local Node modules cannot mask clean-runner release-contract failures. ## [1.5.1783894498] - 2026-07-12 diff --git a/tests/test_release_doctor_contract.py b/tests/test_release_doctor_contract.py index b846b1d7d..c4bb1b2ba 100644 --- a/tests/test_release_doctor_contract.py +++ b/tests/test_release_doctor_contract.py @@ -236,6 +236,17 @@ def test_pr_gate_blocks_broken_docs_and_marketing_builds() -> None: ) +def test_macos_ci_installs_release_site_dependencies_before_integration() -> None: + job = _workflow_job_block("test") + install = "cd release-site && pnpm install --frozen-lockfile" + integration = "Python integration tests (non-VM suites)" + + assert "frontend/pnpm-lock.yaml" in job + assert "release-site/pnpm-lock.yaml" in job + assert install in job + assert job.index(install) < job.index(integration) + + def test_ci_test_steps_do_not_mask_failures_with_true() -> None: workflow = (PROJECT_ROOT / ".github" / "workflows" / "ci.yaml").read_text()