diff --git a/.github/actions/ci-cli-coverage-merge/action.yaml b/.github/actions/ci-cli-coverage-merge/action.yaml index f338bde21a9..9dff906b96e 100644 --- a/.github/actions/ci-cli-coverage-merge/action.yaml +++ b/.github/actions/ci-cli-coverage-merge/action.yaml @@ -34,9 +34,12 @@ runs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + node-version: "24.18.1" cache: npm + - name: Install reviewed npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 + - name: Install dependencies shell: bash env: diff --git a/.github/actions/ci-cli-coverage-shard/action.yaml b/.github/actions/ci-cli-coverage-shard/action.yaml index f2a4a497069..9bb0f1a1cfb 100644 --- a/.github/actions/ci-cli-coverage-shard/action.yaml +++ b/.github/actions/ci-cli-coverage-shard/action.yaml @@ -44,12 +44,15 @@ runs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + node-version: "24.18.1" cache: npm cache-dependency-path: | package-lock.json nemoclaw/package-lock.json + - name: Install reviewed npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 + - name: Install pinned Pi search tools shell: bash env: diff --git a/.github/actions/ci-compile-artifacts/action.yaml b/.github/actions/ci-compile-artifacts/action.yaml index 40f64be2ae2..684e0c28494 100644 --- a/.github/actions/ci-compile-artifacts/action.yaml +++ b/.github/actions/ci-compile-artifacts/action.yaml @@ -10,12 +10,15 @@ runs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.23.2" + node-version: "24.18.1" cache: npm cache-dependency-path: | package-lock.json nemoclaw/package-lock.json + - name: Install reviewed npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 + - name: Identify compiled output id: identity shell: bash diff --git a/.github/actions/ci-install-dependencies.sh b/.github/actions/ci-install-dependencies.sh index c15c69f132b..f6219ee3adf 100755 --- a/.github/actions/ci-install-dependencies.sh +++ b/.github/actions/ci-install-dependencies.sh @@ -75,8 +75,8 @@ if [ "$package_mode" = "registry" ] && [ -n "${NODE_AUTH_TOKEN:-}" ]; then export NPM_CONFIG_USERCONFIG="$trusted_npmrc" fi -npm ci --ignore-scripts --prefer-offline --no-audit --no-fund --cache "$npm_cache" +npm ci --allow-remote=root --ignore-scripts --prefer-offline --no-audit --no-fund --cache "$npm_cache" if [ "$plugin_install_mode" != "none" ]; then npm "${plugin_install_args[@]}" \ - --ignore-scripts --prefer-offline --no-audit --no-fund --cache "$npm_cache" + --allow-remote=root --ignore-scripts --prefer-offline --no-audit --no-fund --cache "$npm_cache" fi diff --git a/.github/actions/ci-installer-integration/action.yaml b/.github/actions/ci-installer-integration/action.yaml index dcc1e22ed75..4f29fed36a2 100644 --- a/.github/actions/ci-installer-integration/action.yaml +++ b/.github/actions/ci-installer-integration/action.yaml @@ -10,12 +10,15 @@ runs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + node-version: "24.18.1" cache: npm cache-dependency-path: | package-lock.json nemoclaw/package-lock.json + - name: Install reviewed npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 + - name: Install dependencies shell: bash env: diff --git a/.github/actions/ci-plugin-coverage/action.yaml b/.github/actions/ci-plugin-coverage/action.yaml index 0f78c17d3be..77cbf2040c9 100644 --- a/.github/actions/ci-plugin-coverage/action.yaml +++ b/.github/actions/ci-plugin-coverage/action.yaml @@ -10,12 +10,15 @@ runs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + node-version: "24.18.1" cache: npm cache-dependency-path: | package-lock.json nemoclaw/package-lock.json + - name: Install reviewed npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 + - name: Install dependencies shell: bash env: diff --git a/.github/actions/ci-reviewed-npm-audit/action.yaml b/.github/actions/ci-reviewed-npm-audit/action.yaml index 6a53485302f..42e61eee295 100644 --- a/.github/actions/ci-reviewed-npm-audit/action.yaml +++ b/.github/actions/ci-reviewed-npm-audit/action.yaml @@ -25,7 +25,7 @@ runs: - name: Setup production-compatible Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.23.2" + node-version: "24.18.1" - name: Resolve npm audit cache buckets id: cache-buckets @@ -92,7 +92,7 @@ runs: shell: bash run: >- env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN - "$GITHUB_ACTION_PATH/verify-and-install-npm.sh" + "$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh" "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" - name: Materialize and audit production dependency graphs diff --git a/.github/actions/ci-static-checks/action.yaml b/.github/actions/ci-static-checks/action.yaml index 75f1539a9d8..484349662d7 100644 --- a/.github/actions/ci-static-checks/action.yaml +++ b/.github/actions/ci-static-checks/action.yaml @@ -10,12 +10,15 @@ runs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" cache: npm cache-dependency-path: | package-lock.json nemoclaw/package-lock.json + - name: Install reviewed npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 + - name: Install base-trusted createRequire verifier dependencies shell: bash run: npm ci --ignore-scripts --no-audit --no-fund --prefix "$GITHUB_ACTION_PATH" diff --git a/.github/actions/prepare-e2e/action.yaml b/.github/actions/prepare-e2e/action.yaml index 14f51bf5a4b..357a8a85069 100644 --- a/.github/actions/prepare-e2e/action.yaml +++ b/.github/actions/prepare-e2e/action.yaml @@ -16,9 +16,12 @@ runs: - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22 + node-version: 24.18.1 cache: npm + - name: Install reviewed npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 + - name: Install root dependencies shell: bash run: npm ci --ignore-scripts diff --git a/.github/actions/setup-reviewed-npm/action.yaml b/.github/actions/setup-reviewed-npm/action.yaml new file mode 100644 index 00000000000..5f2e26d6343 --- /dev/null +++ b/.github/actions/setup-reviewed-npm/action.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: setup-reviewed-npm +description: Install the exact npm release approved by the reviewed npm audit identity. + +runs: + using: composite + steps: + - name: Download, verify, and install reviewed npm + shell: bash + run: >- + env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN + "$GITHUB_ACTION_PATH/verify-and-install-npm.sh" + "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" diff --git a/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh b/.github/actions/setup-reviewed-npm/verify-and-install-npm.sh similarity index 86% rename from .github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh rename to .github/actions/setup-reviewed-npm/verify-and-install-npm.sh index d6675612ae2..ced189656ff 100755 --- a/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh +++ b/.github/actions/setup-reviewed-npm/verify-and-install-npm.sh @@ -38,15 +38,18 @@ npm pack "npm@$version" \ --ignore-scripts --no-audit --no-fund >/dev/null archive="$download_dir/npm-$version.tgz" -IFS=$'\t' read -r actual_sha512 actual_sha256 < <(node -e ' +actual_hashes="$download_dir/actual-hashes" +node -e ' const fs = require("node:fs"); const crypto = require("node:crypto"); const archive = fs.readFileSync(process.argv[1]); process.stdout.write( - crypto.createHash("sha512").update(archive).digest("base64") + "\t" + + crypto.createHash("sha512").update(archive).digest("base64") + "\n" + crypto.createHash("sha256").update(archive).digest("hex") + "\n", ); -' "$archive") +' "$archive" >"$actual_hashes" +IFS= read -r actual_sha512 <"$actual_hashes" +IFS= read -r actual_sha256 < <(sed -n '2p' "$actual_hashes") actual_integrity="sha512-$actual_sha512" if [ "$actual_integrity" != "$expected_integrity" ] || [ "$actual_sha256" != "$expected_sha256" ]; then echo "ERROR: npm@$version archive integrity mismatch." >&2 @@ -71,3 +74,9 @@ fi npm install --global "$archive" \ --userconfig /dev/null \ --ignore-scripts --no-audit --no-fund --offline + +installed_version="$(npm --version)" +if [ "$installed_version" != "$version" ]; then + echo "ERROR: installed npm@$installed_version does not match reviewed npm@$version." >&2 + exit 1 +fi diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 453346214eb..5650982bff5 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -27,6 +27,7 @@ on: - "test/e2e/live/managed-image-activation-e2e.test.ts" - "test/e2e/live/managed-image-activation-e2e-helpers.ts" - ".github/actions/ci-reviewed-npm-audit/**" + - ".github/actions/setup-reviewed-npm/**" - ".github/actions/publish-managed-image-digest/**" - ".github/actions/build-base-image-platform/**" - ".github/actions/publish-base-image-manifest/**" diff --git a/.github/workflows/candidate-compatibility.yaml b/.github/workflows/candidate-compatibility.yaml index 3c11080ebd2..87e9fe79fca 100644 --- a/.github/workflows/candidate-compatibility.yaml +++ b/.github/workflows/candidate-compatibility.yaml @@ -57,7 +57,10 @@ jobs: - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./controller/.github/actions/setup-reviewed-npm - id: identity name: Resolve official candidate provenance @@ -142,10 +145,13 @@ jobs: - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" cache: npm cache-dependency-path: candidate-source/package-lock.json + - name: Install reviewed npm + uses: ./controller/.github/actions/setup-reviewed-npm + - name: Install repository dependencies working-directory: candidate-source run: npm ci --ignore-scripts @@ -270,10 +276,13 @@ jobs: - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" cache: npm cache-dependency-path: candidate-source/package-lock.json + - name: Install reviewed npm + uses: ./controller/.github/actions/setup-reviewed-npm + - name: Install repository dependencies working-directory: candidate-source run: npm ci --ignore-scripts @@ -304,7 +313,7 @@ jobs: continue-on-error: true working-directory: candidate-source env: - DOCKER_GRPC_PROBE_IMAGE: node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c + DOCKER_GRPC_PROBE_IMAGE: node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc E2E_ARTIFACT_DIR: ${{ github.workspace }}/candidate-source/e2e-artifacts/live/openshell-gateway-auth-contract E2E_JOB: "1" E2E_TARGET_ID: openshell-gateway-auth-contract diff --git a/.github/workflows/code-scanning.yaml b/.github/workflows/code-scanning.yaml index 43b3b301af6..f0cb505d602 100644 --- a/.github/workflows/code-scanning.yaml +++ b/.github/workflows/code-scanning.yaml @@ -69,7 +69,10 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.19.0 + node-version: 24.18.1 + + - name: Install reviewed npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Install ShellCheck run: | diff --git a/.github/workflows/codebase-growth-guardrails.yaml b/.github/workflows/codebase-growth-guardrails.yaml index c4c10a8a863..c6e271eaeee 100644 --- a/.github/workflows/codebase-growth-guardrails.yaml +++ b/.github/workflows/codebase-growth-guardrails.yaml @@ -26,6 +26,14 @@ jobs: ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.1 + + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Install trusted dependencies run: npm ci --ignore-scripts --no-audit --no-fund diff --git a/.github/workflows/commit-lint.yaml b/.github/workflows/commit-lint.yaml index 39fd616568c..7c2d443f3ee 100644 --- a/.github/workflows/commit-lint.yaml +++ b/.github/workflows/commit-lint.yaml @@ -27,9 +27,12 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + node-version: "24.18.1" cache: npm + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Install dependencies run: npm install --ignore-scripts diff --git a/.github/workflows/docker-pin-check.yaml b/.github/workflows/docker-pin-check.yaml index 35b5a7e3e76..5871121c77a 100644 --- a/.github/workflows/docker-pin-check.yaml +++ b/.github/workflows/docker-pin-check.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # # Weekly check that the pinned Dockerfile base-image digest is still current. -# Fails with an actionable message when a newer node:22-trixie-slim is available. +# Fails with an actionable message if the node:24.18.1-trixie-slim digest changes. name: Images / Check Docker Base Image Pins diff --git a/.github/workflows/docs-cli-parity-pr.yaml b/.github/workflows/docs-cli-parity-pr.yaml index ea6544425c3..7978ac40048 100644 --- a/.github/workflows/docs-cli-parity-pr.yaml +++ b/.github/workflows/docs-cli-parity-pr.yaml @@ -44,7 +44,10 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm - name: Install dependencies run: npm install --ignore-scripts diff --git a/.github/workflows/docs-preview-pr.yaml b/.github/workflows/docs-preview-pr.yaml index e86230aff16..fe333531285 100644 --- a/.github/workflows/docs-preview-pr.yaml +++ b/.github/workflows/docs-preview-pr.yaml @@ -54,7 +54,10 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "24" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Install docs dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/docs-publish-public.yaml b/.github/workflows/docs-publish-public.yaml index 16965693dac..c5c8dfe7ad4 100644 --- a/.github/workflows/docs-publish-public.yaml +++ b/.github/workflows/docs-publish-public.yaml @@ -42,9 +42,12 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "24" + node-version: "24.18.1" package-manager-cache: false + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Install docs dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/docs-publish-staging.yaml b/.github/workflows/docs-publish-staging.yaml index 8d42b421e57..400097a26fa 100644 --- a/.github/workflows/docs-publish-staging.yaml +++ b/.github/workflows/docs-publish-staging.yaml @@ -40,7 +40,10 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "24" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm - name: Install docs dependencies run: npm ci --ignore-scripts @@ -109,7 +112,11 @@ jobs: if: ${{ steps.previews.outputs.has_previews == 'true' }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "24" + node-version: "24.18.1" + + - name: Install reviewed npm + if: ${{ steps.previews.outputs.has_previews == 'true' }} + uses: ./.github/actions/setup-reviewed-npm - name: Delete Fern previews if: ${{ steps.previews.outputs.has_previews == 'true' }} diff --git a/.github/workflows/e2e-main-retry.yaml b/.github/workflows/e2e-main-retry.yaml index c0534a99518..5519973f2ab 100644 --- a/.github/workflows/e2e-main-retry.yaml +++ b/.github/workflows/e2e-main-retry.yaml @@ -47,7 +47,10 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm - name: Evaluate main E2E retry env: @@ -96,7 +99,10 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm - name: Build advisory same-commit reliability report env: diff --git a/.github/workflows/e2e-standard-profile.yaml b/.github/workflows/e2e-standard-profile.yaml index 63842ab98fe..13c9eb70c17 100644 --- a/.github/workflows/e2e-standard-profile.yaml +++ b/.github/workflows/e2e-standard-profile.yaml @@ -417,7 +417,7 @@ jobs: packages: ${{ inputs.host_packages }} - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -458,7 +458,7 @@ jobs: if: ${{ inputs.target_id == 'channels-stop-start' && (inputs.runtime_provider == 'docker' || inputs.runtime_provider == 'podman') }} shell: /bin/bash --noprofile --norc -e -o pipefail {0} env: - CLEANUP_IMAGE: node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c + CLEANUP_IMAGE: node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc RUNTIME_PROVIDER: ${{ inputs.runtime_provider }} run: | set -euo pipefail diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index dde8d0a211b..9758d2fae4e 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -124,6 +124,7 @@ jobs: ref: ${{ github.workflow_sha }} persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts scripts/checks/package-openshell-sdk-for-pr.mts @@ -137,10 +138,13 @@ jobs: - name: Set up Node for reviewed package download uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" registry-url: https://npm.pkg.github.com scope: "@nvidia" + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - id: package name: Download and verify reviewed OpenShell SDK packages env: @@ -237,7 +241,10 @@ jobs: - name: Set up Node for publication verification uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: 24.18.1 + + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm - id: select_pr_source name: Resolve exact PR managed-image publication @@ -638,7 +645,10 @@ jobs: - name: Set up Node for trusted E2E planning uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: 24.18.1 + + - name: Install reviewed npm for trusted E2E planning + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Install trusted E2E planner dependencies run: npm ci --ignore-scripts --no-audit --no-fund @@ -893,6 +903,9 @@ jobs: ref: ${{ github.workflow_sha }} persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json + scripts/lib/reviewed-npm-audit.mts src/lib/onboard/runtime-provider/native-qualification-authority.ts test/e2e/registry/native-runtime-qualification.ts tools/e2e/native-runtime-qualification-producer-plan.mts @@ -910,7 +923,10 @@ jobs: - name: Set up Node for trusted qualification planning uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: 24.18.1 + + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm - id: source name: Authenticate the candidate and dispatch artifact @@ -1242,6 +1258,9 @@ jobs: path: .qualification-workflow persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json + scripts/lib/reviewed-npm-audit.mts src/lib/onboard/runtime-provider/native-qualification-authority.ts scripts/checks/run-native-runtime-installer-qualification.sh test/e2e/registry/native-runtime-qualification.ts @@ -1261,7 +1280,10 @@ jobs: - name: Set up Node for candidate execution uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.19.0 + node-version: 24.18.1 + + - name: Install reviewed npm + uses: ./.qualification-workflow/.github/actions/setup-reviewed-npm - name: Require a reviewed Ubuntu runtime host shell: bash @@ -2455,6 +2477,9 @@ jobs: path: .qualification-aggregate persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json + scripts/lib/reviewed-npm-audit.mts src/lib/onboard/runtime-provider/native-qualification-authority.ts test/e2e/registry/native-runtime-qualification.ts tools/e2e/native-runtime-qualification-producer-plan.mts @@ -2513,7 +2538,10 @@ jobs: - name: Set up Node for qualification aggregation uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.19.0 + node-version: 24.18.1 + + - name: Install reviewed npm + uses: ./.qualification-aggregate/.github/actions/setup-reviewed-npm - name: Validate and aggregate all 24 case receipts working-directory: .qualification-aggregate @@ -2758,7 +2786,7 @@ jobs: # Configure NEMOCLAW_TRACE_DIR before workspace prep so every child # command writes raw traces under runner temp, never under upload roots. - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -2975,7 +3003,7 @@ jobs: persist-credentials: false - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -3269,7 +3297,7 @@ jobs: NEMOCLAW_NON_INTERACTIVE: "1" NEMOCLAW_CANDIDATE_VERSION: "0.0.116" NEMOCLAW_OPENSHELL_PIN_VERSION: "0.0.116" - DOCKER_GRPC_PROBE_IMAGE: "node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c" + DOCKER_GRPC_PROBE_IMAGE: "node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -3281,7 +3309,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -3357,7 +3385,7 @@ jobs: persist-credentials: false - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -3655,7 +3683,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -3812,7 +3840,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -3931,13 +3959,19 @@ jobs: path: .trusted-openshell-dev-artifact persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json + scripts/lib/reviewed-npm-audit.mts scripts/install-openshell.sh tools/e2e/openshell-dev-artifact.mts - name: Set up Node for OpenShell dev artifact resolution uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: 24.18.1 + + - name: Install reviewed npm + uses: ./.trusted-openshell-dev-artifact/.github/actions/setup-reviewed-npm - id: resolve_openshell_dev_artifact name: Resolve immutable OpenShell dev artifact @@ -3998,7 +4032,7 @@ jobs: - name: Set up Node.js for trusted OpenShell verification uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: 24.18.1 package-manager-cache: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -4010,6 +4044,9 @@ jobs: - *dockerhub-auth + - name: Install reviewed npm for trusted OpenShell verification + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 + - name: Checkout trusted OpenShell dev tooling uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -4084,7 +4121,7 @@ jobs: # it after the trusted installer has consumed the verified OpenShell # assets and Docker credentials have been revoked. - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -4305,7 +4342,7 @@ jobs: http = true - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -4749,7 +4786,7 @@ jobs: http = true - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c - id: runtime-contract name: Validate protected runtime activation contract @@ -5038,7 +5075,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -5211,7 +5248,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -5228,7 +5265,10 @@ jobs: ENV: /dev/null NODE_OPTIONS: "" with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" + + - name: Reinstall reviewed npm after Node reassertion + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Recover Docker CLI before native Podman E2E if: ${{ matrix.runtime_provider == 'podman' }} @@ -5412,7 +5452,10 @@ jobs: - name: Set up Node for Jetson controller uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: 24.18.1 + + - name: Install reviewed npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Dispatch exact commit to Jetson through operator backend env: @@ -5493,7 +5536,7 @@ jobs: packages: expect - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -5636,7 +5679,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -5727,14 +5770,14 @@ jobs: shell: bash run: | set -euo pipefail - docker pull node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c + docker pull node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc - name: Remove Docker auth before current-checkout fixture if: always() uses: NVIDIA/NemoClaw/.github/actions/docker-auth-cleanup@d5f37099766ca82a4516e7d8f0de117cda197fe3 - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" diff --git a/.github/workflows/hosted-runner-recovery.yaml b/.github/workflows/hosted-runner-recovery.yaml index da568b44c14..608dc3fa84c 100644 --- a/.github/workflows/hosted-runner-recovery.yaml +++ b/.github/workflows/hosted-runner-recovery.yaml @@ -47,7 +47,10 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm - name: Evaluate exact hosted-runner-loss evidence env: diff --git a/.github/workflows/installer-hash-check.yaml b/.github/workflows/installer-hash-check.yaml index 5a0315bab90..2c0bf1e3c2f 100644 --- a/.github/workflows/installer-hash-check.yaml +++ b/.github/workflows/installer-hash-check.yaml @@ -36,11 +36,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Set up trusted installer hash parser runtime - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22.19.0 - # The checkout of the latest PR commit supplies data only. The checker and # pin parser execute from the checkout of the PR base SHA below. - name: Checkout pull request head @@ -70,6 +65,19 @@ jobs: scripts/checks/extract-installer-pins.mts sparse-checkout-cone-mode: false + - name: Set up trusted installer hash parser runtime + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.1 + + - name: Install immutable reviewed npm + if: github.event_name == 'pull_request' + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 + + - name: Install trusted-event reviewed npm + if: github.event_name != 'pull_request' + uses: ./.github/actions/setup-reviewed-npm + - name: Verify pull request installer hashes from base-trusted code if: github.event_name == 'pull_request' uses: ./.trusted-installer-hash/.github/actions/ci-installer-hash-check diff --git a/.github/workflows/llama-cpp-image.yaml b/.github/workflows/llama-cpp-image.yaml index c3abab48b88..10debda0fee 100644 --- a/.github/workflows/llama-cpp-image.yaml +++ b/.github/workflows/llama-cpp-image.yaml @@ -79,7 +79,10 @@ jobs: - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.19.0 + node-version: 24.18.1 + + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm - name: Install manifest compiler dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 847cad3c9ca..45e2a63585f 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -84,12 +84,15 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + node-version: "24.18.1" cache: npm cache-dependency-path: | package-lock.json nemoclaw/package-lock.json + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Install dependencies env: NODE_AUTH_TOKEN: ${{ github.token }} @@ -148,9 +151,12 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + node-version: "24.18.1" cache: npm + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Install test dependencies run: npm ci --ignore-scripts --no-audit --no-fund diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 31a55098442..5109628e4aa 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -32,6 +32,7 @@ on: pull_request: paths: - ".github/actions/ci-reviewed-npm-audit/**" + - ".github/actions/setup-reviewed-npm/**" - ".github/workflows/base-image.yaml" - ".github/actions/publish-managed-image-digest/**" - ".github/workflows/managed-images.yaml" @@ -93,13 +94,14 @@ jobs: - name: Checkout npm audit code from the base commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.pull_request.base.sha }} + ref: 98669f24d35f18e49b6b2769cd68709509ea24f2 path: .trusted-reviewed-npm-audit persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json .github/actions/ci-reviewed-npm-audit ci/npm-audit-exceptions.json - ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts scripts/lib/npm-audit-receipt.mts scripts/lib/repository-input-path.mts @@ -111,13 +113,13 @@ jobs: - name: Verify exact audit source and target shell: bash env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} CANDIDATE_SHA: ${{ github.event.pull_request.head.sha }} + REVIEWED_AUDIT_SHA: 98669f24d35f18e49b6b2769cd68709509ea24f2 run: | set -euo pipefail - [[ "$BASE_SHA" =~ ^[a-f0-9]{40}$ ]] [[ "$CANDIDATE_SHA" =~ ^[a-f0-9]{40}$ ]] - test "$(git -C .trusted-reviewed-npm-audit rev-parse --verify HEAD)" = "$BASE_SHA" + [[ "$REVIEWED_AUDIT_SHA" =~ ^[a-f0-9]{40}$ ]] + test "$(git -C .trusted-reviewed-npm-audit rev-parse --verify HEAD)" = "$REVIEWED_AUDIT_SHA" test "$(git -C candidate rev-parse --verify HEAD)" = "$CANDIDATE_SHA" - name: Audit exact PR production npm graphs @@ -163,7 +165,10 @@ jobs: - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.19.0 + node-version: 24.18.1 + + - name: Install reviewed npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Validate staging QA source shell: bash @@ -205,7 +210,14 @@ jobs: install -m 0644 "$source_file" "$destination_file" done for support_relative in \ + ci/reviewed-npm-audit.json \ scripts/lib/bundled-npm-package.mts \ + scripts/lib/patch-bundled-npm-ip-address.mts \ + scripts/lib/reviewed-npm-archive.mts \ + scripts/lib/reviewed-npm-identity.mts \ + scripts/patch-bundled-npm-brace-expansion.mts \ + scripts/patch-bundled-npm-tar.mts \ + scripts/upgrade-bundled-npm.mts \ scripts/security/patches/perl-5.44.0-net-ping-capability-tests.patch do support_source="$candidate_root/$support_relative" @@ -523,7 +535,7 @@ jobs: if: matrix.agent == 'openclaw' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.pull_request.base.sha }} + ref: 98669f24d35f18e49b6b2769cd68709509ea24f2 path: .trusted-mcporter-audit persist-credentials: false sparse-checkout: | @@ -536,7 +548,10 @@ jobs: - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.19.0 + node-version: 24.18.1 + + - name: Install reviewed npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Prepare same-run mcporter audit evidence if: matrix.agent == 'openclaw' @@ -548,7 +563,7 @@ jobs: raw="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.raw.json" policy="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.policy.json" trusted_root="$GITHUB_WORKSPACE/.trusted-mcporter-audit" - test "$(git -C "$trusted_root" rev-parse --verify HEAD)" = '${{ github.event.pull_request.base.sha }}' + test "$(git -C "$trusted_root" rev-parse --verify HEAD)" = '98669f24d35f18e49b6b2769cd68709509ea24f2' node --experimental-strip-types --no-warnings \ "$trusted_root/scripts/lib/npm-audit-receipt.mts" \ --receipt "$receipt" \ @@ -560,7 +575,6 @@ jobs: --audit-config "$trusted_root/ci/reviewed-npm-audit.json" \ --registry https://registry.yarnpkg.com \ --threshold high \ - --legacy-audit true \ --result "$policy" test -f "$receipt"; test -f "$raw"; test -s "$policy"; test ! -L "$policy" printf 'receipt=%s\nraw=%s\npolicy=%s\nreceipt_sha256=%s\npolicy_sha256=%s\n' "$receipt" "$raw" "$policy" "$(sha256sum "$receipt" | cut -d' ' -f1)" "$(sha256sum "$policy" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" @@ -1061,7 +1075,10 @@ jobs: - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.19.0 + node-version: 24.18.1 + + - name: Install reviewed npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Bind E2E correlation identity shell: bash @@ -1163,7 +1180,10 @@ jobs: - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.19.0 + node-version: 24.18.1 + + - name: Install reviewed npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Bind E2E correlation identity shell: bash @@ -1293,10 +1313,18 @@ jobs: - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.19.0 + node-version: 24.18.1 # Pi has no published base alias yet, so the candidate lane builds its own # base on the target architecture. The activation change adds the shared # published-base contract with the rest of the release cohort. + - name: Install reviewed npm + if: github.event_name == 'pull_request' + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 + + - name: Install trusted-event reviewed npm + if: github.event_name != 'pull_request' + uses: ./.github/actions/setup-reviewed-npm + - name: Build the exact Pi candidate base id: base shell: bash @@ -1779,6 +1807,14 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.1 + + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Resolve managed image release identity id: release shell: bash diff --git a/.github/workflows/native-runtime-qualification-collector.yaml b/.github/workflows/native-runtime-qualification-collector.yaml index de02ae4b8e4..a8545a0e7be 100644 --- a/.github/workflows/native-runtime-qualification-collector.yaml +++ b/.github/workflows/native-runtime-qualification-collector.yaml @@ -62,6 +62,9 @@ jobs: path: trusted persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json + scripts/lib/reviewed-npm-audit.mts scripts/lib/read-artifact-zip.mts src/lib/onboard/runtime-provider/native-qualification-authority.ts test/e2e/registry/native-runtime-qualification.ts @@ -71,7 +74,10 @@ jobs: - name: Set up pinned Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.23.1 + node-version: 24.18.1 + + - name: Install reviewed npm + uses: ./trusted/.github/actions/setup-reviewed-npm - name: Authenticate and consume protected qualification evidence id: collect diff --git a/.github/workflows/openshell-sdk-package-pr.yaml b/.github/workflows/openshell-sdk-package-pr.yaml index 3fc73dc8b66..58ab09d3ad2 100644 --- a/.github/workflows/openshell-sdk-package-pr.yaml +++ b/.github/workflows/openshell-sdk-package-pr.yaml @@ -33,6 +33,7 @@ jobs: ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts scripts/checks/package-openshell-sdk-for-pr.mts @@ -46,10 +47,13 @@ jobs: - name: Setup Node.js for reviewed package download uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" registry-url: https://npm.pkg.github.com scope: "@nvidia" + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Download and verify exact OpenShell SDK package id: package env: diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 982bf26a462..5fc2d997055 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -44,9 +44,12 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + node-version: "24.18.1" cache: npm + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Verify Ubuntu 26.04 userspace shell: bash run: | @@ -107,9 +110,12 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + node-version: "24.18.1" cache: npm + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Setup Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -291,7 +297,7 @@ jobs: ) Install-WslUbuntuDependencies -Distro $env:WSL_DISTRO -Packages $packages -TestUser $env:WSL_TEST_USER - - name: Install Node.js 22 in WSL + - name: Install reviewed Node.js and npm in WSL shell: powershell run: | . "$env:TRUSTED_WSL_HELPER" diff --git a/.github/workflows/podman-cpu-proof.yaml b/.github/workflows/podman-cpu-proof.yaml index 4c49cc41137..e3b8a240f05 100644 --- a/.github/workflows/podman-cpu-proof.yaml +++ b/.github/workflows/podman-cpu-proof.yaml @@ -69,9 +69,12 @@ jobs: - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.19.0 + node-version: 24.18.1 cache: npm + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Install locked test dependencies run: npm ci --ignore-scripts @@ -136,9 +139,12 @@ jobs: - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.19.0 + node-version: 24.18.1 cache: npm + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Install locked test dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/portable-profile-e2e.yaml b/.github/workflows/portable-profile-e2e.yaml index 54c7c7df5d5..62d2e2be217 100644 --- a/.github/workflows/portable-profile-e2e.yaml +++ b/.github/workflows/portable-profile-e2e.yaml @@ -149,9 +149,12 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" cache: npm + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Install root dependencies if: github.event_name != 'workflow_dispatch' run: npm ci --ignore-scripts --no-audit --no-fund @@ -281,7 +284,7 @@ jobs: persist-credentials: false - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c - name: Provision restricted rootless Linux runtime shell: bash diff --git a/.github/workflows/post-merge-docs.yaml b/.github/workflows/post-merge-docs.yaml index 416265354dc..e97b409f526 100644 --- a/.github/workflows/post-merge-docs.yaml +++ b/.github/workflows/post-merge-docs.yaml @@ -71,7 +71,10 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./trusted/.github/actions/setup-reviewed-npm - name: Find the documentation range working-directory: trusted @@ -169,7 +172,10 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./trusted/.github/actions/setup-reviewed-npm - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/pr-merge-conflict-fixer.yaml b/.github/workflows/pr-merge-conflict-fixer.yaml index fe45fa28415..f34562e4da8 100644 --- a/.github/workflows/pr-merge-conflict-fixer.yaml +++ b/.github/workflows/pr-merge-conflict-fixer.yaml @@ -33,7 +33,10 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm - id: scan name: Reproduce PR merges @@ -72,7 +75,10 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./trusted/.github/actions/setup-reviewed-npm - id: prepare name: Reproduce the recorded conflict @@ -144,7 +150,10 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./trusted/.github/actions/setup-reviewed-npm - id: download name: Download the resolution patch diff --git a/.github/workflows/pr-review-advisor.yaml b/.github/workflows/pr-review-advisor.yaml index 15f66949035..42c157c1931 100644 --- a/.github/workflows/pr-review-advisor.yaml +++ b/.github/workflows/pr-review-advisor.yaml @@ -167,9 +167,12 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" cache: npm cache-dependency-path: advisor/package-lock.json + - name: Install reviewed npm + uses: ./advisor/.github/actions/setup-reviewed-npm + - name: Install locked runtime run: | set -euo pipefail @@ -272,10 +275,13 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" cache: npm cache-dependency-path: advisor/package-lock.json + - name: Install reviewed npm + uses: ./advisor/.github/actions/setup-reviewed-npm + - name: Load advisor runtime image run: node --no-warnings "$ADVISOR_DIR/tools/pr-review-advisor/export-runtime-env.mts" @@ -403,7 +409,10 @@ jobs: - name: Setup Node for trusted publisher uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./advisor/.github/actions/setup-reviewed-npm - name: Post PR review advisor link env: diff --git a/.github/workflows/pr-self-hosted.yaml b/.github/workflows/pr-self-hosted.yaml index cc366fac328..a849c21a717 100644 --- a/.github/workflows/pr-self-hosted.yaml +++ b/.github/workflows/pr-self-hosted.yaml @@ -107,7 +107,11 @@ jobs: if: ${{ steps.changed.outputs.selected == 'true' }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: 24.18.1 + + - name: Install reviewed npm + if: ${{ steps.changed.outputs.selected == 'true' }} + uses: ./.github/actions/setup-reviewed-npm - name: Install publication verifier dependencies if: ${{ steps.changed.outputs.selected == 'true' }} @@ -160,7 +164,7 @@ jobs: ref: ${{ github.sha }} - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c - name: Bind E2E correlation identity shell: bash @@ -271,9 +275,12 @@ jobs: - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.19.0 + node-version: 24.18.1 cache: npm + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Install root dependencies run: npm ci --ignore-scripts --no-audit --no-fund diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 2b1401ccf95..f2f35f2f118 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -63,9 +63,12 @@ jobs: - name: Setup Node.js for docs-only checks uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" cache: npm + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Install docs-only check dependencies run: npm install --ignore-scripts --no-audit --no-fund @@ -159,9 +162,12 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" cache: npm + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Install dependencies run: npm ci --ignore-scripts --no-audit --no-fund @@ -479,14 +485,19 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ">=22.19.0 <23" + node-version: "24.18.1" cache: npm cache-dependency-path: | package-lock.json nemoclaw/package-lock.json + - name: Install reviewed npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 + - name: Install dependencies run: bash .trusted-ci-actions/.github/actions/ci-install-dependencies.sh + env: + NPM_CONFIG_ALLOW_REMOTE: root - name: Run package-contract and type checks uses: ./.trusted-ci-actions/.github/actions/ci-build-typecheck @@ -552,6 +563,7 @@ jobs: persist-credentials: false sparse-checkout: | .github/actions/ci-reviewed-npm-audit + .github/actions/setup-reviewed-npm ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts @@ -563,7 +575,7 @@ jobs: sparse-checkout-cone-mode: false - name: Audit reviewed production npm graphs - uses: ./.trusted-reviewed-npm-audit/.github/actions/ci-reviewed-npm-audit + uses: NVIDIA/NemoClaw/.github/actions/ci-reviewed-npm-audit@98669f24d35f18e49b6b2769cd68709509ea24f2 with: target-root: ${{ github.workspace }} report-dir: artifacts/reviewed-npm-audit diff --git a/.github/workflows/release-latest-tag.yaml b/.github/workflows/release-latest-tag.yaml index 031b2161514..b408454d91a 100644 --- a/.github/workflows/release-latest-tag.yaml +++ b/.github/workflows/release-latest-tag.yaml @@ -96,7 +96,10 @@ jobs: - name: Set up Node for release-label retirement uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.19.0 + node-version: 24.18.1 + + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm - name: Retire the released target label env: diff --git a/.github/workflows/sandbox-images.yaml b/.github/workflows/sandbox-images.yaml index 0ebcca51a92..bbb4fc5a34e 100644 --- a/.github/workflows/sandbox-images.yaml +++ b/.github/workflows/sandbox-images.yaml @@ -255,9 +255,12 @@ jobs: name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.19.0 + node-version: 24.18.1 cache: npm + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - &install-root-dependencies name: Install root dependencies run: npm ci --ignore-scripts && npm run build:policy-boundary @@ -340,6 +343,9 @@ jobs: - *setup-node + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Resolve sandbox base image uses: ./.github/actions/resolve-sandbox-base-image @@ -446,6 +452,9 @@ jobs: - *setup-node + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - *install-root-dependencies - &download-isolation-image @@ -496,10 +505,14 @@ jobs: - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.19.0 + node-version: "24.18.1" cache: npm - - *install-root-dependencies + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + + - name: Install root dependencies + run: npm ci --ignore-scripts --no-audit --no-fund && npm run build:policy-boundary - *download-isolation-image diff --git a/.github/workflows/staging-launchable-full.yaml b/.github/workflows/staging-launchable-full.yaml index a7bf52425fa..d203f40dbfa 100644 --- a/.github/workflows/staging-launchable-full.yaml +++ b/.github/workflows/staging-launchable-full.yaml @@ -27,6 +27,9 @@ jobs: ref: ${{ github.workflow_sha }} persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json + scripts/lib/reviewed-npm-audit.mts ci/cli-test-timing-hints.json nemoclaw/vitest.project.ts src/ @@ -60,9 +63,12 @@ jobs: timeout-minutes: 10 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + node-version: "24.18.1" cache: npm + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Install dependencies timeout-minutes: 10 run: npm ci --ignore-scripts --no-audit --no-fund diff --git a/Dockerfile b/Dockerfile index a7ba59a3cfb..5336b61bf14 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,8 @@ -# NemoClaw sandbox image — OpenClaw + NemoClaw plugin inside OpenShell -# -# Layers PR-specific code (plugin, blueprint, config, startup script) on top -# of the pre-built base image from GHCR. The base image contains all the -# expensive, rarely-changing layers (apt, setpriv, users, openclaw CLI). -# -# For local builds without GHCR access, build the base first: +# NemoClaw sandbox image layers PR-specific code on the pre-built OpenClaw and OpenShell base. +# Build the base first when GHCR is unavailable: # docker build -f Dockerfile.base -t ghcr.io/nvidia/nemoclaw/sandbox-base:latest . -# Global ARG — must be declared before the first FROM to be visible -# to all FROM directives. Can be overridden via --build-arg. +# Global ARG values precede FROM so every stage can use them. ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest ARG NEMOCLAW_CORPORATE_CA_B64= ARG NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=0 @@ -17,8 +11,24 @@ ARG CODEX_ACP_0_11_1_INTEGRITY=sha512-My2VSlBtvJipJhImHjFDej2ut/p00QqOISRnZgLgLr ARG CODEX_ACP_LINUX_AMD64_0_11_1_INTEGRITY=sha512-30vSoZuW1DP6Nuz24Gg3jgVC37IYe0bZ/Fgc5+372gc0h72NN4zHYAbu5bRd/gUJ9GdwABKrrEPCoFPlOTVTnQ== ARG CODEX_ACP_LINUX_ARM64_0_11_1_INTEGRITY=sha512-I1f6WoSLbLlsWq4zH+vtwdoc4Y41mqRXPpSkfgIifxBw34QmWJmi37etZ7lKTYp6R+J/Z4PUN0rsmnsmKpBZTw== -# Stage 1: Build TypeScript plugin from source -FROM node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c AS builder +FROM scratch AS reviewed-npm-archive +ADD --chmod=0444 --checksum=sha256:5dbb86c71d07a1957f2e90734092dd6a58bdcd9ebc2d8d41ca1c6e6a21d364e1 https://registry.npmjs.org/npm/-/npm-12.0.2.tgz /npm-12.0.2.tgz +FROM node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc AS npm12 +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20250419 curl=8.14.1-2+deb13u5 && rm -rf /var/lib/apt/lists/* +COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/bundled-npm-package.mts scripts/lib/patch-bundled-npm-ip-address.mts scripts/lib/reviewed-npm-identity.mts /scripts/lib/ +COPY scripts/patch-bundled-npm-brace-expansion.mts scripts/patch-bundled-npm-tar.mts scripts/upgrade-bundled-npm.mts /scripts/ +COPY ci/reviewed-npm-audit.json /ci/reviewed-npm-audit.json +COPY --from=reviewed-npm-archive /npm-12.0.2.tgz /tmp/npm-12.0.2.tgz +RUN node /scripts/upgrade-bundled-npm.mts --npm-root /usr/local/lib/node_modules/npm --archive /tmp/npm-12.0.2.tgz +# hadolint ignore=DL3059 +RUN rm /tmp/npm-12.0.2.tgz +# hadolint ignore=DL3059 +RUN node /scripts/patch-bundled-npm-tar.mts --npm-root /usr/local/lib/node_modules/npm +# hadolint ignore=DL3059 +RUN node /scripts/patch-bundled-npm-brace-expansion.mts --npm-root /usr/local/lib/node_modules/npm +# hadolint ignore=DL3059 +RUN node /scripts/lib/patch-bundled-npm-ip-address.mts --npm-root /usr/local/lib/node_modules/npm +FROM npm12 AS builder ENV NPM_CONFIG_AUDIT=false \ NPM_CONFIG_FUND=false \ NPM_CONFIG_UPDATE_NOTIFIER=false \ @@ -40,8 +50,6 @@ RUN npm run build \ && node \ /opt/nemoclaw-build-checks/verify-openshell-policy-boundary-dependencies.mts \ /opt/nemoclaw/dist/shared/openshell-policy-boundary.cjs - -# Stage 2: Build TypeScript messaging runtime preloads. FROM builder AS runtime-preload-builder WORKDIR /opt/nemoclaw-root COPY tsconfig.runtime-preloads.json /opt/nemoclaw-root/ @@ -49,9 +57,7 @@ COPY src/lib/messaging/channels/ /opt/nemoclaw-root/src/lib/messaging/channels/ RUN ln -s /opt/nemoclaw/node_modules /opt/nemoclaw-root/node_modules \ && /opt/nemoclaw/node_modules/.bin/tsc -p tsconfig.runtime-preloads.json -# Copy the reviewed, CI-audited runtime artifacts without materializing an npm -# graph during image assembly. Protected rebuilds remain network-free and the -# final image still receives only the generated bundles. +# Copy reviewed generated runtime bundles without materializing an npm graph. FROM scratch AS mcp-tool-discovery-runtime COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/BUNDLED_PACKAGES.json tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/THIRD_PARTY_LICENSES.txt /opt/mcp-tool-discovery-runtime/dist/ COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/mcp-tool-discovery.bundle /opt/mcp-tool-discovery-runtime/dist/mcp-tool-discovery.mjs @@ -59,10 +65,9 @@ COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery FROM scratch AS managed-startup-runtime-builder COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle /out/managed-startup-image-runtime.cjs -# Compile the bootstrap boundary on the target platform. The output is a -# freestanding static ELF; only its reviewed, non-executable Bash body remains -# interpreted at runtime after the native boundary has scrubbed process control. -FROM node:22-trixie@sha256:a566dd560283ae5615c8bb86b58fa8a1b6f3c82b492473a061672416266625da AS managed-bootstrap-entrypoint-builder +# Compile the target-platform bootstrap as a freestanding static ELF. +# Its reviewed Bash body runs only after the native boundary scrubs process control. +FROM node:24.18.1-trixie@sha256:dfa43abae25030f5456007944f725379d1f5be4bb723bd501ac39ac72ffa5474 AS managed-bootstrap-entrypoint-builder ARG TARGETARCH WORKDIR /opt/nemoclaw-managed-bootstrap-build COPY scripts/managed-bootstrap-entrypoint.c ./ @@ -139,7 +144,7 @@ FROM codex-acp-${TARGETARCH}-archive AS codex-acp-platform-archive # Reviewed-archive invariants (#5896): checksum-addressed source archives, # committed SRI verification, offline installation, and architecture selection. -FROM node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c AS codex-acp-runtime +FROM npm12 AS codex-acp-runtime ARG TARGETARCH ARG CODEX_ACP_0_11_1_INTEGRITY ARG CODEX_ACP_LINUX_AMD64_0_11_1_INTEGRITY @@ -162,13 +167,13 @@ RUN --network=none set -eu; \ rm -rf /tmp/codex-acp; \ command -v codex-acp >/dev/null -FROM node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c AS wechat-npm-cache +FROM npm12 AS wechat-npm-cache COPY agents/openclaw/wechat-runtime/package.json agents/openclaw/wechat-runtime/package-lock.json /opt/wechat-runtime/ -COPY scripts/checks/materialize-locked-npm-cache-seed.mts /opt/checks/ -COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/seed-reviewed-npm-cache.mts /opt/nemoclaw-build-tools/ +COPY scripts/checks/materialize-locked-npm-cache-seed.mts /scripts/checks/materialize-locked-npm-cache-seed.mts +COPY scripts/lib/seed-reviewed-npm-cache.mts /scripts/lib/seed-reviewed-npm-cache.mts COPY --from=wechat-npm-archives / /opt/wechat-npm-archives/ RUN --network=none install -d -o root -g root -m 0755 /out/wechat-npm-cache \ - && node /opt/nemoclaw-build-tools/seed-reviewed-npm-cache.mts \ + && node /scripts/lib/seed-reviewed-npm-cache.mts \ --lockfile /opt/wechat-runtime/package-lock.json \ --cache /out/wechat-npm-cache \ --registry-origin https://registry.npmjs.org/ \ @@ -180,7 +185,7 @@ RUN --network=none install -d -o root -g root -m 0755 /out/wechat-npm-cache \ --userconfig /dev/null --registry https://registry.npmjs.org/ \ --cache /out/wechat-npm-cache \ && NPM_CONFIG_OFFLINE=true \ - node /opt/nemoclaw-build-tools/reviewed-npm-archive.mts \ + node /scripts/lib/reviewed-npm-archive.mts \ --lockfile /opt/wechat-runtime/package-lock.json \ --cache /out/wechat-npm-cache \ --registry-origin https://registry.npmjs.org/ \ @@ -267,7 +272,7 @@ ADD --chmod=0444 --checksum=sha256:1773a16c02b4422653479b9c4d211268f7022bdac0d81 ADD --chmod=0444 --checksum=sha256:1d91d0b0faa50cba223fa937c7b5a4a662968b1d78b3e59dca5c917dd5cf72b2 https://registry.npmjs.org/extend/-/extend-3.0.2.tgz /extend-3.0.2.tgz ADD --chmod=0444 --checksum=sha256:54481d9c62debce1c38b0239f2358eeb3b73f7bb1ba3105bd6123fd81b8b7268 https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz /fetch-1.1.1.tgz ADD --chmod=0444 --checksum=sha256:4abf0d58a4977fce2240e08c280a2bc59f5363e9553a4f236cea6d74cce40c52 https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz /fetch-blob-3.2.0.tgz -ADD --chmod=0444 --checksum=sha256:87fdc6557e71ec47373edfbde774165e976c760845d02733f81fbfe1ad232780 https://registry.npmjs.org/file-type/-/file-type-22.0.1.tgz /file-type-22.0.1.tgz +ADD --chmod=0444 --checksum=sha256:1fbe3e298dfbfa1854600b7891c237a1d2fccaa644f1945919516480206002c9 https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz /file-type-21.3.4.tgz ADD --chmod=0444 --checksum=sha256:22949bfc51a620b3598bbe67d65619a9efd781d52704a38d7ba675e248a8b872 https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz /finalhandler-2.1.1.tgz ADD --chmod=0444 --checksum=sha256:ca8b00245b783f6f6f85e55b6df3d51be88ad74c4881a8ebf8e0796231352c5f https://registry.npmjs.org/@wasm-audio-decoders/flac/-/flac-0.2.10.tgz /flac-0.2.10.tgz ADD --chmod=0444 --checksum=sha256:20b3d612d53281b754602d52a8e6a6e09032169d5399e515f6f5e8b7d3de712d https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz /float-1.0.2.tgz @@ -404,7 +409,7 @@ ADD --chmod=0444 --checksum=sha256:66de2a025036de58bbe50ab1d42a24ec6d33eda338b81 ADD --chmod=0444 --checksum=sha256:2aa86d462e4bcec95cca91c935002e102644e1bb2fcd36a9b52e4f4555c73f96 https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz /real-require-0.2.0.tgz ADD --chmod=0444 --checksum=sha256:d66004def64efb6c13629b0740639344e0775fe537373674571711a2bb2fc704 https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz /redact-0.4.0.tgz ADD --chmod=0444 --checksum=sha256:cad52ea77001223648829bfa3c4e677d30939928b12ed3566148bf2b7e1df18f https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz /reflect-metadata-0.2.2.tgz -ADD --chmod=0444 --checksum=sha256:062c4d9d2b7ba41a3869cf55903d0b5d439d838e241b6b0f42d53aa22d3debd8 https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz /retry-0.12.5.tgz +ADD --chmod=0444 --checksum=sha256:7c97db75aba1e8cb911b9ff349ddeae6153fd3b11fa3f3b772c1dd474ea9f8c8 https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz /retry-0.12.0.tgz ADD --chmod=0444 --checksum=sha256:7521d8445e845475e888ccb7af473c4afb17aabafefe35a23371a8a8c79b8084 https://registry.npmjs.org/retry/-/retry-0.13.1.tgz /retry-0.13.1.tgz ADD --chmod=0444 --checksum=sha256:b144af37b39a9517f7a89f1d867e9c2cf29f13f4147d3e80c499fe6ffab69461 https://registry.npmjs.org/router/-/router-2.2.0.tgz /router-2.2.0.tgz ADD --chmod=0444 --checksum=sha256:d29ace7117aaa0d6b119027e9a157c238e6899bbb35d03f508ae8d4fa9ca8c9d https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz /run-applescript-7.1.0.tgz @@ -487,17 +492,17 @@ FROM openclaw-managed-messaging-npm-${TARGETARCH}-archives AS openclaw-managed-m # Keep the complete managed-image messaging dependency graph inert for normal # Dockerfile builds. Release-image builds select the lock cache stage. -FROM node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c AS openclaw-managed-messaging-npm-cache-0 +FROM node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc AS openclaw-managed-messaging-npm-cache-0 RUN install -d -o root -g root -m 0755 /out/npm-cache -FROM node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c AS openclaw-managed-messaging-npm-cache-1 +FROM npm12 AS openclaw-managed-messaging-npm-cache-1 ARG TARGETARCH ENV NPM_CONFIG_AUDIT=false \ NPM_CONFIG_FUND=false \ NPM_CONFIG_UPDATE_NOTIFIER=false COPY agents/openclaw/managed-image-messaging-runtime/package.json agents/openclaw/managed-image-messaging-runtime/package-lock.json /opt/managed-image-messaging-runtime/ -COPY scripts/checks/materialize-locked-npm-cache-seed.mts /opt/nemoclaw-build-tools/checks/ -COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/seed-reviewed-npm-cache.mts /opt/nemoclaw-build-tools/lib/ +COPY scripts/checks/materialize-locked-npm-cache-seed.mts /scripts/checks/materialize-locked-npm-cache-seed.mts +COPY scripts/lib/seed-reviewed-npm-cache.mts /scripts/lib/seed-reviewed-npm-cache.mts COPY --from=openclaw-managed-messaging-npm-archives / /opt/nemoclaw-build-tools/npm-cache-seed/ RUN --network=none set -eu; \ case "$TARGETARCH" in \ @@ -506,7 +511,7 @@ RUN --network=none set -eu; \ *) echo "ERROR: unsupported managed messaging npm target: $TARGETARCH" >&2; exit 1 ;; \ esac; \ install -d -o root -g root -m 0755 /out/npm-cache; \ - node /opt/nemoclaw-build-tools/lib/seed-reviewed-npm-cache.mts \ + node /scripts/lib/seed-reviewed-npm-cache.mts \ --lockfile /opt/managed-image-messaging-runtime/package-lock.json \ --cache /out/npm-cache \ --registry-origin https://registry.npmjs.org/ \ @@ -516,12 +521,12 @@ RUN --network=none set -eu; \ --ignore-scripts --omit=dev --legacy-peer-deps \ --userconfig /dev/null --registry https://registry.npmjs.org/ \ --cache /out/npm-cache; \ - node /opt/nemoclaw-build-tools/lib/seed-reviewed-npm-cache.mts \ + npm cache verify --cache /out/npm-cache; \ + node /scripts/lib/seed-reviewed-npm-cache.mts \ --packuments-only \ --lockfile /opt/managed-image-messaging-runtime/package-lock.json \ --cache /out/npm-cache \ --registry-origin https://registry.npmjs.org/; \ - npm cache verify --cache /out/npm-cache; \ rm -rf /opt/managed-image-messaging-runtime/node_modules \ /opt/nemoclaw-build-tools/npm-cache-seed; \ chown -R root:root /out/npm-cache; \ @@ -541,14 +546,10 @@ COPY agents/openclaw/mcporter-runtime/package-lock.json /usr/local/lib/nemoclaw/ COPY agents/openclaw/wechat-runtime/package.json /usr/local/lib/nemoclaw/wechat-runtime/package.json COPY agents/openclaw/wechat-runtime/package-lock.json /usr/local/lib/nemoclaw/wechat-runtime/package-lock.json COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json /scripts/ -COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts -COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts -COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts -COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts +COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/bundled-npm-package.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/patch-bundled-npm-ip-address.mts scripts/lib/reviewed-npm-identity.mts /scripts/lib/ COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh -COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts -COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts -COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts +COPY scripts/patch-bundled-npm-brace-expansion.mts scripts/patch-bundled-npm-tar.mts scripts/upgrade-bundled-npm.mts /scripts/ +COPY ci/reviewed-npm-audit.json /ci/reviewed-npm-audit.json FROM scratch AS openclaw-plugin-payload @@ -559,6 +560,7 @@ COPY nemoclaw-blueprint/ /opt/nemoclaw-blueprint/ FROM scratch AS openclaw-patch-payload COPY scripts/patch-openclaw-tool-catalog.mts /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.mts +COPY scripts/lib/patch-openclaw-npm12-pack-json.mts /usr/local/lib/nemoclaw/npm12.mts COPY scripts/patch-openclaw-chat-send.mts /usr/local/lib/nemoclaw/patch-openclaw-chat-send.mts COPY scripts/patch-openclaw-mcp-npx.mts /usr/local/lib/nemoclaw/patch-openclaw-mcp-npx.mts COPY scripts/patch-openclaw-mcp-reliability.mts /usr/local/lib/nemoclaw/patch-openclaw-mcp-reliability.mts @@ -636,26 +638,22 @@ ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3. ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256= ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256= -# A cross-stage root copy is accepted by Docker's legacy builder and creates one -# final-image layer while preserving metadata on existing parent directories. +# Preserve existing parent metadata while creating one final-image layer. COPY --from=openclaw-dependency-payload / / +COPY --from=reviewed-npm-archive /npm-12.0.2.tgz /tmp/npm-12.0.2.tgz +# Standardize a lagging published base from immutable SHA-256- and SRI-bound bytes. +RUN node /scripts/upgrade-bundled-npm.mts --npm-root /usr/local/lib/node_modules/npm --archive /tmp/npm-12.0.2.tgz +# hadolint ignore=DL3059 +RUN rm /tmp/npm-12.0.2.tgz -# OpenClaw 2026.7.1 loads some generated source through jiti. Disable its -# filesystem transform cache so source fragments that mention provider marker -# names do not persist under /tmp/jiti inside the sandbox. +# Keep jiti-generated provider fragments out of the sandbox's /tmp. ENV JITI_FS_CACHE=false -# Base64-encoded host corporate-proxy CA bundle (#6210). Empty by default. When -# onboard detects an operator-supplied corporate CA on the host it bakes it -# here; the RUN below decodes it to a root-owned file that the entrypoint -# appends to the OpenShell trust bundle at runtime. The CA is a public -# certificate, not a secret, so baking it into an image layer is acceptable. +# Onboard can bake this public corporate CA into the OpenShell trust bundle (#6210). +# It is empty by default and is not a secret. ARG NEMOCLAW_CORPORATE_CA_B64 -# Decode the host corporate-proxy CA (#6210) to a root-owned, read-only file -# when onboard baked one in. No-op when NEMOCLAW_CORPORATE_CA_B64 is empty. The -# ARG is expanded by the shell (not interpolated into source), and its value is -# base64 sanitized host-side, so this is not an injection vector. +# Decode a supplied, host-sanitized CA to a root-owned, read-only file. # hadolint ignore=DL3059,DL4006 RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ command -v base64 >/dev/null 2>&1 || { echo "[nemoclaw] base64 is required to decode NEMOCLAW_CORPORATE_CA_B64 but is not installed in the build image" >&2; exit 1; }; \ @@ -676,9 +674,7 @@ RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ && echo "[nemoclaw] baked host corporate-proxy CA into image trust (#6210)"; \ fi -# Use the corporate CA for build-time Node TLS only when onboarding supplied -# it. The runtime entrypoint builds its own merged OpenShell and corporate -# bundle. +# The runtime entrypoint builds its own merged OpenShell and corporate CA bundle. # The final image owns the shipped dependency boundary independently of base # freshness. Reassert the idempotent npm-private fixes after corporate CA setup @@ -795,6 +791,7 @@ COPY --from=wechat-npm-cache /out/wechat-npm-cache/ /usr/local/share/nemoclaw/we COPY --from=openclaw-patch-payload / / RUN chmod 755 /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.mts \ + /usr/local/lib/nemoclaw/npm12.mts \ /usr/local/lib/nemoclaw/patch-openclaw-chat-send.mts \ /usr/local/lib/nemoclaw/patch-openclaw-mcp-npx.mts \ /usr/local/lib/nemoclaw/patch-openclaw-mcp-reliability.mts \ @@ -929,7 +926,7 @@ RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ echo "ERROR: Base image has OpenClaw $CUR_VER, which is newer than reviewed target $OPENCLAW_VERSION" >&2; exit 1; \ else \ echo "INFO: Base image OpenClaw $CUR_VER lacks matching reviewed provenance; installing $OPENCLAW_VERSION"; \ - # npm 10's atomic-move install can hit EROFS on overlayfs when the prior + # npm's atomic-move install can hit EROFS on overlayfs when the prior # install spans image layers. Removing it first also prevents unreviewed # files from surviving a same-version reinstall. rm -rf /usr/local/lib/node_modules/openclaw /usr/local/bin/openclaw; \ @@ -1460,12 +1457,11 @@ RUN node /usr/local/lib/nemoclaw/patch-openclaw-mcp-tools-list-timeout.mts \ RUN node /usr/local/lib/nemoclaw/patch-openclaw-managed-transport-diagnostics.mts \ /usr/local/lib/node_modules/openclaw/dist -# Run the compact tool catalog shim for OpenClaw selection runtimes that still -# need it. OpenClaw 2026.7.1 ships a built-in catalog surface, so the script -# skips cleanly after classifying the compiled selection-*.js shape. # hadolint ignore=DL3059 RUN node /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.mts \ - /usr/local/lib/node_modules/openclaw/dist + /usr/local/lib/node_modules/openclaw/dist \ + && node /usr/local/lib/nemoclaw/npm12.mts \ + /usr/local/lib/node_modules/openclaw/dist "$OPENCLAW_VERSION" # OpenClaw 2026.7.1 moved gateway startup work into shared and per-agent SQLite # databases, but hardens them to owner-only modes on every open. NemoClaw's @@ -2339,6 +2335,7 @@ RUN check_metadata() { \ && check_metadata /scripts/patch-bundled-npm-tar.mts 'root:root:755' \ && check_metadata /opt/nemoclaw/openclaw.plugin.json 'root:root:644' \ && check_metadata /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.mts 'root:root:755' \ + && check_metadata /usr/local/lib/nemoclaw/npm12.mts 'root:root:755' \ && check_metadata /usr/local/lib/nemoclaw/patch-openclaw-gateway-daemon-dialback.mts 'root:root:755' \ && test ! -L /usr/local/bin/nemoclaw-managed-bootstrap \ && check_metadata /usr/local/bin/nemoclaw-managed-bootstrap 'root:root:755' \ diff --git a/Dockerfile.base b/Dockerfile.base index 6b2dbc1f02e..6792eff7146 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -3,7 +3,7 @@ # # NemoClaw sandbox base image — expensive, rarely-changing layers. # -# Contains: node:22-trixie-slim, apt packages, setpriv, user/group setup, +# Contains: node:24.18.1-trixie-slim, apt packages, setpriv, user/group setup, # .openclaw directory structure, OpenClaw CLI, and PyYAML. # # Built on main merges and pushed to GHCR. The production Dockerfile @@ -15,11 +15,11 @@ # structural (users, directories, symlinks) that doesn't depend on # NemoClaw application code. Specifically: # -# node:22-trixie-slim — pinned by sha256 digest, checked weekly by +# node:24.18.1-trixie-slim — pinned by sha256 digest, checked weekly by # docker-pin-check.yaml # apt packages — pinned to Debian trixie versions # util-linux — pinned Debian package providing setpriv -# npm 11.18.0 — reviewed archive + sha512 integrity +# npm 12.0.2 — reviewed archive + sha512 integrity # gateway/sandbox — OS users and groups; names and UIDs are a # users stable contract with OpenShell # .openclaw dirs — directory structure is dictated by the OpenClaw @@ -40,8 +40,9 @@ # 1. OpenClaw CLI version bump — update OPENCLAW_VERSION default below, or override via --build-arg / workflow_dispatch # 2. New apt package needed — add it to the apt-get install list # 3. util-linux upgrade — update the pinned apt package version -# 4. node:22-trixie-slim digest rotated — update-docker-pin.sh updates all -# Dockerfile and Dockerfile.base +# 4. node:24.18.1-trixie-slim digest rotated — run: +# DOCKERFILE=Dockerfile.base scripts/update-docker-pin.sh +# Then repeat with DOCKERFILE= for each remaining Dockerfile containing the pin. # 5. npm upgrade — update upgrade-bundled-npm.mts # 6. New .openclaw subdirectory — add mkdir below # 7. PyYAML or other pip dep bump — change the version below @@ -59,7 +60,7 @@ ARG PERL_PACKAGE_REVISION=1nemoclaw1 # Debian trixie has not published libssh2 or Python packages containing the # reviewed upstream fixes. Build native packages once and install the same # artifacts into the managed OpenClaw image. -FROM node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c AS native-security-builder +FROM node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc AS native-security-builder RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential=12.12 \ @@ -97,7 +98,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN bash /scripts/security/build-perl-security-packages.sh \ /out "${PERL_VERSION}" "${PERL_SHA256}" "${PERL_PACKAGE_REVISION}" -FROM node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c +FROM node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc COPY --from=perl-builder /out /tmp/nemoclaw-native-security @@ -429,12 +430,15 @@ COPY scripts/lib/reviewed-npm-archive.mts \ scripts/lib/openclaw-npm-remediation.mts \ /scripts/lib/ COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh +COPY scripts/lib/patch-openclaw-npm12-pack-json.mts /scripts/lib/patch-openclaw-npm12-pack-json.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts +COPY scripts/lib/reviewed-npm-identity.mts /scripts/lib/reviewed-npm-identity.mts COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts +COPY ci/reviewed-npm-audit.json /ci/reviewed-npm-audit.json -# npm 10.9.8 in the pinned Node 22 image bundles an affected node-tar copy. +# npm 11.16.0 in the pinned Node 24.18.1 image bundles affected tar 7.5.15. # Replace it before npm processes the reviewed npm archive. RUN node /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm @@ -445,19 +449,19 @@ RUN node /scripts/patch-bundled-npm-tar.mts \ RUN node /scripts/upgrade-bundled-npm.mts \ --npm-root /usr/local/lib/node_modules/npm -# npm 11.18.0 restores affected tar 7.5.19. Replace it from the +# npm 12.0.2 restores affected tar 7.5.19. Replace it from the # registry- and SRI-verified 7.5.21 archive before any npm consumers run. # hadolint ignore=DL3059 RUN node /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm -# npm 11.18.0 contains brace-expansion 5.0.7. Replace only that private +# npm 12.0.2 contains brace-expansion 5.0.7. Replace only that private # package from the reviewed 5.0.9 archive after the complete npm upgrade. # hadolint ignore=DL3059 RUN node /scripts/patch-bundled-npm-brace-expansion.mts \ --npm-root /usr/local/lib/node_modules/npm -# npm 11.18.0 contains ip-address 10.2.0. Replace only that private package +# npm 12.0.2 contains ip-address 10.2.0. Replace only that private package # with the reviewed 10.3.1 archive after the complete npm upgrade. # hadolint ignore=DL3059 RUN node /scripts/lib/patch-bundled-npm-ip-address.mts \ @@ -615,7 +619,6 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep && rm -f /tmp/mcporter-npm-audit.json /tmp/mcporter-npm-audit-policy.json \ && pip3 install --no-cache-dir --break-system-packages "pyyaml==6.0.3" - # Baseline health check. The base image runs no service, so this only # verifies the Node.js runtime is functional. Child images that expose # a service (e.g. the production Dockerfile's gateway) MUST override @@ -624,6 +627,13 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ CMD node -e "process.exit(0)" +# npm 12 emits package-keyed objects from `npm pack --json`; OpenClaw 2026.7.1 +# expects the earlier array/direct-object shapes when installing local npm archives. +RUN if [ "$OPENCLAW_VERSION" = "2026.7.1" ]; then \ + node /scripts/lib/patch-openclaw-npm12-pack-json.mts \ + /usr/local/lib/node_modules/openclaw/dist "$OPENCLAW_VERSION"; \ + fi + # Bake Homebrew core (Linuxbrew) into the sandbox base image (#3913). # # Without this, applying the `brew` policy preset and trying to install diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index daff239fb5a..74bd47220db 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -13,6 +13,9 @@ ARG NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=0 # context pins its accepted x86_64 target because Podman does not supply it. ARG TARGETARCH +FROM scratch AS reviewed-npm-archive +ADD --checksum=sha256:5dbb86c71d07a1957f2e90734092dd6a58bdcd9ebc2d8d41ca1c6e6a21d364e1 https://registry.npmjs.org/npm/-/npm-12.0.2.tgz /npm-12.0.2.tgz + # The reviewed npm graph is audited in CI; image assembly copies only its # generated runtime artifacts and therefore needs neither npm nor network. FROM scratch AS mcp-tool-discovery-runtime @@ -25,7 +28,7 @@ COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-im # Compile the bootstrap boundary on the target platform. The output is a # freestanding static ELF; only its reviewed, non-executable Bash body remains # interpreted at runtime after the native boundary has scrubbed process control. -FROM node:22-trixie@sha256:a566dd560283ae5615c8bb86b58fa8a1b6f3c82b492473a061672416266625da AS managed-bootstrap-entrypoint-builder +FROM node:24.18.1-trixie@sha256:dfa43abae25030f5456007944f725379d1f5be4bb723bd501ac39ac72ffa5474 AS managed-bootstrap-entrypoint-builder ARG TARGETARCH WORKDIR /opt/nemoclaw-managed-bootstrap-build COPY scripts/managed-bootstrap-entrypoint.c ./ @@ -71,11 +74,16 @@ RUN set -eu; \ FROM scratch AS hermes-npm-patch-payload +COPY --from=reviewed-npm-archive /npm-12.0.2.tgz /scripts/npm-12.0.2.tgz +COPY tools/mcp-tool-discovery-runtime/npm-cache-seed/tar-7.5.21.tgz /scripts/nemoclaw-bundled-npm-tar.tgz COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts +COPY scripts/lib/reviewed-npm-identity.mts /scripts/lib/reviewed-npm-identity.mts +COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts +COPY ci/reviewed-npm-audit.json /ci/reviewed-npm-audit.json COPY agents/hermes/runtime-boundaries.patch /scripts/hermes-runtime-boundaries.patch COPY agents/hermes/dashboard-external-host.patch /scripts/hermes-dashboard-external-host.patch COPY agents/hermes/secure-dir-skip-chmod.patch /scripts/hermes-secure-dir-skip-chmod.patch @@ -257,6 +265,14 @@ RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ # hadolint ignore=DL3067 COPY --from=hermes-npm-patch-payload / / +# A published base can lag the base-image rollout. Install the complete +# reviewed npm tree from immutable local bytes before private-tree patches. +RUN node /scripts/upgrade-bundled-npm.mts \ + --npm-root /usr/local/lib/node_modules/npm \ + --archive /scripts/npm-12.0.2.tgz +# hadolint ignore=DL3059 +RUN rm /scripts/npm-12.0.2.tgz + # The immutable selected base owns the reviewed Hindsight and agent-browser # patches and the root-owned agent-browser runtime. Verify that inherited boundary # without adding a second network-capable install owner in the final image. @@ -335,10 +351,9 @@ RUN grep -Fq 'from tools.lazy_deps import install_specs' /opt/hermes/hermes_cli/ # The final Hermes image owns the shipped dependency boundary independently of # base freshness. Reassert the idempotent npm-private node-tar fix from the # locked cache seed so protected rebuilds remain network-disabled. -COPY tools/mcp-tool-discovery-runtime/npm-cache-seed/tar-7.5.21.tgz /tmp/nemoclaw-bundled-npm-tar.tgz RUN node /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm \ - --archive /tmp/nemoclaw-bundled-npm-tar.tgz + --archive /scripts/nemoclaw-bundled-npm-tar.tgz # Reassert the npm-private brace-expansion fix for the final filesystem. # When onboarding supplied a corporate CA, use it for the registry-backed @@ -361,28 +376,28 @@ RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ node /scripts/lib/patch-bundled-npm-ip-address.mts \ --npm-root /usr/local/lib/node_modules/npm -# Keep the final image contract explicit even when the published base image -# changes independently of this Dockerfile. -RUN set -eu; \ - hermes_path="$(command -v hermes 2>/dev/null || true)"; \ - if [ "$hermes_path" != "/usr/local/bin/hermes" ]; then \ - echo "ERROR: expected hermes at /usr/local/bin/hermes, got ${hermes_path:-missing}" >&2; \ - exit 1; \ - fi; \ - test -x /usr/local/bin/hermes; \ - /usr/local/bin/hermes --version - -RUN chmod -R a+rX /opt/hermes/.venv \ - && test -r /opt/hermes/.venv/pyvenv.cfg \ - && su -s /bin/sh sandbox -c '/opt/hermes/.venv/bin/python3 -c "import urllib.request"' - # Published base images can lag Dockerfile.base while local feature branches # still layer this final image on top. Invalid state: the selected base has # Hermes source under /opt/hermes but lacks hermes_cli/web_dist. Prebuild the # dashboard bundle here too so `hermes dashboard` never tries to run npm from # the sandbox user account. Remove this fallback after all supported base image # tags are built from Dockerfile.base with HERMES_WEB_DIST prepopulated. +ENV HERMES_WEB_DIST="/opt/hermes/hermes_cli/web_dist" + +# Keep the inherited CLI, Python environment, and dashboard explicit. Strip +# build-only compiler and network-probe packages in the same layer so the +# managed image remains within its import ceiling. RUN set -eu; \ + hermes_path="$(command -v hermes 2>/dev/null || true)"; \ + if [ "$hermes_path" != "/usr/local/bin/hermes" ]; then \ + echo "ERROR: expected hermes at /usr/local/bin/hermes, got ${hermes_path:-missing}" >&2; \ + exit 1; \ + fi; \ + test -x /usr/local/bin/hermes; \ + /usr/local/bin/hermes --version; \ + chmod -R a+rX /opt/hermes/.venv; \ + test -r /opt/hermes/.venv/pyvenv.cfg; \ + su -s /bin/sh sandbox -c '/opt/hermes/.venv/bin/python3 -c "import urllib.request"'; \ if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \ @@ -409,15 +424,7 @@ RUN set -eu; \ fi; \ fi; \ rm -rf /root/.npm /root/.cache/electron /root/.cache/node-gyp /root/.cache/uv; \ - test -d "$hermes_web_dist" - -ENV HERMES_WEB_DIST="/opt/hermes/hermes_cli/web_dist" - -# Harden: remove unnecessary build tools and network probes. build-essential -# comes from the base image for npm/node-gyp builds; the final image does not -# compile anything, so strip the installed compiler packages and fail closed if -# command shims survive. -RUN set -eu; \ + test -d "$hermes_web_dist"; \ dpkg-query -W -f='${Package}\n' >/tmp/hermes-runtime-packages.txt; \ grep -E '^(build-essential|make|gcc|g\+\+|cpp|gcc-[0-9]+|g\+\+-[0-9]+|cpp-[0-9]+|[[:alnum:]_-]+-linux-gnu-(gcc|g\+\+|cpp)(-[0-9]+)?|netcat-openbsd|netcat-traditional|ncat)$' /tmp/hermes-runtime-packages.txt >/tmp/hermes-runtime-tool-packages.txt || true; \ if [ -s /tmp/hermes-runtime-tool-packages.txt ]; then xargs -r apt-get purge -y &2; exit 1 ;; \ - esac \ - && node_archive="/tmp/node-v${NODE_VERSION}-linux-${node_asset_arch}.tar.gz" \ - && curl --proto '=https' --tlsv1.2 -fsSL \ - --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 300 \ - -o "$node_archive" \ - "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${node_asset_arch}.tar.gz" \ - && printf '%s %s\n' "$node_sha256" "$node_archive" | sha256sum -c - \ - && rm -rf \ - /usr/local/include/node \ - /usr/local/lib/node_modules/corepack \ - /usr/local/lib/node_modules/npm \ - /usr/local/share/doc/node \ - /usr/local/share/man/man1/node.1 \ - && rm -f /usr/local/bin/corepack /usr/local/bin/node /usr/local/bin/npm /usr/local/bin/npx \ - && tar --extract --gzip --file "$node_archive" \ - --directory /usr/local --strip-components=1 --no-same-owner \ - && rm -f "$node_archive" \ - && test "$(node --version)" = "v${NODE_VERSION}" \ +# The exact upstream image supplies the reviewed Node.js and bundled npm. +RUN test "$(node --version)" = "v24.18.1" \ && test "$(npm --version)" = "11.16.0" COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts @@ -269,7 +244,9 @@ COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts +COPY scripts/lib/reviewed-npm-identity.mts /scripts/lib/reviewed-npm-identity.mts COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts +COPY ci/reviewed-npm-audit.json /ci/reviewed-npm-audit.json # npm 11.16.0 in Node.js 24.18.1 bundles an affected node-tar copy. # Patch that private package after curl is installed and before npm processes @@ -283,19 +260,19 @@ RUN node /scripts/patch-bundled-npm-tar.mts \ RUN node /scripts/upgrade-bundled-npm.mts \ --npm-root /usr/local/lib/node_modules/npm -# npm 11.18.0 restores affected tar 7.5.19. Replace it from the +# npm 12.0.2 restores affected tar 7.5.19. Replace it from the # registry- and SRI-verified 7.5.21 archive before any npm consumers run. # hadolint ignore=DL3059 RUN node /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm -# Replace npm 11.18.0's private brace-expansion 5.0.7 package with the +# Replace npm 12.0.2's private brace-expansion 5.0.7 package with the # registry- and SRI-pinned 5.0.9 release. # hadolint ignore=DL3059 RUN node /scripts/patch-bundled-npm-brace-expansion.mts \ --npm-root /usr/local/lib/node_modules/npm -# Replace npm 11.18.0's private ip-address 10.2.0 package with the +# Replace npm 12.0.2's private ip-address 10.2.0 package with the # registry- and SRI-pinned 10.3.1 release. # hadolint ignore=DL3059 RUN node /scripts/lib/patch-bundled-npm-ip-address.mts \ diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index de1535a842e..1c5cbcd38bf 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -8,6 +8,9 @@ ARG BASE_IMAGE ARG NEMOCLAW_CORPORATE_CA_B64= +FROM scratch AS reviewed-npm-archive +ADD --chmod=0444 --checksum=sha256:5dbb86c71d07a1957f2e90734092dd6a58bdcd9ebc2d8d41ca1c6e6a21d364e1 https://registry.npmjs.org/npm/-/npm-12.0.2.tgz /npm-12.0.2.tgz + # The reviewed npm graph is audited in CI; image assembly copies only its # generated runtime artifacts and therefore needs neither npm nor network. FROM scratch AS mcp-tool-discovery-runtime @@ -20,7 +23,7 @@ COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-im # Compile the bootstrap boundary on the target platform. The output is a # freestanding static ELF; only its reviewed, non-executable Bash body remains # interpreted at runtime after the native boundary has scrubbed process control. -FROM node:22-trixie@sha256:a566dd560283ae5615c8bb86b58fa8a1b6f3c82b492473a061672416266625da AS managed-bootstrap-entrypoint-builder +FROM node:24.18.1-trixie@sha256:dfa43abae25030f5456007944f725379d1f5be4bb723bd501ac39ac72ffa5474 AS managed-bootstrap-entrypoint-builder ARG TARGETARCH WORKDIR /opt/nemoclaw-managed-bootstrap-build COPY scripts/managed-bootstrap-entrypoint.c ./ @@ -151,19 +154,27 @@ COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts +COPY scripts/lib/reviewed-npm-identity.mts /scripts/lib/reviewed-npm-identity.mts +COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts +COPY ci/reviewed-npm-audit.json /ci/reviewed-npm-audit.json +COPY --from=reviewed-npm-archive /npm-12.0.2.tgz /tmp/npm-12.0.2.tgz + +# Standardize stale and same-run bases before applying npm 12 private-tree +# remediations. The local archive is independently SHA-256 and SRI verified. +RUN node /scripts/upgrade-bundled-npm.mts \ + --npm-root /usr/local/lib/node_modules/npm \ + --archive /tmp/npm-12.0.2.tgz +# hadolint ignore=DL3059 +RUN rm /tmp/npm-12.0.2.tgz # The final managed image owns the shipped dependency boundary independently -# of base freshness. Reassert the idempotent npm-private node-tar fix here. +# of base freshness. Reassert every reviewed npm-private remediation. +# hadolint ignore=DL3059 RUN node /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm - -# Reassert the npm-private brace-expansion fix for the final filesystem. # hadolint ignore=DL3059 RUN node /scripts/patch-bundled-npm-brace-expansion.mts \ --npm-root /usr/local/lib/node_modules/npm - -# Reassert the npm-private ip-address fix for the final filesystem. When -# onboarding supplied a corporate CA, use it for the registry-backed download. # hadolint ignore=DL3059 RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index 8b3bf991df6..449f0b351b0 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -13,7 +13,7 @@ ARG PERL_PACKAGE_REVISION=1nemoclaw1 ARG NEMOCLAW_CORPORATE_CA_B64= -FROM node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c AS native-security-builder +FROM node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc AS native-security-builder ARG NEMOCLAW_CORPORATE_CA_B64 @@ -62,7 +62,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN bash /scripts/security/build-perl-security-packages.sh \ /out "${PERL_VERSION}" "${PERL_SHA256}" "${PERL_PACKAGE_REVISION}" -FROM node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c +FROM node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc ARG NEMOCLAW_CORPORATE_CA_B64 @@ -73,7 +73,9 @@ COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts +COPY scripts/lib/reviewed-npm-identity.mts /scripts/lib/reviewed-npm-identity.mts COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts +COPY ci/reviewed-npm-audit.json /ci/reviewed-npm-audit.json ENV DEBIAN_FRONTEND=noninteractive \ VIRTUAL_ENV=/opt/venv \ @@ -249,19 +251,19 @@ RUN node /scripts/patch-bundled-npm-tar.mts \ RUN node /scripts/upgrade-bundled-npm.mts \ --npm-root /usr/local/lib/node_modules/npm -# npm 11.18.0 restores affected tar 7.5.19. Replace it from the +# npm 12.0.2 restores affected tar 7.5.19. Replace it from the # registry- and SRI-verified 7.5.21 archive before any npm consumers run. # hadolint ignore=DL3059 RUN node /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm -# Replace npm 11.18.0's private brace-expansion 5.0.7 package with the +# Replace npm 12.0.2's private brace-expansion 5.0.7 package with the # registry- and SRI-pinned 5.0.9 release. # hadolint ignore=DL3059 RUN node /scripts/patch-bundled-npm-brace-expansion.mts \ --npm-root /usr/local/lib/node_modules/npm -# Replace npm 11.18.0's private ip-address 10.2.0 package with the +# Replace npm 12.0.2's private ip-address 10.2.0 package with the # registry- and SRI-pinned 10.3.1 release. # hadolint ignore=DL3059 RUN node /scripts/lib/patch-bundled-npm-ip-address.mts \ diff --git a/agents/openclaw/dependency-review.md b/agents/openclaw/dependency-review.md index 118298109e6..f735b979d1f 100644 --- a/agents/openclaw/dependency-review.md +++ b/agents/openclaw/dependency-review.md @@ -37,7 +37,7 @@ The reviewed audit wrapper reports lower-severity production findings and blocks - Installation boundary: the image materializes the reviewed lock into a root-owned dedicated npm cache and adds the exact package metadata needed by npm's offline resolver. Before that cache becomes immutable, the shared `scripts/lib/reviewed-npm-archive.mts` implementation re-packs every locked archive offline from the final cache and rejects registry-origin drift, metadata or packed-byte SRI drift, unsafe filenames, missing archives, and symlinks. The sandbox user copies that verified immutable source into a writable cache used for registry metadata lookup, archive packing, and the OpenClaw plugin install; no retrieval step falls back to `HOME/.npm`. The copy is deleted in the same image layer, and the trusted cache is never writable. The installer runs in offline, legacy-peer mode, then `verify-wechat-runtime-lock.mts` rejects integrity, version, dependency-set, or peer-range drift and refuses an image OpenClaw version below the plugin's locked peer minimum. - Default CI gate: `reviewed-npm-audit` in `.github/workflows/pr.yaml` and `.github/workflows/main.yaml` audits the WeChat locked graph with the shared reviewed npm implementation. The pull request workflow resolves the implementation and policy from the PR base SHA and applies them to the proposed manifest and lockfile. - The shared gate uses Node.js `22.23.2` and verified `npm@10.9.4`. + The shared gate uses Node.js `24.18.1` and verified `npm@12.0.2`. It installs the exact lock with lifecycle scripts disabled and legacy peer resolution, rejects any low-or-higher production advisory, and verifies registry signatures. It also exercises the reviewed archive through a copied writable cache while the trusted source remains read-only. Signature verification makes at most three attempts and retries only `npm error Failed to download`; all other failures stop immediately. diff --git a/agents/openclaw/managed-image-messaging-runtime/package-lock.json b/agents/openclaw/managed-image-messaging-runtime/package-lock.json index 9726dca14a0..b712aebfd96 100644 --- a/agents/openclaw/managed-image-messaging-runtime/package-lock.json +++ b/agents/openclaw/managed-image-messaging-runtime/package-lock.json @@ -36,7 +36,6 @@ "undici", "ws" ], - "hasShrinkwrap": true, "dependencies": { "@discordjs/voice": "0.19.2", "discord-api-types": "0.38.49", @@ -501,7 +500,6 @@ "google-auth-library", "zod" ], - "hasShrinkwrap": true, "dependencies": { "gaxios": "7.1.5", "google-auth-library": "10.9.0", @@ -840,7 +838,6 @@ "express", "typebox" ], - "hasShrinkwrap": true, "dependencies": { "@azure/identity": "4.13.1", "@microsoft/teams.api": "2.0.13", @@ -2692,7 +2689,6 @@ "ws", "zod" ], - "hasShrinkwrap": true, "dependencies": { "@slack/bolt": "4.7.3", "@slack/types": "2.21.1", @@ -2823,6 +2819,13 @@ "npm": ">= 8.6.0" } }, + "node_modules/@openclaw/slack/node_modules/@slack/web-api/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "inBundle": true, + "license": "MIT" + }, "node_modules/@openclaw/slack/node_modules/@types/jsonwebtoken": { "version": "9.0.10", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", @@ -2851,13 +2854,6 @@ "undici-types": "~8.3.0" } }, - "node_modules/@openclaw/slack/node_modules/@types/retry": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", - "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==", - "inBundle": true, - "license": "MIT" - }, "node_modules/@openclaw/slack/node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -3788,6 +3784,13 @@ "node": ">=8" } }, + "node_modules/@openclaw/slack/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "inBundle": true, + "license": "MIT" + }, "node_modules/@openclaw/slack/node_modules/p-timeout": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", @@ -4235,7 +4238,6 @@ "baileys", "typebox" ], - "hasShrinkwrap": true, "dependencies": { "audio-decode": "2.2.3", "baileys": "7.0.0-rc13", @@ -4731,25 +4733,6 @@ "inBundle": true, "license": "MIT" }, - "node_modules/@openclaw/whatsapp/node_modules/file-type": { - "version": "22.0.1", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.0.1.tgz", - "integrity": "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==", - "inBundle": true, - "license": "MIT", - "dependencies": { - "@tokenizer/inflate": "^0.4.1", - "strtok3": "^10.3.5", - "token-types": "^6.1.2", - "uint8array-extras": "^1.5.0" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, "node_modules/@openclaw/whatsapp/node_modules/hashery": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", @@ -4896,6 +4879,25 @@ "node": ">=18" } }, + "node_modules/@openclaw/whatsapp/node_modules/music-metadata/node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/@openclaw/whatsapp/node_modules/node-wav": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/node-wav/-/node-wav-0.0.2.tgz", diff --git a/agents/pi/Dockerfile b/agents/pi/Dockerfile index fa0e1d3a997..b6bba3a22d2 100644 --- a/agents/pi/Dockerfile +++ b/agents/pi/Dockerfile @@ -8,13 +8,16 @@ ARG BASE_IMAGE ARG NEMOCLAW_CORPORATE_CA_B64= +FROM scratch AS reviewed-npm-archive +ADD --chmod=0444 --checksum=sha256:5dbb86c71d07a1957f2e90734092dd6a58bdcd9ebc2d8d41ca1c6e6a21d364e1 https://registry.npmjs.org/npm/-/npm-12.0.2.tgz /npm-12.0.2.tgz + FROM scratch AS managed-startup-runtime-builder COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle /out/managed-startup-image-runtime.cjs # Compile the bootstrap boundary on the target platform. The output is a # freestanding static ELF; only its reviewed, non-executable Bash body remains # interpreted at runtime after the native boundary has scrubbed process control. -FROM node:22-trixie@sha256:a566dd560283ae5615c8bb86b58fa8a1b6f3c82b492473a061672416266625da AS managed-bootstrap-entrypoint-builder +FROM node:24.18.1-trixie@sha256:dfa43abae25030f5456007944f725379d1f5be4bb723bd501ac39ac72ffa5474 AS managed-bootstrap-entrypoint-builder ARG TARGETARCH WORKDIR /opt/nemoclaw-managed-bootstrap-build COPY scripts/managed-bootstrap-entrypoint.c ./ @@ -120,19 +123,27 @@ COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts +COPY scripts/lib/reviewed-npm-identity.mts /scripts/lib/reviewed-npm-identity.mts +COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts +COPY ci/reviewed-npm-audit.json /ci/reviewed-npm-audit.json +COPY --from=reviewed-npm-archive /npm-12.0.2.tgz /tmp/npm-12.0.2.tgz + +# Standardize stale and same-run bases before applying npm 12 private-tree +# remediations. The local archive is independently SHA-256 and SRI verified. +RUN node /scripts/upgrade-bundled-npm.mts \ + --npm-root /usr/local/lib/node_modules/npm \ + --archive /tmp/npm-12.0.2.tgz +# hadolint ignore=DL3059 +RUN rm /tmp/npm-12.0.2.tgz # The final managed image owns the shipped dependency boundary independently -# of base freshness. Reassert the idempotent npm-private node-tar fix here. +# of base freshness. Reassert every reviewed npm-private remediation. +# hadolint ignore=DL3059 RUN node /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm - -# Reassert the npm-private brace-expansion fix for the final filesystem. # hadolint ignore=DL3059 RUN node /scripts/patch-bundled-npm-brace-expansion.mts \ --npm-root /usr/local/lib/node_modules/npm - -# Reassert the npm-private ip-address fix for the final filesystem. When -# onboarding supplied a corporate CA, use it for the registry-backed download. # hadolint ignore=DL3059 RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ diff --git a/agents/pi/Dockerfile.base b/agents/pi/Dockerfile.base index 3b60ac7bd62..2e58c45161f 100644 --- a/agents/pi/Dockerfile.base +++ b/agents/pi/Dockerfile.base @@ -17,7 +17,7 @@ ARG PI_NPM_INTEGRITY=sha512-ncAqFrG+iybuPGOhMiZoEHkEzTpJgz3guYD32pD+M7ucc0WeHmau ARG NEMOCLAW_CORPORATE_CA_B64= -FROM node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c AS native-security-builder +FROM node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc AS native-security-builder ARG NEMOCLAW_CORPORATE_CA_B64 @@ -66,7 +66,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN bash /scripts/security/build-perl-security-packages.sh \ /out "${PERL_VERSION}" "${PERL_SHA256}" "${PERL_PACKAGE_REVISION}" -FROM node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c +FROM node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc ARG NEMOCLAW_CORPORATE_CA_B64 ARG PI_PACKAGE @@ -80,7 +80,9 @@ COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts +COPY scripts/lib/reviewed-npm-identity.mts /scripts/lib/reviewed-npm-identity.mts COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts +COPY ci/reviewed-npm-audit.json /ci/reviewed-npm-audit.json ENV DEBIAN_FRONTEND=noninteractive \ PATH="/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin" @@ -252,19 +254,19 @@ RUN node /scripts/patch-bundled-npm-tar.mts \ RUN node /scripts/upgrade-bundled-npm.mts \ --npm-root /usr/local/lib/node_modules/npm -# npm 11.18.0 restores affected tar 7.5.19. Replace it from the +# npm 12.0.2 restores affected tar 7.5.19. Replace it from the # registry- and SRI-verified 7.5.21 archive before any npm consumers run. # hadolint ignore=DL3059 RUN node /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm -# Replace npm 11.18.0's private brace-expansion 5.0.7 package with the +# Replace npm 12.0.2's private brace-expansion 5.0.7 package with the # registry- and SRI-pinned 5.0.9 release. # hadolint ignore=DL3059 RUN node /scripts/patch-bundled-npm-brace-expansion.mts \ --npm-root /usr/local/lib/node_modules/npm -# Replace npm 11.18.0's private ip-address 10.2.0 package with the +# Replace npm 12.0.2's private ip-address 10.2.0 package with the # registry- and SRI-pinned 10.3.1 release. # hadolint ignore=DL3059 RUN node /scripts/lib/patch-bundled-npm-ip-address.mts \ diff --git a/ci/pi-agent-qualification-v1-linux-amd64.json b/ci/pi-agent-qualification-v1-linux-amd64.json index 4efc2d351b7..dab288973ad 100644 --- a/ci/pi-agent-qualification-v1-linux-amd64.json +++ b/ci/pi-agent-qualification-v1-linux-amd64.json @@ -3,13 +3,13 @@ "agent": "pi", "platform": "linux/amd64", "image": "ghcr.io/nvidia/nemoclaw/pi-sandbox", - "digest": "sha256:3c5585ea23ccc683bc4f3bd747e4d5be2ea0655f629d532a78e7d41b39516afc", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:3c5585ea23ccc683bc4f3bd747e4d5be2ea0655f629d532a78e7d41b39516afc", + "digest": "sha256:c951b70bc0ed34f320107727a4f90fe66d07c3fb356c7ad660ca8b99b58ee24c", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:c951b70bc0ed34f320107727a4f90fe66d07c3fb356c7ad660ca8b99b58ee24c", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "719771e2f8b992ad15c7adbc6b8a2c46d34f664b", + "revision": "6d8363792382d2cfb70ef20cb82a76a9df814d52", "release": "v0.1.0", - "cohort": "ghrun-34740252459-2" + "cohort": "ghrun-34811379655-1" }, "startupProfileContractVersion": 1, "capabilityContractVersion": 1 diff --git a/ci/pi-agent-qualification-v1-linux-arm64.json b/ci/pi-agent-qualification-v1-linux-arm64.json index fae11e0a3e6..e70ef0ccf69 100644 --- a/ci/pi-agent-qualification-v1-linux-arm64.json +++ b/ci/pi-agent-qualification-v1-linux-arm64.json @@ -3,13 +3,13 @@ "agent": "pi", "platform": "linux/arm64", "image": "ghcr.io/nvidia/nemoclaw/pi-sandbox", - "digest": "sha256:365d96a58c66a8da9ab74e1ea90b1d71f2cb25ea805f8646d80932301992c837", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:365d96a58c66a8da9ab74e1ea90b1d71f2cb25ea805f8646d80932301992c837", + "digest": "sha256:cff13951b1a6fd57d20f8bf148ed6785890c24d957dc5a5ebb0c0d33271e93e3", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:cff13951b1a6fd57d20f8bf148ed6785890c24d957dc5a5ebb0c0d33271e93e3", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "719771e2f8b992ad15c7adbc6b8a2c46d34f664b", + "revision": "6d8363792382d2cfb70ef20cb82a76a9df814d52", "release": "v0.1.0", - "cohort": "ghrun-34740252459-2" + "cohort": "ghrun-34811379655-1" }, "startupProfileContractVersion": 1, "capabilityContractVersion": 1 diff --git a/ci/reviewed-npm-audit.json b/ci/reviewed-npm-audit.json index aa9e4b69ed1..657784d1678 100644 --- a/ci/reviewed-npm-audit.json +++ b/ci/reviewed-npm-audit.json @@ -1,9 +1,9 @@ { "schemaVersion": 2, - "nodeVersion": "22.23.2", - "npmVersion": "10.9.4", - "npmIntegrity": "sha512-OnUG836FwboQIbqtefDNlyR0gTHzIfwRfE3DuiNewBvnMnWEpB0VEXwBlFVgqpNzIgYo/MHh3d2Hel/pszapAA==", - "npmArchiveSha256": "4bfba8a0c823024d1926ec9d97a37a00eb60fd2adf44b3d34a686fc32e8f51e4", + "nodeVersion": "24.18.1", + "npmVersion": "12.0.2", + "npmIntegrity": "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw==", + "npmArchiveSha256": "5dbb86c71d07a1957f2e90734092dd6a58bdcd9ebc2d8d41ca1c6e6a21d364e1", "registryOrigin": "https://registry.npmjs.org/", "sourceRegistryPackage": { "artifactName": "nvidia-openshell-sdk-0.0.116.tgz", diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index f6356ca181f..2ff9ee92019 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -101,6 +101,66 @@ "test": "rejects an isolated unreviewed Deep Agents Code Node base pin", "category": "security" }, + { + "file": "test/platform/images/node-tar-dockerfile-contract.test.ts", + "test": "upgrades and verifies reviewed npm before every direct Node stage npm boundary", + "category": "security" + }, + { + "file": "test/platform/images/node-tar-dockerfile-contract.test.ts", + "test": "keeps Hermes npm migration inputs and runtime finalization layer-bounded", + "category": "compatibility" + }, + { + "file": "test/repository/code-scanning-workflow.test.ts", + "test": "installs npm from the immutable reviewed action bootstrap", + "category": "security" + }, + { + "file": "test/repository/github-actions-node-npm-invariant.test.ts", + "test": "selects the reviewed Node and npm identities before further steps", + "category": "security" + }, + { + "file": "test/repository/github-actions-node-npm-invariant.test.ts", + "test": "selects image validation when the reviewed npm bootstrap changes", + "category": "security" + }, + { + "file": "test/repository/github-actions-node-npm-invariant.test.ts", + "test": "keeps composite npm setup dependencies in matching workflow checkouts", + "category": "security" + }, + { + "file": "test/repository/github-actions-node-npm-invariant.test.ts", + "test": "uses one reviewed npm identity across audit, image, and private-patch consumers", + "category": "security" + }, + { + "file": "test/repository/github-actions-node-npm-invariant.test.ts", + "test": "imports the reviewed npm identity from %s", + "category": "security" + }, + { + "file": "test/automation/e2e/platform-vitest-main-workflow.test.ts", + "test": "uses the reviewed Node and npm identities for the WSL build and test lane", + "category": "security" + }, + { + "file": "test/agents/openclaw/openclaw-npm-remediation.test.ts", + "test": "replaces the reviewed bundled Axios graph with the patched graph", + "category": "security" + }, + { + "file": "test/agents/openclaw/openclaw-npm-remediation.test.ts", + "test": "replaces the reviewed Jaeger propagator with its aligned patched core", + "category": "security" + }, + { + "file": "test/agents/openclaw/openclaw-npm-remediation.test.ts", + "test": "replaces the reviewed OpenClaw Discord undici dependency", + "category": "security" + }, { "file": "test/agents/openclaw/openclaw-2026-6-npm-remediation.test.ts", "test": "rebuilds a guarded core archive with the patched fs-safe package bundled", @@ -126,6 +186,16 @@ "test": "cross-checks the allowlist against every production archive install boundary", "category": "security" }, + { + "file": "test/agents/openclaw/openclaw-real-patched-dist-harness.test.ts", + "test": "accepts an explicit absolute Node runtime in the reviewed production lane", + "category": "security" + }, + { + "file": "test/agents/openclaw/openclaw-real-patched-dist-harness.test.ts", + "test": "rejects an unsupported explicit real-dist Node runtime before OpenClaw starts", + "category": "security" + }, { "file": "test/agents/openclaw/openclaw-locked-install.test.ts", "test": "fails closed on missing required packages and symlinked package roots", diff --git a/docs/security/advisory-early-warning.md b/docs/security/advisory-early-warning.md index d977a28a08c..3cc3e676bc2 100644 --- a/docs/security/advisory-early-warning.md +++ b/docs/security/advisory-early-warning.md @@ -96,13 +96,12 @@ The same #7338 sign-off gate applies to this work. Each npm audit report has a `*.provenance.json` sidecar. The sidecars include `coverage/reviewed-npm-audit/` artifacts and `npm-audit.provenance.json` for the WeChat locked runtime graph audit. A configured cache reuses a response only when the package and lock bytes, the pinned npm identity (version, SHA-512 SRI, and archive SHA-256), fixed Yarn audit registry origin, command arguments, and parser identity match. -Current receipts bind the same complete npm identity. -Until 2026-09-18, image builds may accept a still-current version-only or npmjs receipt only through the explicit legacy transition. -Remove the legacy option and verifier path after schema version 2 receipts replace the retained receipts. +Image builds accept only schema version 2 receipts that bind the same complete npm identity and fixed registry. The sidecar records whether the response came from the cache or a live registry request, plus its creation time, age, input digest, and response digest. Each sidecar also records: -- Scanner identity, including `npm audit`, npm version, and Node.js version. +- Scanner identity, including `npm audit`, the exact npm version and verified archive integrity, and the Node.js version. +- Receipt schema version 2 binds the audit result to that npm version and integrity so a consumer rejects mixed or unverified scanner identities. - The fixed Yarn audit registry. The sidecar also records its derived bulk advisory endpoint where npm posts the dependency graph. npm 7 and newer have no quick-audit fallback. diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index ead60c9dde9..54e0982af5c 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -219,6 +219,14 @@ export function parseAuditConfig(contents: string): AuditConfig { parsed.archiveTarVersion !== "7.5.21" || typeof parsed.exceptionFile !== "string" || !parsed.exceptionFile || + typeof parsed.npmVersion !== "string" || + !/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/.test(parsed.npmVersion) || + /[\r\n]/.test(parsed.npmVersion) || + typeof parsed.npmArchiveSha256 !== "string" || + !/^[a-f0-9]{64}$/.test(parsed.npmArchiveSha256) || + typeof parsed.npmIntegrity !== "string" || + !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(parsed.npmIntegrity) || + /[\r\n]/.test(parsed.npmIntegrity) || typeof parsed.registryOrigin !== "string" || !parsed.registryOrigin || !Array.isArray(parsed.archivePackages) || @@ -302,6 +310,25 @@ export function reviewedArchiveGraphManifest(archiveTarVersion: unknown) { } as const; } +export function stageReviewedArchiveForInstall( + graphDirectory: string, + archivePath: string, + index: number, +): string { + if (!Number.isSafeInteger(index) || index < 0) { + throw new Error("reviewed archive install index must be a non-negative integer"); + } + const filename = path.basename(archivePath); + if (!filename.endsWith(".tgz") || filename === ".tgz") { + throw new Error(`reviewed archive install filename is invalid: ${filename}`); + } + const relativeArchivePath = path.join("reviewed-archives", `${index}-${filename}`); + const archiveDirectory = path.join(graphDirectory, "reviewed-archives"); + fs.mkdirSync(archiveDirectory, { recursive: true }); + fs.copyFileSync(archivePath, path.join(graphDirectory, relativeArchivePath)); + return `.${path.sep}${relativeArchivePath}`; +} + function materializeArchiveGraph( packages: readonly ReviewedPackage[], tempRoot: string, @@ -313,7 +340,7 @@ function materializeArchiveGraph( path.join(graphDirectory, "package.json"), `${JSON.stringify(reviewedArchiveGraphManifest(archiveTarVersion), null, 2)}\n`, ); - const archives = packages.map((reviewed) => { + const archives = packages.map((reviewed, index) => { const archive = packReviewedNpmArchive({ expectedIntegrity: reviewed.integrity, label: reviewed.label, @@ -321,22 +348,16 @@ function materializeArchiveGraph( tarballUrl: reviewed.tarballUrl, tempDirectory: tempRoot, }); - return remediateReviewedOpenClawPluginArchive({ + const remediated = remediateReviewedOpenClawPluginArchive({ archivePath: archive.archivePath, packageSpec: reviewed.packageSpec, workingDirectory: archive.rootDirectory, }); + return stageReviewedArchiveForInstall(graphDirectory, remediated.archivePath, index); }); run( "npm", - [ - "install", - "--ignore-scripts", - "--omit=dev", - "--no-audit", - "--no-fund", - ...archives.map((archive) => archive.archivePath), - ], + ["install", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund", ...archives], graphDirectory, ); return graphDirectory; @@ -764,7 +785,12 @@ function auditLockedGraph( directory, exceptionFile, graph: graph.id, - provenance: lockedGraphAuditProvenance(identity, process.version, config.npmVersion), + provenance: lockedGraphAuditProvenance( + identity, + process.version, + config.npmVersion, + config.npmIntegrity, + ), reviewedNpmIdentity: config, reportFile: path.join(artifactDirectory, `locked-graph-${index + 1}.json`), resultFile: path.join(artifactDirectory, `${graph.id}.policy.json`), @@ -792,10 +818,12 @@ function lockedGraphAuditProvenance( identity: LockedGraphIdentity, nodeVersion: string, npmVersion: string, + npmIntegrity: string, ) { return { label: identity.label, nodeVersion, + npmIntegrity, npmVersion, packageSpecs: [identity.packageSpec], }; @@ -856,6 +884,7 @@ export function auditMaterializedSourceGraph( provenance: { label: SOURCE_GRAPH.label, nodeVersion: process.version, + npmIntegrity: options.reviewedNpmIdentity.npmIntegrity, npmVersion: options.reviewedNpmIdentity.npmVersion, packageSpecs: [options.packageSpec], }, @@ -998,6 +1027,7 @@ function main(): void { provenance: { label: "reviewed archive graph", nodeVersion: process.version, + npmIntegrity: config.npmIntegrity, npmVersion, packageSpecs: config.archivePackages.map((reviewed) => reviewed.packageSpec), }, diff --git a/scripts/brev-launchable-ci-cpu.sh b/scripts/brev-launchable-ci-cpu.sh index 1068a482752..4c337bacfe9 100755 --- a/scripts/brev-launchable-ci-cpu.sh +++ b/scripts/brev-launchable-ci-cpu.sh @@ -9,7 +9,7 @@ # # What this installs: # 1. Docker (docker.io) — enabled and running -# 2. Node.js 22 (nodesource) +# 2. Node.js 24.18.1 and verified npm 12.0.2 # 3. OpenShell CLI binary (pinned release) # 4. NemoClaw repo cloned with npm deps installed and TS plugin built # @@ -250,42 +250,44 @@ sudo usermod -aG docker "$TARGET_USER" 2>/dev/null || true # Docker socket permissions to work around stale group membership. info "Docker enabled ($(docker --version 2>/dev/null | head -c 40))" -# 3. Node.js 22 -node_major="" -if command -v node >/dev/null 2>&1; then - node_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || true)" -fi - -if command -v npm >/dev/null 2>&1 && [[ -n "$node_major" ]] && ((node_major >= 22)); then +# 3. Node.js 24.18.1 +NODE_VERSION="24.18.1" +if command -v node >/dev/null 2>&1 && [[ "$(node --version)" == "v${NODE_VERSION}" ]]; then info "Node.js already installed: $(node --version)" else - info "Installing Node.js 22..." - # IMPORTANT: update NODESOURCE_SHA256 when changing setup_22.x URL - NODESOURCE_URL="https://deb.nodesource.com/setup_22.x" - NODESOURCE_SHA256="575583bbac2fccc0b5edd0dbc03e222d9f9dc8d724da996d22754d6411104fd1" - ns_tmp="$(mktemp)" - curl -fsSL "$NODESOURCE_URL" -o "$ns_tmp" \ - || { - rm -f "$ns_tmp" - fail "Failed to download NodeSource installer" - } + case "$(uname -m)" in + x86_64) + node_arch="x64" + node_sha256="9f5eb6ac21845a66c493c91a253b1da32fd684e89e9b7202d4936982336be4ca" + ;; + aarch64 | arm64) + node_arch="arm64" + node_sha256="df224555a083b918e46260cc969838501b9f9a87140c1195e5b9597b56d5dae2" + ;; + *) fail "Unsupported Node.js architecture: $(uname -m)" ;; + esac + info "Installing Node.js ${NODE_VERSION}..." + node_tmp="$(mktemp)" + node_url="https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${node_arch}.tar.gz" + curl -fsSL --proto '=https' --tlsv1.2 "$node_url" -o "$node_tmp" || { + rm -f "$node_tmp" + fail "Failed to download Node.js archive" + } if command -v sha256sum >/dev/null 2>&1; then - actual_hash="$(sha256sum "$ns_tmp" | awk '{print $1}')" + actual_hash="$(sha256sum "$node_tmp" | awk '{print $1}')" elif command -v shasum >/dev/null 2>&1; then - actual_hash="$(shasum -a 256 "$ns_tmp" | awk '{print $1}')" + actual_hash="$(shasum -a 256 "$node_tmp" | awk '{print $1}')" else - rm -f "$ns_tmp" + rm -f "$node_tmp" fail "No SHA-256 tool available (sha256sum/shasum)" fi - if [[ "$actual_hash" != "$NODESOURCE_SHA256" ]]; then - rm -f "$ns_tmp" - fail "NodeSource installer integrity check failed\n Expected: $NODESOURCE_SHA256\n Actual: $actual_hash" + if [[ "$actual_hash" != "$node_sha256" ]]; then + rm -f "$node_tmp" + fail "Node.js archive integrity check failed\n Expected: $node_sha256\n Actual: $actual_hash" fi - info "NodeSource installer integrity verified" - sudo -E bash "$ns_tmp" >/dev/null 2>&1 - rm -f "$ns_tmp" - wait_for_apt_lock - retry 3 10 "install nodejs" sudo apt-get install -y -qq nodejs >/dev/null 2>&1 + sudo tar -xzf "$node_tmp" -C /usr/local --strip-components=1 --no-same-owner + rm -f "$node_tmp" + [[ "$(node --version)" == "v${NODE_VERSION}" ]] || fail "Node.js installation did not produce v${NODE_VERSION}" info "Node.js $(node --version) installed" fi @@ -320,6 +322,14 @@ fi info "Installing npm dependencies..." cd "$NEMOCLAW_CLONE_DIR" +reviewed_npm_tmp="$(mktemp -d)" +trap 'rm -rf "$reviewed_npm_tmp"' EXIT +sudo env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN \ + RUNNER_TEMP="$reviewed_npm_tmp" \ + bash .github/actions/setup-reviewed-npm/verify-and-install-npm.sh ci/reviewed-npm-audit.json +rm -rf "$reviewed_npm_tmp" +trap - EXIT +[[ "$(npm --version)" == "12.0.2" ]] || fail "Reviewed npm 12.0.2 installation failed" npm install --ignore-scripts 2>&1 | tail -3 info "Root deps installed" diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index 602d1f13dac..2c4feb30255 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -249,7 +249,6 @@ validate_audit_evidence() { --audit-config "$trusted_audit_config" \ --registry https://registry.yarnpkg.com \ --threshold high \ - --legacy-audit true \ --result "$audit_policy_result" [[ -f "$audit_policy_result" && -s "$audit_policy_result" && ! -L "$audit_policy_result" ]] || { echo "ERROR: protected managed-image reviewed audit policy result is missing or unsafe" >&2 diff --git a/scripts/checks/growth-guardrails-workflow-boundary.mts b/scripts/checks/growth-guardrails-workflow-boundary.mts index 902eb2cae41..4e64cedb2df 100644 --- a/scripts/checks/growth-guardrails-workflow-boundary.mts +++ b/scripts/checks/growth-guardrails-workflow-boundary.mts @@ -12,6 +12,7 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const WORKFLOW_PATH = join(ROOT, ".github", "workflows", "codebase-growth-guardrails.yaml"); const STATIC_ACTION_PATH = join(ROOT, ".github", "actions", "ci-static-checks", "action.yaml"); const CHECKOUT = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"; +const SETUP_NODE = "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020"; const TEST_COMMAND = "set -euo pipefail\nnpx vitest run --project integration test/automation/pull-requests/growth-guardrails.test.ts"; const STATIC_COMMAND = @@ -64,6 +65,15 @@ export function validateGrowthGuardrailsWorkflowBoundary( "persist-credentials": false, }, }, + { + name: "Set up Node.js", + uses: SETUP_NODE, + with: { "node-version": "24.18.1" }, + }, + { + name: "Install reviewed npm", + uses: "./.github/actions/setup-reviewed-npm", + }, { name: "Install trusted dependencies", run: "npm ci --ignore-scripts --no-audit --no-fund", diff --git a/scripts/checks/materialize-locked-npm-cache-seed.mts b/scripts/checks/materialize-locked-npm-cache-seed.mts index c446f44be70..88ad1e8286f 100644 --- a/scripts/checks/materialize-locked-npm-cache-seed.mts +++ b/scripts/checks/materialize-locked-npm-cache-seed.mts @@ -123,9 +123,24 @@ function targetAllows(entry: JsonRecord, key: keyof NpmPlatformTarget, target: s return !denied && (allowed.length === 0 || allowed.includes(target)); } -function dependencyNames(entry: JsonRecord, key: string): string[] { +function dependencyEntries( + entry: JsonRecord, + key: string, +): Array<{ name: string; requested: string }> { const value = entry[key]; - return value === undefined ? [] : Object.keys(record(value, `package-lock ${key}`)); + if (value === undefined) return []; + return Object.entries(record(value, `package-lock ${key}`)).map(([name, requested]) => { + if (typeof requested !== "string") { + throw new Error(`package-lock ${key} must map package names to string specifications`); + } + return { name, requested }; + }); +} + +function exactDependencyVersion(requested: string): string | undefined { + return /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/u.test(requested) + ? requested + : undefined; } function resolveDependencyPath( @@ -154,16 +169,21 @@ function resolveDependencyPath( function reachablePackagePaths(packages: JsonRecord, target: NpmPlatformTarget): string[] { const root = record(packages[""], "package-lock root package"); - const queue: Array<{ dependency: string; fromPath: string; optional: boolean; peer: boolean }> = - []; - for (const dependency of dependencyNames(root, "dependencies")) { - queue.push({ dependency, fromPath: "", optional: false, peer: false }); + const queue: Array<{ + dependency: string; + fromPath: string; + optional: boolean; + peer: boolean; + requested: string; + }> = []; + for (const { name, requested } of dependencyEntries(root, "dependencies")) { + queue.push({ dependency: name, fromPath: "", optional: false, peer: false, requested }); } - for (const dependency of dependencyNames(root, "devDependencies")) { - queue.push({ dependency, fromPath: "", optional: false, peer: false }); + for (const { name, requested } of dependencyEntries(root, "devDependencies")) { + queue.push({ dependency: name, fromPath: "", optional: false, peer: false, requested }); } - for (const dependency of dependencyNames(root, "optionalDependencies")) { - queue.push({ dependency, fromPath: "", optional: true, peer: false }); + for (const { name, requested } of dependencyEntries(root, "optionalDependencies")) { + queue.push({ dependency: name, fromPath: "", optional: true, peer: false, requested }); } const visited = new Set(); @@ -177,6 +197,12 @@ function reachablePackagePaths(packages: JsonRecord, target: NpmPlatformTarget): throw new Error(`package-lock dependency is unresolved: ${edge.dependency}`); } const entry = record(packages[packagePath], `package-lock entry ${packagePath}`); + const exactVersion = exactDependencyVersion(edge.requested); + if (exactVersion !== undefined && entry.version !== exactVersion) { + throw new Error( + `package-lock exact dependency is unresolved: ${edge.dependency}@${exactVersion} from ${edge.fromPath || "root package"}`, + ); + } const compatible = targetAllows(entry, "os", target.os) && targetAllows(entry, "cpu", target.cpu) && @@ -188,20 +214,38 @@ function reachablePackagePaths(packages: JsonRecord, target: NpmPlatformTarget): if (visited.has(packagePath)) continue; visited.add(packagePath); - for (const dependency of dependencyNames(entry, "dependencies")) { - queue.push({ dependency, fromPath: packagePath, optional: false, peer: false }); + for (const { name, requested } of dependencyEntries(entry, "dependencies")) { + queue.push({ + dependency: name, + fromPath: packagePath, + optional: false, + peer: false, + requested, + }); } - for (const dependency of dependencyNames(entry, "optionalDependencies")) { - queue.push({ dependency, fromPath: packagePath, optional: true, peer: false }); + for (const { name, requested } of dependencyEntries(entry, "optionalDependencies")) { + queue.push({ + dependency: name, + fromPath: packagePath, + optional: true, + peer: false, + requested, + }); } const peerMetadata = entry.peerDependenciesMeta === undefined ? {} : record(entry.peerDependenciesMeta, "package-lock peerDependenciesMeta"); - for (const dependency of dependencyNames(entry, "peerDependencies")) { - const metadata = peerMetadata[dependency]; + for (const { name, requested } of dependencyEntries(entry, "peerDependencies")) { + const metadata = peerMetadata[name]; const optional = metadata === undefined ? false : record(metadata, "peer metadata").optional; - queue.push({ dependency, fromPath: packagePath, optional: optional === true, peer: true }); + queue.push({ + dependency: name, + fromPath: packagePath, + optional: optional === true, + peer: true, + requested, + }); } } return [...visited]; diff --git a/scripts/checks/pi-qualification-receipt-refresh.mts b/scripts/checks/pi-qualification-receipt-refresh.mts index 9b5e1a63d10..e4bc96f82d5 100644 --- a/scripts/checks/pi-qualification-receipt-refresh.mts +++ b/scripts/checks/pi-qualification-receipt-refresh.mts @@ -100,12 +100,18 @@ function mergeBaseRevision(git: GitRunner, baseBranch: string | undefined): stri } function changedPathsFromBase(git: GitRunner, revision: string): string[] { - return requireGitOutput( + const committed = requireGitOutput( git(["diff", "--no-ext-diff", "--no-textconv", "--name-only", "-z", revision, "HEAD", "--"]), `Could not inspect changes after ${revision}`, - ) - .split("\0") - .filter(Boolean); + ); + // Pre-commit runs before the receipt refresh is part of HEAD. Include the + // index so the final receipt commit can be validated without bypassing this + // check; CI still validates the committed comparison with an empty index. + const staged = requireGitOutput( + git(["diff", "--no-ext-diff", "--no-textconv", "--name-only", "-z", "--cached", "--"]), + "Could not inspect staged Pi receipt changes", + ); + return [...new Set(`${committed}${staged}`.split("\0").filter(Boolean))]; } function piImageSourcePaths(rootDir: string): string[] { diff --git a/scripts/e2e/restore-cli-artifact.sh b/scripts/e2e/restore-cli-artifact.sh index a2cbcafb441..6a9435db083 100755 --- a/scripts/e2e/restore-cli-artifact.sh +++ b/scripts/e2e/restore-cli-artifact.sh @@ -11,9 +11,9 @@ if [[ ! -s "$manifest" || ! -s "$payload" ]]; then echo "::error::exact-commit CLI artifact is incomplete" exit 1 fi -[[ "$(node --version)" =~ ^v22\.[0-9]+\.[0-9]+$ ]] \ +[[ "$(node --version)" == "v24.18.1" ]] \ || { - echo "::error::consumer must restore the CLI under the pinned Node 22 toolchain" + echo "::error::consumer must restore the CLI under the pinned Node 24.18.1 toolchain" exit 1 } @@ -49,8 +49,8 @@ jq -e \ .workflow.sha == $workflowSha and .workflow.runId == $runId and .workflow.runAttempt == $runAttempt and - (.toolchain.node | strings | test("^v22\\.[0-9]+\\.[0-9]+$")) and - (.toolchain.npm | strings | test("^[0-9]+\\.[0-9]+\\.[0-9]+$")) and + .toolchain.node == "v24.18.1" and + .toolchain.npm == "12.0.2" and .toolchain.runnerOs == "Linux" and .toolchain.runnerArch == "X64" and .build.command == "npm run build:cli" and diff --git a/scripts/lib/npm-audit-receipt.mts b/scripts/lib/npm-audit-receipt.mts index e74bd813109..2293ac949b9 100755 --- a/scripts/lib/npm-audit-receipt.mts +++ b/scripts/lib/npm-audit-receipt.mts @@ -17,10 +17,6 @@ import { } from "./reviewed-npm-audit.mts"; export const AUDIT_ARGV = NPM_AUDIT_ARGV; -// Remove this bridge after main produces schema v2 receipts and retained legacy receipts expire. -const LEGACY_NPM_AUDIT_REGISTRY = "https://registry.npmjs.org/"; -const LEGACY_NPM_AUDIT_ARGV = ["audit", "--omit=dev", "--json"] as const; -export const LEGACY_NPM_AUDIT_RECEIPT_DEADLINE = Date.parse("2026-09-18T00:00:00.000Z"); export const RECEIPT_LIFETIME_MS = 12 * 60 * 60 * 1000 - 1; export const MAX_FUTURE_SKEW_MS = 5 * 60 * 1000; const SEVERITIES = new Set(["info", "low", "moderate", "high", "critical"]); @@ -46,10 +42,6 @@ const RECEIPT_KEYS = [ "schemaVersion", "severityThreshold", ]; -const LEGACY_RECEIPT_KEYS = RECEIPT_KEYS.filter( - (key) => key !== "npmArchiveSha256" && key !== "npmIntegrity", -); - export type AuditReceipt = Readonly<{ acceptedAdvisoryIds: readonly string[]; argv: readonly string[]; @@ -70,14 +62,6 @@ export type AuditReceipt = Readonly<{ severityThreshold: "info" | "low" | "moderate" | "high" | "critical"; }>; -type LegacyAuditReceipt = Readonly< - Omit & { - schemaVersion: 1; - } ->; - -type VerifiedAuditReceipt = AuditReceipt | LegacyAuditReceipt; - export function sha256(contents: string | Buffer): string { return createHash("sha256").update(contents).digest("hex"); } @@ -231,9 +215,8 @@ export function parseAndVerifyAuditReceipt( rawResponse: string | Buffer; registryOrigin: string; now?: Date; - allowLegacyReceipt?: boolean; }>, -): VerifiedAuditReceipt { +): AuditReceipt { let parsed: unknown; try { parsed = JSON.parse(contents); @@ -243,17 +226,10 @@ export function parseAndVerifyAuditReceipt( if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("receipt must be an object"); const value = parsed as Record; + exactKeys(value, RECEIPT_KEYS, "receipt"); + if (value.schemaVersion !== 2 || value.result !== "pass") + throw new Error("receipt is not a passing schema version 2 receipt"); const now = (expected.now ?? new Date()).getTime(); - const isLegacyReceipt = value.schemaVersion === 1; - exactKeys(value, isLegacyReceipt ? LEGACY_RECEIPT_KEYS : RECEIPT_KEYS, "receipt"); - if (value.result !== "pass" || (!isLegacyReceipt && value.schemaVersion !== 2)) - throw new Error("receipt is not a passing supported receipt"); - if ( - isLegacyReceipt && - (expected.allowLegacyReceipt !== true || now >= LEGACY_NPM_AUDIT_RECEIPT_DEADLINE) - ) { - throw new Error("version-only receipt schema is outside the allowed legacy transition"); - } const reviewedNpmIdentity = parseReviewedNpmIdentity(expected.reviewedNpmIdentity); if ( expected.approvedPackageLockSha256s.length === 0 || @@ -265,26 +241,19 @@ export function parseAndVerifyAuditReceipt( if ( value.graphId !== expected.graphId || value.npmVersion !== reviewedNpmIdentity.npmVersion || - (!isLegacyReceipt && - (value.npmIntegrity !== reviewedNpmIdentity.npmIntegrity || - value.npmArchiveSha256 !== reviewedNpmIdentity.npmArchiveSha256)) + value.npmIntegrity !== reviewedNpmIdentity.npmIntegrity || + value.npmArchiveSha256 !== reviewedNpmIdentity.npmArchiveSha256 ) { throw new Error("receipt identity does not match expected graph and reviewed npm"); } - const currentContract = - value.registryOrigin === expected.registryOrigin && - Array.isArray(value.argv) && - value.argv.length === AUDIT_ARGV.length && - value.argv.every((arg, index) => arg === AUDIT_ARGV[index]); - const legacyContract = - expected.allowLegacyReceipt === true && - now < LEGACY_NPM_AUDIT_RECEIPT_DEADLINE && - value.registryOrigin === LEGACY_NPM_AUDIT_REGISTRY && - Array.isArray(value.argv) && - value.argv.length === LEGACY_NPM_AUDIT_ARGV.length && - value.argv.every((arg, index) => arg === LEGACY_NPM_AUDIT_ARGV[index]); - if (!currentContract && !legacyContract) - throw new Error("receipt npm audit registry and arguments do not match an allowed contract"); + if ( + value.registryOrigin !== expected.registryOrigin || + !Array.isArray(value.argv) || + value.argv.length !== AUDIT_ARGV.length || + value.argv.some((arg, index) => arg !== AUDIT_ARGV[index]) + ) { + throw new Error("receipt npm audit registry and arguments do not match the reviewed contract"); + } const accepted = stringArray(value.acceptedAdvisoryIds, "acceptedAdvisoryIds"); const blocking = stringArray(value.blockingAdvisoryIds, "blockingAdvisoryIds"); if (blocking.length !== 0) throw new Error("passing receipt contains blocking advisories"); @@ -320,7 +289,7 @@ export function parseAndVerifyAuditReceipt( }; } -export function canonicalAuditReceipt(receipt: VerifiedAuditReceipt): string { +export function canonicalAuditReceipt(receipt: AuditReceipt): string { return `${JSON.stringify(receipt, null, 2)}\n`; } @@ -330,7 +299,7 @@ function cli(args: readonly string[]): void { const value = args[index + 1]; if (!args[index]?.startsWith("--") || value === undefined) throw new Error( - "usage: npm-audit-receipt.mts --receipt FILE --package-json FILE --package-lock FILE --raw-report FILE --exceptions FILE --graph ID --audit-config FILE --registry ORIGIN --threshold SEVERITY [--legacy-audit true] [--result FILE]", + "usage: npm-audit-receipt.mts --receipt FILE --package-json FILE --package-lock FILE --raw-report FILE --exceptions FILE --graph ID --audit-config FILE --registry ORIGIN --threshold SEVERITY [--result FILE]", ); values.set(args[index], value); } @@ -345,11 +314,9 @@ function cli(args: readonly string[]): void { "--registry", "--threshold", ]; - const allowed = [...required, "--result", "--legacy-audit"]; + const allowed = [...required, "--result"]; exactKeys( - Object.fromEntries( - [...values].filter(([key]) => key !== "--result" && key !== "--legacy-audit"), - ), + Object.fromEntries([...values].filter(([key]) => key !== "--result")), required, "verifier arguments", ); @@ -372,7 +339,6 @@ function cli(args: readonly string[]): void { packageLock, rawResponse, registryOrigin: values.get("--registry")!, - allowLegacyReceipt: values.get("--legacy-audit") === "true", }); const policyResult = evaluateAuditPolicy({ directory: path.dirname(values.get("--package-json")!), diff --git a/scripts/lib/openclaw-npm-remediation.mts b/scripts/lib/openclaw-npm-remediation.mts index fdfae957879..d4495db2315 100755 --- a/scripts/lib/openclaw-npm-remediation.mts +++ b/scripts/lib/openclaw-npm-remediation.mts @@ -20,7 +20,7 @@ import { } from "node:fs"; import { basename, join, resolve, sep } from "node:path"; import { pathToFileURL } from "node:url"; -import { packReviewedNpmArchive } from "./reviewed-npm-archive.mts"; +import { packReviewedNpmArchive, singleNpmPackResult } from "./reviewed-npm-archive.mts"; type JsonObject = Record; @@ -138,25 +138,25 @@ const REMEDIATIONS: Readonly> = Object.freeze({ // #7337: remove this branch only after a reviewed diagnostics release ships a safe SDK graph. "@openclaw/diagnostics-otel@2026.7.1": { expectedPatchedTreeIntegrity: - "sha512-2qyDTRPqNs97jo/pAWWfxAkVZyCXYqui/IjrGf4eEfYop1eGN8qBMJ/Kp/bJ/V18RNnYpMxHi5ECFelekVxcAQ==", + "sha512-p3TthwGT081xMnHkZNwh6WwObk/OHkNtjImllLRuTezaro09kk6uShJDHpcwRtiW5C9RdbXN5DV/QS2lGoG/zQ==", kind: "jaeger", version: "2026.7.1", }, "@openclaw/discord@2026.7.1": { expectedPatchedTreeIntegrity: - "sha512-w+F8FrRl0wPd0EN2RnLyu6yfixel7BT8Iex4wLLQDvfIac8rLhuksNpFU4uZa8W9wXgh47hguq0F9NSN0BZfOQ==", + "sha512-KUDcFJnqI3O7yKiBUh20ZijM7J7gkbpKGDw4bb8y+uO8s914US6PieyzwAzgycRXWfXPxHQx5CTDrg28PdKfaw==", kind: "undici", version: "2026.7.1", }, "@openclaw/msteams@2026.7.1": { expectedPatchedTreeIntegrity: - "sha512-FL4l65gEbbwtDd9Ogr69+xBNzIfE4YS8Hib36G+kcmX+T0oB1zL+/qs6b4bJc+ygTsh60H3yqpFbXoQeN05JYQ==", + "sha512-qk1PXcRU5r/7zWIJlwHGTBmKIuLoBYHhv6hA1w+mYs0H9JX0nBk6WccahcmiWVzu2SsryCQ4Sck6j4Fbu6kcHg==", kind: "axios", version: "2026.7.1", }, "@openclaw/slack@2026.7.1": { expectedPatchedTreeIntegrity: - "sha512-4ThnsNS+yBlFSkTaQn2xosxrDu1s0vrxcqka5QqFj+8dCEaTa9JVLRgNniYV/QNhO53wc7a2R5oQFElzYspT2w==", + "sha512-A23af8PA4KuO8vju0viceyj1Y0M7ywF66TxKZZ8rI21L/TSn8RrzqiSaOV4UewV3/PLjDz+lY5lY+qbycOCBfg==", kind: "axios", version: "2026.7.1", }, @@ -168,9 +168,12 @@ const REMEDIATIONS: Readonly> = Object.freeze({ }, // openclaw/openclaw#113584: remove after a supported OpenClaw archive // publishes every corrected dependency identity in its manifest and shrinkwrap. + // npm 12 no longer packs or honors npm-shrinkwrap.json. The reviewed archive + // still ships the complete bundleDependencies tree, and this digest pins that + // exact npm 12 packed tree so either a packlist or bundled-graph change fails. "openclaw@2026.7.1": { expectedPatchedTreeIntegrity: - "sha512-PzF1Lyw0yIo3mr7mNGql7azYoioDP+jQ47gERww6vgb9iyKnEWcscScsvv1IOt9yCp6BJTLxcRYYe7X0s95BnA==", + "sha512-j/ArEzhwh+FDiIqgKQBFMiDUk5wHOHzGxbvl5hnh2W8X0nTpJ5oW/VzO8T5B7HqI6MVlQp4NLadFveN209TLLg==", kind: "current-core", version: "2026.7.1", }, @@ -1379,10 +1382,11 @@ export function buildRemediatedOpenClawPluginArchive( env, ); const packed = JSON.parse(packedJson); - if (!Array.isArray(packed) || packed.length !== 1 || typeof packed[0]?.filename !== "string") { + const packedResult = singleNpmPackResult(packed); + if (typeof packedResult?.filename !== "string") { throw new Error(`npm pack returned an invalid remediation result for ${request.packageSpec}`); } - const archivePath = resolve(outputDirectory, basename(packed[0].filename)); + const archivePath = resolve(outputDirectory, basename(packedResult.filename)); validateArchiveMembers(archivePath, remediationRoot, env); const packedPackage = extractArchive( archivePath, diff --git a/scripts/lib/patch-bundled-npm-ip-address.mts b/scripts/lib/patch-bundled-npm-ip-address.mts index 7acc29f1ecb..041fb975dcc 100755 --- a/scripts/lib/patch-bundled-npm-ip-address.mts +++ b/scripts/lib/patch-bundled-npm-ip-address.mts @@ -29,6 +29,9 @@ import { rejectUnsafePackageTree, requireRealDirectory as realDirectory, } from "./bundled-npm-package.mts"; +import { REVIEWED_NPM_VERSION } from "./reviewed-npm-identity.mts"; + +export { REVIEWED_NPM_VERSION }; export const AFFECTED_IP_ADDRESS_VERSION = "10.2.0"; export const FIXED_IP_ADDRESS_VERSION = "10.3.1"; @@ -36,7 +39,6 @@ export const FIXED_IP_ADDRESS_INTEGRITY = "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g=="; export const FIXED_IP_ADDRESS_TARBALL = "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz"; -export const REVIEWED_NPM_VERSION = "11.18.0"; const REVIEWED_IP_ADDRESS_VERSIONS = new Set([ AFFECTED_IP_ADDRESS_VERSION, diff --git a/scripts/lib/patch-openclaw-npm12-pack-json.mts b/scripts/lib/patch-openclaw-npm12-pack-json.mts new file mode 100755 index 00000000000..495377e7694 --- /dev/null +++ b/scripts/lib/patch-openclaw-npm12-pack-json.mts @@ -0,0 +1,83 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const LEGACY_ENTRIES = "const entries = Array.isArray(parsed) ? parsed : [parsed];"; +const NPM12_ENTRIES = + 'const entries = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && !("name" in parsed) && !("version" in parsed) && !("filename" in parsed) && !("id" in parsed) ? Object.values(parsed) : [parsed]; /* nemoclaw: npm 12 keyed npm pack JSON */'; +const REVIEWED_LAYOUTS = { + "2026.3.11": { expectedFiles: 2, filename: /^npm-pack-install-[A-Za-z0-9_-]+\.js$/ }, + "2026.4.24": { expectedFiles: 1, filename: /^install-source-utils-[A-Za-z0-9_-]+\.js$/ }, + "2026.7.1": { expectedFiles: 1, filename: /^install-source-utils-[A-Za-z0-9_-]+\.js$/ }, +} as const; + +function occurrences(contents: string, needle: string): number { + return contents.split(needle).length - 1; +} + +export function patchOpenClawNpm12PackJson( + distDirectory: string, + openClawVersion: string, +): "already-patched" | "patched" { + const layout = REVIEWED_LAYOUTS[openClawVersion as keyof typeof REVIEWED_LAYOUTS]; + if (!layout) throw new Error(`OpenClaw ${openClawVersion} has no reviewed npm 12 parser layout`); + const candidates = fs + .readdirSync(distDirectory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && layout.filename.test(entry.name)) + .map((entry) => path.join(distDirectory, entry.name)); + + const inspected = candidates.map((file) => ({ + contents: fs.readFileSync(file, "utf8"), + file, + })); + const legacyMatches = inspected.flatMap(({ contents, file }) => + Array.from({ length: occurrences(contents, LEGACY_ENTRIES) }, () => file), + ); + const patchedMatches = inspected.flatMap(({ contents, file }) => + Array.from({ length: occurrences(contents, NPM12_ENTRIES) }, () => file), + ); + + if (candidates.length !== layout.expectedFiles) { + throw new Error( + `OpenClaw ${openClawVersion} npm pack JSON parser file count is unsupported: expected=${layout.expectedFiles}, found=${candidates.length}`, + ); + } + if (legacyMatches.length === 0 && patchedMatches.length === layout.expectedFiles) + return "already-patched"; + if (legacyMatches.length !== layout.expectedFiles || patchedMatches.length !== 0) { + throw new Error( + `OpenClaw ${openClawVersion} npm pack JSON parser shape is unsupported: legacy=${legacyMatches.length}, patched=${patchedMatches.length}`, + ); + } + + for (const target of legacyMatches) { + const contents = fs.readFileSync(target, "utf8"); + const patched = contents.replace(LEGACY_ENTRIES, NPM12_ENTRIES); + if (occurrences(patched, LEGACY_ENTRIES) !== 0 || occurrences(patched, NPM12_ENTRIES) !== 1) + throw new Error("OpenClaw npm pack JSON parser patch verification failed"); + fs.writeFileSync(target, patched); + } + return "patched"; +} + +function cli(args: readonly string[]): void { + if (args.length !== 2) + throw new Error( + "usage: patch-openclaw-npm12-pack-json.mts OPENCLAW_DIST_DIRECTORY OPENCLAW_VERSION", + ); + const result = patchOpenClawNpm12PackJson(path.resolve(args[0]!), args[1]!); + console.log(`OpenClaw npm 12 pack JSON parser ${result}`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + try { + cli(process.argv.slice(2)); + } 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 216d7c33743..c3bedbf6620 100755 --- a/scripts/lib/reviewed-npm-archive.mts +++ b/scripts/lib/reviewed-npm-archive.mts @@ -81,6 +81,18 @@ export type ReviewedNpmArchiveFileRequest = Readonly<{ type NpmRunner = (args: readonly string[], request: ReviewedNpmArchiveRequest) => string; +export function singleNpmPackResult(value: unknown): Record | undefined { + const entries = Array.isArray(value) + ? value + : typeof value === "object" && value !== null + ? Object.values(value) + : []; + const entry = entries.length === 1 ? entries[0] : undefined; + return typeof entry === "object" && entry !== null + ? (entry as Record) + : undefined; +} + function runNpm(args: readonly string[], request: ReviewedNpmArchiveRequest): string { const result = spawnSync(request.npmExecutable ?? "npm", args, { encoding: "utf-8", @@ -211,7 +223,7 @@ export function packReviewedNpmArchive( ); try { const packJson = npmRunner( - ["pack", request.tarballUrl, "--pack-destination", rootDirectory, "--json"], + ["pack", request.packageSpec, "--pack-destination", rootDirectory, "--json"], request, ); let parsed: unknown; @@ -220,7 +232,7 @@ export function packReviewedNpmArchive( } catch (error) { throw new Error(`npm pack ${request.packageSpec} did not return JSON: ${String(error)}`); } - const entry = Array.isArray(parsed) && parsed.length === 1 ? parsed[0] : undefined; + const entry = singleNpmPackResult(parsed); const filename = typeof entry === "object" && entry !== null && "filename" in entry ? String(entry.filename ?? "") diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index 7c218421cef..699e26700cc 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -107,8 +107,13 @@ export type AuditCacheEvidence = Readonly<{ }>; export type AuditProvenance = Readonly<{ - schemaVersion: 1; - scanner: Readonly<{ name: "npm audit"; npmVersion: string; nodeVersion: string }>; + schemaVersion: 2; + scanner: Readonly<{ + name: "npm audit"; + npmIntegrity: string; + npmVersion: string; + nodeVersion: string; + }>; registry: AuditEndpoints; run: Readonly<{ startedAt: string; finishedAt: string }>; graph: Readonly<{ label: string; packageSpecs: readonly string[] }>; @@ -121,6 +126,7 @@ export type AuditProvenance = Readonly<{ export type AuditProvenanceContext = Readonly<{ label: string; nodeVersion: string; + npmIntegrity: string; npmVersion: string; packageSpecs: readonly string[]; }>; @@ -632,6 +638,7 @@ export function buildAuditProvenance( finishedAt: string; label: string; nodeVersion: string; + npmIntegrity: string; npmVersion: string; packageSpecs: readonly string[]; rawReportPath: string; @@ -641,8 +648,13 @@ export function buildAuditProvenance( }>, ): AuditProvenance { return { - schemaVersion: 1, - scanner: { name: "npm audit", npmVersion: input.npmVersion, nodeVersion: input.nodeVersion }, + schemaVersion: 2, + scanner: { + name: "npm audit", + npmIntegrity: input.npmIntegrity, + npmVersion: input.npmVersion, + nodeVersion: input.nodeVersion, + }, registry: deriveAuditEndpoints(input.registry), run: { startedAt: input.startedAt, finishedAt: input.finishedAt }, graph: { label: input.label, packageSpecs: input.packageSpecs }, @@ -761,6 +773,7 @@ function parseAuditCacheRecord(source: string): AuditCacheRecord { throw new Error(`npm audit cache input.${key} is invalid`); } if ( + !/^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(String(input.npmIntegrity)) || input.parserIdentity !== NPM_AUDIT_PARSER_IDENTITY || canonicalRegistryOrigin(String(input.registryOrigin)) !== input.registryOrigin ) @@ -1094,6 +1107,7 @@ export function runReviewedNpmAudit( finishedAt, label: options.provenance.label, nodeVersion: options.provenance.nodeVersion, + npmIntegrity: options.provenance.npmIntegrity, npmVersion: options.provenance.npmVersion, packageSpecs: options.provenance.packageSpecs, rawReportPath: path.basename(options.reportFile), diff --git a/scripts/lib/reviewed-npm-identity.mts b/scripts/lib/reviewed-npm-identity.mts new file mode 100755 index 00000000000..d8f2745a8c5 --- /dev/null +++ b/scripts/lib/reviewed-npm-identity.mts @@ -0,0 +1,37 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync } from "node:fs"; + +type ReviewedNpmIdentity = Readonly<{ + npmArchiveSha256: string; + npmIntegrity: string; + npmVersion: string; + registryOrigin: string; +}>; + +const identity = JSON.parse( + readFileSync(new URL("../../ci/reviewed-npm-audit.json", import.meta.url), "utf8"), +) as ReviewedNpmIdentity; + +if (!/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u.test(identity.npmVersion)) { + throw new Error("reviewed npm audit configuration has an invalid npmVersion"); +} +if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(identity.npmIntegrity)) { + throw new Error("reviewed npm audit configuration has an invalid npmIntegrity"); +} +if (!/^[a-f0-9]{64}$/u.test(identity.npmArchiveSha256)) { + throw new Error("reviewed npm audit configuration has an invalid npmArchiveSha256"); +} +if (identity.registryOrigin !== "https://registry.npmjs.org/") { + throw new Error("reviewed npm audit configuration has an invalid registryOrigin"); +} + +export const REVIEWED_NPM_VERSION = identity.npmVersion; +export const REVIEWED_NPM_INTEGRITY = identity.npmIntegrity; +export const REVIEWED_NPM_ARCHIVE_SHA256 = identity.npmArchiveSha256; +export const REVIEWED_NPM_TARBALL = new URL( + `npm/-/npm-${REVIEWED_NPM_VERSION}.tgz`, + identity.registryOrigin, +).href; diff --git a/scripts/lib/seed-reviewed-npm-cache.mts b/scripts/lib/seed-reviewed-npm-cache.mts index b49c47805e4..e2b949b182a 100755 --- a/scripts/lib/seed-reviewed-npm-cache.mts +++ b/scripts/lib/seed-reviewed-npm-cache.mts @@ -12,6 +12,7 @@ import { lockedArchives, type NpmPlatformTarget, } from "../checks/materialize-locked-npm-cache-seed.mts"; +import { REVIEWED_NPM_VERSION } from "./reviewed-npm-identity.mts"; import { readReviewedNpmArchiveFile, type ReviewedNpmArchiveRequest, @@ -55,7 +56,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", "11.18.0"]); +const REVIEWED_CI_NPM_VERSIONS = new Set([REVIEWED_NPM_VERSION]); function packageNameFromLockLocation(location: string): string { const marker = "node_modules/"; @@ -208,7 +209,7 @@ 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, npm@11.17.0, or npm@11.18.0`, + `reviewed npm cache seed does not support npm@${npmVersion}; expected npm@${REVIEWED_NPM_VERSION}`, ); } const npmRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim(); @@ -321,6 +322,10 @@ export async function seedReviewedNpmCache( }), ); const url = packumentUrl(registryOrigin, packageName); + // npm view requests full metadata while npm ci and npm pack request the + // compact install representation. Keep both Accept variants bound to the + // same lock-derived bytes. Consumers that run `npm cache verify` must seed + // these variant records afterward because verification keeps one URL entry. for (const accept of [INSTALL_ACCEPT, "application/json"]) { await put(cachePath, `make-fetch-happen:request-cache:${url}`, packument, { metadata: { diff --git a/scripts/patch-bundled-npm-brace-expansion.mts b/scripts/patch-bundled-npm-brace-expansion.mts index d2a18a2d693..97ccf41b081 100755 --- a/scripts/patch-bundled-npm-brace-expansion.mts +++ b/scripts/patch-bundled-npm-brace-expansion.mts @@ -28,6 +28,9 @@ import { rejectUnsafePackageTree, requireRealDirectory as realDirectory, } from "./lib/bundled-npm-package.mts"; +import { REVIEWED_NPM_VERSION } from "./lib/reviewed-npm-identity.mts"; + +export { REVIEWED_NPM_VERSION }; export const AFFECTED_BRACE_EXPANSION_VERSION = "5.0.7"; export const FIXED_BRACE_EXPANSION_VERSION = "5.0.9"; @@ -35,7 +38,6 @@ export const FIXED_BRACE_EXPANSION_INTEGRITY = "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="; export const FIXED_BRACE_EXPANSION_TARBALL = "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz"; -export const REVIEWED_NPM_VERSION = "11.18.0"; const REVIEWED_BRACE_EXPANSION_VERSIONS = new Set([ AFFECTED_BRACE_EXPANSION_VERSION, diff --git a/scripts/patch-bundled-npm-tar.mts b/scripts/patch-bundled-npm-tar.mts index 76ff1fd8449..1bed501f66a 100755 --- a/scripts/patch-bundled-npm-tar.mts +++ b/scripts/patch-bundled-npm-tar.mts @@ -44,8 +44,7 @@ export const MINIMUM_SAFE_TAR_VERSION = "7.5.21"; * The Dockerfile contract test forces that review whenever either pin changes. */ export const NODE_BASES_REQUIRING_BUNDLED_NPM_TAR_PATCH = [ - "node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c", - "node:24-trixie-slim@sha256:05c08ce4291e9a58f59456a7985176defb12cdd42271f35ff81a3e167ea61d4c", + "node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc", ] as const; function parseVersion(version: unknown, label: string): readonly [number, number, number] { @@ -80,7 +79,7 @@ export function inspectBundledNpmTar(npmRoot: string): BundledNpmTarState { const bundleDependencies = manifest.bundleDependencies; if ( manifest.name !== "npm" || - (npmMajor !== 10 && npmMajor !== 11) || + (npmMajor !== 10 && npmMajor !== 11 && npmMajor !== 12) || typeof tarRange !== "string" || !/^\^7\.5\.(0|[1-9]\d*)$/u.test(tarRange) || !Array.isArray(bundleDependencies) || diff --git a/scripts/update-docker-pin.sh b/scripts/update-docker-pin.sh index 7d9142e02a7..7efdaa863f2 100755 --- a/scripts/update-docker-pin.sh +++ b/scripts/update-docker-pin.sh @@ -2,9 +2,9 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Updates the pinned sha256 digest for node:22-trixie-slim in the Dockerfile. +# Updates the pinned sha256 digest for node:24.18.1-trixie-slim in the Dockerfile. # Queries Docker Hub for the current multi-arch image-index digest and -# rewrites every FROM line that references node:22-trixie-slim. +# rewrites every FROM line that references node:24.18.1-trixie-slim. # # Usage: # scripts/update-docker-pin.sh # update Dockerfile in repo root @@ -21,7 +21,7 @@ case "${1:-}" in esac IMAGE="node" -TAG="22-trixie-slim" +TAG="24.18.1-trixie-slim" DOCKERFILE="${DOCKERFILE:-Dockerfile}" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" DOCKERFILE_PATH="${REPO_ROOT}/${DOCKERFILE}" diff --git a/scripts/upgrade-bundled-npm.mts b/scripts/upgrade-bundled-npm.mts index 972c5c5bac1..f4384b0dbca 100755 --- a/scripts/upgrade-bundled-npm.mts +++ b/scripts/upgrade-bundled-npm.mts @@ -23,23 +23,32 @@ import { readJsonObject as readJson, requireRealDirectory as realDirectory, } from "./lib/bundled-npm-package.mts"; +import { + REVIEWED_NPM_ARCHIVE_SHA256, + REVIEWED_NPM_INTEGRITY, + REVIEWED_NPM_TARBALL, + REVIEWED_NPM_VERSION, +} from "./lib/reviewed-npm-identity.mts"; -export const REVIEWED_NPM_VERSION = "11.18.0"; -export const REVIEWED_NPM_INTEGRITY = - "sha512-T67M4L5wNm0cZ7EBLErcEkY1SmzEW/WJ+SADBzsFUY1UdAPfFHXFQtZ6SEXiK0+vzXysCvAsepbMaBTwnrAD+w=="; -export const REVIEWED_NPM_TARBALL = "https://registry.npmjs.org/npm/-/npm-11.18.0.tgz"; +export { + REVIEWED_NPM_ARCHIVE_SHA256, + REVIEWED_NPM_INTEGRITY, + REVIEWED_NPM_TARBALL, + REVIEWED_NPM_VERSION, +}; // This is the immutable upstream archive inventory, not the completed image -// state. npm 11.18.0 includes affected tar 7.5.19, so every image composition +// state. npm 12.0.2 includes affected tar 7.5.19, so every image composition // patches the private tar tree again after installing this reviewed archive. export const REVIEWED_NPM_PACKAGES = { "brace-expansion": "5.0.7", - picomatch: "4.0.4", - sigstore: "4.1.1", + "ip-address": "10.2.0", + picomatch: "4.0.5", + sigstore: "5.0.0", tar: "7.5.19", } as const; -const REPLACEABLE_NPM_VERSIONS = new Set(["10.9.8", "11.13.0", "11.16.0"]); +const REPLACEABLE_NPM_VERSIONS = new Set(["10.9.8", "11.13.0", "11.16.0", "11.18.0"]); function npmVersion(npmRoot: string): string { const manifest = readJson(join(npmRoot, "package.json"), "npm package manifest"); @@ -111,6 +120,7 @@ export function verifyReviewedNpm(npmRoot: string): ReviewedNpmState { npmVersion: version, packages: { "brace-expansion": packages.get("brace-expansion") ?? [], + "ip-address": packages.get("ip-address") ?? [], picomatch: packages.get("picomatch") ?? [], sigstore: packages.get("sigstore") ?? [], tar: packages.get("tar") ?? [], @@ -183,6 +193,7 @@ function prepareReviewedNpmArchive(commandRunner: BundledNpmCommandRunner): Prep } export type BundledNpmUpgradeDependencies = Readonly<{ + archivePath?: string; commandRunner?: BundledNpmCommandRunner; installArchive?: (archivePath: string, commandRunner: BundledNpmCommandRunner) => void; prepareArchive?: (commandRunner: BundledNpmCommandRunner) => PreparedArchive; @@ -192,10 +203,13 @@ function installReviewedNpm(archivePath: string, commandRunner: BundledNpmComman commandRunner("npm", [ "install", "--global", + archivePath, + "--userconfig", + "/dev/null", "--ignore-scripts", "--no-audit", "--no-fund", - archivePath, + "--offline", ]); } @@ -207,19 +221,31 @@ export function upgradeBundledNpm( const currentVersion = npmVersion(root); const commandRunner = dependencies.commandRunner ?? run; - if (currentVersion === REVIEWED_NPM_VERSION) { + if (dependencies.archivePath !== undefined && dependencies.prepareArchive !== undefined) { + throw new Error("reviewed npm upgrade cannot select both archivePath and prepareArchive"); + } + + const explicitArchiveSource = + dependencies.archivePath !== undefined || dependencies.prepareArchive !== undefined; + if (currentVersion === REVIEWED_NPM_VERSION && !explicitArchiveSource) { const reviewed = verifyReviewedNpm(root); commandRunner("npm", ["--version"]); commandRunner("npx", ["--version"]); return reviewed; } - if (!REPLACEABLE_NPM_VERSIONS.has(currentVersion)) { + if (currentVersion !== REVIEWED_NPM_VERSION && !REPLACEABLE_NPM_VERSIONS.has(currentVersion)) { throw new Error( `npm@${currentVersion} is outside the reviewed upgrade path to npm@${REVIEWED_NPM_VERSION}`, ); } - const prepared = (dependencies.prepareArchive ?? prepareReviewedNpmArchive)(commandRunner); + const prepared = dependencies.archivePath + ? (() => { + const archivePath = resolve(dependencies.archivePath); + verifyReviewedNpmArchive(archivePath); + return { archivePath, cleanup: () => undefined }; + })() + : (dependencies.prepareArchive ?? prepareReviewedNpmArchive)(commandRunner); try { (dependencies.installArchive ?? installReviewedNpm)(prepared.archivePath, commandRunner); const reviewed = verifyReviewedNpm(root); @@ -238,13 +264,23 @@ function argument(name: string): string { return value; } +function optionalArgument(name: string): string | undefined { + const index = process.argv.indexOf(name); + const value = index >= 0 ? process.argv[index + 1] : undefined; + if (index >= 0 && (!value || value.startsWith("--"))) throw new Error(`${name} requires a value`); + return value; +} + function isMainModule(): boolean { return process.argv[1] ? fileURLToPath(import.meta.url) === resolve(process.argv[1]) : false; } if (isMainModule()) { try { - const result = upgradeBundledNpm(argument("--npm-root")); + const archivePath = optionalArgument("--archive"); + const result = upgradeBundledNpm(argument("--npm-root"), { + ...(archivePath ? { archivePath } : {}), + }); process.stdout.write( `Verified npm@${result.npmVersion} with ${Object.entries(result.packages) .map(([name, versions]) => `${name}@${versions.join(",")}`) diff --git a/src/lib/agent/candidate-authority.ts b/src/lib/agent/candidate-authority.ts index 84c2b633118..1989b01468a 100644 --- a/src/lib/agent/candidate-authority.ts +++ b/src/lib/agent/candidate-authority.ts @@ -13,8 +13,8 @@ export const CANDIDATE_QUALIFICATION_RECEIPT_DIGESTS: Readonly< Record > = Object.freeze({ pi: Object.freeze([ - "831231060bf96695e8e93fc9fd29c10c92e6002bfbb611226b1c7e59b1571a77", - "700582d256d8709bcda4c9cd3d0553156337cbf88a1a4930bc9dd2d8e3aee005", + "2c01a70366bf0e25e26d57c27cfa5471df6b447cd9bbfe00b804770870ceee77", + "2ed5286974d22321220a7cdab0aee5ffdb7f3f30423f8660cbccb67a6a20fcb9", ]), }); diff --git a/src/lib/onboard/dockerfile-node-runtime-refresh.test.ts b/src/lib/onboard/dockerfile-node-runtime-refresh.test.ts index 38c761dcd58..3cb41e9381f 100644 --- a/src/lib/onboard/dockerfile-node-runtime-refresh.test.ts +++ b/src/lib/onboard/dockerfile-node-runtime-refresh.test.ts @@ -43,7 +43,7 @@ function patchNodeRefresh(options: { dockerfilePath, [ "ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest", - "FROM node:22-trixie-slim@sha256:builder AS builder", + "FROM node:24.18.1-trixie-slim@sha256:builder AS builder", "FROM ${BASE_IMAGE}", NODE_REFRESH, ].join("\n"), @@ -91,10 +91,11 @@ describe("managed OpenClaw Node runtime refresh", () => { const root = path.resolve(import.meta.dirname, "../../.."); const dockerfile = fs.readFileSync(path.join(root, "Dockerfile"), "utf8"); const baseDockerfile = fs.readFileSync(path.join(root, "Dockerfile.base"), "utf8"); - const builderImage = dockerfile.match(/^FROM (node:[^\s]+) AS builder$/m)?.[1]; + const reviewedNpmImage = dockerfile.match(/^FROM (node:[^\s]+) AS npm12$/m)?.[1]; const baseImage = baseDockerfile.match(/^FROM (node:[^\s]+)$/m)?.[1]; - expect(builderImage).toMatch(/^node:22-trixie-slim@sha256:[0-9a-f]{64}$/); - expect(baseImage).toBe(builderImage); + expect(dockerfile).toContain("FROM npm12 AS builder"); + expect(reviewedNpmImage).toMatch(/^node:24\.18\.1-trixie-slim@sha256:[0-9a-f]{64}$/); + expect(baseImage).toBe(reviewedNpmImage); }); }); diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index 135da7bfc4d..2498256a1e8 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -72,10 +72,11 @@ const CANONICAL_POST_GENERATOR_INSTRUCTION_SHA256 = new Set([ "2801e488822e10a39a5586bd150279e54df4612e30c2fa782453534a466def59", "8f0861e48c0cec37faa662fccd130ab21f972ac3ed2a0ce5f4e5a1e9ec223130", "6364b77bae0a2a4449737beefac36c439333a5e37993ac404c02e375aa170515", - // Reviewed late messaging inputs, metadata setup, and runtime assertions. + // Reviewed late messaging inputs, metadata setup, npm 12 helper, and runtime assertions. "7e5f7e1dfb90e5e4b863afdfb9ba58e57e3693bdc6f47ac8c13e80bdc9eff56b", "8f5966da093ef75cefd35c2b7f1361fbf5b32e63a4a8a34cb3ac7f76a1330e5e", "4c2f29cb433ff14ca386e71373b53e88c705e2ed255b435715681a0dd64e43f9", + "f6a70c469bc8658fd78c2e07adfb7fb9d78aa1189b0966c0bbf5f1d1f1b49e32", "761ea4fbb0da5cf3390ee8f2e56f3703a7da88c96ac47fea89d0fff800f728ae", "9026e48d7294a60abe57d7fed77aaf27e8088d29c48421a0f2d1260e7a6d5bdc", // COPY --from=openclaw-runtime-payload / / diff --git a/src/lib/onboard/experimental/hermes-portable-build-context-files.ts b/src/lib/onboard/experimental/hermes-portable-build-context-files.ts index 4f5e4485763..1b00ff6a868 100644 --- a/src/lib/onboard/experimental/hermes-portable-build-context-files.ts +++ b/src/lib/onboard/experimental/hermes-portable-build-context-files.ts @@ -48,6 +48,7 @@ export const HERMES_PORTABLE_BUILD_CONTEXT_FILES = [ { path: "agents/hermes/start.sh", mode: "100755" }, { path: "agents/hermes/validate-cli-adapter.py", mode: "100755" }, { path: "agents/hermes/validate-env-secret-boundary.py", mode: "100755" }, + { path: "ci/reviewed-npm-audit.json", mode: "100644" }, { path: "nemoclaw-blueprint/blueprint.yaml", mode: "100644" }, { path: "nemoclaw-blueprint/model-specific-setup/hermes/README.md", mode: "100644" }, { @@ -133,6 +134,7 @@ export const HERMES_PORTABLE_BUILD_CONTEXT_FILES = [ { path: "scripts/lib/openclaw-npm-remediation.mts", mode: "100755" }, { path: "scripts/lib/patch-bundled-npm-ip-address.mts", mode: "100755" }, { path: "scripts/lib/reviewed-npm-archive.mts", mode: "100755" }, + { path: "scripts/lib/reviewed-npm-identity.mts", mode: "100755" }, { path: "scripts/lib/sandbox-init.sh", mode: "100755" }, { path: "scripts/lib/sandbox-rlimits.sh", mode: "100644" }, { path: "scripts/managed-bootstrap-entrypoint.c", mode: "100644" }, @@ -141,6 +143,7 @@ export const HERMES_PORTABLE_BUILD_CONTEXT_FILES = [ { path: "scripts/managed-startup-hold.sh", mode: "100755" }, { path: "scripts/patch-bundled-npm-brace-expansion.mts", mode: "100755" }, { path: "scripts/patch-bundled-npm-tar.mts", mode: "100755" }, + { path: "scripts/upgrade-bundled-npm.mts", mode: "100755" }, { path: "src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.116.json", mode: "100644", diff --git a/src/lib/onboard/experimental/hermes-portable-build-context.test.ts b/src/lib/onboard/experimental/hermes-portable-build-context.test.ts index d9607c7273d..8dd95c57421 100644 --- a/src/lib/onboard/experimental/hermes-portable-build-context.test.ts +++ b/src/lib/onboard/experimental/hermes-portable-build-context.test.ts @@ -142,6 +142,9 @@ describe("Hermes portable staged build context", testTimeoutOptions(30_000), () expect(stagedDockerfile).toMatch( /^ADD --checksum=sha256:[a-f0-9]{64} https:\/\/files[.]pythonhosted[.]org\//mu, ); + expect(stagedDockerfile).toContain( + "ADD --checksum=sha256:5dbb86c71d07a1957f2e90734092dd6a58bdcd9ebc2d8d41ca1c6e6a21d364e1 https://registry.npmjs.org/npm/-/npm-12.0.2.tgz /npm-12.0.2.tgz", + ); expect(stagedDockerfile).not.toMatch(/^RUN\s+--/mu); expect(stagedDockerfile).toContain( "COPY --from=hermes-managed-teams-wheels / /opt/nemoclaw-hermes-teams-wheels/", @@ -412,6 +415,18 @@ describe("Hermes portable staged build context", testTimeoutOptions(30_000), () expect(fs.existsSync(reservationRoot)).toBe(reservationExistedBefore); }); + it("rejects a remote npm archive that disagrees with the reviewed identity", () => { + const source = primaryCloneFixture(); + const configPath = path.join(source, "ci/reviewed-npm-audit.json"); + const identity = JSON.parse(fs.readFileSync(configPath, "utf8")) as Record; + identity.npmArchiveSha256 = "0".repeat(64); + fs.writeFileSync(configPath, `${JSON.stringify(identity, null, 2)}\n`, { mode: 0o644 }); + + expect(() => createHermesPortableBuildContextPlan(source, BUILD_SETTINGS)).toThrow( + "unsupported local or unpinned ADD instruction", + ); + }); + it.each([ { instruction: "RUN", option: "--network=none", continued: false, commented: false }, { diff --git a/src/lib/onboard/experimental/hermes-portable-build-context.ts b/src/lib/onboard/experimental/hermes-portable-build-context.ts index f8f69bd1e24..226209a8465 100644 --- a/src/lib/onboard/experimental/hermes-portable-build-context.ts +++ b/src/lib/onboard/experimental/hermes-portable-build-context.ts @@ -77,6 +77,7 @@ const LOCAL_COPY_SOURCES = [ "scripts/lib/openclaw-npm-remediation.mts", "scripts/lib/patch-bundled-npm-ip-address.mts", "scripts/lib/reviewed-npm-archive.mts", + "scripts/lib/reviewed-npm-identity.mts", "scripts/lib/sandbox-init.sh", "scripts/lib/sandbox-rlimits.sh", "scripts/managed-bootstrap-entrypoint.c", @@ -85,6 +86,8 @@ const LOCAL_COPY_SOURCES = [ "scripts/managed-startup-hold.sh", "scripts/patch-bundled-npm-brace-expansion.mts", "scripts/patch-bundled-npm-tar.mts", + "scripts/upgrade-bundled-npm.mts", + "ci/reviewed-npm-audit.json", "src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.116.json", "src/lib/hermes-managed-route.ts", "src/lib/messaging/", @@ -132,6 +135,11 @@ type DirectoryEvidence = { readonly ownerUid: string; }; +type ReviewedNpmAddAuthority = { + readonly checksum: string; + readonly source: string; +}; + export interface HermesPortableBuildContextAuthority { readonly schemaVersion: typeof CONTEXT_SCHEMA_VERSION; readonly sourceRevision: string; @@ -244,7 +252,10 @@ function sourceTokenMatches(relativePath: string, token: string): boolean { return relativePath === token; } -function parseDockerfileSources(bytes: Buffer): readonly string[] { +function parseDockerfileSources( + bytes: Buffer, + reviewedNpm: ReviewedNpmAddAuthority, +): readonly string[] { let text: string; try { text = UTF8.decode(bytes); @@ -302,11 +313,16 @@ function parseDockerfileSources(bytes: Buffer): readonly string[] { if (tokens.length < 2) fail("Dockerfile has an incomplete COPY or ADD instruction"); const sources = tokens.slice(0, -1); if (command === "ADD") { + const source = sources[0]; + const checksum = options[0]; + const isReviewedPythonArchive = source?.startsWith("https://files.pythonhosted.org/"); + const isReviewedNpmArchive = + source === reviewedNpm.source && checksum === reviewedNpm.checksum; if ( sources.length !== 1 || - !sources[0]!.startsWith("https://files.pythonhosted.org/") || options.length !== 1 || - !/^--checksum=sha256:[a-f0-9]{64}$/u.test(options[0]!) + !/^--checksum=sha256:[a-f0-9]{64}$/u.test(checksum ?? "") || + (!isReviewedPythonArchive && !isReviewedNpmArchive) ) { fail("Dockerfile has an unsupported local or unpinned ADD instruction"); } @@ -330,6 +346,32 @@ function parseDockerfileSources(bytes: Buffer): readonly string[] { return local; } +function reviewedNpmAddAuthority(sourceEntries: readonly SourceEntry[]): ReviewedNpmAddAuthority { + const config = sourceEntries.find((entry) => entry.relativePath === "ci/reviewed-npm-audit.json"); + if (config?.kind !== "file" || !config.bytes) fail("reviewed npm identity is unavailable"); + let identity: unknown; + try { + identity = JSON.parse(UTF8.decode(config.bytes)); + } catch { + fail("reviewed npm identity is invalid"); + } + if (!identity || typeof identity !== "object") fail("reviewed npm identity is invalid"); + const candidate = identity as Record; + if ( + typeof candidate.npmVersion !== "string" || + !/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u.test(candidate.npmVersion) || + typeof candidate.npmArchiveSha256 !== "string" || + !SHA256.test(candidate.npmArchiveSha256) || + candidate.registryOrigin !== "https://registry.npmjs.org/" + ) { + fail("reviewed npm identity is invalid"); + } + return { + checksum: `--checksum=sha256:${candidate.npmArchiveSha256}`, + source: new URL(`npm/-/npm-${candidate.npmVersion}.tgz`, candidate.registryOrigin).href, + }; +} + function readRevisionFile(filePath: string): { readonly bytes: Buffer; readonly evidence: RevisionEvidence["files"][number]; @@ -773,7 +815,7 @@ function capture( (entry) => entry.relativePath === SOURCE_DOCKERFILE_RELATIVE_PATH, ); if (dockerfile?.kind !== "file" || !dockerfile.bytes) fail("Dockerfile source is unavailable"); - parseDockerfileSources(dockerfile.bytes); + parseDockerfileSources(dockerfile.bytes, reviewedNpmAddAuthority(sourceEntries)); const contextEntries = renderContextEntries(sourceEntries, settings); return { authority: sourceAuthority(sourceEntries, contextEntries, revision, sourceDirectoryChain), diff --git a/src/lib/onboard/runtime-provider/podman.test.ts b/src/lib/onboard/runtime-provider/podman.test.ts index 52e6ba2cbe9..fff1ada986a 100644 --- a/src/lib/onboard/runtime-provider/podman.test.ts +++ b/src/lib/onboard/runtime-provider/podman.test.ts @@ -461,7 +461,13 @@ describe("managed Podman runtime provider", () => { }), ).toEqual({ cleared: false, failure: "cleanup-helper-image-unavailable" }); expect(cleanupCapture).toHaveBeenCalledExactlyOnceWith( - ["image", "inspect", "--format", "{{.Id}}", expect.stringContaining("node:22-trixie-slim")], + [ + "image", + "inspect", + "--format", + "{{.Id}}", + expect.stringContaining("node:24.18.1-trixie-slim"), + ], 30_000, ); }); @@ -506,7 +512,7 @@ describe("managed Podman runtime provider", () => { "inspect", "--format", "{{.Id}}", - expect.stringContaining("node:22-trixie-slim"), + expect.stringContaining("node:24.18.1-trixie-slim"), ]); }, ); diff --git a/src/lib/onboard/runtime-provider/stopped-sandbox-state-cleanup.ts b/src/lib/onboard/runtime-provider/stopped-sandbox-state-cleanup.ts index 667cfe8cc75..e713056c88b 100644 --- a/src/lib/onboard/runtime-provider/stopped-sandbox-state-cleanup.ts +++ b/src/lib/onboard/runtime-provider/stopped-sandbox-state-cleanup.ts @@ -14,7 +14,7 @@ const FULL_CONTAINER_ID_RE = /^[a-f0-9]{64}$/u; const VOLUME_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$/u; const STATE_PATH_RE = /^\/sandbox\/\.(?:openclaw|hermes)\/[A-Za-z0-9_-]+$/u; const CLEANUP_IMAGE = - "node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c"; + "node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc"; const CLEANUP_LABEL = "com.nvidia.nemoclaw.channel-cleanup"; const CLEANUP_OWNER_LABEL = `${CLEANUP_LABEL}.owner`; const CLEANUP_VOLUME_LABEL = `${CLEANUP_LABEL}.volume`; diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 24a82d73f60..dd1a46cff59 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -379,6 +379,10 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "patch-openclaw-tool-catalog.mts"), path.join(stagedScriptsDir, "patch-openclaw-tool-catalog.mts"), ); + fs.copyFileSync( + path.join(rootDir, "scripts", "lib", "patch-openclaw-npm12-pack-json.mts"), + path.join(stagedScriptsDir, "lib", "patch-openclaw-npm12-pack-json.mts"), + ); fs.copyFileSync( path.join(rootDir, "scripts", "patch-openclaw-chat-send.mts"), path.join(stagedScriptsDir, "patch-openclaw-chat-send.mts"), @@ -446,6 +450,10 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "lib", "reviewed-npm-archive.mts"), path.join(stagedScriptsDir, "lib", "reviewed-npm-archive.mts"), ); + fs.copyFileSync( + path.join(rootDir, "scripts", "lib", "reviewed-npm-identity.mts"), + path.join(stagedScriptsDir, "lib", "reviewed-npm-identity.mts"), + ); fs.copyFileSync( path.join(rootDir, "scripts", "lib", "bundled-npm-package.mts"), path.join(stagedScriptsDir, "lib", "bundled-npm-package.mts"), diff --git a/src/lib/sandbox/privileged-exec.test.ts b/src/lib/sandbox/privileged-exec.test.ts index 02918ff7bee..eb286aeee95 100644 --- a/src/lib/sandbox/privileged-exec.test.ts +++ b/src/lib/sandbox/privileged-exec.test.ts @@ -31,7 +31,7 @@ const lifecycleGenerationPath = require.resolve("../state/registry/lifecycle-gen const lifecycleGenerationCasPath = require.resolve("../state/registry/lifecycle-generation-cas"); const { buildStoppedDockerSandboxChannelCleanupScript } = require(helperPath); const PINNED_CLEANUP_IMAGE = - "node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c"; + "node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc"; const EXPECTED_WECHAT_STATE_PATHS = [ "/sandbox/.openclaw/wechat", "/sandbox/.openclaw/openclaw-weixin", diff --git a/test/agents/hermes/hermes-dashboard-provisioning.test.ts b/test/agents/hermes/hermes-dashboard-provisioning.test.ts index 4752a3aa0c5..70739840a4e 100644 --- a/test/agents/hermes/hermes-dashboard-provisioning.test.ts +++ b/test/agents/hermes/hermes-dashboard-provisioning.test.ts @@ -6,17 +6,26 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { dockerRunCommandBetween, runLoggedDockerShell } from "../../helpers/dockerfile-run-shell"; +import { + dockerRunCommandBetween, + runLoggedDockerShell, + shellCommandSegmentBetween, +} from "../../helpers/dockerfile-run-shell"; const ROOT = path.resolve(import.meta.dirname, "../../.."); const HERMES_DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); function dashboardBuildCommand(hermesRoot: string, rootCache: string): string { const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); - return dockerRunCommandBetween( + const finalization = dockerRunCommandBetween( dockerfile, - "# Published base images can lag Dockerfile.base", - "# Harden: remove unnecessary build tools", + "# Keep the inherited CLI, Python environment, and dashboard explicit", + "# Hermes' WeChat adapter", + ); + return shellCommandSegmentBetween( + finalization, + "if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]", + "dpkg-query -W", ) .replaceAll("/opt/hermes", hermesRoot) .replaceAll("/root/.npm", path.join(rootCache, "npm")) diff --git a/test/agents/openclaw/openclaw-2026-6-npm-remediation.test.ts b/test/agents/openclaw/openclaw-2026-6-npm-remediation.test.ts index 7b480c419fc..de3c677808f 100644 --- a/test/agents/openclaw/openclaw-2026-6-npm-remediation.test.ts +++ b/test/agents/openclaw/openclaw-2026-6-npm-remediation.test.ts @@ -284,9 +284,9 @@ function writePluginArchiveFixtures(): { ' "view:https-proxy-agent@5.0.1:dist.tarball") value="https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz" ;;', ' "view:agent-base@6.0.2:dist.integrity") value="sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==" ;;', ' "view:agent-base@6.0.2:dist.tarball") value="https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" ;;', - ' "pack:https://registry.npmjs.org/axios/-/axios-1.18.0.tgz:--pack-destination") archive="axios-1.18.0-source.tgz"; filename="axios-1.18.0.tgz"; integrity="sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==" ;;', - ' "pack:https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz:--pack-destination") archive="https-proxy-agent-5.0.1-source.tgz"; filename="https-proxy-agent-5.0.1.tgz"; integrity="sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==" ;;', - ' "pack:https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz:--pack-destination") archive="agent-base-6.0.2-source.tgz"; filename="agent-base-6.0.2.tgz"; integrity="sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==" ;;', + ' "pack:axios@1.18.0:--pack-destination") archive="axios-1.18.0-source.tgz"; filename="axios-1.18.0.tgz"; integrity="sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==" ;;', + ' "pack:https-proxy-agent@5.0.1:--pack-destination") archive="https-proxy-agent-5.0.1-source.tgz"; filename="https-proxy-agent-5.0.1.tgz"; integrity="sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==" ;;', + ' "pack:agent-base@6.0.2:--pack-destination") archive="agent-base-6.0.2-source.tgz"; filename="agent-base-6.0.2.tgz"; integrity="sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==" ;;', ' *) echo "unexpected npm fixture invocation: $*" >&2; exit 1 ;;', "esac", 'if [ "$1" = "view" ]; then printf "%s\\n" "$value"; exit 0; fi', diff --git a/test/agents/openclaw/openclaw-dependency-review.test.ts b/test/agents/openclaw/openclaw-dependency-review.test.ts index f0ead664142..b158807c107 100644 --- a/test/agents/openclaw/openclaw-dependency-review.test.ts +++ b/test/agents/openclaw/openclaw-dependency-review.test.ts @@ -246,7 +246,7 @@ check_not_contains "$optional_plugin_block" 'pack_reviewed_npm_tarball' "optiona grep -Fq 'spawnSync(request.npmExecutable ?? "npm", args' "$reviewed_archive_helper" grep -Fq '["view", request.packageSpec, "dist.integrity"]' "$reviewed_archive_helper" grep -Fq '["view", request.packageSpec, "dist.tarball"]' "$reviewed_archive_helper" - grep -Fq '["pack", request.tarballUrl, "--pack-destination", rootDirectory, "--json"]' "$reviewed_archive_helper" + grep -Fq '["pack", request.packageSpec, "--pack-destination", rootDirectory, "--json"]' "$reviewed_archive_helper" grep -Fq 'reported unsafe archive filename' "$reviewed_archive_helper" grep -Fq 'expectedPatchedTreeIntegrity' "$remediation_helper" grep -Fq 'expectedPatchedMetadataIntegrity' "$remediation_helper" diff --git a/test/agents/openclaw/openclaw-integrity-pin-suite.ts b/test/agents/openclaw/openclaw-integrity-pin-suite.ts index a8660445013..4976535fac4 100644 --- a/test/agents/openclaw/openclaw-integrity-pin-suite.ts +++ b/test/agents/openclaw/openclaw-integrity-pin-suite.ts @@ -295,7 +295,7 @@ function runInstallBlock( 'if [ "${1:-}" = "pack" ]; then', ' pack_spec="${2:-}"; pack_dir=""', ' while [ "$#" -gt 0 ]; do if [ "${1:-}" = "--pack-destination" ]; then pack_dir="${2:-}"; shift 2; continue; fi; shift; done', - ' pack_file="$(basename "$pack_spec")"', + ` case "$pack_spec" in "@zed-industries/codex-acp@${PINNED_CODEX_ACP_VERSION}") pack_file="codex-acp-${PINNED_CODEX_ACP_VERSION}.tgz" ;; openclaw@*) pack_file="openclaw-\${pack_spec#openclaw@}.tgz" ;; *) exit 1 ;; esac`, ` reported_pack_file=${JSON.stringify(packFilename ?? "")}`, ...(packFilename === null ? [] @@ -427,8 +427,7 @@ function runInstallBlock( " shift", " done", ' test -n "$pack_dir";', - ' pack_file="$(basename "$pack_spec")";', - ' case "$pack_file" in *.tgz) ;; *) pack_file="${pack_file}.tgz" ;; esac', + ` case "$pack_spec" in "@zed-industries/codex-acp@${PINNED_CODEX_ACP_VERSION}") pack_file="codex-acp-${PINNED_CODEX_ACP_VERSION}.tgz" ;; openclaw@*) pack_file="openclaw-\${pack_spec#openclaw@}.tgz" ;; *) return 1 ;; esac`, ` reported_pack_file=${JSON.stringify(packFilename ?? "")}`, ...(packFilename === null ? [] @@ -553,7 +552,8 @@ function runOptionalOpenClawPluginBlock( 'if [ "${1:-}" = "pack" ]; then', ' pack_spec="${2:-}"; pack_dir=""', ' while [ "$#" -gt 0 ]; do if [ "${1:-}" = "--pack-destination" ]; then pack_dir="${2:-}"; shift 2; continue; fi; shift; done', - ' pack_file="$(basename "$pack_spec")"', + ' package_name="${pack_spec#*/}"; package_name="${package_name%@*}"; package_version="${pack_spec##*@}"', + ' pack_file="${package_name}-${package_version}.tgz"', ` reported_pack_file=${JSON.stringify(pluginPackFilename)}`, ' reported_pack_file="${reported_pack_file:-$pack_file}"', ' printf "fake plugin tarball" > "$pack_dir/$pack_file"', @@ -614,7 +614,8 @@ function runOptionalOpenClawPluginBlock( ' if [ "${1:-}" = "--pack-destination" ]; then pack_dir="${2:-}"; shift 2; continue; fi', " shift", " done", - ' test -n "$pack_dir"; pack_file="$(basename "$pack_spec")";', + ' test -n "$pack_dir"; package_name="${pack_spec#*/}"; package_name="${package_name%@*}"; package_version="${pack_spec##*@}";', + ' pack_file="${package_name}-${package_version}.tgz";', ` reported_pack_file=${JSON.stringify(pluginPackFilename)}`, ' reported_pack_file="${reported_pack_file:-$pack_file}"', ' printf "fake plugin tarball" > "$pack_dir/$pack_file";', @@ -792,7 +793,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes `npm view @openclaw/diagnostics-otel@${PINNED_OPENCLAW_VERSION} dist.tarball`, ); expect(calls).toContain( - "npm pack https://registry.npmjs.org/@openclaw/diagnostics-otel/-/diagnostics-otel-2026.7.1.tgz --pack-destination", + `npm pack @openclaw/diagnostics-otel@${PINNED_OPENCLAW_VERSION} --pack-destination`, ); expect(calls).toMatch( /openclaw plugins install npm-pack:\S*\/diagnostics-otel-2026\.7\.1\.tgz\n/, @@ -808,7 +809,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes `npm view @openclaw/brave-plugin@${PINNED_OPENCLAW_VERSION} dist.tarball`, ); expect(calls).toContain( - "npm pack https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.7.1.tgz --pack-destination", + `npm pack @openclaw/brave-plugin@${PINNED_OPENCLAW_VERSION} --pack-destination`, ); expect(calls).toMatch( /openclaw plugins install npm-pack:\S*\/brave-plugin-2026\.7\.1\.tgz\n/, @@ -907,7 +908,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes `npm view openclaw@${PINNED_OPENCLAW_VERSION} dist.tarball`, ); expect(production.calls).not.toContain( - `npm pack ${PINNED_OPENCLAW_TARBALL} --pack-destination`, + `npm pack openclaw@${PINNED_OPENCLAW_VERSION} --pack-destination`, ); expect(production.calls).toMatch(/npm --prefix \S+\/openclaw-runtime ci /u); expect(production.calls).toContain("--verify-installed-lock"); @@ -915,7 +916,9 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(base.calls).toContain(`npm view openclaw@${PINNED_OPENCLAW_VERSION} version`); expect(base.calls).toContain(`npm view openclaw@${PINNED_OPENCLAW_VERSION} dist.integrity`); expect(base.calls).toContain(`npm view openclaw@${PINNED_OPENCLAW_VERSION} dist.tarball`); - expect(base.calls).not.toContain(`npm pack ${PINNED_OPENCLAW_TARBALL} --pack-destination`); + expect(base.calls).not.toContain( + `npm pack openclaw@${PINNED_OPENCLAW_VERSION} --pack-destination`, + ); expect(base.calls).toMatch(/npm --prefix \S+\/openclaw-runtime ci /u); expect(base.calls).toContain("--verify-installed-lock"); expect(base.calls).toContain("postinstall-bundled-plugins.mjs"); @@ -949,7 +952,9 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes ); expect(calls).not.toContain(`npm view openclaw@${PINNED_OPENCLAW_VERSION} dist.integrity`); expect(calls).not.toContain(`npm view openclaw@${PINNED_OPENCLAW_VERSION} dist.tarball`); - expect(calls).not.toContain(`npm pack ${PINNED_OPENCLAW_TARBALL} --pack-destination`); + expect(calls).not.toContain( + `npm pack openclaw@${PINNED_OPENCLAW_VERSION} --pack-destination`, + ); expect(calls).not.toContain( "npm install -g --no-audit --no-fund --no-progress --ignore-scripts ", ); @@ -1202,7 +1207,9 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(result.stdout).toContain("lacks matching reviewed provenance"); expect(calls).toContain(`npm view openclaw@${PINNED_OPENCLAW_VERSION} dist.integrity`); expect(calls).toContain(`npm view openclaw@${PINNED_OPENCLAW_VERSION} dist.tarball`); - expect(calls).not.toContain(`npm pack ${PINNED_OPENCLAW_TARBALL} --pack-destination`); + expect(calls).not.toContain( + `npm pack openclaw@${PINNED_OPENCLAW_VERSION} --pack-destination`, + ); expect(calls).toMatch(/npm --prefix \S+\/openclaw-runtime ci /u); expect(calls).toContain("--verify-installed-lock"); expect(calls).toContain("postinstall-bundled-plugins.mjs"); @@ -1235,7 +1242,9 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes `Base image has OpenClaw ${UNPINNED_OPENCLAW_VERSION}, which is newer than reviewed target ${PINNED_OPENCLAW_VERSION}`, ); expect(calls).not.toContain(`npm view openclaw@${PINNED_OPENCLAW_VERSION} dist.integrity`); - expect(calls).not.toContain(`npm pack ${PINNED_OPENCLAW_TARBALL} --pack-destination`); + expect(calls).not.toContain( + `npm pack openclaw@${PINNED_OPENCLAW_VERSION} --pack-destination`, + ); expect(provenanceExists).toBe(false); }); @@ -1358,7 +1367,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(result.stderr).toContain(diagnostic); expect(result.stdout).not.toContain(diagnostic); expect(calls).toContain( - `npm pack ${LEGACY_GATEWAY_UPGRADE_OPENCLAW_TARBALL} --pack-destination`, + `npm pack openclaw@${LEGACY_GATEWAY_UPGRADE_OPENCLAW_VERSION} --pack-destination`, ); expect(calls).not.toContain("npm install -g"); }); @@ -1439,9 +1448,8 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes `npm view openclaw@${LEGACY_REBUILD_OPENCLAW_VERSION} dist.tarball`, ); expect(fixtureBase.calls).toContain( - `npm pack ${LEGACY_REBUILD_OPENCLAW_TARBALL} --pack-destination`, + `npm pack openclaw@${LEGACY_REBUILD_OPENCLAW_VERSION} --pack-destination`, ); - expect(fixtureBase.calls).toContain(`openclaw-${LEGACY_REBUILD_OPENCLAW_VERSION}.tgz`); expect(fixtureBase.calls).toContain("npm install -g --ignore-scripts "); expect(fixtureBase.calls).toContain("openclaw-remediated.tgz"); expect(fixtureBase.calls).not.toContain('"archivePath"'); @@ -1685,7 +1693,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); const block = dockerfile.slice( dockerfile.indexOf("FROM scratch AS codex-acp-common-archive"), - dockerfile.indexOf("FROM node:22-trixie-slim", dockerfile.indexOf("AS wechat-npm-cache")), + dockerfile.indexOf("FROM npm12 AS wechat-npm-cache"), ); expect(block).toContain( diff --git a/test/agents/openclaw/openclaw-managed-messaging-offline-build.test.ts b/test/agents/openclaw/openclaw-managed-messaging-offline-build.test.ts index 3567383f6e5..29fa2920f19 100644 --- a/test/agents/openclaw/openclaw-managed-messaging-offline-build.test.ts +++ b/test/agents/openclaw/openclaw-managed-messaging-offline-build.test.ts @@ -123,6 +123,12 @@ describe("OpenClaw managed messaging offline image build", () => { expect(cacheStage).toContain("--archive-directory /opt/nemoclaw-build-tools/npm-cache-seed"); expect(cacheStage).toContain("NPM_CONFIG_OFFLINE=true npm ci"); expect(cacheStage).toContain('--os linux --cpu "$npm_target_cpu" --libc glibc'); + expect(cacheStage.indexOf("npm cache verify")).toBeGreaterThan( + cacheStage.indexOf("NPM_CONFIG_OFFLINE=true npm ci"), + ); + expect(cacheStage.indexOf("--packuments-only")).toBeGreaterThan( + cacheStage.indexOf("npm cache verify"), + ); expect(cacheStage).not.toContain("--network=default"); expect(cacheStage).not.toContain("else \\"); expect(cacheStage).not.toContain("find /opt/nemoclaw-build-tools/npm-cache-seed"); diff --git a/test/agents/openclaw/openclaw-npm-remediation.test.ts b/test/agents/openclaw/openclaw-npm-remediation.test.ts index ba2d54b7ffe..9d48addd575 100644 --- a/test/agents/openclaw/openclaw-npm-remediation.test.ts +++ b/test/agents/openclaw/openclaw-npm-remediation.test.ts @@ -330,12 +330,11 @@ function packFixture(packageDirectory: string, archivePath: string): void { } function readPackageField(directory: string, field: string): T { - const result = spawnSync("npm", ["pkg", "get", field, "--json"], { - cwd: directory, - encoding: "utf-8", - }); - expect(result.status, result.stderr).toBe(0); - return JSON.parse(result.stdout) as T; + let value: unknown = readJson>(path.join(directory, "package.json")); + for (const part of field.split(".")) { + value = (value as Record)[part]; + } + return value as T; } function writeLegacyCoreArchiveFixtures(): { @@ -367,7 +366,7 @@ function writeLegacyCoreArchiveFixtures(): { 'case "$1:$2:${3:-}" in', ' "view:tar@7.5.21:dist.integrity") value="sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==" ;;', ' "view:tar@7.5.21:dist.tarball") value="https://registry.npmjs.org/tar/-/tar-7.5.21.tgz" ;;', - ' "pack:https://registry.npmjs.org/tar/-/tar-7.5.21.tgz:--pack-destination") ;;', + ' "pack:tar@7.5.21:--pack-destination") ;;', ' *) echo "unexpected npm fixture invocation: $*" >&2; exit 1 ;;', "esac", 'if [ "$1" = "view" ]; then printf "%s\\n" "$value"; exit 0; fi', @@ -433,6 +432,7 @@ describe("OpenClaw npm remediation", () => { }, ); + // source-shape-contract: security -- Exact package and shrinkwrap identities prove the guarded Axios remediation installs only the reviewed replacement graph it("replaces the reviewed bundled Axios graph with the patched graph", () => { const directory = writeFixture(); @@ -482,6 +482,7 @@ describe("OpenClaw npm remediation", () => { ); }); + // source-shape-contract: security -- Exact package and shrinkwrap identities prove the guarded Jaeger remediation installs only the reviewed aligned graph it("replaces the reviewed Jaeger propagator with its aligned patched core", () => { const directory = writeDiagnosticsFixture(); @@ -666,6 +667,7 @@ describe("OpenClaw npm remediation", () => { ); }); + // source-shape-contract: security -- Exact package and shrinkwrap identities prove the guarded Discord remediation installs only the reviewed undici graph it("replaces the reviewed OpenClaw Discord undici dependency", () => { const directory = writeDiscordFixture(); @@ -746,7 +748,10 @@ describe("OpenClaw npm remediation", () => { const fixture = writeLegacyCoreArchiveFixtures(); const request = { archivePath: fixture.archivePath, - env: { NEMOCLAW_REVIEWED_NPM_EXECUTABLE: fixture.npmExecutable }, + env: { + NEMOCLAW_REVIEWED_NPM_EXECUTABLE: fixture.npmExecutable, + NPM_CONFIG_CACHE: path.join(path.dirname(fixture.archivePath), "npm-cache"), + }, packageSpec: "openclaw@2026.3.11", workingDirectory: fixture.workingDirectory, }; diff --git a/test/agents/openclaw/openclaw-npm12-pack-json.test.ts b/test/agents/openclaw/openclaw-npm12-pack-json.test.ts new file mode 100644 index 00000000000..a515d0bc0db --- /dev/null +++ b/test/agents/openclaw/openclaw-npm12-pack-json.test.ts @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { patchOpenClawNpm12PackJson } from "../../../scripts/lib/patch-openclaw-npm12-pack-json.mts"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const legacyEntries = "const entries = Array.isArray(parsed) ? parsed : [parsed];"; + +function parserFixture(): string { + return `export function parse(raw) { + const parsed = JSON.parse(raw); + ${legacyEntries} + return entries; +}\n`; +} + +describe("OpenClaw npm 12 pack JSON compatibility", () => { + it("accepts npm 12 keyed output without changing earlier npm output shapes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-npm12-pack-json-")); + const source = path.join(root, "install-source-utils-fixture.js"); + try { + fs.writeFileSync(source, parserFixture()); + expect(patchOpenClawNpm12PackJson(root, "2026.7.1")).toBe("patched"); + expect(patchOpenClawNpm12PackJson(root, "2026.7.1")).toBe("already-patched"); + + const { parse } = (await import(`${pathToFileURL(source).href}?patched=1`)) as { + parse(raw: string): unknown[]; + }; + const metadata = { + filename: "diagnostics-otel-2026.7.1.tgz", + id: "@openclaw/diagnostics-otel@2026.7.1", + name: "@openclaw/diagnostics-otel", + version: "2026.7.1", + }; + expect(parse(JSON.stringify({ "@openclaw/diagnostics-otel": metadata }))).toEqual([metadata]); + expect(parse(JSON.stringify([metadata]))).toEqual([metadata]); + expect(parse(JSON.stringify(metadata))).toEqual([metadata]); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("pins the two-file parser layout for OpenClaw 2026.3.11", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-npm12-2026-3-11-")); + try { + fs.writeFileSync(path.join(root, "npm-pack-install-first.js"), parserFixture()); + fs.writeFileSync(path.join(root, "npm-pack-install-second.js"), parserFixture()); + expect(patchOpenClawNpm12PackJson(root, "2026.3.11")).toBe("patched"); + expect(patchOpenClawNpm12PackJson(root, "2026.3.11")).toBe("already-patched"); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("pins the one-file parser layout for OpenClaw 2026.4.24", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-npm12-2026-4-24-")); + try { + fs.writeFileSync(path.join(root, "install-source-utils-fixture.js"), parserFixture()); + expect(patchOpenClawNpm12PackJson(root, "2026.4.24")).toBe("patched"); + expect(patchOpenClawNpm12PackJson(root, "2026.4.24")).toBe("already-patched"); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("fails closed when the compiled parser shape is missing or ambiguous", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-npm12-pack-shape-")); + try { + fs.writeFileSync(path.join(root, "install-source-utils-missing.js"), "export {};\n"); + expect(() => patchOpenClawNpm12PackJson(root, "2026.7.1")).toThrow(/legacy=0, patched=0/); + fs.writeFileSync(path.join(root, "install-source-utils-first.js"), parserFixture()); + fs.writeFileSync(path.join(root, "install-source-utils-second.js"), parserFixture()); + expect(() => patchOpenClawNpm12PackJson(root, "2026.7.1")).toThrow(/expected=1, found=3/); + expect(() => patchOpenClawNpm12PackJson(root, "2026.8.0")).toThrow( + /has no reviewed npm 12 parser layout/, + ); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("patches both OpenClaw image paths before plugin installation", () => { + const dockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); + const baseDockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile.base"), "utf8"); + const script = "patch-openclaw-npm12-pack-json.mts"; + const invocation = "node /usr/local/lib/nemoclaw/npm12.mts"; + + expect(dockerfile).toContain(`COPY scripts/lib/${script} /usr/local/lib/nemoclaw/npm12.mts`); + expect(dockerfile).toContain(invocation); + expect(dockerfile.indexOf(invocation)).toBeLessThan( + dockerfile.indexOf('openclaw plugins install "npm-pack:${plugin_install_archive}"'), + ); + expect( + dockerfile.slice(dockerfile.indexOf(invocation), dockerfile.indexOf(invocation) + 250), + ).toContain('"$OPENCLAW_VERSION"'); + expect(baseDockerfile).toContain(`COPY scripts/lib/${script} /scripts/lib/${script}`); + const baseInvocationIndex = baseDockerfile.indexOf(`node /scripts/lib/${script}`); + expect(baseInvocationIndex).toBeGreaterThanOrEqual(0); + expect(baseDockerfile.slice(baseInvocationIndex, baseInvocationIndex + 300)).toContain( + "/usr/local/lib/node_modules/openclaw/dist", + ); + expect(baseDockerfile.slice(baseInvocationIndex, baseInvocationIndex + 300)).toContain( + '"$OPENCLAW_VERSION"', + ); + }); +}); diff --git a/test/agents/openclaw/openclaw-optional-plugin-build.test.ts b/test/agents/openclaw/openclaw-optional-plugin-build.test.ts index 295125c9f16..ec6ec8afc88 100644 --- a/test/agents/openclaw/openclaw-optional-plugin-build.test.ts +++ b/test/agents/openclaw/openclaw-optional-plugin-build.test.ts @@ -66,7 +66,7 @@ it("pins Brave web-search and preserves its placeholder during build-time doctor const calls = fs.readFileSync(log, "utf-8"); expect(result.status, result.stderr).toBe(0); expect(calls).toContain("npm view @openclaw/brave-plugin@2026.7.1 dist.integrity"); - expect(calls).toContain(`npm pack ${BRAVE_TARBALL} --pack-destination`); + expect(calls).toContain("npm pack @openclaw/brave-plugin@2026.7.1 --pack-destination"); expect(calls).toContain("plugins install npm-pack:"); expect(calls).toContain( "doctor --fix --non-interactive|BRAVE_API_KEY=openshell:resolve:env:BRAVE_API_KEY", diff --git a/test/agents/openclaw/openclaw-real-patched-dist-harness.test.ts b/test/agents/openclaw/openclaw-real-patched-dist-harness.test.ts index 856afe562bf..cd30b65573e 100644 --- a/test/agents/openclaw/openclaw-real-patched-dist-harness.test.ts +++ b/test/agents/openclaw/openclaw-real-patched-dist-harness.test.ts @@ -6,6 +6,7 @@ import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; @@ -36,8 +37,19 @@ const PATCH_OPENCLAW_MCP_RELIABILITY = path.join( "scripts", "patch-openclaw-mcp-reliability.mts", ); +const PATCH_OPENCLAW_NPM12_PACK_JSON = path.join( + REPO_ROOT, + "scripts", + "lib", + "patch-openclaw-npm12-pack-json.mts", +); const OPENCLAW_VERSION_EXTRACTOR = path.join(REPO_ROOT, "scripts", "extract-semver.sh"); const REAL_OPENCLAW_NODE_ENV = "NEMOCLAW_REAL_OPENCLAW_NODE"; +const REVIEWED_RUNTIME = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"), "utf8"), +) as { nodeVersion: string; npmVersion: string }; +const REVIEWED_NODE_VERSION = REVIEWED_RUNTIME.nodeVersion; +const REVIEWED_NPM_VERSION = REVIEWED_RUNTIME.npmVersion; // Focused patch scripts also scan the full generated dist. APFS cold-cache // reads can exceed one minute, so keep them bounded without using unit-fixture // timings as the real-artifact limit. @@ -165,17 +177,13 @@ function resolveRealOpenClawNodeRuntime( }); requireSpawnSuccess(versionProbe, `probe ${REAL_OPENCLAW_NODE_ENV}`); const version = versionProbe.stdout.trim(); - const match = - version.match(/^v(\d+)\.(\d+)\.(\d+)$/u) ?? + version.match(/^v(\d+)\.(\d+)\.(\d+)$/u) ?? runtimeMismatch(version, "a stable Node version", REAL_OPENCLAW_NODE_ENV); - const major = Number(match[1]); - const minor = Number(match[2]); - const patch = Number(match[3]); - const supportedNode22 = major === 22 && (minor > 22 || (minor === 22 && patch >= 3)); - supportedNode22 || + const reviewedVersion = `v${REVIEWED_NODE_VERSION}`; + version === reviewedVersion || runtimeMismatch( version, - "Node >=22.22.3 <23 (the Dockerfile runtime is Node 22.23.2)", + `Node ${REVIEWED_NODE_VERSION} (the reviewed CI and Dockerfile runtime)`, REAL_OPENCLAW_NODE_ENV, ); @@ -235,12 +243,32 @@ interface PackCommandResult { type PackReviewedTarball = (tarballUrl: string, destination: string) => PackCommandResult; +interface RealNpmPackMetadataResult { + ok: boolean; + error?: string; + tarballName?: string; + metadata?: { + name?: string; + version?: string; + integrity?: string; + }; +} + +type ResolveRealNpmPackArchiveMetadata = (params: { + archivePath: string; + timeoutMs: number; +}) => Promise; + function packReviewedTarball(tarballUrl: string, destination: string): PackCommandResult { const runPack = () => - spawnSync("npm", ["pack", tarballUrl, "--pack-destination", destination, "--silent"], { - encoding: "utf-8", - timeout: 90000, - }); + spawnSync( + "npm", + ["pack", tarballUrl, "--allow-remote=all", "--pack-destination", destination, "--silent"], + { + encoding: "utf-8", + timeout: 90000, + }, + ); const first = runPack(); return first.status === 0 ? first : runPack(); } @@ -299,6 +327,7 @@ describe("OpenClaw real patched-dist materialization guard", () => { expect(command).toContain(shellQuote(OPENCLAW_VERSION_EXTRACTOR)); }); + // source-shape-contract: security -- The real artifact harness must reject every Node runtime except the exact version reviewed for CI and production images it("rejects an unsupported explicit real-dist Node runtime before OpenClaw starts", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-node-runtime-")); try { @@ -306,22 +335,25 @@ describe("OpenClaw real patched-dist materialization guard", () => { fs.writeFileSync(fakeNode, "#!/bin/sh\nprintf 'v22.22.2\\n'\n", { mode: 0o700 }); expect(() => resolveRealOpenClawNodeRuntime({ [REAL_OPENCLAW_NODE_ENV]: fakeNode })).toThrow( - /Node >=22\.22\.3 <23/, + `Node ${REVIEWED_NODE_VERSION}`, ); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); + // source-shape-contract: security -- The real artifact harness must accept the exact reviewed Node runtime before downloading or executing OpenClaw artifacts it("accepts an explicit absolute Node runtime in the reviewed production lane", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-node-runtime-")); try { const fakeNode = path.join(tmp, "node"); - fs.writeFileSync(fakeNode, "#!/bin/sh\nprintf 'v22.23.2\\n'\n", { mode: 0o700 }); + fs.writeFileSync(fakeNode, `#!/bin/sh\nprintf 'v${REVIEWED_NODE_VERSION}\\n'\n`, { + mode: 0o700, + }); expect(resolveRealOpenClawNodeRuntime({ [REAL_OPENCLAW_NODE_ENV]: fakeNode })).toEqual({ executable: fakeNode, - version: "v22.23.2", + version: `v${REVIEWED_NODE_VERSION}`, }); } finally { fs.rmSync(tmp, { recursive: true, force: true }); @@ -403,6 +435,16 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS !== "1")( console.info( `OpenClaw real patched-dist Node runtime: ${nodeRuntime.version} (${nodeRuntime.executable})`, ); + const npmVersion = spawnSync("npm", ["--version"], { + encoding: "utf-8", + timeout: 10_000, + }); + requireSpawnSuccess(npmVersion, "probe reviewed npm runtime"); + requireRuntimeEqual( + npmVersion.stdout.trim(), + REVIEWED_NPM_VERSION, + "OpenClaw real patched-dist npm runtime", + ); const version = readRequiredDockerArg("OPENCLAW_VERSION"); const integrity = readRequiredDockerArg("OPENCLAW_2026_7_1_INTEGRITY"); const tarballUrl = readRequiredDockerArg("OPENCLAW_2026_7_1_TARBALL"); @@ -410,15 +452,29 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS !== "1")( try { const tarballPath = materializeReviewedTarball(tarballUrl, tmp, integrity); - const extractDir = path.join(tmp, "extract"); - fs.mkdirSync(extractDir); - const extract = spawnSync("tar", ["-xzf", tarballPath, "-C", extractDir], { - encoding: "utf-8", - timeout: 60000, - }); - requireSpawnSuccess(extract, "extract reviewed OpenClaw tarball"); + const runtimeRoot = path.join(tmp, "runtime"); + const install = spawnSync( + "npm", + [ + "install", + "--prefix", + runtimeRoot, + "--allow-file=all", + "--ignore-scripts", + "--no-audit", + "--no-fund", + tarballPath, + ], + { + encoding: "utf-8", + env: { ...process.env, NPM_CONFIG_CACHE: path.join(tmp, "npm-cache") }, + maxBuffer: 10 * 1024 * 1024, + timeout: PATCH_COMMAND_TIMEOUT_MS, + }, + ); + requireSpawnSuccess(install, "install reviewed OpenClaw tarball without scripts"); - const dist = path.join(extractDir, "package", "dist"); + const dist = path.join(runtimeRoot, "node_modules", "openclaw", "dist"); fs.statSync(dist).isDirectory() || runtimeMismatch("not a directory", "directory", dist); const dockerPatch = runDockerfilePatchBlock(dist, tmp, version); @@ -455,6 +511,175 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS !== "1")( grep.stdout.trim().length > 0 || runtimeMismatch("empty", "non-empty", marker); }); + const npm12Patch = spawnSync( + nodeRuntime.executable, + [PATCH_OPENCLAW_NPM12_PACK_JSON, dist, version], + { encoding: "utf-8", timeout: PATCH_COMMAND_TIMEOUT_MS }, + ); + requireSpawnSuccess(npm12Patch, "apply npm 12 pack JSON compatibility patch"); + requireRuntimeIncludes( + npm12Patch.stdout, + "OpenClaw npm 12 pack JSON parser patched", + "npm 12 pack JSON patch output", + ); + const npm12PatchAudit = spawnSync( + nodeRuntime.executable, + [PATCH_OPENCLAW_NPM12_PACK_JSON, dist, version], + { encoding: "utf-8", timeout: PATCH_COMMAND_TIMEOUT_MS }, + ); + requireSpawnSuccess(npm12PatchAudit, "audit npm 12 pack JSON compatibility patch"); + requireRuntimeIncludes( + npm12PatchAudit.stdout, + "OpenClaw npm 12 pack JSON parser already-patched", + "npm 12 pack JSON patch idempotence", + ); + + const npm12ParserTargets = fs + .readdirSync(dist) + .filter((file) => /^install-source-utils-[A-Za-z0-9_-]+\.js$/u.test(file)) + .map((file) => path.join(dist, file)) + .filter((file) => + fs.readFileSync(file, "utf-8").includes("nemoclaw: npm 12 keyed npm pack JSON"), + ); + requireRuntimeEqual( + String(npm12ParserTargets.length), + "1", + "npm 12 pack JSON real parser target count", + ); + const realParserModule = (await import( + `${pathToFileURL(npm12ParserTargets[0] as string).href}?nemoclaw-npm12-proof=1` + )) as Record; + const realMetadataResolvers = Object.values(realParserModule).filter( + (value): value is ResolveRealNpmPackArchiveMetadata => + typeof value === "function" && value.name === "resolveNpmPackArchiveMetadata", + ); + requireRuntimeEqual( + String(realMetadataResolvers.length), + "1", + "npm 12 pack JSON real metadata resolver count", + ); + const resolveRealMetadata = realMetadataResolvers[0] as ResolveRealNpmPackArchiveMetadata; + const realMetadata = await resolveRealMetadata({ + archivePath: tarballPath, + timeoutMs: PATCH_COMMAND_TIMEOUT_MS, + }); + realMetadata.ok || + runtimeMismatch( + realMetadata.error ?? "metadata resolution failed", + "successful reviewed archive metadata", + "npm 12 pack JSON real parser", + ); + requireRuntimeEqual( + realMetadata.metadata?.name ?? "missing", + "openclaw", + "npm 12 pack JSON real parser package name", + ); + requireRuntimeEqual( + realMetadata.metadata?.version ?? "missing", + version, + "npm 12 pack JSON real parser package version", + ); + requireRuntimeEqual( + realMetadata.metadata?.integrity ?? "missing", + integrity, + "npm 12 pack JSON real parser integrity", + ); + requireRuntimeEqual( + realMetadata.tarballName ?? "missing", + path.basename(tarballPath), + "npm 12 pack JSON real parser filename", + ); + const fakeNpmBin = path.join(tmp, "npm-pack-shape-bin"); + fs.mkdirSync(fakeNpmBin); + const fakeNpm = path.join(fakeNpmBin, "npm"); + const previousPath = process.env.PATH ?? ""; + const sentinelFilename = "npm12-shape-probe.tgz"; + const sentinelIntegrity = `sha512-${Buffer.alloc(64, 12).toString("base64")}`; + const sentinelVersion = "0.0.0-npm12-shape-probe"; + const expectedMetadata = { + filename: sentinelFilename, + id: `openclaw@${sentinelVersion}`, + integrity: sentinelIntegrity, + name: "openclaw", + version: sentinelVersion, + }; + try { + process.env.PATH = `${fakeNpmBin}:${previousPath}`; + fs.writeFileSync( + fakeNpm, + `#!/bin/sh\nprintf '%s\\n' ${shellQuote(JSON.stringify([expectedMetadata]))}\n`, + { mode: 0o700 }, + ); + const arrayMetadata = await resolveRealMetadata({ + archivePath: tarballPath, + timeoutMs: PATCH_COMMAND_TIMEOUT_MS, + }); + arrayMetadata.ok || + runtimeMismatch( + arrayMetadata.error ?? "metadata resolution failed", + "successful array archive metadata", + "npm 12 pack JSON real parser compatibility", + ); + requireRuntimeEqual( + arrayMetadata.metadata?.name ?? "missing", + "openclaw", + "npm 12 pack JSON real parser array package name", + ); + requireRuntimeEqual( + arrayMetadata.metadata?.version ?? "missing", + sentinelVersion, + "npm 12 pack JSON real parser array package version", + ); + requireRuntimeEqual( + arrayMetadata.metadata?.integrity ?? "missing", + sentinelIntegrity, + "npm 12 pack JSON real parser array integrity", + ); + requireRuntimeEqual( + arrayMetadata.tarballName ?? "missing", + sentinelFilename, + "npm 12 pack JSON real parser array filename", + ); + + fs.writeFileSync( + fakeNpm, + `#!/bin/sh\nprintf '%s\\n' ${shellQuote(JSON.stringify(expectedMetadata))}\n`, + { mode: 0o700 }, + ); + const directMetadata = await resolveRealMetadata({ + archivePath: tarballPath, + timeoutMs: PATCH_COMMAND_TIMEOUT_MS, + }); + directMetadata.ok || + runtimeMismatch( + directMetadata.error ?? "metadata resolution failed", + "successful direct-object archive metadata", + "npm 12 pack JSON real parser compatibility", + ); + requireRuntimeEqual( + directMetadata.metadata?.name ?? "missing", + "openclaw", + "npm 12 pack JSON real parser direct-object package name", + ); + requireRuntimeEqual( + directMetadata.metadata?.version ?? "missing", + sentinelVersion, + "npm 12 pack JSON real parser direct-object package version", + ); + requireRuntimeEqual( + directMetadata.metadata?.integrity ?? "missing", + sentinelIntegrity, + "npm 12 pack JSON real parser direct-object integrity", + ); + requireRuntimeEqual( + directMetadata.tarballName ?? "missing", + sentinelFilename, + "npm 12 pack JSON real parser direct-object filename", + ); + } finally { + process.env.PATH = previousPath; + } + const retryPersistencePreimage = [ "\t\t\tlet suppressNextUserMessagePersistence = params.suppressNextUserMessagePersistence ?? false;", "\t\t\tlet lastPersistedCurrentMessageId;", diff --git a/test/agents/openclaw/openclaw-security-audit-suppressions-real.test.ts b/test/agents/openclaw/openclaw-security-audit-suppressions-real.test.ts index 8df5a42799d..9385ec9f952 100644 --- a/test/agents/openclaw/openclaw-security-audit-suppressions-real.test.ts +++ b/test/agents/openclaw/openclaw-security-audit-suppressions-real.test.ts @@ -7,6 +7,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { parseSingleNpmPackResult } from "../../helpers/npm-pack-result"; + import { describe, expect, it } from "vitest"; import { buildConfig } from "../../../scripts/generate-openclaw-config.mts"; @@ -70,7 +72,7 @@ function installReviewedOpenClaw(workspace: string): string { }; const packed = spawnSync( "npm", - ["pack", reviewed.tarball, "--pack-destination", workspace, "--json"], + ["pack", reviewed.tarball, "--allow-remote=all", "--pack-destination", workspace, "--json"], { encoding: "utf-8", env: childEnv, @@ -82,7 +84,7 @@ function installReviewedOpenClaw(workspace: string): string { assert.equal(packed.error, undefined, `OpenClaw npm pack failed: ${packFailure}`); assert.equal(packed.status, 0, `OpenClaw npm pack failed: ${packFailure}`); assert.ok(packed.stdout.trim(), `OpenClaw npm pack failed: ${packFailure}`); - const packResult = JSON.parse(packed.stdout)[0] as { filename?: string; integrity?: string }; + const packResult = parseSingleNpmPackResult(packed.stdout); assert.equal(packResult.integrity, reviewed.integrity, "OpenClaw tarball integrity mismatch"); assert.ok(packResult.filename, "OpenClaw npm pack omitted the archive filename"); assert.equal(path.basename(packResult.filename), packResult.filename, "Unsafe npm pack filename"); diff --git a/test/automation/e2e/brev-launchable-ci-cpu-checksum.test.ts b/test/automation/e2e/brev-launchable-ci-cpu-checksum.test.ts index 0860068a509..ed15fda5974 100644 --- a/test/automation/e2e/brev-launchable-ci-cpu-checksum.test.ts +++ b/test/automation/e2e/brev-launchable-ci-cpu-checksum.test.ts @@ -9,6 +9,14 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; const SCRIPT = path.join(import.meta.dirname, "../../..", "scripts", "brev-launchable-ci-cpu.sh"); +const REVIEWED_RUNTIME = JSON.parse( + fs.readFileSync( + path.join(import.meta.dirname, "../../..", "ci", "reviewed-npm-audit.json"), + "utf8", + ), +) as { nodeVersion: string; npmVersion: string }; +const REVIEWED_NODE_VERSION = REVIEWED_RUNTIME.nodeVersion; +const REVIEWED_NPM_VERSION = REVIEWED_RUNTIME.npmVersion; const BREV_LIFECYCLE_SCRIPT_MAX_BYTES = 16 * 1024; const ASSET = "openshell-x86_64-unknown-linux-musl.tar.gz"; const PINNED_ASSET_SHA256 = "4fb4476d80a1875a0b83547ec3aba999cf0a2e2d75f95f2f709b622e2103520e"; @@ -25,6 +33,7 @@ type FakeSystemOptions = { | "traversal"; checksum: "match" | "mismatch" | "unpinned"; nodeSourceChecksumTool?: boolean; + reviewedNpmFailure?: boolean; openshellVersion?: string; }; @@ -50,6 +59,7 @@ function makeFakeSystem(options: FakeSystemOptions): { fakeBin: string; launchLog: string; sudoLog: string; + npmTmpLog: string; tarLog: string; } { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-brev-checksum-")); @@ -59,6 +69,7 @@ function makeFakeSystem(options: FakeSystemOptions): { const curlLog = path.join(root, "curl.log"); const dockerLog = path.join(root, "docker.log"); const sudoLog = path.join(root, "sudo.log"); + const npmTmpLog = path.join(root, "npm-tmp.log"); const tarLog = path.join(root, "tar.log"); fs.mkdirSync(fakeBin); @@ -114,14 +125,14 @@ exec bash -c "\${1:-}" writeExecutable( path.join(fakeBin, "node"), `#!/usr/bin/env bash -if [ "\${1:-}" = "-p" ]; then printf '${options.nodeSourceChecksumTool === false ? "20" : "22"}\\n'; exit 0; fi -if [ "\${1:-}" = "--version" ]; then printf 'v22.19.0\\n'; exit 0; fi +if [ "\${1:-}" = "--version" ]; then printf '${options.nodeSourceChecksumTool === false ? "v22.19.0" : `v${REVIEWED_NODE_VERSION}`}\\n'; exit 0; fi exit 0 `, ); writeExecutable( path.join(fakeBin, "npm"), `#!/usr/bin/env bash +if [ "\${1:-}" = "--version" ]; then printf '${REVIEWED_NPM_VERSION}\\n'; exit 0; fi printf 'npm stub %s\\n' "$*" exit 0 `, @@ -169,6 +180,10 @@ exec /usr/bin/tar "$@" path.join(fakeBin, "sudo"), `#!/usr/bin/env bash printf '%s\\n' "$*" >> ${JSON.stringify(sudoLog)} +if [[ "$*" == *setup-reviewed-npm/verify-and-install-npm.sh* ]]; then + printf '%s\\n' "$*" | sed -n 's/.*RUNNER_TEMP=\\([^ ]*\\).*/\\1/p' > ${JSON.stringify(npmTmpLog)} + exit ${options.reviewedNpmFailure ? 42 : 0} +fi if [ "\${1:-}" = "install" ]; then shift if [ "\${1:-}" = "-m" ]; then shift 2; fi @@ -252,6 +267,7 @@ exec /usr/bin/sha256sum "$@" fakeBin, launchLog, sudoLog, + npmTmpLog, tarLog, }; } @@ -287,6 +303,34 @@ describe("brev-launchable-ci-cpu.sh OpenShell checksum gate", { timeout: 30_000 expect(fs.statSync(SCRIPT).size).toBeLessThanOrEqual(BREV_LIFECYCLE_SCRIPT_MAX_BYTES); }); + it("removes temporary npm bootstrap state when installation fails", () => { + const { fake, result } = runLaunchable({ checksum: "match", reviewedNpmFailure: true }); + try { + expect(result.status, combinedLaunchableOutput(result, fake.launchLog)).toBe(42); + const temporaryDirectory = fs.readFileSync(fake.npmTmpLog, "utf8").trim(); + expect(temporaryDirectory).not.toBe(""); + expect(fs.existsSync(temporaryDirectory)).toBe(false); + } finally { + fake.cleanup(); + } + }); + + it("pins both reviewed Node.js archives and installs the canonical reviewed npm", () => { + const source = fs.readFileSync(SCRIPT, "utf8"); + expect(source).toContain(`NODE_VERSION="${REVIEWED_NODE_VERSION}"`); + expect(source).toContain( + 'node_sha256="9f5eb6ac21845a66c493c91a253b1da32fd684e89e9b7202d4936982336be4ca"', + ); + expect(source).toContain( + 'node_sha256="df224555a083b918e46260cc969838501b9f9a87140c1195e5b9597b56d5dae2"', + ); + expect(source).toContain( + "bash .github/actions/setup-reviewed-npm/verify-and-install-npm.sh ci/reviewed-npm-audit.json", + ); + expect(source).toContain(`[[ "$(npm --version)" == "${REVIEWED_NPM_VERSION}" ]]`); + expect(source).not.toContain("deb.nodesource.com"); + }); + it("rejects malformed OPENSHELL_VERSION before downloads or privileged setup", () => { const { fake, result } = runLaunchable({ checksum: "match", @@ -338,7 +382,7 @@ describe("brev-launchable-ci-cpu.sh OpenShell checksum gate", { timeout: 30_000 } }); - it("refuses to run the NodeSource installer as root when no SHA-256 tool is available", () => { + it("refuses to extract the Node.js archive when no SHA-256 tool is available", () => { const { fake, result } = runLaunchable({ checksum: "match", nodeSourceChecksumTool: false, @@ -349,10 +393,10 @@ describe("brev-launchable-ci-cpu.sh OpenShell checksum gate", { timeout: 30_000 expect(result.status, out).toBe(1); expect(out).toContain("No SHA-256 tool available (sha256sum/shasum)"); expect(fs.readFileSync(fake.curlLog, "utf-8")).toContain( - "https://deb.nodesource.com/setup_22.x", + `https://nodejs.org/dist/v${REVIEWED_NODE_VERSION}/node-v${REVIEWED_NODE_VERSION}-linux-x64.tar.gz`, ); - expect(sudoLog).not.toMatch(/^-E bash /m); - expect(out).not.toContain("NodeSource installer integrity verified"); + expect(sudoLog).not.toMatch(/^tar -xzf /m); + expect(out).not.toContain(`Node.js v${REVIEWED_NODE_VERSION} installed`); } finally { fake.cleanup(); } diff --git a/test/automation/e2e/platform-vitest-main-workflow.test.ts b/test/automation/e2e/platform-vitest-main-workflow.test.ts index 1d3a1413b84..6c5dbf4f7c2 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { + readRepoText, readYaml, type Workflow, type WorkflowJob, @@ -11,8 +12,15 @@ import { } from "../../helpers/e2e-workflow-contract"; const WORKFLOW_PATH = ".github/workflows/platform-vitest-main.yaml"; - +const WSL_HELPER_PATH = "tools/wsl/ci-helper.ps1"; +const REVIEWED_NPM_CONFIG_PATH = "ci/reviewed-npm-audit.json"; const workflow = readYaml(WORKFLOW_PATH); +const wslHelperSource = readRepoText(WSL_HELPER_PATH); +const reviewedNpmConfig = JSON.parse(readRepoText(REVIEWED_NPM_CONFIG_PATH)) as { + nodeVersion: string; + npmIntegrity: string; + npmVersion: string; +}; function job(name: string): WorkflowJob { const candidate = workflow.jobs[name]; @@ -27,6 +35,23 @@ function step(jobName: string, name: string): WorkflowStep { } describe("platform evidence workflow", () => { + // source-shape-contract: security -- The WSL boundary must embed the integrity-bound Node and npm bootstrap because it cannot import the TypeScript identity helper + it("uses the reviewed Node and npm identities for the WSL build and test lane", () => { + expect(wslHelperSource).toContain(`node_version="${reviewedNpmConfig.nodeVersion}"`); + expect(wslHelperSource).toContain(`npm_version="${reviewedNpmConfig.npmVersion}"`); + expect(wslHelperSource).toContain(`expected_npm_integrity="${reviewedNpmConfig.npmIntegrity}"`); + expect(wslHelperSource).toContain('npm install --global "$npm_archive"'); + const integrityCheckIndex = wslHelperSource.indexOf('test "$actual_npm_integrity"'); + expect(integrityCheckIndex).toBeGreaterThanOrEqual(0); + expect(integrityCheckIndex).toBeLessThan( + wslHelperSource.indexOf('npm install --global "$npm_archive"'), + ); + expect(wslHelperSource).toContain('test "$(npm --version)" = "$npm_version"'); + expect(step("wsl-vitest", "Install reviewed Node.js and npm in WSL").run).toContain( + "Install-WslNode", + ); + }); + it("marks the container checkout safe before generating build identity", () => { const run = step("ubuntu-2604-contract", "Build CLI").run ?? ""; expect(run).toContain('git config --global --add safe.directory "$GITHUB_WORKSPACE"'); diff --git a/test/automation/pull-requests/growth-guardrails-workflow-boundary.test.ts b/test/automation/pull-requests/growth-guardrails-workflow-boundary.test.ts index 30d95408ec3..fdf1993f2ac 100644 --- a/test/automation/pull-requests/growth-guardrails-workflow-boundary.test.ts +++ b/test/automation/pull-requests/growth-guardrails-workflow-boundary.test.ts @@ -70,19 +70,29 @@ describe("codebase growth guardrails workflow trust boundary", () => { (workflow: Value) => (workflow.jobs["codebase-growth-guardrails"].steps[0].with["persist-credentials"] = true), ], + [ + "Node pin", + (workflow: Value) => + (workflow.jobs["codebase-growth-guardrails"].steps[1].with["node-version"] = "22"), + ], + [ + "reviewed npm", + (workflow: Value) => + (workflow.jobs["codebase-growth-guardrails"].steps[2].uses = "./unreviewed-action"), + ], [ "dependency install", (workflow: Value) => - (workflow.jobs["codebase-growth-guardrails"].steps[1].run = "npm install"), + (workflow.jobs["codebase-growth-guardrails"].steps[3].run = "npm install"), ], [ "test invocation", - (workflow: Value) => (workflow.jobs["codebase-growth-guardrails"].steps[2].run = "npm test"), + (workflow: Value) => (workflow.jobs["codebase-growth-guardrails"].steps[4].run = "npm test"), ], [ "pull request metadata", (workflow: Value) => - (workflow.jobs["codebase-growth-guardrails"].steps[2].env.HEAD_SHA = "untrusted"), + (workflow.jobs["codebase-growth-guardrails"].steps[4].env.HEAD_SHA = "untrusted"), ], [ "job permission override", @@ -92,7 +102,7 @@ describe("codebase growth guardrails workflow trust boundary", () => { [ "failure tolerance", (workflow: Value) => - (workflow.jobs["codebase-growth-guardrails"].steps[2]["continue-on-error"] = true), + (workflow.jobs["codebase-growth-guardrails"].steps[4]["continue-on-error"] = true), ], ])("rejects a mutation to %s", (_boundary, mutate) => { expect(mutatedWorkflow(mutate)).toContain( diff --git a/test/automation/pull-requests/pr-merge-conflict-fixer-workflow-boundary.test.ts b/test/automation/pull-requests/pr-merge-conflict-fixer-workflow-boundary.test.ts index e186ba79965..29650071056 100644 --- a/test/automation/pull-requests/pr-merge-conflict-fixer-workflow-boundary.test.ts +++ b/test/automation/pull-requests/pr-merge-conflict-fixer-workflow-boundary.test.ts @@ -85,8 +85,24 @@ describe("PR merge conflict fixer workflow boundary", () => { .map((step) => step.uses) .filter((reference): reference is string => typeof reference === "string"); expect(actionReferences).not.toHaveLength(0); + expect(actionReferences.filter((reference) => reference.startsWith("./"))).toEqual([ + "./.github/actions/setup-reviewed-npm", + "./trusted/.github/actions/setup-reviewed-npm", + "./trusted/.github/actions/setup-reviewed-npm", + ]); + expect(namedStep(scan, "Install reviewed npm").uses).toBe( + "./.github/actions/setup-reviewed-npm", + ); + expect(namedStep(resolve, "Install reviewed npm").uses).toBe( + "./trusted/.github/actions/setup-reviewed-npm", + ); + expect(namedStep(publish, "Install reviewed npm").uses).toBe( + "./trusted/.github/actions/setup-reviewed-npm", + ); expect( - actionReferences.every((reference) => /^[^@\s]+\/[^@\s]+@[0-9a-f]{40}$/u.test(reference)), + actionReferences + .filter((reference) => !reference.startsWith("./")) + .every((reference) => /^[^@\s]+\/[^@\s]+@[0-9a-f]{40}$/u.test(reference)), ).toBe(true); [scan, resolve, publish].forEach((job) => { diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index ec3c71fc61b..44bbcc544bb 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -32,6 +32,7 @@ type SdkPackageWorkflow = Readonly<{ const trustedCheckoutAction = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"; const trustedSetupNodeAction = "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020"; +const reviewedNpmAction = "./.github/actions/setup-reviewed-npm"; const cliShardCount = "12"; const cliShardTimeoutMinutes = 30; @@ -345,7 +346,11 @@ describe("pull request and main workflow contracts", () => { ).toBe(true); expect(job.needs).toBe("changes"); expect(job.if).toBe("needs.changes.outputs.hugging_face_models == 'true'"); - expect(stepUses(job)).toEqual([trustedCheckoutAction, trustedSetupNodeAction]); + expect(stepUses(job)).toEqual([ + trustedCheckoutAction, + trustedSetupNodeAction, + reviewedNpmAction, + ]); expect(requiredWorkflowStep(job, "Checkout").with?.["persist-credentials"]).toBe(false); expect(requiredWorkflowStep(job, "Install dependencies").run).toBe( "npm ci --ignore-scripts --no-audit --no-fund", @@ -469,6 +474,9 @@ describe("pull request and main workflow contracts", () => { // 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", () => { + expect( + requiredWorkflowStep(prWorkflow.jobs["build-typecheck"], "Install dependencies").env, + ).toEqual({ NPM_CONFIG_ALLOW_REMOTE: "root" }); const packageJob = prWorkflow.jobs["openshell-sdk-package"]; expect(packageJob["timeout-minutes"]).toBe(10); expect(packageJob.permissions).toEqual({ actions: "read", contents: "read" }); diff --git a/test/automation/releases/npm-audit-receipt.test.ts b/test/automation/releases/npm-audit-receipt.test.ts index 690f5644e85..fe08bafb7fb 100644 --- a/test/automation/releases/npm-audit-receipt.test.ts +++ b/test/automation/releases/npm-audit-receipt.test.ts @@ -9,7 +9,6 @@ import { describe, expect, it } from "vitest"; import { canonicalAuditReceipt, createAuditReceipt, - LEGACY_NPM_AUDIT_RECEIPT_DEADLINE, parseAndVerifyAuditReceipt, reviewedLockedGraphSha256s, sha256, @@ -17,9 +16,10 @@ import { const NOW = new Date("2026-09-04T00:00:00.000Z"); const reviewedNpmIdentity = { - npmArchiveSha256: "0".repeat(64), - npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, - npmVersion: "10.9.4", + npmArchiveSha256: "5dbb86c71d07a1957f2e90734092dd6a58bdcd9ebc2d8d41ca1c6e6a21d364e1", + npmIntegrity: + "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw==", + npmVersion: "12.0.2", }; const inputs = { approvedPackageLockSha256s: [sha256("lock")], @@ -71,6 +71,20 @@ const reviewedConfig = { ], }; +const reviewedCliConfig = { + ...reviewedNpmIdentity, + lockedGraphs: [ + { + id: inputs.graphId, + integrity: "sha512-fixture", + label: "mcporter fixture", + lockSha256: sha256(inputs.packageLock), + packageSpec: "mcporter@1.0.0", + tarballUrl: "https://registry.npmjs.org/mcporter/-/mcporter-1.0.0.tgz", + }, + ], +}; + function reviewedDigests(config: unknown): readonly string[] { return reviewedLockedGraphSha256s(JSON.stringify(config), inputs.graphId); } @@ -125,58 +139,11 @@ describe("npm audit receipt", () => { expect(new Date(parsed.expiresAt).getTime() - NOW.getTime()).toBeLessThan(12 * 60 * 60 * 1000); }); - it("accepts a legacy npmjs receipt only through the explicit transition option", () => { - const legacy = { - ...receipt(), - argv: ["audit", "--omit=dev", "--json"], - registryOrigin: "https://registry.npmjs.org/", - }; - expect(() => parseAndVerifyAuditReceipt(canonicalAuditReceipt(legacy), inputs)).toThrow( - /registry/, + it("rejects a receipt that omits the reviewed npm archive identity", () => { + const { npmArchiveSha256: _npmArchiveSha256, ...incomplete } = receipt(); + expect(() => parseAndVerifyAuditReceipt(JSON.stringify(incomplete), inputs)).toThrow( + /unexpected or missing keys/, ); - expect( - parseAndVerifyAuditReceipt(canonicalAuditReceipt(legacy), { - ...inputs, - allowLegacyReceipt: true, - }).registryOrigin, - ).toBe("https://registry.npmjs.org/"); - expect(() => - parseAndVerifyAuditReceipt(canonicalAuditReceipt(legacy), { - ...inputs, - allowLegacyReceipt: true, - now: new Date(LEGACY_NPM_AUDIT_RECEIPT_DEADLINE), - }), - ).toThrow(/allowed contract/); - }); - - it("accepts a version-only schema only through the bounded legacy transition", () => { - const { - npmArchiveSha256: _npmArchiveSha256, - npmIntegrity: _npmIntegrity, - ...versionOnly - } = receipt(); - const legacy = { - ...versionOnly, - argv: ["audit", "--omit=dev", "--json"], - registryOrigin: "https://registry.npmjs.org/", - schemaVersion: 1, - } as const; - expect(() => parseAndVerifyAuditReceipt(canonicalAuditReceipt(legacy), inputs)).toThrow( - /version-only receipt schema/, - ); - expect( - parseAndVerifyAuditReceipt(canonicalAuditReceipt(legacy), { - ...inputs, - allowLegacyReceipt: true, - }).schemaVersion, - ).toBe(1); - expect(() => - parseAndVerifyAuditReceipt(canonicalAuditReceipt(legacy), { - ...inputs, - allowLegacyReceipt: true, - now: new Date(LEGACY_NPM_AUDIT_RECEIPT_DEADLINE), - }), - ).toThrow(/version-only receipt schema/); }); it.each([ @@ -273,48 +240,65 @@ describe("npm audit receipt", () => { ).toThrow(/rawResponseSha256/); }); - it.each([ - { legacy: false, registry: inputs.registryOrigin, unflaggedStatus: 0, unflaggedStderr: "" }, - { - legacy: true, - registry: "https://registry.npmjs.org/", - unflaggedStatus: 1, - unflaggedStderr: "receipt npm audit registry and arguments do not match an allowed contract", - }, - ])( - "provides a local CLI verifier for a BuildKit secret mount ($legacy)", - ({ legacy, registry, unflaggedStatus, unflaggedStderr }) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "receipt-cli-")); - try { - fs.writeFileSync(path.join(root, "package.json"), inputs.packageJson); - fs.writeFileSync(path.join(root, "package-lock.json"), inputs.packageLock); - fs.writeFileSync(path.join(root, "exceptions.json"), inputs.exceptionPolicy); - fs.writeFileSync(path.join(root, "raw.json"), inputs.rawResponse); - fs.writeFileSync( - path.join(root, "reviewed-npm-audit.json"), - JSON.stringify({ - ...reviewedNpmIdentity, - lockedGraphs: [ - { - id: inputs.graphId, - integrity: "sha512-fixture", - label: "mcporter fixture", - lockSha256: sha256(inputs.packageLock), - packageSpec: "mcporter@1.0.0", - tarballUrl: "https://registry.npmjs.org/mcporter/-/mcporter-1.0.0.tgz", - }, - ], - }), - ); - const auditReceipt = legacy - ? { - ...receipt(new Date()), - argv: ["audit", "--omit=dev", "--json"], - registryOrigin: registry, - } - : receipt(new Date()); - fs.writeFileSync(path.join(root, "receipt.json"), canonicalAuditReceipt(auditReceipt)); - const verifierArgs = [ + it("provides a local CLI verifier for a BuildKit secret mount", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "receipt-cli-")); + try { + fs.writeFileSync(path.join(root, "package.json"), inputs.packageJson); + fs.writeFileSync(path.join(root, "package-lock.json"), inputs.packageLock); + fs.writeFileSync(path.join(root, "exceptions.json"), inputs.exceptionPolicy); + fs.writeFileSync(path.join(root, "raw.json"), inputs.rawResponse); + fs.writeFileSync( + path.join(root, "reviewed-npm-audit.json"), + JSON.stringify(reviewedCliConfig), + ); + fs.writeFileSync(path.join(root, "receipt.json"), canonicalAuditReceipt(receipt(new Date()))); + const verifierArgs = [ + path.join(import.meta.dirname, "../../../scripts/lib/npm-audit-receipt.mts"), + "--receipt", + path.join(root, "receipt.json"), + "--package-json", + path.join(root, "package.json"), + "--package-lock", + path.join(root, "package-lock.json"), + "--raw-report", + path.join(root, "raw.json"), + "--exceptions", + path.join(root, "exceptions.json"), + "--graph", + inputs.graphId, + "--audit-config", + path.join(root, "reviewed-npm-audit.json"), + "--registry", + inputs.registryOrigin, + "--threshold", + inputs.severityThreshold, + ]; + const result = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("current policy verified"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects the removed raw-copy option before writing either output", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "receipt-removed-option-")); + const resultFile = path.join(root, "policy.json"); + const rawCopyFile = path.join(root, "copied-raw.json"); + try { + fs.writeFileSync(path.join(root, "package.json"), inputs.packageJson); + fs.writeFileSync(path.join(root, "package-lock.json"), inputs.packageLock); + fs.writeFileSync(path.join(root, "exceptions.json"), inputs.exceptionPolicy); + fs.writeFileSync(path.join(root, "raw.json"), inputs.rawResponse); + fs.writeFileSync( + path.join(root, "reviewed-npm-audit.json"), + JSON.stringify(reviewedNpmIdentity), + ); + fs.writeFileSync(path.join(root, "receipt.json"), canonicalAuditReceipt(receipt(new Date()))); + + const result = spawnSync( + process.execPath, + [ path.join(import.meta.dirname, "../../../scripts/lib/npm-audit-receipt.mts"), "--receipt", path.join(root, "receipt.json"), @@ -331,30 +315,28 @@ describe("npm audit receipt", () => { "--audit-config", path.join(root, "reviewed-npm-audit.json"), "--registry", - registry, + inputs.registryOrigin, "--threshold", inputs.severityThreshold, - ...(legacy ? ["--legacy-audit", "true"] : []), - ]; - const result = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); - expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain("current policy verified"); - const unflaggedArgs = legacy ? verifierArgs.slice(0, -2) : verifierArgs; - const withoutMigrationFlag = spawnSync(process.execPath, unflaggedArgs, { - encoding: "utf8", - }); - expect(withoutMigrationFlag.status).toBe(unflaggedStatus); - expect(withoutMigrationFlag.stderr).toContain(unflaggedStderr); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }, - ); + "--result", + resultFile, + "--raw-copy", + rawCopyFile, + ], + { encoding: "utf8" }, + ); - it("rejects the removed raw-copy option before writing either output", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "receipt-removed-option-")); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("unexpected or missing keys"); + expect(fs.existsSync(resultFile)).toBe(false); + expect(fs.existsSync(rawCopyFile)).toBe(false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it("rejects a receipt produced by a different npm artifact before writing a result", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "receipt-npm-identity-")); const resultFile = path.join(root, "policy.json"); - const rawCopyFile = path.join(root, "copied-raw.json"); try { fs.writeFileSync(path.join(root, "package.json"), inputs.packageJson); fs.writeFileSync(path.join(root, "package-lock.json"), inputs.packageLock); @@ -362,7 +344,7 @@ describe("npm audit receipt", () => { fs.writeFileSync(path.join(root, "raw.json"), inputs.rawResponse); fs.writeFileSync( path.join(root, "reviewed-npm-audit.json"), - JSON.stringify(reviewedNpmIdentity), + JSON.stringify({ ...reviewedCliConfig, npmVersion: "11.18.0" }), ); fs.writeFileSync(path.join(root, "receipt.json"), canonicalAuditReceipt(receipt(new Date()))); @@ -390,16 +372,15 @@ describe("npm audit receipt", () => { inputs.severityThreshold, "--result", resultFile, - "--raw-copy", - rawCopyFile, ], { encoding: "utf8" }, ); expect(result.status).not.toBe(0); - expect(result.stderr).toContain("unexpected or missing keys"); + expect(result.stderr).toContain( + "receipt identity does not match expected graph and reviewed npm", + ); expect(fs.existsSync(resultFile)).toBe(false); - expect(fs.existsSync(rawCopyFile)).toBe(false); } finally { fs.rmSync(root, { recursive: true, force: true }); } diff --git a/test/automation/releases/release-pin-script-curl-proto-pin.test.ts b/test/automation/releases/release-pin-script-curl-proto-pin.test.ts index 4355af994b9..08c04c5268a 100644 --- a/test/automation/releases/release-pin-script-curl-proto-pin.test.ts +++ b/test/automation/releases/release-pin-script-curl-proto-pin.test.ts @@ -27,7 +27,7 @@ describe("release-pin update scripts pin curl to HTTPS (#9979)", () => { fs.chmodSync(fixtureScript, 0o755); fs.writeFileSync( path.join(tmp, "Dockerfile"), - `FROM node:22-trixie-slim@sha256:${"0".repeat(64)}\n`, + `FROM node:24.18.1-trixie-slim@sha256:${"0".repeat(64)}\n`, ); writeExecutable( path.join(fakeBin, "curl"), diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index b9d0451f4bf..40c7409b3b0 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -14,6 +14,13 @@ import { emitAuditReceipt } from "../../../scripts/audit-reviewed-npm-graph.mts" import { prepareReviewedNpmBootstrap } from "../../support/reviewed-npm-bootstrap"; const REPO_ROOT = path.join(import.meta.dirname, "../../.."); +const NPM_INTEGRITY = + "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw=="; +const REVIEWED_NPM_IDENTITY = { + npmArchiveSha256: "5dbb86c71d07a1957f2e90734092dd6a58bdcd9ebc2d8d41ca1c6e6a21d364e1", + npmIntegrity: NPM_INTEGRITY, + npmVersion: "12.0.2", +} as const; const TRUSTED_WORKFLOWS = [ "e2e.yaml", "managed-images.yaml", @@ -152,6 +159,16 @@ describe("npm audit handoff", () => { const exceptionPolicy = '{"schemaVersion":1,"exceptions":[]}\n'; const rawReport = '{"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n'; + const lockedGraphs = [ + { + id: "temporary-graph", + integrity: "sha512-fixture", + label: "temporary graph fixture", + lockSha256: createHash("sha256").update(packageLock).digest("hex"), + packageSpec: "temporary-graph@1.0.0", + tarballUrl: "https://registry.npmjs.org/temporary-graph/-/temporary-graph-1.0.0.tgz", + }, + ]; try { fs.writeFileSync(packageJsonFile, packageJson); fs.writeFileSync(packageLockFile, packageLock); @@ -160,19 +177,8 @@ describe("npm audit handoff", () => { fs.writeFileSync( auditConfigFile, JSON.stringify({ - npmArchiveSha256: "0".repeat(64), - npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, - npmVersion: "10.9.4", - lockedGraphs: [ - { - id: "temporary-graph", - integrity: "sha512-fixture", - label: "temporary graph fixture", - lockSha256: createHash("sha256").update(packageLock).digest("hex"), - packageSpec: "temporary-graph@1.0.0", - tarballUrl: "https://registry.npmjs.org/temporary-graph/-/temporary-graph-1.0.0.tgz", - }, - ], + ...REVIEWED_NPM_IDENTITY, + lockedGraphs, }), ); fs.writeFileSync( @@ -182,11 +188,7 @@ describe("npm audit handoff", () => { const receiptFile = emitAuditReceipt({ artifactDirectory: root, graphId: "temporary-graph", - reviewedNpmIdentity: { - npmArchiveSha256: "0".repeat(64), - npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, - npmVersion: "10.9.4", - }, + reviewedNpmIdentity: REVIEWED_NPM_IDENTITY, packageJsonFile, packageLockFile, preserveInputs: true, @@ -245,19 +247,9 @@ describe("npm audit handoff", () => { fs.writeFileSync( auditConfigFile, JSON.stringify({ - npmArchiveSha256: "0".repeat(64), - npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, + ...REVIEWED_NPM_IDENTITY, npmVersion: "11.18.0", - lockedGraphs: [ - { - id: "temporary-graph", - integrity: "sha512-fixture", - label: "temporary graph fixture", - lockSha256: createHash("sha256").update(packageLock).digest("hex"), - packageSpec: "temporary-graph@1.0.0", - tarballUrl: "https://registry.npmjs.org/temporary-graph/-/temporary-graph-1.0.0.tgz", - }, - ], + lockedGraphs, }), ); const rejected = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); @@ -427,8 +419,6 @@ describe("npm audit handoff", () => { "https://registry.yarnpkg.com", "--threshold", "high", - "--legacy-audit", - "true", "--result", trustedPolicyResult, ]; diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 6e1fd602588..045dd9a445a 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -321,6 +321,8 @@ describe("trusted npm audit workflow (#5896)", () => { expect(cacheBucketStep.run).toContain( "const targetRoot = process.env.NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT;", ); + expect(cacheBucketStep.run).toContain("const identity = parseReviewedNpmIdentity(config);"); + expect(cacheBucketStep.run).toContain("...identity"); expect(cacheBucketStep.run).not.toContain("${{ inputs.cache-directory }}"); expect(cacheBucketStep.run).not.toContain("${{ inputs.target-root }}"); }); @@ -470,11 +472,7 @@ describe("trusted npm audit workflow (#5896)", () => { const receiptOptions = { artifactDirectory: root, graphId: "temporary-graph", - reviewedNpmIdentity: { - npmArchiveSha256: "0".repeat(64), - npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, - npmVersion: "10.9.4", - }, + reviewedNpmIdentity: REVIEWED_AUDIT_CONFIG, packageJsonFile, packageLockFile, preserveInputs: true, @@ -1328,11 +1326,7 @@ describe("trusted npm audit workflow (#5896)", () => { directory: "/materialized", exceptionFile: "/exceptions.json", packageSpec: "nemoclaw@0.0.0", - reviewedNpmIdentity: { - npmArchiveSha256: "a".repeat(64), - npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, - npmVersion: "10.9.4", - }, + reviewedNpmIdentity: REVIEWED_AUDIT_CONFIG, threshold: "high", }, { @@ -1344,14 +1338,11 @@ describe("trusted npm audit workflow (#5896)", () => { graph: "nemoclaw-cli", provenance: { label: "NemoClaw CLI locked production graph", - npmVersion: "10.9.4", + npmIntegrity: REVIEWED_AUDIT_CONFIG.npmIntegrity, + npmVersion: REVIEWED_AUDIT_CONFIG.npmVersion, packageSpecs: ["nemoclaw@0.0.0"], }, - reviewedNpmIdentity: { - npmArchiveSha256: "a".repeat(64), - npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, - npmVersion: "10.9.4", - }, + reviewedNpmIdentity: REVIEWED_AUDIT_CONFIG, reportFile: path.join("/artifacts", "source-graph.json"), resultFile: path.join("/artifacts", "source-graph-policy.json"), threshold: "high", diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index 0cdf620bb80..543942943ba 100644 --- a/test/automation/releases/reviewed-npm-audit.test.ts +++ b/test/automation/releases/reviewed-npm-audit.test.ts @@ -5,6 +5,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { + reviewedArchiveGraphManifest, + stageReviewedArchiveForInstall, +} from "../../../scripts/audit-reviewed-npm-graph.mts"; import { type AuditExceptionRegistry, NPM_AUDIT_ARGV, @@ -40,11 +44,49 @@ const CONFIG = JSON.parse( ) as { severityThreshold: "info" | "low" | "moderate" | "high" | "critical"; }; + +describe("reviewed archive graph materialization", () => { + it("rejects an affected tar release", () => { + expect(() => reviewedArchiveGraphManifest("7.5.20")).toThrow( + "reviewed archive graph tar version must be exactly 7.5.21", + ); + }); + + it("stages archives at deterministic graph-relative paths", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-archive-stage-")); + try { + const firstGraph = path.join(root, "first-random-root", "graph"); + const secondGraph = path.join(root, "second-random-root", "graph"); + const firstArchive = path.join(root, "first-random-root", "fixture-1.0.0.tgz"); + const secondArchive = path.join(root, "second-random-root", "fixture-1.0.0.tgz"); + fs.mkdirSync(firstGraph, { recursive: true }); + fs.mkdirSync(secondGraph, { recursive: true }); + fs.writeFileSync(firstArchive, "reviewed archive bytes"); + fs.writeFileSync(secondArchive, "reviewed archive bytes"); + + const firstInstallPath = stageReviewedArchiveForInstall(firstGraph, firstArchive, 0); + const secondInstallPath = stageReviewedArchiveForInstall(secondGraph, secondArchive, 0); + + expect(firstInstallPath).toBe( + `.${path.sep}${path.join("reviewed-archives", "0-fixture-1.0.0.tgz")}`, + ); + expect(secondInstallPath).toBe(firstInstallPath); + expect(firstInstallPath).not.toContain(root); + expect(fs.readFileSync(path.join(firstGraph, firstInstallPath))).toEqual( + fs.readFileSync(path.join(secondGraph, secondInstallPath)), + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); const CHECKED_IN_POLICY = parseAuditExceptionRegistry( fs.readFileSync(path.join(REPO_ROOT, "ci", "npm-audit-exceptions.json"), "utf-8"), ); const EMPTY_POLICY: AuditExceptionRegistry = { schemaVersion: 1, exceptions: [] }; const NOW = new Date("2026-07-21T12:00:00Z"); +const NPM_INTEGRITY = + "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw=="; function withInstalledGraph( packages: Readonly>, @@ -605,6 +647,12 @@ describe("npm audit raw cache", () => { ); }); + it("rejects a standalone npm integrity argument", () => { + expect(() => + parseReviewedNpmAuditCliArgs(cliArgs(".", "--npm-integrity", npmIdentity.npmIntegrity), {}), + ).toThrow("unknown npm audit arguments: --npm-integrity"); + }); + it("rejects a truncated reviewed npm SHA-512 integrity", () => { expect(() => parseReviewedNpmIdentity({ ...npmIdentity, npmIntegrity: "sha512-A" })).toThrow( "npm audit configuration has an invalid npmIntegrity", @@ -809,8 +857,9 @@ describe("npm audit provenance", () => { const provenance = buildAuditProvenance({ finishedAt: "2026-07-21T20:09:41.000Z", label: "reviewed archive graph", - nodeVersion: "v22.22.2", - npmVersion: "10.9.7", + nodeVersion: "v24.18.1", + npmIntegrity: NPM_INTEGRITY, + npmVersion: "12.0.2", packageSpecs: ["openclaw@2026.6.10", "@openclaw/slack@2026.6.10"], rawReportPath: "reviewed-archive-graph.json", registry: "https://registry.npmjs.org/", @@ -818,8 +867,13 @@ describe("npm audit provenance", () => { startedAt: "2026-07-21T20:09:12.000Z", }); expect(provenance).toEqual({ - schemaVersion: 1, - scanner: { name: "npm audit", npmVersion: "10.9.7", nodeVersion: "v22.22.2" }, + schemaVersion: 2, + scanner: { + name: "npm audit", + npmIntegrity: NPM_INTEGRITY, + npmVersion: "12.0.2", + nodeVersion: "v24.18.1", + }, registry: deriveAuditEndpoints("https://registry.npmjs.org/"), run: { startedAt: "2026-07-21T20:09:12.000Z", finishedAt: "2026-07-21T20:09:41.000Z" }, graph: { @@ -837,8 +891,9 @@ describe("npm audit provenance", () => { failure: "npm audit failed without vulnerability findings: ECONNREFUSED", finishedAt: "2026-07-21T20:09:41.000Z", label: "reviewed archive graph", - nodeVersion: "v22.22.2", - npmVersion: "10.9.7", + nodeVersion: "v24.18.1", + npmIntegrity: NPM_INTEGRITY, + npmVersion: "12.0.2", packageSpecs: ["openclaw@2026.6.10"], rawReportPath: "reviewed-archive-graph.json", registry: "https://registry.npmjs.org/", @@ -894,8 +949,9 @@ describe("npm audit provenance", () => { graph: "fixture-graph", provenance: { label: "fixture graph", - nodeVersion: "v22.22.2", - npmVersion: "10.9.7", + nodeVersion: "v24.18.1", + npmIntegrity: NPM_INTEGRITY, + npmVersion: "12.0.2", packageSpecs: ["fixture@1.0.0"], }, reportFile: reportPath, diff --git a/test/automation/releases/reviewed-npm-bootstrap.test.ts b/test/automation/releases/reviewed-npm-bootstrap.test.ts index 185f616258a..9b502cea6f9 100644 --- a/test/automation/releases/reviewed-npm-bootstrap.test.ts +++ b/test/automation/releases/reviewed-npm-bootstrap.test.ts @@ -1,13 +1,136 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; -import { runReviewedNpmBootstrap } from "../../support/reviewed-npm-bootstrap"; +const REPO_ROOT = path.join(import.meta.dirname, "../../.."); +const BOOTSTRAP = path.join( + REPO_ROOT, + ".github", + "actions", + "setup-reviewed-npm", + "verify-and-install-npm.sh", +); -type BootstrapOptions = Parameters[0]; +function identity(archive: Buffer): Record { + return { + npmArchiveSha256: createHash("sha256").update(archive).digest("hex"), + npmIntegrity: `sha512-${createHash("sha512").update(archive).digest("base64")}`, + npmVersion: "12.0.2", + }; +} + +type BootstrapFixtureOptions = { + archive: string; + archiveManifest?: "invalid" | "matching" | "mismatched" | "missing"; + environment?: NodeJS.ProcessEnv; + installedVersion?: string; + mutateIdentity?: (identity: Record) => Record; +}; + +function createArchive( + root: string, + contents: string, + manifest: NonNullable, +): string { + const packageRoot = path.join(root, "package"); + const archiveFile = path.join(root, "fixture.tgz"); + fs.mkdirSync(packageRoot); + fs.writeFileSync(path.join(packageRoot, "README.md"), contents); + const writeManifest = { + invalid: () => fs.writeFileSync(path.join(packageRoot, "package.json"), "{invalid json\n"), + matching: () => + fs.writeFileSync(path.join(packageRoot, "package.json"), '{"version":"12.0.2"}\n'), + mismatched: () => + fs.writeFileSync(path.join(packageRoot, "package.json"), '{"version":"12.0.3"}\n'), + missing: () => undefined, + } satisfies Record, () => unknown>; + writeManifest[manifest](); + const packed = spawnSync("tar", ["-czf", archiveFile, "-C", root, "package"], { + encoding: "utf8", + }); + assert.equal(packed.status, 0, packed.stderr); + return archiveFile; +} + +function runBootstrapFixture(options: BootstrapFixtureOptions) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-bootstrap-")); + const bin = path.join(root, "bin"); + const npmLog = path.join(root, "npm.log"); + const installMarker = path.join(root, "install-called"); + const identityPath = path.join(root, "reviewed-npm-audit.json"); + const archiveFile = createArchive(root, options.archive, options.archiveManifest ?? "matching"); + const reviewedIdentity = identity(fs.readFileSync(archiveFile)); + + fs.mkdirSync(bin); + fs.writeFileSync( + path.join(bin, "npm"), + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" +case "$1" in + pack) + pack_args="$*" + shift + download_dir="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--pack-destination" ]; then + download_dir="$2" + break + fi + shift + done + [ -n "$download_dir" ] + [ "$pack_args" = "pack npm@12.0.2 --pack-destination $download_dir --userconfig /dev/null --registry https://registry.npmjs.org/ --ignore-scripts --no-audit --no-fund" ] + cp "$NEMOCLAW_TEST_ARCHIVE_FILE" "$download_dir/npm-12.0.2.tgz" + ;; + install) + : > "$NEMOCLAW_TEST_INSTALL_MARKER" + ;; + --version) + printf '%s\\n' "$NEMOCLAW_TEST_INSTALLED_VERSION" + ;; + *) + exit 2 + ;; +esac +`, + { mode: 0o755 }, + ); + fs.writeFileSync( + identityPath, + `${JSON.stringify(options.mutateIdentity?.(reviewedIdentity) ?? reviewedIdentity)}\n`, + ); + const result = spawnSync("bash", [BOOTSTRAP, identityPath], { + encoding: "utf8", + env: { + ...process.env, + ...options.environment, + NEMOCLAW_TEST_ARCHIVE_FILE: archiveFile, + NEMOCLAW_TEST_INSTALL_MARKER: installMarker, + NEMOCLAW_TEST_INSTALLED_VERSION: options.installedVersion ?? "12.0.2", + NEMOCLAW_TEST_NPM_LOG: npmLog, + PATH: `${bin}:${process.env.PATH ?? ""}`, + RUNNER_TEMP: root, + }, + }); + return { + cleanup: () => fs.rmSync(root, { recursive: true, force: true }), + installCalled: fs.existsSync(installMarker), + npmInvocations: fs.existsSync(npmLog) ? fs.readFileSync(npmLog, "utf8").trim().split("\n") : [], + result, + }; +} describe("reviewed npm bootstrap", () => { + const archive = "verified archive\n"; + it.each([ [ "malformed version", @@ -44,13 +167,13 @@ describe("reviewed npm bootstrap", () => { "archive integrity mismatch", 1, ], - ["version mismatch", { archiveManifest: "mismatched" }, "archive version 12.0.3", 1], - ["missing metadata", { archiveManifest: "missing" }, "is missing or invalid", 1], - ["invalid metadata", { archiveManifest: "invalid" }, "is missing or invalid", 1], - ] as [string, BootstrapOptions, string, number][])( + ["archive version mismatch", { archiveManifest: "mismatched" }, "archive version 12.0.3", 1], + ["missing archive metadata", { archiveManifest: "missing" }, "is missing or invalid", 1], + ["invalid archive metadata", { archiveManifest: "invalid" }, "is missing or invalid", 1], + ] as [string, Omit, string, number][])( "rejects %s before installation (#8253)", (_caseName, options, error, npmInvocations) => { - const fixture = runReviewedNpmBootstrap(options); + const fixture = runBootstrapFixture({ archive, ...options }); try { expect(fixture.result.status).toBe(1); expect(fixture.result.stderr).toContain(error); @@ -62,23 +185,51 @@ describe("reviewed npm bootstrap", () => { }, ); - it("installs the verified archive offline despite ambient npm configuration (#8253)", () => { - const fixture = runReviewedNpmBootstrap({ - environment: () => ({ + it("rejects a post-install npm version mismatch (#8253)", () => { + const fixture = runBootstrapFixture({ archive, installedVersion: "12.0.3" }); + try { + expect(fixture.result.status).toBe(1); + expect(fixture.result.stderr).toContain( + "installed npm@12.0.3 does not match reviewed npm@12.0.2", + ); + expect(fixture.npmInvocations).toHaveLength(3); + expect(fixture.installCalled).toBe(true); + } finally { + fixture.cleanup(); + } + }); + + it("installs a matching archive offline (#8253)", () => { + const fixture = runBootstrapFixture({ archive }); + try { + const { npmInvocations, result } = fixture; + expect(result.status).toBe(0); + expect(npmInvocations).toHaveLength(3); + expect(npmInvocations[0]).toMatch( + /^pack npm@12\.0\.2 --pack-destination .* --userconfig \/dev\/null --registry https:\/\/registry\.npmjs\.org\/ --ignore-scripts --no-audit --no-fund$/, + ); + expect(npmInvocations[1]).toMatch( + /^install --global .*\/npm-12\.0\.2\.tgz --userconfig \/dev\/null --ignore-scripts --no-audit --no-fund --offline$/, + ); + expect(npmInvocations[2]).toBe("--version"); + } finally { + fixture.cleanup(); + } + }); + + it("overrides ambient npm configuration for the archive download (#8253)", () => { + const fixture = runBootstrapFixture({ + archive, + environment: { NPM_CONFIG_REGISTRY: "https://registry.example.test/", NPM_CONFIG_USERCONFIG: "/tmp/untrusted-npmrc", - }), + }, }); try { - expect(fixture.result.status, fixture.result.stderr).toBe(0); - expect(fixture.installCalled).toBe(true); - expect(fixture.npmInvocations).toHaveLength(2); + expect(fixture.result.status).toBe(0); expect(fixture.npmInvocations[0]).toMatch( /^pack npm@12\.0\.2 --pack-destination .* --userconfig \/dev\/null --registry https:\/\/registry\.npmjs\.org\/ --ignore-scripts --no-audit --no-fund$/, ); - expect(fixture.npmInvocations[1]).toMatch( - /^install --global .*\/npm-12\.0\.2\.tgz --userconfig \/dev\/null --ignore-scripts --no-audit --no-fund --offline$/, - ); } finally { fixture.cleanup(); } diff --git a/test/e2e/README.md b/test/e2e/README.md index a0f588c618f..bc7939fdf88 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -55,7 +55,7 @@ the sandbox can resolve their paths. The candidate CLI comes from the source commit that an E2E run tests. The `generate-matrix` job prepares it once through the shared `ci-compile-artifacts` action. Main CI and PR CI use that action too. It always produces the CLI and full plugin, -with Node.js 22.23.2, and verifies the embedded source revision and source maps. +with Node.js 24.18.1, and verifies the embedded source revision and source maps. E2E loads the action from a separate checkout of the trusted workflow revision. The existing E2E artifact handoff retains the CLI and shared-module payload during this migration. The job publishes root `dist/` and `nemoclaw/dist/shared/` in one content-addressed artifact. @@ -117,7 +117,7 @@ Before the action restores root `dist/` and `nemoclaw/dist/shared/` into the wor - The upload digest is present and well formed. - The candidate SHA matches the expected commit. - The manifest matches the source, workflow run, toolchain contract, and payload. -- The restore action uses a Node.js 22 process to stream each file as binary data when it verifies SHA-256 digests. +- The restore action uses the pinned Node.js 24.18.1 process to stream each file as binary data when it verifies SHA-256 digests. - The archive contains no path traversal, links, special files, or files outside root `dist/` and `nemoclaw/dist/shared/`. - Neither root `dist/` nor `nemoclaw/dist/` already exists, including as a dangling symbolic link. - The candidate checkout's `nemoclaw/` path is a directory and is not a symbolic link. @@ -851,7 +851,8 @@ dispatches qualify the exact stable OpenShell 0.0.116 product contract and do no select the development runtime. The OpenClaw, Hermes, and LangChain Deep Agents Code shards restore and verify that same artifact with the trusted workflow revision. -The `actions/setup-node` step selects Node.js 22 and disables automatic package manager caching before candidate checkout. +The `actions/setup-node` step selects Node.js 24.18.1 and disables automatic package manager caching before candidate checkout. +The trusted workflow installs the exact reviewed npm 12 archive before it installs planner dependencies. An argument- and asset-allowlisted `gh` shim presents only the retained files to the unchanged trusted `scripts/install-openshell.sh` path. A separate `curl` shim blocks network fallback. The installer still checks the release checksums and archive structure before installation. diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 6fbb005c4be..2f171e74703 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -43,8 +43,8 @@ export const REBUILD_TIMEOUT_MS = 25 * 60_000; export const PROBE_TIMEOUT_MS = 120_000; export const LIVE_TIMEOUT_MS = 90 * 60_000; export const OPENSHELL_EXEC_ARGUMENT_LIMIT_BYTES = 32_768; -const FAKE_API_IMAGE = - "node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c"; +export const FAKE_API_IMAGE = + "node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc"; const DEFAULT_OPENSHELL_DOCKER_NETWORK = "openshell-docker"; export const FAKE_API_PROXY_READINESS_PORT = 8079; export const FAKE_API_PROXY_SOURCE = String.raw` diff --git a/test/e2e/live/openshell-gateway-auth-probe.ts b/test/e2e/live/openshell-gateway-auth-probe.ts index bb0c8e2b465..55c4b541889 100644 --- a/test/e2e/live/openshell-gateway-auth-probe.ts +++ b/test/e2e/live/openshell-gateway-auth-probe.ts @@ -10,7 +10,7 @@ import { getDockerDriverGatewayLocalTlsBundle } from "../../../src/lib/onboard/d const SANDBOX_JWT_SUBJECT_PREFIX = "spiffe://openshell/sandbox/"; export const DOCKER_GRPC_PROBE_IMAGE = - "node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c"; + "node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc"; const CONTAINER_PROBE_CA_PATH = "/tmp/nemoclaw-probe-ca.crt"; const CONTAINER_PROBE_CLIENT_CERT_PATH = "/tmp/nemoclaw-probe-client.crt"; const CONTAINER_PROBE_CLIENT_KEY_PATH = "/tmp/nemoclaw-probe-client.key"; diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 80b0aa574e3..0948785cab6 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -265,6 +265,7 @@ { "live": "test/e2e/live/openshell-gateway-auth-source-contract.test.ts", "liveSources": [ + "test/e2e/live/openshell-gateway-auth-probe.ts", "test/e2e/live/openshell-gateway-auth-source-contract-helpers.ts", "test/e2e/live/openshell-v0116-tls-server-name-source.ts" ], diff --git a/test/e2e/support/base-image-publication-workflow-boundary.test.ts b/test/e2e/support/base-image-publication-workflow-boundary.test.ts index 2c124a0897d..e8c60fd130d 100644 --- a/test/e2e/support/base-image-publication-workflow-boundary.test.ts +++ b/test/e2e/support/base-image-publication-workflow-boundary.test.ts @@ -246,7 +246,12 @@ describe("base-image publication workflow boundary (#7372)", () => { ["Node condition", (value) => (gateSteps(value)[2].if = "${{ always() }}")], ["Node pin", (value) => (gateSteps(value)[2].uses = "actions/setup-node@v6")], ["Node dependency cache", (value) => (gateSteps(value)[2].with!.cache = "npm")], - ["verifier condition", (value) => (gateSteps(value)[3].if = "${{ always() }}")], + [ + "verifier condition", + (value) => + (gateStep(value, "Select base and optional managed-image publication").if = + "${{ always() }}"), + ], [ "base publication selection condition", (value) => @@ -261,19 +266,30 @@ describe("base-image publication workflow boundary (#7372)", () => { "base contract validation condition", (value) => (gateStep(value, "Validate immutable Deep Agents Code base").if = "${{ false }}"), ], - ["verifier token", (value) => (gateSteps(value)[3].env!.GITHUB_TOKEN = "${{ secrets.TOKEN }}")], + [ + "verifier token", + (value) => + (gateStep(value, "Select base and optional managed-image publication").env!.GITHUB_TOKEN = + "${{ secrets.TOKEN }}"), + ], [ "verifier SHA", - (value) => (gateSteps(value)[3].env!.EXPECTED_SHA = "${{ inputs.checkout_sha }}"), + (value) => + (gateStep(value, "Select base and optional managed-image publication").env!.EXPECTED_SHA = + "${{ inputs.checkout_sha }}"), ], [ "managed-image publication requirement", - (value) => (gateSteps(value)[3].env!.REQUIRE_MANAGED_IMAGE_PUBLICATION = "0"), + (value) => + (gateStep(value, "Select base and optional managed-image publication").env![ + "REQUIRE_MANAGED_IMAGE_PUBLICATION" + ] = "0"), ], [ "verifier command", (value) => { - gateSteps(value)[3].run = "node tools/e2e/base-image-publication.mts"; + gateStep(value, "Select base and optional managed-image publication").run = + "node tools/e2e/base-image-publication.mts"; }, ], [ diff --git a/test/e2e/support/cli-artifact-workflow-boundary.test.ts b/test/e2e/support/cli-artifact-workflow-boundary.test.ts index aa1b602ba5b..17d9401149c 100644 --- a/test/e2e/support/cli-artifact-workflow-boundary.test.ts +++ b/test/e2e/support/cli-artifact-workflow-boundary.test.ts @@ -124,7 +124,10 @@ type RestoreFixtureOptions = { buildIdentitySha?: string; consumerRunAttempt?: string; expectedPayloadSha256?: string; + consumerNodeVersion?: string; manifestCandidateSha?: string; + manifestNodeVersion?: string; + manifestNpmVersion?: string; manifestRunAttempt?: string; preexistingDist?: | "dangling-symlink" @@ -394,8 +397,8 @@ async function runRestoreValidation(owner: ProcessOwner, options: RestoreFixture runAttempt: options.manifestRunAttempt ?? options.producerRunAttempt ?? "1", }, toolchain: { - node: "v22.23.1", - npm: "10.9.2", + node: options.manifestNodeVersion ?? "v24.18.1", + npm: options.manifestNpmVersion ?? "12.0.2", runnerOs: "Linux", runnerArch: "X64", }, @@ -407,7 +410,7 @@ async function runRestoreValidation(owner: ProcessOwner, options: RestoreFixture const nodeWrapper = path.join(toolDirectory, "node"); fs.writeFileSync( nodeWrapper, - `#!/usr/bin/env bash\nset -euo pipefail\nif [[ "$#" -eq 1 && "$1" == "--version" ]]; then\n echo v22.23.1\n exit 0\nfi\nexec ${JSON.stringify(process.execPath)} "$@"\n`, + `#!/usr/bin/env bash\nset -euo pipefail\nif [[ "$#" -eq 1 && "$1" == "--version" ]]; then\n echo ${options.consumerNodeVersion ?? "v24.18.1"}\n exit 0\nfi\nexec ${JSON.stringify(process.execPath)} "$@"\n`, { mode: 0o755 }, ); const lockfileSha256 = sha256File(path.join(workspace, "package-lock.json")); @@ -738,6 +741,25 @@ describe.concurrent("exact-commit CLI artifact restore", () => { ); }); + it.for([ + ["Node", { manifestNodeVersion: "v24.18.0" }], + ["npm", { manifestNpmVersion: "12.0.1" }], + ] as const)( + ([tool, _options]: readonly [string, RestoreFixtureOptions]) => + `rejects a CLI artifact built with a different ${tool} version`, + async ([, options], context) => { + await expectRestoreFailure(context, options, "exact-commit CLI artifact provenance mismatch"); + }, + ); + + it("rejects restoration under a different Node version", async (context) => { + await expectRestoreFailure( + context, + { consumerNodeVersion: "v24.18.0" }, + "consumer must restore the CLI under the pinned Node 24.18.1 toolchain", + ); + }); + it("rejects a payload digest mismatch before artifact extraction (#7915)", async (context) => { await expectRestoreFailure( context, diff --git a/test/e2e/support/jetson-managed-revision-boundary.test.ts b/test/e2e/support/jetson-managed-revision-boundary.test.ts index 0ad80e02227..e424c31555d 100644 --- a/test/e2e/support/jetson-managed-revision-boundary.test.ts +++ b/test/e2e/support/jetson-managed-revision-boundary.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { UPLOAD_E2E_ARTIFACTS_ACTION } from "../../../tools/e2e/upload-e2e-artifacts-workflow-boundary.mts"; +import { E2E_ACTION_PROVENANCE } from "../../../tools/e2e/workflow-boundary-policy.mts"; import { validateJetsonDispatchBoundary } from "../../../tools/e2e/workflow-boundary.mts"; const REQUIRED_SELECTOR = @@ -48,7 +49,11 @@ function syntheticJetsonWorkflow(selector = REQUIRED_SELECTOR): unknown { { name: "Set up Node for Jetson controller", uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", - with: { "node-version": ">=22.19.0 <23" }, + with: { "node-version": "24.18.1" }, + }, + { + name: "Install reviewed npm", + uses: E2E_ACTION_PROVENANCE.reviewedNpmSetup.reference, }, { env: { diff --git a/test/e2e/support/jetson-workflow-boundary.test.ts b/test/e2e/support/jetson-workflow-boundary.test.ts index b5536471b7e..d1062189f56 100644 --- a/test/e2e/support/jetson-workflow-boundary.test.ts +++ b/test/e2e/support/jetson-workflow-boundary.test.ts @@ -156,10 +156,22 @@ describe("Jetson nvmap GPU E2E workflow boundary", () => { }); expect(errors).toContain( - "jetson-nvmap-gpu controller must contain only checkout, Node setup, dispatch, and upload", + "jetson-nvmap-gpu controller must contain only checkout, Node/npm setup, dispatch, and upload", ); }); + it("rejects a mutable npm setup action in the Jetson controller (#8142)", () => { + const errors = validateWorkflowMutation((workflow) => { + const job = (workflow.jobs as Record)["jetson-nvmap-gpu"] as { + steps?: Array<{ name?: string; uses?: string }>; + }; + const setup = job.steps!.find((step) => step.name === "Install reviewed npm")!; + setup.uses = "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@main"; + }); + + expect(errors).toContain("jetson-nvmap-gpu controller must install reviewed npm immutably"); + }); + it("keeps the runner temporary artifact path on the dispatch step (#8142)", () => { const errors = validateWorkflowMutation((workflow) => { const job = (workflow.jobs as Record)["jetson-nvmap-gpu"] as { diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index a0599b0c128..dab8afeb539 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -17,6 +17,7 @@ import { buildProcessTokenProbe } from "../fixtures/process-token-probe.ts"; import { buildSandboxNodeInvocation, buildSandboxShellInvocation, + FAKE_API_IMAGE, FAKE_API_PROXY_READINESS_PORT, FAKE_API_PROXY_READINESS_SOURCE, FAKE_API_PROXY_SOURCE, @@ -45,6 +46,12 @@ const OPENSHELL_NETWORK_INSPECT = JSON.stringify([ ]); const execFileAsync = promisify(execFile); +it("pins the fake messaging API to the reviewed Node runtime", () => { + expect(FAKE_API_IMAGE).toBe( + "node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc", + ); +}); + async function waitFor(predicate: () => boolean, message: string): Promise { const deadline = Date.now() + 5_000; let matched = predicate(); diff --git a/test/e2e/support/native-runtime-qualification-collector-workflow.test.ts b/test/e2e/support/native-runtime-qualification-collector-workflow.test.ts index 7e6e93c1a60..289e885dfdd 100644 --- a/test/e2e/support/native-runtime-qualification-collector-workflow.test.ts +++ b/test/e2e/support/native-runtime-qualification-collector-workflow.test.ts @@ -112,7 +112,12 @@ describe("native runtime qualification collector workflow", () => { "sparse-checkout-cone-mode": false, }); expect(String(checkout.with?.["sparse-checkout"]).trim().split(/\s+/u).sort()).toEqual( - localImportClosure(COLLECTOR_ENTRYPOINT), + [ + ".github/actions/setup-reviewed-npm", + "ci/reviewed-npm-audit.json", + "scripts/lib/reviewed-npm-audit.mts", + ...localImportClosure(COLLECTOR_ENTRYPOINT), + ].sort(), ); expect( (collect as WorkflowStep & { readonly "working-directory"?: string })["working-directory"], diff --git a/test/e2e/support/native-runtime-qualification-producer-workflow.test.ts b/test/e2e/support/native-runtime-qualification-producer-workflow.test.ts index e4ebb122077..139d72c5bb4 100644 --- a/test/e2e/support/native-runtime-qualification-producer-workflow.test.ts +++ b/test/e2e/support/native-runtime-qualification-producer-workflow.test.ts @@ -574,7 +574,7 @@ describe("native runtime qualification producer workflow", () => { expect(identity.run).toContain('.status == "in_progress"'); expect(identity.run).toContain("select(length == 1)"); expect(identity.run).toContain("Aggregate job lookup exceeds the bounded 100-job page"); - expect(setupNode.with?.["node-version"]).toBe("22.19.0"); + expect(setupNode.with?.["node-version"]).toBe("24.18.1"); expect(collect.run).toContain("native-runtime-qualification-producer-aggregate.mts"); expect(collect.env?.QUALIFICATION_PLAN).toBe( "${{ needs.native-runtime-qualification-producer-plan.outputs.matrix }}", diff --git a/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts b/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts index e2e9fb85eb5..b3035368906 100644 --- a/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts +++ b/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts @@ -10,7 +10,7 @@ import { const JOB_NAME = "openclaw-plugin-runtime-exdev"; const BUILDER_IMAGE = - "node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c"; + "node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc"; function validWorkflow(): OpenClawPluginRuntimeExdevWorkflow { const expression = (value: string) => "${{ " + value + " }}"; @@ -52,7 +52,7 @@ function validWorkflow(): OpenClawPluginRuntimeExdevWorkflow { }, { name: "Prepare E2E workspace", - uses: "NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75", + uses: "NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c", }, { name: "Run OpenClaw cross-device plugin lifecycle live test", @@ -153,7 +153,7 @@ describe("OpenClaw plugin runtime EXDEV workflow boundary", () => { "openclaw-plugin-runtime-exdev action 'Checkout candidate' must pin a full SHA", "openclaw-plugin-runtime-exdev checkout must disable persisted credentials", "openclaw-plugin-runtime-exdev must use the reviewed prepare-e2e action", - "openclaw-plugin-runtime-exdev step 'Pre-pull current-checkout Docker Hub builder image' must run: docker pull node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c", + "openclaw-plugin-runtime-exdev step 'Pre-pull current-checkout Docker Hub builder image' must run: docker pull node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc", "openclaw-plugin-runtime-exdev must always revoke Docker auth before the current-checkout fixture", "openclaw-plugin-runtime-exdev must use the pinned Docker auth cleanup action before artifact restore", "openclaw-plugin-runtime-exdev runtime proof must not receive workflow credentials", diff --git a/test/e2e/support/openshell-gateway-auth-contract-workflow-boundary.test.ts b/test/e2e/support/openshell-gateway-auth-contract-workflow-boundary.test.ts index 78c64f3191d..e3e3533d2e6 100644 --- a/test/e2e/support/openshell-gateway-auth-contract-workflow-boundary.test.ts +++ b/test/e2e/support/openshell-gateway-auth-contract-workflow-boundary.test.ts @@ -70,7 +70,7 @@ describe("OpenShell gateway auth contract workflow boundary", () => { "openshell-gateway-auth-contract must use the trusted execution plan", "openshell-gateway-auth-contract must run on ubuntu-latest", "openshell-gateway-auth-contract must retain its 20 minute resource budget", - "openshell-gateway-auth-contract must set DOCKER_GRPC_PROBE_IMAGE=node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c", + "openshell-gateway-auth-contract must set DOCKER_GRPC_PROBE_IMAGE=node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc", "openshell-gateway-auth-contract must set E2E_ARTIFACT_DIR=${{ github.workspace }}/e2e-artifacts/live/openshell-gateway-auth-contract", "openshell-gateway-auth-contract must set NEMOCLAW_CANDIDATE_VERSION=0.0.116", "openshell-gateway-auth-contract must set NEMOCLAW_OPENSHELL_PIN_VERSION=0.0.116", diff --git a/test/e2e/support/openshell-gateway-auth-source-contract-helpers.test.ts b/test/e2e/support/openshell-gateway-auth-source-contract-helpers.test.ts index 57528144de0..5f6e394fc46 100644 --- a/test/e2e/support/openshell-gateway-auth-source-contract-helpers.test.ts +++ b/test/e2e/support/openshell-gateway-auth-source-contract-helpers.test.ts @@ -12,6 +12,7 @@ import { scanAndApproveOpenShellGatewayAuthArtifacts, } from "../../../tools/e2e/openshell-gateway-auth-artifact-safety.mts"; import { ArtifactSink } from "../fixtures/artifacts.ts"; +import { DOCKER_GRPC_PROBE_IMAGE } from "../live/openshell-gateway-auth-probe.ts"; import { assertOpenShellGatewayAuthArtifactsSafe, buildSandboxTokenContainerProbeInvocation, @@ -34,6 +35,12 @@ function withArtifactDir(fn: (dir: string) => void): void { } describe("OpenShell gateway auth source contract helpers", () => { + it("pins the Docker gRPC probe to the reviewed Node runtime", () => { + expect(DOCKER_GRPC_PROBE_IMAGE).toBe( + "node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc", + ); + }); + it("passes the sandbox JWT through stdin without adding it to container arguments", () => { const stateDir = path.resolve("/tmp/nemoclaw-auth-source-state"); const sandboxToken = "Bearer sandbox-token"; diff --git a/test/e2e/support/prepare-e2e-workflow-boundary.test.ts b/test/e2e/support/prepare-e2e-workflow-boundary.test.ts index c9224bbb66e..6f551d25df3 100644 --- a/test/e2e/support/prepare-e2e-workflow-boundary.test.ts +++ b/test/e2e/support/prepare-e2e-workflow-boundary.test.ts @@ -104,7 +104,29 @@ describe("prepare-e2e workflow boundary", () => { try { expect(validatePrepareE2eAction(actionPath)).toContain( - "prepare-e2e must pin Node 22, run npm ci, and conditionally build the CLI", + "prepare-e2e must pin reviewed Node and npm, run npm ci, and conditionally build the CLI", + ); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + + it("rejects reviewed npm loaded from the candidate checkout", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "prepare-e2e-reviewed-npm-")); + const actionPath = path.join(directory, "action.yaml"); + const source = fs.readFileSync( + path.join(process.cwd(), ".github/actions/prepare-e2e/action.yaml"), + "utf8", + ); + const action = YAML.parse(source) as Record; + const runs = action.runs as { steps: WorkflowStep[] }; + runs.steps.find((step) => step.name === "Install reviewed npm")!.uses = + "./.github/actions/setup-reviewed-npm"; + fs.writeFileSync(actionPath, YAML.stringify(action)); + + try { + expect(validatePrepareE2eAction(actionPath)).toContain( + "prepare-e2e must pin reviewed Node and npm, run npm ci, and conditionally build the CLI", ); } finally { fs.rmSync(directory, { force: true, recursive: true }); diff --git a/test/fixtures/brev-node22-bootstrap.fixture b/test/fixtures/brev-node22-bootstrap.fixture new file mode 100644 index 00000000000..b56a431505b --- /dev/null +++ b/test/fixtures/brev-node22-bootstrap.fixture @@ -0,0 +1,363 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Brev launchable startup script — CI-Ready CPU +# +# Pre-bakes a VM with everything needed for NemoClaw E2E tests so that +# CI runs only need to: rsync branch code → npm ci → nemoclaw onboard → test. +# +# What this installs: +# 1. Docker (docker.io) — enabled and running +# 2. Node.js 22 (nodesource) +# 3. OpenShell CLI binary (pinned release) +# 4. NemoClaw repo cloned with npm deps installed and TS plugin built +# +# What this does NOT install (intentionally): +# - code-server (not needed for automated CI) +# - VS Code themes/extensions +# - NVIDIA Container Toolkit (see brev-launchable-ci-gpu.sh for GPU flavor) +# - Ollama / vLLM +# +# Readiness detection: +# Writes /var/run/nemoclaw-launchable-ready when complete. +# Also writes "=== Ready ===" to /tmp/launch-plugin.log for backward compat. +# +# Usage (Brev launchable startup script — one-liner that curls this): +# curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw//scripts/brev-launchable-ci-cpu.sh | bash +# bash scripts/brev-launchable-ci-cpu.sh --print-openshell-version # resolve only +# +# Environment overrides: +# OPENSHELL_VERSION — OpenShell CLI release tag (default: stable selector below) +# NEMOCLAW_OPENSHELL_CHANNEL — Release channel (stable/dev/auto) +# NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL — Required opt-in for the unverified dev channel +# NEMOCLAW_REF — NemoClaw git ref to clone (default: main) +# NEMOCLAW_CLONE_DIR — Where to clone NemoClaw (default: ~/NemoClaw) +# +# Related: +# - Epic: https://github.com/NVIDIA/NemoClaw/issues/1326 +# - Issue: https://github.com/NVIDIA/NemoClaw/issues/1327 + +set -euo pipefail + +# ── Configuration ──────────────────────────────────────────────────── +OPENSHELL_VERSION="${OPENSHELL_VERSION:-}" +NEMOCLAW_REF="${NEMOCLAW_REF:-main}" + +LAUNCH_LOG="${LAUNCH_LOG:-/tmp/launch-plugin.log}" +SENTINEL="/var/run/nemoclaw-launchable-ready" + +# ── Suppress apt noise ─────────────────────────────────────────────── +export DEBIAN_FRONTEND=noninteractive +export NEEDRESTART_MODE=a + +# Logging +mkdir -p "$(dirname "$LAUNCH_LOG")" +exec > >(tee -a "$LAUNCH_LOG") 2>&1 + +_ts() { date '+%H:%M:%S'; } +info() { printf '\033[0;32m[%s ci-cpu]\033[0m %s\n' "$(_ts)" "$1"; } +warn() { printf '\033[1;33m[%s ci-cpu]\033[0m %s\n' "$(_ts)" "$1"; } +fail() { + printf '\033[0;31m[%s ci-cpu]\033[0m %s\n' "$(_ts)" "$1" + exit 1 +} + +assert_openshell_version() { + local raw="$1" + if [[ ! "$raw" =~ ^v?[0-9]+[.][0-9]+[.][0-9]+$ ]]; then + fail "Invalid OPENSHELL_VERSION '$raw'; expected vX.Y.Z or X.Y.Z" + fi +} + +if [ -z "$OPENSHELL_VERSION" ]; then + case "${NEMOCLAW_OPENSHELL_CHANNEL:-stable}" in + dev) OPENSHELL_VERSION="dev" ;; + stable | auto) OPENSHELL_VERSION="v0.0.106" ;; + *) fail "NEMOCLAW_OPENSHELL_CHANNEL must be one of: stable, dev, auto" ;; + esac +fi +if [ "${1:-}" = "--print-openshell-version" ]; then + printf '%s\n' "$OPENSHELL_VERSION" + exit 0 +fi +if [[ "$OPENSHELL_VERSION" = "dev" ]]; then + if [[ "${NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL:-}" != "1" ]]; then + fail "Dev channel install skips SHA-256 verification. Set NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL=1 to explicitly accept an unverified OpenShell dev-channel install." + fi + warn "Dev channel install skips SHA-256 verification. Use only in trusted environments." +else + assert_openshell_version "$OPENSHELL_VERSION" + if [[ "$OPENSHELL_VERSION" != v* ]]; then + OPENSHELL_VERSION="v${OPENSHELL_VERSION}" + fi +fi +OPENSHELL_VERSION_NO_V="${OPENSHELL_VERSION#v}" +TARGET_USER="${SUDO_USER:-$(id -un)}" +TARGET_HOME="$(getent passwd "$TARGET_USER" | cut -d: -f6)" +NEMOCLAW_CLONE_DIR="${NEMOCLAW_CLONE_DIR:-${TARGET_HOME}/NemoClaw}" + +# ── Retry helper ───────────────────────────────────────────────────── +# Usage: retry 3 10 "description" command arg1 arg2 +retry() { + local max_attempts="$1" sleep_sec="$2" desc="$3" + shift 3 + local attempt=1 + while true; do + if "$@"; then + return 0 + fi + if ((attempt >= max_attempts)); then + warn "Failed after $max_attempts attempts: $desc" + return 1 + fi + info "Retry $attempt/$max_attempts for: $desc (sleeping ${sleep_sec}s)" + sleep "$sleep_sec" + ((attempt++)) + done +} + +# Wait for apt locks. +# Brev VMs sometimes have unattended-upgrades running at boot. +wait_for_apt_lock() { + local max_wait=120 elapsed=0 + while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \ + || fuser /var/lib/apt/lists/lock >/dev/null 2>&1; do + if ((elapsed >= max_wait)); then + warn "apt lock not released after ${max_wait}s — proceeding anyway" + return 0 + fi + if ((elapsed % 15 == 0)); then + info "Waiting for apt lock to be released... (${elapsed}s)" + fi + sleep 5 + ((elapsed += 5)) + done +} + +openshell_cli_asset_for_arch() { + local arch + arch="$(uname -m)" + case "$arch" in + x86_64 | amd64) printf '%s\n' "openshell-x86_64-unknown-linux-musl.tar.gz" ;; + aarch64 | arm64) printf '%s\n' "openshell-aarch64-unknown-linux-musl.tar.gz" ;; + *) fail "Unsupported architecture: $arch" ;; + esac +} + +openshell_cli_pinned_sha256() { + local release_tag="$1" asset="$2" + case "${release_tag}:${asset}" in + v0.0.106:openshell-x86_64-unknown-linux-musl.tar.gz) + printf '%s\n' "d1a885a91b3e5aaa006c36aca95dc78bed0638c1ba1a79b55f1da93211b8a0a0" + ;; + v0.0.106:openshell-aarch64-unknown-linux-musl.tar.gz) + printf '%s\n' "ce981904ae8febd9cd6b3fbceb04e1dcfb48da6042bac08eadf0c2211f83fe55" + ;; + *) + return 1 + ;; + esac +} + +openshell_checksum_line() { + local checksum_file="$1" asset="$2" + awk -v asset="$asset" '$2 == asset { print; found=1; exit } END { if (!found) exit 1 }' "$checksum_file" +} + +validate_openshell_archive() { + local archive="$1" expected_member="$2" members verbose + members="$(LC_ALL=C tar -tzf "$archive")" \ + || fail "Unable to list OpenShell archive $(basename "$archive")" + [ "$members" = "$expected_member" ] \ + || fail "Unsafe OpenShell archive $(basename "$archive"): expected exactly one member named $expected_member" + verbose="$(LC_ALL=C tar -tvzf "$archive")" \ + || fail "Unable to inspect OpenShell archive $(basename "$archive")" + [[ "$verbose" != *$'\n'* && "${verbose:0:1}" = "-" && "${verbose##* }" = "$expected_member" ]] \ + || fail "Unsafe OpenShell archive $(basename "$archive"): $expected_member must be one regular file" +} + +verify_openshell_cli_asset() { + local tmpdir="$1" asset="$2" checksum_file="openshell-checksums-sha256.txt" + local checksum_line expected_sha release_sha + local -a sha_cmd + if command -v sha256sum >/dev/null 2>&1; then + sha_cmd=(sha256sum) + elif command -v shasum >/dev/null 2>&1; then + sha_cmd=(shasum -a 256) + else + fail "No SHA-256 tool available (sha256sum/shasum)" + fi + + retry 3 10 "download openshell checksum" \ + curl -fsSL -o "$tmpdir/$checksum_file" \ + "https://github.com/NVIDIA/OpenShell/releases/download/${OPENSHELL_VERSION}/${checksum_file}" + checksum_line="$(openshell_checksum_line "$tmpdir/$checksum_file" "$asset")" \ + || fail "OpenShell checksum file does not list $asset" + expected_sha="$(openshell_cli_pinned_sha256 "$OPENSHELL_VERSION" "$asset")" \ + || fail "No NemoClaw-pinned SHA-256 for OpenShell ${OPENSHELL_VERSION} asset ${asset}" + release_sha="$(printf '%s\n' "$checksum_line" | awk '{print $1}')" + [[ "$release_sha" == "$expected_sha" ]] \ + || fail "OpenShell release checksum for $asset does not match NemoClaw-pinned ${OPENSHELL_VERSION} digest" + (cd "$tmpdir" && printf '%s\n' "$checksum_line" | "${sha_cmd[@]}" -c -) \ + || fail "OpenShell CLI checksum verification failed for $asset" +} + +install_openshell_cli_release() { + local asset tmpdir + asset="$(openshell_cli_asset_for_arch)" + tmpdir="$(mktemp -d)" + retry 3 10 "download openshell" \ + curl -fsSL -o "$tmpdir/$asset" \ + "https://github.com/NVIDIA/OpenShell/releases/download/${OPENSHELL_VERSION}/${asset}" + if [[ "$OPENSHELL_VERSION" != "dev" ]]; then + verify_openshell_cli_asset "$tmpdir" "$asset" + fi + validate_openshell_archive "$tmpdir/$asset" openshell + tar xzf "$tmpdir/$asset" -C "$tmpdir" + sudo install -m 755 "$tmpdir/openshell" /usr/local/bin/openshell + rm -rf "$tmpdir" +} + +# ══════════════════════════════════════════════════════════════════════ +# 1. System packages +# Kill unattended-upgrades immediately — it grabs the apt lock on boot +# and can block for 60-120s. Irrelevant on an ephemeral CI VM. +sudo systemctl stop unattended-upgrades 2>/dev/null || true +sudo systemctl disable unattended-upgrades 2>/dev/null || true +sudo killall -9 unattended-upgr 2>/dev/null || true + +info "Installing system packages..." +wait_for_apt_lock +retry 3 10 "apt-get update" sudo apt-get update -qq +retry 3 10 "apt-get install" sudo apt-get install -y -qq \ + ca-certificates curl git jq tar >/dev/null 2>&1 +info "System packages installed" + +# 2. Docker +if command -v docker >/dev/null 2>&1; then + info "Docker already installed" +else + info "Installing Docker..." + wait_for_apt_lock + retry 3 10 "install docker" sudo apt-get install -y -qq docker.io >/dev/null 2>&1 + info "Docker installed" +fi +sudo systemctl enable --now docker +sudo usermod -aG docker "$TARGET_USER" 2>/dev/null || true +# The current bootstrap process predates the usermod above, so any Docker +# daemon command in this session must use `sg docker -c ...`. New SSH sessions +# naturally receive the docker group. Never weaken the host-root-equivalent +# Docker socket permissions to work around stale group membership. +info "Docker enabled ($(docker --version 2>/dev/null | head -c 40))" + +# 3. Node.js 22 +node_major="" +if command -v node >/dev/null 2>&1; then + node_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || true)" +fi + +if command -v npm >/dev/null 2>&1 && [[ -n "$node_major" ]] && ((node_major >= 22)); then + info "Node.js already installed: $(node --version)" +else + info "Installing Node.js 22..." + # IMPORTANT: update NODESOURCE_SHA256 when changing setup_22.x URL + NODESOURCE_URL="https://deb.nodesource.com/setup_22.x" + NODESOURCE_SHA256="575583bbac2fccc0b5edd0dbc03e222d9f9dc8d724da996d22754d6411104fd1" + ns_tmp="$(mktemp)" + curl -fsSL "$NODESOURCE_URL" -o "$ns_tmp" \ + || { + rm -f "$ns_tmp" + fail "Failed to download NodeSource installer" + } + if command -v sha256sum >/dev/null 2>&1; then + actual_hash="$(sha256sum "$ns_tmp" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + actual_hash="$(shasum -a 256 "$ns_tmp" | awk '{print $1}')" + else + rm -f "$ns_tmp" + fail "No SHA-256 tool available (sha256sum/shasum)" + fi + if [[ "$actual_hash" != "$NODESOURCE_SHA256" ]]; then + rm -f "$ns_tmp" + fail "NodeSource installer integrity check failed\n Expected: $NODESOURCE_SHA256\n Actual: $actual_hash" + fi + info "NodeSource installer integrity verified" + sudo -E bash "$ns_tmp" >/dev/null 2>&1 + rm -f "$ns_tmp" + wait_for_apt_lock + retry 3 10 "install nodejs" sudo apt-get install -y -qq nodejs >/dev/null 2>&1 + info "Node.js $(node --version) installed" +fi + +# 4. OpenShell CLI +if command -v openshell >/dev/null 2>&1; then + _installed_ver="$(openshell --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo '0.0.0')" + _pinned_ver="$OPENSHELL_VERSION_NO_V" + if [ "$_installed_ver" = "$_pinned_ver" ]; then + info "OpenShell CLI already installed at pinned version: $_installed_ver" + else + info "OpenShell CLI $_installed_ver does not match pinned ${_pinned_ver} — reinstalling..." + install_openshell_cli_release + info "OpenShell CLI upgraded: $(openshell --version 2>&1 || echo unknown)" + fi +else + info "Installing OpenShell CLI ${OPENSHELL_VERSION}..." + install_openshell_cli_release + info "OpenShell CLI installed: $(openshell --version 2>&1 || echo unknown)" +fi + +# 5. Clone NemoClaw and install deps +if [[ -d "$NEMOCLAW_CLONE_DIR/.git" ]]; then + info "NemoClaw repo exists at $NEMOCLAW_CLONE_DIR — refreshing" + git -C "$NEMOCLAW_CLONE_DIR" fetch origin "$NEMOCLAW_REF" + git -C "$NEMOCLAW_CLONE_DIR" checkout "$NEMOCLAW_REF" + git -C "$NEMOCLAW_CLONE_DIR" pull --ff-only origin "$NEMOCLAW_REF" || true +else + info "Cloning NemoClaw (ref: $NEMOCLAW_REF)..." + git clone --branch "$NEMOCLAW_REF" --depth 1 \ + "https://github.com/NVIDIA/NemoClaw.git" "$NEMOCLAW_CLONE_DIR" +fi + +info "Installing npm dependencies..." +cd "$NEMOCLAW_CLONE_DIR" +npm install --ignore-scripts 2>&1 | tail -3 +info "Root deps installed" + +# --ignore-scripts above skips the `prepare` lifecycle which normally +# builds dist/ (via `build:cli`). Build it explicitly — bin/nemoclaw.js +# does `require("../dist/nemoclaw")` and needs the compiled output. +info "Building CLI (dist/)..." +npm run build:cli 2>&1 | tail -3 +info "CLI built" + +info "Building TypeScript plugin..." +cd "$NEMOCLAW_CLONE_DIR/nemoclaw" +npm install --ignore-scripts 2>&1 | tail -3 +npm run build 2>&1 | tail -3 +cd "$NEMOCLAW_CLONE_DIR" +info "Plugin built" + +# Expose the nemoclaw CLI on PATH. Earlier this was `sudo npm link`, but +# on cold CPU Brev that routinely hangs inside npm's global-prefix +# housekeeping and `sudo chown -R node_modules` traversal (≥20 min in +# CI). npm link just creates two symlinks in the end; do them directly +# so setup stays deterministic and fast. +info "Linking nemoclaw CLI (direct symlink)..." +sudo ln -sf "$NEMOCLAW_CLONE_DIR/bin/nemoclaw.js" /usr/local/bin/nemoclaw +sudo chmod +x "$NEMOCLAW_CLONE_DIR/bin/nemoclaw.js" +info "nemoclaw CLI linked at /usr/local/bin/nemoclaw" + +# ══════════════════════════════════════════════════════════════════════ +# 6. Readiness sentinel +# ══════════════════════════════════════════════════════════════════════ +sudo touch "$SENTINEL" +echo "=== Ready ===" | sudo tee -a "$LAUNCH_LOG" >/dev/null + +info "════════════════════════════════════════════════════" +info " CI-Ready CPU launchable setup complete" +info " NemoClaw: $NEMOCLAW_CLONE_DIR (ref: $NEMOCLAW_REF)" +info " OpenShell: $(openshell --version 2>&1 || echo unknown)" +info " Node.js: $(node --version)" +info " Docker: $(docker --version 2>/dev/null | head -c 40)" +info " Sentinel: $SENTINEL" +info "════════════════════════════════════════════════════" diff --git a/test/helpers/compiled-artifact-fixture.ts b/test/helpers/compiled-artifact-fixture.ts index f7f71c454cc..740d7e68c16 100644 --- a/test/helpers/compiled-artifact-fixture.ts +++ b/test/helpers/compiled-artifact-fixture.ts @@ -86,6 +86,7 @@ export function runCompiledArtifactPreparation( for (const [index, step] of action.runs.steps.entries()) { if (!enabled(step.if) || (failure && step.if !== "always()")) continue; if (step.uses?.startsWith("actions/setup-node@")) continue; + if (step.uses?.startsWith("NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@")) continue; if (step.uses?.startsWith("actions/cache/restore@")) { restored++; outputs[step.id!] = { "cache-hit": String(cacheHit) }; diff --git a/test/helpers/dockerfile-run-commands.ts b/test/helpers/dockerfile-run-commands.ts index 36d48f9cef6..96d87cdd12e 100644 --- a/test/helpers/dockerfile-run-commands.ts +++ b/test/helpers/dockerfile-run-commands.ts @@ -234,6 +234,38 @@ function normalizedInstructionBody(source: string): string { .replace(/^[ \t\r\n]+|[ \t\r\n]+$/gu, ""); } +export function requireDockerfileCopySources( + source: string, + sourcePath: string, + destinationPath: string, + expectedCount: number, +): readonly DockerfileInstruction[] { + const destinationDirectory = destinationPath.slice(0, destinationPath.lastIndexOf("/") + 1); + const matches = dockerfileInstructions(source).filter((instruction) => { + if (instruction.keyword !== "COPY") return false; + const words = normalizedInstructionBody(instruction.body).split(" "); + const destination = words.at(-1); + return ( + words.slice(0, -1).includes(sourcePath) && + (destination === destinationPath || destination === destinationDirectory) + ); + }); + if (matches.length !== expectedCount) { + throw new Error( + `Expected ${expectedCount} COPY instruction(s) of ${sourcePath} to ${destinationPath}, found ${matches.length}`, + ); + } + return matches; +} + +export function requireSingleDockerfileCopySource( + source: string, + sourcePath: string, + destinationPath: string, +): DockerfileInstruction { + return requireDockerfileCopySources(source, sourcePath, destinationPath, 1)[0]!; +} + export function requireReviewedDockerfileRunCommands( source: string, command: string, diff --git a/test/helpers/dockerfile-run-shell.ts b/test/helpers/dockerfile-run-shell.ts index 42a4e79d0ce..6d8430bd788 100644 --- a/test/helpers/dockerfile-run-shell.ts +++ b/test/helpers/dockerfile-run-shell.ts @@ -50,6 +50,19 @@ export function dockerRunCommandBetween( .replace(/^(?:--[a-z-]+=[^\s]+\s+)+/u, ""); } +export function shellCommandSegmentBetween( + command: string, + startMarker: string, + endMarker: string, +): string { + const start = command.indexOf(startMarker); + const end = command.indexOf(endMarker, start); + if (start === -1 || end === -1 || end <= start) { + throw new Error(`Expected shell command segment between ${startMarker} and ${endMarker}`); + } + return command.slice(start, end).trim(); +} + function shellEnvironment( overrides: Record | undefined, ): NodeJS.ProcessEnv | undefined { diff --git a/test/helpers/npm-pack-result.ts b/test/helpers/npm-pack-result.ts new file mode 100644 index 00000000000..3403c0e92f0 --- /dev/null +++ b/test/helpers/npm-pack-result.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { singleNpmPackResult } from "../../scripts/lib/reviewed-npm-archive.mts"; + +export type NpmPackResult = Readonly<{ + filename?: string; + files?: ReadonlyArray>; + integrity?: string; +}>; + +export function parseSingleNpmPackResult(source: string): NpmPackResult { + const parsed: unknown = JSON.parse(source); + const result = singleNpmPackResult(parsed); + if (result === undefined) { + throw new Error("npm pack must return exactly one package result"); + } + return result as NpmPackResult; +} + +export function npmPackFilePaths(source: string): string[] { + return (parseSingleNpmPackResult(source).files ?? []).flatMap(({ path }) => + typeof path === "string" ? [path] : [], + ); +} diff --git a/test/helpers/reviewed-npm-fixture.ts b/test/helpers/reviewed-npm-fixture.ts index 3e37a5e5bc6..cd9a21a85fa 100644 --- a/test/helpers/reviewed-npm-fixture.ts +++ b/test/helpers/reviewed-npm-fixture.ts @@ -21,7 +21,7 @@ export function writeReviewedNpmFixture( ]); const packCases = packages.map((reviewed) => { const filename = path.basename(new URL(reviewed.tarballUrl).pathname); - return ` ${JSON.stringify(reviewed.tarballUrl)}) printf 'fixture' > "$pack_dir/${filename}"; printf '[{"filename":"${filename}","integrity":"%s"}]\\n' ${JSON.stringify(reviewed.integrity)} ;;`; + return ` ${JSON.stringify(reviewed.packageSpec)}) printf 'fixture' > "$pack_dir/${filename}"; printf '{"${reviewed.packageSpec}":{"filename":"${filename}","integrity":"%s"}}\\n' ${JSON.stringify(reviewed.integrity)} ;;`; }); fs.writeFileSync( fixturePath, diff --git a/test/inference/llama/llama-cpp-image-workflow.test.ts b/test/inference/llama/llama-cpp-image-workflow.test.ts index 1f4658f4887..f2447a02337 100644 --- a/test/inference/llama/llama-cpp-image-workflow.test.ts +++ b/test/inference/llama/llama-cpp-image-workflow.test.ts @@ -677,9 +677,14 @@ describe("llama.cpp image PR workflow", () => { .flatMap((job) => job.steps ?? []) .map((step) => step.uses) .filter((uses): uses is string => uses !== undefined); - actions.forEach((action) => { - expect(action).toMatch(fullShaAction); - }); + expect(actions.filter((action) => action.startsWith("./"))).toEqual([ + "./.github/actions/setup-reviewed-npm", + ]); + actions + .filter((action) => !action.startsWith("./")) + .forEach((action) => { + expect(action).toMatch(fullShaAction); + }); expect(buildStep.with?.platforms).toBe("${{ matrix.platform }}"); expect(buildStep.with?.provenance).toBe(false); diff --git a/test/inference/managed/managed-image-publication-workflow.test.ts b/test/inference/managed/managed-image-publication-workflow.test.ts index 1da547012d0..822f14511a3 100644 --- a/test/inference/managed/managed-image-publication-workflow.test.ts +++ b/test/inference/managed/managed-image-publication-workflow.test.ts @@ -31,6 +31,7 @@ import { import type { Job, Workflow } from "../../helpers/managed-image-publication-workflow-types"; const fullShaAction = /^[^@]+@[0-9a-f]{40}$/iu; +const reviewedAuditSha = "98669f24d35f18e49b6b2769cd68709509ea24f2"; function needsOutput(job: string, output: string): string { return `\${{ needs.${job}.outputs.${output} }}`; @@ -463,7 +464,7 @@ describe("complete managed-image publication workflow", () => { }); const trustedCheckout = step(reviewedAudit, "Checkout npm audit code from the base commit"); expect(trustedCheckout.with).toMatchObject({ - ref: "${{ github.event.pull_request.base.sha }}", + ref: reviewedAuditSha, path: ".trusted-reviewed-npm-audit", "persist-credentials": false, "sparse-checkout-cone-mode": false, @@ -474,8 +475,8 @@ describe("complete managed-image publication workflow", () => { expect(trustedCheckout.with?.["sparse-checkout"]).toContain("ci/reviewed-npm-audit.json"); const verifyAuditIdentities = step(reviewedAudit, "Verify exact audit source and target"); expect(verifyAuditIdentities.env).toEqual({ - BASE_SHA: "${{ github.event.pull_request.base.sha }}", CANDIDATE_SHA: "${{ github.event.pull_request.head.sha }}", + REVIEWED_AUDIT_SHA: reviewedAuditSha, }); expect(verifyAuditIdentities.run).toContain( "git -C .trusted-reviewed-npm-audit rev-parse --verify HEAD", @@ -510,6 +511,15 @@ describe("complete managed-image publication workflow", () => { expect(releaseIdentity.run).toContain("git describe --tags --match 'v*' \"$CANDIDATE_SHA\""); expect(releaseIdentity.run).toContain("value=%s"); expect(step(prBuilder, "Set up Docker Buildx").id).toBe("buildx"); + const auditVerifierCheckout = step(prBuilder, "Checkout trusted mcporter audit verifier"); + expect(auditVerifierCheckout.with?.ref).toBe(reviewedAuditSha); + expect(step(prBuilder, "Install reviewed npm").uses).toBe( + `NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@${reviewedAuditSha}`, + ); + const prepareAuditEvidence = step(prBuilder, "Prepare same-run mcporter audit evidence"); + expect(prepareAuditEvidence.run).toContain(`rev-parse --verify HEAD)" = '${reviewedAuditSha}'`); + expect(prepareAuditEvidence.run).not.toContain("--legacy-audit"); + expect(prepareAuditEvidence.run).not.toContain("--legacy-npmjs"); const matrixByAgent = new Map(matrix.map((entry) => [entry.agent, entry])); expect([...matrixByAgent.keys()].sort()).toEqual([ "hermes", @@ -528,7 +538,9 @@ describe("complete managed-image publication workflow", () => { expect(steps.indexOf(permissionDrift)).toBeLessThan(steps.indexOf(localBaseBuild)); expect(steps.indexOf(permissionDrift)).toBeLessThan(steps.indexOf(registryBaseBuild)); - for (const action of steps.filter((candidate) => candidate.uses)) { + for (const action of steps.filter( + (candidate) => candidate.uses && !candidate.uses.startsWith("./"), + )) { expect(action.uses, action.name).toMatch(fullShaAction); } diff --git a/test/inference/managed/managed-image-staging-qa-workflow.test.ts b/test/inference/managed/managed-image-staging-qa-workflow.test.ts index d804368cdfb..f0b2a5e4079 100644 --- a/test/inference/managed/managed-image-staging-qa-workflow.test.ts +++ b/test/inference/managed/managed-image-staging-qa-workflow.test.ts @@ -98,7 +98,16 @@ describe("managed-image staging QA workflow", () => { const overlaySource = required(overlay.run, "staging QA dependency overlay is missing"); expect(overlaySource).toContain("agents/langchain-deepagents-code/Dockerfile.base"); expect(overlaySource).toContain("agents/langchain-deepagents-code/requirements.lock"); - expect(overlaySource).toContain("scripts/lib/bundled-npm-package.mts"); + [ + "ci/reviewed-npm-audit.json", + "scripts/lib/bundled-npm-package.mts", + "scripts/lib/patch-bundled-npm-ip-address.mts", + "scripts/lib/reviewed-npm-archive.mts", + "scripts/lib/reviewed-npm-identity.mts", + "scripts/patch-bundled-npm-brace-expansion.mts", + "scripts/patch-bundled-npm-tar.mts", + "scripts/upgrade-bundled-npm.mts", + ].forEach((npmSupportFile) => expect(overlaySource).toContain(npmSupportFile)); expect(overlaySource).toContain( "scripts/security/patches/perl-5.44.0-net-ping-capability-tests.patch", ); @@ -109,19 +118,30 @@ describe("managed-image staging QA workflow", () => { const dockerfile = "agents/langchain-deepagents-code/Dockerfile.base"; const packageBuilder = "scripts/security/build-native-security-packages.sh"; const requirements = "agents/langchain-deepagents-code/requirements.lock"; - const npmBundler = "scripts/lib/bundled-npm-package.mts"; + const npmSupportFiles = [ + "ci/reviewed-npm-audit.json", + "scripts/lib/bundled-npm-package.mts", + "scripts/lib/patch-bundled-npm-ip-address.mts", + "scripts/lib/reviewed-npm-archive.mts", + "scripts/lib/reviewed-npm-identity.mts", + "scripts/patch-bundled-npm-brace-expansion.mts", + "scripts/patch-bundled-npm-tar.mts", + "scripts/upgrade-bundled-npm.mts", + ]; const perlPatch = "scripts/security/patches/perl-5.44.0-net-ping-capability-tests.patch"; try { writeOverlayFixture(candidateRoot, dockerfile, "candidate Dockerfile\n"); writeOverlayFixture(candidateRoot, packageBuilder, "exit 42\n"); writeOverlayFixture(candidateRoot, requirements, "candidate requirements\n"); - writeOverlayFixture(candidateRoot, npmBundler, "candidate npm bundler\n"); writeOverlayFixture(candidateRoot, perlPatch, "candidate Perl patch\n"); writeOverlayFixture(stagingRoot, dockerfile, "staging Dockerfile\n"); writeOverlayFixture(stagingRoot, packageBuilder, "exit 41\n"); writeOverlayFixture(stagingRoot, requirements, "staging requirements\n"); - writeOverlayFixture(stagingRoot, npmBundler, "staging npm bundler\n"); writeOverlayFixture(stagingRoot, perlPatch, "staging Perl patch\n"); + npmSupportFiles.forEach((supportFile) => { + writeOverlayFixture(candidateRoot, supportFile, "candidate npm support\n"); + writeOverlayFixture(stagingRoot, supportFile, "staging npm support\n"); + }); const summary = path.join(overlayFixture, "summary.md"); const result = spawnSync("bash", ["-c", overlaySource], { encoding: "utf-8", diff --git a/test/install/installer-hash-check.test.ts b/test/install/installer-hash-check.test.ts index a94f2cb1aeb..1a629d7055f 100644 --- a/test/install/installer-hash-check.test.ts +++ b/test/install/installer-hash-check.test.ts @@ -41,7 +41,13 @@ const INSTALLER_TEMPLATE = fs.readFileSync( path.join(REPO_ROOT, "scripts/install-openshell.sh"), "utf8", ); -const BREV_TEMPLATE = fs.readFileSync( +// Historical release-table scenarios use the already-trusted Node 22 template. +// The live installer-hash check validates the current production bootstrap. +const BREV_TEMPLATE = `#!/usr/bin/env bash\n${fs.readFileSync( + path.join(REPO_ROOT, "test/fixtures/brev-node22-bootstrap.fixture"), + "utf8", +)}`; +const PRODUCTION_BREV_TEMPLATE = fs.readFileSync( path.join(REPO_ROOT, "scripts/brev-launchable-ci-cpu.sh"), "utf8", ); @@ -129,6 +135,7 @@ type FixtureMode = | "partial" | "partial-asset-missing" | "partial-manifest-missing" + | "production-brev" | "pr-checker-bypass" | "pr-parser-bypass" | "brev-stable-version-drift" @@ -149,6 +156,7 @@ type PinFormatting = const corruptFirstBrevPin = (source: string): string => source.replace(ASSET_DIGESTS.get(ASSETS[0]) ?? "missing", "0".repeat(64)); const BREV_MUTATIONS: Partial string>> = { + "production-brev": () => PRODUCTION_BREV_TEMPLATE, "brev-bypassed-comparison": (source) => source.replace('[[ "$release_sha" == "$expected_sha" ]]', "true"), "brev-changed-asset": (source) => @@ -914,6 +922,13 @@ describe("installer hash verification", () => { expect(result.stdout).toContain("All installer hashes are current"); }); + it("validates the current production Brev bootstrap against the trusted template", () => { + const result = runFixture("production-brev", "0.0.116"); + + expect(result.status, result.stdout).toBe(0); + expect(result.stdout).toContain("All installer hashes are current"); + }); + it("verifies the pinned Homebrew formula", () => { const result = runFixture("complete", undefined, true); diff --git a/test/install/materialize-locked-npm-cache-seed.test.ts b/test/install/materialize-locked-npm-cache-seed.test.ts index ee2bdd41f6d..701002b1070 100644 --- a/test/install/materialize-locked-npm-cache-seed.test.ts +++ b/test/install/materialize-locked-npm-cache-seed.test.ts @@ -60,7 +60,7 @@ function writeLock(root: string, archives: readonly LockedArchive[]): string { ], ...packages.map(({ entry, name }) => [ `node_modules/${name}`, - { integrity: entry.integrity, resolved: entry.resolved }, + { integrity: entry.integrity, resolved: entry.resolved, version: "1.0.0" }, ]), ]), }, @@ -165,13 +165,14 @@ describe("locked npm cache seed materialization", () => { ]); }); - it("does not invent an archive for a peer omitted by a legacy-peer lock", async () => { + it("does not invent an archive for an absent optional peer", async () => { const alpha = archive("alpha", "alpha archive"); const lockfile = writeLock(testRoot, [alpha.locked]); const lock = JSON.parse(readFileSync(lockfile, "utf8")) as { packages: Record>; }; lock.packages["node_modules/alpha"].peerDependencies = { host: ">=1" }; + lock.packages["node_modules/alpha"].peerDependenciesMeta = { host: { optional: true } }; writeFileSync(lockfile, `${JSON.stringify(lock, null, 2)}\n`); const seed = path.join(testRoot, "seed"); @@ -186,6 +187,80 @@ describe("locked npm cache seed materialization", () => { expect(readdirSync(seed).sort()).toEqual(["alpha-1.0.0.tgz", "manifest.json"]); }); + it.each(["^1.0.0", "~1.0.0", ">=1.0.0 <2", "npm:beta@1.0.0", "1.0.0+build.1"])( + "does not treat non-exact dependency spec %s as an exact locked version", + async (requested) => { + const alpha = archive("alpha", "alpha archive"); + const lockfile = writeLock(testRoot, [alpha.locked]); + const lock = JSON.parse(readFileSync(lockfile, "utf8")) as { + packages: Record>; + }; + lock.packages[""].dependencies = { alpha: requested }; + lock.packages["node_modules/alpha"].version = "9.9.9"; + writeFileSync(lockfile, `${JSON.stringify(lock, null, 2)}\n`); + + const manifest = await materializeLockedNpmCacheSeed({ + downloadArchive: async () => alpha.bytes, + lockfile, + output: path.join(testRoot, "seed"), + target: TARGET, + }); + + expect(manifest.archiveCount).toBe(1); + }, + ); + + it("rejects a resolved optional peer with a different exact version", async () => { + const alpha = archive("alpha", "alpha archive"); + const beta = archive("beta", "beta archive"); + const lockfile = writeLock(testRoot, [alpha.locked, beta.locked]); + const lock = JSON.parse(readFileSync(lockfile, "utf8")) as { + packages: Record>; + }; + lock.packages[""].dependencies = { alpha: "1.0.0" }; + lock.packages["node_modules/alpha"].peerDependencies = { beta: "2.0.0" }; + lock.packages["node_modules/alpha"].peerDependenciesMeta = { beta: { optional: true } }; + writeFileSync(lockfile, `${JSON.stringify(lock, null, 2)}\n`); + const downloadArchive = vi.fn(async () => alpha.bytes); + + await expect( + materializeLockedNpmCacheSeed({ + downloadArchive, + lockfile, + output: path.join(testRoot, "seed"), + target: TARGET, + }), + ).rejects.toThrow( + "package-lock exact dependency is unresolved: beta@2.0.0 from node_modules/alpha", + ); + expect(downloadArchive).not.toHaveBeenCalled(); + }); + + it("rejects an exact dependency resolved to a different locked version", async () => { + const alpha = archive("alpha", "alpha archive"); + const beta = archive("beta", "beta archive"); + const lockfile = writeLock(testRoot, [alpha.locked, beta.locked]); + const lock = JSON.parse(readFileSync(lockfile, "utf8")) as { + packages: Record>; + }; + lock.packages[""].dependencies = { alpha: "1.0.0" }; + lock.packages["node_modules/alpha"].dependencies = { beta: "2.0.0" }; + writeFileSync(lockfile, `${JSON.stringify(lock, null, 2)}\n`); + const downloadArchive = vi.fn(async () => alpha.bytes); + + await expect( + materializeLockedNpmCacheSeed({ + downloadArchive, + lockfile, + output: path.join(testRoot, "seed"), + target: TARGET, + }), + ).rejects.toThrow( + "package-lock exact dependency is unresolved: beta@2.0.0 from node_modules/alpha", + ); + expect(downloadArchive).not.toHaveBeenCalled(); + }); + it("rejects a lock archive outside the exact npm registry origin", async () => { const alpha = archive("alpha", "alpha archive"); const lockfile = writeLock(testRoot, [ diff --git a/test/install/patch-bundled-npm-brace-expansion.test.ts b/test/install/patch-bundled-npm-brace-expansion.test.ts index d3911fe8171..ecc678b0c4d 100644 --- a/test/install/patch-bundled-npm-brace-expansion.test.ts +++ b/test/install/patch-bundled-npm-brace-expansion.test.ts @@ -35,7 +35,7 @@ function fixture(braceExpansionVersion: string) { const replacementRoot = path.join(root, "replacement"); writeJson(path.join(npmRoot, "package.json"), { name: "npm", - version: "11.18.0", + version: "12.0.2", }); writeJson(path.join(npmRoot, "node_modules", "brace-expansion", "package.json"), { dependencies: { "balanced-match": "^4.0.2" }, @@ -77,7 +77,7 @@ describe("npm bundled brace-expansion remediation", () => { expect(patchBundledNpmBraceExpansion(target)).toMatchObject({ braceExpansionVersion: FIXED_BRACE_EXPANSION_VERSION, - npmVersion: "11.18.0", + npmVersion: "12.0.2", state: "fixed", }); expect( diff --git a/test/install/patch-bundled-npm-tar.test.ts b/test/install/patch-bundled-npm-tar.test.ts index 96a3e381060..8ec650afb84 100644 --- a/test/install/patch-bundled-npm-tar.test.ts +++ b/test/install/patch-bundled-npm-tar.test.ts @@ -33,7 +33,10 @@ function writeJson(file: string, value: object): void { fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); } -function fixture(npmVersion: "10.9.7" | "11.13.0" | "11.16.0" | "11.18.0", tarVersion: string) { +function fixture( + npmVersion: "10.9.7" | "11.13.0" | "11.16.0" | "11.18.0" | "12.0.2", + tarVersion: string, +) { const root = temporaryDirectory(); const npmRoot = path.join(root, "npm"); const replacementRoot = path.join(root, "replacement"); @@ -42,7 +45,11 @@ function fixture(npmVersion: "10.9.7" | "11.13.0" | "11.16.0" | "11.18.0", tarVe version: npmVersion, dependencies: { tar: - npmVersion === "11.18.0" ? "^7.5.19" : npmVersion.startsWith("10.") ? "^7.5.11" : "^7.5.13", + npmVersion === "11.18.0" || npmVersion === "12.0.2" + ? "^7.5.19" + : npmVersion.startsWith("10.") + ? "^7.5.11" + : "^7.5.13", }, bundleDependencies: ["other", "tar"], }); @@ -82,6 +89,7 @@ describe("npm bundled node-tar remediation", () => { ["Node.js 24.18 npm", "11.16.0", "7.5.15"], ["reviewed npm advisory release", "11.18.0", "7.5.19"], ["reviewed npm affected boundary", "11.18.0", "7.5.20"], + ["reviewed npm 12 release", "12.0.2", "7.5.19"], ] as const)("replaces the complete affected tree for %s", (_label, npmVersion, tarVersion) => { const target = fixture(npmVersion, tarVersion); @@ -274,7 +282,7 @@ describe("npm bundled node-tar remediation", () => { const drifted = fixture("10.9.7", "7.5.11"); const manifestPath = path.join(drifted.npmRoot, "package.json"); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); - manifest.version = "12.0.0"; + manifest.version = "13.0.0"; writeJson(manifestPath, manifest); expect(() => patchBundledNpmTar(drifted)).toThrow("layout has drifted"); diff --git a/test/install/reviewed-npm-archive.test.ts b/test/install/reviewed-npm-archive.test.ts index 4d328266bf2..521e7380619 100644 --- a/test/install/reviewed-npm-archive.test.ts +++ b/test/install/reviewed-npm-archive.test.ts @@ -11,6 +11,7 @@ import { type ReviewedNpmCacheRequest, removeReviewedNpmArchive, resolveReviewedNpmArchivePath, + singleNpmPackResult, verifyReviewedNpmCache, verifyReviewedNpmLockPackages, verifyReviewedNpmMetadata, @@ -127,6 +128,20 @@ afterEach(() => { }); describe("reviewed npm archive", () => { + it.each([ + ["legacy array", [{ filename: "reviewed.tgz" }], { filename: "reviewed.tgz" }], + [ + "npm 12 package-keyed object", + { reviewed: { filename: "reviewed.tgz" } }, + { filename: "reviewed.tgz" }, + ], + ["multiple results", { first: {}, second: {} }, undefined], + ["non-object result", "reviewed.tgz", undefined], + ["single non-object entry", ["reviewed.tgz"], undefined], + ])("normalizes %s pack JSON", (_label, value, expected) => { + expect(singleNpmPackResult(value)).toEqual(expected); + }); + it("verifies exact registry metadata and returns only a contained local archive", () => { const calls: string[][] = []; const archive = packReviewedNpmArchive(request(), (args, reviewed) => { @@ -150,7 +165,7 @@ describe("reviewed npm archive", () => { expect(calls).toEqual([ ["view", PACKAGE_SPEC, "dist.integrity"], ["view", PACKAGE_SPEC, "dist.tarball"], - ["pack", TARBALL_URL, "--pack-destination", archive.rootDirectory, "--json"], + ["pack", PACKAGE_SPEC, "--pack-destination", archive.rootDirectory, "--json"], ]); expect(archive.archivePath).toBe(path.join(archive.rootDirectory, "reviewed-1.2.3.tgz")); expect(fs.existsSync(archive.archivePath)).toBe(true); @@ -158,6 +173,38 @@ describe("reviewed npm archive", () => { expect(fs.existsSync(archive.rootDirectory)).toBe(false); }); + it("accepts npm 12's package-keyed pack JSON without weakening the single-result boundary", () => { + const archive = packReviewedNpmArchive(request(), (args, reviewed) => { + return args[0] === "view" + ? args[2] === "dist.integrity" + ? INTEGRITY + : TARBALL_URL + : (() => { + const destination = args[3] as string; + const filename = "reviewed-1.2.3.tgz"; + fs.writeFileSync(path.join(destination, filename), "reviewed bytes"); + return JSON.stringify({ + reviewed: { filename, integrity: reviewed.expectedIntegrity }, + }); + })(); + }); + + expect(archive.archivePath).toBe(path.join(archive.rootDirectory, "reviewed-1.2.3.tgz")); + removeReviewedNpmArchive(archive); + }); + + it("rejects npm 12 pack JSON containing more than one result", () => { + expect(() => + packReviewedNpmArchive(request(), (args) => { + return args[0] === "view" + ? args[2] === "dist.integrity" + ? INTEGRITY + : TARBALL_URL + : JSON.stringify({ first: {}, second: {} }); + }), + ).toThrow("did not report filename and integrity"); + }); + it.each([ ["dist.integrity", "sha512-drift"], ["dist.tarball", "https://unexpected.invalid/reviewed.tgz"], diff --git a/test/install/seed-reviewed-npm-cache.test.ts b/test/install/seed-reviewed-npm-cache.test.ts index 2fa57a434aa..d364116da65 100644 --- a/test/install/seed-reviewed-npm-cache.test.ts +++ b/test/install/seed-reviewed-npm-cache.test.ts @@ -4,6 +4,7 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -13,6 +14,7 @@ import { lockedArchivesFromDirectory, seedReviewedNpmCache, } from "../../scripts/lib/seed-reviewed-npm-cache.mts"; +import { parseSingleNpmPackResult } from "../helpers/npm-pack-result"; const PACKAGE_NAME = "@example/reviewed"; const PACKAGE_SPEC = `${PACKAGE_NAME}@1.2.3`; @@ -21,6 +23,15 @@ const TARBALL_URL = "https://registry.npmjs.org/@example/reviewed/-/reviewed-1.2 const TARGET = { cpu: "x64", libc: "glibc", os: "linux" } as const; const roots: string[] = []; +function cachePutFromInstalledNpm(): CachePut { + const npmRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim(); + const require = createRequire(import.meta.url); + const cacachePath = require.resolve("cacache", { + paths: [path.join(npmRoot, "npm", "node_modules")], + }); + return (require(cacachePath) as Readonly<{ put: CachePut }>).put; +} + type PutCall = Readonly<{ cachePath: string; data: Buffer; @@ -138,6 +149,19 @@ describe("reviewed npm cache seed", () => { expect(calls[2]?.data.toString()).toContain(`"tarball":"${TARBALL_URL}"`); expect(calls[2]?.data.toString()).toContain('"hasShrinkwrap":true'); expect(calls[2]?.data.toString()).toContain('"bundleDependencies":["bundled-child"]'); + expect(calls[2]?.metadata).toMatchObject({ + reqHeaders: { + accept: "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*", + }, + resHeaders: { + "content-type": "application/vnd.npm.install-v1+json", + vary: "accept", + }, + }); + expect(calls[3]?.metadata).toMatchObject({ + reqHeaders: { accept: "application/json" }, + resHeaders: { "content-type": "application/json", vary: "accept" }, + }); }); it("combines every locked version into one offline packument", async () => { @@ -171,35 +195,89 @@ describe("reviewed npm cache seed", () => { ]); }); - it("serves npm view offline from lock-derived packuments", async () => { + it("serves npm 12 metadata and pack offline after cache verification", async () => { const input = fixture(); - await seedReviewedNpmCache({ - ...request(input, new Map()), - packumentsOnly: true, - }); + const packageDirectory = path.join(input.root, "package-source"); + const outputDirectory = path.join(input.root, "packed-output"); + fs.mkdirSync(packageDirectory); + fs.mkdirSync(outputDirectory); + fs.writeFileSync( + path.join(packageDirectory, "package.json"), + JSON.stringify({ name: PACKAGE_NAME, version: "1.2.3" }), + ); + const localPack = parseSingleNpmPackResult( + execFileSync( + "npm", + ["pack", packageDirectory, "--ignore-scripts", "--pack-destination", input.root, "--json"], + { + encoding: "utf8", + env: { + ...process.env, + NPM_CONFIG_CACHE: path.join(input.root, "local-pack-cache"), + }, + }, + ), + ); + expect(localPack.filename).toBeTruthy(); + const archivePath = path.join(input.root, localPack.filename!); + const archive = fs.readFileSync(archivePath); + const integrity = `sha512-${createHash("sha512").update(archive).digest("base64")}`; + fs.writeFileSync( + input.lockfilePath, + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { dependencies: { [PACKAGE_NAME]: "1.2.3" } }, + [`node_modules/${PACKAGE_NAME}`]: { + integrity, + resolved: TARBALL_URL, + version: "1.2.3", + }, + }, + }), + ); - const integrity = execFileSync( - "npm", - [ - "view", - PACKAGE_SPEC, - "dist.integrity", - "--userconfig", - "/dev/null", - "--registry", - REGISTRY_ORIGIN, - ], + await seedReviewedNpmCache( + request(input, new Map([[PACKAGE_SPEC, archivePath]])), + cachePutFromInstalledNpm(), + ); + execFileSync("npm", ["cache", "verify", "--cache", input.cacheDirectory]); + await seedReviewedNpmCache( { - encoding: "utf8", - env: { - ...process.env, - NPM_CONFIG_CACHE: input.cacheDirectory, - NPM_CONFIG_OFFLINE: "true", - }, + ...request(input, new Map()), + packumentsOnly: true, }, - ).trim(); + cachePutFromInstalledNpm(), + ); + const npmEnv = { + ...process.env, + NPM_CONFIG_CACHE: input.cacheDirectory, + NPM_CONFIG_OFFLINE: "true", + NPM_CONFIG_REGISTRY: REGISTRY_ORIGIN, + }; + const viewedIntegrity = execFileSync("npm", ["view", PACKAGE_SPEC, "dist.integrity"], { + encoding: "utf8", + env: npmEnv, + }).trim(); + const viewedTarball = execFileSync("npm", ["view", PACKAGE_SPEC, "dist.tarball"], { + encoding: "utf8", + env: npmEnv, + }).trim(); + const offlinePack = parseSingleNpmPackResult( + execFileSync( + "npm", + ["pack", PACKAGE_SPEC, "--ignore-scripts", "--pack-destination", outputDirectory, "--json"], + { + encoding: "utf8", + env: npmEnv, + }, + ), + ); - expect(integrity).toBe(input.integrity); + expect(viewedIntegrity).toBe(integrity); + expect(viewedTarball).toBe(TARBALL_URL); + expect(offlinePack.integrity).toBe(integrity); + expect(fs.existsSync(path.join(outputDirectory, offlinePack.filename!))).toBe(true); }); it("rejects an unreviewed npm version before loading npm cache internals", async () => { @@ -217,7 +295,7 @@ describe("reviewed npm cache seed", () => { 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, npm@11.17.0, or npm@11.18.0", + "reviewed npm cache seed does not support npm@99.0.0; expected npm@12.0.2", ); expect(fs.readFileSync(tracePath, "utf8")).toBe("--version\n"); }); diff --git a/test/install/upgrade-bundled-npm.test.ts b/test/install/upgrade-bundled-npm.test.ts index 31af44d8bae..0e2f2f74b50 100644 --- a/test/install/upgrade-bundled-npm.test.ts +++ b/test/install/upgrade-bundled-npm.test.ts @@ -13,7 +13,10 @@ import { verifyBundledNpmTar, } from "../../scripts/patch-bundled-npm-tar.mts"; import { + REVIEWED_NPM_ARCHIVE_SHA256, + REVIEWED_NPM_INTEGRITY, REVIEWED_NPM_PACKAGES, + REVIEWED_NPM_TARBALL, REVIEWED_NPM_VERSION, upgradeBundledNpm, verifyReviewedNpm, @@ -37,12 +40,13 @@ function writePackage(root: string, location: string, name: string, version: str writeJson(path.join(root, "node_modules", location, "package.json"), { name, version }); } -function affectedNpm(version: "10.9.8" | "11.13.0" | "11.16.0"): string { +function affectedNpm(version: "10.9.8" | "11.13.0" | "11.16.0" | "11.18.0"): string { const root = path.join(temporaryDirectory(), "npm"); writeJson(path.join(root, "package.json"), { name: "npm", version }); writePackage(root, "brace-expansion", "brace-expansion", "2.0.2"); writePackage(root, "picomatch", "picomatch", "4.0.3"); writePackage(root, "sigstore", "sigstore", version === "10.9.8" ? "3.1.0" : "4.0.0"); + writePackage(root, "ip-address", "ip-address", "10.1.0"); writePackage(root, "tar", "tar", "7.5.20"); return root; } @@ -52,7 +56,7 @@ function reviewedNpm(): string { writeJson(path.join(root, "package.json"), { bundleDependencies: ["tar"], dependencies: { tar: "^7.5.19" }, - engines: { node: "^20.17.0 || >=22.9.0" }, + engines: { node: "^22.22.2 || ^24.15.0 || >=26.0.0" }, name: "npm", version: REVIEWED_NPM_VERSION, }); @@ -62,6 +66,7 @@ function reviewedNpm(): string { "brace-expansion", REVIEWED_NPM_PACKAGES["brace-expansion"], ); + writePackage(root, "ip-address", "ip-address", REVIEWED_NPM_PACKAGES["ip-address"]); writePackage( root, path.join("tinyglobby", "node_modules", "picomatch"), @@ -89,6 +94,17 @@ afterEach(() => { }); describe("reviewed bundled npm upgrade", () => { + it("binds the reviewed npm release to its immutable registry artifact", () => { + expect(REVIEWED_NPM_VERSION).toBe("12.0.2"); + expect(REVIEWED_NPM_INTEGRITY).toBe( + "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw==", + ); + expect(REVIEWED_NPM_TARBALL).toBe("https://registry.npmjs.org/npm/-/npm-12.0.2.tgz"); + expect(REVIEWED_NPM_ARCHIVE_SHA256).toBe( + "5dbb86c71d07a1957f2e90734092dd6a58bdcd9ebc2d8d41ca1c6e6a21d364e1", + ); + }); + it("verifies the complete reviewed dependency set", () => { const state = verifyReviewedNpm(reviewedNpm()); @@ -96,6 +112,7 @@ describe("reviewed bundled npm upgrade", () => { npmVersion: REVIEWED_NPM_VERSION, packages: { "brace-expansion": [REVIEWED_NPM_PACKAGES["brace-expansion"]], + "ip-address": [REVIEWED_NPM_PACKAGES["ip-address"]], picomatch: [REVIEWED_NPM_PACKAGES.picomatch], sigstore: [REVIEWED_NPM_PACKAGES.sigstore], tar: [REVIEWED_NPM_PACKAGES.tar], @@ -116,7 +133,7 @@ describe("reviewed bundled npm upgrade", () => { expect(verifyBundledNpmTar(npmRoot).tarVersion).toBe("7.5.21"); }); - it.each(["10.9.8", "11.13.0", "11.16.0"] as const)( + it.each(["10.9.8", "11.13.0", "11.16.0", "11.18.0"] as const)( "replaces npm %s before running npm or npx", (version) => { const npmRoot = affectedNpm(version); @@ -153,6 +170,43 @@ describe("reviewed bundled npm upgrade", () => { }, ); + it("installs the verified npm archive without consulting registry state", () => { + const npmRoot = affectedNpm("11.16.0"); + const replacementRoot = reviewedNpm(); + const archivePath = path.join(temporaryDirectory(), "npm-12.0.2.tgz"); + fs.writeFileSync(archivePath, "reviewed fixture\n"); + const commands: Array<{ args: readonly string[]; command: string }> = []; + const sideEffects: Readonly void) | undefined>> = { + "npm install": () => { + fs.rmSync(npmRoot, { recursive: true }); + fs.cpSync(replacementRoot, npmRoot, { recursive: true }); + }, + }; + + upgradeBundledNpm(npmRoot, { + commandRunner(command, args) { + commands.push({ args, command }); + sideEffects[`${command} ${args[0] ?? ""}`]?.(); + }, + prepareArchive: () => ({ archivePath, cleanup: () => undefined }), + }); + + expect(commands[0]).toEqual({ + command: "npm", + args: [ + "install", + "--global", + archivePath, + "--userconfig", + "/dev/null", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--offline", + ], + }); + }); + it("does not download npm when the reviewed tree is already installed", () => { const npmRoot = reviewedNpm(); const commands: string[] = []; @@ -160,18 +214,44 @@ describe("reviewed bundled npm upgrade", () => { expect( upgradeBundledNpm(npmRoot, { commandRunner: (command) => commands.push(command), - prepareArchive: () => { - throw new Error("unexpected download"); - }, }).npmVersion, ).toBe(REVIEWED_NPM_VERSION); expect(commands).toEqual(["npm", "npx"]); }); + it("reinstalls explicit reviewed bytes over an already-remediated npm tree", () => { + const npmRoot = reviewedNpm(); + writePackage(npmRoot, "brace-expansion", "brace-expansion", "5.0.9"); + const replacementRoot = reviewedNpm(); + const archivePath = path.join(temporaryDirectory(), "npm-12.0.2.tgz"); + fs.writeFileSync(archivePath, "reviewed fixture\n"); + const commands: string[] = []; + + const result = upgradeBundledNpm(npmRoot, { + commandRunner: (command) => commands.push(command), + installArchive() { + commands.push("install-reviewed-npm"); + fs.rmSync(npmRoot, { recursive: true }); + fs.cpSync(replacementRoot, npmRoot, { recursive: true }); + }, + prepareArchive: () => ({ archivePath, cleanup: () => commands.push("cleanup") }), + }); + + expect(result.packages["brace-expansion"]).toEqual(["5.0.7"]); + expect(commands).toEqual(["install-reviewed-npm", "npm", "npx", "cleanup"]); + }); + + it("verifies a supplied archive even when the reviewed tree is already installed", () => { + const archivePath = path.join(temporaryDirectory(), "npm.tgz"); + fs.writeFileSync(archivePath, "not npm\n"); + + expect(() => upgradeBundledNpm(reviewedNpm(), { archivePath })).toThrow("integrity mismatch"); + }); + it("fails closed on reviewed-package drift", () => { const drifted = reviewedNpm(); - writePackage(drifted, "sigstore", "sigstore", "4.1.0"); - expect(() => verifyReviewedNpm(drifted)).toThrow("expected only 4.1.1"); + writePackage(drifted, "sigstore", "sigstore", "4.1.1"); + expect(() => verifyReviewedNpm(drifted)).toThrow("expected only 5.0.0"); }); it("fails closed on an unreviewed current npm version", () => { @@ -186,4 +266,19 @@ describe("reviewed bundled npm upgrade", () => { fs.writeFileSync(archivePath, "not npm\n"); expect(() => verifyReviewedNpmArchive(archivePath)).toThrow("integrity mismatch"); }); + + it("verifies a caller-supplied archive before invoking the installer", () => { + const npmRoot = affectedNpm("11.18.0"); + const archivePath = path.join(temporaryDirectory(), "npm.tgz"); + fs.writeFileSync(archivePath, "not npm\n"); + + expect(() => + upgradeBundledNpm(npmRoot, { + archivePath, + installArchive: () => { + throw new Error("installer must not run"); + }, + }), + ).toThrow("integrity mismatch"); + }); }); diff --git a/test/install/wechat-locked-install.test.ts b/test/install/wechat-locked-install.test.ts index bdc7fb28af9..0652e7dc2ef 100644 --- a/test/install/wechat-locked-install.test.ts +++ b/test/install/wechat-locked-install.test.ts @@ -167,7 +167,7 @@ printf 'verify|%s|%s|openclaw=%s|offline=%s|cache=%s\n' "$2" "$3" "$4" "$NPM_CON `npm|view|@tencent-weixin/openclaw-weixin@2.4.3|cache=${fs.realpathSync(installCache)}|offline=true`, ); expect(calls).toContain( - `npm|pack|${WECHAT_TARBALL}|cache=${fs.realpathSync(installCache)}|offline=true`, + `npm|pack|@tencent-weixin/openclaw-weixin@2.4.3|cache=${fs.realpathSync(installCache)}|offline=true`, ); expect(calls).toContain(`offline=true|peer=true|cache=${fs.realpathSync(installCache)}`); expect(calls).toContain( diff --git a/test/onboarding/onboard.test.ts b/test/onboarding/onboard.test.ts index 93f4bb905eb..583e324bc54 100644 --- a/test/onboarding/onboard.test.ts +++ b/test/onboarding/onboard.test.ts @@ -607,6 +607,9 @@ startGateway(null).catch((error) => { expect(fs.existsSync(path.join(buildCtx, "scripts", "patch-openclaw-tool-catalog.mts"))).toBe( true, ); + expect( + fs.existsSync(path.join(buildCtx, "scripts", "lib", "patch-openclaw-npm12-pack-json.mts")), + ).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "setup.sh"))).toBe(false); expect(fs.existsSync(path.join(buildCtx, "nemoclaw", "node_modules"))).toBe(false); } finally { diff --git a/test/package-contract/config-schema.test.ts b/test/package-contract/config-schema.test.ts index c90c8aea67a..e2177f286aa 100644 --- a/test/package-contract/config-schema.test.ts +++ b/test/package-contract/config-schema.test.ts @@ -6,6 +6,7 @@ import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { npmPackFilePaths } from "../helpers/npm-pack-result"; import { createPackageFixture } from "./helpers/package-fixture"; const REPOSITORY_ROOT = path.join(import.meta.dirname, "..", ".."); @@ -28,13 +29,12 @@ describe("compiled config schema consumer", () => { ], }); try { - const report = JSON.parse( + const files = npmPackFilePaths( execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { cwd: fixtureRoot, encoding: "utf8", }), - ) as Array<{ files: Array<{ path: string }> }>; - const files = report[0]?.files.map((file) => file.path) ?? []; + ); expect(files).toEqual( expect.arrayContaining([ "schemas/nemoclaw-config-v1.schema.json", diff --git a/test/package-contract/managed-inference-catalog.test.ts b/test/package-contract/managed-inference-catalog.test.ts index af568182452..1b377b0ad42 100644 --- a/test/package-contract/managed-inference-catalog.test.ts +++ b/test/package-contract/managed-inference-catalog.test.ts @@ -6,6 +6,7 @@ import { readFileSync, rmSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { npmPackFilePaths } from "../helpers/npm-pack-result"; import { parseCompiledServingCatalogJson } from "../../dist/lib/inference/serving/catalog"; import catalogSchema from "../../managed-inference/schemas/catalog.schema.json" with { type: "json" }; import modelSchema from "../../managed-inference/schemas/model.schema.json" with { type: "json" }; @@ -35,13 +36,12 @@ describe("compiled managed inference serving catalog", () => { entries: ["dist/managed-inference", "managed-inference"], }); try { - const result = JSON.parse( + const files = npmPackFilePaths( execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { cwd: fixtureRoot, encoding: "utf8", }), - ) as Array<{ files: Array<{ path: string }> }>; - const files = result[0]?.files.map((file) => file.path) ?? []; + ); expect(files).toContain("dist/managed-inference/catalog.json"); expect(files).toContain("managed-inference/schemas/catalog.schema.json"); diff --git a/test/package-contract/nemoclaw-plugin-metadata.test.ts b/test/package-contract/nemoclaw-plugin-metadata.test.ts index d2b0301c8a0..824d4fccd14 100644 --- a/test/package-contract/nemoclaw-plugin-metadata.test.ts +++ b/test/package-contract/nemoclaw-plugin-metadata.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; +import { npmPackFilePaths } from "../helpers/npm-pack-result"; import { createMinimumOpenClawPluginApi, MINIMUM_OPENCLAW_PLUGIN_API_VERSION, @@ -119,8 +120,7 @@ describe("packed NemoClaw plugin metadata", () => { timeout: 30_000, }); expect(packed.status, `${packed.stdout}${packed.stderr}`).toBe(0); - const report = JSON.parse(packed.stdout) as Array<{ files?: Array<{ path?: string }> }>; - const packedPaths = new Set((report[0]?.files ?? []).map((entry) => entry.path)); + const packedPaths = new Set(npmPackFilePaths(packed.stdout)); expect(packedPaths).toContain("openclaw.plugin.json"); expect(extensions.every((extension) => packedPaths.has(extension.replace(/^\.\//, "")))).toBe( diff --git a/test/package-contract/openshell-policy-boundary.test.ts b/test/package-contract/openshell-policy-boundary.test.ts index 9f706142738..ba361f07a15 100644 --- a/test/package-contract/openshell-policy-boundary.test.ts +++ b/test/package-contract/openshell-policy-boundary.test.ts @@ -12,6 +12,7 @@ import { describe, expect, it } from "vitest"; import YAML from "yaml"; import { auditOpenShellPolicyBoundaryDependencies } from "../../scripts/checks/verify-openshell-policy-boundary-dependencies.mts"; +import { npmPackFilePaths } from "../helpers/npm-pack-result"; import { createPackageFixture } from "./helpers/package-fixture"; const repoRoot = path.join(import.meta.dirname, "..", ".."); @@ -30,17 +31,14 @@ function collectPackedPaths(): ReadonlySet { entries: ["agents"], }); try { - const output = JSON.parse( + const paths = npmPackFilePaths( execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { cwd: fixtureRoot, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, }), - ) as - | Array<{ files?: Array<{ path?: string }> }> - | Record }>; - const report = Array.isArray(output) ? output[0] : Object.values(output)[0]; - return new Set((report?.files ?? []).flatMap((entry) => (entry.path ? [entry.path] : []))); + ); + return new Set(paths); } finally { fs.rmSync(fixtureRoot, { recursive: true, force: true }); } diff --git a/test/package-contract/openshell-sdk-loading.test.ts b/test/package-contract/openshell-sdk-loading.test.ts index dc9db8ef353..4109f549492 100644 --- a/test/package-contract/openshell-sdk-loading.test.ts +++ b/test/package-contract/openshell-sdk-loading.test.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import YAML from "yaml"; import { createPackageFixture } from "./helpers/package-fixture"; +import { npmPackFilePaths } from "../helpers/npm-pack-result"; const repositoryRoot = path.join(import.meta.dirname, "..", ".."); const roots: string[] = []; @@ -118,15 +119,11 @@ console.log(JSON.stringify({ client, policy })); identity: ["fixture-ca", "fixture-cert", "fixture-key"], }); expect(YAML.parse(result.policy)).toEqual({ version: 1 }); - const packed = JSON.parse( - execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { - cwd: root, - encoding: "utf8", - }), - ) as Array<{ files: Array<{ path: string }> }>; - expect(packed[0]?.files.map(({ path: filePath }) => filePath)).toContain( - "dist/lib/adapters/openshell/sdk-import.mjs", - ); + const packed = execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { + cwd: root, + encoding: "utf8", + }); + expect(npmPackFilePaths(packed)).toContain("dist/lib/adapters/openshell/sdk-import.mjs"); }); it("loads the compiled adapters before the optional SDK is installed", () => { diff --git a/test/platform/images/bundled-npm-brace-expansion-dockerfile-contract.test.ts b/test/platform/images/bundled-npm-brace-expansion-dockerfile-contract.test.ts index 8f00f76f65b..226d10433da 100644 --- a/test/platform/images/bundled-npm-brace-expansion-dockerfile-contract.test.ts +++ b/test/platform/images/bundled-npm-brace-expansion-dockerfile-contract.test.ts @@ -12,7 +12,12 @@ import { REVIEWED_NPM_VERSION, } from "../../../scripts/patch-bundled-npm-brace-expansion.mts"; import { REVIEWED_NPM_VERSION as UPGRADED_NPM_VERSION } from "../../../scripts/upgrade-bundled-npm.mts"; -import { requireSingleReviewedDockerfileRunCommand } from "../../helpers/dockerfile-run-commands"; +import { + requireDockerfileCopySources, + requireReviewedDockerfileRunCommands, + requireSingleDockerfileCopySource, + requireSingleReviewedDockerfileRunCommand, +} from "../../helpers/dockerfile-run-commands"; const repoRoot = path.resolve(import.meta.dirname, "../../.."); const baseDockerfiles = [ @@ -25,25 +30,30 @@ const finalDockerfiles = [ "agents/hermes/Dockerfile", "agents/langchain-deepagents-code/Dockerfile", ] as const; -const copyInstruction = - "COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts"; +const copySource = "scripts/patch-bundled-npm-brace-expansion.mts"; +const copyDestination = "/scripts/patch-bundled-npm-brace-expansion.mts"; const patchInstruction = "node /scripts/patch-bundled-npm-brace-expansion.mts"; const npmRootArguments = ["--npm-root", "/usr/local/lib/node_modules/npm"] as const; const hermesTarCacheSeedArguments = [ ...npmRootArguments, "--archive", - "/tmp/nemoclaw-bundled-npm-tar.tgz", + "/scripts/nemoclaw-bundled-npm-tar.tgz", ] as const; const tarPatchArgumentsByDockerfile = { Dockerfile: npmRootArguments, "agents/hermes/Dockerfile": hermesTarCacheSeedArguments, "agents/langchain-deepagents-code/Dockerfile": npmRootArguments, } as const; +const tarPatchCountByDockerfile = { + Dockerfile: 2, + "agents/hermes/Dockerfile": 1, + "agents/langchain-deepagents-code/Dockerfile": 1, +} as const; describe("bundled npm brace-expansion image remediation contract", () => { it("binds the replacement to the reviewed npm and registry artifact", () => { expect(REVIEWED_NPM_VERSION).toBe(UPGRADED_NPM_VERSION); - expect(REVIEWED_NPM_VERSION).toBe("11.18.0"); + expect(REVIEWED_NPM_VERSION).toBe("12.0.2"); expect(FIXED_BRACE_EXPANSION_VERSION).toBe("5.0.9"); expect(FIXED_BRACE_EXPANSION_INTEGRITY).toMatch(/^sha512-[A-Za-z0-9+/]+=*$/u); expect(FIXED_BRACE_EXPANSION_TARBALL).toBe( @@ -53,7 +63,7 @@ describe("bundled npm brace-expansion image remediation contract", () => { it.each(baseDockerfiles)("patches the reviewed npm tree after upgrading it in %s", (file) => { const source = fs.readFileSync(path.join(repoRoot, file), "utf8"); - const copy = source.indexOf(copyInstruction); + const copy = requireSingleDockerfileCopySource(source, copySource, copyDestination).start; const upgrade = requireSingleReviewedDockerfileRunCommand( source, "node /scripts/upgrade-bundled-npm.mts", @@ -74,19 +84,27 @@ describe("bundled npm brace-expansion image remediation contract", () => { "reasserts the private package fix in the completed %s filesystem", (file) => { const source = fs.readFileSync(path.join(repoRoot, file), "utf8"); - const copy = source.indexOf(copyInstruction); - const tarPatch = requireSingleReviewedDockerfileRunCommand( + const copy = requireDockerfileCopySources( + source, + copySource, + copyDestination, + tarPatchCountByDockerfile[file], + ).at(-1)!.start; + const tarPatches = requireReviewedDockerfileRunCommands( source, "node /scripts/patch-bundled-npm-tar.mts", tarPatchArgumentsByDockerfile[file], - ).commandStart; - const bracePatch = requireSingleReviewedDockerfileRunCommand( + tarPatchCountByDockerfile[file], + ); + const tarPatch = tarPatches.at(-1)!.commandStart; + const bracePatches = requireReviewedDockerfileRunCommands( source, patchInstruction, npmRootArguments, + tarPatchCountByDockerfile[file], ); + const bracePatch = bracePatches.at(-1)!; - expect(copy, file).toBeGreaterThanOrEqual(0); expect(tarPatch, file).toBeGreaterThan(copy); expect(bracePatch.commandStart, file).toBeGreaterThan(tarPatch); }, diff --git a/test/platform/images/bundled-npm-ip-address-dockerfile-contract.test.ts b/test/platform/images/bundled-npm-ip-address-dockerfile-contract.test.ts index 8964cdb70a8..ee3f974453d 100644 --- a/test/platform/images/bundled-npm-ip-address-dockerfile-contract.test.ts +++ b/test/platform/images/bundled-npm-ip-address-dockerfile-contract.test.ts @@ -12,7 +12,12 @@ import { REVIEWED_NPM_VERSION, } from "../../../scripts/lib/patch-bundled-npm-ip-address.mts"; import { REVIEWED_NPM_VERSION as UPGRADED_NPM_VERSION } from "../../../scripts/upgrade-bundled-npm.mts"; -import { requireSingleReviewedDockerfileRunCommand } from "../../helpers/dockerfile-run-commands"; +import { + requireDockerfileCopySources, + requireReviewedDockerfileRunCommands, + requireSingleDockerfileCopySource, + requireSingleReviewedDockerfileRunCommand, +} from "../../helpers/dockerfile-run-commands"; const repoRoot = path.resolve(import.meta.dirname, "../../.."); const baseDockerfiles = [ @@ -25,25 +30,30 @@ const finalDockerfiles = [ "agents/hermes/Dockerfile", "agents/langchain-deepagents-code/Dockerfile", ] as const; -const copyInstruction = - "COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts"; +const copySource = "scripts/lib/patch-bundled-npm-ip-address.mts"; +const copyDestination = "/scripts/lib/patch-bundled-npm-ip-address.mts"; const patchCommand = "node /scripts/lib/patch-bundled-npm-ip-address.mts"; const npmRootArguments = ["--npm-root", "/usr/local/lib/node_modules/npm"] as const; const hermesTarCacheSeedArguments = [ ...npmRootArguments, "--archive", - "/tmp/nemoclaw-bundled-npm-tar.tgz", + "/scripts/nemoclaw-bundled-npm-tar.tgz", ] as const; const tarPatchArgumentsByDockerfile = { Dockerfile: npmRootArguments, "agents/hermes/Dockerfile": hermesTarCacheSeedArguments, "agents/langchain-deepagents-code/Dockerfile": npmRootArguments, } as const; +const tarPatchCountByDockerfile = { + Dockerfile: 2, + "agents/hermes/Dockerfile": 1, + "agents/langchain-deepagents-code/Dockerfile": 1, +} as const; describe("bundled npm ip-address image remediation contract", () => { it("binds the replacement to the reviewed npm and registry artifact", () => { expect(REVIEWED_NPM_VERSION).toBe(UPGRADED_NPM_VERSION); - expect(REVIEWED_NPM_VERSION).toBe("11.18.0"); + expect(REVIEWED_NPM_VERSION).toBe("12.0.2"); expect(FIXED_IP_ADDRESS_VERSION).toBe("10.3.1"); expect(FIXED_IP_ADDRESS_INTEGRITY).toBe( "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", @@ -55,7 +65,7 @@ describe("bundled npm ip-address image remediation contract", () => { it.each(baseDockerfiles)("patches the reviewed npm tree after upgrading it in %s", (file) => { const source = fs.readFileSync(path.join(repoRoot, file), "utf8"); - const copy = source.indexOf(copyInstruction); + const copy = requireSingleDockerfileCopySource(source, copySource, copyDestination).start; const upgrade = requireSingleReviewedDockerfileRunCommand( source, "node /scripts/upgrade-bundled-npm.mts", @@ -70,24 +80,34 @@ describe("bundled npm ip-address image remediation contract", () => { it.each(finalDockerfiles)("reasserts the private package fix in the completed %s", (file) => { const source = fs.readFileSync(path.join(repoRoot, file), "utf8"); - const copy = source.indexOf(copyInstruction); - const tarPatch = requireSingleReviewedDockerfileRunCommand( + const copy = requireDockerfileCopySources( + source, + copySource, + copyDestination, + tarPatchCountByDockerfile[file], + ).at(-1)!.start; + const tarPatches = requireReviewedDockerfileRunCommands( source, "node /scripts/patch-bundled-npm-tar.mts", tarPatchArgumentsByDockerfile[file], - ).commandStart; - const bracePatch = requireSingleReviewedDockerfileRunCommand( + tarPatchCountByDockerfile[file], + ); + const tarPatch = tarPatches.at(-1)!.commandStart; + const bracePatches = requireReviewedDockerfileRunCommands( source, "node /scripts/patch-bundled-npm-brace-expansion.mts", npmRootArguments, - ).commandStart; - const ipAddressPatch = requireSingleReviewedDockerfileRunCommand( + tarPatchCountByDockerfile[file], + ); + const bracePatch = bracePatches.at(-1)!.commandStart; + const ipAddressPatches = requireReviewedDockerfileRunCommands( source, patchCommand, npmRootArguments, + tarPatchCountByDockerfile[file], ); + const ipAddressPatch = ipAddressPatches.at(-1)!; - expect(copy, file).toBeGreaterThanOrEqual(0); expect(tarPatch, file).toBeGreaterThan(copy); expect(bracePatch, file).toBeGreaterThan(tarPatch); expect(ipAddressPatch.commandStart, file).toBeGreaterThan(bracePatch); diff --git a/test/platform/images/node-tar-dockerfile-contract.test.ts b/test/platform/images/node-tar-dockerfile-contract.test.ts index 262e5c2be18..7ea8fd5c1ae 100644 --- a/test/platform/images/node-tar-dockerfile-contract.test.ts +++ b/test/platform/images/node-tar-dockerfile-contract.test.ts @@ -6,11 +6,14 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { FIXED_TAR_VERSION } from "../../../scripts/patch-bundled-npm-tar.mts"; import { - FIXED_TAR_VERSION, - NODE_BASES_REQUIRING_BUNDLED_NPM_TAR_PATCH, -} from "../../../scripts/patch-bundled-npm-tar.mts"; + REVIEWED_NPM_ARCHIVE_SHA256, + REVIEWED_NPM_TARBALL, + REVIEWED_NPM_VERSION, +} from "../../../scripts/upgrade-bundled-npm.mts"; import { + dockerfileInstructions, dockerfileRunCommandPositions, requireReviewedDockerfileRunCommands, requireSingleReviewedDockerfileRunCommand, @@ -64,7 +67,19 @@ const dockerfiles = [ ] as const; const patchCommand = "node /scripts/patch-bundled-npm-tar.mts"; const npmRootArguments = ["--npm-root", "/usr/local/lib/node_modules/npm"] as const; -const hermesFinalArchivePath = "/tmp/nemoclaw-bundled-npm-tar.tgz"; +const reviewedNpmArchivePath = "/tmp/npm-12.0.2.tgz"; +const reviewedNpmUpgradeArguments = [ + ...npmRootArguments, + "--archive", + reviewedNpmArchivePath, +] as const; +const hermesReviewedNpmArchivePath = "/scripts/npm-12.0.2.tgz"; +const hermesReviewedNpmUpgradeArguments = [ + ...npmRootArguments, + "--archive", + hermesReviewedNpmArchivePath, +] as const; +const hermesFinalArchivePath = "/scripts/nemoclaw-bundled-npm-tar.tgz"; const hermesFinalPatchArguments = [ ...npmRootArguments, "--archive", @@ -76,7 +91,55 @@ const pinnedBaseDockerfiles = [ "agents/langchain-deepagents-code/Dockerfile.base", "agents/pi/Dockerfile.base", ] as const; -const reviewedNodeBases = new Set(NODE_BASES_REQUIRING_BUNDLED_NPM_TAR_PATCH); +const REVIEWED_NODE_BASE = + "node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc"; +const reviewedNodeBases = new Set([REVIEWED_NODE_BASE]); +const directNodeDockerfiles = [ + "Dockerfile", + "Dockerfile.base", + "agents/hermes/Dockerfile", + "agents/hermes/Dockerfile.base", + "agents/langchain-deepagents-code/Dockerfile", + "agents/langchain-deepagents-code/Dockerfile.base", + "agents/pi/Dockerfile", + "agents/pi/Dockerfile.base", +] as const; +const npmHelperInvocationMarkers = [ + /\/opt\/nemoclaw-build-tools\/npm-ci-locked\.sh/gu, + /node \/opt\/[^\s]*reviewed-npm-archive\.mts/gu, + /node \/opt\/[^\s]*seed-reviewed-npm-cache\.mts/gu, +] as const; + +interface DirectNodeStage { + readonly file: string; + readonly name: string; + readonly source: string; +} + +function directNodeStages(file: string, source: string): DirectNodeStage[] { + const starts = [...source.matchAll(/^FROM(?:\s+--\S+)*\s+([^\s]+)(?:\s+AS\s+(\S+))?$/gmu)]; + return starts.flatMap((match, index) => + match[1]!.startsWith("node:24.18.1-") || match[1] === "npm12" + ? [ + { + file, + name: match[2] ?? "", + source: source.slice(match.index, starts[index + 1]?.index ?? source.length), + }, + ] + : [], + ); +} + +function firstNpmInvocation(stage: DirectNodeStage): number { + const commandPositions = ["npm", "npx"].flatMap((command) => + dockerfileRunCommandPositions(stage.source, command), + ); + const helperPositions = npmHelperInvocationMarkers.flatMap((marker) => + [...stage.source.matchAll(marker)].map((match) => match.index!), + ); + return Math.min(...commandPositions, ...helperPositions, Number.POSITIVE_INFINITY); +} interface ShellToken { end: number; @@ -264,20 +327,15 @@ describe("node-tar image remediation contract", () => { assertReviewedNodeBases(file, source); for (const base of nodeBaseReferences(source)) observedBases.add(base); }); - expect([...observedBases].sort()).toEqual( - [...NODE_BASES_REQUIRING_BUNDLED_NPM_TAR_PATCH].sort(), - ); + expect([...observedBases]).toEqual([REVIEWED_NODE_BASE]); }); // source-shape-contract: security -- Each managed Dockerfile must remain bound to a reviewed Node base digest. it("rejects an isolated unreviewed Deep Agents Code Node base pin", () => { const file = "agents/langchain-deepagents-code/Dockerfile.base"; const source = fs.readFileSync(path.join(repoRoot, file), "utf8"); - const reviewedBase = NODE_BASES_REQUIRING_BUNDLED_NPM_TAR_PATCH.find((base) => - base.startsWith("node:22-"), - ); - assert(reviewedBase !== undefined, "the reviewed Node 22 base must be registered"); - const unreviewedBase = `node:22-trixie-slim@sha256:${"0".repeat(64)}`; + const reviewedBase = REVIEWED_NODE_BASE; + const unreviewedBase = `node:24.18.1-trixie-slim@sha256:${"0".repeat(64)}`; const changedSource = source.replaceAll(reviewedBase, unreviewedBase); expect(() => assertReviewedNodeBases(file, changedSource)).toThrow( @@ -327,9 +385,7 @@ describe("node-tar image remediation contract", () => { .replace(/\s+/g, " "); const reviewedCopy = patchInputStage.indexOf("COPY scripts/lib/reviewed-npm-archive.mts"); const helperCopy = patchInputStage.indexOf("scripts/lib/bundled-npm-package.mts"); - const patchCopy = patchInputStage.indexOf( - "COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts", - ); + const patchCopy = patchInputStage.indexOf("scripts/patch-bundled-npm-tar.mts"); const patchRuns = requireReviewedDockerfileRunCommands( source, patchCommand, @@ -341,10 +397,11 @@ describe("node-tar image remediation contract", () => { const patchInputReady = patchPayloadLayer >= 0 ? patchPayloadLayer : patchCopy; const archiveCopy = `COPY tools/mcp-tool-discovery-runtime/npm-cache-seed/tar-${FIXED_TAR_VERSION}.tgz ${hermesFinalArchivePath}`; - const archiveCopyIndex = source.indexOf(archiveCopy); + const archiveInputIndex = patchInputStage.indexOf(archiveCopy); + const archiveReady = patchPayloadLayer >= 0 ? patchPayloadLayer : archiveInputIndex; expect({ - archiveBeforePatch: archiveCopyIndex >= 0 && firstPatchRun > archiveCopyIndex, - archivePresent: archiveCopyIndex >= 0, + archiveBeforePatch: archiveInputIndex >= 0 && firstPatchRun > archiveReady, + archivePresent: archiveInputIndex >= 0, }).toEqual( file === "agents/hermes/Dockerfile" ? { archiveBeforePatch: true, archivePresent: true } @@ -354,7 +411,7 @@ describe("node-tar image remediation contract", () => { expect(reviewedCopy, file).toBeGreaterThanOrEqual(0); expect( flattenedPatchInputStage.includes( - "COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/bundled-npm-package.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/openclaw-npm-remediation.mts /scripts/lib/", + "COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/bundled-npm-package.mts", ) || patchInputStage.includes( "COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts", @@ -388,6 +445,176 @@ describe("node-tar image remediation contract", () => { }); describe("reviewed npm image remediation contract", () => { + it.each([ + "Dockerfile", + "agents/hermes/Dockerfile", + "agents/langchain-deepagents-code/Dockerfile", + "agents/pi/Dockerfile", + ])("installs reviewed npm in the final $file stage from immutable local bytes", (file) => { + const dockerfile = fs.readFileSync(path.join(repoRoot, file), "utf8"); + const archiveStage = namedStage(dockerfile, "reviewed-npm-archive"); + const source = completedStage(dockerfile); + const portableOptions = file === "agents/hermes/Dockerfile" ? "" : "--chmod=0444 "; + const archiveSource = `ADD ${portableOptions}--checksum=sha256:${REVIEWED_NPM_ARCHIVE_SHA256} ${REVIEWED_NPM_TARBALL} /npm-${REVIEWED_NPM_VERSION}.tgz`; + const selectedArchivePath = + file === "agents/hermes/Dockerfile" ? hermesReviewedNpmArchivePath : reviewedNpmArchivePath; + const directArchiveCopy = `COPY --from=reviewed-npm-archive /npm-${REVIEWED_NPM_VERSION}.tgz ${selectedArchivePath}`; + const archiveCopy = + file === "agents/hermes/Dockerfile" + ? "COPY --from=hermes-npm-patch-payload / /" + : directArchiveCopy; + const archiveCopyIndex = source.indexOf(archiveCopy); + const upgradeRun = requireSingleReviewedDockerfileRunCommand( + source, + "node /scripts/upgrade-bundled-npm.mts", + file === "agents/hermes/Dockerfile" + ? hermesReviewedNpmUpgradeArguments + : reviewedNpmUpgradeArguments, + ).commandStart; + const firstPrivatePatch = source.indexOf(patchCommand); + + expect(archiveStage).toContain(archiveSource); + expect( + file !== "agents/hermes/Dockerfile" || + namedStage(dockerfile, "hermes-npm-patch-payload").includes(directArchiveCopy), + ).toBe(true); + expect(archiveCopyIndex, file).toBeGreaterThanOrEqual(0); + expect(upgradeRun, file).toBeGreaterThan(archiveCopyIndex); + expect(firstPrivatePatch, file).toBeGreaterThan(upgradeRun); + }); + + it("prepares the root npm build stage from the same immutable archive", () => { + const dockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); + const npm12 = directNodeStages("Dockerfile", dockerfile).find(({ name }) => name === "npm12"); + assert(npm12, "Dockerfile must contain the npm12 stage"); + + expect(npm12.source).toContain( + `COPY --from=reviewed-npm-archive /npm-${REVIEWED_NPM_VERSION}.tgz ${reviewedNpmArchivePath}`, + ); + const upgradeRun = requireSingleReviewedDockerfileRunCommand( + npm12.source, + "node /scripts/upgrade-bundled-npm.mts", + reviewedNpmUpgradeArguments, + ).commandStart; + const tarRun = requireSingleReviewedDockerfileRunCommand( + npm12.source, + patchCommand, + npmRootArguments, + ).commandStart; + const braceRun = npm12.source.indexOf("node /scripts/patch-bundled-npm-brace-expansion.mts"); + const ipAddressRun = npm12.source.indexOf("node /scripts/lib/patch-bundled-npm-ip-address.mts"); + + expect(upgradeRun).toBeGreaterThan(npm12.source.indexOf(reviewedNpmArchivePath)); + expect(tarRun).toBeGreaterThan(upgradeRun); + expect(braceRun).toBeGreaterThan(tarRun); + expect(ipAddressRun).toBeGreaterThan(braceRun); + }); + + // source-shape-contract: compatibility -- Hermes archive staging and adjacent runtime checks must share existing layers so the final image stays within the Docker import ceiling. + it("keeps Hermes npm migration inputs and runtime finalization layer-bounded", () => { + const dockerfile = fs.readFileSync(path.join(repoRoot, "agents/hermes/Dockerfile"), "utf8"); + const payload = namedStage(dockerfile, "hermes-npm-patch-payload"); + const npmUpgrade = requireSingleReviewedDockerfileRunCommand( + completedStage(dockerfile), + "node /scripts/upgrade-bundled-npm.mts", + hermesReviewedNpmUpgradeArguments, + ); + const finalization = dockerfileInstructions(completedStage(dockerfile)).filter( + ({ body, keyword }) => keyword === "RUN" && body.includes('hermes_path="$(command -v hermes'), + ); + + expect(payload).toContain( + `COPY --from=reviewed-npm-archive /npm-${REVIEWED_NPM_VERSION}.tgz ${hermesReviewedNpmArchivePath}`, + ); + expect(payload).toContain( + `COPY tools/mcp-tool-discovery-runtime/npm-cache-seed/tar-${FIXED_TAR_VERSION}.tgz ${hermesFinalArchivePath}`, + ); + expect(payload).not.toContain("/tmp/"); + expect(npmUpgrade.instruction.body).not.toContain("/tmp/"); + expect(finalization).toHaveLength(1); + expect( + [ + "chmod -R a+rX /opt/hermes/.venv", + "hermes_web_dist=/opt/hermes/hermes_cli/web_dist", + "apt-get autoremove --purge -y", + ].every((marker) => finalization[0]!.body.includes(marker)), + ).toBe(true); + }); + + // source-shape-contract: security -- Direct Node stages must upgrade reviewed npm before any npm-backed build boundary executes. + it("upgrades and verifies reviewed npm before every direct Node stage npm boundary", () => { + expect( + directNodeStages( + "platform-fixture", + "FROM --platform=$BUILDPLATFORM node:24.18.1-trixie AS builder\nRUN npm ci\n", + ).map(({ file, name }) => `${file}:${name}`), + ).toEqual(["platform-fixture:builder"]); + const rootDockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); + const stages = directNodeDockerfiles.flatMap((file) => + directNodeStages(file, fs.readFileSync(path.join(repoRoot, file), "utf8")), + ); + const invokingStages = stages.filter((stage) => Number.isFinite(firstNpmInvocation(stage))); + const upgrade = "node /scripts/upgrade-bundled-npm.mts"; + + expect(stages.map(({ file, name }) => `${file}:${name}`)).toEqual([ + "Dockerfile:npm12", + "Dockerfile:builder", + "Dockerfile:managed-bootstrap-entrypoint-builder", + "Dockerfile:codex-acp-runtime", + "Dockerfile:wechat-npm-cache", + "Dockerfile:openclaw-managed-messaging-npm-cache-0", + "Dockerfile:openclaw-managed-messaging-npm-cache-1", + "Dockerfile.base:native-security-builder", + "Dockerfile.base:", + "agents/hermes/Dockerfile:managed-bootstrap-entrypoint-builder", + "agents/hermes/Dockerfile.base:native-security-builder", + "agents/hermes/Dockerfile.base:", + "agents/langchain-deepagents-code/Dockerfile:managed-bootstrap-entrypoint-builder", + "agents/langchain-deepagents-code/Dockerfile.base:native-security-builder", + "agents/langchain-deepagents-code/Dockerfile.base:", + "agents/pi/Dockerfile:managed-bootstrap-entrypoint-builder", + "agents/pi/Dockerfile.base:native-security-builder", + "agents/pi/Dockerfile.base:", + ]); + expect( + rootDockerfile.match( + /^COPY scripts\/lib\/seed-reviewed-npm-cache[.]mts \/scripts\/lib\/seed-reviewed-npm-cache[.]mts$/gmu, + ), + ).toHaveLength(2); + expect( + rootDockerfile.match( + /^COPY scripts\/checks\/materialize-locked-npm-cache-seed[.]mts \/scripts\/checks\/materialize-locked-npm-cache-seed[.]mts$/gmu, + ), + ).toHaveLength(2); + expect( + rootDockerfile.match(/node \/scripts\/lib\/seed-reviewed-npm-cache[.]mts/gmu), + ).toHaveLength(3); + expect(rootDockerfile).toContain("node /scripts/lib/reviewed-npm-archive.mts"); + expect(rootDockerfile).not.toContain("/opt/nemoclaw-build-tools/seed-reviewed-npm-cache.mts"); + expect(rootDockerfile).not.toContain( + "/opt/nemoclaw-build-tools/lib/seed-reviewed-npm-cache.mts", + ); + expect(invokingStages.map(({ file, name }) => `${file}:${name}`)).toEqual([ + "Dockerfile:builder", + "Dockerfile:codex-acp-runtime", + "Dockerfile:wechat-npm-cache", + "Dockerfile:openclaw-managed-messaging-npm-cache-1", + "Dockerfile.base:", + "agents/hermes/Dockerfile.base:", + "agents/pi/Dockerfile.base:", + ]); + expect( + invokingStages.every((stage) => { + const position = stage.source.indexOf(upgrade); + return ( + (position >= 0 || stage.source.startsWith("FROM npm12 AS ")) && + position < firstNpmInvocation(stage) + ); + }), + ).toBe(true); + expect(REVIEWED_NPM_VERSION).toBe("12.0.2"); + }); + it.each([ ["a flag-only global option", "npm --silent ci"], ["mixed global options", "npm --prefix /work --silent install"], diff --git a/test/repository/ci-install-dependencies.test.ts b/test/repository/ci-install-dependencies.test.ts index 4dc5985bd58..4c97e0937da 100644 --- a/test/repository/ci-install-dependencies.test.ts +++ b/test/repository/ci-install-dependencies.test.ts @@ -69,8 +69,8 @@ describe("shared CI dependency installer", () => { expect(result.status, result.stderr).toBe(0); expect(readFileSync(fixture.trace, "utf8").trim().split("\n")).toEqual([ - `ci --ignore-scripts --prefer-offline --no-audit --no-fund --cache ${join(fixture.root, "npm-cache")}`, - `--prefix nemoclaw ci --ignore-scripts --prefer-offline --no-audit --no-fund --cache ${join(fixture.root, "npm-cache")}`, + `ci --allow-remote=root --ignore-scripts --prefer-offline --no-audit --no-fund --cache ${join(fixture.root, "npm-cache")}`, + `--prefix nemoclaw ci --allow-remote=root --ignore-scripts --prefer-offline --no-audit --no-fund --cache ${join(fixture.root, "npm-cache")}`, ]); }); @@ -80,8 +80,8 @@ describe("shared CI dependency installer", () => { expect(result.status, result.stderr).toBe(0); expect(readFileSync(fixture.trace, "utf8").trim().split("\n")).toEqual([ - `ci --ignore-scripts --prefer-offline --no-audit --no-fund --cache ${join(fixture.root, "npm-cache")}`, - `--prefix nemoclaw ci --omit=dev --ignore-scripts --prefer-offline --no-audit --no-fund --cache ${join(fixture.root, "npm-cache")}`, + `ci --allow-remote=root --ignore-scripts --prefer-offline --no-audit --no-fund --cache ${join(fixture.root, "npm-cache")}`, + `--prefix nemoclaw ci --omit=dev --allow-remote=root --ignore-scripts --prefer-offline --no-audit --no-fund --cache ${join(fixture.root, "npm-cache")}`, ]); }); @@ -91,7 +91,7 @@ describe("shared CI dependency installer", () => { expect(result.status, result.stderr).toBe(0); expect(readFileSync(fixture.trace, "utf8").trim()).toBe( - `ci --ignore-scripts --prefer-offline --no-audit --no-fund --cache ${join(fixture.root, "npm-cache")}`, + `ci --allow-remote=root --ignore-scripts --prefer-offline --no-audit --no-fund --cache ${join(fixture.root, "npm-cache")}`, ); }); diff --git a/test/repository/code-scanning-workflow.test.ts b/test/repository/code-scanning-workflow.test.ts index 579616117f5..ecbe80ccf4a 100644 --- a/test/repository/code-scanning-workflow.test.ts +++ b/test/repository/code-scanning-workflow.test.ts @@ -85,6 +85,13 @@ esac } describe("ShellCheck SARIF workflow boundary", () => { + // source-shape-contract: security -- ShellCheck conversion must install npm from an immutable reviewed action before executing the trusted converter + it("installs npm from the immutable reviewed action bootstrap", () => { + expect(requiredStep("Install reviewed npm").uses).toBe( + "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2", + ); + }); + it("keeps a preinstalled ShellCheck only when its json1 formatter works (#7684)", () => { const { calls, result } = runShellCheckInstall({ preinstalledSupportsJson1: true }); diff --git a/test/repository/github-actions-node-npm-invariant.test.ts b/test/repository/github-actions-node-npm-invariant.test.ts new file mode 100644 index 00000000000..aea75d6ed22 --- /dev/null +++ b/test/repository/github-actions-node-npm-invariant.test.ts @@ -0,0 +1,405 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; +import { REVIEWED_NPM_VERSION as BRACE_NPM_VERSION } from "../../scripts/patch-bundled-npm-brace-expansion.mts"; +import { REVIEWED_NPM_VERSION as IP_ADDRESS_NPM_VERSION } from "../../scripts/lib/patch-bundled-npm-ip-address.mts"; +import { + REVIEWED_NPM_ARCHIVE_SHA256, + REVIEWED_NPM_INTEGRITY, + REVIEWED_NPM_VERSION as BUNDLED_NPM_VERSION, +} from "../../scripts/upgrade-bundled-npm.mts"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "../.."); +const GITHUB_ROOT = path.join(REPO_ROOT, ".github"); +const SETUP_NODE = "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020"; +const REVIEWED_NPM_ACTION = "setup-reviewed-npm"; +const IMMUTABLE_REVIEWED_NPM_ACTION = + "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2"; +const IMMUTABLE_PREPARE_E2E_NPM_ACTION = + "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2"; +const IMMUTABLE_PREPARE_E2E_ACTION = + "NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c"; + +type Step = { + if?: string; + name?: string; + run?: string; + uses?: string; + with?: Record; +}; +type ActionDocument = { runs?: { steps?: Step[] } }; +type WorkflowDocument = { + jobs?: Record; + on?: { + pull_request?: { paths?: string[] }; + push?: { paths?: string[] }; + }; +}; +type StepGroup = { file: string; label: string; steps: Step[] }; + +function yamlFiles(directory: string): string[] { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const candidate = path.join(directory, entry.name); + return entry.isDirectory() + ? yamlFiles(candidate) + : /\.ya?ml$/u.test(entry.name) + ? [candidate] + : []; + }); +} + +function sourceFiles(directory: string): string[] { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const candidate = path.join(directory, entry.name); + return [".git", "coverage", "dist", "node_modules"].includes(entry.name) + ? [] + : entry.isDirectory() + ? sourceFiles(candidate) + : /(?:\.ya?ml|\.[cm]?[jt]s)$/u.test(entry.name) + ? [candidate] + : []; + }); +} + +function stepGroups(file: string): StepGroup[] { + const document = YAML.parse(fs.readFileSync(file, "utf8")) as ActionDocument & WorkflowDocument; + return [ + ...Object.entries(document.jobs ?? {}).flatMap(([label, definition]) => + definition.steps ? [{ file, label, steps: definition.steps }] : [], + ), + ...(document.runs?.steps ? [{ file, label: "runs", steps: document.runs.steps }] : []), + ]; +} + +const identity = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), +) as { + nodeVersion: string; + npmArchiveSha256: string; + npmIntegrity: string; + npmVersion: string; +}; +const groups = yamlFiles(GITHUB_ROOT).flatMap(stepGroups); +const setupNodeSteps = groups.flatMap(({ file, label, steps }) => + steps.flatMap((step, index) => + step.uses?.startsWith("actions/setup-node@") ? [{ file, label, steps, step, index }] : [], + ), +); + +function installsReviewedNpm(step: Step): boolean { + return Boolean( + step.uses?.includes(REVIEWED_NPM_ACTION) || + step.uses?.includes("/.github/actions/prepare-e2e@") || + step.run?.includes("Install-WslNode") || + step.run?.includes("setup-reviewed-npm/verify-and-install-npm.sh"), + ); +} + +function installsReviewedNode(step: Step): boolean { + return Boolean( + step.uses?.startsWith("actions/setup-node@") || + step.uses?.includes("/.github/actions/prepare-e2e@") || + step.run?.includes("Install-WslNode"), + ); +} + +function runsSanitizedReviewedNpmBootstrap(step: Step): boolean { + const run = step.run ?? ""; + return ( + run.includes("env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN") && + run.includes("$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh") && + run.includes("$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json") + ); +} + +function runsNpm(step: Step): boolean { + return /(?:^|[\n;&|({])\s*(?:(?:sudo|env|[A-Za-z_][A-Za-z0-9_]*=\S*)\s+)*(?:npm|npx)(?:\s|$)/mu.test( + step.run ?? "", + ); +} + +describe("controlled setup-node environments", () => { + it.each(["CI=true npx vitest", "sudo npm ci", "env NODE_ENV=test npm ci"])( + "detects prefixed npm execution: %s", + (run) => expect(runsNpm({ run })).toBe(true), + ); + + // source-shape-contract: security -- Changes to the reviewed npm bootstrap must select both PR image validation and the post-merge base-image publication path. + it("selects image validation when the reviewed npm bootstrap changes", () => { + const reviewedNpmBootstrap = ".github/actions/setup-reviewed-npm/**"; + const readWorkflow = (file: string) => + YAML.parse( + fs.readFileSync(path.join(GITHUB_ROOT, "workflows", file), "utf8"), + ) as WorkflowDocument; + + expect(readWorkflow("managed-images.yaml").on?.pull_request?.paths).toContain( + reviewedNpmBootstrap, + ); + expect(readWorkflow("base-image.yaml").on?.push?.paths).toContain(reviewedNpmBootstrap); + }); + + // source-shape-contract: security -- Every controlled setup-node environment must install the integrity-bound npm release before an npm command executes. + it("selects the reviewed Node and npm identities before further steps", () => { + const setupIdentities = setupNodeSteps.map(({ step }) => [ + step.uses, + String(step.with?.["node-version"]), + ]); + + expect(identity).toMatchObject({ nodeVersion: "24.18.1", npmVersion: "12.0.2" }); + expect(setupNodeSteps.length).toBeGreaterThan(0); + expect( + setupIdentities.every( + ([action, node]) => action === SETUP_NODE && node === identity.nodeVersion, + ), + ).toBe(true); + const invalidOrder = setupNodeSteps + .map(({ file, label, steps, step, index }) => { + const laterSteps = steps.slice(index + 1); + const reviewedIndex = laterSteps.findIndex(installsReviewedNpm); + const npmConsumerIndex = laterSteps.findIndex(runsNpm); + const matchingCondition = + step.if === undefined || + laterSteps.some( + (candidate) => installsReviewedNpm(candidate) && candidate.if === step.if, + ); + return { + label: `${path.relative(REPO_ROOT, file)}:${label}`, + valid: + reviewedIndex >= 0 && + (npmConsumerIndex < 0 || reviewedIndex < npmConsumerIndex) && + matchingCondition, + }; + }) + .filter(({ valid }) => !valid) + .map(({ label }) => label); + expect(invalidOrder).toEqual([]); + + const invalidWorkflowConsumers = groups + .filter(({ file }) => path.relative(GITHUB_ROOT, file).startsWith(`workflows${path.sep}`)) + .flatMap(({ file, label, steps }) => + steps + .map((step, index) => ({ index, step })) + .filter(({ step }) => runsNpm(step)) + .flatMap(({ index, step }) => { + const priorSteps = steps.slice(0, index); + const setupIndex = priorSteps.map(installsReviewedNode).lastIndexOf(true); + const reviewedIndex = priorSteps.map(installsReviewedNpm).lastIndexOf(true); + const setup = priorSteps[setupIndex]; + const reviewed = priorSteps[reviewedIndex]; + const matchingSetupCondition = setup?.if === undefined || setup.if === step.if; + const matchingReviewedCondition = reviewed?.if === undefined || reviewed.if === step.if; + return setupIndex >= 0 && + reviewedIndex >= setupIndex && + matchingSetupCondition && + matchingReviewedCondition + ? [] + : [`${path.relative(REPO_ROOT, file)}:${label}:${step.name ?? index}`]; + }), + ); + expect(invalidWorkflowConsumers).toEqual([]); + }); + + it("keeps every workflow-local reviewed npm action behind its matching checkout", () => { + const workflowGroups = groups.filter(({ file }) => + path.relative(GITHUB_ROOT, file).startsWith(`workflows${path.sep}`), + ); + + const actionSteps = workflowGroups.flatMap(({ file, label, steps }) => + steps.flatMap((step, index) => + step.uses?.startsWith("./") && step.uses.includes(REVIEWED_NPM_ACTION) + ? [{ file, label, step, steps, index }] + : [], + ), + ); + const invalidCheckouts = actionSteps + .map(({ file, label, step, steps, index }) => { + const match = /^\.\/(.*?)\.github\/actions\/setup-reviewed-npm$/u.exec(step.uses ?? ""); + const checkoutPath = match?.[1].replace(/\/$/u, "") ?? ""; + const matchingCheckout = steps + .slice(0, index) + .filter((candidate) => candidate.uses?.startsWith("actions/checkout@")) + .reverse() + .find(({ with: inputs }) => + checkoutPath === "" + ? inputs?.path === undefined + : String(inputs?.path) === checkoutPath, + ); + const sparseCheckout = matchingCheckout?.with?.["sparse-checkout"]; + const sparsePaths = String(sparseCheckout ?? "") + .split("\n") + .map((entry) => entry.trim()) + .filter(Boolean); + const completeSparseCheckout = + sparseCheckout === undefined || + (sparsePaths.includes(".github/actions/setup-reviewed-npm") && + sparsePaths.includes("ci/reviewed-npm-audit.json") && + sparsePaths.includes("scripts/lib/reviewed-npm-audit.mts")); + return { + label: `${path.relative(REPO_ROOT, file)}:${label}:${step.uses}`, + valid: match !== null && matchingCheckout !== undefined && completeSparseCheckout, + }; + }) + .filter(({ valid }) => !valid) + .map(({ label }) => label); + expect(invalidCheckouts).toEqual([]); + }); + + // source-shape-contract: security -- Sparse trusted-action checkouts must include the canonical installer and identity required by reviewed npm composite actions. + it("keeps composite npm setup dependencies in matching workflow checkouts", () => { + const workflowGroups = groups.filter(({ file }) => + path.relative(GITHUB_ROOT, file).startsWith(`workflows${path.sep}`), + ); + const requiredSparsePaths = [ + ".github/actions/setup-reviewed-npm", + "ci/reviewed-npm-audit.json", + ]; + + const invalidCheckouts = workflowGroups + .flatMap(({ file, label, steps }) => + steps.flatMap((step, index) => { + const localPath = step.uses?.startsWith("./") ? step.uses.slice(2) : ""; + const actionMarker = localPath.indexOf(".github/actions/"); + const actionPath = actionMarker < 0 ? "" : localPath.slice(actionMarker); + const actionFile = path.join(REPO_ROOT, actionPath, "action.yaml"); + const actionSource = + actionPath !== "" && fs.existsSync(actionFile) + ? fs.readFileSync(actionFile, "utf8") + : ""; + const requiresReviewedNpm = actionSource.includes( + "$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh", + ); + const checkoutPath = + actionMarker < 0 ? "" : localPath.slice(0, actionMarker).replace(/\/$/u, ""); + return requiresReviewedNpm + ? [{ actionPath, checkoutPath, file, index, label, steps }] + : []; + }), + ) + .map(({ actionPath, checkoutPath, file, index, label, steps }) => { + const matchingCheckout = steps + .slice(0, index) + .filter((candidate) => candidate.uses?.startsWith("actions/checkout@")) + .reverse() + .find(({ with: inputs }) => + checkoutPath === "" + ? inputs?.path === undefined + : String(inputs?.path) === checkoutPath, + ); + const sparseCheckout = matchingCheckout?.with?.["sparse-checkout"]; + const sparsePaths = String(sparseCheckout ?? "") + .split("\n") + .map((entry) => entry.trim()) + .filter(Boolean); + const completeSparseCheckout = + sparseCheckout === undefined || + requiredSparsePaths.every( + (requiredPath) => sparsePaths.filter((entry) => entry === requiredPath).length === 1, + ); + return { + label: `${path.relative(REPO_ROOT, file)}:${label}:${actionPath}`, + valid: matchingCheckout !== undefined && completeSparseCheckout, + }; + }) + .filter(({ valid }) => !valid) + .map(({ label }) => label); + + expect(invalidCheckouts).toEqual([]); + }); + + it.each([ + ".github/workflows/managed-images.yaml:pr-staging-qa-deep-code", + ".github/workflows/managed-images.yaml:pr-build-and-entrypoint", + ".github/workflows/managed-images.yaml:pr-managed-activation", + ".github/workflows/managed-images.yaml:pr-openclaw-mcp-discovery", + ".github/workflows/managed-images.yaml:pi-candidate", + ".github/workflows/pr.yaml:build-typecheck", + ".github/workflows/docs-preview-pr.yaml:preview", + ])("uses immutable npm setup in protected pull request job %s", (owner) => { + const group = groups.find( + ({ file, label }) => `${path.relative(REPO_ROOT, file)}:${label}` === owner, + ); + expect(group, owner).toBeDefined(); + const action = group?.steps.find((step) => step.uses === IMMUTABLE_REVIEWED_NPM_ACTION); + expect(action, owner).toBeDefined(); + }); + + it("keeps composite npm setup rooted in the trusted action checkout", () => { + const invalidActions = setupNodeSteps + .filter(({ file }) => path.relative(GITHUB_ROOT, file).startsWith(`actions${path.sep}`)) + .filter(({ steps, index }) => { + const reviewed = steps.slice(index + 1).find(installsReviewedNpm); + return ( + ![IMMUTABLE_REVIEWED_NPM_ACTION, IMMUTABLE_PREPARE_E2E_NPM_ACTION].includes( + reviewed?.uses ?? "", + ) && !runsSanitizedReviewedNpmBootstrap(reviewed ?? {}) + ); + }) + .map(({ file }) => path.relative(REPO_ROOT, file)); + expect(invalidActions).toEqual([]); + }); + + // source-shape-contract: security -- Cross-consumer version and digest equality prevents CI, image, and private-patch paths from authorizing different npm archives + it("uses one reviewed npm identity across audit, image, and private-patch consumers", () => { + expect( + new Set([ + identity.npmVersion, + BUNDLED_NPM_VERSION, + BRACE_NPM_VERSION, + IP_ADDRESS_NPM_VERSION, + ]), + ).toEqual(new Set(["12.0.2"])); + expect(identity.npmIntegrity).toBe(REVIEWED_NPM_INTEGRITY); + expect(identity.npmArchiveSha256).toBe(REVIEWED_NPM_ARCHIVE_SHA256); + + const actionRoot = path.join(GITHUB_ROOT, "actions/setup-reviewed-npm"); + const action = fs.readFileSync(path.join(actionRoot, "action.yaml"), "utf8"); + const actionDocument = YAML.parse(action) as ActionDocument; + const installerStep = actionDocument.runs?.steps?.find(({ run }) => + run?.includes("$GITHUB_ACTION_PATH/verify-and-install-npm.sh"), + ); + expect(fs.existsSync(path.join(actionRoot, "verify-and-install-npm.sh"))).toBe(true); + expect(action).toContain("$GITHUB_ACTION_PATH/verify-and-install-npm.sh"); + expect(action).toContain("$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json"); + expect(action).not.toContain("../ci-reviewed-npm-audit/"); + expect(installerStep?.run).toMatch( + /^env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN\s+"\$GITHUB_ACTION_PATH\/verify-and-install-npm[.]sh"/u, + ); + + const pinPattern = + /NVIDIA\/NemoClaw\/\.github\/actions\/(?:setup-reviewed-npm|prepare-e2e)@[0-9a-f]{40}/gu; + const invalidPins = [ + GITHUB_ROOT, + path.join(REPO_ROOT, "scripts"), + path.join(REPO_ROOT, "tools"), + ] + .flatMap(sourceFiles) + .flatMap((file) => + [...fs.readFileSync(file, "utf8").matchAll(pinPattern)] + .map(([reference]) => reference) + .filter( + (reference) => + reference !== IMMUTABLE_REVIEWED_NPM_ACTION && + reference !== IMMUTABLE_PREPARE_E2E_ACTION, + ) + .map((reference) => `${path.relative(REPO_ROOT, file)}:${reference}`), + ); + expect(invalidPins).toEqual([]); + }); + + // source-shape-contract: security -- Every npm-mutating script must import the canonical reviewed identity so independent version or digest pins cannot drift + it.each([ + "scripts/upgrade-bundled-npm.mts", + "scripts/patch-bundled-npm-brace-expansion.mts", + "scripts/lib/patch-bundled-npm-ip-address.mts", + "scripts/lib/seed-reviewed-npm-cache.mts", + ])("imports the reviewed npm identity from %s", (source) => { + expect(fs.readFileSync(path.join(REPO_ROOT, source), "utf8"), source).toContain( + "reviewed-npm-identity.mts", + ); + }); +}); diff --git a/test/repository/pi-qualification-receipt-refresh.test.ts b/test/repository/pi-qualification-receipt-refresh.test.ts index 73cdfb58bc4..db9b36a17cf 100644 --- a/test/repository/pi-qualification-receipt-refresh.test.ts +++ b/test/repository/pi-qualification-receipt-refresh.test.ts @@ -75,6 +75,7 @@ describe("Pi qualification receipt refresh", () => { accepted?: ReadonlySet; headRevision?: string; sourceParity?: boolean; + stagedPaths?: readonly string[]; } = {}, ): void { checkPiQualificationReceiptRefresh({ @@ -84,7 +85,10 @@ describe("Pi qualification receipt refresh", () => { args[0] === "merge-base" ? { status: 0, stdout: "base\n" } : args.includes("--name-only") - ? { status: 0, stdout: `${changedPaths.join("\0")}\0` } + ? { + status: 0, + stdout: `${(args.includes("--cached") ? (options.stagedPaths ?? []) : changedPaths).join("\0")}\0`, + } : args.includes("--quiet") ? args[3] === (options.headRevision ?? "HEAD") ? { status: options.sourceParity === false ? 1 : 0, stdout: "" } @@ -129,6 +133,14 @@ describe("Pi qualification receipt refresh", () => { ).not.toThrow(); }); + it("accepts a staged receipt refresh after a committed image source change", () => { + expect(() => + run(["protected/app/config.json"], { + stagedPaths: RECEIPTS.map(({ path: receiptPath }) => receiptPath), + }), + ).not.toThrow(); + }); + it("compares receipt parity against the exact PR head instead of a synthetic merge", () => { const headRevision = "d".repeat(40); expect(() => diff --git a/test/repository/plugin-vitest-project.test.ts b/test/repository/plugin-vitest-project.test.ts index 3c2e5a0ed03..3c3f5d7bd35 100644 --- a/test/repository/plugin-vitest-project.test.ts +++ b/test/repository/plugin-vitest-project.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { createRequire } from "node:module"; import path from "node:path"; @@ -74,10 +74,19 @@ describe("plugin Vitest project contract", () => { it("keeps plugin production and test TypeScript projects disjoint", () => { const productionFiles = listedTypeScriptFiles("nemoclaw/tsconfig.json"); const testFiles = listedTypeScriptFiles("nemoclaw/tsconfig.test.json"); + const typecheck = spawnSync("npm", ["--prefix", "nemoclaw", "run", "typecheck"], { + cwd: repositoryRoot, + encoding: "utf8", + }); + const typecheckOutput = [typecheck.stdout, typecheck.stderr].join("\n"); expect(productionFiles.some((file) => file.endsWith(".test.ts"))).toBe(false); expect(testFiles).toContain(path.join(repositoryRoot, "nemoclaw", "src", "register.test.ts")); expect(testFiles).toContain(path.join(repositoryRoot, "nemoclaw", "vitest.config.ts")); expect(testFiles).toContain(path.join(repositoryRoot, "nemoclaw", "vitest.project.ts")); + expect(typecheck.error).toBeUndefined(); + expect(typecheck.status, typecheckOutput).toBe(0); + expect(typecheckOutput).toContain("-p tsconfig.json"); + expect(typecheckOutput).toContain("-p tsconfig.test.json"); }); }); diff --git a/test/repository/prepare-ci-npm-install.test.ts b/test/repository/prepare-ci-npm-install.test.ts index aa6c1adc8f9..e3915b7c3bf 100644 --- a/test/repository/prepare-ci-npm-install.test.ts +++ b/test/repository/prepare-ci-npm-install.test.ts @@ -17,6 +17,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { parseSingleNpmPackResult } from "../helpers/npm-pack-result"; import { prepareCiNpmInstallWithReviewedConfig, @@ -91,11 +92,11 @@ function reviewedConfigSource( artifactDirectory: "artifacts/reviewed-npm-audit", exceptionFile: "ci/npm-audit-exceptions.json", lockedGraphs: [], - nodeVersion: "22.23.2", - npmArchiveSha256: "4bfba8a0c823024d1926ec9d97a37a00eb60fd2adf44b3d34a686fc32e8f51e4", + nodeVersion: "24.18.1", + npmArchiveSha256: "5dbb86c71d07a1957f2e90734092dd6a58bdcd9ebc2d8d41ca1c6e6a21d364e1", npmIntegrity: - "sha512-OnUGvKW3lJs/ooPKDKUNfz1UmMfF48YWbjNA20QdiWrCVnZaAPppOfHPnfGiPb+1lKIsxjKXQ4UAfDI7PcvLPg==", - npmVersion: "10.9.4", + "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw==", + npmVersion: "12.0.2", registryOrigin: "https://registry.npmjs.org/", schemaVersion: 2, severityThreshold: "high", @@ -147,15 +148,13 @@ function packedInstallFixture() { ); writeFileSync(join(packageRoot, "index.js"), "export {};\n"); rmSync(join(source.artifactDirectory, artifactName)); - const packed = JSON.parse( + const entry = parseSingleNpmPackResult( 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! }; @@ -360,7 +359,7 @@ describe("trusted OpenShell SDK archive preparation", () => { expect(stage.mock.calls[0]?.[0].archive.equals(archiveBytes)).toBe(true); }); - it("installs the reviewed archive offline after npm stages it", async () => { + it("installs the reviewed root archive offline after npm stages it", async () => { const { packageIdentity, source } = packedInstallFixture(); await prepareCiNpmInstallWithReviewedConfig( @@ -371,6 +370,7 @@ describe("trusted OpenShell SDK archive preparation", () => { "npm", [ "ci", + "--allow-remote=root", "--offline", "--ignore-scripts", "--no-audit", diff --git a/test/runtime/gateway/gateway-state-reconcile-2276.test.ts b/test/runtime/gateway/gateway-state-reconcile-2276.test.ts index 48f3c960f0e..b54c39ed612 100644 --- a/test/runtime/gateway/gateway-state-reconcile-2276.test.ts +++ b/test/runtime/gateway/gateway-state-reconcile-2276.test.ts @@ -315,6 +315,19 @@ beforeEach(() => { fs.mkdirSync(registryDir, { recursive: true }); fs.writeFileSync(installerInvocationsFile, ""); fs.writeFileSync(dockerInvocationsFile, ""); + // Image freshness has its own tests; this process fixture represents unchanged inputs. + fs.writeFileSync( + path.join(homeLocalBin, "git"), + `#!${process.execPath} +const { spawnSync } = require("node:child_process"); +const args = process.argv.slice(2); +if (args.includes("diff") && args.includes("--quiet")) process.exit(0); +const result = spawnSync("/usr/bin/git", args, { env: process.env, stdio: "inherit" }); +if (result.error) throw result.error; +process.exit(result.status ?? 1); +`, + { mode: 0o755 }, + ); fs.writeFileSync( path.join(homeLocalBin, "bash"), `#!${process.execPath} diff --git a/test/runtime/messaging/messaging-build-applier-integrity.test.ts b/test/runtime/messaging/messaging-build-applier-integrity.test.ts index 14b300d942e..2e48b45d83a 100644 --- a/test/runtime/messaging/messaging-build-applier-integrity.test.ts +++ b/test/runtime/messaging/messaging-build-applier-integrity.test.ts @@ -167,7 +167,7 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { const trace = fs.readFileSync(tracePath, "utf-8"); expect(trace).toContain("npm|view|@openclaw/slack@2026.7.1|dist.integrity"); expect(trace).toContain("npm|view|@openclaw/slack@2026.7.1|dist.tarball"); - expect(trace).toContain(`npm|pack|${OPENCLAW_SLACK_2026_7_1_TARBALL}|--pack-destination`); + expect(trace).toContain("npm|pack|@openclaw/slack@2026.7.1|--pack-destination"); expect(trace).toContain("openclaw|plugins|install|npm-pack:"); expect(trace).toContain("slack-2026.7.1.tgz|"); } finally { @@ -286,7 +286,7 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { expect(message).toContain("Actual: sha512-packed-drift"); const trace = fs.readFileSync(tracePath, "utf-8"); expect(trace).toContain("npm|view|@openclaw/slack@2026.7.1|dist.integrity"); - expect(trace).toContain(`npm|pack|${OPENCLAW_SLACK_2026_7_1_TARBALL}|--pack-destination`); + expect(trace).toContain("npm|pack|@openclaw/slack@2026.7.1|--pack-destination"); expect(trace).not.toContain("openclaw|plugins|install"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); @@ -341,7 +341,7 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { expect(result.stderr).not.toContain("../slack-2026.7.1.tgz"); const trace = fs.readFileSync(tracePath, "utf-8"); expect(trace).toContain("npm|view|@openclaw/slack@2026.7.1|dist.integrity"); - expect(trace).toContain(`npm|pack|${OPENCLAW_SLACK_2026_7_1_TARBALL}|--pack-destination`); + expect(trace).toContain("npm|pack|@openclaw/slack@2026.7.1|--pack-destination"); expect(trace).not.toContain("openclaw|plugins|install"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); diff --git a/test/runtime/messaging/messaging-build-applier.test.ts b/test/runtime/messaging/messaging-build-applier.test.ts index 667ec13ddd9..e5233169c9d 100644 --- a/test/runtime/messaging/messaging-build-applier.test.ts +++ b/test/runtime/messaging/messaging-build-applier.test.ts @@ -81,11 +81,11 @@ function fakeOpenClawPluginNpmPackScriptLines(): string[] { 'if [ "${1:-}" = "pack" ]; then', ' pack_dir="${4:-}";', ' case "${2:-}" in', - ` "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz") pack_file="discord-2026.7.1.tgz"; pack_integrity="\${OPENCLAW_DISCORD_INTEGRITY:-\${OPENCLAW_DISCORD_2026_7_1_INTEGRITY:-}}" ;;`, - ` "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz") pack_file="openclaw-weixin-2.4.3.tgz"; pack_integrity="\${TENCENT_WEIXIN_2_4_3_INTEGRITY:-}" ;;`, - ` "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz") pack_file="slack-2026.7.1.tgz"; pack_integrity="\${OPENCLAW_SLACK_INTEGRITY:-\${OPENCLAW_SLACK_2026_7_1_INTEGRITY:-}}" ;;`, - ` "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.7.1.tgz") pack_file="whatsapp-2026.7.1.tgz"; pack_integrity="\${OPENCLAW_WHATSAPP_2026_7_1_INTEGRITY:-}" ;;`, - ` "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz") pack_file="msteams-2026.7.1.tgz"; pack_integrity="\${OPENCLAW_MSTEAMS_2026_7_1_INTEGRITY:-}" ;;`, + ` "@openclaw/discord@2026.7.1") pack_file="discord-2026.7.1.tgz"; pack_integrity="\${OPENCLAW_DISCORD_INTEGRITY:-\${OPENCLAW_DISCORD_2026_7_1_INTEGRITY:-}}" ;;`, + ` "@tencent-weixin/openclaw-weixin@2.4.3") pack_file="openclaw-weixin-2.4.3.tgz"; pack_integrity="\${TENCENT_WEIXIN_2_4_3_INTEGRITY:-}" ;;`, + ` "@openclaw/slack@2026.7.1") pack_file="slack-2026.7.1.tgz"; pack_integrity="\${OPENCLAW_SLACK_INTEGRITY:-\${OPENCLAW_SLACK_2026_7_1_INTEGRITY:-}}" ;;`, + ` "@openclaw/whatsapp@2026.7.1") pack_file="whatsapp-2026.7.1.tgz"; pack_integrity="\${OPENCLAW_WHATSAPP_2026_7_1_INTEGRITY:-}" ;;`, + ` "@openclaw/msteams@2026.7.1") pack_file="msteams-2026.7.1.tgz"; pack_integrity="\${OPENCLAW_MSTEAMS_2026_7_1_INTEGRITY:-}" ;;`, " *) exit 1 ;;", " esac", ' test -n "$pack_dir"; test -n "$pack_integrity";', @@ -678,9 +678,7 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(applyMessagingBuildPhase(serializedPlan, "agent-install", env)).toEqual([]); const trace = fs.readFileSync(tracePath, "utf-8"); expect(trace).toContain("npm|view|@openclaw/discord@2026.7.1|dist.integrity"); - expect(trace).toContain( - "npm|pack|https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz|--pack-destination", - ); + expect(trace).toContain("npm|pack|@openclaw/discord@2026.7.1|--pack-destination"); expect(trace).toContain("plugins|install|npm-pack:"); expect(trace).toContain("discord-2026.7.1.tgz|ignore-scripts=true/true"); } finally { @@ -708,7 +706,7 @@ describe("messaging-build-applier.mts: agent-install", () => { " process.stdout.write('https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz\\n');", " process.exit(0);", "}", - "if (command === 'pack' && packageSpec === 'https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz') {", + "if (command === 'pack' && packageSpec === '@openclaw/msteams@2026.7.1') {", " const packFile = 'msteams-2026.7.1.tgz';", " fs.writeFileSync(path.join(destination, packFile), 'fake plugin tarball');", " process.stdout.write(JSON.stringify([{ filename: packFile, integrity: process.env.OPENCLAW_MSTEAMS_2026_7_1_INTEGRITY }]) + '\\n');", @@ -748,9 +746,7 @@ describe("messaging-build-applier.mts: agent-install", () => { const trace = fs.readFileSync(tracePath, "utf-8"); expect(trace).toContain("npm|view|@openclaw/msteams@2026.7.1|dist.integrity"); expect(trace).toContain("npm|view|@openclaw/msteams@2026.7.1|dist.tarball"); - expect(trace).toContain( - "npm|pack|https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz|--pack-destination", - ); + expect(trace).toContain("npm|pack|@openclaw/msteams@2026.7.1|--pack-destination"); expect(trace).toContain("openclaw|plugins|install|npm-pack:"); expect(trace).toContain("msteams-2026.7.1.tgz|"); expect(remediateReviewedArchive).toHaveBeenCalledWith( @@ -976,7 +972,7 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(trace).toContain(`npm|view|${packageSpec}|dist.integrity`); expect(trace).toContain(`npm|view|${packageSpec}|dist.tarball`); - expect(trace).toContain(`npm|pack|${tarballUrl}|--pack-destination`); + expect(trace).toContain(`npm|pack|${packageSpec}|--pack-destination`); expect(trace).toContain("plugins|install|npm-pack:"); expect(trace).toContain(`${archiveName}||||`); @@ -1033,9 +1029,7 @@ describe("messaging-build-applier.mts: agent-install", () => { const trace = fs.readFileSync(tracePath, "utf-8"); expect(trace).toContain("npm|view|@openclaw/slack@2026.7.1|dist.integrity"); expect(trace).toContain("npm|view|@openclaw/slack@2026.7.1|dist.tarball"); - expect(trace).toContain( - "npm|pack|https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz|--pack-destination", - ); + expect(trace).toContain("npm|pack|@openclaw/slack@2026.7.1|--pack-destination"); expect(trace).toContain("openclaw|plugins|install|npm-pack:"); expect(trace).toContain("slack-2026.7.1.tgz|"); expect(remediateReviewedArchive).toHaveBeenCalledWith( diff --git a/test/runtime/sandbox/sandbox-build-context.test.ts b/test/runtime/sandbox/sandbox-build-context.test.ts index cfe5d1b4922..760776bdb20 100644 --- a/test/runtime/sandbox/sandbox-build-context.test.ts +++ b/test/runtime/sandbox/sandbox-build-context.test.ts @@ -124,7 +124,11 @@ describe("sandbox build context staging", () => { ); writeFixture( path.join("ci", "reviewed-npm-audit.json"), - `${JSON.stringify({ npmVersion: "10.9.4" })}\n`, + `${JSON.stringify({ + npmIntegrity: + "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw==", + npmVersion: "12.0.2", + })}\n`, ); for (const runtimeName of [ "managed-image-messaging-runtime", @@ -271,6 +275,7 @@ describe("sandbox build context staging", () => { writeFixture(path.join("src", "lib", relativePath)); } writeFixture(path.join("scripts", "patch-openclaw-tool-catalog.mts")); + writeFixture(path.join("scripts", "lib", "patch-openclaw-npm12-pack-json.mts")); writeFixture(path.join("scripts", "patch-openclaw-chat-send.mts")); writeFixture(path.join("scripts", "patch-openclaw-mcp-npx.mts")); writeFixture(path.join("scripts", "patch-openclaw-mcp-reliability.mts")); @@ -287,6 +292,7 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "upgrade-bundled-npm.mts")); writeFixture(path.join("scripts", "verify-wechat-runtime-lock.mts")); writeFixture(path.join("scripts", "lib", "reviewed-npm-archive.mts"), "fixture\n", 0o700); + writeFixture(path.join("scripts", "lib", "reviewed-npm-identity.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "bundled-npm-package.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "seed-reviewed-npm-cache.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "reviewed-npm-audit.mts"), "fixture\n", 0o700); @@ -515,16 +521,21 @@ describe("sandbox build context staging", () => { } } - function expectStagedScriptModes(buildCtx: string) { + function expectStagedScriptModes(buildCtx: string, sourceRoot: string) { const stagedScripts = path.join(buildCtx, "scripts"); const stagedLib = path.join(stagedScripts, "lib"); const stagedHelper = path.join(stagedLib, "reviewed-npm-archive.mts"); + const stagedIdentity = path.join(stagedLib, "reviewed-npm-identity.mts"); const stagedPackageHelper = path.join(stagedLib, "bundled-npm-package.mts"); const stagedSeed = path.join(stagedLib, "seed-reviewed-npm-cache.mts"); expect((fs.statSync(stagedScripts).mode & 0o777).toString(8)).toBe("755"); expect((fs.statSync(stagedLib).mode & 0o777).toString(8)).toBe("755"); expect((fs.statSync(stagedHelper).mode & 0o777).toString(8)).toBe("755"); + expect(fs.readFileSync(stagedIdentity, "utf8")).toBe( + fs.readFileSync(path.join(sourceRoot, "scripts", "lib", "reviewed-npm-identity.mts"), "utf8"), + ); + expect((fs.statSync(stagedIdentity).mode & 0o777).toString(8)).toBe("755"); expect((fs.statSync(stagedPackageHelper).mode & 0o777).toString(8)).toBe("755"); expect((fs.statSync(stagedSeed).mode & 0o777).toString(8)).toBe("755"); } @@ -691,7 +702,7 @@ describe("sandbox build context staging", () => { const previousUmask = process.umask(0o077); try { const { buildCtx } = stageOptimizedSandboxBuildContext(sourceRoot, tmpDir); - expectStagedScriptModes(buildCtx); + expectStagedScriptModes(buildCtx, sourceRoot); } finally { process.umask(previousUmask); } @@ -712,7 +723,7 @@ describe("sandbox build context staging", () => { const previousUmask = process.umask(0o077); try { const { buildCtx } = stageLegacySandboxBuildContext(sourceRoot, tmpDir); - expectStagedScriptModes(buildCtx); + expectStagedScriptModes(buildCtx, sourceRoot); } finally { process.umask(previousUmask); } diff --git a/test/runtime/sandbox/sandbox-provisioning.test.ts b/test/runtime/sandbox/sandbox-provisioning.test.ts index 14e3c9a5dda..3b55883625b 100644 --- a/test/runtime/sandbox/sandbox-provisioning.test.ts +++ b/test/runtime/sandbox/sandbox-provisioning.test.ts @@ -18,6 +18,7 @@ import { dockerRunCommandBetween, runDockerShell, runLoggedDockerShell, + shellCommandSegmentBetween, } from "../../helpers/dockerfile-run-shell"; const ROOT = path.resolve(import.meta.dirname, "../../.."); @@ -1035,10 +1036,15 @@ describe("Hermes sandbox provisioning", () => { const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-path-")); const manifestHermes = path.join(tmp, "usr", "local", "bin", "hermes"); - const command = dockerRunCommandBetween( + const finalization = dockerRunCommandBetween( dockerfile, - "# Keep the final image contract explicit", - "# Harden: remove unnecessary build tools", + "# Keep the inherited CLI, Python environment, and dashboard explicit", + "# Hermes' WeChat adapter", + ); + const command = shellCommandSegmentBetween( + finalization, + 'hermes_path="$(command -v hermes', + "chmod -R a+rX /opt/hermes/.venv", ).replaceAll("/usr/local/bin/hermes", manifestHermes); const scriptPath = path.join(tmp, "run.sh"); try { @@ -1202,10 +1208,15 @@ describe("Hermes sandbox provisioning", () => { fs.mkdirSync(cachePath, { recursive: true }); fs.writeFileSync(path.join(cachePath, "build-only-cache"), "unused after image assembly\n"); }); - const command = dockerRunCommandBetween( + const finalization = dockerRunCommandBetween( dockerfile, - "# Published base images can lag Dockerfile.base", - "# Harden: remove unnecessary build tools", + "# Keep the inherited CLI, Python environment, and dashboard explicit", + "# Hermes' WeChat adapter", + ); + const command = shellCommandSegmentBetween( + finalization, + "if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]", + "dpkg-query -W", ) .replaceAll("/opt/hermes", hermesRoot) .replaceAll("/root/.npm", path.join(rootCache, "npm")) diff --git a/test/security/fetch-guard-patch-regression.test.ts b/test/security/fetch-guard-patch-regression.test.ts index d43c1ed8df5..ca19aea54e8 100644 --- a/test/security/fetch-guard-patch-regression.test.ts +++ b/test/security/fetch-guard-patch-regression.test.ts @@ -374,7 +374,7 @@ describe("fetch-guard patch regression guard", () => { "newer than reviewed target " + CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION, ); expect(newer.calls).not.toContain( - `npm pack https://registry.npmjs.org/openclaw/-/openclaw-${CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION}.tgz --pack-destination`, + `npm pack openclaw@${CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION} --pack-destination`, ); expect(newer.calls).not.toContain("npm install -g --no-audit --no-fund --no-progress "); }); diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index be30b8b2cf4..c8ddcc85080 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -188,6 +188,9 @@ describe("mcporter image supply-chain controls", () => { flattenedContents.includes( "COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/bundled-npm-package.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/openclaw-npm-remediation.mts /scripts/lib/", ) || + flattenedContents.includes( + "COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/bundled-npm-package.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/patch-bundled-npm-ip-address.mts scripts/lib/reviewed-npm-identity.mts /scripts/lib/", + ) || contents.includes( "COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts", ), @@ -206,6 +209,8 @@ describe("mcporter image supply-chain controls", () => { expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false", ); + expect(auditContents).not.toContain("--legacy-audit"); + expect(auditContents).not.toContain("--legacy-npmjs"); expect(expectedReviewedNpmVersion).toMatch(/^[0-9]+\.[0-9]+\.[0-9]+$/); expect(auditContents).not.toContain("/scripts/lib/npm-audit-receipt.mts"); expect(auditContents).toContain("sha256sum --check --status"); diff --git a/test/support/managed-bootstrap-image-contract.ts b/test/support/managed-bootstrap-image-contract.ts index 38ba987a8f7..40208679ca9 100644 --- a/test/support/managed-bootstrap-image-contract.ts +++ b/test/support/managed-bootstrap-image-contract.ts @@ -30,7 +30,7 @@ const COMPILER_FLAGS = [ ] as const; const MANAGED_BOOTSTRAP_BUILDER_IMAGE = - "node:22-trixie@sha256:a566dd560283ae5615c8bb86b58fa8a1b6f3c82b492473a061672416266625da"; + "node:24.18.1-trixie@sha256:dfa43abae25030f5456007944f725379d1f5be4bb723bd501ac39ac72ffa5474"; const DISCOVERY_RUNTIME_ROOT = "/usr/local/lib/nemoclaw/mcp-tool-discovery-runtime"; const DISCOVERY_RUNTIME_PATH = `${DISCOVERY_RUNTIME_ROOT}/mcp-tool-discovery.mjs`; const DISCOVERY_EXPECTED_CONTRACT = diff --git a/test/support/reviewed-npm-bootstrap.ts b/test/support/reviewed-npm-bootstrap.ts index 0c953eba242..435de0efdbd 100644 --- a/test/support/reviewed-npm-bootstrap.ts +++ b/test/support/reviewed-npm-bootstrap.ts @@ -10,7 +10,7 @@ import path from "node:path"; const REPO_ROOT = path.join(import.meta.dirname, "../.."); const BOOTSTRAP = path.join( REPO_ROOT, - ".github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh", + ".github/actions/setup-reviewed-npm/verify-and-install-npm.sh", ); export type ReviewedNpmIdentity = Record< @@ -23,6 +23,7 @@ type FixtureOptions = { command?: string; configFile?: (root: string) => string; environment?: (root: string) => NodeJS.ProcessEnv; + installedVersion?: string; mutateIdentity?: (identity: ReviewedNpmIdentity) => ReviewedNpmIdentity; prepare?: (root: string) => void; }; @@ -84,6 +85,7 @@ case "$1" in exit 2 ;; install) : > "$NEMOCLAW_TEST_INSTALL_MARKER" ;; + --version) printf '%s\n' "$NEMOCLAW_TEST_INSTALLED_VERSION" ;; *) exit 2 ;; esac `, @@ -103,6 +105,7 @@ esac ...options.environment?.(root), NEMOCLAW_TEST_ARCHIVE_FILE: archiveFile, NEMOCLAW_TEST_INSTALL_MARKER: installMarker, + NEMOCLAW_TEST_INSTALLED_VERSION: options.installedVersion ?? "12.0.2", NEMOCLAW_TEST_NPM_LOG: npmLog, PATH: `${bin}:${process.env.PATH ?? ""}`, RUNNER_TEMP: root, diff --git a/tools/e2e/base-image-publication.mts b/tools/e2e/base-image-publication.mts index 5c431e3fbde..436931ab2c1 100644 --- a/tools/e2e/base-image-publication.mts +++ b/tools/e2e/base-image-publication.mts @@ -30,6 +30,7 @@ const SHA_PATTERN = /^[0-9a-f]{40}$/u; const SAFE_PATH_PATTERN = /^[A-Za-z0-9._/-]+$/u; const REVIEWED_PATH_GLOBS = new Map([ [".github/actions/ci-reviewed-npm-audit/**", /^[.]github\/actions\/ci-reviewed-npm-audit\/.+$/u], + [".github/actions/setup-reviewed-npm/**", /^[.]github\/actions\/setup-reviewed-npm\/.+$/u], [ ".github/actions/publish-managed-image-digest/**", /^[.]github\/actions\/publish-managed-image-digest\/.+$/u, diff --git a/tools/e2e/hermes-gpu-startup-workflow-boundary.mts b/tools/e2e/hermes-gpu-startup-workflow-boundary.mts index a0f477a4e5b..a166f1eb389 100644 --- a/tools/e2e/hermes-gpu-startup-workflow-boundary.mts +++ b/tools/e2e/hermes-gpu-startup-workflow-boundary.mts @@ -274,18 +274,21 @@ if ! @run restore`; const restoreI = steps.findIndex((step) => step.name === CLI_ARTIFACT_RESTORE_STEP); const ni = steps.findIndex((step) => step.name === "Reassert trusted Node runtime"); const node = steps[ni]; - const staleDockerRestore = steps[ni + 1]; - const nativePodmanRuntime = steps[ni + 2]; + const reviewedNpm = steps[ni + 1]; + const staleDockerRestore = steps[ni + 2]; + const nativePodmanRuntime = steps[ni + 3]; if ( runStep.shell !== BASH || !trustedEnv(runStep) || pi < 0 || restoreI <= pi || ni !== restoreI + 1 || - ni + 3 !== steps.indexOf(runStep) || + ni + 4 !== steps.indexOf(runStep) || node?.uses !== "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020" || !trustedEnv(node) || asRecord(node?.env).NODE_OPTIONS !== "" || + reviewedNpm?.name !== "Reinstall reviewed npm after Node reassertion" || + reviewedNpm?.uses !== E2E_ACTION_PROVENANCE.reviewedNpmSetup.reference || staleDockerRestore?.name !== "Recover Docker CLI before native Podman E2E" || staleDockerRestore?.uses !== E2E_ACTION_PROVENANCE.restoreNativePodmanRuntime.reference || staleDockerRestore?.if !== "${{ matrix.runtime_provider == 'podman' }}" || diff --git a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts index 866a32b361b..473009af679 100644 --- a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts +++ b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts @@ -248,7 +248,7 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR const prepare = requireStep(errors, workflowSteps, "Prepare E2E workspace"); if ( prepare?.uses !== - "NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75" + "NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c" ) { errors.push(`${JOB_ID} must pin the trusted E2E preparation action`); } diff --git a/tools/e2e/mcp-dev-workflow-boundary-digests.mts b/tools/e2e/mcp-dev-workflow-boundary-digests.mts index b3567218fe1..ead349f5b12 100644 --- a/tools/e2e/mcp-dev-workflow-boundary-digests.mts +++ b/tools/e2e/mcp-dev-workflow-boundary-digests.mts @@ -10,9 +10,9 @@ export const MCP_DEV_JOB_EXECUTION_CONTEXT_SHA256 = export const MCP_DEV_TRUSTED_NODE_SETUP_CONTENT_SHA256 = "69ce3f37667cc6b5301fbb76074d3575da1cd45eb90b164dc407a17137a2d273"; export const MCP_DEV_TRUSTED_PREFIX_CONTENT_SHA256 = - "13ef92da728304746efe1060102e392271a002e382d4ca540bcc38bbde838201"; + "a99862977077321de6f23184485a71e8dbbc2a40afb6d69aa39c82c38410646d"; export const MCP_DEV_POST_INSTALL_TRANSITION_CONTENT_SHA256 = - "9fae24e2a586143abeb36916b556e924d268300d03375bfe6936c5ad97aab9d5"; + "be192ff3c94b205d6d4b70030bf7ea97702d38c5c7cceec402503892b2c0920e"; export function contentSha256(value: unknown): string { return createHash("sha256") diff --git a/tools/e2e/mcp-workflow-boundary.mts b/tools/e2e/mcp-workflow-boundary.mts index 997015af171..c29fd7f4c03 100644 --- a/tools/e2e/mcp-workflow-boundary.mts +++ b/tools/e2e/mcp-workflow-boundary.mts @@ -49,9 +49,10 @@ const DEV_ARTIFACT_DOWNLOAD_ACTION = const DEV_ARTIFACT_TRUSTED_CHECKOUT_NAME = "Checkout trusted OpenShell dev tooling"; const DEV_ARTIFACT_TRUSTED_CHECKOUT = ".trusted-openshell-dev-artifact"; const DEV_ARTIFACT_COPY_HELPER = ".github/scripts/copy-openshell-dev-asset.sh"; -const DEV_ARTIFACT_TRUSTED_PATHS = +const DEV_ARTIFACT_TOOL_PATHS = "scripts/install-openshell.sh\ntools/e2e/openshell-dev-artifact.mts\n"; -const DEV_ARTIFACT_SHARD_TRUSTED_PATHS = `${DEV_ARTIFACT_COPY_HELPER}\n.github/scripts/docker-auth-cleanup.sh\n${DEV_ARTIFACT_TRUSTED_PATHS}`; +const DEV_ARTIFACT_TRUSTED_PATHS = `.github/actions/setup-reviewed-npm\nci/reviewed-npm-audit.json\nscripts/lib/reviewed-npm-audit.mts\n${DEV_ARTIFACT_TOOL_PATHS}`; +const DEV_ARTIFACT_SHARD_TRUSTED_PATHS = `${DEV_ARTIFACT_COPY_HELPER}\n.github/scripts/docker-auth-cleanup.sh\n${DEV_ARTIFACT_TOOL_PATHS}`; const DEV_ARTIFACT_TRUSTED_TOOL = `\${{ github.workspace }}/${DEV_ARTIFACT_TRUSTED_CHECKOUT}/${DEV_ARTIFACT_TOOL}`; const DEV_ARTIFACT_TRUSTED_COPY_HELPER = `\${{ github.workspace }}/${DEV_ARTIFACT_TRUSTED_CHECKOUT}/${DEV_ARTIFACT_COPY_HELPER}`; const DEV_ARTIFACT_TRUSTED_INSTALLER = `\${{ github.workspace }}/${DEV_ARTIFACT_TRUSTED_CHECKOUT}/scripts/install-openshell.sh`; @@ -575,6 +576,7 @@ function validateJobExecution( const devCleanup = namedStep(job, DEV_DOCKER_CLEANUP_NAME); const dockerAuth = namedStep(job, "Authenticate to Docker Hub"); const prepare = namedStep(job, "Prepare E2E workspace"); + const reviewedNpm = namedStep(job, "Install reviewed npm for trusted OpenShell verification"); const trustedNodeSetup = namedStep(job, DEV_TRUSTED_NODE_SETUP_NAME); const trustedNodeSetupIndex = steps.indexOf(trustedNodeSetup); const dockerAuthIndex = steps.indexOf(dockerAuth); @@ -583,18 +585,26 @@ function validateJobExecution( const installIndex = steps.indexOf(install); const restoreCliIndex = steps.indexOf(restoreCli); const trustedInstallSequence = [ + reviewedNpm, trustedCheckout, restoreArtifact, verifyArtifact, devCleanup, install, ]; + requireEqual( + errors, + reviewedNpm.uses, + "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2", + "mcp-bridge-dev must install reviewed npm from the immutable trusted action", + ); if ( dockerAuthIndex !== trustedNodeSetupIndex + 2 || - trustedCheckoutIndex !== dockerAuthIndex + 1 || + steps.indexOf(reviewedNpm) !== dockerAuthIndex + 1 || + trustedCheckoutIndex !== dockerAuthIndex + 2 || prepareIndex !== installIndex + 1 || restoreCliIndex !== prepareIndex + 1 || - trustedInstallSequence.some((step, offset) => steps[trustedCheckoutIndex + offset] !== step) + trustedInstallSequence.some((step, offset) => steps[dockerAuthIndex + 1 + offset] !== step) ) { errors.push( "mcp-bridge-dev must complete trusted Node.js setup, Docker auth, artifact verification, credential revocation, and installation before candidate dependency preparation and CLI restore", diff --git a/tools/e2e/openclaw-plugin-runtime-exdev-workflow-boundary.mts b/tools/e2e/openclaw-plugin-runtime-exdev-workflow-boundary.mts index f5da99f81b1..03458e0d64c 100644 --- a/tools/e2e/openclaw-plugin-runtime-exdev-workflow-boundary.mts +++ b/tools/e2e/openclaw-plugin-runtime-exdev-workflow-boundary.mts @@ -25,7 +25,7 @@ const JOB_CONTRACTS = [ runName: "Run OpenClaw cross-device plugin lifecycle live test", uploadName: "Upload OpenClaw cross-device plugin lifecycle artifacts", builderImage: - "node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c", + "node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc", }, ] as const; diff --git a/tools/e2e/openshell-gateway-auth-contract-workflow-boundary.mts b/tools/e2e/openshell-gateway-auth-contract-workflow-boundary.mts index b84e6905ba8..102ebb17d1a 100644 --- a/tools/e2e/openshell-gateway-auth-contract-workflow-boundary.mts +++ b/tools/e2e/openshell-gateway-auth-contract-workflow-boundary.mts @@ -18,7 +18,7 @@ const OPENSHELL_INSTALL_RUN = const FULL_SHA_ACTION = /^[^\s@]+@[0-9a-f]{40}$/u; const TRUSTED_PLAN_CONDITION = `\${{ contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), '${JOB_NAME}') }}`; const GATEWAY_PROBE_IMAGE = - "node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c"; + "node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc"; const ARTIFACT_SAFETY_GATED_UPLOAD = "${{ always() && steps.artifact_safety.outcome == 'success' && steps.artifact_safety.outputs.approved_path != '' }}"; const APPROVED_ARTIFACT_PATH = "${{ steps.artifact_safety.outputs.approved_path }}"; diff --git a/tools/e2e/operations-workflow-boundary.mts b/tools/e2e/operations-workflow-boundary.mts index 26b4c376f44..0fab8c72d60 100644 --- a/tools/e2e/operations-workflow-boundary.mts +++ b/tools/e2e/operations-workflow-boundary.mts @@ -748,6 +748,10 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", with: {}, }, + { + name: "Install reviewed npm", + uses: "./.github/actions/setup-reviewed-npm", + }, { id: "select_pr_source", name: "Resolve exact PR managed-image publication", diff --git a/tools/e2e/prepare-e2e-workflow-boundary.mts b/tools/e2e/prepare-e2e-workflow-boundary.mts index 64d3f1db036..c28998c2d49 100644 --- a/tools/e2e/prepare-e2e-workflow-boundary.mts +++ b/tools/e2e/prepare-e2e-workflow-boundary.mts @@ -78,7 +78,11 @@ export function validatePrepareE2eAction(actionPath = DEFAULT_ACTION_PATH): stri { name: "Set up Node", uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", - with: { "node-version": 22, cache: "npm" }, + with: { "node-version": "24.18.1", cache: "npm" }, + }, + { + name: "Install reviewed npm", + uses: "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2", }, { name: "Install root dependencies", @@ -93,7 +97,9 @@ export function validatePrepareE2eAction(actionPath = DEFAULT_ACTION_PATH): stri }, ]; if (!isDeepStrictEqual(runs.steps, expectedSteps)) { - errors.push("prepare-e2e must pin Node 22, run npm ci, and conditionally build the CLI"); + errors.push( + "prepare-e2e must pin reviewed Node and npm, run npm ci, and conditionally build the CLI", + ); } return errors; } @@ -202,7 +208,12 @@ export function validatePrepareE2eInvocations(workflow: WorkflowRecord): string[ errors.push(`${jobName} prepare-e2e invocation must not override its canonical contract`); } - for (const retiredStep of ["Set up Node", "Install root dependencies", "Build CLI"]) { + for (const retiredStep of [ + "Set up Node", + "Install reviewed npm", + "Install root dependencies", + "Build CLI", + ]) { if (jobSteps.some((step) => step.name === retiredStep)) { errors.push(`${jobName} must not duplicate prepare-e2e step '${retiredStep}'`); } diff --git a/tools/e2e/standard-profile-workflow-boundary.mts b/tools/e2e/standard-profile-workflow-boundary.mts index 7e128d72878..8fe2f11c5aa 100644 --- a/tools/e2e/standard-profile-workflow-boundary.mts +++ b/tools/e2e/standard-profile-workflow-boundary.mts @@ -568,7 +568,7 @@ function validateProfileWorkflow(errors: string[], profile: WorkflowRecord): voi stoppedStateHelper.shell !== EXECUTION_PLAN_SHELL || !isDeepStrictEqual(record(stoppedStateHelper.env), { CLEANUP_IMAGE: - "node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c", + "node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc", RUNTIME_PROVIDER: "${{ inputs.runtime_provider }}", }) || !stoppedStateHelperRun.includes('docker pull "$CLEANUP_IMAGE"') || diff --git a/tools/e2e/target-catalogue.mts b/tools/e2e/target-catalogue.mts index 25cfe147f1d..82a40b5d467 100644 --- a/tools/e2e/target-catalogue.mts +++ b/tools/e2e/target-catalogue.mts @@ -271,11 +271,13 @@ const SKILL_LIFECYCLE_OWNING_PATHS = [ const PI_IMAGE_SOURCE_OWNING_PATHS = [ ".dockerignore", "agents/pi/", + "ci/reviewed-npm-audit.json", "nemoclaw-blueprint/", "scripts/lib/bundled-npm-package.mts", "scripts/lib/entrypoint-env-wrapper.sh", "scripts/lib/patch-bundled-npm-ip-address.mts", "scripts/lib/reviewed-npm-archive.mts", + "scripts/lib/reviewed-npm-identity.mts", "scripts/lib/sandbox-rlimits.sh", "scripts/managed-bootstrap-entrypoint.c", "scripts/managed-bootstrap-trampoline.sh", diff --git a/tools/e2e/workflow-boundary-policy.mts b/tools/e2e/workflow-boundary-policy.mts index f0aeffd199f..b75e838bf2a 100644 --- a/tools/e2e/workflow-boundary-policy.mts +++ b/tools/e2e/workflow-boundary-policy.mts @@ -2,10 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 export const E2E_ACTION_PROVENANCE = { + reviewedNpmSetup: { + reference: + "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2", + }, prepareWorkspace: { reference: - "NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75", - contentSha256: "1283c2eadfbc38ccb3b795684ba5ced9c89ae2040fffbb6b81854a9d1926802b", + "NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c", + contentSha256: "4458b3491e5e01097db99a212c4a7bf5ae0cc62cdeda7fef8e3862ed572d2c2b", }, nativePodmanRuntime: { reference: diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index aa3b9d09fa7..565a197ed96 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -1919,6 +1919,10 @@ function validateJetsonControllerBoundary(errors: string[], jobs: WorkflowRecord } else { requireFullShaAction(errors, setupNode, "jetson-nvmap-gpu Node setup"); } + const setupNpm = namedStep(steps, "Install reviewed npm"); + if (setupNpm?.uses !== E2E_ACTION_PROVENANCE.reviewedNpmSetup.reference) { + errors.push("jetson-nvmap-gpu controller must install reviewed npm immutably"); + } const dispatch = namedStep(steps, "Dispatch exact commit to Jetson through operator backend"); if ( dispatch?.run !== "node --no-warnings tools/e2e/jetson-dispatch-client.mts" || @@ -1945,9 +1949,9 @@ function validateJetsonControllerBoundary(errors: string[], jobs: WorkflowRecord ) { errors.push("jetson-nvmap-gpu controller must upload its bounded dispatch artifact"); } - if (steps.length !== 4) { + if (steps.length !== 5 || steps.indexOf(setupNpm ?? {}) !== steps.indexOf(setupNode ?? {}) + 1) { errors.push( - "jetson-nvmap-gpu controller must contain only checkout, Node setup, dispatch, and upload", + "jetson-nvmap-gpu controller must contain only checkout, Node/npm setup, dispatch, and upload", ); } } @@ -2576,6 +2580,11 @@ function validateTrustedE2ePlannerBoundary( generateSteps, "Install trusted E2E planner dependencies", ); + const trustedNpmInstall = requireStep( + errors, + generateSteps, + "Install reviewed npm for trusted E2E planning", + ); requireFullShaAction(errors, trustedPlannerCheckout, "trusted E2E planner checkout"); if ( !isDeepStrictEqual(asRecord(trustedPlannerCheckout?.with), { @@ -2591,6 +2600,9 @@ function validateTrustedE2ePlannerBoundary( if (Object.keys(asRecord(trustedPlannerSetup?.with)).some((key) => key !== "node-version")) { errors.push("trusted E2E planner must not enable additional Node setup inputs"); } + if (trustedNpmInstall?.uses !== E2E_ACTION_PROVENANCE.reviewedNpmSetup.reference) { + errors.push("trusted E2E planner must install reviewed npm from an immutable action"); + } if (trustedPlannerInstall?.run !== "npm ci --ignore-scripts --no-audit --no-fund") { errors.push("trusted E2E planner dependencies must install without lifecycle scripts"); } @@ -2598,6 +2610,7 @@ function validateTrustedE2ePlannerBoundary( ? generateSteps.indexOf(trustedPlannerCheckout) : -1; const trustedSetupIndex = trustedPlannerSetup ? generateSteps.indexOf(trustedPlannerSetup) : -1; + const trustedNpmIndex = trustedNpmInstall ? generateSteps.indexOf(trustedNpmInstall) : -1; const trustedInstallIndex = trustedPlannerInstall ? generateSteps.indexOf(trustedPlannerInstall) : -1; @@ -2606,7 +2619,8 @@ function validateTrustedE2ePlannerBoundary( if ( trustedPlannerIndex < 0 || trustedSetupIndex <= trustedPlannerIndex || - trustedInstallIndex <= trustedSetupIndex || + trustedNpmIndex <= trustedSetupIndex || + trustedInstallIndex <= trustedNpmIndex || generateIndex <= trustedInstallIndex || candidateCheckoutIndex <= generateIndex ) { diff --git a/tools/wsl/ci-helper.ps1 b/tools/wsl/ci-helper.ps1 index c9ac607b4b8..9d825ef45be 100644 --- a/tools/wsl/ci-helper.ps1 +++ b/tools/wsl/ci-helper.ps1 @@ -302,15 +302,17 @@ function Install-WslUbuntuDependencies { function Get-WslNodeInstallScript { return @' set -euo pipefail -node_version="22.23.2" +node_version="24.18.1" +npm_version="12.0.2" +expected_npm_integrity="sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw==" case "$(uname -m)" in x86_64) node_arch="x64" - node_sha256="d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307" + node_sha256="d6c664df3f3f61458e8c277585571328522d705166723a7c7823a9253a4d15a0" ;; aarch64 | arm64) node_arch="arm64" - node_sha256="fff4078c5def658577f92c88db7db3bc0072924bfb93fe52c1e744a54e94abb8" + node_sha256="7201e3a09dc825bac57867c81913e2b8f0ef87d04cb9082af4cda82f6ff3d88c" ;; *) echo "Unsupported Node.js architecture: $(uname -m)" >&2 @@ -332,6 +334,29 @@ printf '%s %s\n' "$node_sha256" "$archive" | sha256sum --check --status || { } tar --extract --xz --file "$archive" --directory /usr/local --strip-components=1 test "$(node --version)" = "v${node_version}" +env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN \ + npm pack "npm@${npm_version}" \ + --pack-destination "$temp_dir" \ + --userconfig /dev/null \ + --registry https://registry.npmjs.org/ \ + --ignore-scripts --no-audit --no-fund >/dev/null +npm_archive="$temp_dir/npm-${npm_version}.tgz" +actual_npm_integrity="sha512-$( + node -e ' + const fs = require("node:fs"); + const crypto = require("node:crypto"); + process.stdout.write(crypto.createHash("sha512").update(fs.readFileSync(process.argv[1])).digest("base64")); + ' "$npm_archive" +)" +test "$actual_npm_integrity" = "$expected_npm_integrity" || { + echo "npm@${npm_version} archive integrity verification failed" >&2 + exit 1 +} +env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN \ + npm install --global "$npm_archive" \ + --userconfig /dev/null \ + --ignore-scripts --no-audit --no-fund --offline +test "$(npm --version)" = "$npm_version" node --version npm --version '@