diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7c787a5a22..b77616998f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -233,6 +233,15 @@ jobs: - name: Regression test tokenless catalog version mismatch detection run: bash tests/test-check-component-versions.sh + check-build-runtime-deps: + name: Check build runtime dependencies + runs-on: anolisa-k8s-general-ci-x64 + steps: + - uses: actions/checkout@v4 + + - name: Test aggregated runtime dependency preflight + run: bash tests/test-build-all-runtime-deps.sh + # ========================================================================= # Step 3: Build & Lint copilot-shell # ========================================================================= diff --git a/.github/workflows/sec-core-source-code-build.yaml b/.github/workflows/sec-core-source-code-build.yaml index 6df4574dbc..ac44e970c6 100644 --- a/.github/workflows/sec-core-source-code-build.yaml +++ b/.github/workflows/sec-core-source-code-build.yaml @@ -17,6 +17,7 @@ permissions: env: RUSTUP_DIST_SERVER: https://static.rust-lang.org RUSTUP_UPDATE_ROOT: https://static.rust-lang.org/rustup + UV_PYTHON_INSTALL_MIRROR: https://github.com/astral-sh/python-build-standalone/releases/download jobs: build: @@ -36,7 +37,7 @@ jobs: if: matrix.container != '' run: | sed -i -e "s/cloud.aliyuncs/aliyun/g" /etc/yum.repos.d/*.repo - dnf install -y tar git sudo + dnf install -y tar git sudo bubblewrap gnupg2 jq # Fix sudo PAM in container: replace with permissive config cat > /etc/pam.d/sudo <<'EOF' #%PAM-1.0 @@ -45,12 +46,31 @@ jobs: session sufficient pam_permit.so EOF - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Expose Node.js in the system PATH + run: | + sudo ln -sfn "$(command -v node)" /usr/local/bin/node + PATH=/usr/local/bin:/usr/bin:/bin node --version + - name: Install runtime dependencies (Ubuntu 22.04) + if: matrix.container == '' + run: | + sudo apt-get update + sudo apt-get install -y bubblewrap gnupg jq - uses: dtolnay/rust-toolchain@1.93.0 with: components: clippy, rustfmt, rust-src - name: Build and install run: | - ./scripts/build-all.sh --component sec-core + build_status=0 + ./scripts/build-all.sh --component sec-core || build_status=$? + if (( build_status != 0 )); then + echo "::group::sec-core build failure log" + tail -n 200 target/build.log 2>/dev/null || echo "target/build.log is unavailable" + echo "::endgroup::" + exit "$build_status" + fi echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Verify CLI run: | @@ -60,6 +80,17 @@ jobs: run: agent-sec-daemon --help - name: Verify sandbox run: linux-sandbox --help + - name: Verify runtime dependencies + run: | + sec_runtime_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + PATH="$sec_runtime_path" bwrap --version + if PATH="$sec_runtime_path" command -v gpg; then + PATH="$sec_runtime_path" gpg --version + else + PATH="$sec_runtime_path" gpg2 --version + fi + PATH="$sec_runtime_path" node --version + PATH="$sec_runtime_path" jq --version - name: Verify deployment run: | echo "=== Skills ===" @@ -96,6 +127,13 @@ jobs: runs-on: anolisa-k8s-general-ci-x64 steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Expose Node.js in the system PATH + run: | + sudo ln -sfn "$(command -v node)" /usr/local/bin/node + PATH=/usr/local/bin:/usr/bin:/bin node --version - uses: dtolnay/rust-toolchain@1.93.0 with: components: clippy, rustfmt, rust-src @@ -105,7 +143,14 @@ jobs: sudo apt-get install -y passwd util-linux - name: Build and install in system mode run: | - ./scripts/build-all.sh --component sec-core --system + build_status=0 + ./scripts/build-all.sh --component sec-core --system || build_status=$? + if (( build_status != 0 )); then + echo "::group::sec-core build failure log" + tail -n 200 target/build.log 2>/dev/null || echo "target/build.log is unavailable" + echo "::endgroup::" + exit "$build_status" + fi - name: Verify system commands run: | agent-sec-cli --version @@ -113,6 +158,17 @@ jobs: agent-sec-cli scan-pii --help >/dev/null agent-sec-daemon --help linux-sandbox --help + - name: Verify runtime dependencies + run: | + sec_runtime_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + PATH="$sec_runtime_path" bwrap --version + if PATH="$sec_runtime_path" command -v gpg; then + PATH="$sec_runtime_path" gpg --version + else + PATH="$sec_runtime_path" gpg2 --version + fi + PATH="$sec_runtime_path" node --version + PATH="$sec_runtime_path" jq --version - name: Verify system deployment run: | echo "=== System binaries ===" @@ -149,5 +205,6 @@ jobs: esac namei -l /usr/local/lib/anolisa/sec-core/venv/bin/python sudo useradd -m -s /bin/bash secprobe || true + sudo -u secprobe env PATH=/usr/local/bin:/usr/bin:/bin node --version sudo -u secprobe /usr/local/bin/agent-sec-cli --version sudo -u secprobe /usr/local/bin/agent-sec-cli scan-pii --help >/dev/null diff --git a/scripts/build-all.sh b/scripts/build-all.sh index baf7c6efa1..7a8826ee83 100755 --- a/scripts/build-all.sh +++ b/scripts/build-all.sh @@ -73,6 +73,7 @@ INSTALL_EXTENSIONS_DIR="$USER_COSH_EXTENSIONS_DIR" SEC_CORE_BIN_DIR="" SEC_CORE_LIB_DIR="" SEC_CORE_RUST_TOOLCHAIN="1.93.0" +RUNTIME_SYSTEM_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" # ─── output / staging ─── @@ -604,17 +605,24 @@ query_repo_ver() { fi } +node_version_satisfies_on_path() { + local command_path="$1" required="$2" version + version="$(PATH="$command_path" node -v 2>/dev/null)" || return 1 + version="$(extract_ver "$version" || true)" + [[ -n "$version" ]] && ver_gte "$version" "$required" +} + install_node() { step "Node.js (for copilot-shell)" local REQUIRED="20.0.0" + local NVM_INSTALL_MAJOR="24" local node_pkg="nodejs" npm_pkg="npm" _node_ver_ok() { - cmd_exists node || return 1 - local v - v=$(extract_ver "$(node -v 2>/dev/null)" || echo "") - [[ -n "$v" ]] && ver_gte "$v" "$REQUIRED" + local command_path="$PATH" + [[ "$INSTALL_MODE" == "system" ]] && command_path="$RUNTIME_SYSTEM_PATH" + node_version_satisfies_on_path "$command_path" "$REQUIRED" } _source_nvm() { @@ -626,7 +634,9 @@ install_node() { _configure_npm_mirror if _node_ver_ok; then - ok "Node.js $(node -v) already installed, skipping" + local command_path="$PATH" + [[ "$INSTALL_MODE" == "system" ]] && command_path="$RUNTIME_SYSTEM_PATH" + ok "Node.js $(PATH="$command_path" node -v) already installed, skipping" return 0 fi @@ -637,7 +647,9 @@ install_node() { if [[ "$PKG_BASE" == "deb" ]]; then sudo apt-get update -y 2>/dev/null || true; fi sudo $PKG_INSTALL $node_pkg $npm_pkg 2>/dev/null || true if _node_ver_ok; then - ok "Node.js $(node -v) installed via package manager" + local command_path="$PATH" + [[ "$INSTALL_MODE" == "system" ]] && command_path="$RUNTIME_SYSTEM_PATH" + ok "Node.js $(PATH="$command_path" node -v) installed via package manager" return 0 fi warn "Package manager install did not satisfy version requirement" @@ -645,6 +657,10 @@ install_node() { info "Repository $node_pkg${repo_ver:+ $repo_ver} does not meet >= $REQUIRED" fi + if [[ "$INSTALL_MODE" == "system" ]]; then + die "Repository Node.js cannot satisfy >= $REQUIRED; install a supported Node.js in the system PATH and retry" + fi + info "Installing Node.js via nvm ..." if [[ "${SHELL}" == */zsh ]]; then touch "$HOME/.zshrc"; else touch "$HOME/.bashrc"; fi @@ -712,7 +728,8 @@ install_node() { fi cmd_exists nvm || die "Failed to install nvm" - nvm install 20 || die "nvm install 20 failed; check network or mirror settings" + nvm install "$NVM_INSTALL_MAJOR" || \ + die "nvm install $NVM_INSTALL_MAJOR failed; check network or mirror settings" _configure_npm_mirror @@ -1400,6 +1417,13 @@ do_install_deps() { if $DRY_RUN; then step "Dependency plan" echo "DRY-RUN: detect Linux distribution and package manager" + if $DO_INSTALL || $DEPS_ONLY; then + if [[ "$INSTALL_MODE" == "system" ]]; then + echo "DRY-RUN: preflight all selected component runtime dependencies" + else + echo "DRY-RUN: preflight platform capabilities before user dependency setup" + fi + fi if want_component cosh || want_component sec-core || want_component sight; then echo "DRY-RUN: check/install Node.js if needed" fi @@ -1421,6 +1445,9 @@ do_install_deps() { if want_component sight; then echo "DRY-RUN: check agentsight eBPF dependencies" fi + if { $DO_INSTALL || $DEPS_ONLY; } && [[ "$INSTALL_MODE" != "system" ]]; then + echo "DRY-RUN: verify all runtime dependencies after user dependency setup" + fi ok "Dependency setup plan generated" return 0 fi @@ -1428,6 +1455,14 @@ do_install_deps() { step "Detecting system" detect_distro + if $DO_INSTALL || $DEPS_ONLY; then + if [[ "$INSTALL_MODE" == "system" ]]; then + preflight_runtime_dependencies || return 1 + else + preflight_runtime_dependencies platform-only || return 1 + fi + fi + if want_component cosh || want_component sec-core || want_component sight; then install_node fi @@ -1457,6 +1492,10 @@ do_install_deps() { check_ebpf_deps fi + if { $DO_INSTALL || $DEPS_ONLY; } && [[ "$INSTALL_MODE" != "system" ]]; then + preflight_runtime_dependencies || return 1 + fi + echo "" ok "Dependency setup complete" } @@ -1799,38 +1838,475 @@ install_skills() { fi } -install_sec_core_runtime_deps() { - if cmd_exists bwrap && { cmd_exists gpg || cmd_exists gpg2; } && cmd_exists jq; then +# Runtime preflight also runs with --ignore-deps, where dependency setup has +# not populated PKG_BASE and PKG_INSTALL. Detection stays non-fatal so every +# selected component can appear in one actionable report. +detect_runtime_package_manager() { + if [[ "$PKG_BASE" =~ ^(deb|rpm)$ && -n "$PKG_INSTALL" ]]; then return 0 fi - if [[ "$INSTALL_MODE" != "system" ]]; then - cmd_exists bwrap || warn "bubblewrap not found; linux-sandbox may not run until it is installed." - if ! cmd_exists gpg && ! cmd_exists gpg2; then - warn "gpg/gpg2 not found; skill signature setup will need GnuPG." + [[ -r /etc/os-release ]] || return 1 + local ID="" ID_LIKE="" + # shellcheck source=/dev/null + source /etc/os-release + + if [[ "${ID:-}" =~ ^(fedora|rhel|centos|anolis|alinux)$ ]] || \ + [[ "${ID_LIKE:-}" =~ (fedora|rhel) ]]; then + PKG_BASE="rpm" + if cmd_exists dnf; then + PKG_INSTALL="dnf install -y" + elif cmd_exists yum; then + PKG_INSTALL="yum install -y" + else + return 1 + fi + elif [[ "${ID:-}" =~ ^(debian|ubuntu)$ ]] || [[ "${ID_LIKE:-}" =~ debian ]]; then + cmd_exists apt-get || return 1 + PKG_BASE="deb" + PKG_INSTALL="apt-get install -y" + else + return 1 + fi +} + +runtime_manifest_path() { + case "$1" in + cosh) echo "$PROJECT_ROOT/src/anolisa/manifests/components/cosh/component.toml" ;; + skills) echo "$PROJECT_ROOT/src/anolisa/manifests/components/os-skills/component.toml" ;; + sec-core) echo "$PROJECT_ROOT/src/agent-sec-core/.anolisa/component.toml" ;; + cosh-ng) echo "$PROJECT_ROOT/src/cosh-ng/.anolisa/component.toml" ;; + tokenless) echo "$PROJECT_ROOT/src/anolisa/manifests/components/tokenless/component.toml" ;; + ws-ckpt) echo "$PROJECT_ROOT/src/anolisa/manifests/components/ws-ckpt/component.toml" ;; + memory) echo "$PROJECT_ROOT/src/anolisa/manifests/components/agent-memory/component.toml" ;; + sight) echo "$PROJECT_ROOT/src/anolisa/manifests/components/agentsight/component.toml" ;; + *) return 1 ;; + esac +} + +# Emits component|name|kind|probe|rpm|deb|check|version|min_kernel records. +runtime_dependencies_for_manifest() { + local component="$1" manifest="$2" + [[ -r "$manifest" ]] || return 1 + awk -v component="$component" ' + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + function quoted_string(value, quote, position, char, escaped, result) { + value = trim(value) + quote = substr(value, 1, 1) + if (quote != "\"" && quote != sprintf("%c", 39)) return "" + value = substr(value, 2) + escaped = 0 + result = "" + for (position = 1; position <= length(value); position++) { + char = substr(value, position, 1) + if (quote == "\"" && escaped) { + result = result char + escaped = 0 + } else if (quote == "\"" && char == "\\") { + escaped = 1 + } else if (char == quote) { + return result + } else { + result = result char + } + } + return "" + } + function assignment_string(line, value) { + value = line + sub(/^[^=]*=[[:space:]]*/, "", value) + return quoted_string(value) + } + function inline_string(line, key, pattern, value) { + pattern = "(^|[,{[:space:]])" key "[[:space:]]*=[[:space:]]*" + if (!match(line, pattern)) return "" + value = substr(line, RSTART + RLENGTH) + return quoted_string(value) + } + function emit() { + if (name != "") { + print component "|" name "|" kind "|" probe "|" rpm "|" deb \ + "|" check "|" version "|" min_kernel + } + } + function clear_dep() { + name = kind = probe = rpm = deb = check = version = min_kernel = "" + } + { + header = trim($0) + if (header ~ /^\[\[[[:space:]]*component\.dependencies[[:space:]]*\]\]([[:space:]]*#.*)?$/) { + emit(); clear_dep(); inside = 1; next + } + if (inside && header ~ /^\[/) { + emit(); clear_dep(); inside = 0; next + } + if (!inside || $0 !~ /=/) next + + key = $0 + sub(/=.*/, "", key) + key = trim(key) + if (key == "name") name = assignment_string($0) + else if (key == "kind") kind = assignment_string($0) + else if (key == "probe") probe = assignment_string($0) + else if (key == "check") check = assignment_string($0) + else if (key == "version") version = assignment_string($0) + else if (key == "min_kernel") min_kernel = assignment_string($0) + else if (key == "packages") { + rpm = inline_string($0, "rpm") + deb = inline_string($0, "deb") + } + } + END { emit() } + ' "$manifest" +} + +runtime_dependency_for_source_build() { + local record="$1" component name + IFS='|' read -r component name _ <<< "$record" + if [[ "$component" == "sec-core" && "$name" == "systemd" ]]; then + # Source installs do not install or manage the packaged systemd unit. + return 0 + elif [[ "$component" == "cosh-ng" && "$name" == "openssl1.1" ]]; then + # The packaged cosh-ng contract targets OpenSSL 1.1, while a source + # build links against the host development package. + echo 'cosh-ng|openssl|system-package|pkg-config --exists openssl|openssl-devel|libssl-dev|||' + elif [[ "$component" == "sec-core" && "$name" == "nodejs" ]]; then + # The manifest only probes runtime presence, while the OpenClaw plugin + # source build requires the same Node version as copilot-shell. + echo 'sec-core|node|language-runtime|node --version|nodejs|nodejs||>=20|' + else + echo "$record" + fi +} + +source_build_runtime_dependencies() { + local component + while IFS= read -r component; do + case "$component" in + sight) + echo 'sight|node|language-runtime|node --version|nodejs|nodejs||>=20|' + ;; + esac + done < <(runtime_install_components) +} + +runtime_install_components() { + local component + while IFS= read -r component; do + # ws-ckpt installs no files in user mode, so its daemon runtime + # requirements must not block unrelated user-profile installs. + if [[ "$INSTALL_MODE" == "user" && "$component" == "ws-ckpt" ]]; then + continue + fi + echo "$component" + done < <(active_components) +} + +selected_runtime_dependencies() { + local component manifest dependencies record + while IFS= read -r component; do + manifest="$(runtime_manifest_path "$component")" || return 1 + dependencies="$(runtime_dependencies_for_manifest "$component" "$manifest")" || return 1 + while IFS= read -r record; do + [[ -n "$record" ]] || continue + runtime_dependency_for_source_build "$record" + done <<< "$dependencies" + done < <(runtime_install_components) + source_build_runtime_dependencies +} + +runtime_command_path() { + if [[ "$INSTALL_MODE" == "system" ]]; then + echo "$RUNTIME_SYSTEM_PATH" + elif [[ -n "${PATH:-}" ]]; then + # Preserve user-local runtimes while keeping system package tools in + # sbin discoverable from non-login environments. + echo "$PATH:$RUNTIME_SYSTEM_PATH" + else + echo "$RUNTIME_SYSTEM_PATH" + fi +} + +normalize_runtime_version() { + local version="$1" + case "$version" in + *.*.*) echo "$version" ;; + *.*) echo "${version}.0" ;; + *) echo "${version}.0.0" ;; + esac +} + +runtime_probe_succeeds() { + local dependency="$1" probe="$2" version="$3" + local command_path output required actual + local -a argv=() + read -r -a argv <<< "$probe" + [[ ${#argv[@]} -gt 0 ]] || return 1 + command_path="$(runtime_command_path)" + output="$(PATH="$command_path" "${argv[@]}" 2>/dev/null)" || { + # The rpm package is gnupg2, whose binary name differs across distros. + if [[ "$dependency" == "gnupg" ]]; then + output="$(PATH="$command_path" gpg2 --version 2>/dev/null)" || return 1 + else + return 1 + fi + } + [[ -z "$version" ]] && return 0 + [[ "$version" == '>='* ]] || return 0 + required="$(normalize_runtime_version "${version#>=}")" + actual="$(extract_ver "$output" || true)" + [[ -n "$actual" ]] && ver_gte "$actual" "$required" +} + +runtime_package_present() { + local rpm_package="$1" deb_package="$2" + case "$PKG_BASE" in + rpm) [[ -n "$rpm_package" ]] && rpm -q "$rpm_package" &>/dev/null ;; + deb) [[ -n "$deb_package" ]] && dpkg -s "$deb_package" &>/dev/null ;; + *) return 1 ;; + esac +} + +runtime_kernel_satisfies() { + local minimum="$1" current required + [[ -z "$minimum" ]] && return 0 + current="$(uname -r 2>/dev/null | grep -oE '^[0-9]+\.[0-9]+(\.[0-9]+)?' || true)" + [[ -n "$current" ]] || return 1 + current="$(normalize_runtime_version "$current")" + required="$(normalize_runtime_version "$minimum")" + ver_gte "$current" "$required" +} + +runtime_btrfs_available() { + local filesystems="${RUNTIME_PROC_FILESYSTEMS:-/proc/filesystems}" + grep -qw btrfs "$filesystems" 2>/dev/null && return 0 + # Kernel module tools live in sbin on supported distributions. This host + # capability must not depend on whether the invoking user's PATH includes it. + PATH="$RUNTIME_SYSTEM_PATH" modprobe -n btrfs &>/dev/null +} + +RUNTIME_DEP_DETAIL="" +runtime_dependency_present() { + local name="$1" kind="$2" probe="$3" rpm_package="$4" deb_package="$5" + local check="$6" version="$7" min_kernel="$8" + RUNTIME_DEP_DETAIL="" + + if ! runtime_kernel_satisfies "$min_kernel"; then + RUNTIME_DEP_DETAIL="requires kernel >= ${min_kernel}" + return 1 + fi + + case "$kind" in + system-package) + if [[ -n "$probe" ]]; then + runtime_probe_succeeds "$name" "$probe" "" && return 0 + RUNTIME_DEP_DETAIL="requires probe: ${probe}" + else + runtime_package_present "$rpm_package" "$deb_package" && return 0 + RUNTIME_DEP_DETAIL="required system package is not installed" + fi + ;; + language-runtime) + runtime_probe_succeeds "$name" "${probe:-$name --version}" "$version" && return 0 + RUNTIME_DEP_DETAIL="requires ${name}${version:+ ${version}}" + ;; + platform-capability) + case "$check" in + btf) + [[ -f /sys/kernel/btf/vmlinux ]] && return 0 + RUNTIME_DEP_DETAIL="kernel BTF is unavailable (/sys/kernel/btf/vmlinux)" + ;; + btrfs) + runtime_btrfs_available && return 0 + RUNTIME_DEP_DETAIL="btrfs is neither registered nor loadable" + ;; + *) + RUNTIME_DEP_DETAIL="unknown platform check: ${check:-}" + ;; + esac + ;; + *) + RUNTIME_DEP_DETAIL="unknown dependency kind: ${kind:-}" + ;; + esac + return 1 +} + +collect_missing_runtime_dependencies() { + local -n missing_ref="$1" + local filter="${2:-all}" + local dependencies component name kind probe rpm_package deb_package check version min_kernel + missing_ref=() + dependencies="$(selected_runtime_dependencies)" || { + err "Failed to load runtime dependency manifests" + return 1 + } + while IFS='|' read -r component name kind probe rpm_package deb_package check version min_kernel; do + [[ -n "$name" ]] || continue + if [[ "$filter" == "platform-only" && "$kind" != "platform-capability" ]]; then + continue + fi + if ! runtime_dependency_present \ + "$name" "$kind" "$probe" "$rpm_package" "$deb_package" \ + "$check" "$version" "$min_kernel"; then + missing_ref+=("${component}|${name}|${kind}|${probe}|${rpm_package}|${deb_package}|${check}|${version}|${min_kernel}|${RUNTIME_DEP_DETAIL}") + fi + done <<< "$dependencies" +} + +runtime_missing_packages() { + local -n missing_ref="$1" packages_ref="$2" + local record component name kind probe rpm_package deb_package check version min_kernel detail package + local -A seen=() + packages_ref=() + for record in "${missing_ref[@]}"; do + IFS='|' read -r component name kind probe rpm_package deb_package check version min_kernel detail <<< "$record" + # Selecting a language-runtime distribution is a user decision; only + # native system packages participate in the automatic transaction. + [[ "$kind" == "system-package" ]] || continue + if [[ "$PKG_BASE" == "rpm" ]]; then package="$rpm_package"; else package="$deb_package"; fi + [[ -n "$package" && -z "${seen[$package]:-}" ]] || continue + seen[$package]=1 + packages_ref+=("$package") + done +} + +runtime_missing_has_blocker() { + local record kind + for record in "$@"; do + IFS='|' read -r _ _ kind _ <<< "$record" + # Resolve manual runtimes and host capabilities before mutating packages. + [[ "$kind" == "platform-capability" || "$kind" == "language-runtime" ]] && return 0 + done + return 1 +} + +runtime_retry_command() { + local retry=("$PROJECT_ROOT/scripts/build-all.sh") component + while IFS= read -r component; do + retry+=("--component" "$component") + done < <(active_components) + [[ "$INSTALL_MODE" == "system" ]] && retry+=("--system") + $INSTALL_DEPS || retry+=("--ignore-deps") + $DEPS_ONLY && retry+=("--deps-only") + shell_args "${retry[@]}" +} + +report_missing_runtime_dependencies() { + local phase="$1" package_manager_known="$2"; shift 2 + local missing=("$@") packages=() record component name kind probe rpm_package deb_package + local check version min_kernel detail manual_runtime=false + + if [[ "$phase" == "after-install" ]]; then + err "Runtime dependencies are still missing after package installation; no components were installed:" + else + err "Missing runtime dependencies; no component files were installed:" + fi + for record in "${missing[@]}"; do + IFS='|' read -r component name kind probe rpm_package deb_package check version min_kernel detail <<< "$record" + echo " ${component}: ${name} [${kind}]${detail:+ - ${detail}}" + [[ "$kind" == "language-runtime" ]] && manual_runtime=true + done + + runtime_missing_packages missing packages + if [[ ${#packages[@]} -gt 0 ]]; then + echo "" + if [[ "$package_manager_known" == "true" ]]; then + local install_command=() privilege_prefix="sudo " + read -r -a install_command <<< "$PKG_INSTALL" + [[ "$(id -u)" -eq 0 ]] && privilege_prefix="" + info "Install them with:" + echo " ${privilege_prefix}$(shell_args "${install_command[@]}" "${packages[@]}")" + else + err "Cannot determine a supported deb/rpm package manager; install the listed dependencies manually." + fi + fi + + if $manual_runtime; then + echo "" + info "Install these language runtimes manually:" + for record in "${missing[@]}"; do + IFS='|' read -r component name kind probe rpm_package deb_package check version min_kernel detail <<< "$record" + [[ "$kind" == "language-runtime" ]] || continue + if [[ "$INSTALL_MODE" == "system" ]]; then + echo " ${component}: ${name} ${version} in $RUNTIME_SYSTEM_PATH" + else + echo " ${component}: ${name} ${version} in PATH" + fi + done + fi + + echo "" + info "Then retry:" + echo " $(runtime_retry_command)" + return 1 +} + +preflight_runtime_dependencies() { + local filter="${1:-all}" + if [[ "$filter" == "platform-only" ]]; then + step "Runtime platform capability preflight" + else + step "Runtime dependency preflight" + fi + + local missing=() + local package_manager_known=true + detect_runtime_package_manager || package_manager_known=false + collect_missing_runtime_dependencies missing "$filter" || return 1 + if [[ ${#missing[@]} -eq 0 ]]; then + if [[ "$filter" == "platform-only" ]]; then + ok "Selected component platform capabilities are available" + else + ok "Selected component runtime dependencies are available" fi - cmd_exists jq || warn "jq not found; sec-core helper scripts may need jq." return 0 fi - if [[ -z "$PKG_INSTALL" ]]; then - detect_distro + # User installs and --ignore-deps never mutate system package state. + if [[ "$INSTALL_MODE" != "system" ]] || ! $INSTALL_DEPS; then + report_missing_runtime_dependencies "preflight" "$package_manager_known" "${missing[@]}" + return 1 fi - if ! cmd_exists bwrap; then - info "Installing runtime dependency: bubblewrap ..." - as_root $PKG_INSTALL bubblewrap || warn "bubblewrap not installed (linux-sandbox runtime dep)" + if ! $package_manager_known || runtime_missing_has_blocker "${missing[@]}"; then + report_missing_runtime_dependencies "preflight" "$package_manager_known" "${missing[@]}" + return 1 fi - if ! cmd_exists gpg && ! cmd_exists gpg2; then - local gpg_pkg="gnupg2" - [[ "$PKG_BASE" == "deb" ]] && gpg_pkg="gnupg" - info "Installing runtime dependency: ${gpg_pkg} ..." - as_root $PKG_INSTALL "$gpg_pkg" || warn "${gpg_pkg} not installed (skill signature verification)" + + local packages=() install_command=() + runtime_missing_packages missing packages + read -r -a install_command <<< "$PKG_INSTALL" + if [[ ${#install_command[@]} -eq 0 || ${#packages[@]} -eq 0 ]]; then + report_missing_runtime_dependencies "preflight" false "${missing[@]}" + return 1 + fi + + if [[ "$PKG_BASE" == "deb" ]]; then + info "Refreshing APT package indexes ..." + if ! as_root apt-get update; then + err "Failed to refresh APT package indexes; no runtime packages were installed." + return 1 + fi fi - if ! cmd_exists jq; then - info "Installing runtime dependency: jq ..." - as_root $PKG_INSTALL jq || warn "jq not installed (sec-core helper/signing dependency)" + + info "Installing runtime dependencies: ${packages[*]}" + if ! as_root "${install_command[@]}" "${packages[@]}"; then + warn "The package manager returned an error; re-checking every runtime dependency." + fi + + missing=() + collect_missing_runtime_dependencies missing "$filter" || return 1 + if [[ ${#missing[@]} -gt 0 ]]; then + report_missing_runtime_dependencies "after-install" true "${missing[@]}" + return 1 fi + + ok "Runtime dependencies installed and verified" } install_sec_core() { @@ -1849,7 +2325,6 @@ install_sec_core() { else echo "DRY-RUN: make -C $dir install BUILD_DIR=$build_dir INSTALL_PROFILE=user" fi - echo "DRY-RUN: check/install sec-core runtime dependencies" ok "agent-sec-core install plan generated for $SEC_CORE_BIN_DIR and $SEC_CORE_LIB_DIR" return 0 fi @@ -1878,8 +2353,6 @@ install_sec_core() { BUILD_DIR="$build_dir" INSTALL_PROFILE=user fi - install_sec_core_runtime_deps - ok "agent-sec-core installed to $SEC_CORE_BIN_DIR and $SEC_CORE_LIB_DIR" if [[ "$INSTALL_MODE" != "system" ]]; then info "Make sure $SEC_CORE_BIN_DIR is in PATH before starting integrations." @@ -1956,27 +2429,6 @@ install_tokenless() { fi } -install_wsckpt_runtime_deps() { - [[ "$INSTALL_MODE" == "system" ]] || return 0 - - if $DRY_RUN; then - echo "DRY-RUN: check/install ws-ckpt runtime dependency: btrfs-progs" - return 0 - fi - - if cmd_exists mkfs.btrfs; then - return 0 - fi - - if [[ -z "$PKG_INSTALL" ]]; then - detect_distro - fi - - info "Installing runtime dependency: btrfs-progs ..." - as_root $PKG_INSTALL btrfs-progs || \ - warn "btrfs-progs not installed; ws-ckpt btrfs-loop backend may not start" -} - install_wsckpt() { step "Installing ws-ckpt" local dir="$PROJECT_ROOT/src/ws-ckpt" @@ -1985,7 +2437,6 @@ install_wsckpt() { fi run_component_make_install "ws-ckpt" "$dir" if [[ "$INSTALL_MODE" == "system" ]]; then - install_wsckpt_runtime_deps refresh_systemd_service ws-ckpt.service else info "Skipping ws-ckpt systemd service in user mode; use --system for service management." @@ -2013,6 +2464,11 @@ install_agent_memory() { do_install() { step "Installing components (mode=${INSTALL_MODE})" + if $DRY_RUN; then + echo "DRY-RUN: preflight selected component runtime dependencies before install (host probes skipped)" + else + preflight_runtime_dependencies || return 1 + fi if want_component cosh; then install_cosh; fi if want_component skills; then install_skills; fi if want_component sec-core; then install_sec_core; fi @@ -2499,4 +2955,6 @@ main() { ok "Done" } -main "$@" +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/src/anolisa/manifests/components/ws-ckpt/component.toml b/src/anolisa/manifests/components/ws-ckpt/component.toml index 484f5e9fd8..5c8b8fb270 100644 --- a/src/anolisa/manifests/components/ws-ckpt/component.toml +++ b/src/anolisa/manifests/components/ws-ckpt/component.toml @@ -24,7 +24,7 @@ min_kernel = "5.4" [[component.dependencies]] name = "btrfs-progs" kind = "system-package" -probe = "btrfs version" +probe = "mkfs.btrfs --version" packages = { rpm = "btrfs-progs", deb = "btrfs-progs" } [[component.dependencies]] diff --git a/tests/test-build-all-runtime-deps.sh b/tests/test-build-all-runtime-deps.sh new file mode 100755 index 0000000000..a87e1219c7 --- /dev/null +++ b/tests/test-build-all-runtime-deps.sh @@ -0,0 +1,736 @@ +#!/usr/bin/env bash +set -euo pipefail + +TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$TEST_DIR/.." && pwd)" + +# build-all.sh guards main when sourced, which lets these tests exercise the +# preflight without building components or mutating the host package state. +# shellcheck source=../scripts/build-all.sh +source "$PROJECT_ROOT/scripts/build-all.sh" + +TEST_TMP="$(mktemp -d)" +trap 'rm -rf "$TEST_TMP"' EXIT + +fail() { + echo "not ok - $*" >&2 + return 1 +} + +assert_contains() { + local file="$1" expected="$2" + grep -Fq -- "$expected" "$file" || fail "expected '$expected' in output" +} + +assert_not_contains() { + local file="$1" unexpected="$2" + if grep -Fq -- "$unexpected" "$file"; then + fail "did not expect '$unexpected' in output" + fi +} + +declare -a TEST_DEPENDENCIES=() +declare -A PRESENT_DEPENDENCIES=() + +selected_runtime_dependencies() { + printf '%s\n' "${TEST_DEPENDENCIES[@]}" +} + +runtime_dependency_present() { + local name="$1" + RUNTIME_DEP_DETAIL="missing test dependency" + [[ "${PRESENT_DEPENDENCIES[$name]:-false}" == "true" ]] +} + +id() { + if [[ "${1:-}" == "-u" ]]; then + echo 1000 + else + command id "$@" + fi +} + +as_root() { + AS_ROOT_CALLS=$((AS_ROOT_CALLS + 1)) + AS_ROOT_HISTORY+="$*"$'\n' + if [[ "$*" == "apt-get update" ]]; then + [[ "$APT_UPDATE_RESULT" == "success" ]] + return + fi + AS_ROOT_ARGS="$*" + if [[ "$INSTALL_RESULT" == "success" ]]; then + local record name + for record in "${TEST_DEPENDENCIES[@]}"; do + IFS='|' read -r _ name _ <<< "$record" + PRESENT_DEPENDENCIES[$name]=true + done + return 0 + fi + [[ "$INSTALL_RESULT" != "error" ]] +} + +reset_preflight_stubs() { + INSTALL_MODE="user" + INSTALL_DEPS=true + DO_INSTALL=true + DEPS_ONLY=false + DRY_RUN=false + PKG_BASE="deb" + PKG_INSTALL="apt-get install -y" + COMPONENTS=(sec-core tokenless ws-ckpt) + AS_ROOT_CALLS=0 + AS_ROOT_ARGS="" + AS_ROOT_HISTORY="" + APT_UPDATE_RESULT="success" + INSTALL_RESULT="none" + TEST_STATUS=0 + TEST_OUTPUT="$TEST_TMP/output" + RUNTIME_SYSTEM_PATH="$TEST_TMP/system-bin" + rm -rf "$RUNTIME_SYSTEM_PATH" + mkdir -p "$RUNTIME_SYSTEM_PATH" + : > "$TEST_OUTPUT" + TEST_DEPENDENCIES=( + 'sec-core|bubblewrap|system-package|bwrap --version|bubblewrap|bubblewrap||||' + 'tokenless|bash|system-package|bash --version|bash|bash||||' + 'ws-ckpt|rsync|system-package|rsync --version|rsync|rsync||||' + ) + PRESENT_DEPENDENCIES=() +} + +run_preflight() { + set +e + preflight_runtime_dependencies > "$TEST_OUTPUT" 2>&1 + TEST_STATUS=$? + set -e +} + +test_manifest_parser_covers_component_dependencies() { + reset_preflight_stubs + local output="$TEST_TMP/manifests" + : > "$output" + local component manifest + for component in cosh skills sec-core cosh-ng tokenless ws-ckpt memory sight; do + manifest="$(runtime_manifest_path "$component")" + runtime_dependencies_for_manifest "$component" "$manifest" >> "$output" + done + + assert_contains "$output" 'cosh|node|language-runtime|node --version|nodejs|nodejs||>=20|' + assert_contains "$output" 'sec-core|bubblewrap|system-package|bwrap --version|bubblewrap|bubblewrap|||' + assert_contains "$output" 'cosh-ng|openssl1.1|system-package||openssl1.1|libssl1.1|||' + assert_contains "$output" 'tokenless|python3|system-package|python3 --version|python3|python3|||' + assert_contains "$output" 'ws-ckpt|btrfs-progs|system-package|mkfs.btrfs --version|btrfs-progs|btrfs-progs|||' + assert_contains "$output" 'ws-ckpt|rsync|system-package|rsync --version|rsync|rsync|||' + assert_contains "$output" 'ws-ckpt|btrfs|platform-capability||||btrfs||5.4' + assert_contains "$output" 'sight|ebpf-btf|platform-capability||||btf||5.8' + [[ "$(wc -l < "$output")" -eq 15 ]] || fail "unexpected manifest dependency count" + + local source_dependency + source_dependency="$(runtime_dependency_for_source_build \ + 'cosh-ng|openssl1.1|system-package||openssl1.1|libssl1.1|||')" + [[ "$source_dependency" == \ + 'cosh-ng|openssl|system-package|pkg-config --exists openssl|openssl-devel|libssl-dev|||' ]] || \ + fail "cosh-ng source dependency was not adapted" + + source_dependency="$(runtime_dependency_for_source_build \ + 'sec-core|nodejs|system-package|node --version|nodejs|nodejs||||')" + [[ "$source_dependency" == \ + 'sec-core|node|language-runtime|node --version|nodejs|nodejs||>=20|' ]] || \ + fail "sec-core source Node dependency was not versioned" + + source_dependency="$(runtime_dependency_for_source_build \ + 'sec-core|systemd|system-package|systemctl --version|systemd|systemd||||')" + [[ -z "$source_dependency" ]] || \ + fail "sec-core source dependencies retained packaged systemd" + + COMPONENTS=(sight) + source_dependency="$(source_build_runtime_dependencies)" + [[ "$source_dependency" == \ + 'sight|node|language-runtime|node --version|nodejs|nodejs||>=20|' ]] || \ + fail "agentsight source Node dependency was not collected" +} + +test_user_skips_ws_ckpt_noop_install_dependencies() { + reset_preflight_stubs + local user_output="$TEST_TMP/user-install-dependencies" + local system_output="$TEST_TMP/system-install-dependencies" + + bash -c ' + source "$1" + INSTALL_MODE=user + COMPONENTS=() + selected_runtime_dependencies + ' bash "$PROJECT_ROOT/scripts/build-all.sh" > "$user_output" + + assert_not_contains "$user_output" 'ws-ckpt|' + assert_contains "$user_output" 'cosh|node|language-runtime|node --version' + + bash -c ' + source "$1" + INSTALL_MODE=system + COMPONENTS=(ws-ckpt) + selected_runtime_dependencies + ' bash "$PROJECT_ROOT/scripts/build-all.sh" > "$system_output" + + assert_contains "$system_output" \ + 'ws-ckpt|btrfs-progs|system-package|mkfs.btrfs --version' + assert_contains "$system_output" 'ws-ckpt|btrfs|platform-capability' +} + +test_manifest_parser_uses_toml_keys_not_order() { + reset_preflight_stubs + local manifest="$TEST_TMP/reordered-component.toml" + local output="$TEST_TMP/reordered-dependencies" + printf '%s\n' \ + '[[component.dependencies]] # reordered but equivalent' \ + 'packages={ deb = '\''gnupg'\'',rpm="gnupg2" }' \ + 'probe = '\''gpg --version'\''' \ + 'kind="system-package"' \ + 'name = "gnupg"' \ + > "$manifest" + + runtime_dependencies_for_manifest sec-core "$manifest" > "$output" + + assert_contains "$output" 'sec-core|gnupg|system-package|gpg --version|gnupg2|gnupg|||' + [[ "$(wc -l < "$output")" -eq 1 ]] || fail "reordered TOML emitted extra records" +} + +test_user_reports_all_components_once_without_root() { + reset_preflight_stubs + TEST_DEPENDENCIES+=( + 'cosh-ng|bash|system-package|bash --version|bash|bash||||' + 'ws-ckpt|btrfs|platform-capability||||btrfs||5.4|' + ) + + run_preflight + + [[ $TEST_STATUS -ne 0 ]] || fail "user preflight unexpectedly succeeded" + [[ $AS_ROOT_CALLS -eq 0 ]] || fail "user preflight invoked as_root" + assert_contains "$TEST_OUTPUT" 'sec-core: bubblewrap [system-package]' + assert_contains "$TEST_OUTPUT" 'tokenless: bash [system-package]' + assert_contains "$TEST_OUTPUT" 'cosh-ng: bash [system-package]' + assert_contains "$TEST_OUTPUT" 'ws-ckpt: btrfs [platform-capability]' + assert_contains "$TEST_OUTPUT" 'sudo apt-get install -y bubblewrap bash rsync' + [[ "$(grep -o ' apt-get install ' "$TEST_OUTPUT" | wc -l)" -eq 1 ]] || \ + fail "expected one aggregated install command" +} + +test_system_installs_packages_once_and_reprobes() { + reset_preflight_stubs + INSTALL_MODE="system" + INSTALL_RESULT="success" + + run_preflight + + [[ $TEST_STATUS -eq 0 ]] || fail "system preflight did not recover" + [[ $AS_ROOT_CALLS -eq 2 ]] || fail "expected one APT refresh and package transaction" + [[ "$AS_ROOT_HISTORY" == \ + $'apt-get update\napt-get install -y bubblewrap bash rsync\n' ]] || \ + fail "unexpected APT transaction order: $AS_ROOT_HISTORY" + [[ "$AS_ROOT_ARGS" == 'apt-get install -y bubblewrap bash rsync' ]] || \ + fail "unexpected package transaction: $AS_ROOT_ARGS" + assert_contains "$TEST_OUTPUT" 'installed and verified' +} + +test_system_stops_before_packages_for_platform_blocker() { + reset_preflight_stubs + INSTALL_MODE="system" + INSTALL_RESULT="success" + TEST_DEPENDENCIES+=( + 'sight|ebpf-btf|platform-capability||||btf||5.8|' + ) + + run_preflight + + [[ $TEST_STATUS -ne 0 ]] || fail "platform blocker unexpectedly succeeded" + [[ $AS_ROOT_CALLS -eq 0 ]] || fail "packages changed before platform validation" + assert_contains "$TEST_OUTPUT" 'sight: ebpf-btf [platform-capability]' +} + +test_system_reprobe_failure_reports_every_dependency() { + reset_preflight_stubs + INSTALL_MODE="system" + INSTALL_RESULT="none" + + run_preflight + + [[ $TEST_STATUS -ne 0 ]] || fail "failed re-probe unexpectedly succeeded" + [[ $AS_ROOT_CALLS -eq 2 ]] || fail "expected one APT refresh and package transaction" + assert_contains "$TEST_OUTPUT" 'still missing after package installation' + assert_contains "$TEST_OUTPUT" 'sec-core: bubblewrap' + assert_contains "$TEST_OUTPUT" 'ws-ckpt: rsync' +} + +test_system_apt_update_failure_stops_before_install() { + reset_preflight_stubs + INSTALL_MODE="system" + INSTALL_RESULT="success" + APT_UPDATE_RESULT="error" + + run_preflight + + [[ $TEST_STATUS -ne 0 ]] || fail "failed APT refresh unexpectedly continued" + [[ $AS_ROOT_CALLS -eq 1 ]] || fail "package install ran after failed APT refresh" + [[ "$AS_ROOT_HISTORY" == $'apt-get update\n' ]] || \ + fail "unexpected commands after failed APT refresh: $AS_ROOT_HISTORY" + assert_contains "$TEST_OUTPUT" \ + 'Failed to refresh APT package indexes; no runtime packages were installed.' +} + +test_unknown_package_manager_reports_aggregate() { + reset_preflight_stubs + PKG_BASE="" + PKG_INSTALL="" + detect_runtime_package_manager() { return 1; } + + run_preflight + + [[ $TEST_STATUS -ne 0 ]] || fail "unknown package manager unexpectedly succeeded" + [[ $AS_ROOT_CALLS -eq 0 ]] || fail "unknown package manager invoked as_root" + assert_contains "$TEST_OUTPUT" 'sec-core: bubblewrap' + assert_contains "$TEST_OUTPUT" 'ws-ckpt: rsync' + assert_contains "$TEST_OUTPUT" 'Cannot determine a supported deb/rpm package manager' + assert_not_contains "$TEST_OUTPUT" 'Install them with:' +} + +test_rpm_report_uses_manifest_package_names() { + reset_preflight_stubs + PKG_BASE="rpm" + PKG_INSTALL="dnf install -y" + COMPONENTS=(sec-core sight) + TEST_DEPENDENCIES=( + 'sec-core|gnupg|system-package|gpg --version|gnupg2|gnupg||||' + 'sight|elfutils-libelf|system-package|grep -aqF libelf.so.1 /etc/ld.so.cache|elfutils-libelf|libelf1||||' + ) + + run_preflight + + [[ $TEST_STATUS -ne 0 ]] || fail "rpm user preflight unexpectedly succeeded" + [[ $AS_ROOT_CALLS -eq 0 ]] || fail "rpm user preflight invoked as_root" + assert_contains "$TEST_OUTPUT" 'sudo dnf install -y gnupg2 elfutils-libelf' +} + +test_ignore_deps_never_installs_packages() { + reset_preflight_stubs + INSTALL_MODE="system" + INSTALL_DEPS=false + + run_preflight + + [[ $TEST_STATUS -ne 0 ]] || fail "--ignore-deps preflight unexpectedly succeeded" + [[ $AS_ROOT_CALLS -eq 0 ]] || fail "--ignore-deps invoked as_root" + assert_contains "$TEST_OUTPUT" '--ignore-deps' +} + +test_system_node_rejects_user_nvm_fallback() { + reset_preflight_stubs + local user_bin="$TEST_TMP/user-bin" + mkdir -p "$user_bin" + printf '#!/bin/bash\necho v24.15.0\n' > "$user_bin/node" + chmod +x "$user_bin/node" + printf '#!/bin/bash\necho v18.19.0\n' > "$RUNTIME_SYSTEM_PATH/node" + chmod +x "$RUNTIME_SYSTEM_PATH/node" + PATH="$user_bin:$PATH" + + INSTALL_MODE="system" + if runtime_probe_succeeds node 'node --version' '>=20'; then + fail "system preflight accepted the installing user's nvm Node" + fi + + INSTALL_MODE="user" + runtime_probe_succeeds node 'node --version' '>=20' || \ + fail "user preflight did not preserve the user-local Node priority" +} + +test_system_old_repo_node_is_manual_blocker() { + reset_preflight_stubs + INSTALL_MODE="system" + INSTALL_RESULT="success" + TEST_DEPENDENCIES=( + 'cosh|node|language-runtime|node --version|nodejs|nodejs||>=20|' + 'sec-core|bubblewrap|system-package|bwrap --version|bubblewrap|bubblewrap||||' + ) + query_repo_ver() { echo 18.19.0; } + + run_preflight + + [[ $TEST_STATUS -ne 0 ]] || fail "old repository Node unexpectedly passed preflight" + [[ $AS_ROOT_CALLS -eq 0 ]] || fail "packages changed before manual runtime was resolved" + assert_contains "$TEST_OUTPUT" 'cosh: node [language-runtime]' + assert_contains "$TEST_OUTPUT" 'sudo apt-get install -y bubblewrap' + assert_not_contains "$TEST_OUTPUT" 'apt-get install -y nodejs' + assert_contains "$TEST_OUTPUT" 'node >=20 in' +} + +test_system_language_runtime_never_auto_installs() { + reset_preflight_stubs + INSTALL_MODE="system" + INSTALL_RESULT="success" + TEST_DEPENDENCIES=( + 'cosh|node|language-runtime|node --version|nodejs|nodejs||>=20|' + 'sec-core|bubblewrap|system-package|bwrap --version|bubblewrap|bubblewrap||||' + ) + REPO_QUERY_CALLS=0 + query_repo_ver() { + REPO_QUERY_CALLS=$((REPO_QUERY_CALLS + 1)) + echo 24.19.0 + } + + run_preflight + + [[ $TEST_STATUS -ne 0 ]] || fail "language runtime was auto-installed" + [[ $AS_ROOT_CALLS -eq 0 ]] || fail "packages changed before manual runtime was resolved" + [[ $REPO_QUERY_CALLS -eq 0 ]] || fail "preflight tried to select a Node repository version" + assert_contains "$TEST_OUTPUT" 'sudo apt-get install -y bubblewrap' + assert_not_contains "$TEST_OUTPUT" 'apt-get install -y nodejs' +} + +test_install_node_system_does_not_fall_back_to_nvm() { + reset_preflight_stubs + INSTALL_MODE="system" + query_repo_ver() { echo 18.19.0; } + _configure_npm_mirror() { :; } + + set +e + ( install_node ) > "$TEST_OUTPUT" 2>&1 + TEST_STATUS=$? + set -e + + [[ $TEST_STATUS -ne 0 ]] || fail "old system Node repository unexpectedly succeeded" + assert_contains "$TEST_OUTPUT" 'Repository Node.js cannot satisfy >= 20.0.0' + assert_not_contains "$TEST_OUTPUT" 'Installing Node.js via nvm' + assert_not_contains "$TEST_OUTPUT" 'NodeSource' +} + +test_install_node_user_installs_node24_with_nvm() { + reset_preflight_stubs + HOME="$TEST_TMP/node-user" + SHELL="/bin/bash" + mkdir -p "$HOME" + NODE_INSTALLED=false + NVM_ARGS="" + node() { + $NODE_INSTALLED || return 127 + echo v24.19.0 + } + npm() { echo 11.9.0; } + nvm() { + NVM_ARGS="$*" + [[ "$1" == "install" && "$2" == "24" ]] || return 1 + NODE_INSTALLED=true + } + query_repo_ver() { echo 18.19.0; } + _configure_npm_mirror() { :; } + + install_node > "$TEST_OUTPUT" 2>&1 + + [[ "$NVM_ARGS" == 'install 24' ]] || fail "unexpected nvm install: $NVM_ARGS" + assert_contains "$TEST_OUTPUT" 'Node.js v24.19.0' +} + +test_system_package_probe_ignores_user_path() { + reset_preflight_stubs + local user_bin="$TEST_TMP/user-bin" + mkdir -p "$user_bin" + printf '#!/bin/bash\necho jq-1.7\n' > "$user_bin/jq" + chmod +x "$user_bin/jq" + PATH="$user_bin:$PATH" + + INSTALL_MODE="system" + if runtime_probe_succeeds jq 'jq --version' ''; then + fail "system package probe accepted jq from the user's PATH" + fi +} + +test_language_runtime_version_is_enforced() { + reset_preflight_stubs + local node="$RUNTIME_SYSTEM_PATH/node" + local user_bin="$TEST_TMP/user-bin" + mkdir -p "$user_bin" + printf '#!/bin/bash\necho v18.20.0\n' > "$user_bin/node" + chmod +x "$user_bin/node" + PATH="$user_bin:$PATH" + printf '#!/bin/bash\necho v18.20.0\n' > "$node" + chmod +x "$node" + INSTALL_MODE="system" + if runtime_probe_succeeds node 'node --version' '>=20'; then + fail "Node 18 unexpectedly satisfied >=20" + fi + printf '#!/bin/bash\necho v20.1.0\n' > "$node" + runtime_probe_succeeds node 'node --version' '>=20' || \ + fail "Node 20 did not satisfy >=20" +} + +test_btrfs_module_probe_uses_system_path() { + reset_preflight_stubs + RUNTIME_PROC_FILESYSTEMS="$TEST_TMP/filesystems" + : > "$RUNTIME_PROC_FILESYSTEMS" + local user_bin="$TEST_TMP/user-bin" + rm -rf "$user_bin" + mkdir -p "$user_bin" + PATH="$user_bin:/usr/bin:/bin" + local modprobe="$RUNTIME_SYSTEM_PATH/modprobe" + printf '#!/bin/bash\nexit 0\n' > "$modprobe" + chmod +x "$modprobe" + + runtime_btrfs_available || fail "loadable btrfs module was rejected" + printf '#!/bin/bash\nexit 1\n' > "$modprobe" + if runtime_btrfs_available; then + fail "unavailable btrfs capability unexpectedly succeeded" + fi +} + +test_btrfs_progs_probe_includes_system_sbin() { + reset_preflight_stubs + local user_bin="$TEST_TMP/user-bin" + rm -rf "$user_bin" + mkdir -p "$user_bin" + printf '#!/bin/bash\necho btrfs-progs v6.6\n' > "$user_bin/btrfs" + chmod +x "$user_bin/btrfs" + PATH="$user_bin:/usr/bin:/bin" + + if runtime_probe_succeeds btrfs-progs 'mkfs.btrfs --version' ''; then + fail "btrfs-progs probe passed without mkfs.btrfs" + fi + + printf '#!/bin/bash\necho mkfs.btrfs, part of btrfs-progs v6.6\n' > \ + "$RUNTIME_SYSTEM_PATH/mkfs.btrfs" + chmod +x "$RUNTIME_SYSTEM_PATH/mkfs.btrfs" + runtime_probe_succeeds btrfs-progs 'mkfs.btrfs --version' '' || \ + fail "system mkfs.btrfs was hidden by the restricted user PATH" +} + +test_manifest_load_failure_is_not_silently_ignored() { + reset_preflight_stubs + selected_runtime_dependencies() { return 1; } + + run_preflight + + [[ $TEST_STATUS -ne 0 ]] || fail "manifest load failure unexpectedly succeeded" + assert_contains "$TEST_OUTPUT" 'Failed to load runtime dependency manifests' +} + +test_deps_only_runs_runtime_preflight() { + reset_preflight_stubs + COMPONENTS=(tokenless) + DEPS_ONLY=true + RUNTIME_PREFLIGHT_CALLS=0 + detect_distro() { :; } + install_rust() { :; } + install_just() { :; } + preflight_runtime_dependencies() { + RUNTIME_PREFLIGHT_CALLS=$((RUNTIME_PREFLIGHT_CALLS + 1)) + RUNTIME_PREFLIGHT_FILTERS+="${1:-all} " + } + RUNTIME_PREFLIGHT_FILTERS="" + + do_install_deps > "$TEST_OUTPUT" 2>&1 + + [[ $RUNTIME_PREFLIGHT_CALLS -eq 2 ]] || \ + fail "user deps-only did not run two-phase preflight" + [[ "$RUNTIME_PREFLIGHT_FILTERS" == 'platform-only all ' ]] || \ + fail "unexpected user preflight order: $RUNTIME_PREFLIGHT_FILTERS" + + INSTALL_MODE="system" + RUNTIME_PREFLIGHT_CALLS=0 + RUNTIME_PREFLIGHT_FILTERS="" + do_install_deps > "$TEST_OUTPUT" 2>&1 + [[ $RUNTIME_PREFLIGHT_CALLS -eq 1 && "$RUNTIME_PREFLIGHT_FILTERS" == 'all ' ]] || \ + fail "system deps-only did not preflight once before setup" +} + +test_retry_command_preserves_mode_and_location() { + reset_preflight_stubs + COMPONENTS=(memory) + INSTALL_MODE="system" + DEPS_ONLY=true + local retry + retry="$(cd "$PROJECT_ROOT/src/agent-memory" && runtime_retry_command)" + retry="${retry% }" + [[ "$retry" == \ + "$PROJECT_ROOT/scripts/build-all.sh --component memory --system --deps-only" ]] || \ + fail "deps-only retry is not reproducible: $retry" + + DEPS_ONLY=false + INSTALL_DEPS=false + retry="$(cd "$PROJECT_ROOT/src/agent-memory" && runtime_retry_command)" + retry="${retry% }" + [[ "$retry" == \ + "$PROJECT_ROOT/scripts/build-all.sh --component memory --system --ignore-deps" ]] || \ + fail "ignore-deps retry is not reproducible: $retry" +} + +test_user_source_dependency_setup_precedes_full_preflight() { + reset_preflight_stubs + COMPONENTS=(cosh) + TEST_DEPENDENCIES=( + 'cosh|node|language-runtime|node --version|nodejs|nodejs||>=20|' + ) + query_repo_ver() { echo 18.19.0; } + detect_distro() { :; } + install_node() { + echo SOURCE_DEP_ACTION + PRESENT_DEPENDENCIES[node]=true + } + install_build_tools() { :; } + + set +e + do_install_deps > "$TEST_OUTPUT" 2>&1 + TEST_STATUS=$? + set -e + + [[ $TEST_STATUS -eq 0 ]] || fail "user Node setup did not satisfy preflight" + assert_contains "$TEST_OUTPUT" SOURCE_DEP_ACTION + assert_contains "$TEST_OUTPUT" 'runtime dependencies are available' + local setup_line preflight_line + setup_line="$(grep -n -m1 SOURCE_DEP_ACTION "$TEST_OUTPUT" | cut -d: -f1)" + preflight_line="$(grep -n -m1 'Runtime dependency preflight' "$TEST_OUTPUT" | cut -d: -f1)" + (( setup_line < preflight_line )) || fail "full preflight preceded user Node setup" +} + +test_system_satisfied_preflight_precedes_source_dependency_setup() { + reset_preflight_stubs + COMPONENTS=(cosh) + TEST_DEPENDENCIES=( + 'cosh|node|language-runtime|node --version|nodejs|nodejs||>=20|' + ) + PRESENT_DEPENDENCIES[node]=true + INSTALL_MODE="system" + detect_distro() { :; } + install_node() { echo SOURCE_DEP_ACTION; } + install_build_tools() { :; } + + do_install_deps > "$TEST_OUTPUT" 2>&1 + + assert_contains "$TEST_OUTPUT" 'runtime dependencies are available' + assert_contains "$TEST_OUTPUT" SOURCE_DEP_ACTION + local setup_line preflight_line + preflight_line="$(grep -n -m1 'Runtime dependency preflight' "$TEST_OUTPUT" | cut -d: -f1)" + setup_line="$(grep -n -m1 SOURCE_DEP_ACTION "$TEST_OUTPUT" | cut -d: -f1)" + (( preflight_line < setup_line )) || fail "source dependency setup preceded preflight" +} + +test_platform_preflight_precedes_dependency_changes() { + reset_preflight_stubs + COMPONENTS=(sight) + INSTALL_MODE="system" + TEST_DEPENDENCIES=( + 'sight|ebpf-btf|platform-capability||||btf||5.8|' + ) + detect_distro() { :; } + install_node() { echo SOURCE_DEP_ACTION; } + install_build_tools() { echo SOURCE_DEP_ACTION; } + install_rust() { echo SOURCE_DEP_ACTION; } + check_ebpf_deps() { echo SOURCE_DEP_ACTION; } + + set +e + do_install_deps > "$TEST_OUTPUT" 2>&1 + TEST_STATUS=$? + set -e + + [[ $TEST_STATUS -ne 0 ]] || fail "platform blocker unexpectedly succeeded" + [[ $AS_ROOT_CALLS -eq 0 ]] || fail "platform blocker changed runtime packages" + assert_contains "$TEST_OUTPUT" 'sight: ebpf-btf [platform-capability]' + assert_not_contains "$TEST_OUTPUT" SOURCE_DEP_ACTION +} + +test_no_install_skips_runtime_preflight() { + reset_preflight_stubs + COMPONENTS=(tokenless) + DO_INSTALL=false + DEPS_ONLY=false + RUNTIME_PREFLIGHT_CALLS=0 + detect_distro() { :; } + install_rust() { :; } + install_just() { :; } + preflight_runtime_dependencies() { + RUNTIME_PREFLIGHT_CALLS=$((RUNTIME_PREFLIGHT_CALLS + 1)) + } + + do_install_deps > "$TEST_OUTPUT" 2>&1 + + [[ $RUNTIME_PREFLIGHT_CALLS -eq 0 ]] || fail "build-only ran preflight" +} + +test_preflight_failure_precedes_first_install() { + reset_preflight_stubs + COMPONENTS=(skills) + preflight_runtime_dependencies() { + echo PREFLIGHT_FAILED + return 1 + } + install_skills() { echo INSTALL_ACTION; } + + set +e + do_install > "$TEST_OUTPUT" 2>&1 + TEST_STATUS=$? + set -e + + [[ $TEST_STATUS -ne 0 ]] || fail "install continued after preflight failure" + assert_contains "$TEST_OUTPUT" PREFLIGHT_FAILED + assert_not_contains "$TEST_OUTPUT" INSTALL_ACTION +} + +test_dry_run_skips_host_preflight() { + reset_preflight_stubs + COMPONENTS=(skills) + DRY_RUN=true + RUNTIME_PREFLIGHT_CALLS=0 + preflight_runtime_dependencies() { + RUNTIME_PREFLIGHT_CALLS=$((RUNTIME_PREFLIGHT_CALLS + 1)) + } + install_skills() { echo INSTALL_ACTION; } + + do_install > "$TEST_OUTPUT" 2>&1 + + [[ $RUNTIME_PREFLIGHT_CALLS -eq 0 ]] || fail "dry-run probed the host" + assert_contains "$TEST_OUTPUT" 'host probes skipped' + assert_contains "$TEST_OUTPUT" INSTALL_ACTION + local preflight_line install_line + preflight_line="$(grep -n -m1 'host probes skipped' "$TEST_OUTPUT" | cut -d: -f1)" + install_line="$(grep -n -m1 INSTALL_ACTION "$TEST_OUTPUT" | cut -d: -f1)" + (( preflight_line < install_line )) || fail "dry-run listed install before preflight" +} + +run_test() { + local name="$1" status + set +e + ( set -e; "$name" ) + status=$? + set -e + if [[ $status -eq 0 ]]; then + echo "ok - $name" + else + echo "not ok - $name" >&2 + return 1 + fi +} + +run_test test_manifest_parser_covers_component_dependencies +run_test test_user_skips_ws_ckpt_noop_install_dependencies +run_test test_manifest_parser_uses_toml_keys_not_order +run_test test_user_reports_all_components_once_without_root +run_test test_system_installs_packages_once_and_reprobes +run_test test_system_stops_before_packages_for_platform_blocker +run_test test_system_reprobe_failure_reports_every_dependency +run_test test_system_apt_update_failure_stops_before_install +run_test test_unknown_package_manager_reports_aggregate +run_test test_rpm_report_uses_manifest_package_names +run_test test_ignore_deps_never_installs_packages +run_test test_system_node_rejects_user_nvm_fallback +run_test test_system_old_repo_node_is_manual_blocker +run_test test_system_language_runtime_never_auto_installs +run_test test_install_node_system_does_not_fall_back_to_nvm +run_test test_install_node_user_installs_node24_with_nvm +run_test test_system_package_probe_ignores_user_path +run_test test_language_runtime_version_is_enforced +run_test test_btrfs_module_probe_uses_system_path +run_test test_btrfs_progs_probe_includes_system_sbin +run_test test_manifest_load_failure_is_not_silently_ignored +run_test test_deps_only_runs_runtime_preflight +run_test test_retry_command_preserves_mode_and_location +run_test test_user_source_dependency_setup_precedes_full_preflight +run_test test_system_satisfied_preflight_precedes_source_dependency_setup +run_test test_platform_preflight_precedes_dependency_changes +run_test test_no_install_skips_runtime_preflight +run_test test_preflight_failure_precedes_first_install +run_test test_dry_run_skips_host_preflight