diff --git a/.github/actions/ci-build-typecheck/action.yaml b/.github/actions/ci-build-typecheck/action.yaml index 03e12309ee6..db06972694c 100644 --- a/.github/actions/ci-build-typecheck/action.yaml +++ b/.github/actions/ci-build-typecheck/action.yaml @@ -18,6 +18,8 @@ runs: - name: Install dependencies shell: bash + env: + NODE_AUTH_TOKEN: ${{ github.event_name == 'push' && github.token || '' }} run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Build TypeScript plugin diff --git a/.github/actions/ci-cli-coverage-merge/action.yaml b/.github/actions/ci-cli-coverage-merge/action.yaml index 05c6af4799d..6e8bb427c81 100644 --- a/.github/actions/ci-cli-coverage-merge/action.yaml +++ b/.github/actions/ci-cli-coverage-merge/action.yaml @@ -39,7 +39,9 @@ runs: - name: Install dependencies shell: bash - run: npm install --ignore-scripts + env: + NODE_AUTH_TOKEN: ${{ github.event_name == 'push' && github.token || '' }} + run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Download compiled CLI artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/actions/ci-cli-coverage-shard/action.yaml b/.github/actions/ci-cli-coverage-shard/action.yaml index 63aedad52ac..f6b6a85eb90 100644 --- a/.github/actions/ci-cli-coverage-shard/action.yaml +++ b/.github/actions/ci-cli-coverage-shard/action.yaml @@ -101,6 +101,8 @@ runs: - name: Install dependencies shell: bash + env: + NODE_AUTH_TOKEN: ${{ github.event_name == 'push' && github.token || '' }} run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Validate changed live E2E mock parity diff --git a/.github/actions/ci-install-dependencies.sh b/.github/actions/ci-install-dependencies.sh index 2dc2d1bff3f..1721875fe7d 100755 --- a/.github/actions/ci-install-dependencies.sh +++ b/.github/actions/ci-install-dependencies.sh @@ -4,5 +4,57 @@ set -euo pipefail -npm ci --ignore-scripts -npm --prefix nemoclaw ci --ignore-scripts +candidate_npmrc="$(find . -path './.git' -prune -o -name .npmrc -print -quit)" +if [ -n "$candidate_npmrc" ]; then + echo "Candidate repository npm configuration is not allowed during trusted dependency installation." >&2 + exit 1 +fi + +for shrinkwrap in npm-shrinkwrap.json nemoclaw/npm-shrinkwrap.json; do + if [ -e "$shrinkwrap" ]; then + echo "Candidate npm shrinkwrap files are not allowed during trusted dependency installation." >&2 + exit 1 + fi +done + +event_name="${GITHUB_EVENT_NAME:-local}" +package_mode="registry" +if [ "$event_name" = "pull_request" ]; then + package_mode="artifact" + if [ -n "${NODE_AUTH_TOKEN:-}" ]; then + echo "Pull request dependency installation must not receive a package credential." >&2 + exit 1 + fi +fi + +target_root="$(pwd -P)" +trusted_root="$(cd "$(dirname "$0")/../.." && pwd -P)" +npm_cache="${NPM_CONFIG_CACHE:-${RUNNER_TEMP:-$target_root/.ci-cache}/npm}" +mkdir -p "$npm_cache" + +NEMOCLAW_CI_NPM_CACHE="$npm_cache" \ + NEMOCLAW_CI_NPM_PACKAGE_MODE="$package_mode" \ + NEMOCLAW_CI_TARGET_ROOT="$target_root" \ + NEMOCLAW_OPEN_SHELL_SDK_ARTIFACT_DIRECTORY="${RUNNER_TEMP:-$target_root/.ci-artifacts}/openshell-sdk" \ + node --experimental-strip-types "$trusted_root/scripts/checks/prepare-ci-npm-install.mts" + +trusted_npmrc="" +cleanup() { + if [ -n "$trusted_npmrc" ]; then + rm -f "$trusted_npmrc" + fi +} +trap cleanup EXIT + +if [ "$package_mode" = "registry" ] && [ -n "${NODE_AUTH_TOKEN:-}" ]; then + trusted_npmrc="${RUNNER_TEMP:-$target_root/.ci-cache}/trusted-npmrc" + mkdir -p "$(dirname "$trusted_npmrc")" + umask 077 + printf '%s\n' \ + '@nvidia:registry=https://npm.pkg.github.com' \ + "//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}" >"$trusted_npmrc" + export NPM_CONFIG_USERCONFIG="$trusted_npmrc" +fi + +npm ci --ignore-scripts --prefer-offline --cache "$npm_cache" +npm --prefix nemoclaw ci --ignore-scripts --prefer-offline --cache "$npm_cache" diff --git a/.github/actions/ci-installer-integration/action.yaml b/.github/actions/ci-installer-integration/action.yaml index b8d4487932d..dcc1e22ed75 100644 --- a/.github/actions/ci-installer-integration/action.yaml +++ b/.github/actions/ci-installer-integration/action.yaml @@ -18,6 +18,8 @@ runs: - name: Install dependencies shell: bash + env: + NODE_AUTH_TOKEN: ${{ github.event_name == 'push' && github.token || '' }} run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Build installer integration artifacts diff --git a/.github/actions/ci-plugin-coverage/action.yaml b/.github/actions/ci-plugin-coverage/action.yaml index 9af48f0b627..0f78c17d3be 100644 --- a/.github/actions/ci-plugin-coverage/action.yaml +++ b/.github/actions/ci-plugin-coverage/action.yaml @@ -18,6 +18,8 @@ runs: - name: Install dependencies shell: bash + env: + NODE_AUTH_TOKEN: ${{ github.event_name == 'push' && github.token || '' }} run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Run plugin coverage diff --git a/.github/actions/ci-static-checks/action.yaml b/.github/actions/ci-static-checks/action.yaml index a98eb4b9e3d..6ae0a731265 100644 --- a/.github/actions/ci-static-checks/action.yaml +++ b/.github/actions/ci-static-checks/action.yaml @@ -12,6 +12,9 @@ runs: with: node-version: "22" cache: npm + cache-dependency-path: | + package-lock.json + nemoclaw/package-lock.json - name: Install base-trusted createRequire verifier dependencies shell: bash @@ -33,13 +36,11 @@ runs: [ "$HADOLINT_SHA256" = "$ACTUAL" ] || { echo "::error::hadolint checksum mismatch"; exit 1; } chmod +x /usr/local/bin/hadolint - - name: Validate sandbox payload lockfile - shell: bash - run: npm --prefix nemoclaw ci --ignore-scripts --dry-run - - name: Install dependencies shell: bash - run: npm install --ignore-scripts + env: + NODE_AUTH_TOKEN: ${{ github.event_name == 'push' && github.token || '' }} + run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Verify reviewed runtime bundles shell: bash diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index f8c306d17fc..22f408932ab 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -95,6 +95,66 @@ env: NEMOCLAW_E2E_SHARD: default jobs: + package-openshell-sdk: + if: ${{ github.event_name == 'workflow_dispatch' && contains(format(',{0},', inputs.jobs), ',external-gateway-health,') }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + packages: read + outputs: + artifact_name: ${{ steps.identity.outputs.artifact_name }} + steps: + - name: Check out trusted OpenShell SDK package verifier + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: | + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/package-openshell-sdk-for-pr.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts + sparse-checkout-cone-mode: false + + - name: Set up Node for reviewed package download + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + registry-url: https://npm.pkg.github.com + scope: "@nvidia" + + - id: package + name: Download and verify exact OpenShell SDK package + env: + NEMOCLAW_OPEN_SHELL_SDK_OUTPUT_DIRECTORY: ${{ runner.temp }}/openshell-sdk + NODE_AUTH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + artifact_path="$(node --experimental-strip-types scripts/checks/package-openshell-sdk-for-pr.mts)" + test -n "$artifact_path" + printf 'artifact_path=%s\n' "$artifact_path" >> "$GITHUB_OUTPUT" + + - id: identity + name: Record reviewed OpenShell SDK artifact identity + env: + RUN_ATTEMPT: ${{ github.run_attempt }} + RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + artifact_name="openshell-sdk-e2e-${RUN_ID}-${RUN_ATTEMPT}" + printf 'artifact_name=%s\n' "$artifact_name" >> "$GITHUB_OUTPUT" + + - name: Upload reviewed OpenShell SDK archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.identity.outputs.artifact_name }} + path: ${{ steps.package.outputs.artifact_path }} + if-no-files-found: error + retention-days: 1 + base-image-publication: runs-on: ubuntu-latest timeout-minutes: 55 @@ -3263,6 +3323,71 @@ jobs: shell: bash run: bash .github/scripts/docker-auth-cleanup.sh + external-gateway-health: + needs: [generate-matrix, package-openshell-sdk] + if: ${{ contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'external-gateway-health') }} + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + E2E_JOB: "1" + E2E_DEFAULT_ENABLED: "0" + E2E_TARGET_ID: "external-gateway-health" + E2E_AGENT_RUNTIME: "none" + E2E_OBSERVABLE_OUTCOME: "The reviewed SDK observes exact public gateway health over explicit HTTPS and CA" + E2E_ENVIRONMENT_OR_INFERENCE_ENDPOINT: "Ubuntu host with OpenShell 0.0.106; no inference endpoint" + E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/external-gateway-health + NEMOCLAW_RUN_LIVE_E2E: "1" + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_OPENSHELL_PIN_VERSION: "0.0.106" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ inputs.checkout_repository || github.repository }} + ref: ${{ inputs.checkout_sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Prepare E2E workspace + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + with: + build-cli: "false" + + - name: Restore exact-commit CLI artifact + uses: NVIDIA/NemoClaw/.github/actions/restore-e2e-cli-artifact@c246409193a31133cab10c8a3589001cc0d59eb3 + with: + provenance-json: ${{ needs.generate-matrix.outputs.cli_artifact_provenance }} + + - name: Download reviewed OpenShell SDK archive + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ needs.package-openshell-sdk.outputs.artifact_name }} + path: ${{ runner.temp }}/openshell-sdk + + - name: Install reviewed OpenShell SDK archive without package credentials + run: | + set -euo pipefail + mapfile -t archives < <(find "$RUNNER_TEMP/openshell-sdk" -maxdepth 1 -type f -name '*.tgz' -print) + test "${#archives[@]}" -eq 1 + env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN \ + npm install --no-save --package-lock=false --ignore-scripts "${archives[0]}" + + - name: Install OpenShell CLI + run: env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN bash scripts/install-openshell.sh + + - name: Run external gateway health live test + run: | + set -euo pipefail + export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH" + npx tsx tools/e2e/live-vitest-invocation.mts run \ + --test-path test/e2e/live/external-gateway-health.test.ts + + - name: Upload external gateway health artifacts + if: always() + uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 + with: + name: e2e-external-gateway-health + path: e2e-artifacts/live/external-gateway-health/ + mcp-bridge: needs: [base-image-publication, generate-matrix] if: ${{ contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'mcp-bridge') }} @@ -5737,6 +5862,7 @@ jobs: catalogue-github-read, catalogue-brave-nvidia-inference, openshell-gateway-auth-contract, + external-gateway-health, mcp-bridge, openshell-credential-generation-window, openshell-dev-artifact, diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 1b89e6fa04b..3ee97455e2d 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -22,6 +22,9 @@ concurrency: jobs: static-checks: + permissions: + contents: read + packages: read runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -34,6 +37,9 @@ jobs: uses: ./.github/actions/ci-static-checks build-typecheck: + permissions: + contents: read + packages: read runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -46,6 +52,9 @@ jobs: uses: ./.github/actions/ci-build-typecheck installer-integration: + permissions: + contents: read + packages: read runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -139,6 +148,9 @@ jobs: run: npx vitest run --project integration test/agents/openclaw/openclaw-security-audit-suppressions-real.test.ts --silent=false --reporter=default cli-test-shards: + permissions: + contents: read + packages: read runs-on: ubuntu-24.04 # Keep the post-merge budget aligned with pull requests so the same # duration-weighted coverage roster can finish and upload its artifacts. @@ -167,6 +179,7 @@ jobs: actions: read code-quality: write contents: read + packages: read pull-requests: read runs-on: ubuntu-latest timeout-minutes: 10 @@ -241,6 +254,7 @@ jobs: permissions: code-quality: write contents: read + packages: read pull-requests: read runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.github/workflows/openshell-sdk-package-pr.yaml b/.github/workflows/openshell-sdk-package-pr.yaml new file mode 100644 index 00000000000..ba8d5f2a719 --- /dev/null +++ b/.github/workflows/openshell-sdk-package-pr.yaml @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Security / Package OpenShell SDK for PR + +run-name: "OpenShell SDK PR #${{ github.event.pull_request.number }} head ${{ github.event.pull_request.head.sha }} base ${{ github.event.pull_request.base.sha }}" + +# This workflow is loaded from the pull request base branch. It must never +# check out or execute pull request content because its token can read packages. +on: + pull_request_target: + types: [opened, synchronize, reopened, edited] + +permissions: + contents: read + +concurrency: + group: openshell-sdk-package-${{ github.event.pull_request.number }}-${{ github.event.action != 'edited' || github.event.changes.base != null }} + cancel-in-progress: true + +jobs: + package-openshell-sdk: + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && (github.event.action != 'edited' || github.event.changes.base != null) }} + permissions: + contents: read + packages: read + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout base-controlled package verifier + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + sparse-checkout: | + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/package-openshell-sdk-for-pr.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts + sparse-checkout-cone-mode: false + + - name: Setup Node.js for reviewed package download + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + registry-url: https://npm.pkg.github.com + scope: "@nvidia" + + - name: Download and verify exact OpenShell SDK package + id: package + env: + NEMOCLAW_OPEN_SHELL_SDK_OUTPUT_DIRECTORY: ${{ runner.temp }}/openshell-sdk + NODE_AUTH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + artifact_path="$(node --experimental-strip-types scripts/checks/package-openshell-sdk-for-pr.mts)" + [ -n "$artifact_path" ] + printf 'artifact_path=%s\n' "$artifact_path" >> "$GITHUB_OUTPUT" + + - name: Upload verified OpenShell SDK archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: openshell-sdk-${{ github.event.pull_request.head.sha }} + path: ${{ steps.package.outputs.artifact_path }} + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 1b65a5a7cc0..93a59f5410e 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -96,7 +96,7 @@ jobs: run: npm run docs static-checks: - needs: changes + needs: [changes, openshell-sdk-package] if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -121,14 +121,206 @@ jobs: .github/actions/ci-plugin-coverage .github/actions/ci-installer-integration .github/actions/ci-install-dependencies.sh + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/prepare-ci-npm-install.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts sparse-checkout-cone-mode: false + - name: Download verified OpenShell SDK archive + if: needs.openshell-sdk-package.outputs.required == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-sdk-package + path: ${{ runner.temp }}/openshell-sdk + - name: Run static checks uses: ./.trusted-ci-actions/.github/actions/ci-static-checks - build-typecheck: + openshell-sdk-package: needs: changes if: needs.changes.outputs.code == 'true' + permissions: + actions: read + contents: read + outputs: + required: ${{ steps.locate.outputs.required }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout pull request lockfiles + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: | + package-lock.json + nemoclaw/package-lock.json + sparse-checkout-cone-mode: false + + - name: Checkout base package decision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: .trusted-sdk-package-decision + persist-credentials: false + sparse-checkout: | + .github/workflows/openshell-sdk-package-pr.yaml + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/prepare-ci-npm-install.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts + sparse-checkout-cone-mode: false + + - name: Locate exact base-controlled SDK package run + id: locate + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + trusted_inspector=.trusted-sdk-package-decision/scripts/checks/prepare-ci-npm-install.mts + trusted_workflow=.trusted-sdk-package-decision/.github/workflows/openshell-sdk-package-pr.yaml + if [ ! -f "$trusted_inspector" ]; then + if [ -f "$trusted_workflow" ]; then + echo "::error title=Incomplete SDK package support::The pull request base has a package workflow without its trusted inspector." + exit 1 + fi + if ! jq -se ' + length == 2 and + all(.[]; + type == "object" and + (.lockfileVersion | type == "number") and + (.packages | type == "object")) and + ([.[] | .. | objects | select(has("resolved")) | .resolved] | + all(type == "string" and startswith("https://registry.npmjs.org/"))) + ' package-lock.json nemoclaw/package-lock.json >/dev/null; then + echo "::error title=SDK package unavailable::The pull request base requires two valid public-registry npm lockfiles." + exit 1 + fi + echo "required=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + decision="$( + NEMOCLAW_CI_NPM_PACKAGE_MODE=inspect \ + NEMOCLAW_CI_TARGET_ROOT="$GITHUB_WORKSPACE" \ + node --experimental-strip-types "$trusted_inspector" + )" + required="$(jq -er '.required | select(type == "boolean")' <<<"$decision")" + if [ "$required" != "true" ]; then + echo "required=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$HEAD_REPOSITORY" != "$GITHUB_REPOSITORY" ]; then + echo "::error title=SDK package unavailable::The reviewed OpenShell SDK is available only to same-repository pull requests." + exit 1 + fi + if [ ! -f "$trusted_workflow" ]; then + echo "::error title=Missing trusted SDK package workflow::The pull request base cannot package the approved OpenShell SDK." + exit 1 + fi + + artifact_name="$(jq -er '.artifactName | select(type == "string" and test("^[a-z0-9][a-z0-9._-]*\\.tgz$"))' <<<"$decision")" + printf 'artifact_name=%s\n' "$artifact_name" >> "$GITHUB_OUTPUT" + echo "required=true" >> "$GITHUB_OUTPUT" + newest_matching_run_url="" + newest_matching_run_status="" + for attempt in $(seq 1 84); do + if ! runs="$(gh api \ + "repos/$GITHUB_REPOSITORY/actions/workflows/openshell-sdk-package-pr.yaml/runs?event=pull_request_target&per_page=100" \ + 2>/dev/null)"; then + echo "::error title=SDK package workflow unavailable::Could not inspect reviewed SDK package workflow runs. After GitHub Actions access returns, rerun the failed openshell-sdk-package job in CI / Pull Request." + exit 1 + fi + matches="$(jq -cer \ + --arg base "$BASE_SHA" \ + --arg head "$HEAD_SHA" \ + --argjson pr "$PR_NUMBER" ' + [.workflow_runs[] | + select(.event == "pull_request_target") | + select(any(.pull_requests[]?; + .number == $pr and .head.sha == $head and .base.sha == $base))] | + sort_by(.created_at) | reverse + ' <<<"$runs" 2>/dev/null || true)" + if jq -e 'length > 0' <<<"$matches" >/dev/null 2>&1; then + newest_matching_run_url="$(jq -er '.[0].html_url | select(type == "string" and startswith("https://github.com/"))' <<<"$matches")" + newest_matching_run_status="$(jq -er '.[0].status | select(type == "string" and test("^[a-z_]+$"))' <<<"$matches")" + pending_run=false + newest_completed_url="" + while IFS= read -r match; do + status="$(jq -r '.status' <<<"$match")" + if [ "$status" != "completed" ]; then + pending_run=true + continue + fi + run_url="$(jq -er '.html_url | select(type == "string" and startswith("https://github.com/"))' <<<"$match")" + if [ -z "$newest_completed_url" ]; then + newest_completed_url="$run_url" + fi + conclusion="$(jq -r '.conclusion' <<<"$match")" + if [ "$conclusion" != "success" ]; then + continue + fi + run_id="$(jq -er '.id | select(type == "number" and . > 0 and . <= 9007199254740991)' <<<"$match")" + artifact_listing_url="repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts?per_page=100" + if ! artifacts="$(gh api "$artifact_listing_url" 2>/dev/null)"; then + echo "::error title=SDK package artifact unavailable::Could not inspect the reviewed SDK archive from $run_url. After GitHub Actions access returns, rerun the failed openshell-sdk-package job in CI / Pull Request." + exit 1 + fi + expected_artifact="openshell-sdk-$HEAD_SHA" + if jq -e --arg name "$expected_artifact" ' + [.artifacts[]? | select(.name == $name and .expired == false)] | length == 1 + ' <<<"$artifacts" >/dev/null; then + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + exit 0 + fi + done < <(jq -c '.[]' <<<"$matches") + if [ "$pending_run" = "false" ]; then + echo "::error title=SDK package artifact unavailable::No unexpired reviewed SDK archive is available. Last checked $newest_completed_url. Rerun Security / Package OpenShell SDK for PR for this latest PR commit. Then rerun the failed openshell-sdk-package job in CI / Pull Request." + exit 1 + fi + fi + sleep 5 + done + latest_run_detail="No matching run was found." + if [ -n "$newest_matching_run_url" ]; then + latest_run_detail="Last matching run: $newest_matching_run_url ($newest_matching_run_status)." + fi + echo "::error title=SDK package workflow timed out::No successful reviewed SDK package run for this latest PR commit was available within seven minutes. $latest_run_detail Rerun Security / Package OpenShell SDK for PR for this latest PR commit. Then rerun the failed openshell-sdk-package job in CI / Pull Request." + exit 1 + + - name: Download exact base-controlled SDK archive + if: steps.locate.outputs.required == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ steps.locate.outputs.run_id }} + name: openshell-sdk-${{ github.event.pull_request.head.sha }} + path: ${{ runner.temp }}/openshell-sdk + + - name: Publish SDK archive inside this CI run + if: steps.locate.outputs.required == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: openshell-sdk-package + path: ${{ runner.temp }}/openshell-sdk/${{ steps.locate.outputs.artifact_name }} + if-no-files-found: error + retention-days: 1 + + build-typecheck: + needs: [changes, openshell-sdk-package] + if: needs.changes.outputs.code == 'true' + permissions: + contents: read runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -151,14 +343,29 @@ jobs: .github/actions/ci-plugin-coverage .github/actions/ci-installer-integration .github/actions/ci-install-dependencies.sh + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/prepare-ci-npm-install.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts sparse-checkout-cone-mode: false + - name: Download verified OpenShell SDK archive + if: needs.openshell-sdk-package.outputs.required == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-sdk-package + path: ${{ runner.temp }}/openshell-sdk + - name: Run build and type checks uses: ./.trusted-ci-actions/.github/actions/ci-build-typecheck installer-integration: - needs: changes + needs: [changes, openshell-sdk-package] if: needs.changes.outputs.code == 'true' + permissions: + contents: read runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -176,8 +383,21 @@ jobs: sparse-checkout: | .github/actions/ci-installer-integration .github/actions/ci-install-dependencies.sh + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/prepare-ci-npm-install.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts sparse-checkout-cone-mode: false + - name: Download verified OpenShell SDK archive + if: needs.openshell-sdk-package.outputs.required == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-sdk-package + path: ${{ runner.temp }}/openshell-sdk + - name: Run installer integration tests uses: ./.trusted-ci-actions/.github/actions/ci-installer-integration @@ -252,8 +472,10 @@ jobs: report-dir: artifacts/reviewed-npm-audit cli-test-shards: - needs: changes + needs: [changes, openshell-sdk-package] if: needs.changes.outputs.code == 'true' + permissions: + contents: read runs-on: ubuntu-24.04 # Coverage startup plus the stable, duration-weighted roster can exceed # the former 15-minute cap before Vitest writes its shard artifacts. @@ -283,8 +505,21 @@ jobs: .github/actions/ci-plugin-coverage .github/actions/ci-installer-integration .github/actions/ci-install-dependencies.sh + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/prepare-ci-npm-install.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts sparse-checkout-cone-mode: false + - name: Download verified OpenShell SDK archive + if: needs.openshell-sdk-package.outputs.required == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-sdk-package + path: ${{ runner.temp }}/openshell-sdk + - name: Run CLI coverage shard uses: ./.trusted-ci-actions/.github/actions/ci-cli-coverage-shard with: @@ -295,6 +530,7 @@ jobs: needs: - changes - cli-test-shards + - openshell-sdk-package if: ${{ always() && needs.changes.outputs.code == 'true' }} permissions: actions: read @@ -379,15 +615,28 @@ jobs: .github/actions/ci-plugin-coverage .github/actions/ci-installer-integration .github/actions/ci-install-dependencies.sh + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/prepare-ci-npm-install.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts sparse-checkout-cone-mode: false + - name: Download verified OpenShell SDK archive + if: needs.openshell-sdk-package.outputs.required == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-sdk-package + path: ${{ runner.temp }}/openshell-sdk + - name: Merge CLI coverage uses: ./.trusted-ci-actions/.github/actions/ci-cli-coverage-merge with: shard-count: "12" plugin-tests: - needs: changes + needs: [changes, openshell-sdk-package] if: needs.changes.outputs.code == 'true' permissions: code-quality: write @@ -415,8 +664,21 @@ jobs: .github/actions/ci-plugin-coverage .github/actions/ci-installer-integration .github/actions/ci-install-dependencies.sh + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/prepare-ci-npm-install.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts sparse-checkout-cone-mode: false + - name: Download verified OpenShell SDK archive + if: needs.openshell-sdk-package.outputs.required == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-sdk-package + path: ${{ runner.temp }}/openshell-sdk + - name: Run plugin coverage uses: ./.trusted-ci-actions/.github/actions/ci-plugin-coverage @@ -429,6 +691,7 @@ jobs: - installer-integration - wechat-runtime-audit - reviewed-npm-audit + - openshell-sdk-package - cli-tests - plugin-tests if: always() @@ -448,6 +711,7 @@ jobs: INSTALLER_INTEGRATION_RESULT: ${{ needs['installer-integration'].result }} WECHAT_RUNTIME_AUDIT_RESULT: ${{ needs['wechat-runtime-audit'].result }} REVIEWED_NPM_AUDIT_RESULT: ${{ needs['reviewed-npm-audit'].result }} + OPEN_SHELL_SDK_PACKAGE_RESULT: ${{ needs['openshell-sdk-package'].result }} CLI_TESTS_RESULT: ${{ needs['cli-tests'].result }} GH_TOKEN: ${{ github.token }} PLUGIN_TESTS_RESULT: ${{ needs['plugin-tests'].result }} @@ -493,7 +757,7 @@ jobs: local job_id="" dependency_url="$RUN_URL" case "$name" in - changes|docs-only-checks|static-checks|build-typecheck|installer-integration|wechat-runtime-audit|reviewed-npm-audit|cli-tests|plugin-tests) ;; + changes|docs-only-checks|static-checks|build-typecheck|installer-integration|wechat-runtime-audit|reviewed-npm-audit|openshell-sdk-package|cli-tests|plugin-tests) ;; *) return ;; esac load_job_listing @@ -550,6 +814,7 @@ jobs: require_success "installer-integration" "$INSTALLER_INTEGRATION_RESULT" require_success "wechat-runtime-audit" "$WECHAT_RUNTIME_AUDIT_RESULT" require_success "reviewed-npm-audit" "$REVIEWED_NPM_AUDIT_RESULT" + require_success "openshell-sdk-package" "$OPEN_SHELL_SDK_PACKAGE_RESULT" require_success "cli-tests" "$CLI_TESTS_RESULT" require_success "plugin-tests" "$PLUGIN_TESTS_RESULT" else @@ -559,6 +824,7 @@ jobs: allow_success_or_skipped "installer-integration" "$INSTALLER_INTEGRATION_RESULT" allow_success_or_skipped "wechat-runtime-audit" "$WECHAT_RUNTIME_AUDIT_RESULT" allow_success_or_skipped "reviewed-npm-audit" "$REVIEWED_NPM_AUDIT_RESULT" + allow_success_or_skipped "openshell-sdk-package" "$OPEN_SHELL_SDK_PACKAGE_RESULT" allow_success_or_skipped "cli-tests" "$CLI_TESTS_RESULT" allow_success_or_skipped "plugin-tests" "$PLUGIN_TESTS_RESULT" fi diff --git a/ci/reviewed-npm-audit.json b/ci/reviewed-npm-audit.json index 416f9c8c07b..bf6cd17a2bd 100644 --- a/ci/reviewed-npm-audit.json +++ b/ci/reviewed-npm-audit.json @@ -2,6 +2,31 @@ "schemaVersion": 2, "nodeVersion": "22.23.2", "registryOrigin": "https://registry.npmjs.org/", + "sourceRegistryPackage": { + "artifactName": "nvidia-openshell-sdk-0.0.106.tgz", + "label": "OpenShell TypeScript SDK 0.0.106", + "packageSpec": "@nvidia/openshell-sdk@0.0.106", + "integrity": "sha512-dB4mLex23Pnw61caGMR2CMHQihy9bj7IK2elJJd718k3yevm+fOt/vG6dJg8/5us4la2BwcOdRwLvOia3tdwFw==", + "tarballUrl": "https://npm.pkg.github.com/download/@nvidia/openshell-sdk/0.0.106/dc32180ba1d658fc4ec309bdf89d2b162196928d" + }, + "sourceNestedShrinkwrapPackages": ["@earendil-works/pi-coding-agent@0.80.6"], + "sourceRegistryPackagesWithoutIntegrity": [ + { + "label": "Pi agent core 0.80.6 nested shrinkwrap entry", + "packageSpec": "@earendil-works/pi-agent-core@0.80.6", + "tarballUrl": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz" + }, + { + "label": "Pi AI 0.80.6 nested shrinkwrap entry", + "packageSpec": "@earendil-works/pi-ai@0.80.6", + "tarballUrl": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz" + }, + { + "label": "Pi TUI 0.80.6 nested shrinkwrap entry", + "packageSpec": "@earendil-works/pi-tui@0.80.6", + "tarballUrl": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.6.tgz" + } + ], "severityThreshold": "high", "exceptionFile": "ci/npm-audit-exceptions.json", "archiveGraphId": "reviewed-archive-graph", diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 53535f150cf..258cb15d9b8 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -166,6 +166,41 @@ "test": "executes pull request installer hash checks only from the PR base SHA", "category": "security" }, + { + "file": "test/automation/pull-requests/pr-workflow-contract.test.ts", + "test": "keeps package access out of pull request controlled execution", + "category": "security" + }, + { + "file": "test/automation/pull-requests/pr-workflow-contract.test.ts", + "test": "derives the package and archive identity from the base-controlled decision", + "category": "security" + }, + { + "file": "test/automation/pull-requests/pr-workflow-contract.test.ts", + "test": "does not grant package access to pull request jobs", + "category": "security" + }, + { + "file": "test/automation/pull-requests/pr-workflow-contract.test.ts", + "test": "limits main package reads to dependency-install jobs", + "category": "security" + }, + { + "file": "test/automation/pull-requests/pr-workflow-contract.test.ts", + "test": "provides the package token only to trusted main dependency installation", + "category": "security" + }, + { + "file": "test/automation/pull-requests/pr-workflow-contract.test.ts", + "test": "passes only the base-packaged SDK archive to pull request dependency jobs", + "category": "security" + }, + { + "file": "test/automation/pull-requests/pr-workflow-contract.test.ts", + "test": "passes the verified SDK archive to %s", + "category": "security" + }, { "file": "test/automation/releases/release-daily-brev-image.test.ts", "test": "attests one daily request before the isolated dispatch job (#9799)", @@ -176,6 +211,16 @@ "test": "keeps the LKG credential on the production-only dispatch step (#9798)", "category": "security" }, + { + "file": "test/automation/releases/reviewed-npm-audit-workflow.test.ts", + "test": "rejects the removed plural source-registry package shape", + "category": "security" + }, + { + "file": "test/automation/releases/reviewed-npm-audit-workflow.test.ts", + "test": "rejects malformed reviewed source package specifications", + "category": "security" + }, { "file": "test/agents/hermes/reviewed-hermes-platform-action.test.ts", "test": "publishes the verified native manifest digest", diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index a73660d33ec..57c5de8c7db 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -29,6 +29,12 @@ type ReviewedPackage = Readonly<{ packageSpec: string; tarballUrl: string; }>; +type SourceRegistryPackage = ReviewedPackage & Readonly<{ artifactName: string }>; +type PackageWithoutIntegrity = Readonly<{ + label: string; + packageSpec: string; + tarballUrl: string; +}>; type LockedGraph = ReviewedPackage & Readonly<{ directory: string; @@ -47,6 +53,9 @@ type AuditConfig = Readonly<{ registryOrigin: string; schemaVersion: 2; severityThreshold: Severity; + sourceNestedShrinkwrapPackages: readonly string[]; + sourceRegistryPackage: SourceRegistryPackage; + sourceRegistryPackagesWithoutIntegrity: readonly PackageWithoutIntegrity[]; }>; type ReviewedAuditReport = Readonly<{ label: string; result: AuditPolicyResult }>; @@ -56,6 +65,11 @@ const TARGET_REPO_ROOT = fs.realpathSync( ); const CONFIG_PATH = resolveTrustedAuditConfigPath(TRUSTED_REPO_ROOT); const SEVERITIES: readonly Severity[] = ["info", "low", "moderate", "high", "critical"]; +const SEMVER_NUMERIC_IDENTIFIER = String.raw`(?:0|[1-9][0-9]*)`; +const SEMVER_PRERELEASE_IDENTIFIER = String.raw`(?:${SEMVER_NUMERIC_IDENTIFIER}|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)`; +const EXACT_NPM_PACKAGE_SPEC = new RegExp( + String.raw`^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)@${SEMVER_NUMERIC_IDENTIFIER}\.${SEMVER_NUMERIC_IDENTIFIER}\.${SEMVER_NUMERIC_IDENTIFIER}(?:-${SEMVER_PRERELEASE_IDENTIFIER}(?:\.${SEMVER_PRERELEASE_IDENTIFIER})*)?$`, +); const SOURCE_GRAPH = { id: "nemoclaw-cli", label: "NemoClaw CLI locked production graph", @@ -143,6 +157,39 @@ export function parseAuditConfig(contents: string): AuditConfig { !parsed.registryOrigin || !Array.isArray(parsed.archivePackages) || !Array.isArray(parsed.lockedGraphs) || + !Array.isArray(parsed.sourceNestedShrinkwrapPackages) || + parsed.sourceNestedShrinkwrapPackages.some( + (packageSpec) => + typeof packageSpec !== "string" || + !EXACT_NPM_PACKAGE_SPEC.test(packageSpec), + ) || + new Set(parsed.sourceNestedShrinkwrapPackages).size !== + parsed.sourceNestedShrinkwrapPackages.length || + !Array.isArray(parsed.sourceRegistryPackagesWithoutIntegrity) || + parsed.sourceRegistryPackagesWithoutIntegrity.some( + (reviewed) => + typeof reviewed.label !== "string" || + !reviewed.label || + typeof reviewed.packageSpec !== "string" || + !EXACT_NPM_PACKAGE_SPEC.test(reviewed.packageSpec) || + typeof reviewed.tarballUrl !== "string" || + !reviewed.tarballUrl, + ) || + new Set(parsed.sourceRegistryPackagesWithoutIntegrity.map(({ packageSpec }) => packageSpec)) + .size !== parsed.sourceRegistryPackagesWithoutIntegrity.length || + typeof parsed.sourceRegistryPackage !== "object" || + parsed.sourceRegistryPackage === null || + Array.isArray(parsed.sourceRegistryPackage) || + typeof parsed.sourceRegistryPackage.artifactName !== "string" || + !/^[a-z0-9][a-z0-9._-]*\.tgz$/.test(parsed.sourceRegistryPackage.artifactName) || + typeof parsed.sourceRegistryPackage.label !== "string" || + !parsed.sourceRegistryPackage.label || + typeof parsed.sourceRegistryPackage.packageSpec !== "string" || + !EXACT_NPM_PACKAGE_SPEC.test(parsed.sourceRegistryPackage.packageSpec) || + typeof parsed.sourceRegistryPackage.integrity !== "string" || + !parsed.sourceRegistryPackage.integrity || + typeof parsed.sourceRegistryPackage.tarballUrl !== "string" || + !parsed.sourceRegistryPackage.tarballUrl || parsed.lockedGraphs.some( (graph) => typeof graph.id !== "string" || @@ -306,17 +353,39 @@ function assertRegularFile(file: string, label: string): void { } } +function installProductionSourceDependencies(directory: string): void { + run("npm", ["ci", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund"], directory); +} + export function materializeSourceGraph( sourcePackage: string, sourceLock: string, destination: string, registryOrigin: string, - installProductionDependencies: (directory: string) => void = (directory) => - void run("npm", ["ci", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund"], directory), + installProductionDependencies: (directory: string) => void = installProductionSourceDependencies, + sourceRegistryPackage?: ReviewedPackage, + sourceNestedShrinkwrapPackages: readonly string[] = [], + sourceRegistryPackagesWithoutIntegrity: readonly PackageWithoutIntegrity[] = [], ): string { assertRegularFile(sourcePackage, "NemoClaw CLI package manifest"); assertRegularFile(sourceLock, "NemoClaw CLI lockfile"); - verifyReviewedNpmLockPackages({ lockfilePath: sourceLock, omitDev: true, registryOrigin }); + verifyReviewedNpmLockPackages({ + allowedNestedShrinkwrapPackages: sourceNestedShrinkwrapPackages, + lockfilePath: sourceLock, + omitDev: true, + registryOrigin, + reviewedRegistryPackages: sourceRegistryPackage + ? [ + { + expectedIntegrity: sourceRegistryPackage.integrity, + label: sourceRegistryPackage.label, + packageSpec: sourceRegistryPackage.packageSpec, + tarballUrl: sourceRegistryPackage.tarballUrl, + }, + ] + : [], + reviewedPackagesWithoutIntegrity: sourceRegistryPackagesWithoutIntegrity, + }); const lockSha256 = createHash("sha256").update(fs.readFileSync(sourceLock)).digest("hex"); fs.mkdirSync(destination); fs.copyFileSync(sourcePackage, path.join(destination, "package.json")); @@ -453,6 +522,10 @@ function auditSourceGraph( sourceLock, path.join(tempRoot, "source-graph"), config.registryOrigin, + installProductionSourceDependencies, + config.sourceRegistryPackage, + config.sourceNestedShrinkwrapPackages, + config.sourceRegistryPackagesWithoutIntegrity, ); return auditMaterializedSourceGraph({ directory, diff --git a/scripts/checks/package-openshell-sdk-for-pr.mts b/scripts/checks/package-openshell-sdk-for-pr.mts new file mode 100755 index 00000000000..9fe1787d4a5 --- /dev/null +++ b/scripts/checks/package-openshell-sdk-for-pr.mts @@ -0,0 +1,53 @@ +#!/usr/bin/env -S node --experimental-strip-types +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { copyFileSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { parseAuditConfig } from "../audit-reviewed-npm-graph.mts"; +import { packReviewedNpmArchive, removeReviewedNpmArchive } from "../lib/reviewed-npm-archive.mts"; + +const TRUSTED_REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +export function packageReviewedOpenShellSdk(outputDirectory: string): string { + if (!outputDirectory) { + throw new Error("reviewed OpenShell SDK output directory is required"); + } + const config = parseAuditConfig( + readFileSync(join(TRUSTED_REPOSITORY_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), + ); + const reviewed = config.sourceRegistryPackage; + const archive = packReviewedNpmArchive({ + env: process.env, + expectedIntegrity: reviewed.integrity, + label: reviewed.label, + packageSpec: reviewed.packageSpec, + tarballUrl: reviewed.tarballUrl, + }); + const output = resolve(outputDirectory); + try { + rmSync(output, { force: true, recursive: true }); + mkdirSync(output, { recursive: true }); + const artifact = join(output, reviewed.artifactName); + copyFileSync(archive.archivePath, artifact); + return artifact; + } finally { + removeReviewedNpmArchive(archive); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const outputDirectory = process.env.NEMOCLAW_OPEN_SHELL_SDK_OUTPUT_DIRECTORY; + if (!outputDirectory) { + console.error("NEMOCLAW_OPEN_SHELL_SDK_OUTPUT_DIRECTORY is required"); + process.exit(1); + } + try { + process.stdout.write(`${packageReviewedOpenShellSdk(outputDirectory)}\n`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/checks/prepare-ci-npm-install.mts b/scripts/checks/prepare-ci-npm-install.mts new file mode 100755 index 00000000000..369857dfbe9 --- /dev/null +++ b/scripts/checks/prepare-ci-npm-install.mts @@ -0,0 +1,312 @@ +#!/usr/bin/env -S node --experimental-strip-types +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { parseAuditConfig } from "../audit-reviewed-npm-graph.mts"; +import { + readReviewedNpmArchiveFile, + verifyReviewedNpmLockPackages, +} from "../lib/reviewed-npm-archive.mts"; + +const TRUSTED_REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const MAXIMUM_ARCHIVE_BYTES = 32 * 1024 * 1024; +const MAXIMUM_NPM_DIAGNOSTIC_INPUT_CHARACTERS = 4096; +const MAXIMUM_NPM_DIAGNOSTIC_CHARACTERS = 512; +const NPM_DIAGNOSTIC_URL_PATTERN = /[a-z][a-z0-9+.-]*:\/\/[^\s'"]+/giu; +const NPM_DIAGNOSTIC_AUTH_HEADER_PATTERN = + /(\b(?:authorization|proxy-authorization|cookie|set-cookie)[ \t]*[:=])[^\r\n]*/giu; +const NPM_DIAGNOSTIC_CREDENTIAL_ASSIGNMENT_PATTERN = + /((?:^|[^A-Za-z0-9])(?:[A-Za-z0-9._-]*(?:auth|credential|key|pass|passwd|password|secret|token)[A-Za-z0-9._-]*)[ \t]*(?:=|:)[ \t]*)(?:"(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|[^\s]+)/giu; +const NPM_DIAGNOSTIC_PRIVATE_KEY_PATTERN = + /-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z0-9]+ )?PRIVATE KEY-----/gu; +const NPM_DIAGNOSTIC_TOKEN_PATTERN = + /\b(?:github_pat_|ghp_|glpat-|gsk_|hf_|nvcf-|nvapi-|pypi-|sk-(?:ant-|proj-)?|tvly-|xapp-|xox[bpas]-)[A-Za-z0-9_-]{8,}/giu; +const NPM_DIAGNOSTIC_JWT_PATTERN = + /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{10,}\b/gu; +const NPM_DIAGNOSTIC_OPAQUE_VALUE_PATTERN = /\b[A-Za-z0-9_+/=-]{32,}\b/gu; + +type PreparationRequest = Readonly<{ + artifactDirectory?: string; + cacheDirectory: string; + mode: "artifact" | "registry"; + targetRoot: string; +}>; + +type AuditConfig = ReturnType; + +type NpmCacheStageRequest = Readonly<{ + archive: Buffer; + artifactName: string; + cacheDirectory: string; +}>; + +type NpmCacheStager = (request: NpmCacheStageRequest) => void; + +function npmCacheFailureDiagnostic( + stderr: string, + request: NpmCacheStageRequest, + stagingRoot: string, +): string { + return stderr + .slice(0, MAXIMUM_NPM_DIAGNOSTIC_INPUT_CHARACTERS) + .replace(NPM_DIAGNOSTIC_PRIVATE_KEY_PATTERN, "") + .replace(NPM_DIAGNOSTIC_URL_PATTERN, "") + .replace(NPM_DIAGNOSTIC_AUTH_HEADER_PATTERN, "$1 ") + .replace(NPM_DIAGNOSTIC_CREDENTIAL_ASSIGNMENT_PATTERN, "$1") + .replace(/\bBearer[ \t]+\S+/giu, "Bearer ") + .replace(NPM_DIAGNOSTIC_TOKEN_PATTERN, "") + .replace(NPM_DIAGNOSTIC_JWT_PATTERN, "") + .replace(NPM_DIAGNOSTIC_OPAQUE_VALUE_PATTERN, "") + .replaceAll(stagingRoot, "") + .replaceAll(request.cacheDirectory, "") + .replace(/[\u0000-\u001f\u007f]+/gu, " ") + .trim() + .slice(0, MAXIMUM_NPM_DIAGNOSTIC_CHARACTERS); +} + +export type ReviewedSourceRegistryPackage = Readonly<{ + artifactName: string; + integrity: string; + label: string; + packageSpec: string; + tarballUrl: string; +}>; + +export type ReviewedSourceRegistryArtifactRequest = Readonly<{ + allowedNestedShrinkwrapPackages: readonly string[]; + artifactDirectory: string; + cacheDirectory: string; + lockfilePath: string; + reviewed: ReviewedSourceRegistryPackage; + reviewedPackagesWithoutIntegrity: readonly Readonly<{ + label: string; + packageSpec: string; + tarballUrl: string; + }>[]; + registryOrigin: string; +}>; + +function stageReviewedArchiveWithNpm(request: NpmCacheStageRequest): void { + const stagingRoot = mkdtempSync(join(tmpdir(), "nemoclaw-reviewed-npm-cache-add-")); + try { + const archivePath = join(stagingRoot, request.artifactName); + writeFileSync(archivePath, request.archive, { mode: 0o600 }); + const result = spawnSync( + "npm", + [ + "cache", + "add", + archivePath, + "--cache", + request.cacheDirectory, + "--offline", + "--ignore-scripts", + ], + { + encoding: "utf8", + env: { ...process.env, NPM_CONFIG_UPDATE_NOTIFIER: "false" }, + maxBuffer: 16 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + const diagnostic = npmCacheFailureDiagnostic(result.stderr, request, stagingRoot); + const detail = diagnostic ? `: ${diagnostic}` : ""; + throw new Error( + `npm could not stage the reviewed OpenShell SDK archive (exit ${String(result.status ?? "unavailable")})${detail}`, + ); + } + } finally { + rmSync(stagingRoot, { force: true, recursive: true }); + } +} + +export async function seedReviewedSourceRegistryArtifact( + request: ReviewedSourceRegistryArtifactRequest, + stage: NpmCacheStager = stageReviewedArchiveWithNpm, +): Promise { + if (!isAbsolute(request.artifactDirectory)) { + throw new Error("reviewed OpenShell SDK artifact directory must be absolute"); + } + const artifactDirectory = resolve(request.artifactDirectory); + if (!existsSync(artifactDirectory)) { + throw new Error("reviewed OpenShell SDK artifact is required"); + } + const directoryEntry = lstatSync(artifactDirectory); + if (!directoryEntry.isDirectory() || directoryEntry.isSymbolicLink()) { + throw new Error("reviewed OpenShell SDK artifact path must be a non-symlink directory"); + } + const entries = readdirSync(artifactDirectory); + if (entries.length !== 1 || entries[0] !== request.reviewed.artifactName) { + throw new Error("reviewed OpenShell SDK artifact directory has unexpected contents"); + } + const archivePath = resolve(join(artifactDirectory, request.reviewed.artifactName)); + const reviewedRegistryPackage = { + expectedIntegrity: request.reviewed.integrity, + label: request.reviewed.label, + packageSpec: request.reviewed.packageSpec, + tarballUrl: request.reviewed.tarballUrl, + }; + const lockedPackages = verifyReviewedNpmLockPackages({ + allowedNestedShrinkwrapPackages: request.allowedNestedShrinkwrapPackages, + allowNestedShrinkwrap: false, + lockfilePath: request.lockfilePath, + registryOrigin: request.registryOrigin, + reviewedPackagesWithoutIntegrity: request.reviewedPackagesWithoutIntegrity, + reviewedRegistryPackages: [reviewedRegistryPackage], + }); + if (!lockedPackages.includes(request.reviewed.packageSpec)) { + throw new Error("reviewed OpenShell SDK artifact is not used by the selected lockfile"); + } + const cacheDirectory = resolve(request.cacheDirectory); + if ( + !isAbsolute(request.cacheDirectory) || + !existsSync(cacheDirectory) || + !lstatSync(cacheDirectory).isDirectory() + ) { + throw new Error("reviewed OpenShell SDK cache must be an existing absolute directory"); + } + const archive = readReviewedNpmArchiveFile({ + archivePath, + expectedIntegrity: request.reviewed.integrity, + label: request.reviewed.label, + maximumBytes: MAXIMUM_ARCHIVE_BYTES, + }); + stage({ archive, artifactName: request.reviewed.artifactName, cacheDirectory }); +} + +function readTrustedAuditConfig(): AuditConfig { + return parseAuditConfig( + readFileSync(join(TRUSTED_REPOSITORY_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), + ); +} + +function inspectReviewedLocks(targetRoot: string, config: AuditConfig) { + const reviewed = config.sourceRegistryPackage; + const reviewedRegistryPackages = [ + { + expectedIntegrity: reviewed.integrity, + label: reviewed.label, + packageSpec: reviewed.packageSpec, + tarballUrl: reviewed.tarballUrl, + }, + ]; + const lockfiles = ["package-lock.json", "nemoclaw/package-lock.json"].map((relativePath) => { + const lockfilePath = join(targetRoot, relativePath); + const packages = verifyReviewedNpmLockPackages({ + allowedNestedShrinkwrapPackages: config.sourceNestedShrinkwrapPackages, + lockfilePath, + registryOrigin: config.registryOrigin, + reviewedPackagesWithoutIntegrity: config.sourceRegistryPackagesWithoutIntegrity, + reviewedRegistryPackages, + }); + return { lockfilePath, packages }; + }); + return { + config, + reviewed, + reviewedLockfilePath: lockfiles.find(({ packages }) => packages.includes(reviewed.packageSpec)) + ?.lockfilePath, + }; +} + +export function inspectCiNpmInstall(targetRoot: string) { + const inspected = inspectReviewedLocks(resolve(targetRoot), readTrustedAuditConfig()); + return { + artifactName: inspected.reviewed.artifactName, + required: inspected.reviewedLockfilePath !== undefined, + } as const; +} + +async function prepareCiNpmInstallWithConfig( + request: PreparationRequest, + config: AuditConfig, + stage?: NpmCacheStager, +): Promise { + const targetRoot = resolve(request.targetRoot); + const cacheDirectory = resolve(request.cacheDirectory); + const { reviewed, reviewedLockfilePath } = inspectReviewedLocks(targetRoot, config); + const sdkIsLocked = reviewedLockfilePath !== undefined; + + if (request.mode === "registry") return; + if (!request.artifactDirectory) { + if (sdkIsLocked) throw new Error("reviewed OpenShell SDK artifact is required"); + return; + } + if (!isAbsolute(request.artifactDirectory)) { + throw new Error("reviewed OpenShell SDK artifact directory must be absolute"); + } + const artifactDirectory = resolve(request.artifactDirectory); + if (!existsSync(artifactDirectory)) { + if (sdkIsLocked) throw new Error("reviewed OpenShell SDK artifact is required"); + return; + } + if (!reviewedLockfilePath) { + throw new Error("reviewed OpenShell SDK artifact is not used by either lockfile"); + } + await seedReviewedSourceRegistryArtifact( + { + allowedNestedShrinkwrapPackages: config.sourceNestedShrinkwrapPackages, + artifactDirectory, + cacheDirectory, + lockfilePath: reviewedLockfilePath, + registryOrigin: config.registryOrigin, + reviewed, + reviewedPackagesWithoutIntegrity: config.sourceRegistryPackagesWithoutIntegrity, + }, + stage, + ); +} + +export async function prepareCiNpmInstallWithReviewedConfig( + request: PreparationRequest, + reviewedConfigSource: string, + stage?: NpmCacheStager, +): Promise { + return prepareCiNpmInstallWithConfig(request, parseAuditConfig(reviewedConfigSource), stage); +} + +export async function prepareCiNpmInstall( + request: PreparationRequest, + stage?: NpmCacheStager, +): Promise { + return prepareCiNpmInstallWithConfig(request, readTrustedAuditConfig(), stage); +} + +function requestFromEnvironment(): PreparationRequest { + const mode = process.env.NEMOCLAW_CI_NPM_PACKAGE_MODE; + const targetRoot = process.env.NEMOCLAW_CI_TARGET_ROOT; + const cacheDirectory = process.env.NEMOCLAW_CI_NPM_CACHE; + if ((mode !== "artifact" && mode !== "registry") || !targetRoot || !cacheDirectory) { + throw new Error("trusted CI npm preparation environment is incomplete"); + } + return { + artifactDirectory: process.env.NEMOCLAW_OPEN_SHELL_SDK_ARTIFACT_DIRECTORY, + cacheDirectory, + mode, + targetRoot, + }; +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + const mode = process.env.NEMOCLAW_CI_NPM_PACKAGE_MODE; + const targetRoot = process.env.NEMOCLAW_CI_TARGET_ROOT; + const task = + mode === "inspect" && targetRoot + ? Promise.resolve(inspectCiNpmInstall(targetRoot)).then((result) => + process.stdout.write(`${JSON.stringify(result)}\n`), + ) + : prepareCiNpmInstall(requestFromEnvironment()); + task.catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/scripts/lib/reviewed-npm-archive.mts b/scripts/lib/reviewed-npm-archive.mts index f69cf63722d..2e879f6cc6e 100755 --- a/scripts/lib/reviewed-npm-archive.mts +++ b/scripts/lib/reviewed-npm-archive.mts @@ -61,11 +61,24 @@ export type ReviewedNpmMetadata = Readonly<{ tarballUrl: string; }>; +export type ReviewedNpmPackageWithoutIntegrity = Readonly<{ + label: string; + packageSpec: string; + tarballUrl: string; +}>; + export type ReviewedNpmArchive = Readonly<{ archivePath: string; rootDirectory: string; }>; +export type ReviewedNpmArchiveFileRequest = Readonly<{ + archivePath: string; + expectedIntegrity: string; + label: string; + maximumBytes?: number; +}>; + type NpmRunner = (args: readonly string[], request: ReviewedNpmArchiveRequest) => string; function runNpm(args: readonly string[], request: ReviewedNpmArchiveRequest): string { @@ -97,6 +110,43 @@ function requireReviewedRequest(request: ReviewedNpmArchiveRequest): void { } } +export function readReviewedNpmArchiveFile(request: ReviewedNpmArchiveFileRequest): Buffer { + if (!isAbsolute(request.archivePath)) { + throw new Error(`${request.label} archive path must be absolute`); + } + let descriptor: number | undefined; + try { + const archivePath = resolve(request.archivePath); + descriptor = openSync(archivePath, "r"); + const opened = fstatSync(descriptor); + const pathEntry = lstatSync(archivePath); + if ( + !opened.isFile() || + !pathEntry.isFile() || + pathEntry.isSymbolicLink() || + opened.dev !== pathEntry.dev || + opened.ino !== pathEntry.ino + ) { + throw new Error("archive must be a non-symlink regular file"); + } + if (request.maximumBytes !== undefined && opened.size > request.maximumBytes) { + throw new Error("archive must be a bounded regular file"); + } + const archive = readFileSync(descriptor); + const actualIntegrity = `sha512-${createHash("sha512").update(archive).digest("base64")}`; + if (actualIntegrity !== request.expectedIntegrity) { + throw new Error( + `${request.label} archive integrity mismatch\nExpected: ${request.expectedIntegrity}\nActual: ${actualIntegrity}`, + ); + } + return archive; + } catch (error) { + throw new Error(`${request.label} archive is unreadable: ${String(error)}`); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + export function verifyReviewedNpmMetadata( request: ReviewedNpmArchiveRequest, npmRunner: NpmRunner = runNpm, @@ -421,9 +471,37 @@ function readReviewedLockPackages( omitDev = false, allowEmpty = false, allowNestedShrinkwrap = false, + reviewedRegistryPackages: readonly ReviewedNpmArchiveRequest[] = [], + allowedNestedShrinkwrapPackages: readonly string[] = [], + reviewedPackagesWithoutIntegrity: readonly ReviewedNpmPackageWithoutIntegrity[] = [], ): readonly ReviewedNpmArchiveRequest[] { const reviewed: ReviewedNpmArchiveRequest[] = []; const identities = new Map(); + const reviewedRegistryIdentities = new Map(); + const allowedNestedShrinkwrapIdentities = new Set(allowedNestedShrinkwrapPackages); + const reviewedPackagesWithoutIntegrityBySpec = new Map( + reviewedPackagesWithoutIntegrity.map((reviewed) => [reviewed.packageSpec, reviewed]), + ); + for (const reviewedPackage of reviewedRegistryPackages) { + requireReviewedRequest(reviewedPackage); + let parsedTarball: URL; + try { + parsedTarball = new URL(reviewedPackage.tarballUrl); + } catch { + throw new Error(`${reviewedPackage.label} must use a valid reviewed npm tarball URL`); + } + if ( + parsedTarball.protocol !== "https:" || + parsedTarball.username || + parsedTarball.password || + reviewedRegistryIdentities.has(reviewedPackage.packageSpec) + ) { + throw new Error( + `${reviewedPackage.label} must use one credential-free HTTPS package identity`, + ); + } + reviewedRegistryIdentities.set(reviewedPackage.packageSpec, reviewedPackage); + } const productionLocations = omitDev ? productionLockLocations(packages) : undefined; for (const [location, value] of Object.entries(packages)) { if (location === "") continue; @@ -433,30 +511,56 @@ function readReviewedLockPackages( const record = value as Record; assertNotProductionDev(productionLocations, location, record); if (omitDev && record.dev === true) continue; - if (!allowNestedShrinkwrap && Object.prototype.hasOwnProperty.call(record, "hasShrinkwrap")) { - throw new Error( - `reviewed npm lock package must not delegate to nested shrinkwrap: ${location}`, - ); - } const locationName = packageNameFromLockLocation(location); const packageName = typeof record.name === "string" ? record.name : locationName; const version = typeof record.version === "string" ? record.version : ""; const packageSpec = `${packageName}@${version}`; + if ( + !allowNestedShrinkwrap && + Object.prototype.hasOwnProperty.call(record, "hasShrinkwrap") && + !allowedNestedShrinkwrapIdentities.has(packageSpec) + ) { + throw new Error( + `reviewed npm lock package must not delegate to nested shrinkwrap: ${location}`, + ); + } const expectedIntegrity = typeof record.integrity === "string" ? record.integrity : ""; const tarballUrl = typeof record.resolved === "string" ? record.resolved : ""; - requireReviewedRequest({ - expectedIntegrity, - label: `locked npm package ${packageSpec}`, - packageSpec, - tarballUrl, - }); + const reviewedPackageWithoutIntegrity = reviewedPackagesWithoutIntegrityBySpec.get(packageSpec); + if (expectedIntegrity) { + requireReviewedRequest({ + expectedIntegrity, + label: `locked npm package ${packageSpec}`, + packageSpec, + tarballUrl, + }); + } else if ( + !reviewedPackageWithoutIntegrity || + reviewedPackageWithoutIntegrity.tarballUrl !== tarballUrl + ) { + throw new Error( + `locked npm package ${packageSpec} must use a committed sha512 npm integrity value`, + ); + } let parsedTarball: URL; try { parsedTarball = new URL(tarballUrl); } catch { throw new Error(`reviewed npm lock has an invalid tarball URL: ${location}`); } - if ( + const reviewedRegistryIdentity = reviewedRegistryIdentities.get(packageSpec); + if (reviewedRegistryIdentity) { + if ( + reviewedRegistryIdentity.expectedIntegrity !== expectedIntegrity || + reviewedRegistryIdentity.tarballUrl !== tarballUrl || + parsedTarball.username || + parsedTarball.password + ) { + throw new Error( + `reviewed npm lock package does not match its approved registry identity: ${location}`, + ); + } + } else if ( parsedTarball.origin !== registryOrigin || parsedTarball.username || parsedTarball.password @@ -477,7 +581,7 @@ function readReviewedLockPackages( tarballUrl, }; identities.set(packageSpec, request); - reviewed.push(request); + if (!reviewedPackageWithoutIntegrity) reviewed.push(request); } if (!allowEmpty && reviewed.length === 0) { throw new Error(`reviewed npm lock contains no packages: ${lockfilePath}`); @@ -487,9 +591,12 @@ function readReviewedLockPackages( export function verifyReviewedNpmLockPackages( request: Readonly<{ + allowedNestedShrinkwrapPackages?: readonly string[]; allowNestedShrinkwrap?: boolean; lockfilePath: string; omitDev?: boolean; + reviewedRegistryPackages?: readonly ReviewedNpmArchiveRequest[]; + reviewedPackagesWithoutIntegrity?: readonly ReviewedNpmPackageWithoutIntegrity[]; registryOrigin: string; }>, ): readonly string[] { @@ -501,6 +608,9 @@ export function verifyReviewedNpmLockPackages( request.omitDev, true, request.allowNestedShrinkwrap, + request.reviewedRegistryPackages, + request.allowedNestedShrinkwrapPackages, + request.reviewedPackagesWithoutIntegrity, ).map(({ packageSpec }) => packageSpec); } diff --git a/scripts/lib/seed-reviewed-npm-cache.mts b/scripts/lib/seed-reviewed-npm-cache.mts index 9de0d14b581..b9e9de690d1 100755 --- a/scripts/lib/seed-reviewed-npm-cache.mts +++ b/scripts/lib/seed-reviewed-npm-cache.mts @@ -3,16 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { - closeSync, - existsSync, - fstatSync, - lstatSync, - openSync, - readdirSync, - readFileSync, -} from "node:fs"; +import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { isAbsolute, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -21,7 +12,12 @@ import { lockedArchives, type NpmPlatformTarget, } from "../checks/materialize-locked-npm-cache-seed.mts"; -import { verifyReviewedNpmLockPackages } from "./reviewed-npm-archive.mts"; +import { + readReviewedNpmArchiveFile, + type ReviewedNpmArchiveRequest, + type ReviewedNpmPackageWithoutIntegrity, + verifyReviewedNpmLockPackages, +} from "./reviewed-npm-archive.mts"; export type CachePut = ( cachePath: string, @@ -31,12 +27,18 @@ export type CachePut = ( ) => Promise; export type ReviewedNpmCacheSeedRequest = Readonly<{ + allowedNestedShrinkwrapPackages?: readonly string[]; + allowNestedShrinkwrap?: boolean; archives: ReadonlyMap; cacheDirectory: string; lockfilePath: string; + maximumArchiveBytes?: number; packumentsOnly?: boolean; + reviewedPackagesWithoutIntegrity?: readonly ReviewedNpmPackageWithoutIntegrity[]; + reviewedRegistryPackages?: readonly ReviewedNpmArchiveRequest[]; registryOrigin: string; selectedPackageSpecs?: ReadonlySet; + tarballsOnly?: boolean; }>; type LockedPackage = Readonly<{ @@ -53,6 +55,7 @@ type LockedPackage = Readonly<{ }>; const INSTALL_ACCEPT = "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*"; +const REVIEWED_CI_NPM_VERSIONS = new Set(["10.9.4", "10.9.8", "11.17.0"]); function packageNameFromLockLocation(location: string): string { const marker = "node_modules/"; @@ -72,11 +75,21 @@ function requireObject(value: unknown, label: string): Record { function readLockedPackages( lockfilePath: string, registryOrigin: string, + request: Pick< + ReviewedNpmCacheSeedRequest, + | "allowedNestedShrinkwrapPackages" + | "allowNestedShrinkwrap" + | "reviewedPackagesWithoutIntegrity" + | "reviewedRegistryPackages" + > = {}, ): readonly LockedPackage[] { const expectedSpecs = new Set( verifyReviewedNpmLockPackages({ - allowNestedShrinkwrap: true, + allowedNestedShrinkwrapPackages: request.allowedNestedShrinkwrapPackages, + allowNestedShrinkwrap: request.allowNestedShrinkwrap ?? true, lockfilePath, + reviewedPackagesWithoutIntegrity: request.reviewedPackagesWithoutIntegrity, + reviewedRegistryPackages: request.reviewedRegistryPackages, registryOrigin, }), ); @@ -135,35 +148,6 @@ function readLockedPackages( return locked; } -function readArchive(archivePath: string, packageSpec: string): Buffer { - if (!isAbsolute(archivePath)) { - throw new Error(`reviewed npm cache seed archive must be absolute: ${packageSpec}`); - } - const resolvedPath = resolve(archivePath); - let descriptor: number | undefined; - try { - descriptor = openSync(resolvedPath, "r"); - const opened = fstatSync(descriptor); - const pathEntry = lstatSync(resolvedPath); - if ( - !opened.isFile() || - !pathEntry.isFile() || - pathEntry.isSymbolicLink() || - opened.dev !== pathEntry.dev || - opened.ino !== pathEntry.ino - ) { - throw new Error("archive must be a non-symlink regular file"); - } - return readFileSync(descriptor); - } catch (error) { - throw new Error( - `reviewed npm cache seed archive is unreadable: ${packageSpec}: ${String(error)}`, - ); - } finally { - if (descriptor !== undefined) closeSync(descriptor); - } -} - export function lockedArchivesFromDirectory( archiveDirectory: string, lockfilePath: string, @@ -221,6 +205,12 @@ function packumentUrl(registryOrigin: string, packageName: string): string { } function loadCachePut(): CachePut { + const npmVersion = execFileSync("npm", ["--version"], { encoding: "utf8" }).trim(); + if (!REVIEWED_CI_NPM_VERSIONS.has(npmVersion)) { + throw new Error( + `reviewed npm cache seed does not support npm@${npmVersion}; expected npm@10.9.4, npm@10.9.8, or npm@11.17.0`, + ); + } const npmRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim(); const require = createRequire(import.meta.url); const cacachePath = require.resolve("cacache", { @@ -257,7 +247,10 @@ export async function seedReviewedNpmCache( ); } const registryOrigin = parsedRegistry.origin; - const locked = readLockedPackages(request.lockfilePath, registryOrigin); + if (request.packumentsOnly && request.tarballsOnly) { + throw new Error("reviewed npm cache seed cannot select both packuments-only and tarballs-only"); + } + const locked = readLockedPackages(request.lockfilePath, registryOrigin, request); const selectedPackageSpecs = request.selectedPackageSpecs ?? new Set(locked.map(({ name, version }) => `${name}@${version}`)); @@ -277,13 +270,12 @@ export async function seedReviewedNpmCache( throw new Error(`reviewed npm cache seed archive is missing: ${packageSpec}`); expectedArchives.delete(packageSpec); unexpectedArchives.delete(packageSpec); - const archive = readArchive(archivePath, packageSpec); - const actualIntegrity = `sha512-${createHash("sha512").update(archive).digest("base64")}`; - if (actualIntegrity !== entry.integrity) { - throw new Error( - `reviewed npm cache seed integrity mismatch for ${packageSpec}\nExpected: ${entry.integrity}\nActual: ${actualIntegrity}`, - ); - } + const archive = readReviewedNpmArchiveFile({ + archivePath, + expectedIntegrity: entry.integrity, + label: `reviewed npm cache seed ${packageSpec}`, + maximumBytes: request.maximumArchiveBytes, + }); await put(cachePath, `make-fetch-happen:request-cache:${entry.resolved}`, archive, { metadata: { options: { compress: true }, @@ -299,20 +291,22 @@ export async function seedReviewedNpmCache( await put(cachePath, `pacote:tarball:${packageSpec}`, archive); } - const version = { - ...(entry.bundleDependencies ? { bundleDependencies: entry.bundleDependencies } : {}), - ...(entry.dependencies ? { dependencies: entry.dependencies } : {}), - dist: { integrity: entry.integrity, tarball: entry.resolved }, - ...(entry.hasShrinkwrap ? { hasShrinkwrap: true } : {}), - name: entry.name, - ...(entry.optionalDependencies ? { optionalDependencies: entry.optionalDependencies } : {}), - ...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}), - ...(entry.peerDependenciesMeta ? { peerDependenciesMeta: entry.peerDependenciesMeta } : {}), - version: entry.version, - }; - const versions = packumentVersions.get(entry.name) ?? {}; - versions[entry.version] = version; - packumentVersions.set(entry.name, versions); + if (!request.tarballsOnly) { + const version = { + ...(entry.bundleDependencies ? { bundleDependencies: entry.bundleDependencies } : {}), + ...(entry.dependencies ? { dependencies: entry.dependencies } : {}), + dist: { integrity: entry.integrity, tarball: entry.resolved }, + ...(entry.hasShrinkwrap ? { hasShrinkwrap: true } : {}), + name: entry.name, + ...(entry.optionalDependencies ? { optionalDependencies: entry.optionalDependencies } : {}), + ...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}), + ...(entry.peerDependenciesMeta ? { peerDependenciesMeta: entry.peerDependenciesMeta } : {}), + version: entry.version, + }; + const versions = packumentVersions.get(entry.name) ?? {}; + versions[entry.version] = version; + packumentVersions.set(entry.name, versions); + } seeded.push(packageSpec); } for (const [packageName, versions] of [...packumentVersions].sort(([left], [right]) => diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index fc4179b4535..998183829b8 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -22,6 +22,13 @@ type CiWorkflow = { jobs: Record; }; +type SdkPackageWorkflow = Readonly<{ + concurrency?: Readonly>; + jobs: Readonly>; + on?: Readonly>; + permissions?: Readonly>; +}>; + type InstallerHashAction = CompositeAction & { inputs?: Record; }; @@ -82,6 +89,14 @@ const trustedActionDirs = [ const cliShardCount = "12"; const cliShardTimeoutMinutes = 30; +const dependencyInstallJobs = [ + "build-typecheck", + "cli-tests", + "installer-integration", + "cli-test-shards", + "plugin-tests", + "static-checks", +] as const; function stepRuns(jobOrAction: WorkflowJob | CompositeAction): string[] { const steps = "runs" in jobOrAction ? jobOrAction.runs.steps : (jobOrAction.steps ?? []); @@ -142,6 +157,105 @@ function runWorkflowShellStep( }; } +type SdkPackageLocatorFixture = Readonly<{ + artifactFailureRunId?: number; + artifactsByRunId?: Readonly>; + runs: readonly unknown[]; + step: WorkflowStep; + workflowRunFailure?: boolean; +}>; + +function runSdkPackageLocator(fixture: SdkPackageLocatorFixture): Readonly<{ + githubOutput: string; + result: ReturnType; +}> { + const tempRoot = mkdtempSync(join(tmpdir(), "nemoclaw-sdk-package-locator-")); + try { + const trustedRoot = join(tempRoot, ".trusted-sdk-package-decision"); + const inspectorDirectory = join(trustedRoot, "scripts/checks"); + const workflowDirectory = join(trustedRoot, ".github/workflows"); + const fakeBin = join(tempRoot, "bin"); + mkdirSync(inspectorDirectory, { recursive: true }); + mkdirSync(workflowDirectory, { recursive: true }); + mkdirSync(fakeBin); + writeFileSync( + join(inspectorDirectory, "prepare-ci-npm-install.mts"), + 'process.stdout.write(JSON.stringify({ required: true, artifactName: "reviewed-sdk.tgz" }));\n', + ); + writeFileSync(join(workflowDirectory, "openshell-sdk-package-pr.yaml"), "name: test\n"); + writeFileSync(join(fakeBin, "seq"), "#!/bin/sh\nprintf '1\\n'\n", { mode: 0o755 }); + writeFileSync(join(fakeBin, "sleep"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + writeFileSync( + join(fakeBin, "gh"), + [ + "#!/usr/bin/env node", + 'const request = process.argv.slice(2).join(" ");', + 'if (request.includes("actions/workflows/openshell-sdk-package-pr.yaml/runs")) {', + ' if (process.env.FAKE_WORKFLOW_RUN_FAILURE === "true") {', + ' process.stderr.write("untrusted API failure detail\\n");', + " process.exit(1);", + " }", + " process.stdout.write(JSON.stringify({ workflow_runs: JSON.parse(process.env.FAKE_WORKFLOW_RUNS) }));", + " process.exit(0);", + "}", + "const artifactMatch = request.match(/actions\\/runs\\/(\\d+)\\/artifacts/);", + "if (!artifactMatch) process.exit(64);", + "const runId = Number(artifactMatch[1]);", + "if (runId === Number(process.env.FAKE_ARTIFACT_FAILURE_RUN_ID)) {", + ' process.stderr.write("untrusted artifact API failure detail\\n");', + " process.exit(1);", + "}", + "const listings = JSON.parse(process.env.FAKE_ARTIFACTS_BY_RUN_ID);", + "process.stdout.write(JSON.stringify(listings[String(runId)] ?? { artifacts: [] }));", + ].join("\n"), + { mode: 0o755 }, + ); + const outputPath = join(tempRoot, "github-output"); + const result = runWorkflowShellStep( + fixture.step, + { + BASE_SHA: "base-sha", + FAKE_ARTIFACTS_BY_RUN_ID: JSON.stringify(fixture.artifactsByRunId ?? {}), + FAKE_ARTIFACT_FAILURE_RUN_ID: String(fixture.artifactFailureRunId ?? 0), + FAKE_WORKFLOW_RUNS: JSON.stringify(fixture.runs), + FAKE_WORKFLOW_RUN_FAILURE: String(fixture.workflowRunFailure ?? false), + GH_TOKEN: "test-token", + GITHUB_OUTPUT: outputPath, + GITHUB_REPOSITORY: "NVIDIA/NemoClaw", + GITHUB_WORKSPACE: tempRoot, + HEAD_REPOSITORY: "NVIDIA/NemoClaw", + HEAD_SHA: "head-sha", + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + PR_NUMBER: "10368", + }, + tempRoot, + ); + return { + githubOutput: existsSync(outputPath) ? readFileSync(outputPath, "utf8") : "", + result, + }; + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function sdkPackageWorkflowRun( + id: number, + status: string, + conclusion: string | null, + createdAt: string, +): Readonly> { + return { + conclusion, + created_at: createdAt, + event: "pull_request_target", + html_url: `https://github.com/NVIDIA/NemoClaw/actions/runs/${id}`, + id, + pull_requests: [{ base: { sha: "base-sha" }, head: { sha: "head-sha" }, number: 10368 }], + status, + }; +} + function workflowJob( id: unknown, name: unknown, @@ -325,6 +439,10 @@ describe("pull request and main workflow contracts", () => { const mainWorkflow = readYaml(".github/workflows/main.yaml"); const dcoWorkflow = readYaml(".github/workflows/dco-check.yaml"); const installerHashWorkflow = readYaml(".github/workflows/installer-hash-check.yaml"); + const sdkPackageWorkflow = readYaml( + ".github/workflows/openshell-sdk-package-pr.yaml", + ); + const sdkPackageJob = sdkPackageWorkflow.jobs["package-openshell-sdk"]; const installerHashAction = readYaml( ".github/actions/ci-installer-hash-check/action.yaml", @@ -356,6 +474,393 @@ describe("pull request and main workflow contracts", () => { expect(workflow.jobs["cli-test-shards"]?.["timeout-minutes"]).toBe(cliShardTimeoutMinutes); }); + // source-shape-contract: security -- Pull request jobs must never receive the GitHub Packages credential + it("does not grant package access to pull request jobs", () => { + expect(prWorkflow.permissions).toEqual({ contents: "read" }); + expect( + Object.entries(prWorkflow.jobs).filter(([, job]) => job.permissions?.packages !== undefined), + ).toEqual([]); + }); + + // source-shape-contract: security -- Trusted main jobs may read packages only where the reviewed installer consumes the token + it("limits main package reads to dependency-install jobs", () => { + expect(mainWorkflow.permissions).toEqual({ contents: "read" }); + expect( + Object.entries(mainWorkflow.jobs) + .filter(([, job]) => job.permissions?.packages !== undefined) + .map(([jobName, job]) => [jobName, job.permissions?.packages] as const) + .sort(([left], [right]) => left.localeCompare(right)), + ).toEqual([ + ["build-typecheck", "read"], + ["cli-test-shards", "read"], + ["cli-tests", "read"], + ["installer-integration", "read"], + ["plugin-tests", "read"], + ["static-checks", "read"], + ]); + }); + + // source-shape-contract: security -- The shared action must pass a package token only on trusted main pushes + it("provides the package token only to trusted main dependency installation", () => { + const actions = [ + sharedActions.staticChecks, + sharedActions.buildTypecheck, + sharedActions.cliCoverageMerge, + sharedActions.installerIntegration, + sharedActions.cliCoverageShard, + sharedActions.pluginCoverage, + ]; + expect( + actions.map((action) => requiredStep(action, "Setup Node.js").with?.["registry-url"]), + ).toEqual(actions.map(() => undefined)); + expect(actions.map((action) => requiredStep(action, "Setup Node.js").with?.scope)).toEqual( + actions.map(() => undefined), + ); + expect(actions.map((action) => requiredStep(action, "Install dependencies").env)).toEqual( + actions.map(() => ({ + NODE_AUTH_TOKEN: "${{ github.event_name == 'push' && github.token || '' }}", + })), + ); + expect(actions.map((action) => requiredStep(action, "Install dependencies").run)).toEqual( + actions.map(() => 'bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh"'), + ); + }); + + // source-shape-contract: security -- The PR workflow must select an exact base-controlled package run before publishing its archive internally + it("passes only the base-packaged SDK archive to pull request dependency jobs", () => { + const packageJob = prWorkflow.jobs["openshell-sdk-package"]; + expect(packageJob["timeout-minutes"]).toBe(10); + expect(packageJob.permissions).toEqual({ actions: "read", contents: "read" }); + expect(packageJob.outputs).toEqual({ required: "${{ steps.locate.outputs.required }}" }); + expect(requiredWorkflowStep(packageJob, "Checkout base package decision").with).toMatchObject({ + ref: "${{ github.event.pull_request.base.sha }}", + path: ".trusted-sdk-package-decision", + }); + const locate = requiredWorkflowStep(packageJob, "Locate exact base-controlled SDK package run"); + expect(locate.env?.HEAD_REPOSITORY).toBe( + "${{ github.event.pull_request.head.repo.full_name }}", + ); + expect(locate.run).toContain( + "trusted_inspector=.trusted-sdk-package-decision/scripts/checks/prepare-ci-npm-install.mts", + ); + expect(locate.run).toContain('if [ ! -f "$trusted_inspector" ]'); + expect(locate.run).toContain('if [ -f "$trusted_workflow" ]'); + expect(locate.run).toContain( + 'all(type == "string" and startswith("https://registry.npmjs.org/"))', + ); + expect(locate.run).toContain("requires two valid public-registry npm lockfiles"); + expect(locate.run).toContain("actions/workflows/openshell-sdk-package-pr.yaml/runs"); + expect(locate.run).toContain("for attempt in $(seq 1 84)"); + expect(locate.run).toContain("sleep 5"); + expect(locate.run).toContain(".head.sha == $head and .base.sha == $base"); + expect(locate.run).toContain("required=false"); + expect(locate.run).toContain('[ "$HEAD_REPOSITORY" != "$GITHUB_REPOSITORY" ]'); + expect(locate.run).toContain("available only to same-repository pull requests"); + expect(locate.run).not.toContain("@nvidia/openshell-sdk@0.0.106"); + expect(locate.run).not.toContain("nvidia-openshell-sdk-0.0.106.tgz"); + }); + + // The one-time bootstrap may proceed only while both lockfiles use the public registry. + it("allows the package workflow bootstrap without a private registry lock", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "nemoclaw-sdk-package-bootstrap-")); + try { + mkdirSync(join(tempRoot, "nemoclaw"), { recursive: true }); + const lock = JSON.stringify({ + lockfileVersion: 3, + packages: { + "node_modules/example": { + resolved: "https://registry.npmjs.org/example/-/example-1.0.0.tgz", + }, + }, + }); + writeFileSync(join(tempRoot, "package-lock.json"), lock); + writeFileSync(join(tempRoot, "nemoclaw/package-lock.json"), lock); + const outputPath = join(tempRoot, "github-output"); + const locate = requiredWorkflowStep( + prWorkflow.jobs["openshell-sdk-package"], + "Locate exact base-controlled SDK package run", + ); + + const result = runWorkflowShellStep( + locate, + { + GITHUB_OUTPUT: outputPath, + GITHUB_REPOSITORY: "NVIDIA/NemoClaw", + GITHUB_WORKSPACE: tempRoot, + HEAD_REPOSITORY: "NVIDIA/NemoClaw", + }, + tempRoot, + ); + + expect(result).toMatchObject({ status: 0, stderr: "" }); + expect(readFileSync(outputPath, "utf8")).toBe("required=false\n"); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + // A private registry lock cannot bypass a base that lacks the trusted package workflow. + it("rejects a private registry lock during the package workflow bootstrap", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "nemoclaw-sdk-package-bootstrap-")); + try { + mkdirSync(join(tempRoot, "nemoclaw"), { recursive: true }); + writeFileSync( + join(tempRoot, "package-lock.json"), + JSON.stringify({ lockfileVersion: 3, packages: {} }), + ); + writeFileSync( + join(tempRoot, "nemoclaw/package-lock.json"), + JSON.stringify({ + lockfileVersion: 3, + packages: { + "node_modules/private": { + resolved: "https://npm.pkg.github.com/download/private/package/1.0.0/archive", + }, + }, + }), + ); + const locate = requiredWorkflowStep( + prWorkflow.jobs["openshell-sdk-package"], + "Locate exact base-controlled SDK package run", + ); + + const result = runWorkflowShellStep( + locate, + { + GITHUB_OUTPUT: join(tempRoot, "github-output"), + GITHUB_REPOSITORY: "NVIDIA/NemoClaw", + GITHUB_WORKSPACE: tempRoot, + HEAD_REPOSITORY: "NVIDIA/NemoClaw", + }, + tempRoot, + ); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("requires two valid public-registry npm lockfiles"); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("rejects a malformed lockfile during the package workflow bootstrap", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "nemoclaw-sdk-package-bootstrap-")); + try { + mkdirSync(join(tempRoot, "nemoclaw"), { recursive: true }); + writeFileSync( + join(tempRoot, "package-lock.json"), + JSON.stringify({ lockfileVersion: 3, packages: {} }), + ); + writeFileSync(join(tempRoot, "nemoclaw/package-lock.json"), "not JSON"); + const locate = requiredWorkflowStep( + prWorkflow.jobs["openshell-sdk-package"], + "Locate exact base-controlled SDK package run", + ); + + const result = runWorkflowShellStep( + locate, + { + GITHUB_OUTPUT: join(tempRoot, "github-output"), + GITHUB_REPOSITORY: "NVIDIA/NemoClaw", + GITHUB_WORKSPACE: tempRoot, + HEAD_REPOSITORY: "NVIDIA/NemoClaw", + }, + tempRoot, + ); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("requires two valid public-registry npm lockfiles"); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("explains how to recover when the exact SDK package artifact expired", () => { + const { githubOutput, result } = runSdkPackageLocator({ + artifactsByRunId: { + "321": { artifacts: [{ expired: true, name: "openshell-sdk-head-sha" }] }, + }, + runs: [sdkPackageWorkflowRun(321, "completed", "success", "2026-08-27T00:00:00Z")], + step: requiredWorkflowStep( + prWorkflow.jobs["openshell-sdk-package"], + "Locate exact base-controlled SDK package run", + ), + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("reviewed SDK archive"); + expect(result.stdout).toContain("https://github.com/NVIDIA/NemoClaw/actions/runs/321"); + expect(result.stdout).toContain("Rerun Security / Package OpenShell SDK for PR"); + expect(result.stdout).toContain( + "Then rerun the failed openshell-sdk-package job in CI / Pull Request", + ); + expect(githubOutput).not.toContain("run_id="); + }); + + it("uses an older exact SDK package run after a newer run is cancelled", () => { + const { githubOutput, result } = runSdkPackageLocator({ + artifactsByRunId: { + "320": { artifacts: [{ expired: false, name: "openshell-sdk-head-sha" }] }, + }, + runs: [ + sdkPackageWorkflowRun(321, "completed", "cancelled", "2026-08-27T01:00:00Z"), + sdkPackageWorkflowRun(320, "completed", "success", "2026-08-27T00:00:00Z"), + ], + step: requiredWorkflowStep( + prWorkflow.jobs["openshell-sdk-package"], + "Locate exact base-controlled SDK package run", + ), + }); + + expect(result).toMatchObject({ status: 0, stderr: "" }); + expect(githubOutput).toContain("run_id=320\n"); + }); + + it("explains how to retry an SDK artifact-listing failure", () => { + const { githubOutput, result } = runSdkPackageLocator({ + artifactFailureRunId: 321, + runs: [sdkPackageWorkflowRun(321, "completed", "success", "2026-08-27T00:00:00Z")], + step: requiredWorkflowStep( + prWorkflow.jobs["openshell-sdk-package"], + "Locate exact base-controlled SDK package run", + ), + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("After GitHub Actions access returns"); + expect(result.stdout).toContain( + "rerun the failed openshell-sdk-package job in CI / Pull Request", + ); + expect(result.stdout).not.toContain("Rerun Security / Package OpenShell SDK for PR"); + expect(result.stderr).not.toContain("untrusted artifact API failure detail"); + expect(githubOutput).not.toContain("run_id="); + }); + + it("explains how to retry an SDK workflow-run-listing failure", () => { + const { githubOutput, result } = runSdkPackageLocator({ + runs: [], + step: requiredWorkflowStep( + prWorkflow.jobs["openshell-sdk-package"], + "Locate exact base-controlled SDK package run", + ), + workflowRunFailure: true, + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("Could not inspect reviewed SDK package workflow runs"); + expect(result.stdout).toContain("After GitHub Actions access returns"); + expect(result.stdout).toContain( + "rerun the failed openshell-sdk-package job in CI / Pull Request", + ); + expect(result.stderr).not.toContain("untrusted API failure detail"); + expect(githubOutput).not.toContain("run_id="); + }); + + it("explains how to recover when the SDK package wait expires", () => { + const { githubOutput, result } = runSdkPackageLocator({ + runs: [sdkPackageWorkflowRun(321, "in_progress", null, "2026-08-27T00:00:00Z")], + step: requiredWorkflowStep( + prWorkflow.jobs["openshell-sdk-package"], + "Locate exact base-controlled SDK package run", + ), + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("this latest PR commit"); + expect(result.stdout).toContain("within seven minutes"); + expect(result.stdout).toContain( + "Last matching run: https://github.com/NVIDIA/NemoClaw/actions/runs/321 (in_progress)", + ); + expect(result.stdout).toContain("Rerun Security / Package OpenShell SDK for PR"); + expect(result.stdout).toContain( + "Then rerun the failed openshell-sdk-package job in CI / Pull Request", + ); + expect(result.stdout).not.toContain("exact-head"); + expect(githubOutput).not.toContain("run_id="); + }); + + // source-shape-contract: security -- Every PR dependency consumer must receive the verified archive without package access + it.each(dependencyInstallJobs)("passes the verified SDK archive to %s", (jobName) => { + const job = prWorkflow.jobs[jobName]; + expect(job.needs).toEqual(expect.arrayContaining(["changes", "openshell-sdk-package"])); + expect(job.permissions?.packages).toBeUndefined(); + const download = requiredWorkflowStep(job, "Download verified OpenShell SDK archive"); + expect(download.uses).toBe( + "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", + ); + expect(download.if).toBe("needs.openshell-sdk-package.outputs.required == 'true'"); + expect(download.with).toMatchObject({ + name: "openshell-sdk-package", + path: "${{ runner.temp }}/openshell-sdk", + }); + }); + + // source-shape-contract: security -- The package credential must remain in a base-loaded workflow that uploads only the verified SDK archive + it("keeps package access out of pull request controlled execution", () => { + expect(sdkPackageWorkflow.on).toEqual({ + pull_request_target: { types: ["opened", "synchronize", "reopened", "edited"] }, + }); + expect(sdkPackageWorkflow.concurrency).toEqual({ + group: + "openshell-sdk-package-${{ github.event.pull_request.number }}-${{ github.event.action != 'edited' || github.event.changes.base != null }}", + "cancel-in-progress": true, + }); + expect(sdkPackageWorkflow.permissions).toEqual({ contents: "read" }); + expect(sdkPackageJob.permissions).toEqual({ contents: "read", packages: "read" }); + expect(sdkPackageJob.if).toBe( + "${{ github.event.pull_request.head.repo.full_name == github.repository && (github.event.action != 'edited' || github.event.changes.base != null) }}", + ); + expect(sdkPackageJob["timeout-minutes"]).toBe(5); + + const checkout = requiredWorkflowStep( + sdkPackageJob, + "Checkout base-controlled package verifier", + ); + expect(checkout.uses).toBe(trustedCheckoutAction); + expect(checkout.with).toMatchObject({ + ref: "${{ github.event.pull_request.base.sha }}", + "persist-credentials": false, + }); + expect(String(checkout.with?.["sparse-checkout"])).not.toContain("pull_request.head"); + + const fetch = requiredWorkflowStep( + sdkPackageJob, + "Download and verify exact OpenShell SDK package", + ); + expect(fetch.env).toEqual({ + NEMOCLAW_OPEN_SHELL_SDK_OUTPUT_DIRECTORY: "${{ runner.temp }}/openshell-sdk", + NODE_AUTH_TOKEN: "${{ github.token }}", + }); + expect(fetch.run).toContain( + "node --experimental-strip-types scripts/checks/package-openshell-sdk-for-pr.mts", + ); + expect(fetch.run).toContain("artifact_path="); + expect( + (sdkPackageJob.steps ?? []) + .filter((candidate) => candidate.name !== fetch.name) + .map((candidate) => candidate.env?.NODE_AUTH_TOKEN), + ).toEqual( + (sdkPackageJob.steps ?? []) + .filter((candidate) => candidate.name !== fetch.name) + .map(() => undefined), + ); + + const upload = requiredWorkflowStep(sdkPackageJob, "Upload verified OpenShell SDK archive"); + expect(upload.uses).toBe("actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"); + expect(upload.with).toMatchObject({ + name: "openshell-sdk-${{ github.event.pull_request.head.sha }}", + path: "${{ steps.package.outputs.artifact_path }}", + "if-no-files-found": "error", + "retention-days": 1, + }); + }); + + // source-shape-contract: security -- The credential-bearing workflow must derive one package identity from reviewed base data instead of duplicating package coordinates + it("derives the package and archive identity from the base-controlled decision", () => { + const serialized = JSON.stringify(sdkPackageWorkflow); + expect(serialized).not.toContain("@nvidia/openshell-sdk@0.0.106"); + expect(serialized).not.toContain("nvidia-openshell-sdk-0.0.106.tgz"); + }); + // source-shape-contract: security -- PR base SHA action execution prevents pull-request code from authorizing installer hashes it("executes pull request installer hash checks only from the PR base SHA", () => { expect(installerHashTrustViolations(installerHashWorkflow)).toEqual([]); @@ -613,6 +1118,7 @@ describe("pull request and main workflow contracts", () => { CODE_CHANGED: "true", DOCS_ONLY_RESULT: "skipped", INSTALLER_INTEGRATION_RESULT: "success", + OPEN_SHELL_SDK_PACKAGE_RESULT: "success", PLUGIN_TESTS_RESULT: "success", REVIEWED_NPM_AUDIT_RESULT: "success", STATIC_RESULT: "success", @@ -650,6 +1156,7 @@ describe("pull request and main workflow contracts", () => { CODE_CHANGED: "false", DOCS_ONLY_RESULT: "success", INSTALLER_INTEGRATION_RESULT: "skipped", + OPEN_SHELL_SDK_PACKAGE_RESULT: "skipped", PLUGIN_TESTS_RESULT: "skipped", REVIEWED_NPM_AUDIT_RESULT: "skipped", STATIC_RESULT: "skipped", diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 79d5cc0ba9f..257d40bccc1 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -54,10 +54,13 @@ function writeProductionSourceGraph( root: string, packageRecord: Readonly>, additionalPackageRecords: Readonly>>> = {}, + optional = false, ): Readonly<{ sourceLock: string; sourcePackage: string }> { const source = path.join(root, "source"); const manifest = { - dependencies: { "fixture-package": "1.0.0" }, + ...(optional + ? { optionalDependencies: { "fixture-package": "1.0.0" } } + : { dependencies: { "fixture-package": "1.0.0" } }), name: "source-graph-fixture", private: true, version: "1.0.0", @@ -124,6 +127,15 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { registryOrigin: "https://registry.npmjs.org/", schemaVersion: 2, severityThreshold: "high", + sourceNestedShrinkwrapPackages: [], + sourceRegistryPackage: { + artifactName: "reviewed-package-1.0.0.tgz", + integrity: "sha512-reviewedintegrity", + label: "reviewed package 1.0.0", + packageSpec: "@example/reviewed@1.0.0", + tarballUrl: "https://npm.pkg.github.com/download/@example/reviewed/1.0.0/reviewed", + }, + sourceRegistryPackagesWithoutIntegrity: [], }; expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( @@ -131,17 +143,56 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { ); }); - it("pins the reviewed archive graph to the first tar release outside the advisory", () => { + // source-shape-contract: security -- One reviewed package field prevents a second package identity from bypassing the credential-isolation workflow + it("rejects the removed plural source-registry package shape", () => { const configFile = path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"); - const config = parseAuditConfig(fs.readFileSync(configFile, "utf-8")); + const config = JSON.parse(fs.readFileSync(configFile, "utf-8")) as Record; + config.sourceRegistryPackages = [config.sourceRegistryPackage]; + delete config.sourceRegistryPackage; - expect(config.archiveTarVersion).toBe("7.5.21"); - expect(reviewedArchiveGraphManifest(config.archiveTarVersion)).toEqual({ - name: "nemoclaw-reviewed-production-graph", - overrides: { tar: "7.5.21" }, - private: true, - version: "1.0.0", - }); + expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( + "ci/reviewed-npm-audit.json is invalid", + ); + }); + + // source-shape-contract: security -- Exact package specifications must fail before malformed reviewed identities can authorize dependency installation + it("rejects malformed reviewed source package specifications", () => { + const configFile = path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"); + const readConfig = () => + JSON.parse(fs.readFileSync(configFile, "utf-8")) as { + sourceRegistryPackage: { packageSpec: string }; + sourceRegistryPackagesWithoutIntegrity: Array<{ packageSpec: string }>; + }; + + let config = readConfig(); + config.sourceRegistryPackage.packageSpec = "@nvidia/openshell-sdk@latest"; + expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( + "ci/reviewed-npm-audit.json is invalid", + ); + + config = readConfig(); + config.sourceRegistryPackage.packageSpec = "@nvidia/openshell-sdk@01.2.3"; + expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( + "ci/reviewed-npm-audit.json is invalid", + ); + + config = readConfig(); + config.sourceRegistryPackage.packageSpec = "@nvidia/openshell-sdk@1.2.3-01"; + expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( + "ci/reviewed-npm-audit.json is invalid", + ); + + config = readConfig(); + config.sourceRegistryPackage.packageSpec = "@nvidia/openshell-sdk@1.2.3-foo..bar"; + expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( + "ci/reviewed-npm-audit.json is invalid", + ); + + config = readConfig(); + config.sourceRegistryPackagesWithoutIntegrity[0]!.packageSpec = "not-an-exact-spec"; + expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( + "ci/reviewed-npm-audit.json is invalid", + ); }); it("rejects an affected tar release for the reviewed archive graph", () => { @@ -385,6 +436,122 @@ esac } }); + it("accepts one exact package identity from an approved additional registry", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-source-graph-reviewed-registry-")); + const destination = path.join(root, "materialized"); + const integrity = "sha512-fixture"; + const tarballUrl = "https://npm.pkg.github.com/download/fixture-package/1.0.0/revision"; + const { sourceLock, sourcePackage } = writeProductionSourceGraph( + root, + { + integrity, + optional: true, + resolved: tarballUrl, + version: "1.0.0", + }, + {}, + true, + ); + let installCalled = false; + try { + expect( + materializeSourceGraph( + sourcePackage, + sourceLock, + destination, + "https://registry.npmjs.org", + () => { + installCalled = true; + }, + { + integrity, + label: "reviewed fixture package", + packageSpec: "fixture-package@1.0.0", + tarballUrl, + }, + ), + ).toBe(destination); + expect(installCalled).toBe(true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("accepts one exact package without registry integrity metadata", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-source-graph-without-integrity-")); + const destination = path.join(root, "materialized"); + const tarballUrl = "https://registry.npmjs.org/fixture-package/-/fixture-package-1.0.0.tgz"; + const { sourceLock, sourcePackage } = writeProductionSourceGraph( + root, + { optional: true, resolved: tarballUrl, version: "1.0.0" }, + {}, + true, + ); + let installCalled = false; + try { + expect( + materializeSourceGraph( + sourcePackage, + sourceLock, + destination, + "https://registry.npmjs.org", + () => { + installCalled = true; + }, + undefined, + [], + [ + { + label: "reviewed fixture package without integrity", + packageSpec: "fixture-package@1.0.0", + tarballUrl, + }, + ], + ), + ).toBe(destination); + expect(installCalled).toBe(true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects drift from an approved additional-registry package identity", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-source-graph-registry-drift-")); + const destination = path.join(root, "materialized"); + const tarballUrl = "https://npm.pkg.github.com/download/fixture-package/1.0.0/revision"; + const { sourceLock, sourcePackage } = writeProductionSourceGraph(root, { + integrity: "sha512-fixture", + resolved: tarballUrl, + version: "1.0.0", + }); + let installCalled = false; + try { + expect(() => + materializeSourceGraph( + sourcePackage, + sourceLock, + destination, + "https://registry.npmjs.org", + () => { + installCalled = true; + }, + { + integrity: "sha512-another-value", + label: "reviewed fixture package", + packageSpec: "fixture-package@1.0.0", + tarballUrl, + }, + ), + ).toThrow( + "reviewed npm lock package does not match its approved registry identity: node_modules/fixture-package", + ); + expect(installCalled).toBe(false); + expect(fs.existsSync(destination)).toBe(false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it("rejects dev: true when root production dependencies reach the package (#8116)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-source-graph-dev-flag-")); const destination = path.join(root, "materialized"); diff --git a/test/e2e/README.md b/test/e2e/README.md index 3025c97917d..6228a43c926 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -266,6 +266,19 @@ discovery command locally to inspect the generated test matrix: npx tsx tools/e2e/credential-free-tests.mts ``` +### External gateway health + +`external-gateway-health` is an explicit-only retained job for the external +OpenShell target work in issue #9872. The trusted workflow downloads and +verifies the exact OpenShell SDK archive with package-read permission. The +candidate job receives the archive but no package credential. It calls a local +OpenShell 0.0.106 gateway over HTTPS with an explicit CA. The target does not +read an authentication file or make an authenticated gateway call. + +Use `jobs=external-gateway-health` for the manual pull request E2E run. The +target records the expected release, reported release, public health status, +and transport. It also stops the gateway and removes its temporary state. + ## Catalogue Targets `tools/e2e/target-catalogue.mts` declares live E2E targets that share one execution shape. @@ -617,6 +630,8 @@ The current-checkout fixture locally prebuilds its repository-controlled v1 and v2 Dockerfiles with BuildKit, then hands only those local image references to OpenShell. User-supplied `--from` Dockerfiles retain the gateway-builder trust boundary and are never host-prebuilt by this fixture. +When a PR changes a base-image input, the current-checkout fixture enables that +base-image build after the workflow removes Docker Hub credentials. The runtime target for `openclaw-plugin-runtime-exdev` is 16–17 minutes. Push-run timing for the reduced lifecycle has not yet been measured. diff --git a/test/e2e/live/external-gateway-health-helpers.ts b/test/e2e/live/external-gateway-health-helpers.ts new file mode 100644 index 00000000000..28f74769eeb --- /dev/null +++ b/test/e2e/live/external-gateway-health-helpers.ts @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { type ChildProcess, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +import { ensureDockerDriverGatewayLocalTlsBundle } from "../../../dist/lib/onboard/docker-driver-gateway-local-tls"; +import type { ArtifactSink } from "../fixtures/artifacts.ts"; +import type { CleanupRegistry } from "../fixtures/cleanup.ts"; +import { expect } from "../fixtures/e2e-test.ts"; +import { OPENSHELL_V0106_QUALIFICATION } from "../fixtures/openshell-v0106-qualification.ts"; +import { spawnObservedChild } from "../fixtures/observed-child-process.ts"; +import type { TestProgress } from "../fixtures/progress.ts"; + +export const EXTERNAL_GATEWAY_HEALTH_TIMEOUT_MS = 3 * 60_000; +const HEALTH_TIMEOUT_MS = 5_000; + +type OpenShellHealthClient = Readonly<{ + raw: Readonly<{ + health( + request: Record, + options: Readonly<{ signal: AbortSignal }>, + ): Promise; + }>; +}>; + +type OpenShellSdkModule = Readonly<{ + OpenShellClient: Readonly<{ + connect(options: Readonly<{ gateway: string; caCert: Buffer }>): Promise; + }>; +}>; + +type ScenarioFixtures = Readonly<{ + artifacts: ArtifactSink; + cleanup: CleanupRegistry; + progress: TestProgress; + skip: (message?: string) => void; +}>; + +function resolveGatewayBin(): string | null { + for (const candidate of [ + process.env.OPENSHELL_GATEWAY_BIN, + path.join(os.homedir(), ".local", "bin", "openshell-gateway"), + "/usr/local/bin/openshell-gateway", + "/usr/bin/openshell-gateway", + ]) { + if (candidate && fs.existsSync(candidate)) return candidate; + } + const result = spawnSync("sh", ["-c", "command -v openshell-gateway"], { + encoding: "utf8", + killSignal: "SIGKILL", + stdio: ["ignore", "pipe", "pipe"], + timeout: 5_000, + }); + return result.status === 0 && result.stdout.trim() ? result.stdout.trim() : null; +} + +function pickPort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("failed to allocate a TCP port"))); + return; + } + server.close((error) => (error ? reject(error) : resolve(address.port))); + }); + }); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function stopGateway(gateway: ChildProcess): Promise { + if (gateway.exitCode !== null) return; + gateway.kill("SIGTERM"); + for (let attempt = 0; attempt < 20; attempt += 1) { + if (gateway.exitCode !== null) return; + await delay(100); + } + gateway.kill("SIGKILL"); +} + +async function loadSdk(): Promise { + const packageName: string = "@nvidia/openshell-sdk"; + const loaded = (await import(packageName)) as Partial; + if (!loaded.OpenShellClient || typeof loaded.OpenShellClient.connect !== "function") { + throw new Error("the reviewed OpenShell SDK client export is unavailable"); + } + return loaded as OpenShellSdkModule; +} + +async function waitForPublicHealth(options: { + caCert: Buffer; + endpoint: string; + gateway: ChildProcess; +}): Promise> { + const deadline = Date.now() + 60_000; + const sdk = await loadSdk(); + const client = await sdk.OpenShellClient.connect({ + gateway: options.endpoint, + caCert: options.caCert, + }); + while (Date.now() < deadline) { + if (options.gateway.exitCode !== null) { + throw new Error("OpenShell gateway exited before the public health check completed"); + } + try { + const result = await client.raw.health( + {}, + { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) }, + ); + if (typeof result === "object" && result !== null && !Array.isArray(result)) { + const health = result as Record; + if (health.version) return { status: health.status, version: health.version }; + } + } catch { + // The gateway can refuse connections until its listener is ready. + } + await delay(250); + } + throw new Error("OpenShell gateway public health did not become available"); +} + +export async function runExternalGatewayHealthScenario({ + artifacts, + cleanup, + progress, + skip, +}: ScenarioFixtures): Promise { + const gatewayBin = resolveGatewayBin(); + if (!gatewayBin) skip("openshell-gateway 0.0.106 is required"); + + progress.phase("confirm the exact OpenShell gateway and SDK prerequisites"); + const version = spawnSync(gatewayBin!, ["--version"], { + encoding: "utf8", + killSignal: "SIGKILL", + stdio: ["ignore", "pipe", "pipe"], + timeout: 5_000, + }); + expect(version.status, `${version.stdout}\n${version.stderr}`).toBe(0); + expect(`${version.stdout}\n${version.stderr}`).toContain(OPENSHELL_V0106_QUALIFICATION.version); + + const port = await pickPort(); + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-external-health-")); + cleanup.add("remove external gateway health state", () => + fs.rmSync(stateDir, { recursive: true, force: true }), + ); + const tls = ensureDockerDriverGatewayLocalTlsBundle({ gatewayBin: gatewayBin!, stateDir }); + const configPath = path.join(stateDir, "gateway.toml"); + fs.writeFileSync( + configPath, + [ + "[openshell]", + "version = 1", + "", + "[openshell.gateway]", + `bind_address = "127.0.0.1:${String(port)}"`, + "compute_drivers = []", + "disable_tls = false", + "", + "[openshell.gateway.tls]", + `cert_path = ${JSON.stringify(tls.serverCertPath)}`, + `key_path = ${JSON.stringify(tls.serverKeyPath)}`, + "require_client_auth = false", + "", + "[openshell.gateway.auth]", + "allow_unauthenticated_users = true", + "", + ].join("\n"), + { mode: 0o600 }, + ); + + progress.phase("launch a TLS gateway without client-certificate authentication"); + let gatewayOutput = ""; + const gateway = spawnObservedChild(gatewayBin!, [], { + activityLabel: "command: external-gateway-health", + progress, + spawn: { + env: { + ...process.env, + OPENSHELL_DB_URL: `sqlite:${path.join(stateDir, "openshell.db")}`, + OPENSHELL_GATEWAY_CONFIG: configPath, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + }); + gateway.stdout?.on("data", (chunk: Buffer) => { + gatewayOutput += chunk.toString("utf8"); + }); + gateway.stderr?.on("data", (chunk: Buffer) => { + gatewayOutput += chunk.toString("utf8"); + }); + cleanup.add("stop external gateway health gateway", () => stopGateway(gateway)); + + try { + progress.phase("observe public health through the reviewed SDK"); + const health = await waitForPublicHealth({ + caCert: fs.readFileSync(tls.caPath), + endpoint: `https://127.0.0.1:${String(port)}`, + gateway, + }); + expect(health.version).toBe(OPENSHELL_V0106_QUALIFICATION.version); + expect(health.status).toBe(1); + await artifacts.writeJson("external-gateway-health.json", { + expectedRelease: OPENSHELL_V0106_QUALIFICATION.version, + reportedRelease: health.version, + status: "healthy", + transport: "https-explicit-ca", + }); + } finally { + await artifacts.writeText("external-gateway.log", gatewayOutput); + } +} diff --git a/test/e2e/live/external-gateway-health.test.ts b/test/e2e/live/external-gateway-health.test.ts new file mode 100644 index 00000000000..6196b15a9c3 --- /dev/null +++ b/test/e2e/live/external-gateway-health.test.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { test } from "../fixtures/e2e-test.ts"; +import { + EXTERNAL_GATEWAY_HEALTH_TIMEOUT_MS, + runExternalGatewayHealthScenario, +} from "./external-gateway-health-helpers.ts"; + +test( + "OpenShell public health accepts the reviewed SDK over explicit HTTPS and CA (#9872)", + { + timeout: EXTERNAL_GATEWAY_HEALTH_TIMEOUT_MS, + meta: { + e2ePhases: [ + "confirm the exact OpenShell gateway and SDK prerequisites", + "launch a TLS gateway without client-certificate authentication", + "observe public health through the reviewed SDK", + ], + }, + }, + runExternalGatewayHealthScenario, +); diff --git a/test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts b/test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts index decec70a4bf..8c5a3fad0f9 100644 --- a/test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts +++ b/test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts @@ -36,6 +36,17 @@ export type OpenShellTrustedImageWrapper = OpenShellDriverConfigTestWrapper & { selectImage(imageRef: string): void; }; +export function withEnabledLocalBaseImageBuild(operation: () => T): T { + const previous = process.env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD; + process.env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD = "1"; + try { + return operation(); + } finally { + if (previous === undefined) delete process.env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD; + else process.env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD = previous; + } +} + export function trustedExdevImageRef(tag: string): string { const imageRef = `${LOCAL_SANDBOX_IMAGE_REPO}:${tag}`; assert.match(imageRef, TRUSTED_EXDEV_IMAGE_REF_PATTERN); diff --git a/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts b/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts index a488c955774..253c95e4ce9 100644 --- a/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts +++ b/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts @@ -47,6 +47,7 @@ import { DELEGATED_CAPABILITY_COMMENT_PREFIX, registerTrustedPluginFixtureImageCleanup, trustedExdevImageRef, + withEnabledLocalBaseImageBuild, } from "./openclaw-plugin-runtime-exdev-trusted-prebuild.ts"; import { createOpenShellDriverConfigTestWrapper, @@ -1309,10 +1310,12 @@ test( }); progress.phase("build and onboard plugin v1"); - const baseImageResolution = pullAndResolveBaseImageDigest({ - forceRefresh: true, - requireOpenshellSandboxAbi: true, - }); + const baseImageResolution = withEnabledLocalBaseImageBuild(() => + pullAndResolveBaseImageDigest({ + forceRefresh: true, + requireOpenshellSandboxAbi: true, + }), + ); assert( baseImageResolution, "current CLI must resolve an OpenShell-compatible sandbox base image", diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 1f82b65d2ba..b663c52d5e2 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -208,6 +208,13 @@ "live": "test/e2e/live/openshell-gateway-auth-source-contract.test.ts", "fast": ["test/e2e/support/openshell-gateway-auth-source-contract-helpers.test.ts"] }, + { + "live": "test/e2e/live/external-gateway-health.test.ts", + "fast": [ + "test/e2e/support/external-gateway-health-workflow-boundary.test.ts", + "test/install/reviewed-npm-archive.test.ts" + ] + }, { "live": "test/e2e/live/openshell-credential-generation-window.test.ts", "fast": [ diff --git a/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts b/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts index 8dafbad1f32..56547940fac 100644 --- a/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts +++ b/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts @@ -19,6 +19,7 @@ import { readWorkflow } from "../../helpers/e2e-workflow-contract"; import { testTimeout } from "../../helpers/timeouts"; const NO_IMAGE_E2E_JOBS = [ + "external-gateway-health", "staging-brev-launchable", "staging-brev-launchable-identity", "shared-e2e", diff --git a/test/e2e/support/external-gateway-health-workflow-boundary.test.ts b/test/e2e/support/external-gateway-health-workflow-boundary.test.ts new file mode 100644 index 00000000000..0c7a2aa680f --- /dev/null +++ b/test/e2e/support/external-gateway-health-workflow-boundary.test.ts @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + readExternalGatewayHealthWorkflow, + validateExternalGatewayHealthWorkflow, + validateExternalGatewayHealthWorkflowBoundary, +} from "../../../tools/e2e/external-gateway-health-workflow-boundary.mts"; + +describe("external gateway health workflow boundary", () => { + it("accepts the checked-in trusted package and live-test contract", () => { + expect(validateExternalGatewayHealthWorkflowBoundary()).toEqual([]); + }); + + it("rejects package credentials or untrusted candidate execution in the package job", () => { + const workflow = readExternalGatewayHealthWorkflow(); + const job = workflow.jobs["package-openshell-sdk"]; + job.if = "${{ always() }}"; + job.permissions = { contents: "write", packages: "write" }; + const checkout = job.steps!.find((step) => step.uses?.startsWith("actions/checkout@"))!; + checkout.with!.ref = "${{ inputs.checkout_sha }}"; + const download = job.steps!.find( + (step) => step.name === "Download and verify exact OpenShell SDK package", + )!; + download.env!.NODE_AUTH_TOKEN = "${{ secrets.PACKAGE_TOKEN }}"; + + expect(validateExternalGatewayHealthWorkflow(workflow)).toEqual( + expect.arrayContaining([ + "package-openshell-sdk must run only for the explicit external health selector", + "package-openshell-sdk must retain its bounded package-read trust boundary", + "package-openshell-sdk must execute only the trusted sparse package verifier checkout", + "package-openshell-sdk must scope its package credential to the reviewed downloader", + ]), + ); + }); + + it("rejects credential exposure and candidate or artifact substitution in the live job", () => { + const workflow = readExternalGatewayHealthWorkflow(); + const job = workflow.jobs["external-gateway-health"]; + job.needs = "generate-matrix"; + job.env = { ...job.env, GITHUB_TOKEN: "${{ github.token }}" }; + const checkout = job.steps!.find((step) => step.uses?.startsWith("actions/checkout@"))!; + checkout.with!.ref = "main"; + const download = job.steps!.find( + (step) => step.name === "Download reviewed OpenShell SDK archive", + )!; + download.with!.name = "unreviewed-sdk"; + const install = job.steps!.find( + (step) => step.name === "Install reviewed OpenShell SDK archive without package credentials", + )!; + install.run = "npm install @nvidia/openshell-sdk@latest"; + const run = job.steps!.find((step) => step.name === "Run external gateway health live test")!; + run.env = { NODE_AUTH_TOKEN: "${{ secrets.PACKAGE_TOKEN }}" }; + + expect(validateExternalGatewayHealthWorkflow(workflow)).toEqual( + expect.arrayContaining([ + "external-gateway-health must wait for the candidate CLI and reviewed SDK archive", + "external-gateway-health must not expose GITHUB_TOKEN at job scope", + "external-gateway-health must use the exact candidate checkout without persisted credentials", + "external-gateway-health must download only this run's reviewed SDK archive", + "external-gateway-health SDK install must retain: env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN", + 'external-gateway-health SDK install must retain: npm install --no-save --package-lock=false --ignore-scripts "${archives[0]}"', + "external-gateway-health must run only the credential-free external health test", + ]), + ); + }); +}); diff --git a/test/e2e/support/openclaw-plugin-runtime-exdev-trusted-prebuild.test.ts b/test/e2e/support/openclaw-plugin-runtime-exdev-trusted-prebuild.test.ts index 58de5287c53..df41345180e 100644 --- a/test/e2e/support/openclaw-plugin-runtime-exdev-trusted-prebuild.test.ts +++ b/test/e2e/support/openclaw-plugin-runtime-exdev-trusted-prebuild.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { CleanupRegistry } from "../fixtures/cleanup.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; @@ -9,10 +9,28 @@ import { acceptTrustedPluginFixturePrebuild, registerTrustedPluginFixtureImageCleanup, trustedExdevImageRef, + withEnabledLocalBaseImageBuild, } from "../live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts"; const IMAGE_ID = `sha256:${"a".repeat(64)}`; +afterEach(() => vi.unstubAllEnvs()); + +it("limits the local base-image build setting to one operation", () => { + vi.stubEnv("NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD", "0"); + + expect( + withEnabledLocalBaseImageBuild(() => process.env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD), + ).toBe("1"); + expect(process.env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD).toBe("0"); + expect(() => + withEnabledLocalBaseImageBuild(() => { + throw new Error("base-image build failed"); + }), + ).toThrow("base-image build failed"); + expect(process.env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD).toBe("0"); +}); + function commandResult(exitCode = 0, stderr = ""): ShellProbeResult { return { artifacts: { result: "result.json", stderr: "stderr.txt", stdout: "stdout.txt" }, diff --git a/test/e2e/support/workflow-plan.test.ts b/test/e2e/support/workflow-plan.test.ts index 19da31a1e3f..a1185a77f4a 100644 --- a/test/e2e/support/workflow-plan.test.ts +++ b/test/e2e/support/workflow-plan.test.ts @@ -112,6 +112,7 @@ describe("E2E workflow plan", () => { expect(plan.hermesSelected).toBe(true); expect(plan.explicitOnlyJobs).toEqual([ "staging-brev-launchable-identity", + "external-gateway-health", "llama-cpp-dgx-spark-qualification", ]); expect(releaseRequiredWorkflowJobs()).toContain("live"); diff --git a/test/install/reviewed-npm-archive.test.ts b/test/install/reviewed-npm-archive.test.ts index 6c205978bc6..4d328266bf2 100644 --- a/test/install/reviewed-npm-archive.test.ts +++ b/test/install/reviewed-npm-archive.test.ts @@ -19,11 +19,8 @@ import { const INTEGRITY = `sha512-${"a".repeat(88)}`; const PACKAGE_SPEC = "@example/reviewed@1.2.3"; const TARBALL_URL = "https://registry.npmjs.org/@example/reviewed/-/reviewed-1.2.3.tgz"; -const WECHAT_LOCK = path.join( - import.meta.dirname, - "../..", - "agents/openclaw/wechat-runtime/package-lock.json", -); +const CACHE_PACKAGE_SPEC = "@example/cache-one@1.0.0"; +const CACHE_PACKAGE_TWO_SPEC = "cache-two@2.0.0"; const roots: string[] = []; function request(): ReviewedNpmArchiveRequest { @@ -43,14 +40,50 @@ function cacheRequest(): ReviewedNpmCacheRequest { roots.push(tempDirectory); const cacheDirectory = path.join(tempDirectory, "cache"); fs.mkdirSync(cacheDirectory); + const lockfilePath = path.join(tempDirectory, "package-lock.json"); + fs.writeFileSync( + lockfilePath, + `${JSON.stringify({ + lockfileVersion: 3, + packages: { + "": {}, + "node_modules/@example/cache-one": { + integrity: INTEGRITY, + resolved: "https://registry.npmjs.org/@example/cache-one/-/cache-one-1.0.0.tgz", + version: "1.0.0", + }, + "node_modules/cache-two": { + integrity: INTEGRITY, + resolved: "https://registry.npmjs.org/cache-two/-/cache-two-2.0.0.tgz", + version: "2.0.0", + }, + }, + })}\n`, + ); return { cacheDirectory, - lockfilePath: WECHAT_LOCK, + lockfilePath, registryOrigin: "https://registry.npmjs.org/", tempDirectory, }; } +function writeSyntheticLock( + reviewed: ReviewedNpmCacheRequest, + filename: string, + packageRecord: Readonly>, +): string { + const lockfilePath = path.join(reviewed.tempDirectory as string, filename); + fs.writeFileSync( + lockfilePath, + `${JSON.stringify({ + lockfileVersion: 3, + packages: { "": {}, "node_modules/@example/reviewed": packageRecord }, + })}\n`, + ); + return lockfilePath; +} + function cachedArchiveRunner( calls: Array<{ args: readonly string[]; request: ReviewedNpmArchiveRequest }>, mutation?: Readonly<{ filename?: string; integrity?: string; packageSpec: string }>, @@ -187,12 +220,11 @@ describe("reviewed npm archive", () => { const calls: Array<{ args: readonly string[]; request: ReviewedNpmArchiveRequest }> = []; const reviewed = cacheRequest(); expect(verifyReviewedNpmCache(reviewed, cachedArchiveRunner(calls))).toEqual([ - "@tencent-weixin/openclaw-weixin@2.4.3", - "qrcode-terminal@0.12.0", - "zod@4.4.3", + CACHE_PACKAGE_SPEC, + CACHE_PACKAGE_TWO_SPEC, ]); - expect(calls.filter(({ args }) => args[0] === "pack")).toHaveLength(3); + expect(calls.filter(({ args }) => args[0] === "pack")).toHaveLength(2); calls.forEach(({ request: archiveRequest }) => { expect(archiveRequest.env).toMatchObject({ NPM_CONFIG_CACHE: reviewed.cacheDirectory, @@ -239,29 +271,69 @@ describe("reviewed npm archive", () => { it("allows nested shrinkwrap metadata only for explicit cache-seed inspection", () => { const reviewed = cacheRequest(); - const lock = JSON.parse(fs.readFileSync(WECHAT_LOCK, "utf-8")); - lock.packages["node_modules/@tencent-weixin/openclaw-weixin"].hasShrinkwrap = true; - const lockfilePath = path.join(reviewed.tempDirectory as string, "shrinkwrap-seed-lock.json"); - fs.writeFileSync(lockfilePath, `${JSON.stringify(lock, null, 2)}\n`); + const lockfilePath = writeSyntheticLock(reviewed, "shrinkwrap-seed-lock.json", { + hasShrinkwrap: true, + integrity: INTEGRITY, + resolved: TARBALL_URL, + version: "1.2.3", + }); const request = { lockfilePath, registryOrigin: "https://registry.npmjs.org/" }; expect(() => verifyReviewedNpmLockPackages(request)).toThrow( "must not delegate to nested shrinkwrap", ); expect(verifyReviewedNpmLockPackages({ ...request, allowNestedShrinkwrap: true })).toEqual([ - "@tencent-weixin/openclaw-weixin@2.4.3", - "qrcode-terminal@0.12.0", - "zod@4.4.3", + PACKAGE_SPEC, ]); }); + it("validates but does not archive an approved package without integrity", () => { + const reviewed = cacheRequest(); + const lockfilePath = path.join( + reviewed.tempDirectory as string, + "package-without-integrity-lock.json", + ); + const packageWithoutIntegrity = { + label: "reviewed package without integrity", + packageSpec: "fixture-without-integrity@1.0.0", + tarballUrl: + "https://registry.npmjs.org/fixture-without-integrity/-/fixture-without-integrity-1.0.0.tgz", + }; + fs.writeFileSync( + lockfilePath, + `${JSON.stringify({ + lockfileVersion: 3, + packages: { + "": {}, + "node_modules/@example/reviewed": { + integrity: INTEGRITY, + resolved: TARBALL_URL, + version: "1.2.3", + }, + "node_modules/fixture-without-integrity": { + resolved: packageWithoutIntegrity.tarballUrl, + version: "1.0.0", + }, + }, + })}\n`, + ); + + expect( + verifyReviewedNpmLockPackages({ + lockfilePath, + registryOrigin: "https://registry.npmjs.org/", + reviewedPackagesWithoutIntegrity: [packageWithoutIntegrity], + }), + ).toEqual([PACKAGE_SPEC]); + }); + it("rejects an off-origin locked archive before npm can read the cache", () => { const reviewed = cacheRequest(); - const lock = JSON.parse(fs.readFileSync(WECHAT_LOCK, "utf-8")); - lock.packages["node_modules/qrcode-terminal"].resolved = - "https://registry.example.test/qrcode-terminal-0.12.0.tgz"; - const lockfilePath = path.join(reviewed.tempDirectory as string, "off-origin-lock.json"); - fs.writeFileSync(lockfilePath, `${JSON.stringify(lock, null, 2)}\n`); + const lockfilePath = writeSyntheticLock(reviewed, "off-origin-lock.json", { + integrity: INTEGRITY, + resolved: "https://registry.example.test/reviewed-1.2.3.tgz", + version: "1.2.3", + }); let npmCalled = false; expect(() => @@ -276,12 +348,12 @@ describe("reviewed npm archive", () => { it.each([ { expected: "downloaded tarball integrity mismatch", - mutation: { integrity: "sha512-drift", packageSpec: "qrcode-terminal@0.12.0" }, + mutation: { integrity: "sha512-drift", packageSpec: CACHE_PACKAGE_TWO_SPEC }, name: "packed SRI drift", }, { expected: "reported unsafe archive filename", - mutation: { filename: "../../qrcode-terminal.tgz", packageSpec: "qrcode-terminal@0.12.0" }, + mutation: { filename: "../../cache-two.tgz", packageSpec: CACHE_PACKAGE_TWO_SPEC }, name: "an unsafe packed filename", }, ])("rejects $name in the final cache", ({ expected, mutation }) => { diff --git a/test/install/seed-reviewed-npm-cache.test.ts b/test/install/seed-reviewed-npm-cache.test.ts index f02acbfaeb5..1b28baf12e7 100644 --- a/test/install/seed-reviewed-npm-cache.test.ts +++ b/test/install/seed-reviewed-npm-cache.test.ts @@ -6,7 +6,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { type CachePut, @@ -72,6 +72,7 @@ function request( } afterEach(() => { + vi.unstubAllEnvs(); for (const root of roots.splice(0)) fs.rmSync(root, { force: true, recursive: true }); }); @@ -201,6 +202,26 @@ describe("reviewed npm cache seed", () => { expect(integrity).toBe(input.integrity); }); + it("rejects an unreviewed npm version before loading npm cache internals", async () => { + const input = fixture(); + const binDirectory = path.join(input.root, "bin"); + const tracePath = path.join(input.root, "npm.trace"); + const npmPath = path.join(binDirectory, "npm"); + fs.mkdirSync(binDirectory); + fs.writeFileSync( + npmPath, + `#!/bin/sh\nprintf '%s\\n' "$*" >> "$NPM_TRACE"\nprintf '99.0.0\\n'\n`, + ); + fs.chmodSync(npmPath, 0o755); + vi.stubEnv("PATH", `${binDirectory}:${process.env.PATH ?? ""}`); + vi.stubEnv("NPM_TRACE", tracePath); + + await expect(seedReviewedNpmCache(request(input))).rejects.toThrow( + "reviewed npm cache seed does not support npm@99.0.0; expected npm@10.9.4, npm@10.9.8, or npm@11.17.0", + ); + expect(fs.readFileSync(tracePath, "utf8")).toBe("--version\n"); + }); + it("rejects missing, extra, and integrity-mismatched archives", async () => { const input = fixture(); await expect( @@ -220,7 +241,7 @@ describe("reviewed npm cache seed", () => { ).rejects.toThrow("received unlocked archives: unexpected@9.9.9"); fs.writeFileSync(input.archivePath, "drifted archive bytes"); await expect(seedReviewedNpmCache(request(input), async () => undefined)).rejects.toThrow( - `integrity mismatch for ${PACKAGE_SPEC}`, + `${PACKAGE_SPEC} archive integrity mismatch`, ); }); @@ -241,4 +262,15 @@ describe("reviewed npm cache seed", () => { ), ).rejects.toThrow("registry origin is invalid"); }); + + it("rejects an archive larger than the configured seed limit", async () => { + const input = fixture(); + + await expect( + seedReviewedNpmCache( + { ...request(input), maximumArchiveBytes: input.archive.length - 1 }, + async () => undefined, + ), + ).rejects.toThrow("archive must be a bounded regular file"); + }); }); diff --git a/test/repository/ci-install-dependencies.test.ts b/test/repository/ci-install-dependencies.test.ts index 8316b21023d..359e5e8f11f 100644 --- a/test/repository/ci-install-dependencies.test.ts +++ b/test/repository/ci-install-dependencies.test.ts @@ -2,39 +2,183 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; -import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; const temporaryRoots: string[] = []; +const installer = join(import.meta.dirname, "../../.github/actions/ci-install-dependencies.sh"); +const compositeActionPath = join(import.meta.dirname, "../../.github/actions/ci-build-typecheck"); + +function makeFixture(): { root: string; trace: string; path: string } { + const root = mkdtempSync(join(tmpdir(), "nemoclaw-ci-install-")); + temporaryRoots.push(root); + const bin = join(root, "bin"); + const trace = join(root, "npm.trace"); + mkdirSync(bin); + const npm = join(bin, "npm"); + writeFileSync(npm, `#!/bin/sh\nprintf '%s\n' "$*" >> "$NPM_TRACE"\n`); + chmodSync(npm, 0o755); + mkdirSync(join(root, "nemoclaw")); + const lock = JSON.stringify({ + lockfileVersion: 3, + name: "fixture", + packages: { "": { name: "fixture", version: "1.0.0" } }, + requires: true, + version: "1.0.0", + }); + writeFileSync(join(root, "package-lock.json"), `${lock}\n`); + writeFileSync(join(root, "nemoclaw", "package-lock.json"), `${lock}\n`); + return { root, trace, path: `${bin}:${process.env.PATH || ""}` }; +} afterEach(() => { for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true }); }); describe("shared CI dependency installer", () => { - it("installs root and plugin dependencies from lockfiles without lifecycle scripts", () => { - const root = mkdtempSync(join(tmpdir(), "nemoclaw-ci-install-")); - temporaryRoots.push(root); - const bin = join(root, "bin"); - const trace = join(root, "npm.trace"); - mkdirSync(bin); - const npm = join(bin, "npm"); - writeFileSync(npm, `#!/bin/sh\nprintf '%s\n' "$*" >> "$NPM_TRACE"\n`); - chmodSync(npm, 0o755); - - const result = spawnSync("bash", [".github/actions/ci-install-dependencies.sh"], { - cwd: join(import.meta.dirname, "../.."), + it("installs from a composite-action path without lifecycle scripts", () => { + const fixture = makeFixture(); + + const result = spawnSync("bash", [installer], { + cwd: fixture.root, encoding: "utf8", - env: { ...process.env, NPM_TRACE: trace, PATH: `${bin}:${process.env.PATH || ""}` }, + env: { + ...process.env, + GITHUB_ACTION_PATH: compositeActionPath, + GITHUB_EVENT_NAME: "pull_request", + NPM_CONFIG_CACHE: join(fixture.root, "npm-cache"), + NPM_TRACE: fixture.trace, + PATH: fixture.path, + }, }); expect(result.status, result.stderr).toBe(0); - expect(readFileSync(trace, "utf8").trim().split("\n")).toEqual([ - "ci --ignore-scripts", - "--prefix nemoclaw ci --ignore-scripts", + expect(readFileSync(fixture.trace, "utf8").trim().split("\n")).toEqual([ + `ci --ignore-scripts --prefer-offline --cache ${join(fixture.root, "npm-cache")}`, + `--prefix nemoclaw ci --ignore-scripts --prefer-offline --cache ${join(fixture.root, "npm-cache")}`, ]); }); + + it("rejects candidate npm configuration before npm receives the package token", () => { + const fixture = makeFixture(); + writeFileSync( + join(fixture.root, "nemoclaw", ".npmrc"), + "@nvidia:registry=https://example.invalid\n", + ); + + const result = spawnSync("bash", [installer], { + cwd: fixture.root, + encoding: "utf8", + env: { + ...process.env, + NODE_AUTH_TOKEN: "credential-sentinel", + NPM_TRACE: fixture.trace, + PATH: fixture.path, + }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toBe( + "Candidate repository npm configuration is not allowed during trusted dependency installation.\n", + ); + expect(result.stderr).not.toContain("credential-sentinel"); + expect(existsSync(fixture.trace)).toBe(false); + }); + + it("rejects a package credential in pull request jobs before npm runs", () => { + const fixture = makeFixture(); + const result = spawnSync("bash", [installer], { + cwd: fixture.root, + encoding: "utf8", + env: { + ...process.env, + GITHUB_EVENT_NAME: "pull_request", + NODE_AUTH_TOKEN: "credential-sentinel", + NPM_TRACE: fixture.trace, + PATH: fixture.path, + }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toBe( + "Pull request dependency installation must not receive a package credential.\n", + ); + expect(result.stderr).not.toContain("credential-sentinel"); + expect(existsSync(fixture.trace)).toBe(false); + }); + + it.each(["npm-shrinkwrap.json", "nemoclaw/npm-shrinkwrap.json"])( + "rejects candidate %s before npm runs", + (relativePath) => { + const fixture = makeFixture(); + writeFileSync(join(fixture.root, relativePath), "{}\n"); + + const result = spawnSync("bash", [installer], { + cwd: fixture.root, + encoding: "utf8", + env: { + ...process.env, + GITHUB_EVENT_NAME: "push", + NODE_AUTH_TOKEN: "credential-sentinel", + NPM_TRACE: fixture.trace, + PATH: fixture.path, + }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toBe( + "Candidate npm shrinkwrap files are not allowed during trusted dependency installation.\n", + ); + expect(existsSync(fixture.trace)).toBe(false); + }, + ); + + it.each(["package-lock.json", "nemoclaw/package-lock.json"])( + "rejects an unreviewed dev package in %s before npm runs", + (relativePath) => { + const fixture = makeFixture(); + const lock = { + lockfileVersion: 3, + name: "fixture", + packages: { + "": { + devDependencies: { "unreviewed-package": "1.0.0" }, + name: "fixture", + version: "1.0.0", + }, + "node_modules/unreviewed-package": { + dev: true, + integrity: "sha512-dGVzdA==", + resolved: "https://packages.example.invalid/unreviewed-package.tgz", + version: "1.0.0", + }, + }, + requires: true, + version: "1.0.0", + }; + writeFileSync(join(fixture.root, relativePath), `${JSON.stringify(lock)}\n`); + + const result = spawnSync("bash", [installer], { + cwd: fixture.root, + encoding: "utf8", + env: { ...process.env, NPM_TRACE: fixture.trace, PATH: fixture.path }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("must use the reviewed registry"); + expect(result.stderr).not.toContain("credential-sentinel"); + expect(existsSync(fixture.trace)).toBe(false); + }, + ); }); diff --git a/test/repository/prepare-ci-npm-install.test.ts b/test/repository/prepare-ci-npm-install.test.ts new file mode 100644 index 00000000000..cb5f76b89ea --- /dev/null +++ b/test/repository/prepare-ci-npm-install.test.ts @@ -0,0 +1,369 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + chmodSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + truncateSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + prepareCiNpmInstallWithReviewedConfig, + seedReviewedSourceRegistryArtifact, + type ReviewedSourceRegistryPackage, +} from "../../scripts/checks/prepare-ci-npm-install.mts"; + +const temporaryRoots: string[] = []; +const archiveBytes = Buffer.from("reviewed OpenShell SDK fixture"); +const artifactName = "nvidia-openshell-sdk-0.0.106.tgz"; +const reviewed: ReviewedSourceRegistryPackage = { + artifactName, + integrity: `sha512-${createHash("sha512").update(archiveBytes).digest("base64")}`, + label: "OpenShell TypeScript SDK 0.0.106", + packageSpec: "@nvidia/openshell-sdk@0.0.106", + tarballUrl: "https://npm.pkg.github.com/download/@nvidia/openshell-sdk/0.0.106/reviewed-fixture", +}; + +type CacheStageRequest = Readonly<{ + archive: Buffer; + artifactName: string; + cacheDirectory: string; +}>; + +function cacheStageMock() { + return vi.fn((_request: CacheStageRequest) => undefined); +} + +function reviewedLock(packageIdentity: ReviewedSourceRegistryPackage = reviewed) { + return { + lockfileVersion: 3, + name: "reviewed-sdk-artifact-fixture", + packages: { + "": { dependencies: { "@nvidia/openshell-sdk": "0.0.106" } }, + "node_modules/@nvidia/openshell-sdk": { + integrity: packageIdentity.integrity, + resolved: packageIdentity.tarballUrl, + version: "0.0.106", + }, + }, + version: "1.0.0", + }; +} + +function publicLock() { + return { + lockfileVersion: 3, + name: "public-lock-fixture", + packages: { "": {} }, + version: "1.0.0", + }; +} + +function reviewedConfigSource(packageIdentity: ReviewedSourceRegistryPackage = reviewed) { + return JSON.stringify({ + archiveGraphId: "reviewed-archive-graph", + archivePackages: [], + archiveTarVersion: "7.5.21", + artifactDirectory: "artifacts/reviewed-npm-audit", + exceptionFile: "ci/npm-audit-exceptions.json", + lockedGraphs: [], + nodeVersion: "22.23.2", + registryOrigin: "https://registry.npmjs.org/", + schemaVersion: 2, + severityThreshold: "high", + sourceNestedShrinkwrapPackages: [], + sourceRegistryPackage: packageIdentity, + sourceRegistryPackagesWithoutIntegrity: [], + }); +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "nemoclaw-reviewed-sdk-artifact-")); + temporaryRoots.push(root); + const artifactDirectory = join(root, "artifact"); + const cacheDirectory = join(root, "cache"); + const lockfilePath = join(root, "package-lock.json"); + mkdirSync(artifactDirectory); + mkdirSync(cacheDirectory); + writeFileSync(join(artifactDirectory, artifactName), archiveBytes); + writeFileSync(lockfilePath, JSON.stringify(reviewedLock())); + return { artifactDirectory, cacheDirectory, lockfilePath, root }; +} + +function installFixture( + reviewedLocation: "root" | "nemoclaw", + packageIdentity: ReviewedSourceRegistryPackage = reviewed, +) { + const source = fixture(); + const nestedRoot = join(source.root, "nemoclaw"); + mkdirSync(nestedRoot); + writeFileSync( + source.lockfilePath, + JSON.stringify(reviewedLocation === "root" ? reviewedLock(packageIdentity) : publicLock()), + ); + writeFileSync( + join(nestedRoot, "package-lock.json"), + JSON.stringify(reviewedLocation === "nemoclaw" ? reviewedLock(packageIdentity) : publicLock()), + ); + return source; +} + +function packedInstallFixture() { + const source = installFixture("root"); + const packageRoot = join(source.root, "sdk-package"); + mkdirSync(packageRoot); + writeFileSync( + join(packageRoot, "package.json"), + JSON.stringify({ name: "@nvidia/openshell-sdk", version: "0.0.106" }), + ); + writeFileSync(join(packageRoot, "index.js"), "export {};\n"); + rmSync(join(source.artifactDirectory, artifactName)); + const packed = JSON.parse( + execFileSync( + "npm", + ["pack", packageRoot, "--pack-destination", source.artifactDirectory, "--json"], + { encoding: "utf8" }, + ), + ) as Array<{ filename?: string; integrity?: string }>; + expect(packed).toHaveLength(1); + const entry = packed[0]!; + expect(entry.filename).toBe(artifactName); + expect(entry.integrity).toMatch(/^sha512-/); + const packageIdentity = { ...reviewed, integrity: entry.integrity! }; + writeFileSync(source.lockfilePath, JSON.stringify(reviewedLock(packageIdentity))); + writeFileSync( + join(source.root, "package.json"), + JSON.stringify({ + dependencies: { "@nvidia/openshell-sdk": "0.0.106" }, + name: "reviewed-sdk-install-fixture", + private: true, + version: "1.0.0", + }), + ); + return { packageIdentity, source }; +} + +function installRequest(source: ReturnType, mode: "artifact" | "registry") { + return { + artifactDirectory: source.artifactDirectory, + cacheDirectory: source.cacheDirectory, + mode, + targetRoot: source.root, + } as const; +} + +function request(source: ReturnType) { + return { + allowedNestedShrinkwrapPackages: [], + artifactDirectory: source.artifactDirectory, + cacheDirectory: source.cacheDirectory, + lockfilePath: source.lockfilePath, + registryOrigin: "https://registry.npmjs.org/", + reviewed, + reviewedPackagesWithoutIntegrity: [], + }; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("trusted OpenShell SDK archive preparation", () => { + it("reports a bounded redacted npm cache-stage failure", async () => { + const source = fixture(); + const executableDirectory = join(source.root, "bin"); + const npmPath = join(executableDirectory, "npm"); + const longDetail = "x".repeat(700); + mkdirSync(executableDirectory); + writeFileSync( + npmPath, + `#!/bin/sh\nprintf '%s\\n' 'NPM_TOKEN=private-diagnostic-value https://user:private-password@example.test/path?token=private-query Authorization: Bearer private-bearer-value ${longDetail}' >&2\nexit 23\n`, + ); + chmodSync(npmPath, 0o700); + vi.stubEnv("PATH", `${executableDirectory}:${process.env.PATH ?? ""}`); + + const failure = await seedReviewedSourceRegistryArtifact(request(source)).catch( + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(Error); + const message = (failure as Error).message; + expect(message).toContain("npm could not stage the reviewed OpenShell SDK archive (exit 23)"); + expect(message).toContain("NPM_TOKEN="); + expect(message).toContain(""); + expect(message).not.toContain("private-diagnostic-value"); + expect(message).not.toContain("private-bearer-value"); + expect(message).not.toContain("private-password"); + expect(message).not.toContain("private-query"); + expect(message).not.toContain(longDetail); + expect(message.length).toBeLessThan(650); + }); + + it("requires the reviewed archive when the root lock uses the SDK", async () => { + const source = installFixture("root"); + const stage = cacheStageMock(); + rmSync(source.artifactDirectory, { force: true, recursive: true }); + + await expect( + prepareCiNpmInstallWithReviewedConfig( + installRequest(source, "artifact"), + reviewedConfigSource(), + stage, + ), + ).rejects.toThrow("reviewed OpenShell SDK artifact is required"); + expect(stage).not.toHaveBeenCalled(); + }); + + it("requires the reviewed archive when the plugin lock uses the SDK", async () => { + const source = installFixture("nemoclaw"); + const stage = cacheStageMock(); + rmSync(source.artifactDirectory, { force: true, recursive: true }); + + await expect( + prepareCiNpmInstallWithReviewedConfig( + installRequest(source, "artifact"), + reviewedConfigSource(), + stage, + ), + ).rejects.toThrow("reviewed OpenShell SDK artifact is required"); + expect(stage).not.toHaveBeenCalled(); + }); + + it("passes the verified archive from the root lock to npm cache preparation", async () => { + const source = installFixture("root"); + const stage = cacheStageMock(); + + await prepareCiNpmInstallWithReviewedConfig( + installRequest(source, "artifact"), + reviewedConfigSource(), + stage, + ); + + expect(stage).toHaveBeenCalledOnce(); + expect(stage.mock.calls[0]?.[0].archive.equals(archiveBytes)).toBe(true); + }); + + it("passes the verified archive from the plugin lock to npm cache preparation", async () => { + const source = installFixture("nemoclaw"); + const stage = cacheStageMock(); + + await prepareCiNpmInstallWithReviewedConfig( + installRequest(source, "artifact"), + reviewedConfigSource(), + stage, + ); + + expect(stage).toHaveBeenCalledOnce(); + }); + + it("uses registry mode without requiring or caching an archive", async () => { + const source = installFixture("root"); + const stage = cacheStageMock(); + rmSync(source.artifactDirectory, { force: true, recursive: true }); + + await prepareCiNpmInstallWithReviewedConfig( + installRequest(source, "registry"), + reviewedConfigSource(), + stage, + ); + + expect(stage).not.toHaveBeenCalled(); + }); + + it("stages only the exact reviewed tarball request and package identity", async () => { + const source = fixture(); + const stage = cacheStageMock(); + + await seedReviewedSourceRegistryArtifact(request(source), stage); + + expect(stage).toHaveBeenCalledOnce(); + expect(stage.mock.calls[0]?.[0]).toMatchObject({ + artifactName, + cacheDirectory: source.cacheDirectory, + }); + expect(stage.mock.calls[0]?.[0].archive.equals(archiveBytes)).toBe(true); + }); + + it("installs the reviewed archive offline after npm stages it", async () => { + const { packageIdentity, source } = packedInstallFixture(); + + await prepareCiNpmInstallWithReviewedConfig( + installRequest(source, "artifact"), + reviewedConfigSource(packageIdentity), + ); + execFileSync( + "npm", + [ + "ci", + "--offline", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--cache", + source.cacheDirectory, + ], + { cwd: source.root, encoding: "utf8" }, + ); + + const installed = JSON.parse( + readFileSync(join(source.root, "node_modules/@nvidia/openshell-sdk/package.json"), "utf8"), + ) as { version?: string }; + expect(installed.version).toBe("0.0.106"); + }); + + it("rejects changed bytes before writing the npm cache", async () => { + const source = fixture(); + const stage = cacheStageMock(); + writeFileSync(join(source.artifactDirectory, artifactName), "changed archive"); + + await expect(seedReviewedSourceRegistryArtifact(request(source), stage)).rejects.toThrow( + "integrity mismatch", + ); + expect(stage).not.toHaveBeenCalled(); + }); + + it("rejects symlinked or additional artifact content before writing the npm cache", async () => { + const source = fixture(); + const stage = cacheStageMock(); + writeFileSync(join(source.root, "outside.tgz"), archiveBytes); + rmSync(join(source.artifactDirectory, artifactName)); + symlinkSync(join(source.root, "outside.tgz"), join(source.artifactDirectory, artifactName)); + + await expect(seedReviewedSourceRegistryArtifact(request(source), stage)).rejects.toThrow( + "non-symlink regular file", + ); + expect(stage).not.toHaveBeenCalled(); + + rmSync(join(source.artifactDirectory, artifactName)); + writeFileSync(join(source.artifactDirectory, artifactName), archiveBytes); + writeFileSync(join(source.artifactDirectory, "unexpected.tgz"), archiveBytes); + await expect(seedReviewedSourceRegistryArtifact(request(source), stage)).rejects.toThrow( + "unexpected contents", + ); + expect(stage).not.toHaveBeenCalled(); + }); + + it("rejects an oversized artifact before writing the npm cache", async () => { + const source = fixture(); + const stage = cacheStageMock(); + truncateSync(join(source.artifactDirectory, artifactName), 32 * 1024 * 1024 + 1); + + await expect(seedReviewedSourceRegistryArtifact(request(source), stage)).rejects.toThrow( + "bounded regular file", + ); + expect(stage).not.toHaveBeenCalled(); + }); +}); diff --git a/tools/e2e/check-semantic-phases.mts b/tools/e2e/check-semantic-phases.mts index 4198fc1e209..0c292e731d5 100644 --- a/tools/e2e/check-semantic-phases.mts +++ b/tools/e2e/check-semantic-phases.mts @@ -378,6 +378,10 @@ const OBSERVED_CHILD_PROGRESS_POLICIES = new Map; + id?: string; + if?: string; + name?: string; + run?: string; + uses?: string; + with?: Record; +}; + +type WorkflowJob = { + env?: Record; + if?: string; + needs?: string | string[]; + outputs?: Record; + permissions?: Record; + "runs-on"?: string; + steps?: WorkflowStep[]; + "timeout-minutes"?: number; +}; + +export type ExternalGatewayHealthWorkflow = { + jobs: Record; +}; + +export function readExternalGatewayHealthWorkflow( + workflowPath = DEFAULT_WORKFLOW_PATH, +): ExternalGatewayHealthWorkflow { + return YAML.parse(readFileSync(workflowPath, "utf8")) as ExternalGatewayHealthWorkflow; +} + +function findStep(job: WorkflowJob, name: string): WorkflowStep { + return job.steps?.find((step) => step.name === name) ?? {}; +} + +function sameMembers(value: string | string[] | undefined, expected: readonly string[]): boolean { + const actual = Array.isArray(value) ? value : value ? [value] : []; + return JSON.stringify([...actual].sort()) === JSON.stringify([...expected].sort()); +} + +function requireOrder( + errors: string[], + steps: WorkflowStep[], + beforeName: string, + afterName: string, +): void { + const before = steps.findIndex((step) => step.name === beforeName); + const after = steps.findIndex((step) => step.name === afterName); + if (before < 0 || after < 0 || before >= after) { + errors.push(`${HEALTH_JOB} step '${beforeName}' must precede '${afterName}'`); + } +} + +function validatePackageJob(errors: string[], job: WorkflowJob): void { + if ( + job.if !== + "${{ github.event_name == 'workflow_dispatch' && contains(format(',{0},', inputs.jobs), ',external-gateway-health,') }}" + ) { + errors.push(`${PACKAGE_JOB} must run only for the explicit external health selector`); + } + if ( + job["runs-on"] !== "ubuntu-latest" || + job["timeout-minutes"] !== 5 || + JSON.stringify(job.permissions) !== JSON.stringify({ contents: "read", packages: "read" }) + ) { + errors.push(`${PACKAGE_JOB} must retain its bounded package-read trust boundary`); + } + if (job.outputs?.artifact_name !== "${{ steps.identity.outputs.artifact_name }}") { + errors.push(`${PACKAGE_JOB} must expose only the recorded artifact name`); + } + + const steps = job.steps ?? []; + const checkout = findStep(job, "Check out trusted OpenShell SDK package verifier"); + const sparse = String(checkout.with?.["sparse-checkout"] ?? "") + .split(/\r?\n/u) + .map((entry) => entry.trim()) + .filter(Boolean) + .sort(); + const expectedSparse = [ + "ci/reviewed-npm-audit.json", + "scripts/audit-reviewed-npm-graph.mts", + "scripts/checks/package-openshell-sdk-for-pr.mts", + "scripts/lib/openclaw-npm-remediation.mts", + "scripts/lib/reviewed-npm-archive.mts", + "scripts/lib/reviewed-npm-audit.mts", + ].sort(); + if ( + checkout.uses !== CHECKOUT_ACTION || + checkout.with?.ref !== "${{ github.workflow_sha }}" || + checkout.with?.["persist-credentials"] !== false || + checkout.with?.["sparse-checkout-cone-mode"] !== false || + JSON.stringify(sparse) !== JSON.stringify(expectedSparse) + ) { + errors.push(`${PACKAGE_JOB} must execute only the trusted sparse package verifier checkout`); + } + + const setup = findStep(job, "Set up Node for reviewed package download"); + if ( + setup.uses !== SETUP_NODE_ACTION || + setup.with?.["node-version"] !== "22" || + setup.with?.["registry-url"] !== "https://npm.pkg.github.com" || + setup.with?.scope !== "@nvidia" + ) { + errors.push(`${PACKAGE_JOB} must use the reviewed Node and GitHub Packages setup`); + } + + const download = findStep(job, "Download and verify exact OpenShell SDK package"); + if ( + download.id !== "package" || + download.env?.NEMOCLAW_OPEN_SHELL_SDK_OUTPUT_DIRECTORY !== "${{ runner.temp }}/openshell-sdk" || + download.env?.NODE_AUTH_TOKEN !== "${{ github.token }}" || + !download.run?.includes( + "node --experimental-strip-types scripts/checks/package-openshell-sdk-for-pr.mts", + ) + ) { + errors.push(`${PACKAGE_JOB} must scope its package credential to the reviewed downloader`); + } + + const identity = findStep(job, "Record reviewed OpenShell SDK artifact identity"); + if ( + identity.id !== "identity" || + identity.env?.RUN_ATTEMPT !== "${{ github.run_attempt }}" || + identity.env?.RUN_ID !== "${{ github.run_id }}" || + !identity.run?.includes('artifact_name="openshell-sdk-e2e-${RUN_ID}-${RUN_ATTEMPT}"') + ) { + errors.push(`${PACKAGE_JOB} must bind the artifact name to this workflow attempt`); + } + + const upload = findStep(job, "Upload reviewed OpenShell SDK archive"); + if ( + upload.uses !== UPLOAD_ACTION || + upload.with?.name !== "${{ steps.identity.outputs.artifact_name }}" || + upload.with?.path !== "${{ steps.package.outputs.artifact_path }}" || + upload.with?.["if-no-files-found"] !== "error" || + upload.with?.["retention-days"] !== 1 + ) { + errors.push(`${PACKAGE_JOB} must publish only the exact one-day reviewed archive`); + } + requireOrder(errors, steps, checkout.name ?? "", setup.name ?? ""); + requireOrder(errors, steps, setup.name ?? "", download.name ?? ""); + requireOrder(errors, steps, download.name ?? "", upload.name ?? ""); +} + +function validateHealthJob(errors: string[], job: WorkflowJob): void { + if (!sameMembers(job.needs, ["generate-matrix", PACKAGE_JOB])) { + errors.push(`${HEALTH_JOB} must wait for the candidate CLI and reviewed SDK archive`); + } + if ( + job.if !== + "${{ contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'external-gateway-health') }}" + ) { + errors.push(`${HEALTH_JOB} must use the trusted explicit selection and dependency gate`); + } + if (job["runs-on"] !== "ubuntu-latest" || job["timeout-minutes"] !== 15) { + errors.push(`${HEALTH_JOB} must retain its 15 minute Ubuntu resource budget`); + } + const env = job.env ?? {}; + const expectedEnv = { + E2E_AGENT_RUNTIME: "none", + E2E_ARTIFACT_DIR: "${{ github.workspace }}/e2e-artifacts/live/external-gateway-health", + E2E_DEFAULT_ENABLED: "0", + E2E_ENVIRONMENT_OR_INFERENCE_ENDPOINT: + "Ubuntu host with OpenShell 0.0.106; no inference endpoint", + E2E_JOB: "1", + E2E_NON_INTERACTIVE: undefined, + E2E_OBSERVABLE_OUTCOME: + "The reviewed SDK observes exact public gateway health over explicit HTTPS and CA", + E2E_TARGET_ID: HEALTH_JOB, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_OPENSHELL_PIN_VERSION: "0.0.106", + NEMOCLAW_RUN_LIVE_E2E: "1", + }; + for (const [name, value] of Object.entries(expectedEnv)) { + if (value === undefined ? Object.hasOwn(env, name) : env[name] !== value) { + errors.push(`${HEALTH_JOB} must retain ${name}=${String(value)}`); + } + } + for (const name of ["GITHUB_TOKEN", "NODE_AUTH_TOKEN", "NVIDIA_API_KEY"]) { + if (Object.hasOwn(env, name)) errors.push(`${HEALTH_JOB} must not expose ${name} at job scope`); + } + + const steps = job.steps ?? []; + const checkout = steps.find((step) => step.uses?.startsWith("actions/checkout@")) ?? {}; + if ( + checkout.uses !== CHECKOUT_ACTION || + checkout.with?.repository !== "${{ inputs.checkout_repository || github.repository }}" || + checkout.with?.ref !== "${{ inputs.checkout_sha || github.sha }}" || + checkout.with?.["fetch-depth"] !== 0 || + checkout.with?.["persist-credentials"] !== false + ) { + errors.push( + `${HEALTH_JOB} must use the exact candidate checkout without persisted credentials`, + ); + } + const prepare = findStep(job, "Prepare E2E workspace"); + if (prepare.uses !== PREPARE_E2E_ACTION || prepare.with?.["build-cli"] !== "false") { + errors.push(`${HEALTH_JOB} must use the reviewed dependency preparation without rebuilding`); + } + const restore = findStep(job, "Restore exact-commit CLI artifact"); + if ( + !restore.uses?.startsWith("NVIDIA/NemoClaw/.github/actions/restore-e2e-cli-artifact@") || + restore.with?.["provenance-json"] !== + "${{ needs.generate-matrix.outputs.cli_artifact_provenance }}" + ) { + errors.push(`${HEALTH_JOB} must restore the exact candidate CLI artifact`); + } + const download = findStep(job, "Download reviewed OpenShell SDK archive"); + if ( + download.uses !== DOWNLOAD_ACTION || + download.with?.name !== "${{ needs.package-openshell-sdk.outputs.artifact_name }}" || + download.with?.path !== "${{ runner.temp }}/openshell-sdk" + ) { + errors.push(`${HEALTH_JOB} must download only this run's reviewed SDK archive`); + } + const installSdk = findStep( + job, + "Install reviewed OpenShell SDK archive without package credentials", + ); + for (const fragment of [ + "env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN", + 'npm install --no-save --package-lock=false --ignore-scripts "${archives[0]}"', + ]) { + if (!installSdk.run?.includes(fragment)) { + errors.push(`${HEALTH_JOB} SDK install must retain: ${fragment}`); + } + } + const installOpenShell = findStep(job, "Install OpenShell CLI"); + if ( + installOpenShell.run !== + "env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN bash scripts/install-openshell.sh" + ) { + errors.push(`${HEALTH_JOB} OpenShell install must not receive package credentials`); + } + const run = findStep(job, "Run external gateway health live test"); + if ( + !run.run?.includes("tools/e2e/live-vitest-invocation.mts run") || + !run.run?.includes("test/e2e/live/external-gateway-health.test.ts") || + Object.keys(run.env ?? {}).length > 0 || + JSON.stringify(run).includes("secrets.") + ) { + errors.push(`${HEALTH_JOB} must run only the credential-free external health test`); + } + const upload = findStep(job, "Upload external gateway health artifacts"); + if ( + upload.if !== "always()" || + upload.uses !== UPLOAD_E2E_ARTIFACTS_ACTION || + upload.with?.name !== "e2e-external-gateway-health" || + upload.with?.path !== "e2e-artifacts/live/external-gateway-health/" + ) { + errors.push(`${HEALTH_JOB} must always use the reviewed artifact uploader`); + } + requireOrder(errors, steps, prepare.name ?? "", restore.name ?? ""); + requireOrder(errors, steps, restore.name ?? "", download.name ?? ""); + requireOrder(errors, steps, download.name ?? "", installSdk.name ?? ""); + requireOrder(errors, steps, installSdk.name ?? "", run.name ?? ""); + requireOrder(errors, steps, run.name ?? "", upload.name ?? ""); +} + +export function validateExternalGatewayHealthWorkflow( + workflow: ExternalGatewayHealthWorkflow, +): string[] { + const errors: string[] = []; + const packageJob = workflow.jobs[PACKAGE_JOB]; + const healthJob = workflow.jobs[HEALTH_JOB]; + if (!packageJob) errors.push(`workflow is missing ${PACKAGE_JOB}`); + else validatePackageJob(errors, packageJob); + if (!healthJob) errors.push(`workflow is missing ${HEALTH_JOB}`); + else validateHealthJob(errors, healthJob); + return errors; +} + +export function validateExternalGatewayHealthWorkflowBoundary( + workflowPath = DEFAULT_WORKFLOW_PATH, +): string[] { + return validateExternalGatewayHealthWorkflow(readExternalGatewayHealthWorkflow(workflowPath)); +} diff --git a/tools/e2e/operations-workflow-boundary.mts b/tools/e2e/operations-workflow-boundary.mts index 9aa781868aa..fdd00fa79a1 100644 --- a/tools/e2e/operations-workflow-boundary.mts +++ b/tools/e2e/operations-workflow-boundary.mts @@ -16,6 +16,7 @@ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const DEFAULT_WORKFLOW_PATH = join(REPO_ROOT, ".github", "workflows", "e2e.yaml"); const DEFAULT_ADVISOR_PATH = join(REPO_ROOT, ".github", "workflows", "pr-review-advisor.yaml"); const META_JOBS = new Set([ + "package-openshell-sdk", "native-runtime-qualification-podman-toolchain", "native-runtime-qualification-producer-plan", "release-qualification", @@ -494,6 +495,10 @@ function validateManualPrDispatch(errors: string[], workflow: OperationsWorkflow jobName === "base-image-publication" && step.name === "Check out trusted E2E workflow" && step.with?.ref === "${{ github.workflow_sha }}"; + const trustedOpenShellSdkPackageCheckout = + jobName === "package-openshell-sdk" && + step.name === "Check out trusted OpenShell SDK package verifier" && + step.with?.ref === "${{ github.workflow_sha }}"; const trustedManagedImageRuntimeCheckout = jobName === "managed-image-protected-runtime" && step.name === "Checkout trusted protected runtime qualification" && @@ -569,6 +574,7 @@ function validateManualPrDispatch(errors: string[], workflow: OperationsWorkflow trustedRelevantE2eCheckout || trustedLaunchableLaneCheckout || trustedPublicationCheckout || + trustedOpenShellSdkPackageCheckout || trustedManagedImageMultiarchResolverCheckout || trustedManagedImageRuntimeCheckout || trustedLlamaCppPlanCheckout || diff --git a/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts b/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts index 6cefa1075d9..d661e36644c 100644 --- a/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts +++ b/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts @@ -37,6 +37,7 @@ const UPLOAD_E2E_ARTIFACTS_ACTION_PREFIX = "NVIDIA/NemoClaw/.github/actions/uplo const UPLOAD_ARTIFACT_ACTION = "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"; const UPLOAD_ARTIFACT_ACTION_PREFIX = "actions/upload-artifact@"; const MANAGED_IMAGE_BUILD_CACHE_PUBLISH_STEP = "Publish exact amd64 protected runtime build cache"; +const OPEN_SHELL_SDK_E2E_PACKAGE_UPLOAD_STEP = "Upload reviewed OpenShell SDK archive"; const MANAGED_IMAGE_BUILD_CACHE_ARTIFACT_NAME = "${{ env.NEMOCLAW_PROTECTED_MANAGED_IMAGE_BUILD_CACHE_ARTIFACT }}"; const MANAGED_IMAGE_BUILD_CACHE_ARTIFACT_PATH = @@ -108,7 +109,27 @@ function isExactNativeRuntimeAggregateUpload(jobName: string, step: WorkflowStep ); } +function isExactOpenShellSdkE2ePackageUpload(jobName: string, step: WorkflowStep): boolean { + const inputs = record(step.with); + return ( + jobName === "package-openshell-sdk" && + step.name === OPEN_SHELL_SDK_E2E_PACKAGE_UPLOAD_STEP && + step.uses === UPLOAD_ARTIFACT_ACTION && + inputs.name === "${{ steps.identity.outputs.artifact_name }}" && + inputs.path === "${{ steps.package.outputs.artifact_path }}" && + inputs["if-no-files-found"] === "error" && + inputs["retention-days"] === 1 + ); +} + const EXPLICIT_UPLOAD_CONTRACTS = new Map([ + [ + "external-gateway-health", + { + name: "e2e-external-gateway-health", + path: "e2e-artifacts/live/external-gateway-health/", + }, + ], [ "generate-matrix", { @@ -462,6 +483,7 @@ export function validateUploadE2eArtifactsInvocations(workflow: WorkflowRecord): uses.startsWith(UPLOAD_ARTIFACT_ACTION_PREFIX) && !isExactCommitCliArtifactUpload && !isExactManagedImageBuildCacheUpload(jobName, step) && + !isExactOpenShellSdkE2ePackageUpload(jobName, step) && !isExactNativeRuntimeAggregateUpload(jobName, step) ) { errors.push(`${jobName} must not invoke actions/upload-artifact directly`); diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index 8bca7f44ca5..97b6d77c6c8 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -55,6 +55,10 @@ import { validateUploadE2eArtifactsWorkflowBoundary, } from "./upload-e2e-artifacts-workflow-boundary.mts"; import { validateE2eWorkspaceBootstrapBoundary } from "./workspace-bootstrap-workflow-boundary.mts"; +import { + type ExternalGatewayHealthWorkflow, + validateExternalGatewayHealthWorkflow, +} from "./external-gateway-health-workflow-boundary.mts"; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const DEFAULT_E2E_WORKFLOW_PATH = join(REPO_ROOT, ".github", "workflows", "e2e.yaml"); @@ -201,6 +205,7 @@ const PUBLIC_NVIDIA_ENDPOINT_KEY_JOBS = new Set([ "model-router-provider-routed-inference", ]); const NO_IMAGE_E2E_JOBS = new Set([ + "external-gateway-health", "staging-brev-launchable", "staging-brev-launchable-identity", SHARED_E2E_JOB_ID, @@ -1176,7 +1181,9 @@ function validateFreeStandingJobSelector( ): void { const job = asRecord(jobs[jobName]); const expectedNeeds = - jobName === "mcp-bridge-dev" + jobName === "external-gateway-health" + ? ["generate-matrix", "package-openshell-sdk"] + : jobName === "mcp-bridge-dev" ? ["base-image-publication", "generate-matrix", "openshell-dev-artifact"] : [ "mcp-bridge", @@ -2602,6 +2609,9 @@ export function validateE2eWorkflow(workflowValue: unknown): string[] { ...validateOpenShellGatewayAuthContractWorkflow( workflow as unknown as OpenShellGatewayAuthContractWorkflow, ), + ...validateExternalGatewayHealthWorkflow( + workflow as unknown as ExternalGatewayHealthWorkflow, + ), ); errors.push(...validateE2eOperationsWorkflow(workflow as unknown as OperationsWorkflow)); errors.push(...validateStandardProfileWorkflowBoundary(workflow));