diff --git a/scripts/checks/run-native-runtime-installer-qualification.sh b/scripts/checks/run-native-runtime-installer-qualification.sh new file mode 100755 index 00000000000..0dfd610e6e8 --- /dev/null +++ b/scripts/checks/run-native-runtime-installer-qualification.sh @@ -0,0 +1,416 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail +umask 077 + +MAX_INSTALLER_BYTES=524288 +MAX_SETUP_SCRIPT_BYTES=131072 +MAX_JSON_BYTES=4096 +CANONICAL_REPOSITORY="https://github.com/NVIDIA/NemoClaw.git" + +usage() { + printf '%s\n' \ + "Usage: $0 --candidate-checkout --candidate-sha --installer-sha256 --architecture --artifact-dir " +} + +fail() { + printf 'Native runtime installer qualification failed: %s\n' "$*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 \ + || fail "$1 is required for native runtime installer qualification." +} + +file_sha256() { + sha256sum "$1" | awk '{print $1}' +} + +trusted_git() { + ( + export GIT_CONFIG_GLOBAL=/dev/null + export GIT_CONFIG_NOSYSTEM=1 + export GIT_NO_REPLACE_OBJECTS=1 + command git -c core.fsmonitor=false -c core.hooksPath=/dev/null "$@" + ) +} + +bounded_file() { + local file_path="$1" + local maximum_bytes="$2" + local byte_count="" + byte_count="$(wc -c <"$file_path" | tr -d '[:space:]')" + [[ "$byte_count" =~ ^[0-9]+$ && "$byte_count" -le "$maximum_bytes" ]] \ + || fail "$(basename "$file_path") exceeds its receipt size limit." +} + +assert_canonical_directory() { + local directory="$1" + local label="$2" + local canonical="" + + [[ "$directory" == /* && -d "$directory" && ! -L "$directory" && -O "$directory" ]] \ + || fail "$label must be an absolute, non-symlinked directory owned by the qualification process UID." + canonical="$(cd "$directory" && pwd -P)" + [[ "$canonical" == "$directory" ]] \ + || fail "$label must not contain symbolic links or path traversal." +} + +assert_checkout_has_no_git_credentials() { + local checkout="$1" + local label="$2" + if trusted_git -C "$checkout" config --local --no-includes --get-regexp '^credential\.' >/dev/null 2>&1 \ + || trusted_git -C "$checkout" config --local --no-includes --get-regexp '^http\..*\.extraheader$' >/dev/null 2>&1; then + fail "$label must not store Git credentials." + fi +} + +verify_checkout() { + local checkout="$1" + local expected_revision="$2" + local label="$3" + local repository_root="" + local revision="" + local remote="" + local -a remote_urls=() + + assert_canonical_directory "$checkout" "$label" + [[ -e "${checkout}/.git" && ! -L "${checkout}/.git" ]] \ + || fail "$label must contain Git metadata that is not a symbolic link." + repository_root="$(trusted_git -C "$checkout" rev-parse --show-toplevel 2>/dev/null)" \ + || fail "$label is not a Git checkout." + [[ "$(cd "$repository_root" && pwd -P)" == "$checkout" ]] \ + || fail "$label must be the repository root." + revision="$(trusted_git -C "$checkout" rev-parse --verify 'HEAD^{commit}' 2>/dev/null)" \ + || fail "$label does not identify a commit." + [[ "$revision" == "$expected_revision" ]] \ + || fail "$label does not match the candidate commit." + mapfile -t remote_urls < <( + trusted_git -C "$checkout" config --local --no-includes --get-all remote.origin.url 2>/dev/null + ) + [[ "${#remote_urls[@]}" -eq 1 ]] || fail "$label must have one origin repository." + remote="${remote_urls[0]}" + case "$remote" in + "$CANONICAL_REPOSITORY" | "${CANONICAL_REPOSITORY%.git}") ;; + *) fail "$label has an unexpected origin repository." ;; + esac + assert_checkout_has_no_git_credentials "$checkout" "$label" +} + +verify_committed_file() { + local checkout="$1" + local revision="$2" + local relative_path="$3" + local file_path="$4" + local label="$5" + local maximum_bytes="$6" + local committed_blob="" + local working_blob="" + + [[ -f "$file_path" && ! -L "$file_path" && -O "$file_path" ]] \ + || fail "$label must be a non-symlinked regular file owned by the qualification process UID." + bounded_file "$file_path" "$maximum_bytes" + committed_blob="$(trusted_git -C "$checkout" rev-parse "${revision}:${relative_path}" 2>/dev/null)" \ + || fail "The candidate commit does not contain ${relative_path}." + working_blob="$(trusted_git hash-object --no-filters "$file_path" 2>/dev/null)" \ + || fail "Could not identify the Git object for ${label}." + [[ "$working_blob" == "$committed_blob" ]] \ + || fail "$label bytes do not match the candidate commit." +} + +verify_installer() { + local checkout="$1" + local revision="$2" + local installer="$3" + local expected_sha256="$4" + local actual_sha256="" + + verify_committed_file \ + "$checkout" \ + "$revision" \ + "scripts/install.sh" \ + "$installer" \ + "The candidate installer" \ + "$MAX_INSTALLER_BYTES" + actual_sha256="$(file_sha256 "$installer")" + [[ "$actual_sha256" == "$expected_sha256" ]] \ + || fail "The candidate installer SHA-256 does not match the trusted plan." +} + +docker_socket_paths() { + printf '%s\n' /var/run/docker.sock /run/docker.sock + if [[ -n "${XDG_RUNTIME_DIR:-}" ]]; then + printf '%s\n' "${XDG_RUNTIME_DIR%/}/docker.sock" + fi +} + +assert_docker_unavailable() { + local phase="$1" + local docker_guard="$2" + local expected_guard_sha256="$3" + local docker_command="" + local actual_guard_sha256="" + local guard_status=0 + local socket_path="" + local variable_name="" + + for variable_name in DOCKER_CERT_PATH DOCKER_CONFIG DOCKER_CONTEXT DOCKER_HOST DOCKER_TLS_VERIFY; do + [[ -z "${!variable_name:-}" ]] \ + || fail "${variable_name} must be unset during the ${phase} Docker check." + done + + [[ -f "$docker_guard" && -x "$docker_guard" && ! -L "$docker_guard" && -O "$docker_guard" ]] \ + || fail "The Docker command guard has invalid file properties during the ${phase} check." + actual_guard_sha256="$(file_sha256 "$docker_guard")" + [[ "$actual_guard_sha256" == "$expected_guard_sha256" ]] \ + || fail "The Docker command guard bytes changed before the ${phase} check." + docker_command="$(type -P docker 2>/dev/null || true)" + [[ "$docker_command" == "$docker_guard" ]] \ + || fail "Docker commands must resolve to the qualification guard during the ${phase} check." + "$docker_guard" >/dev/null 2>&1 || guard_status=$? + [[ "$guard_status" -eq 97 ]] \ + || fail "The Docker command guard did not deny execution during the ${phase} check." + + require_command systemctl + if systemctl is-active --quiet docker.service 2>/dev/null; then + fail "docker.service is active during the ${phase} check." + fi + if systemctl is-active --quiet docker.socket 2>/dev/null; then + fail "docker.socket is active during the ${phase} check." + fi + require_command pgrep + if pgrep -x dockerd >/dev/null 2>&1; then + fail "dockerd is running during the ${phase} check." + fi + + while IFS= read -r socket_path; do + [[ -n "$socket_path" ]] || continue + [[ ! -S "$socket_path" ]] \ + || fail "A Docker socket exists during the ${phase} check." + done < <(docker_socket_paths) +} + +run_native_runtime_installer_qualification() { + candidate_checkout="" + candidate_sha="" + expected_installer_sha256="" + expected_architecture="" + artifact_dir_input="" + while [[ "$#" -gt 0 ]]; do + case "$1" in + --candidate-checkout) + [[ "$#" -ge 2 ]] || fail "--candidate-checkout requires a value." + candidate_checkout="$2" + shift 2 + ;; + --candidate-sha) + [[ "$#" -ge 2 ]] || fail "--candidate-sha requires a value." + candidate_sha="$2" + shift 2 + ;; + --installer-sha256) + [[ "$#" -ge 2 ]] || fail "--installer-sha256 requires a value." + expected_installer_sha256="$2" + shift 2 + ;; + --architecture) + [[ "$#" -ge 2 ]] || fail "--architecture requires a value." + expected_architecture="$2" + shift 2 + ;; + --artifact-dir) + [[ "$#" -ge 2 ]] || fail "--artifact-dir requires a value." + artifact_dir_input="$2" + shift 2 + ;; + --help | -h) + usage + exit 0 + ;; + *) + usage >&2 + fail "Unknown argument: $1" + ;; + esac + done + + for required_command in awk bash git mktemp pgrep sha256sum systemctl wc; do + require_command "$required_command" + done + + [[ "$candidate_sha" =~ ^[0-9a-f]{40}$ ]] \ + || fail "--candidate-sha must be a lowercase 40-character commit SHA." + [[ "$expected_installer_sha256" =~ ^[0-9a-f]{64}$ ]] \ + || fail "--installer-sha256 must be a lowercase SHA-256 digest." + case "$expected_architecture" in + amd64 | arm64) ;; + *) fail "--architecture must be amd64 or arm64." ;; + esac + assert_canonical_directory "$candidate_checkout" "The candidate checkout" + + [[ "$artifact_dir_input" == /* ]] \ + || fail "--artifact-dir must be an absolute path." + [[ ! -e "$artifact_dir_input" && ! -L "$artifact_dir_input" ]] \ + || fail "--artifact-dir must not already exist." + artifact_parent="$(dirname "$artifact_dir_input")" + artifact_name="$(basename "$artifact_dir_input")" + [[ "$artifact_name" =~ ^[A-Za-z0-9._-]+$ && "$artifact_name" != "." && "$artifact_name" != ".." ]] \ + || fail "--artifact-dir must end with a simple directory name." + assert_canonical_directory "$artifact_parent" "The artifact parent" + artifact_dir="${artifact_parent}/${artifact_name}" + + case "$(uname -m)" in + x86_64) runner_architecture=amd64 ;; + aarch64 | arm64) runner_architecture=arm64 ;; + *) fail "This runner architecture is not supported by native runtime qualification." ;; + esac + [[ "$runner_architecture" == "$expected_architecture" ]] \ + || fail "The requested architecture does not match the runner architecture." + + candidate_installer="${candidate_checkout}/scripts/install.sh" + candidate_setup_script="${candidate_checkout}/scripts/setup-jetson.sh" + verify_checkout "$candidate_checkout" "$candidate_sha" "The candidate checkout" + verify_installer \ + "$candidate_checkout" \ + "$candidate_sha" \ + "$candidate_installer" \ + "$expected_installer_sha256" + verify_committed_file \ + "$candidate_checkout" \ + "$candidate_sha" \ + "scripts/setup-jetson.sh" \ + "$candidate_setup_script" \ + "The candidate setup script" \ + "$MAX_SETUP_SCRIPT_BYTES" + + qualification_root="$(mktemp -d /tmp/nemoclaw-native-runtime-installer.XXXXXX)" + qualification_home="${qualification_root}/home" + qualification_tmp="${qualification_root}/tmp" + docker_guard_dir="${qualification_root}/docker-guard" + managed_payload_root="${qualification_root}/managed-installer-payload" + verified_script_dir="${qualification_root}/candidate-scripts" + verified_installer="${verified_script_dir}/install.sh" + verified_setup_script="${verified_script_dir}/setup-jetson.sh" + installed_checkout="${qualification_home}/.nemoclaw/source" + receipt_stage="$(mktemp -d "${artifact_parent}/.${artifact_name}.XXXXXX")" + mkdir -m 700 \ + "$qualification_home" \ + "$qualification_tmp" \ + "$docker_guard_dir" \ + "$managed_payload_root" \ + "$verified_script_dir" + + cleanup() { + if [[ -n "${receipt_stage:-}" && -d "$receipt_stage" && ! -L "$receipt_stage" ]]; then + rm -rf -- "$receipt_stage" + fi + if [[ -n "${qualification_root:-}" && -d "$qualification_root" && ! -L "$qualification_root" ]]; then + rm -rf -- "$qualification_root" + fi + } + trap cleanup EXIT + + cp -- "$candidate_installer" "$verified_installer" + cp -- "$candidate_setup_script" "$verified_setup_script" + chmod 500 "$verified_installer" "$verified_setup_script" + [[ "$(file_sha256 "$verified_installer")" == "$expected_installer_sha256" ]] \ + || fail "The verified installer copy changed before execution." + verify_committed_file \ + "$candidate_checkout" \ + "$candidate_sha" \ + "scripts/setup-jetson.sh" \ + "$verified_setup_script" \ + "The verified setup script" \ + "$MAX_SETUP_SCRIPT_BYTES" + + docker_guard="${docker_guard_dir}/docker" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "Docker commands are blocked during native runtime installer qualification.\\n" >&2' \ + 'exit 97' >"$docker_guard" + chmod 500 "$docker_guard" + docker_guard_sha256="$(file_sha256 "$docker_guard")" + PATH="${docker_guard_dir}:${PATH}" + export PATH + + assert_docker_unavailable "pre-execution" "$docker_guard" "$docker_guard_sha256" + + candidate_status=0 + # The child shell expands positional parameters inside this literal program. + # shellcheck disable=SC2016 + env -i \ + ACCEPT_THIRD_PARTY_SOFTWARE=1 \ + HOME="$qualification_home" \ + LANG=C.UTF-8 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ + NEMOCLAW_DEFER_OPENSHELL_INSTALL=1 \ + NEMOCLAW_INSTALL_REF="$candidate_sha" \ + NEMOCLAW_NO_EXPRESS=1 \ + NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_REPO_ROOT="$managed_payload_root" \ + NEMOCLAW_SHIM_DIR="${qualification_home}/.local/bin" \ + NON_INTERACTIVE=1 \ + NO_COLOR=1 \ + PATH="$PATH" \ + TMPDIR="$qualification_tmp" \ + bash --noprofile --norc -c ' + set -euo pipefail + source "$1" + SCRIPT_DIR="$2" + _INSTALLER_SCRIPT_PATH="$1" + declare -F install_nemoclaw_before_onboarding >/dev/null \ + || { printf "Candidate installer has no pre-onboarding phase executor.\n" >&2; exit 96; } + install_nemoclaw_before_onboarding + ' _ "$verified_installer" "$verified_script_dir" || candidate_status=$? + + assert_docker_unavailable "post-execution" "$docker_guard" "$docker_guard_sha256" + [[ "$candidate_status" -eq 0 ]] \ + || fail "The candidate installer phase executor exited with status ${candidate_status}." + + verify_checkout "$installed_checkout" "$candidate_sha" "The installed checkout" + verify_installer \ + "$installed_checkout" \ + "$candidate_sha" \ + "${installed_checkout}/scripts/install.sh" \ + "$expected_installer_sha256" + + cp -- "$verified_installer" "${receipt_stage}/installer.sh" + printf '{"receiptVersion":1,"script":"scripts/install.sh","scriptSha256":"%s","candidateSha":"%s","architecture":"%s"}\n' \ + "$expected_installer_sha256" "$candidate_sha" "$runner_architecture" \ + >"${receipt_stage}/invocation.json" + printf '{"receiptVersion":1,"repository":"%s","revision":"%s","installerSha256":"%s"}\n' \ + "$CANONICAL_REPOSITORY" "$candidate_sha" "$expected_installer_sha256" \ + >"${receipt_stage}/candidate-source.json" + printf '{"receiptVersion":1,"repository":"%s","requestedRevision":"%s","installedRevision":"%s","installMode":"managed","installerSha256":"%s"}\n' \ + "$CANONICAL_REPOSITORY" "$candidate_sha" "$candidate_sha" "$expected_installer_sha256" \ + >"${receipt_stage}/installed-source.json" + printf '{"receiptVersion":1,"requested":"%s","runner":"%s"}\n' \ + "$expected_architecture" "$runner_architecture" \ + >"${receipt_stage}/architecture.json" + printf '%s\n' \ + '{"receiptVersion":1,"preExecution":{"dockerCommandGuarded":true,"dockerEnvironmentVariablesUnset":true,"dockerServiceInactive":true,"dockerSocketUnitInactive":true,"dockerdProcessNameAbsent":true,"defaultSocketPathsAbsent":true},"postExecution":{"dockerCommandGuarded":true,"dockerEnvironmentVariablesUnset":true,"dockerServiceInactive":true,"dockerSocketUnitInactive":true,"dockerdProcessNameAbsent":true,"defaultSocketPathsAbsent":true}}' \ + >"${receipt_stage}/docker-absence.json" + + bounded_file "${receipt_stage}/installer.sh" "$MAX_INSTALLER_BYTES" + for receipt_path in \ + "${receipt_stage}/invocation.json" \ + "${receipt_stage}/candidate-source.json" \ + "${receipt_stage}/installed-source.json" \ + "${receipt_stage}/architecture.json" \ + "${receipt_stage}/docker-absence.json"; do + bounded_file "$receipt_path" "$MAX_JSON_BYTES" + done + chmod 600 "${receipt_stage}"/* + mv -T -- "$receipt_stage" "$artifact_dir" \ + || fail "Could not publish the qualification receipts to ${artifact_dir}." + receipt_stage="" + + printf 'Native runtime installer qualification receipts: %s\n' "$artifact_dir" +} + +if [[ "${BASH_SOURCE[0]:-}" == "$0" ]]; then + run_native_runtime_installer_qualification "$@" +fi diff --git a/scripts/install.sh b/scripts/install.sh index 99462145c7a..73775f1157d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -5894,6 +5894,29 @@ maybe_offer_express_install() { esac } +# The qualification runner calls these phases without starting onboarding. +# --------------------------------------------------------------------------- +install_nemoclaw_before_onboarding() { + _INSTALL_START=$SECONDS + bash "${SCRIPT_DIR}/setup-jetson.sh" + + step 1 "Node.js" + install_nodejs + ensure_supported_runtime + resolve_pending_express_wsl_provider + ensure_station_express_pair + + step 2 "${_CLI_DISPLAY} CLI" + # Ollama and vLLM install/upgrade and model pulls are owned by + # `nemoclaw onboard` (the install-ollama / install-vllm branches). + # install.sh stays focused on dependency setup. + fix_npm_permissions + preinstall_backup_and_retire_legacy_gateway + install_nemoclaw + verify_nemoclaw + require_reportable_openshell_version +} + # Main # --------------------------------------------------------------------------- main() { @@ -6047,24 +6070,7 @@ main() { # host prerequisite preparation before the generic Docker bootstrap. prepare_installer_host - _INSTALL_START=$SECONDS - bash "${SCRIPT_DIR}/setup-jetson.sh" - - step 1 "Node.js" - install_nodejs - ensure_supported_runtime - resolve_pending_express_wsl_provider - ensure_station_express_pair - - step 2 "${_CLI_DISPLAY} CLI" - # Ollama and vLLM install/upgrade and model pulls are owned by - # `nemoclaw onboard` (the install-ollama / install-vllm branches). - # install.sh stays focused on dependency setup. - fix_npm_permissions - preinstall_backup_and_retire_legacy_gateway - install_nemoclaw - verify_nemoclaw - require_reportable_openshell_version + install_nemoclaw_before_onboarding # Gate the onboarding-adjacent steps on the absolute CLI path so a stale # shell PATH cache no longer suppresses auto-onboarding (#3276). Falls diff --git a/test/install-native-runtime-qualification.test.ts b/test/install-native-runtime-qualification.test.ts new file mode 100644 index 00000000000..39f305b5477 --- /dev/null +++ b/test/install-native-runtime-qualification.test.ts @@ -0,0 +1,478 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, onTestFinished } from "vitest"; + +const REPOSITORY_ROOT = path.join(import.meta.dirname, ".."); +const INSTALLER = path.join(REPOSITORY_ROOT, "scripts", "install.sh"); +const QUALIFICATION_RUNNER = path.join( + REPOSITORY_ROOT, + "scripts", + "checks", + "run-native-runtime-installer-qualification.sh", +); +const OTHER_SHA = "89abcdef0123456789abcdef0123456789abcdef"; +const ARCHITECTURE = os.arch() === "x64" ? "amd64" : "arm64"; +const describeLinux = process.platform === "linux" ? describe : describe.skip; + +function temporaryDirectory(prefix: string): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + onTestFinished(() => fs.rmSync(directory, { recursive: true, force: true })); + return directory; +} + +function dockerFreeEnvironment(): NodeJS.ProcessEnv { + const environment = { ...process.env }; + for (const name of [ + "DOCKER_CERT_PATH", + "DOCKER_CONFIG", + "DOCKER_CONTEXT", + "DOCKER_HOST", + "DOCKER_TLS_VERIFY", + "XDG_RUNTIME_DIR", + ]) { + delete environment[name]; + } + return environment; +} + +function writeExecutable(filePath: string, contents: string): void { + fs.writeFileSync(filePath, contents, { mode: 0o755 }); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function runGit(repository: string, args: string[]): string { + const result = spawnSync("git", args, { + cwd: repository, + encoding: "utf-8", + }); + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + return result.stdout.trim(); +} + +type CandidateFixture = { + root: string; + installer: string; + installerSha256: string; + revision: string; + sourceMarker: string; +}; + +function candidateFixture( + options: { + dockerState?: string; + installPreviousRevision?: boolean; + replaceDockerGuard?: boolean; + } = {}, +): CandidateFixture { + const fixtureRoot = temporaryDirectory("nemoclaw-native-candidate-"); + const candidateRoot = path.join(fixtureRoot, "candidate"); + const scriptsDirectory = path.join(candidateRoot, "scripts"); + const installer = path.join(scriptsDirectory, "install.sh"); + const sourceMarker = path.join(fixtureRoot, "candidate-sourced"); + fs.mkdirSync(scriptsDirectory, { recursive: true }); + + runGit(candidateRoot, ["init", "--quiet"]); + runGit(candidateRoot, ["config", "user.name", "Qualification Test"]); + runGit(candidateRoot, ["config", "user.email", "qualification@example.com"]); + runGit(candidateRoot, ["config", "commit.gpgsign", "false"]); + fs.writeFileSync(path.join(candidateRoot, "README.md"), "candidate fixture\n"); + runGit(candidateRoot, ["add", "README.md"]); + runGit(candidateRoot, ["commit", "--quiet", "-m", "test: add candidate fixture"]); + + const dockerMutation = options.dockerState + ? `printf 'running\\n' >${shellQuote(options.dockerState)}` + : ":"; + const installedRevision = options.installPreviousRevision + ? 'git -C "$installed_checkout" checkout --quiet --detach HEAD^' + : ":"; + const guardMutation = options.replaceDockerGuard + ? `docker_guard_path="$(command -v docker)" + chmod u+w "$docker_guard_path" + printf '#!/usr/bin/env bash\\nexit 0\\n' >"$docker_guard_path" + chmod 500 "$docker_guard_path"` + : ":"; + writeExecutable( + installer, + `#!/usr/bin/env bash +set -euo pipefail +printf 'sourced\\n' >${shellQuote(sourceMarker)} + +install_nemoclaw_before_onboarding() { + [[ -z "\${QUALIFICATION_TEST_CREDENTIAL:-}" ]] || exit 71 + ${dockerMutation} + ${guardMutation} + installed_checkout="\${HOME}/.nemoclaw/source" + mkdir -p "$(dirname "$installed_checkout")" + git clone --quiet ${shellQuote(candidateRoot)} "$installed_checkout" + git -C "$installed_checkout" remote set-url origin https://github.com/NVIDIA/NemoClaw.git + ${installedRevision} +} +`, + ); + writeExecutable(path.join(scriptsDirectory, "setup-jetson.sh"), "#!/usr/bin/env bash\nexit 0\n"); + runGit(candidateRoot, ["add", "scripts/install.sh", "scripts/setup-jetson.sh"]); + runGit(candidateRoot, ["commit", "--quiet", "-m", "test: add installer phase"]); + runGit(candidateRoot, ["remote", "add", "origin", "https://github.com/NVIDIA/NemoClaw.git"]); + + const installerBytes = fs.readFileSync(installer); + return { + root: candidateRoot, + installer, + installerSha256: createHash("sha256").update(installerBytes).digest("hex"), + revision: runGit(candidateRoot, ["rev-parse", "HEAD"]), + sourceMarker, + }; +} + +function runQualification( + candidate: CandidateFixture, + artifactDirectory: string, + options: { + candidateSha?: string; + environment?: NodeJS.ProcessEnv; + installerSha256?: string; + publishRace?: boolean; + } = {}, +) { + const toolDirectory = temporaryDirectory("nemoclaw-native-tools-"); + const socketProbe = path.join(toolDirectory, "docker.sock"); + writeExecutable(path.join(toolDirectory, "systemctl"), "#!/usr/bin/env bash\nexit 1\n"); + writeExecutable(path.join(toolDirectory, "pgrep"), "#!/usr/bin/env bash\nexit 1\n"); + const moveScript = options.publishRace + ? '#!/usr/bin/env bash\nmkdir -p -- "${!#}"\ntouch -- "${!#}/.concurrent-writer"\nexec /usr/bin/mv "$@"\n' + : '#!/usr/bin/env bash\nexec /usr/bin/mv "$@"\n'; + writeExecutable(path.join(toolDirectory, "mv"), moveScript); + const qualificationArguments = [ + "--candidate-checkout", + candidate.root, + "--candidate-sha", + options.candidateSha ?? candidate.revision, + "--installer-sha256", + options.installerSha256 ?? candidate.installerSha256, + "--architecture", + ARCHITECTURE, + "--artifact-dir", + artifactDirectory, + ]; + const inheritedPath = + options.environment?.PATH ?? `${toolDirectory}:${process.env.PATH ?? "/usr/bin:/bin"}`; + return spawnSync( + "bash", + [ + "-c", + ` +set -euo pipefail +source "$QUALIFICATION_RUNNER" +docker_socket_paths() { printf '%s\\n' "$QUALIFICATION_SOCKET_PROBE"; } +run_native_runtime_installer_qualification "$@" +`, + "_", + ...qualificationArguments, + ], + { + encoding: "utf-8", + env: { + ...dockerFreeEnvironment(), + ...options.environment, + PATH: inheritedPath, + QUALIFICATION_RUNNER, + QUALIFICATION_SOCKET_PROBE: socketProbe, + }, + }, + ); +} + +function phaseHarness(body: string, environment: NodeJS.ProcessEnv = {}) { + return spawnSync("bash", ["-c", body], { + encoding: "utf-8", + env: { + ...dockerFreeEnvironment(), + ...environment, + INSTALLER_UNDER_TEST: INSTALLER, + }, + }); +} + +describeLinux("native runtime installer qualification", () => { + it("keeps the ordinary installer phase order", () => { + const fixtureRoot = temporaryDirectory("nemoclaw-native-phase-"); + const setupDirectory = path.join(fixtureRoot, "payload"); + const callLog = path.join(fixtureRoot, "calls.log"); + fs.mkdirSync(setupDirectory); + writeExecutable( + path.join(setupDirectory, "setup-jetson.sh"), + '#!/usr/bin/env bash\nprintf "setup-jetson\\n" >>"$CALL_LOG"\n', + ); + + const result = phaseHarness( + ` +set -euo pipefail +source "$INSTALLER_UNDER_TEST" +SCRIPT_DIR="$SETUP_DIRECTORY" +record() { printf '%s\n' "$1" >>"$CALL_LOG"; } +load_station_vllm_conflict_helpers() { :; } +consume_station_local_vllm_resume() { return 1; } +resolve_nemoclaw_gateway_port() { printf '8080'; } +preflight_explicit_express_flags() { :; } +print_banner() { :; } +preflight_usage_notice_prompt() { :; } +prepare_installer_host() { record prepare-installer-host; } +step() { record "step-$1-$2"; } +install_nodejs() { record install-nodejs; } +ensure_supported_runtime() { record ensure-supported-runtime; } +resolve_pending_express_wsl_provider() { record resolve-pending-express-wsl-provider; } +ensure_station_express_pair() { record ensure-station-express-pair; } +fix_npm_permissions() { record fix-npm-permissions; } +preinstall_backup_and_retire_legacy_gateway() { record preinstall-backup; } +install_nemoclaw() { record install-nemoclaw; } +verify_nemoclaw() { record verify-nemoclaw; } +require_reportable_openshell_version() { record require-reportable-openshell-version; } +command_exists() { return 1; } +finalize_install() { record finalize-install; } +clear_station_resume_after_completed_onboarding() { :; } +main --non-interactive --yes-i-accept-third-party-software +`, + { CALL_LOG: callLog, SETUP_DIRECTORY: setupDirectory }, + ); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + expect(fs.readFileSync(callLog, "utf-8").trim().split("\n")).toEqual([ + "prepare-installer-host", + "setup-jetson", + "step-1-Node.js", + "install-nodejs", + "ensure-supported-runtime", + "resolve-pending-express-wsl-provider", + "ensure-station-express-pair", + "step-2-NemoClaw CLI", + "fix-npm-permissions", + "preinstall-backup", + "install-nemoclaw", + "verify-nemoclaw", + "require-reportable-openshell-version", + "step-3-Onboarding", + "finalize-install", + ]); + }); + + it("rejects a candidate commit mismatch before it sources candidate code", () => { + const candidate = candidateFixture(); + const artifactParent = temporaryDirectory("nemoclaw-native-artifacts-"); + const result = runQualification(candidate, path.join(artifactParent, "qualification"), { + candidateSha: OTHER_SHA, + }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + "candidate checkout does not match the candidate commit", + ); + expect(fs.existsSync(candidate.sourceMarker)).toBe(false); + }); + + it("rejects changed installer bytes before it sources candidate code", () => { + const candidate = candidateFixture(); + const artifactParent = temporaryDirectory("nemoclaw-native-artifacts-"); + fs.appendFileSync(candidate.installer, "# changed after checkout\n"); + const changedDigest = createHash("sha256") + .update(fs.readFileSync(candidate.installer)) + .digest("hex"); + const result = runQualification(candidate, path.join(artifactParent, "qualification"), { + installerSha256: changedDigest, + }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + "candidate installer bytes do not match the candidate commit", + ); + expect(fs.existsSync(candidate.sourceMarker)).toBe(false); + }); + + it("rejects an installer digest that differs from the trusted plan", () => { + const candidate = candidateFixture(); + const artifactParent = temporaryDirectory("nemoclaw-native-artifacts-"); + const result = runQualification(candidate, path.join(artifactParent, "qualification"), { + installerSha256: "a".repeat(64), + }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + "candidate installer SHA-256 does not match the trusted plan", + ); + expect(fs.existsSync(candidate.sourceMarker)).toBe(false); + }); + + it("rejects a candidate checkout that stores a Git credential header", () => { + const candidate = candidateFixture(); + const artifactParent = temporaryDirectory("nemoclaw-native-artifacts-"); + runGit(candidate.root, [ + "config", + "http.https://github.com/.extraheader", + "AUTHORIZATION: bearer test-value", + ]); + const result = runQualification(candidate, path.join(artifactParent, "qualification")); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + "candidate checkout must not store Git credentials", + ); + expect(fs.existsSync(candidate.sourceMarker)).toBe(false); + }); + + it("rejects active Docker before it sources candidate code", () => { + const candidate = candidateFixture(); + const fixtureRoot = temporaryDirectory("nemoclaw-native-docker-"); + const toolDirectory = path.join(fixtureRoot, "bin"); + const artifactParent = path.join(fixtureRoot, "artifacts"); + fs.mkdirSync(toolDirectory); + fs.mkdirSync(artifactParent); + writeExecutable(path.join(toolDirectory, "systemctl"), "#!/usr/bin/env bash\nexit 0\n"); + + const result = runQualification(candidate, path.join(artifactParent, "qualification"), { + environment: { PATH: `${toolDirectory}:${process.env.PATH ?? "/usr/bin:/bin"}` }, + }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + "docker.service is active during the pre-execution check", + ); + expect(fs.existsSync(candidate.sourceMarker)).toBe(false); + }); + + it("rejects Docker that becomes active during candidate execution", () => { + const fixtureRoot = temporaryDirectory("nemoclaw-native-docker-"); + const dockerState = path.join(fixtureRoot, "dockerd-running"); + const candidate = candidateFixture({ dockerState }); + const toolDirectory = path.join(fixtureRoot, "bin"); + const artifactParent = path.join(fixtureRoot, "artifacts"); + fs.mkdirSync(toolDirectory); + fs.mkdirSync(artifactParent); + writeExecutable(path.join(toolDirectory, "systemctl"), "#!/usr/bin/env bash\nexit 1\n"); + writeExecutable( + path.join(toolDirectory, "pgrep"), + `#!/usr/bin/env bash +[[ -e ${shellQuote(dockerState)} ]] +`, + ); + + const result = runQualification(candidate, path.join(artifactParent, "qualification"), { + environment: { PATH: `${toolDirectory}:${process.env.PATH ?? "/usr/bin:/bin"}` }, + }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + "dockerd is running during the post-execution check", + ); + expect(fs.existsSync(candidate.sourceMarker)).toBe(true); + }); + + it("rejects Docker command guard bytes changed by candidate code", () => { + const candidate = candidateFixture({ replaceDockerGuard: true }); + const artifactParent = temporaryDirectory("nemoclaw-native-artifacts-"); + const result = runQualification(candidate, path.join(artifactParent, "qualification")); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + "Docker command guard bytes changed before the post-execution check", + ); + }); + + it("independently rejects an installed checkout at a different commit", () => { + const candidate = candidateFixture({ installPreviousRevision: true }); + const artifactParent = temporaryDirectory("nemoclaw-native-artifacts-"); + const result = runQualification(candidate, path.join(artifactParent, "qualification")); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + "installed checkout does not match the candidate commit", + ); + }); + + it("writes bounded receipts after both Docker checks and installed-source verification", () => { + const candidate = candidateFixture(); + const artifactParent = temporaryDirectory("nemoclaw-native-artifacts-"); + const artifactDirectory = path.join(artifactParent, "qualification"); + const credentialValue = "native-qualification-credential-value"; + const result = runQualification(candidate, artifactDirectory, { + environment: { QUALIFICATION_TEST_CREDENTIAL: credentialValue }, + }); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + const receiptNames = fs.readdirSync(artifactDirectory).sort(); + expect(receiptNames).toEqual([ + "architecture.json", + "candidate-source.json", + "docker-absence.json", + "installed-source.json", + "installer.sh", + "invocation.json", + ]); + const receiptContents = receiptNames + .map((name) => fs.readFileSync(path.join(artifactDirectory, name), "utf-8")) + .join("\n"); + expect(receiptContents).not.toContain(credentialValue); + expect(fs.statSync(path.join(artifactDirectory, "installer.sh")).size).toBeLessThanOrEqual( + 524288, + ); + for (const receiptName of receiptNames.filter((name) => name.endsWith(".json"))) { + expect(fs.statSync(path.join(artifactDirectory, receiptName)).size).toBeLessThanOrEqual(4096); + } + expect( + JSON.parse(fs.readFileSync(path.join(artifactDirectory, "invocation.json"), "utf-8")), + ).toMatchObject({ + candidateSha: candidate.revision, + architecture: ARCHITECTURE, + scriptSha256: candidate.installerSha256, + }); + expect( + JSON.parse(fs.readFileSync(path.join(artifactDirectory, "installed-source.json"), "utf-8")), + ).toMatchObject({ + requestedRevision: candidate.revision, + installedRevision: candidate.revision, + installMode: "managed", + installerSha256: candidate.installerSha256, + }); + expect( + JSON.parse(fs.readFileSync(path.join(artifactDirectory, "docker-absence.json"), "utf-8")), + ).toEqual({ + receiptVersion: 1, + preExecution: { + dockerCommandGuarded: true, + dockerEnvironmentVariablesUnset: true, + dockerServiceInactive: true, + dockerSocketUnitInactive: true, + dockerdProcessNameAbsent: true, + defaultSocketPathsAbsent: true, + }, + postExecution: { + dockerCommandGuarded: true, + dockerEnvironmentVariablesUnset: true, + dockerServiceInactive: true, + dockerSocketUnitInactive: true, + dockerdProcessNameAbsent: true, + defaultSocketPathsAbsent: true, + }, + }); + }); + + it("fails closed when a populated receipt target appears during publication", () => { + const candidate = candidateFixture(); + const artifactParent = temporaryDirectory("nemoclaw-native-artifacts-"); + const artifactDirectory = path.join(artifactParent, "qualification"); + + const result = runQualification(candidate, artifactDirectory, { publishRace: true }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Could not publish the qualification receipts"); + expect(fs.readdirSync(artifactDirectory)).toEqual([".concurrent-writer"]); + }); +});