From 470a5417558c65260d59cdb5eabb01d35834535e Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 4 Sep 2026 13:24:49 -0700 Subject: [PATCH 01/30] chore(deps): standardize node 24 and npm 12 Signed-off-by: Carlos Villela --- .../actions/ci-cli-coverage-merge/action.yaml | 5 +- .../actions/ci-cli-coverage-shard/action.yaml | 5 +- .../actions/ci-compile-artifacts/action.yaml | 5 +- .../ci-installer-integration/action.yaml | 5 +- .../actions/ci-plugin-coverage/action.yaml | 5 +- .../actions/ci-reviewed-npm-audit/action.yaml | 2 +- .../verify-and-install-npm.sh | 6 + .github/actions/ci-static-checks/action.yaml | 5 +- .github/actions/prepare-e2e/action.yaml | 5 +- .../actions/setup-reviewed-npm/action.yaml | 33 +++++ .../workflows/candidate-compatibility.yaml | 15 ++- .github/workflows/code-scanning.yaml | 7 +- .github/workflows/commit-lint.yaml | 5 +- .github/workflows/docs-cli-parity-pr.yaml | 5 +- .github/workflows/docs-preview-pr.yaml | 5 +- .github/workflows/docs-publish-public.yaml | 5 +- .github/workflows/docs-publish-staging.yaml | 10 +- .github/workflows/e2e-main-retry.yaml | 10 +- .github/workflows/e2e.yaml | 67 ++++++++-- .github/workflows/hosted-runner-recovery.yaml | 5 +- .github/workflows/installer-hash-check.yaml | 5 +- .../issue-9880-staging-reproduction.yaml | 7 +- .github/workflows/llama-cpp-image.yaml | 5 +- .github/workflows/main.yaml | 10 +- .github/workflows/managed-images.yaml | 27 +++- ...ative-runtime-qualification-collector.yaml | 7 +- .../workflows/openshell-sdk-package-pr.yaml | 7 +- .github/workflows/platform-vitest-main.yaml | 10 +- .github/workflows/podman-cpu-proof.yaml | 10 +- .github/workflows/portable-profile-e2e.yaml | 5 +- .github/workflows/post-merge-docs.yaml | 10 +- .../workflows/pr-merge-conflict-fixer.yaml | 15 ++- .github/workflows/pr-review-advisor.yaml | 15 ++- .github/workflows/pr-self-hosted.yaml | 10 +- .github/workflows/pr.yaml | 19 ++- .github/workflows/release-latest-tag.yaml | 5 +- .github/workflows/sandbox-images-and-e2e.yaml | 14 ++- Dockerfile | 18 +-- Dockerfile.base | 18 +-- agents/hermes/Dockerfile | 2 +- agents/hermes/Dockerfile.base | 40 ++---- agents/langchain-deepagents-code/Dockerfile | 2 +- .../langchain-deepagents-code/Dockerfile.base | 10 +- agents/pi/Dockerfile | 2 +- agents/pi/Dockerfile.base | 10 +- ci/reviewed-npm-audit.json | 6 +- ci/source-shape-test-budget.json | 10 ++ scripts/lib/patch-bundled-npm-ip-address.mts | 2 +- scripts/lib/seed-reviewed-npm-cache.mts | 4 +- scripts/patch-bundled-npm-brace-expansion.mts | 2 +- scripts/patch-bundled-npm-tar.mts | 2 +- scripts/upgrade-bundled-npm.mts | 16 +-- .../dockerfile-node-runtime-refresh.test.ts | 4 +- .../stopped-sandbox-state-cleanup.ts | 2 +- src/lib/sandbox/privileged-exec.test.ts | 2 +- .../hosted-runner-recovery-workflow.test.ts | 2 +- .../reviewed-npm-audit-workflow.test.ts | 28 +++-- test/e2e/live/messaging-providers-helpers.ts | 2 +- test/e2e/live/openshell-gateway-auth-probe.ts | 2 +- ...me-qualification-producer-workflow.test.ts | 2 +- .../patch-bundled-npm-brace-expansion.test.ts | 4 +- test/install/patch-bundled-npm-tar.test.ts | 14 ++- test/install/seed-reviewed-npm-cache.test.ts | 2 +- test/install/upgrade-bundled-npm.test.ts | 21 +++- ...race-expansion-dockerfile-contract.test.ts | 2 +- ...npm-ip-address-dockerfile-contract.test.ts | 2 +- .../node-tar-dockerfile-contract.test.ts | 116 ++++++++++++++++-- .../github-actions-node-npm-invariant.test.ts | 72 +++++++++++ .../repository/prepare-ci-npm-install.test.ts | 4 +- .../managed-bootstrap-image-contract.ts | 2 +- 70 files changed, 627 insertions(+), 191 deletions(-) create mode 100644 .github/actions/setup-reviewed-npm/action.yaml create mode 100644 test/repository/github-actions-node-npm-invariant.test.ts diff --git a/.github/actions/ci-cli-coverage-merge/action.yaml b/.github/actions/ci-cli-coverage-merge/action.yaml index 6e8bb427c81..fe8becffe28 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: ./.github/actions/setup-reviewed-npm + - 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 ea642e0d58d..f8d3a6790ca 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: ./.github/actions/setup-reviewed-npm + - 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 37d74ecf0ec..1e7ff9f2282 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" + 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 shell: bash env: diff --git a/.github/actions/ci-installer-integration/action.yaml b/.github/actions/ci-installer-integration/action.yaml index dcc1e22ed75..e02add062b6 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: ./.github/actions/setup-reviewed-npm + - 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..a66c5db22e2 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: ./.github/actions/setup-reviewed-npm + - 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 a40dac73160..40b97071f0a 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 reviewed npm audit cache buckets id: cache-buckets diff --git a/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh b/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh index 7e984e477bd..1bd9aa3dd96 100755 --- a/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh +++ b/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh @@ -31,3 +31,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/actions/ci-static-checks/action.yaml b/.github/actions/ci-static-checks/action.yaml index 6ae0a731265..a807ce60892 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" + 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 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..c05c3731e54 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: ./.github/actions/setup-reviewed-npm + - 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..27090506714 --- /dev/null +++ b/.github/actions/setup-reviewed-npm/action.yaml @@ -0,0 +1,33 @@ +# 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: Resolve reviewed npm identity + id: identity + shell: bash + run: | + set -euo pipefail + node --input-type=module - "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + const [configFile] = process.argv.slice(2); + const config = JSON.parse(readFileSync(configFile, "utf8")); + if (!/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/.test(config.npmVersion)) { + throw new Error("reviewed npm audit configuration has an invalid npmVersion"); + } + if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(config.npmIntegrity)) { + throw new Error("reviewed npm audit configuration has an invalid npmIntegrity"); + } + appendFileSync(process.env.GITHUB_OUTPUT, "version=" + config.npmVersion + "\nintegrity=" + config.npmIntegrity + "\n"); + NODE + + - name: Download, verify, and install reviewed npm + shell: bash + env: + NEMOCLAW_REVIEWED_NPM_VERSION: ${{ steps.identity.outputs.version }} + NEMOCLAW_REVIEWED_NPM_INTEGRITY: ${{ steps.identity.outputs.integrity }} + run: env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN "$GITHUB_ACTION_PATH/../ci-reviewed-npm-audit/verify-and-install-npm.sh" diff --git a/.github/workflows/candidate-compatibility.yaml b/.github/workflows/candidate-compatibility.yaml index e820f6d0263..466d406ee42 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" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./.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" + node-version: "24.18.1" cache: npm cache-dependency-path: candidate-source/package-lock.json + - name: Install reviewed npm + uses: ./.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" + node-version: "24.18.1" cache: npm cache-dependency-path: candidate-source/package-lock.json + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Install repository dependencies working-directory: candidate-source run: npm ci --ignore-scripts diff --git a/.github/workflows/code-scanning.yaml b/.github/workflows/code-scanning.yaml index 62514677e53..e168c8dc2d1 100644 --- a/.github/workflows/code-scanning.yaml +++ b/.github/workflows/code-scanning.yaml @@ -63,13 +63,18 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.workflow_sha }} path: trusted-shellcheck-converter sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json scripts/shellcheck-json1-to-sarif.mts sparse-checkout-cone-mode: false - 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: ./.github/actions/setup-reviewed-npm - name: Install ShellCheck run: | 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/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..d5af06da5af 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: ./.github/actions/setup-reviewed-npm - 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..bcd5e0cc30c 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,10 @@ 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 + 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 c37339a6ae0..4f458affecd 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" + 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" + 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.yaml b/.github/workflows/e2e.yaml index 62b2ed02c89..d3fd7f8795d 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -130,6 +130,8 @@ jobs: ref: ${{ github.workflow_sha }} persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts scripts/checks/package-openshell-sdk-for-pr.mts @@ -142,10 +144,13 @@ jobs: - name: Set up Node for reviewed package download uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + 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 exact OpenShell SDK package env: @@ -236,7 +241,10 @@ jobs: - name: Set up Node for publication verification 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 - id: select_pr_source name: Resolve exact PR managed-image publication @@ -670,7 +678,10 @@ jobs: - name: Set up Node for trusted E2E planning 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 trusted E2E planner dependencies run: npm ci --ignore-scripts --no-audit --no-fund @@ -919,6 +930,8 @@ jobs: ref: ${{ github.workflow_sha }} persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json src/lib/onboard/runtime-provider/native-qualification-authority.ts test/e2e/registry/native-runtime-qualification.ts tools/e2e/native-runtime-qualification-producer-plan.mts @@ -936,7 +949,10 @@ jobs: - name: Set up Node for trusted qualification planning 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 - id: source name: Authenticate the candidate and dispatch artifact @@ -1268,6 +1284,8 @@ jobs: path: .qualification-workflow persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json src/lib/onboard/runtime-provider/native-qualification-authority.ts scripts/checks/run-native-runtime-installer-qualification.sh test/e2e/registry/native-runtime-qualification.ts @@ -1287,7 +1305,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: ./.github/actions/setup-reviewed-npm - name: Require a reviewed Ubuntu runtime host shell: bash @@ -2481,6 +2502,8 @@ jobs: path: .qualification-aggregate persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json src/lib/onboard/runtime-provider/native-qualification-authority.ts test/e2e/registry/native-runtime-qualification.ts tools/e2e/native-runtime-qualification-producer-plan.mts @@ -2539,7 +2562,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: ./.github/actions/setup-reviewed-npm - name: Validate and aggregate all 24 case receipts working-directory: .qualification-aggregate @@ -3957,13 +3983,18 @@ jobs: path: .trusted-openshell-dev-artifact persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json 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 + node-version: 24.18.1 + + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm - id: resolve_openshell_dev_artifact name: Resolve immutable OpenShell dev artifact @@ -4023,9 +4054,12 @@ jobs: - name: Set up Node.js for trusted OpenShell verification uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22 + node-version: 24.18.1 package-manager-cache: false + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ inputs.checkout_repository || github.repository }} @@ -4043,6 +4077,8 @@ jobs: path: .trusted-openshell-dev-artifact persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json .github/scripts/copy-openshell-dev-asset.sh .github/scripts/docker-auth-cleanup.sh scripts/install-openshell.sh @@ -4661,9 +4697,12 @@ jobs: - name: Set up Node for trusted llama.cpp plan compilation 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 trusted llama.cpp plan dependencies run: npm ci --ignore-scripts @@ -5425,7 +5464,10 @@ jobs: ENV: /dev/null NODE_OPTIONS: "" with: - node-version: "22" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm - name: Prepare native Podman E2E runtime uses: NVIDIA/NemoClaw/.github/actions/setup-native-podman-e2e@c87144de2c8e2d90b14cf11b31718846e32c65de @@ -5597,7 +5639,10 @@ jobs: - name: Set up Node for Jetson controller 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: Dispatch exact commit to Jetson through operator backend env: diff --git a/.github/workflows/hosted-runner-recovery.yaml b/.github/workflows/hosted-runner-recovery.yaml index 887b73fd1dc..8b03d3f72e0 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" + 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..2389af1ce6f 100644 --- a/.github/workflows/installer-hash-check.yaml +++ b/.github/workflows/installer-hash-check.yaml @@ -39,10 +39,13 @@ jobs: - name: Set up trusted installer hash parser runtime uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22.19.0 + node-version: 24.18.1 # 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: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Checkout pull request head if: github.event_name == 'pull_request' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/issue-9880-staging-reproduction.yaml b/.github/workflows/issue-9880-staging-reproduction.yaml index 19c41e25387..67f63c5d790 100644 --- a/.github/workflows/issue-9880-staging-reproduction.yaml +++ b/.github/workflows/issue-9880-staging-reproduction.yaml @@ -28,6 +28,8 @@ jobs: ref: ${{ github.workflow_sha }} persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json test/e2e/ test/helpers/ tools/e2e/ @@ -61,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/.github/workflows/llama-cpp-image.yaml b/.github/workflows/llama-cpp-image.yaml index a2f06615ced..5ee3feaf1d3 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 0b0efb4215d..38f509c69dd 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -92,12 +92,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 }} @@ -156,9 +159,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 c681a1cd388..ce9a722c8e8 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -95,6 +95,8 @@ jobs: 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 @@ -159,7 +161,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: Validate staging QA source shell: bash @@ -486,7 +491,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: Validate Deep Agents PR base build arguments if: matrix.agent == 'langchain-deepagents-code' @@ -958,7 +966,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: Bind E2E correlation identity shell: bash @@ -1060,7 +1071,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: Bind E2E correlation identity shell: bash @@ -1190,10 +1204,13 @@ 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 + uses: ./.github/actions/setup-reviewed-npm + - name: Build the exact Pi candidate base id: base shell: bash diff --git a/.github/workflows/native-runtime-qualification-collector.yaml b/.github/workflows/native-runtime-qualification-collector.yaml index 58fe53cdb43..00387c506f1 100644 --- a/.github/workflows/native-runtime-qualification-collector.yaml +++ b/.github/workflows/native-runtime-qualification-collector.yaml @@ -62,6 +62,8 @@ jobs: path: trusted persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json scripts/lib/read-artifact-zip.mts src/lib/onboard/runtime-provider/native-qualification-authority.ts test/e2e/registry/native-runtime-qualification.ts @@ -71,7 +73,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: ./.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 04a9b44451c..45300acc584 100644 --- a/.github/workflows/openshell-sdk-package-pr.yaml +++ b/.github/workflows/openshell-sdk-package-pr.yaml @@ -33,6 +33,8 @@ jobs: ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts scripts/checks/package-openshell-sdk-for-pr.mts @@ -45,10 +47,13 @@ jobs: - name: Setup Node.js for reviewed package download uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + 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 7a00d21c510..e71e49c5faf 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: | @@ -106,9 +109,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: diff --git a/.github/workflows/podman-cpu-proof.yaml b/.github/workflows/podman-cpu-proof.yaml index 817138b653e..d2c8bc86aab 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 abd93622d5c..7371b9f3582 100644 --- a/.github/workflows/portable-profile-e2e.yaml +++ b/.github/workflows/portable-profile-e2e.yaml @@ -66,9 +66,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 root dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/post-merge-docs.yaml b/.github/workflows/post-merge-docs.yaml index bf23276c49c..cb010ab6ed7 100644 --- a/.github/workflows/post-merge-docs.yaml +++ b/.github/workflows/post-merge-docs.yaml @@ -73,7 +73,10 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./trusted/.github/actions/setup-reviewed-npm - name: Find the documentation range working-directory: trusted @@ -171,7 +174,10 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + 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 3d3c384cad4..ac4da03dac1 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" + 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" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./.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" + node-version: "24.18.1" + + - name: Install reviewed npm + uses: ./.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 fecdd9ef190..b8711711302 100644 --- a/.github/workflows/pr-review-advisor.yaml +++ b/.github/workflows/pr-review-advisor.yaml @@ -106,9 +106,12 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + node-version: "24.18.1" cache: npm cache-dependency-path: advisor/package-lock.json + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Install locked runtime run: | set -euo pipefail @@ -209,10 +212,13 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22" + node-version: "24.18.1" cache: npm cache-dependency-path: advisor/package-lock.json + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - name: Load advisor runtime image run: node --experimental-strip-types --no-warnings "$ADVISOR_DIR/tools/pr-review-advisor/export-runtime-env.mts" @@ -340,7 +346,10 @@ jobs: - name: Setup Node for trusted publisher 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: Post PR review advisor link env: diff --git a/.github/workflows/pr-self-hosted.yaml b/.github/workflows/pr-self-hosted.yaml index e4413dd45d4..0bfef9028f9 100644 --- a/.github/workflows/pr-self-hosted.yaml +++ b/.github/workflows/pr-self-hosted.yaml @@ -102,7 +102,10 @@ jobs: if: ${{ steps.changed.outputs.selected == 'true' }} 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 publication verifier dependencies if: ${{ steps.changed.outputs.selected == 'true' }} @@ -287,9 +290,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 diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 60f6d7b943f..d0a0503a303 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" + 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 @@ -117,6 +120,8 @@ jobs: path: .trusted-ci-actions persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json .github/actions/ci-static-checks .github/actions/ci-build-typecheck .github/actions/ci-compile-artifacts @@ -158,9 +163,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 ci --ignore-scripts --no-audit --no-fund @@ -439,6 +447,8 @@ jobs: path: .trusted-ci-actions persist-credentials: false sparse-checkout: | + .github/actions/setup-reviewed-npm + ci/reviewed-npm-audit.json .github/actions/ci-build-typecheck .github/actions/ci-install-dependencies.sh ci/reviewed-npm-audit.json @@ -466,12 +476,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 run: bash .trusted-ci-actions/.github/actions/ci-install-dependencies.sh diff --git a/.github/workflows/release-latest-tag.yaml b/.github/workflows/release-latest-tag.yaml index b85720253f9..6305691d027 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-and-e2e.yaml b/.github/workflows/sandbox-images-and-e2e.yaml index 07e0951d525..795d3e24625 100644 --- a/.github/workflows/sandbox-images-and-e2e.yaml +++ b/.github/workflows/sandbox-images-and-e2e.yaml @@ -269,9 +269,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 @@ -354,6 +357,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 @@ -474,6 +480,9 @@ jobs: - *setup-node + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - *install-root-dependencies - &download-isolation-image @@ -515,6 +524,9 @@ jobs: - *setup-node + - name: Install reviewed npm + uses: ./.github/actions/setup-reviewed-npm + - *install-root-dependencies - *download-isolation-image diff --git a/Dockerfile b/Dockerfile index cf22361afed..944dbb08d82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,8 +17,10 @@ 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 node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc AS npm12 +COPY scripts/lib/bundled-npm-package.mts scripts/upgrade-bundled-npm.mts /scripts/ +RUN apt-get update && apt-get install -y --no-install-recommends curl=8.14.1-2+deb13u4 && rm -rf /var/lib/apt/lists/* && node --experimental-strip-types /scripts/upgrade-bundled-npm.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 +42,6 @@ RUN npm run build \ && node --experimental-strip-types \ /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/ @@ -62,7 +62,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 ./ @@ -139,7 +139,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,7 +162,7 @@ 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/ @@ -487,10 +487,10 @@ 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 \ diff --git a/Dockerfile.base b/Dockerfile.base index b2ab4c2e660..ed082ff24d5 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,7 +40,7 @@ # 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 +# 4. node:24.18.1-trixie-slim digest rotated — update-docker-pin.sh updates all # Dockerfile and Dockerfile.base # 5. npm upgrade — update upgrade-bundled-npm.mts # 6. New .openclaw subdirectory — add mkdir below @@ -59,7 +59,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 +97,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 @@ -447,19 +447,19 @@ RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ RUN node --experimental-strip-types /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 --experimental-strip-types /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 --experimental-strip-types /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 --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts \ diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index b5e6664f534..88b4f465a75 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -25,7 +25,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 ./ diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 33ca870cd5d..94209044420 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -23,7 +23,7 @@ ARG PERL_VERSION=5.44.0 ARG PERL_SHA256=505cf43912e9480495c344c70260452e32aa2a73c546a026b3f100053b23ce91 ARG PERL_PACKAGE_REVISION=1nemoclaw1 -FROM node:24-trixie-slim@sha256:05c08ce4291e9a58f59456a7985176defb12cdd42271f35ff81a3e167ea61d4c 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 \ @@ -58,7 +58,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:24-trixie-slim@sha256:05c08ce4291e9a58f59456a7985176defb12cdd42271f35ff81a3e167ea61d4c +FROM node:24.18.1-trixie-slim@sha256:ac39e4b5fcb2b1b34b20364fd58b2e898f3bb80731ee6f62a7536f9df3d6aadc COPY --from=perl-builder /out /tmp/nemoclaw-native-security @@ -77,7 +77,6 @@ ARG HERMES_SEMVER=0.20.6 ARG HERMES_TARBALL_SHA256=e622723b5bf3cd6c1db974d92d32242f1cb63f61c1112b6f708b34d619ef0fc7 ARG HERMES_NPM_INTEGRITY=sha512-s5q1IEBifCBb77QMwkse4MRaAaoZSxIa4IkicIO3jL7MIdq15YvnSyiNvsTOWNBi6t3shFpIg+H7+9MJsOiSkg== ARG HERMES_UV_EXTRAS="anthropic messaging web pty mcp acp" -ARG NODE_VERSION=24.18.1 ARG UV_VERSION=0.11.33 ARG NEMOCLAW_HERMES_RUNTIME_BOUNDARIES_PATCH_SHA256=820563cc3450de582663c8137670001bb9974ac8a5584901880982f6509747b8 ARG NEMOCLAW_HERMES_SECURE_DIR_SOURCE_SHA256=ffae3271120cf53eb8a7f574f76758eac3ea172f9ddc22c1056277e37d8d392c @@ -235,33 +234,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* \ && test -z "$(dpkg --audit)" -# The current node:24-trixie-slim digest still contains Node.js 24.16.0. Overlay -# the reviewed Node.js 24.18.1 release from nodejs.org so the Hermes runtime -# includes the security fixes without waiting for Docker Hub tag rotation. -# hadolint ignore=DL4006 -RUN arch="$(dpkg --print-architecture)" \ - && case "$arch" in \ - amd64) node_asset_arch="x64"; node_sha256="9f5eb6ac21845a66c493c91a253b1da32fd684e89e9b7202d4936982336be4ca" ;; \ - arm64) node_asset_arch="arm64"; node_sha256="df224555a083b918e46260cc969838501b9f9a87140c1195e5b9597b56d5dae2" ;; \ - *) echo "Unsupported architecture for Node.js: $arch" >&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 @@ -283,19 +257,19 @@ RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ RUN node --experimental-strip-types /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 --experimental-strip-types /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 --experimental-strip-types /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 --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts \ diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index 5d14b60c9da..f5ef6c9d09f 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -20,7 +20,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 ./ diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index 1fb5d7146f0..fa4c45cb798 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 @@ -249,19 +249,19 @@ RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ RUN node --experimental-strip-types /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 --experimental-strip-types /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 --experimental-strip-types /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 --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts \ diff --git a/agents/pi/Dockerfile b/agents/pi/Dockerfile index 9166857d2d5..483a22ea279 100644 --- a/agents/pi/Dockerfile +++ b/agents/pi/Dockerfile @@ -14,7 +14,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 ./ diff --git a/agents/pi/Dockerfile.base b/agents/pi/Dockerfile.base index 44279ca94a6..b69354242aa 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 @@ -252,19 +252,19 @@ RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ RUN node --experimental-strip-types /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 --experimental-strip-types /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 --experimental-strip-types /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 --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts \ diff --git a/ci/reviewed-npm-audit.json b/ci/reviewed-npm-audit.json index 0c18030a4dd..6653f9b726b 100644 --- a/ci/reviewed-npm-audit.json +++ b/ci/reviewed-npm-audit.json @@ -1,8 +1,8 @@ { "schemaVersion": 2, - "nodeVersion": "22.23.2", - "npmVersion": "10.9.4", - "npmIntegrity": "sha512-OnUG836FwboQIbqtefDNlyR0gTHzIfwRfE3DuiNewBvnMnWEpB0VEXwBlFVgqpNzIgYo/MHh3d2Hel/pszapAA==", + "nodeVersion": "24.18.1", + "npmVersion": "12.0.2", + "npmIntegrity": "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw==", "registryOrigin": "https://registry.npmjs.org/", "sourceRegistryPackage": { "artifactName": "nvidia-openshell-sdk-0.0.106.tgz", diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 97317bbc81a..b25664703ba 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -101,6 +101,16 @@ "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/repository/github-actions-node-npm-invariant.test.ts", + "test": "selects the reviewed Node and npm identities before further steps", + "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", diff --git a/scripts/lib/patch-bundled-npm-ip-address.mts b/scripts/lib/patch-bundled-npm-ip-address.mts index 5d7270b7199..a15ebf6d107 100755 --- a/scripts/lib/patch-bundled-npm-ip-address.mts +++ b/scripts/lib/patch-bundled-npm-ip-address.mts @@ -36,7 +36,7 @@ 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"; +export const REVIEWED_NPM_VERSION = "12.0.2"; const REVIEWED_IP_ADDRESS_VERSIONS = new Set([ AFFECTED_IP_ADDRESS_VERSION, diff --git a/scripts/lib/seed-reviewed-npm-cache.mts b/scripts/lib/seed-reviewed-npm-cache.mts index 2afe5eb8e63..14c0cc2cb5e 100755 --- a/scripts/lib/seed-reviewed-npm-cache.mts +++ b/scripts/lib/seed-reviewed-npm-cache.mts @@ -55,7 +55,7 @@ type LockedPackage = Readonly<{ }>; const INSTALL_ACCEPT = "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*"; -const REVIEWED_CI_NPM_VERSIONS = new Set(["10.9.4", "10.9.8", "11.17.0", "11.18.0"]); +const REVIEWED_CI_NPM_VERSIONS = new Set(["10.9.4", "10.9.8", "11.17.0", "11.18.0", "12.0.2"]); function packageNameFromLockLocation(location: string): string { const marker = "node_modules/"; @@ -208,7 +208,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@10.9.4, npm@10.9.8, npm@11.17.0, npm@11.18.0, or npm@12.0.2`, ); } const npmRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim(); diff --git a/scripts/patch-bundled-npm-brace-expansion.mts b/scripts/patch-bundled-npm-brace-expansion.mts index b91b7c42234..e3104b48d78 100755 --- a/scripts/patch-bundled-npm-brace-expansion.mts +++ b/scripts/patch-bundled-npm-brace-expansion.mts @@ -35,7 +35,7 @@ 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"; +export const REVIEWED_NPM_VERSION = "12.0.2"; 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 fb054cd4177..2a3c7bf5c15 100755 --- a/scripts/patch-bundled-npm-tar.mts +++ b/scripts/patch-bundled-npm-tar.mts @@ -80,7 +80,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/upgrade-bundled-npm.mts b/scripts/upgrade-bundled-npm.mts index 913a53fe9c6..ea34fa7c9b3 100755 --- a/scripts/upgrade-bundled-npm.mts +++ b/scripts/upgrade-bundled-npm.mts @@ -24,22 +24,23 @@ import { requireRealDirectory as realDirectory, } from "./lib/bundled-npm-package.mts"; -export const REVIEWED_NPM_VERSION = "11.18.0"; +export const REVIEWED_NPM_VERSION = "12.0.2"; 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"; + "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw=="; +export const REVIEWED_NPM_TARBALL = "https://registry.npmjs.org/npm/-/npm-12.0.2.tgz"; // 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 +112,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") ?? [], diff --git a/src/lib/onboard/dockerfile-node-runtime-refresh.test.ts b/src/lib/onboard/dockerfile-node-runtime-refresh.test.ts index 38c761dcd58..c22b33bfbf8 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"), @@ -94,7 +94,7 @@ describe("managed OpenClaw Node runtime refresh", () => { const builderImage = dockerfile.match(/^FROM (node:[^\s]+) AS builder$/m)?.[1]; const baseImage = baseDockerfile.match(/^FROM (node:[^\s]+)$/m)?.[1]; - expect(builderImage).toMatch(/^node:22-trixie-slim@sha256:[0-9a-f]{64}$/); + expect(builderImage).toMatch(/^node:24\.18\.1-trixie-slim@sha256:[0-9a-f]{64}$/); expect(baseImage).toBe(builderImage); }); }); 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/privileged-exec.test.ts b/src/lib/sandbox/privileged-exec.test.ts index 6797e67e4fe..b44ba88fd61 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/automation/pull-requests/hosted-runner-recovery-workflow.test.ts b/test/automation/pull-requests/hosted-runner-recovery-workflow.test.ts index 6eccb4f5bbe..3ecb37767d5 100644 --- a/test/automation/pull-requests/hosted-runner-recovery-workflow.test.ts +++ b/test/automation/pull-requests/hosted-runner-recovery-workflow.test.ts @@ -113,7 +113,7 @@ describe("hosted-runner recovery workflow boundary", () => { }); const setupNode = step(job, "Setup Node.js"); expect(setupNode.uses).toBe(TRUSTED_SETUP_NODE); - expect(setupNode.with).toEqual({ "node-version": "22" }); + expect(setupNode.with).toEqual({ "node-version": "24.18.1" }); expect( job.steps?.filter((candidate) => candidate.uses?.startsWith("actions/checkout@")), ).toHaveLength(1); diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 8d12e2b4b9d..7c8d7a441e2 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -450,7 +450,7 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { emitAuditReceipt({ artifactDirectory: root, graphId: "temporary-graph", - npmVersion: "10.9.4", + npmVersion: "12.0.2", packageJsonFile, packageLockFile, preserveInputs: true, @@ -582,7 +582,7 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { replacementLockSha256: digest, }, ], - nodeVersion: "22.23.2", + nodeVersion: "24.18.1", npmIntegrity: REVIEWED_AUDIT_CONFIG.npmIntegrity, npmVersion: REVIEWED_AUDIT_CONFIG.npmVersion, registryOrigin: "https://registry.npmjs.org/", @@ -695,7 +695,7 @@ case "$1" in shift done [ -n "$download_dir" ] - printf 'tampered archive\\n' > "$download_dir/npm-10.9.4.tgz" + printf 'tampered archive\\n' > "$download_dir/npm-12.0.2.tgz" ;; install) : > "$NEMOCLAW_TEST_INSTALL_MARKER" @@ -713,7 +713,7 @@ esac env: { ...process.env, NEMOCLAW_REVIEWED_NPM_INTEGRITY: "sha512-invalid", - NEMOCLAW_REVIEWED_NPM_VERSION: "10.9.4", + NEMOCLAW_REVIEWED_NPM_VERSION: "12.0.2", NEMOCLAW_TEST_INSTALL_MARKER: installMarker, NEMOCLAW_TEST_NPM_LOG: npmLog, PATH: `${bin}:${process.env.PATH ?? ""}`, @@ -722,7 +722,7 @@ esac }); expect(result.status).toBe(1); - expect(result.stderr).toContain("npm@10.9.4 archive integrity mismatch"); + expect(result.stderr).toContain("npm@12.0.2 archive integrity mismatch"); expect(fs.readFileSync(npmLog, "utf8")).toBe("pack\n"); expect(fs.existsSync(installMarker)).toBe(false); } finally { @@ -763,10 +763,13 @@ case "$1" in shift done [ -n "$download_dir" ] - printf 'verified archive\\n' > "$download_dir/npm-10.9.4.tgz" + printf 'verified archive\\n' > "$download_dir/npm-12.0.2.tgz" ;; install) ;; + --version) + printf '12.0.2\n' + ;; *) exit 2 ;; @@ -781,7 +784,7 @@ esac env: { ...process.env, NEMOCLAW_REVIEWED_NPM_INTEGRITY: integrity, - NEMOCLAW_REVIEWED_NPM_VERSION: "10.9.4", + NEMOCLAW_REVIEWED_NPM_VERSION: "12.0.2", NEMOCLAW_TEST_NPM_LOG: npmLog, PATH: `${bin}:${process.env.PATH ?? ""}`, RUNNER_TEMP: root, @@ -790,11 +793,12 @@ esac const npmInvocations = fs.readFileSync(npmLog, "utf8").trim().split("\n"); expect(result.status).toBe(0); - expect(npmInvocations).toHaveLength(2); - expect(npmInvocations[0]).toContain("pack npm@10.9.4 --pack-destination"); + expect(npmInvocations).toHaveLength(3); + expect(npmInvocations[0]).toContain("pack npm@12.0.2 --pack-destination"); expect(npmInvocations[1]).toMatch( - /^install --global .*\/npm-10\.9\.4\.tgz --userconfig \/dev\/null --ignore-scripts --no-audit --no-fund --offline$/, + /^install --global .*\/npm-12\.0\.2\.tgz --userconfig \/dev\/null --ignore-scripts --no-audit --no-fund --offline$/, ); + expect(npmInvocations[2]).toBe("--version"); } finally { fs.rmSync(root, { recursive: true, force: true }); } @@ -1389,7 +1393,7 @@ esac artifactDirectory: "/artifacts", directory: "/materialized", exceptionFile: "/exceptions.json", - npmVersion: "10.9.4", + npmVersion: "12.0.2", packageSpec: "nemoclaw@0.0.0", threshold: "high", }, @@ -1402,7 +1406,7 @@ esac graph: "nemoclaw-cli", provenance: { label: "NemoClaw CLI locked production graph", - npmVersion: "10.9.4", + npmVersion: "12.0.2", packageSpecs: ["nemoclaw@0.0.0"], }, reportFile: path.join("/artifacts", "source-graph.json"), diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 1806f308f1a..978e3dcd935 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -44,7 +44,7 @@ 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"; + "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/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/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 689ca6e6fad..996e2537087 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/seed-reviewed-npm-cache.test.ts b/test/install/seed-reviewed-npm-cache.test.ts index 2fa57a434aa..05b79eb07d2 100644 --- a/test/install/seed-reviewed-npm-cache.test.ts +++ b/test/install/seed-reviewed-npm-cache.test.ts @@ -217,7 +217,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@10.9.4, npm@10.9.8, npm@11.17.0, npm@11.18.0, or 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..4c0f674214f 100644 --- a/test/install/upgrade-bundled-npm.test.ts +++ b/test/install/upgrade-bundled-npm.test.ts @@ -13,7 +13,9 @@ import { verifyBundledNpmTar, } from "../../scripts/patch-bundled-npm-tar.mts"; import { + REVIEWED_NPM_INTEGRITY, REVIEWED_NPM_PACKAGES, + REVIEWED_NPM_TARBALL, REVIEWED_NPM_VERSION, upgradeBundledNpm, verifyReviewedNpm, @@ -37,12 +39,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; } @@ -62,6 +65,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 +93,14 @@ 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"); + }); + it("verifies the complete reviewed dependency set", () => { const state = verifyReviewedNpm(reviewedNpm()); @@ -96,6 +108,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 +129,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); @@ -170,8 +183,8 @@ describe("reviewed bundled npm upgrade", () => { 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", () => { 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 229fa255535..9d0bcb6808c 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 @@ -44,7 +44,7 @@ const tarPatchArgumentsByDockerfile = { 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( 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 f7795c3074d..a6a2d3e09c9 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 @@ -44,7 +44,7 @@ const tarPatchArgumentsByDockerfile = { 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==", diff --git a/test/platform/images/node-tar-dockerfile-contract.test.ts b/test/platform/images/node-tar-dockerfile-contract.test.ts index 292d2508b8d..462541c920d 100644 --- a/test/platform/images/node-tar-dockerfile-contract.test.ts +++ b/test/platform/images/node-tar-dockerfile-contract.test.ts @@ -6,10 +6,8 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { - FIXED_TAR_VERSION, - NODE_BASES_REQUIRING_BUNDLED_NPM_TAR_PATCH, -} from "../../../scripts/patch-bundled-npm-tar.mts"; +import { FIXED_TAR_VERSION } from "../../../scripts/patch-bundled-npm-tar.mts"; +import { REVIEWED_NPM_VERSION } from "../../../scripts/upgrade-bundled-npm.mts"; import { dockerfileRunCommandPositions, requireReviewedDockerfileRunCommands, @@ -76,7 +74,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 --experimental-strip-types \/opt\/[^\s]*reviewed-npm-archive\.mts/gu, + /node --experimental-strip-types \/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+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 +310,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( @@ -388,6 +429,55 @@ describe("node-tar image remediation contract", () => { }); describe("reviewed npm image remediation contract", () => { + // 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", () => { + 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 --experimental-strip-types /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(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/github-actions-node-npm-invariant.test.ts b/test/repository/github-actions-node-npm-invariant.test.ts new file mode 100644 index 00000000000..3e188ea4ace --- /dev/null +++ b/test/repository/github-actions-node-npm-invariant.test.ts @@ -0,0 +1,72 @@ +// 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"; + +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 AUDIT_ACTION = ".github/actions/ci-reviewed-npm-audit/action.yaml"; + +type Step = { uses?: string; with?: Record }; +type ActionDocument = { runs?: { steps?: Step[] } }; +type WorkflowDocument = { jobs?: Record }; +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 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; npmVersion: string }; +const setupNodeSteps = yamlFiles(GITHUB_ROOT) + .flatMap(stepGroups) + .flatMap(({ file, label, steps }) => + steps.flatMap((step, index) => + step.uses?.startsWith("actions/setup-node@") ? [{ file, label, steps, step, index }] : [], + ), + ); + +describe("controlled setup-node environments", () => { + // source-shape-contract: security -- Every controlled setup-node environment must install the integrity-bound npm release before later workflow steps execute. + it("selects the reviewed Node and npm identities before further steps", () => { + const setupIdentities = setupNodeSteps.map(({ step }) => [ + step.uses, + String(step.with?.["node-version"]), + ]); + const reviewedNpmSteps = setupNodeSteps + .filter(({ file }) => path.relative(REPO_ROOT, file) !== AUDIT_ACTION) + .map(({ steps, index }) => steps[index + 1]?.uses); + + expect(identity).toMatchObject({ nodeVersion: "24.18.1", npmVersion: "12.0.2" }); + expect( + setupIdentities.every( + ([action, node]) => action === SETUP_NODE && node === identity.nodeVersion, + ), + ).toBe(true); + expect(reviewedNpmSteps.every((action) => action?.includes(REVIEWED_NPM_ACTION))).toBe(true); + }); +}); diff --git a/test/repository/prepare-ci-npm-install.test.ts b/test/repository/prepare-ci-npm-install.test.ts index bfdee3c9e87..a6076a63ab3 100644 --- a/test/repository/prepare-ci-npm-install.test.ts +++ b/test/repository/prepare-ci-npm-install.test.ts @@ -78,10 +78,10 @@ function reviewedConfigSource(packageIdentity: ReviewedSourceRegistryPackage = r artifactDirectory: "artifacts/reviewed-npm-audit", exceptionFile: "ci/npm-audit-exceptions.json", lockedGraphs: [], - nodeVersion: "22.23.2", + nodeVersion: "24.18.1", npmIntegrity: "sha512-OnUGvKW3lJs/ooPKDKUNfz1UmMfF48YWbjNA20QdiWrCVnZaAPppOfHPnfGiPb+1lKIsxjKXQ4UAfDI7PcvLPg==", - npmVersion: "10.9.4", + npmVersion: "12.0.2", registryOrigin: "https://registry.npmjs.org/", schemaVersion: 2, severityThreshold: "high", diff --git a/test/support/managed-bootstrap-image-contract.ts b/test/support/managed-bootstrap-image-contract.ts index 130b04bd9eb..e622254faf3 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 = From 667c431859bf8d0a75b7f6a4946e635640e7734d Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Fri, 4 Sep 2026 16:46:23 -0700 Subject: [PATCH 02/30] ci(e2e): bootstrap reviewed npm receipts Signed-off-by: Charan Jagwani --- .github/actions/prepare-e2e/action.yaml | 2 +- ci/pi-agent-qualification-v1-linux-amd64.json | 8 ++++---- ci/pi-agent-qualification-v1-linux-arm64.json | 8 ++++---- src/lib/agent/candidate-authority.ts | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/actions/prepare-e2e/action.yaml b/.github/actions/prepare-e2e/action.yaml index c05c3731e54..96fbade3fcf 100644 --- a/.github/actions/prepare-e2e/action.yaml +++ b/.github/actions/prepare-e2e/action.yaml @@ -20,7 +20,7 @@ runs: cache: npm - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e - name: Install root dependencies shell: bash diff --git a/ci/pi-agent-qualification-v1-linux-amd64.json b/ci/pi-agent-qualification-v1-linux-amd64.json index f0f2082230c..dcc4973bd90 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:7e95430e4ef79951289a0db4a3a0c18e19a3022731da7461e1f6968fd6fde737", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:7e95430e4ef79951289a0db4a3a0c18e19a3022731da7461e1f6968fd6fde737", + "digest": "sha256:3072694e31b8b67c29e20070735c51d036dec93b4cf268681ceaaaf26f8d043f", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:3072694e31b8b67c29e20070735c51d036dec93b4cf268681ceaaaf26f8d043f", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "8489f2bfa61756a0b9e923c4e6a10a254e19f410", + "revision": "470a5417558c65260d59cdb5eabb01d35834535e", "release": "v0.1.0", - "cohort": "ghrun-33803401296-2" + "cohort": "ghrun-33916258004-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 962f1bcd15f..55ee9d9e228 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:904e897b2f798fd4ee83f9a96083d6f00624d7c4c58b6facfc39b0b5bbefe5f4", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:904e897b2f798fd4ee83f9a96083d6f00624d7c4c58b6facfc39b0b5bbefe5f4", + "digest": "sha256:1ffe05f2976b2e1b21b5324a9b373a0679bb6d6824634c314b210ee097901e24", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:1ffe05f2976b2e1b21b5324a9b373a0679bb6d6824634c314b210ee097901e24", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "8489f2bfa61756a0b9e923c4e6a10a254e19f410", + "revision": "470a5417558c65260d59cdb5eabb01d35834535e", "release": "v0.1.0", - "cohort": "ghrun-33803401296-2" + "cohort": "ghrun-33916258004-1" }, "startupProfileContractVersion": 1, "capabilityContractVersion": 1 diff --git a/src/lib/agent/candidate-authority.ts b/src/lib/agent/candidate-authority.ts index 4b9b7aa6a1d..3d458ff9a26 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([ - "8c53f248ddcef01931f48b66624421763c5c892ffb32f1936a6dccca6344e938", - "2cca1daacbead12b9ba24d7b9e0859b6c26f5b7b1e0a2179a176ae1155176c40", + "2feec3bde072ff79fd0e7005d52637780de6e7d1b3f1d72a505457d455dd7654", + "65798aa3a608ac9eb7a2240514309211585ef46104f719321633276a6b59da69", ]), }); From eb3b31a6242c5823abc875df8e5723422713bc4b Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Fri, 4 Sep 2026 17:06:16 -0700 Subject: [PATCH 03/30] chore(deps): standardize on npm 12.0.2 Signed-off-by: Charan Jagwani --- .../actions/ci-cli-coverage-merge/action.yaml | 6 +- .../actions/ci-cli-coverage-shard/action.yaml | 6 +- .../actions/ci-compile-artifacts/action.yaml | 6 +- .../ci-installer-integration/action.yaml | 6 +- .../actions/ci-plugin-coverage/action.yaml | 6 +- .../actions/ci-reviewed-npm-audit/action.yaml | 21 ++- .../verify-and-install-npm.sh | 39 ---- .github/actions/ci-static-checks/action.yaml | 6 +- .../actions/setup-reviewed-npm/action.yaml | 26 +-- .../verify-and-install-npm.sh | 74 ++++++++ .../workflows/candidate-compatibility.yaml | 6 +- .github/workflows/code-scanning.yaml | 2 +- .github/workflows/docker-pin-check.yaml | 2 +- .github/workflows/docs-publish-staging.yaml | 1 + .github/workflows/e2e-standard-profile.yaml | 2 +- .github/workflows/e2e.yaml | 54 +++--- .github/workflows/installer-hash-check.yaml | 21 ++- .github/workflows/managed-images.yaml | 14 +- ...ative-runtime-qualification-collector.yaml | 2 +- .../workflows/openshell-sdk-package-pr.yaml | 1 - .github/workflows/platform-vitest-main.yaml | 2 +- .github/workflows/portable-profile-e2e.yaml | 2 +- .../workflows/pr-merge-conflict-fixer.yaml | 4 +- .github/workflows/pr-review-advisor.yaml | 6 +- .github/workflows/pr-self-hosted.yaml | 3 +- .github/workflows/pr.yaml | 2 +- Dockerfile | 83 ++++----- Dockerfile.base | 4 +- agents/hermes/Dockerfile | 15 ++ agents/hermes/Dockerfile.base | 2 + agents/langchain-deepagents-code/Dockerfile | 23 ++- .../langchain-deepagents-code/Dockerfile.base | 2 + agents/openclaw/dependency-review.md | 2 +- agents/pi/Dockerfile | 23 ++- agents/pi/Dockerfile.base | 2 + ci/reviewed-npm-audit.json | 1 + ci/source-shape-test-budget.json | 30 +++ docs/security/advisory-early-warning.md | 5 +- scripts/audit-reviewed-npm-graph.mts | 43 ++++- scripts/lib/npm-audit-receipt.mts | 42 ++++- scripts/lib/openclaw-npm-remediation.mts | 28 ++- scripts/lib/patch-bundled-npm-ip-address.mts | 4 +- scripts/lib/reviewed-npm-archive.mts | 9 +- scripts/lib/reviewed-npm-audit.mts | 49 ++++- scripts/lib/reviewed-npm-identity.mts | 37 ++++ scripts/lib/seed-reviewed-npm-cache.mts | 5 +- scripts/patch-bundled-npm-brace-expansion.mts | 4 +- scripts/patch-bundled-npm-tar.mts | 3 +- scripts/update-docker-pin.sh | 6 +- scripts/upgrade-bundled-npm.mts | 44 ++++- src/lib/sandbox/build-context.ts | 4 + .../openclaw/openclaw-integrity-pin-suite.ts | 40 ++-- .../openclaw/openclaw-npm-remediation.test.ts | 30 ++- .../openclaw-optional-plugin-build.test.ts | 2 +- .../e2e/platform-vitest-main-workflow.test.ts | 23 +++ .../releases/npm-audit-receipt.test.ts | 18 +- .../release-pin-script-curl-proto-pin.test.ts | 2 +- .../reviewed-npm-audit-workflow.test.ts | 158 +--------------- .../releases/reviewed-npm-audit.test.ts | 111 +++++++++-- .../releases/reviewed-npm-bootstrap.test.ts | 154 +++++++++++++++ test/e2e/README.md | 3 +- ...in-runtime-exdev-workflow-boundary.test.ts | 2 +- .../prepare-e2e-workflow-boundary.test.ts | 3 +- .../sandbox-images-workflow-boundary.test.ts | 2 +- test/helpers/reviewed-npm-fixture.ts | 2 +- ...managed-image-publication-workflow.test.ts | 6 +- test/install/reviewed-npm-archive.test.ts | 34 +++- test/install/seed-reviewed-npm-cache.test.ts | 2 +- test/install/upgrade-bundled-npm.test.ts | 30 ++- .../node-tar-dockerfile-contract.test.ts | 68 ++++++- .../github-actions-node-npm-invariant.test.ts | 175 ++++++++++++++++-- .../sandbox/sandbox-build-context.test.ts | 7 +- .../fetch-guard-patch-regression.test.ts | 2 +- ...-spark-qualification-workflow-boundary.mts | 2 +- ...ge-protected-runtime-workflow-boundary.mts | 2 +- tools/e2e/prepare-e2e-workflow-boundary.mts | 18 +- .../e2e/sandbox-images-workflow-boundary.mts | 4 +- tools/e2e/workflow-boundary-policy.mts | 4 +- tools/e2e/workflow-boundary.mts | 12 +- .../dependency-review.md | 2 +- tools/wsl/ci-helper.ps1 | 31 +++- 81 files changed, 1256 insertions(+), 483 deletions(-) delete mode 100755 .github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh create mode 100755 .github/actions/setup-reviewed-npm/verify-and-install-npm.sh create mode 100755 scripts/lib/reviewed-npm-identity.mts create mode 100644 test/automation/releases/reviewed-npm-bootstrap.test.ts diff --git a/.github/actions/ci-cli-coverage-merge/action.yaml b/.github/actions/ci-cli-coverage-merge/action.yaml index fe8becffe28..82818e9de2a 100644 --- a/.github/actions/ci-cli-coverage-merge/action.yaml +++ b/.github/actions/ci-cli-coverage-merge/action.yaml @@ -38,7 +38,11 @@ runs: cache: npm - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + shell: bash + run: >- + env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN + "$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh" + "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" - name: Install dependencies shell: bash diff --git a/.github/actions/ci-cli-coverage-shard/action.yaml b/.github/actions/ci-cli-coverage-shard/action.yaml index f8d3a6790ca..793f81a6481 100644 --- a/.github/actions/ci-cli-coverage-shard/action.yaml +++ b/.github/actions/ci-cli-coverage-shard/action.yaml @@ -51,7 +51,11 @@ runs: nemoclaw/package-lock.json - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + shell: bash + run: >- + env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN + "$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh" + "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" - name: Install pinned Pi search tools shell: bash diff --git a/.github/actions/ci-compile-artifacts/action.yaml b/.github/actions/ci-compile-artifacts/action.yaml index 1e7ff9f2282..f3df03415f3 100644 --- a/.github/actions/ci-compile-artifacts/action.yaml +++ b/.github/actions/ci-compile-artifacts/action.yaml @@ -17,7 +17,11 @@ runs: nemoclaw/package-lock.json - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + shell: bash + run: >- + env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN + "$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh" + "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" - name: Install dependencies shell: bash diff --git a/.github/actions/ci-installer-integration/action.yaml b/.github/actions/ci-installer-integration/action.yaml index e02add062b6..d16007d2bb7 100644 --- a/.github/actions/ci-installer-integration/action.yaml +++ b/.github/actions/ci-installer-integration/action.yaml @@ -17,7 +17,11 @@ runs: nemoclaw/package-lock.json - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + shell: bash + run: >- + env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN + "$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh" + "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" - name: Install dependencies shell: bash diff --git a/.github/actions/ci-plugin-coverage/action.yaml b/.github/actions/ci-plugin-coverage/action.yaml index a66c5db22e2..c310ed0b412 100644 --- a/.github/actions/ci-plugin-coverage/action.yaml +++ b/.github/actions/ci-plugin-coverage/action.yaml @@ -17,7 +17,11 @@ runs: nemoclaw/package-lock.json - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + shell: bash + run: >- + env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN + "$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh" + "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" - name: Install dependencies shell: bash diff --git a/.github/actions/ci-reviewed-npm-audit/action.yaml b/.github/actions/ci-reviewed-npm-audit/action.yaml index 40b97071f0a..28eaca87b7c 100644 --- a/.github/actions/ci-reviewed-npm-audit/action.yaml +++ b/.github/actions/ci-reviewed-npm-audit/action.yaml @@ -54,10 +54,13 @@ runs: if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(config.npmIntegrity) || /[\r\n]/.test(config.npmIntegrity)) { throw new Error("reviewed npm audit configuration has an invalid npmIntegrity"); } + if (!/^[a-f0-9]{64}$/.test(config.npmArchiveSha256)) { + throw new Error("reviewed npm audit configuration has an invalid npmArchiveSha256"); + } const directories = ["", ...config.lockedGraphs.map((graph) => graph.directory)].sort(); const hash = createHash("sha256"); hash.update(configSource); - hash.update(JSON.stringify({ argv: ["audit", "--registry=https://registry.yarnpkg.com", "--omit=dev", "--json"], npmVersion: config.npmVersion, registry: "https://registry.yarnpkg.com/", schemaVersion: 1 })); + hash.update(JSON.stringify({ argv: ["audit", "--registry=https://registry.yarnpkg.com", "--omit=dev", "--json"], npmArchiveSha256: config.npmArchiveSha256, npmIntegrity: config.npmIntegrity, npmVersion: config.npmVersion, registry: "https://registry.yarnpkg.com/", schemaVersion: 2 })); for (const directory of directories) { for (const file of ["package.json", "package-lock.json"]) { const relative = join(directory, file); @@ -67,7 +70,7 @@ runs: } appendFileSync( process.env.GITHUB_OUTPUT, - `input-digest=${hash.digest("hex")}\nnpm-version=${config.npmVersion}\nnpm-integrity=${config.npmIntegrity}\n`, + `input-digest=${hash.digest("hex")}\n`, ); NODE printf 'current=%s\nprevious=%s\n' "$current_bucket" "$previous_bucket" >> "$GITHUB_OUTPUT" @@ -78,21 +81,21 @@ runs: uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ${{ inputs.cache-directory }} - key: reviewed-npm-audit-v1-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.current }} + key: reviewed-npm-audit-v2-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.current }} - name: Restore previous reviewed npm audit cache bucket if: steps.cache-current.outputs.cache-hit != 'true' uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ${{ inputs.cache-directory }} - key: reviewed-npm-audit-v1-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.previous }} + key: reviewed-npm-audit-v2-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.previous }} - name: Download and verify production npm shell: bash - env: - NEMOCLAW_REVIEWED_NPM_VERSION: ${{ steps.cache-buckets.outputs.npm-version }} - NEMOCLAW_REVIEWED_NPM_INTEGRITY: ${{ steps.cache-buckets.outputs.npm-integrity }} - run: env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN "$GITHUB_ACTION_PATH/verify-and-install-npm.sh" + run: >- + env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN + "$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh" + "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" - name: Materialize and audit reviewed npm graphs shell: bash @@ -109,7 +112,7 @@ runs: uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ${{ inputs.cache-directory }} - key: reviewed-npm-audit-v1-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.current }} + key: reviewed-npm-audit-v2-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.current }} - name: Upload reviewed npm audit reports if: always() diff --git a/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh b/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh deleted file mode 100755 index 1bd9aa3dd96..00000000000 --- a/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -set -euo pipefail - -version="${NEMOCLAW_REVIEWED_NPM_VERSION:?}" -expected_integrity="${NEMOCLAW_REVIEWED_NPM_INTEGRITY:?}" -download_dir="$(mktemp -d "$RUNNER_TEMP/reviewed-npm.XXXXXX")" - -npm pack "npm@$version" \ - --pack-destination "$download_dir" \ - --userconfig /dev/null \ - --registry https://registry.npmjs.org/ \ - --ignore-scripts --no-audit --no-fund >/dev/null - -archive="$download_dir/npm-$version.tgz" -actual_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")); - ' "$archive" -)" -actual_integrity="sha512-$actual_sha512" -if [ "$actual_integrity" != "$expected_integrity" ]; then - echo "ERROR: npm@$version archive integrity mismatch." >&2 - exit 1 -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/actions/ci-static-checks/action.yaml b/.github/actions/ci-static-checks/action.yaml index a807ce60892..122584a57e7 100644 --- a/.github/actions/ci-static-checks/action.yaml +++ b/.github/actions/ci-static-checks/action.yaml @@ -17,7 +17,11 @@ runs: nemoclaw/package-lock.json - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + shell: bash + run: >- + env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN + "$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh" + "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" - name: Install base-trusted createRequire verifier dependencies shell: bash diff --git a/.github/actions/setup-reviewed-npm/action.yaml b/.github/actions/setup-reviewed-npm/action.yaml index 27090506714..5f2e26d6343 100644 --- a/.github/actions/setup-reviewed-npm/action.yaml +++ b/.github/actions/setup-reviewed-npm/action.yaml @@ -7,27 +7,9 @@ description: Install the exact npm release approved by the reviewed npm audit id runs: using: composite steps: - - name: Resolve reviewed npm identity - id: identity - shell: bash - run: | - set -euo pipefail - node --input-type=module - "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" <<'NODE' - import { appendFileSync, readFileSync } from "node:fs"; - const [configFile] = process.argv.slice(2); - const config = JSON.parse(readFileSync(configFile, "utf8")); - if (!/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/.test(config.npmVersion)) { - throw new Error("reviewed npm audit configuration has an invalid npmVersion"); - } - if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(config.npmIntegrity)) { - throw new Error("reviewed npm audit configuration has an invalid npmIntegrity"); - } - appendFileSync(process.env.GITHUB_OUTPUT, "version=" + config.npmVersion + "\nintegrity=" + config.npmIntegrity + "\n"); - NODE - - name: Download, verify, and install reviewed npm shell: bash - env: - NEMOCLAW_REVIEWED_NPM_VERSION: ${{ steps.identity.outputs.version }} - NEMOCLAW_REVIEWED_NPM_INTEGRITY: ${{ steps.identity.outputs.integrity }} - run: env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN "$GITHUB_ACTION_PATH/../ci-reviewed-npm-audit/verify-and-install-npm.sh" + 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/setup-reviewed-npm/verify-and-install-npm.sh b/.github/actions/setup-reviewed-npm/verify-and-install-npm.sh new file mode 100755 index 00000000000..7bed0811784 --- /dev/null +++ b/.github/actions/setup-reviewed-npm/verify-and-install-npm.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "ERROR: reviewed npm identity path is required." >&2 + exit 1 +fi + +config_file="$1" +download_dir="$(mktemp -d "$RUNNER_TEMP/reviewed-npm.XXXXXX")" +trap 'rm -rf "$download_dir"' EXIT +identity_file="$download_dir/identity" + +node --input-type=module - "$config_file" >"$identity_file" <<'NODE' +import { readFileSync } from "node:fs"; + +const [configFile] = process.argv.slice(2); +const config = JSON.parse(readFileSync(configFile, "utf8")); +if (!/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/.test(config.npmVersion)) { + throw new Error("reviewed npm audit configuration has an invalid npmVersion"); +} +if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(config.npmIntegrity)) { + throw new Error("reviewed npm audit configuration has an invalid npmIntegrity"); +} +if (!/^[a-f0-9]{64}$/.test(config.npmArchiveSha256)) { + throw new Error("reviewed npm audit configuration has an invalid npmArchiveSha256"); +} +process.stdout.write(`${config.npmVersion}\n${config.npmIntegrity}\n${config.npmArchiveSha256}\n`); +NODE + +IFS= read -r version <"$identity_file" +IFS= read -r expected_integrity < <(sed -n '2p' "$identity_file") +IFS= read -r expected_sha256 < <(sed -n '3p' "$identity_file") +[ -n "$version" ] +[ -n "$expected_integrity" ] +[ -n "$expected_sha256" ] + +npm pack "npm@$version" \ + --pack-destination "$download_dir" \ + --userconfig /dev/null \ + --registry https://registry.npmjs.org/ \ + --ignore-scripts --no-audit --no-fund >/dev/null + +archive="$download_dir/npm-$version.tgz" +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") + "\n" + + crypto.createHash("sha256").update(archive).digest("hex") + "\n", + ); +' "$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 + exit 1 +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/candidate-compatibility.yaml b/.github/workflows/candidate-compatibility.yaml index 466d406ee42..98f0874f439 100644 --- a/.github/workflows/candidate-compatibility.yaml +++ b/.github/workflows/candidate-compatibility.yaml @@ -60,7 +60,7 @@ jobs: node-version: "24.18.1" - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: ./controller/.github/actions/setup-reviewed-npm - id: identity name: Resolve official candidate provenance @@ -150,7 +150,7 @@ jobs: cache-dependency-path: candidate-source/package-lock.json - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: ./controller/.github/actions/setup-reviewed-npm - name: Install repository dependencies working-directory: candidate-source @@ -281,7 +281,7 @@ jobs: cache-dependency-path: candidate-source/package-lock.json - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: ./controller/.github/actions/setup-reviewed-npm - name: Install repository dependencies working-directory: candidate-source diff --git a/.github/workflows/code-scanning.yaml b/.github/workflows/code-scanning.yaml index e168c8dc2d1..f5413c42e88 100644 --- a/.github/workflows/code-scanning.yaml +++ b/.github/workflows/code-scanning.yaml @@ -74,7 +74,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: ./trusted-shellcheck-converter/.github/actions/setup-reviewed-npm - name: Install ShellCheck run: | 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-publish-staging.yaml b/.github/workflows/docs-publish-staging.yaml index bcd5e0cc30c..400097a26fa 100644 --- a/.github/workflows/docs-publish-staging.yaml +++ b/.github/workflows/docs-publish-staging.yaml @@ -115,6 +115,7 @@ jobs: 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 diff --git a/.github/workflows/e2e-standard-profile.yaml b/.github/workflows/e2e-standard-profile.yaml index 550c81245aa..5521002bb9d 100644 --- a/.github/workflows/e2e-standard-profile.yaml +++ b/.github/workflows/e2e-standard-profile.yaml @@ -414,7 +414,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@667c431859bf8d0a75b7f6a4946e635640e7734d with: build-cli: "false" diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index d3fd7f8795d..2d01cfdceda 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -132,7 +132,6 @@ jobs: sparse-checkout: | .github/actions/setup-reviewed-npm ci/reviewed-npm-audit.json - ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts scripts/checks/package-openshell-sdk-for-pr.mts scripts/lib/openclaw-npm-remediation.mts @@ -680,8 +679,8 @@ jobs: with: node-version: 24.18.1 - - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + - name: Install reviewed npm for trusted E2E planning + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e - name: Install trusted E2E planner dependencies run: npm ci --ignore-scripts --no-audit --no-fund @@ -835,7 +834,7 @@ jobs: - name: Prepare E2E workspace if: ${{ inputs.checkout_sha == '' || inputs.jobs != 'native-runtime-qualification-producer' || inputs.targets != '' }} - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d - id: package_cli_artifact name: Package exact-commit CLI @@ -1308,7 +1307,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: ./.qualification-workflow/.github/actions/setup-reviewed-npm - name: Require a reviewed Ubuntu runtime host shell: bash @@ -2565,7 +2564,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: ./.qualification-aggregate/.github/actions/setup-reviewed-npm - name: Validate and aggregate all 24 case receipts working-directory: .qualification-aggregate @@ -2602,7 +2601,7 @@ jobs: persist-credentials: false - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d with: build-cli: "false" @@ -2846,7 +2845,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@667c431859bf8d0a75b7f6a4946e635640e7734d with: build-cli: "false" @@ -3057,7 +3056,7 @@ jobs: persist-credentials: false - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d with: build-cli: "false" @@ -3352,7 +3351,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d with: build-cli: "false" @@ -3428,7 +3427,7 @@ jobs: persist-credentials: false - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d with: build-cli: "false" @@ -3719,7 +3718,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d with: build-cli: "false" @@ -3870,7 +3869,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d with: build-cli: "false" @@ -3994,7 +3993,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: ./.trusted-openshell-dev-artifact/.github/actions/setup-reviewed-npm - id: resolve_openshell_dev_artifact name: Resolve immutable OpenShell dev artifact @@ -4057,9 +4056,6 @@ jobs: node-version: 24.18.1 package-manager-cache: false - - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ inputs.checkout_repository || github.repository }} @@ -4077,8 +4073,6 @@ jobs: path: .trusted-openshell-dev-artifact persist-credentials: false sparse-checkout: | - .github/actions/setup-reviewed-npm - ci/reviewed-npm-audit.json .github/scripts/copy-openshell-dev-asset.sh .github/scripts/docker-auth-cleanup.sh scripts/install-openshell.sh @@ -4145,7 +4139,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@667c431859bf8d0a75b7f6a4946e635640e7734d with: build-cli: "false" @@ -4346,7 +4340,7 @@ jobs: http = true - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d with: build-cli: "false" @@ -4812,7 +4806,7 @@ jobs: driver: docker - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d with: build-cli: "false" @@ -5015,7 +5009,7 @@ jobs: http = true - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d - name: Validate protected runtime activation contract env: @@ -5280,7 +5274,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d with: build-cli: "false" @@ -5447,7 +5441,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d with: build-cli: "false" @@ -5466,8 +5460,8 @@ jobs: with: node-version: "24.18.1" - - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + - name: Reinstall reviewed npm after Node reassertion + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e - name: Prepare native Podman E2E runtime uses: NVIDIA/NemoClaw/.github/actions/setup-native-podman-e2e@c87144de2c8e2d90b14cf11b31718846e32c65de @@ -5723,7 +5717,7 @@ jobs: packages: expect - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d with: build-cli: "false" @@ -5912,7 +5906,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d with: build-cli: "false" @@ -6004,7 +5998,7 @@ jobs: 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@667c431859bf8d0a75b7f6a4946e635640e7734d with: build-cli: "false" diff --git a/.github/workflows/installer-hash-check.yaml b/.github/workflows/installer-hash-check.yaml index 2389af1ce6f..13195cee6ef 100644 --- a/.github/workflows/installer-hash-check.yaml +++ b/.github/workflows/installer-hash-check.yaml @@ -36,16 +36,8 @@ 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: 24.18.1 - # 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: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm - - name: Checkout pull request head if: github.event_name == 'pull_request' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -73,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@470a5417558c65260d59cdb5eabb01d35834535e + + - 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/managed-images.yaml b/.github/workflows/managed-images.yaml index ce9a722c8e8..44be9c5ddda 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -99,7 +99,6 @@ jobs: 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/openclaw-npm-remediation.mts @@ -164,7 +163,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e - name: Validate staging QA source shell: bash @@ -494,7 +493,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e - name: Validate Deep Agents PR base build arguments if: matrix.agent == 'langchain-deepagents-code' @@ -969,7 +968,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e - name: Bind E2E correlation identity shell: bash @@ -1074,7 +1073,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e - name: Bind E2E correlation identity shell: bash @@ -1209,6 +1208,11 @@ jobs: # 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@470a5417558c65260d59cdb5eabb01d35834535e + + - 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 diff --git a/.github/workflows/native-runtime-qualification-collector.yaml b/.github/workflows/native-runtime-qualification-collector.yaml index 00387c506f1..2661e503fd4 100644 --- a/.github/workflows/native-runtime-qualification-collector.yaml +++ b/.github/workflows/native-runtime-qualification-collector.yaml @@ -76,7 +76,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: ./.github/actions/setup-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 45300acc584..cd6ba3557ef 100644 --- a/.github/workflows/openshell-sdk-package-pr.yaml +++ b/.github/workflows/openshell-sdk-package-pr.yaml @@ -35,7 +35,6 @@ jobs: sparse-checkout: | .github/actions/setup-reviewed-npm ci/reviewed-npm-audit.json - ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts scripts/checks/package-openshell-sdk-for-pr.mts scripts/lib/openclaw-npm-remediation.mts diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index e71e49c5faf..340ac814943 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -298,7 +298,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/portable-profile-e2e.yaml b/.github/workflows/portable-profile-e2e.yaml index 7371b9f3582..bd640f3abe6 100644 --- a/.github/workflows/portable-profile-e2e.yaml +++ b/.github/workflows/portable-profile-e2e.yaml @@ -173,7 +173,7 @@ jobs: persist-credentials: false - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d - name: Provision restricted rootless Linux runtime shell: bash diff --git a/.github/workflows/pr-merge-conflict-fixer.yaml b/.github/workflows/pr-merge-conflict-fixer.yaml index ac4da03dac1..fdd4edefe96 100644 --- a/.github/workflows/pr-merge-conflict-fixer.yaml +++ b/.github/workflows/pr-merge-conflict-fixer.yaml @@ -78,7 +78,7 @@ jobs: node-version: "24.18.1" - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: ./trusted/.github/actions/setup-reviewed-npm - id: prepare name: Reproduce the recorded conflict @@ -153,7 +153,7 @@ jobs: node-version: "24.18.1" - name: Install reviewed npm - uses: ./.github/actions/setup-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 b8711711302..2d3d2ef54b4 100644 --- a/.github/workflows/pr-review-advisor.yaml +++ b/.github/workflows/pr-review-advisor.yaml @@ -110,7 +110,7 @@ jobs: cache: npm cache-dependency-path: advisor/package-lock.json - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: ./advisor/.github/actions/setup-reviewed-npm - name: Install locked runtime run: | @@ -217,7 +217,7 @@ jobs: cache-dependency-path: advisor/package-lock.json - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: ./advisor/.github/actions/setup-reviewed-npm - name: Load advisor runtime image run: node --experimental-strip-types --no-warnings "$ADVISOR_DIR/tools/pr-review-advisor/export-runtime-env.mts" @@ -349,7 +349,7 @@ jobs: node-version: "24.18.1" - name: Install reviewed npm - uses: ./.github/actions/setup-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 0bfef9028f9..d8d1faf9bfb 100644 --- a/.github/workflows/pr-self-hosted.yaml +++ b/.github/workflows/pr-self-hosted.yaml @@ -105,6 +105,7 @@ jobs: 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 @@ -157,7 +158,7 @@ jobs: ref: ${{ github.sha }} - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d - name: Bind E2E correlation identity shell: bash diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index d0a0503a303..fa68372dd24 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -483,7 +483,7 @@ jobs: nemoclaw/package-lock.json - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e - name: Install dependencies run: bash .trusted-ci-actions/.github/actions/ci-install-dependencies.sh diff --git a/Dockerfile b/Dockerfile index 944dbb08d82..49a1db6951c 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,9 +11,23 @@ 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== +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 -COPY scripts/lib/bundled-npm-package.mts scripts/upgrade-bundled-npm.mts /scripts/ -RUN apt-get update && apt-get install -y --no-install-recommends curl=8.14.1-2+deb13u4 && rm -rf /var/lib/apt/lists/* && node --experimental-strip-types /scripts/upgrade-bundled-npm.mts --npm-root /usr/local/lib/node_modules/npm +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20250419 curl=8.14.1-2+deb13u4 && 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 --experimental-strip-types /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 --experimental-strip-types /scripts/patch-bundled-npm-tar.mts --npm-root /usr/local/lib/node_modules/npm +# hadolint ignore=DL3059 +RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts --npm-root /usr/local/lib/node_modules/npm +# hadolint ignore=DL3059 +RUN node --experimental-strip-types /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 \ @@ -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,9 +65,8 @@ 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. +# 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 @@ -137,8 +142,7 @@ ADD --chmod=0444 --checksum=sha256:b1b01eb1522aea8f652cc7b692d1c417195713deb12b3 # hadolint ignore=DL3006 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. +# Enforce checksum-addressed archives, SRI, offline install, and target architecture (#5896). FROM npm12 AS codex-acp-runtime ARG TARGETARCH ARG CODEX_ACP_0_11_1_INTEGRITY @@ -541,14 +545,9 @@ 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/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts -COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.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-archive.mts scripts/lib/bundled-npm-package.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/npm-audit-receipt.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/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 @@ -638,26 +637,22 @@ ARG MCPORTER_0_7_3_INTEGRITY=sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImu ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_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 --experimental-strip-types /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; }; \ @@ -678,9 +673,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 @@ -920,7 +913,7 @@ RUN --network=default \ 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; \ diff --git a/Dockerfile.base b/Dockerfile.base index ed082ff24d5..c16423464b8 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -434,9 +434,11 @@ COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.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 --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 88b4f465a75..55e42de06f1 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 --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 @@ -76,6 +79,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 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 @@ -255,6 +261,15 @@ RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ # Cross-stage root copies are accepted by Docker's legacy builder and create # one final-image layer while preserving metadata on existing parent paths. COPY --from=hermes-npm-patch-payload / / +COPY --from=reviewed-npm-archive /npm-12.0.2.tgz /tmp/npm-12.0.2.tgz + +# 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 --experimental-strip-types /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 immutable selected base owns the reviewed Hindsight and agent-browser # patches and the root-owned agent-browser runtime. Verify that inherited boundary diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 94209044420..640decff07e 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -243,7 +243,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 diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index f5ef6c9d09f..b6935291187 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 @@ -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 --experimental-strip-types /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 --experimental-strip-types /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 --experimental-strip-types /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 fa4c45cb798..8544644ca1e 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -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 \ diff --git a/agents/openclaw/dependency-review.md b/agents/openclaw/dependency-review.md index 39299a736d8..ac78cd684a8 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/pi/Dockerfile b/agents/pi/Dockerfile index 483a22ea279..a2706e1c39e 100644 --- a/agents/pi/Dockerfile +++ b/agents/pi/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 + 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 @@ -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 --experimental-strip-types /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 --experimental-strip-types /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 --experimental-strip-types /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 b69354242aa..7964a29d28f 100644 --- a/agents/pi/Dockerfile.base +++ b/agents/pi/Dockerfile.base @@ -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" diff --git a/ci/reviewed-npm-audit.json b/ci/reviewed-npm-audit.json index 6653f9b726b..24acac781ed 100644 --- a/ci/reviewed-npm-audit.json +++ b/ci/reviewed-npm-audit.json @@ -3,6 +3,7 @@ "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.106.tgz", diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index b25664703ba..69906a25d8f 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -111,6 +111,36 @@ "test": "selects the reviewed Node and npm identities before further steps", "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", diff --git a/docs/security/advisory-early-warning.md b/docs/security/advisory-early-warning.md index 5f364ad4255..07fb6468e3a 100644 --- a/docs/security/advisory-early-warning.md +++ b/docs/security/advisory-early-warning.md @@ -95,11 +95,12 @@ The same #7338 sign-off gate applies to this work. Each reviewed 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, npm version, fixed Yarn audit registry origin, command arguments, and parser identity match. Until 2026-09-11, image builds may accept a still-current npmjs receipt only through the explicit legacy transition. Remove the legacy option and verifier path after Yarn-bound receipts replace the retained npmjs receipts. +A configured cache reuses a response only when the package and lock bytes, exact npm version and verified archive integrity, fixed Yarn audit registry origin, command arguments, and parser identity match. Until 2026-09-11, image builds may accept a still-current npmjs receipt only through the explicit legacy transition. Remove the legacy option and verifier path after Yarn-bound receipts replace the retained npmjs receipts. 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 ca794d55adb..449aa4e3d55 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -56,6 +56,7 @@ type AuditConfig = Readonly<{ exceptionFile: string; lockedGraphs: readonly LockedGraph[]; nodeVersion: string; + npmArchiveSha256: string; npmIntegrity: string; npmVersion: string; registryOrigin: string; @@ -200,6 +201,8 @@ export function parseAuditConfig(contents: string): AuditConfig { 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) || @@ -288,6 +291,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, @@ -299,7 +321,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, @@ -307,11 +329,12 @@ 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", @@ -321,7 +344,7 @@ function materializeArchiveGraph( "--omit=dev", "--no-audit", "--no-fund", - ...archives.map((archive) => archive.archivePath), + ...archives, ], graphDirectory, ); @@ -713,6 +736,7 @@ function auditLockedGraph( tempRoot: string, exceptionFile: string, artifactDirectory: string, + npmIntegrity: string, npmVersion: string, ) { const directory = materializeLockedGraph(graph, tempRoot, config.registryOrigin); @@ -724,6 +748,7 @@ function auditLockedGraph( provenance: { label: graph.label, nodeVersion: process.version, + npmIntegrity, npmVersion, packageSpecs: [graph.packageSpec], }, @@ -754,6 +779,7 @@ function auditSourceGraph( tempRoot: string, exceptionFile: string, artifactDirectory: string, + npmIntegrity: string, npmVersion: string, ) { const sourcePackage = targetRepositoryPath("package.json", "NemoClaw CLI package manifest"); @@ -776,6 +802,7 @@ function auditSourceGraph( directory, exceptionFile, artifactDirectory, + npmIntegrity, npmVersion, packageSpec: `${sourceManifest.name}@${sourceManifest.version}`, threshold: config.severityThreshold, @@ -787,6 +814,7 @@ export function auditMaterializedSourceGraph( artifactDirectory: string; directory: string; exceptionFile: string; + npmIntegrity: string; npmVersion: string; packageSpec: string; threshold: Severity; @@ -804,6 +832,7 @@ export function auditMaterializedSourceGraph( provenance: { label: SOURCE_GRAPH.label, nodeVersion: process.version, + npmIntegrity: options.npmIntegrity, npmVersion: options.npmVersion, packageSpecs: [options.packageSpec], }, @@ -823,6 +852,7 @@ export function emitAuditReceipt( options: Readonly<{ artifactDirectory: string; graphId: string; + npmIntegrity: string; npmVersion: string; packageJsonFile: string; packageLockFile: string; @@ -855,6 +885,7 @@ export function emitAuditReceipt( ), exceptionPolicySha256: options.result.exceptionPolicySha256, graphId: options.graphId, + npmIntegrity: options.npmIntegrity, npmVersion: options.npmVersion, packageJson: fs.readFileSync(options.packageJsonFile), packageLock: fs.readFileSync(options.packageLockFile), @@ -929,6 +960,7 @@ function main(): void { tempRoot, exceptionFile, artifactDirectory, + config.npmIntegrity, npmVersion, ); const archiveDirectory = materializeArchiveGraph( @@ -944,6 +976,7 @@ function main(): void { provenance: { label: "reviewed archive graph", nodeVersion: process.version, + npmIntegrity: config.npmIntegrity, npmVersion, packageSpecs: config.archivePackages.map((reviewed) => reviewed.packageSpec), }, @@ -960,6 +993,7 @@ function main(): void { tempRoot, exceptionFile, artifactDirectory, + config.npmIntegrity, npmVersion, ), ); @@ -977,6 +1011,7 @@ function main(): void { emitAuditReceipt({ artifactDirectory, graphId: SOURCE_GRAPH.id, + npmIntegrity: config.npmIntegrity, npmVersion, packageJsonFile: targetRepositoryPath("package.json", "NemoClaw CLI package manifest"), packageLockFile: targetRepositoryPath("package-lock.json", "NemoClaw CLI lockfile"), @@ -988,6 +1023,7 @@ function main(): void { emitAuditReceipt({ artifactDirectory, graphId: config.archiveGraphId, + npmIntegrity: config.npmIntegrity, npmVersion, packageJsonFile: path.join(archiveDirectory, "package.json"), packageLockFile: path.join(archiveDirectory, "package-lock.json"), @@ -1001,6 +1037,7 @@ function main(): void { emitAuditReceipt({ artifactDirectory, graphId: graph.id, + npmIntegrity: config.npmIntegrity, npmVersion, packageJsonFile: targetRepositoryPath( path.join(graph.directory, "package.json"), diff --git a/scripts/lib/npm-audit-receipt.mts b/scripts/lib/npm-audit-receipt.mts index 1a1f9df116b..a0337cf27a4 100755 --- a/scripts/lib/npm-audit-receipt.mts +++ b/scripts/lib/npm-audit-receipt.mts @@ -29,6 +29,7 @@ const RECEIPT_KEYS = [ "exceptionPolicySha256", "expiresAt", "graphId", + "npmIntegrity", "npmVersion", "packageJsonSha256", "packageLockSha256", @@ -47,13 +48,14 @@ export type AuditReceipt = Readonly<{ exceptionPolicySha256: string; expiresAt: string; graphId: string; + npmIntegrity: string; npmVersion: string; packageJsonSha256: string; packageLockSha256: string; rawResponseSha256: string; registryOrigin: string; result: "pass"; - schemaVersion: 1; + schemaVersion: 2; severityThreshold: "info" | "low" | "moderate" | "high" | "critical"; }>; @@ -101,6 +103,7 @@ export function createAuditReceipt( createdAt?: Date; exceptionPolicySha256: string; graphId: string; + npmIntegrity: string; npmVersion: string; packageJson: string | Buffer; packageLock: string | Buffer; @@ -120,13 +123,14 @@ export function createAuditReceipt( exceptionPolicySha256: options.exceptionPolicySha256, expiresAt: new Date(created.getTime() + RECEIPT_LIFETIME_MS).toISOString(), graphId: options.graphId, + npmIntegrity: options.npmIntegrity, npmVersion: options.npmVersion, packageJsonSha256: sha256(options.packageJson), packageLockSha256: sha256(options.packageLock), rawResponseSha256: sha256(options.rawResponse), registryOrigin: options.registryOrigin, result: "pass", - schemaVersion: 1, + schemaVersion: 2, severityThreshold: options.severityThreshold, }; } @@ -135,6 +139,7 @@ export function parseAndVerifyAuditReceipt( contents: string, expected: Readonly<{ graphId: string; + npmIntegrity: string; npmVersion: string; exceptionPolicy: string | Buffer; severityThreshold: AuditReceipt["severityThreshold"]; @@ -156,10 +161,14 @@ export function parseAndVerifyAuditReceipt( throw new Error("receipt must be an object"); const value = parsed as Record; exactKeys(value, RECEIPT_KEYS, "receipt"); - if (value.schemaVersion !== 1 || value.result !== "pass") - throw new Error("receipt is not a passing schema version 1 receipt"); - if (value.graphId !== expected.graphId || value.npmVersion !== expected.npmVersion) - throw new Error("receipt identity does not match expected graph and npm"); + if (value.schemaVersion !== 2 || value.result !== "pass") + throw new Error("receipt is not a passing schema version 2 receipt"); + if ( + value.graphId !== expected.graphId || + value.npmVersion !== expected.npmVersion || + value.npmIntegrity !== expected.npmIntegrity + ) + throw new Error("receipt identity does not match expected graph and npm artifact"); const now = (expected.now ?? new Date()).getTime(); const currentContract = value.registryOrigin === expected.registryOrigin && @@ -211,7 +220,9 @@ export function canonicalAuditReceipt(receipt: AuditReceipt): string { return `${JSON.stringify(receipt, null, 2)}\n`; } -export function reviewedNpmVersionFromConfig(contents: string): string { +export function reviewedNpmIdentityFromConfig( + contents: string, +): Readonly<{ npmIntegrity: string; npmVersion: string }> { let parsed: unknown; try { parsed = JSON.parse(contents); @@ -222,6 +233,10 @@ export function reviewedNpmVersionFromConfig(contents: string): string { typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Record).npmVersion : undefined; + const npmIntegrity = + typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record).npmIntegrity + : undefined; if ( typeof npmVersion !== "string" || !/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/.test(npmVersion) || @@ -229,7 +244,14 @@ export function reviewedNpmVersionFromConfig(contents: string): string { ) { throw new Error("reviewed npm audit configuration has an invalid npmVersion"); } - return npmVersion; + if ( + typeof npmIntegrity !== "string" || + !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(npmIntegrity) || + /[\r\n]/.test(npmIntegrity) + ) { + throw new Error("reviewed npm audit configuration has an invalid npmIntegrity"); + } + return { npmIntegrity, npmVersion }; } function cli(args: readonly string[]): void { @@ -269,12 +291,12 @@ function cli(args: readonly string[]): void { const packageLock = fs.readFileSync(values.get("--package-lock")!); const rawResponse = fs.readFileSync(values.get("--raw-report")!); const exceptionPolicy = fs.readFileSync(values.get("--exceptions")!); - const npmVersion = reviewedNpmVersionFromConfig( + const npmIdentity = reviewedNpmIdentityFromConfig( fs.readFileSync(values.get("--audit-config")!, "utf8"), ); parseAndVerifyAuditReceipt(fs.readFileSync(values.get("--receipt")!, "utf8"), { graphId: values.get("--graph")!, - npmVersion, + ...npmIdentity, exceptionPolicy, severityThreshold: values.get("--threshold")! as AuditReceipt["severityThreshold"], packageJson, diff --git a/scripts/lib/openclaw-npm-remediation.mts b/scripts/lib/openclaw-npm-remediation.mts index 0623c3b7851..3cf9639fc65 100755 --- a/scripts/lib/openclaw-npm-remediation.mts +++ b/scripts/lib/openclaw-npm-remediation.mts @@ -24,6 +24,16 @@ import { packReviewedNpmArchive } from "./reviewed-npm-archive.mts"; type JsonObject = Record; +export function singleNpmPackResult(value: unknown): JsonObject | 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 JsonObject) : undefined; +} + type Remediation = Readonly<{ expectedPatchedMetadataIntegrity?: string; expectedPatchedTreeIntegrity?: string; @@ -138,25 +148,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 +178,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 +1392,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 a15ebf6d107..407fbac9757 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 = "12.0.2"; const REVIEWED_IP_ADDRESS_VERSIONS = new Set([ AFFECTED_IP_ADDRESS_VERSION, diff --git a/scripts/lib/reviewed-npm-archive.mts b/scripts/lib/reviewed-npm-archive.mts index 2e879f6cc6e..44f48f223ee 100755 --- a/scripts/lib/reviewed-npm-archive.mts +++ b/scripts/lib/reviewed-npm-archive.mts @@ -211,7 +211,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 +220,12 @@ 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 entries = Array.isArray(parsed) + ? parsed + : typeof parsed === "object" && parsed !== null + ? Object.values(parsed) + : []; + const entry = entries.length === 1 ? entries[0] : undefined; 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 b79b271c930..221c723a761 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -63,8 +63,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[] }>; @@ -77,6 +82,7 @@ export type AuditProvenance = Readonly<{ export type AuditProvenanceContext = Readonly<{ label: string; nodeVersion: string; + npmIntegrity: string; npmVersion: string; packageSpecs: readonly string[]; }>; @@ -123,7 +129,7 @@ export function npmAuditProcessOptions(directory: string) { }; } const NPM_AUDIT_CACHE_MAX_BYTES = 64 * 1024 * 1024; -const NPM_AUDIT_CACHE_SCHEMA_VERSION = 1; +const NPM_AUDIT_CACHE_SCHEMA_VERSION = 2; const NPM_AUDIT_PARSER_IDENTITY = "reviewed-npm-audit-report-v1"; type NpmAuditCommandResult = Readonly<{ @@ -460,6 +466,7 @@ export function buildAuditProvenance( finishedAt: string; label: string; nodeVersion: string; + npmIntegrity: string; npmVersion: string; packageSpecs: readonly string[]; rawReportPath: string; @@ -469,8 +476,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 }, @@ -499,6 +511,7 @@ function npmVersion(directory: string): string { type AuditCacheInput = Readonly<{ argv: readonly string[]; + npmIntegrity: string; npmVersion: string; packageJsonSha256: string; packageLockSha256: string; @@ -507,7 +520,7 @@ type AuditCacheInput = Readonly<{ }>; type AuditCacheRecord = Readonly<{ - schemaVersion: 1; + schemaVersion: 2; createdAt: string; input: AuditCacheInput; result: Readonly<{ stdout: string; exitCode: number }>; @@ -529,6 +542,7 @@ function canonicalRegistryOrigin(registry: string): string | null { export function buildAuditCacheInput( directory: string, + npmIntegrity: string, npmVersion: string, registry: string, ): AuditCacheInput { @@ -536,6 +550,7 @@ export function buildAuditCacheInput( if (!registryOrigin) throw new Error("npm audit cache requires a valid HTTP(S) registry"); return { argv: NPM_AUDIT_ARGV, + npmIntegrity, npmVersion, packageJsonSha256: sha256(fs.readFileSync(path.join(directory, "package.json"))), packageLockSha256: sha256(fs.readFileSync(path.join(directory, "package-lock.json"))), @@ -562,6 +577,7 @@ function parseAuditCacheRecord(source: string): AuditCacheRecord { input, new Set([ "argv", + "npmIntegrity", "npmVersion", "packageJsonSha256", "packageLockSha256", @@ -575,6 +591,7 @@ function parseAuditCacheRecord(source: string): AuditCacheRecord { if (!Array.isArray(input.argv) || JSON.stringify(input.argv) !== JSON.stringify(NPM_AUDIT_ARGV)) throw new Error("npm audit cache input.argv is invalid"); for (const key of [ + "npmIntegrity", "npmVersion", "packageJsonSha256", "packageLockSha256", @@ -593,6 +610,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 ) @@ -650,7 +668,7 @@ function writeAuditCache( ): void { if (!Number.isSafeInteger(result.status)) return; const record: AuditCacheRecord = { - schemaVersion: 1, + schemaVersion: 2, createdAt, input, result: { stdout: result.stdout, exitCode: result.status as number }, @@ -851,6 +869,7 @@ export function runReviewedNpmAudit( directory: string; exceptionFile: string; graph: string; + npmIntegrity?: string; provenance?: AuditProvenanceContext; reportFile?: string; resultFile?: string; @@ -867,9 +886,14 @@ export function runReviewedNpmAudit( const registry = NPM_AUDIT_REGISTRY; let cacheInput: AuditCacheInput | undefined; if (cacheFile) { + const npmIntegrity = options.provenance?.npmIntegrity ?? options.npmIntegrity; + if (!npmIntegrity) { + throw new Error("reviewed npm audit cache requires the reviewed npm integrity"); + } try { cacheInput = buildAuditCacheInput( options.directory, + npmIntegrity, options.provenance?.npmVersion ?? npmVersion(options.directory), registry, ); @@ -914,6 +938,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), @@ -954,6 +979,7 @@ function parseCliArgs(args: readonly string[]): { directory: string; exceptionFile: string; graph: string; + npmIntegrity?: string; reportFile?: string; resultFile?: string; threshold: Severity; @@ -972,6 +998,7 @@ function parseCliArgs(args: readonly string[]): { "--directory", "--exceptions", "--graph", + "--npm-integrity", "--report", "--result", "--threshold", @@ -990,11 +1017,19 @@ function parseCliArgs(args: readonly string[]): { } if (!SEVERITIES.includes(threshold as Severity)) throw new Error("reviewed npm audit threshold is invalid"); + const npmIntegrity = values.get("--npm-integrity"); + if (values.has("--cache") && !npmIntegrity) { + throw new Error("reviewed npm audit --cache requires --npm-integrity"); + } + if (npmIntegrity && !/^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(npmIntegrity)) { + throw new Error("reviewed npm audit npm integrity is invalid"); + } return { ...(values.has("--cache") ? { cacheFile: values.get("--cache") } : {}), directory, exceptionFile, graph, + ...(npmIntegrity ? { npmIntegrity } : {}), threshold: threshold as Severity, ...(values.has("--report") ? { reportFile: values.get("--report") } : {}), ...(values.has("--result") ? { resultFile: values.get("--result") } : {}), diff --git a/scripts/lib/reviewed-npm-identity.mts b/scripts/lib/reviewed-npm-identity.mts new file mode 100755 index 00000000000..7c5ec7f4ec0 --- /dev/null +++ b/scripts/lib/reviewed-npm-identity.mts @@ -0,0 +1,37 @@ +#!/usr/bin/env -S node --experimental-strip-types +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { 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 14c0cc2cb5e..6fbbc47b92f 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", "12.0.2"]); +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, npm@11.18.0, or npm@12.0.2`, + `reviewed npm cache seed does not support npm@${npmVersion}; expected npm@${REVIEWED_NPM_VERSION}`, ); } const npmRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim(); diff --git a/scripts/patch-bundled-npm-brace-expansion.mts b/scripts/patch-bundled-npm-brace-expansion.mts index e3104b48d78..4b45d718c6e 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 = "12.0.2"; 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 2a3c7bf5c15..535480da25b 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] { 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 ea34fa7c9b3..a84b331b111 100755 --- a/scripts/upgrade-bundled-npm.mts +++ b/scripts/upgrade-bundled-npm.mts @@ -23,11 +23,19 @@ 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 = "12.0.2"; -export const REVIEWED_NPM_INTEGRITY = - "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw=="; -export const REVIEWED_NPM_TARBALL = "https://registry.npmjs.org/npm/-/npm-12.0.2.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 12.0.2 includes affected tar 7.5.19, so every image composition @@ -185,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; @@ -209,7 +218,14 @@ export function upgradeBundledNpm( const currentVersion = npmVersion(root); const commandRunner = dependencies.commandRunner ?? run; + if (dependencies.archivePath !== undefined && dependencies.prepareArchive !== undefined) { + throw new Error("reviewed npm upgrade cannot select both archivePath and prepareArchive"); + } + if (currentVersion === REVIEWED_NPM_VERSION) { + if (dependencies.archivePath !== undefined) { + verifyReviewedNpmArchive(resolve(dependencies.archivePath)); + } const reviewed = verifyReviewedNpm(root); commandRunner("npm", ["--version"]); commandRunner("npx", ["--version"]); @@ -221,7 +237,13 @@ export function upgradeBundledNpm( ); } - 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); @@ -240,13 +262,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/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 77154902781..f41dbc624f0 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -441,6 +441,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/test/agents/openclaw/openclaw-integrity-pin-suite.ts b/test/agents/openclaw/openclaw-integrity-pin-suite.ts index f6e217dd6c9..e327de6d0a7 100644 --- a/test/agents/openclaw/openclaw-integrity-pin-suite.ts +++ b/test/agents/openclaw/openclaw-integrity-pin-suite.ts @@ -303,7 +303,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 ? [] @@ -435,8 +435,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 ? [] @@ -557,7 +556,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"', @@ -618,7 +618,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";', @@ -871,7 +872,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/, @@ -887,7 +888,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/, @@ -986,7 +987,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"); @@ -994,7 +995,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"); @@ -1028,7 +1031,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 ", ); @@ -1281,7 +1286,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"); @@ -1314,7 +1321,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); }); @@ -1437,7 +1446,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"); }); @@ -1518,9 +1527,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"'); @@ -1764,7 +1772,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-npm-remediation.test.ts b/test/agents/openclaw/openclaw-npm-remediation.test.ts index ba2d54b7ffe..771f1ddcee3 100644 --- a/test/agents/openclaw/openclaw-npm-remediation.test.ts +++ b/test/agents/openclaw/openclaw-npm-remediation.test.ts @@ -24,6 +24,7 @@ import { patchOpenClawDiagnosticsOtelPackageGraph, patchOpenClawDiscordPackageGraph, patchOpenClawPluginPackageGraph, + singleNpmPackResult, } from "../../../scripts/lib/openclaw-npm-remediation.mts"; const temporaryDirectories: string[] = []; @@ -330,12 +331,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 +367,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', @@ -393,6 +393,14 @@ afterEach(() => { }); describe("OpenClaw npm remediation", () => { + it("normalizes legacy array and npm 12 package-keyed pack results", () => { + const result = { filename: "openclaw-2026.7.1.tgz" }; + + expect(singleNpmPackResult([result])).toEqual(result); + expect(singleNpmPackResult({ openclaw: result })).toEqual(result); + expect(singleNpmPackResult({ first: result, second: result })).toBeUndefined(); + }); + it("hashes package entries through opened file descriptors", () => { const directory = mkdtempSync(path.join(tmpdir(), "nemoclaw-openclaw-tree-integrity-")); temporaryDirectories.push(directory); @@ -433,6 +441,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 +491,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 +676,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 +757,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-optional-plugin-build.test.ts b/test/agents/openclaw/openclaw-optional-plugin-build.test.ts index 8e6ec8f7ecf..ac87f7ea442 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/automation/e2e/platform-vitest-main-workflow.test.ts b/test/automation/e2e/platform-vitest-main-workflow.test.ts index af34e3416ae..09b6a75ed02 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -14,8 +14,14 @@ import { const WORKFLOW_PATH = ".github/workflows/platform-vitest-main.yaml"; const WSL_HELPER_PATH = "tools/wsl/ci-helper.ps1"; const MACOS_REQUIREMENTS_PATH = "ci/platform-vitest-macos-requirements.lock"; +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]; @@ -30,6 +36,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"'); + expect(wslHelperSource.indexOf('test "$actual_npm_integrity"')).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/releases/npm-audit-receipt.test.ts b/test/automation/releases/npm-audit-receipt.test.ts index 63aede6fe08..c36f72abae0 100644 --- a/test/automation/releases/npm-audit-receipt.test.ts +++ b/test/automation/releases/npm-audit-receipt.test.ts @@ -15,9 +15,12 @@ import { } from "../../../scripts/lib/npm-audit-receipt.mts"; const NOW = new Date("2026-09-04T00:00:00.000Z"); +const NPM_INTEGRITY = + "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw=="; const inputs = { graphId: "mcporter-runtime", - npmVersion: "10.9.4", + npmIntegrity: NPM_INTEGRITY, + npmVersion: "12.0.2", exceptionPolicy: '{"schemaVersion":1,"exceptions":[]}\n', severityThreshold: "high", packageJson: "package", @@ -34,6 +37,7 @@ function receipt(createdAt = NOW) { createdAt, exceptionPolicySha256: sha256(inputs.exceptionPolicy), graphId: inputs.graphId, + npmIntegrity: inputs.npmIntegrity, npmVersion: inputs.npmVersion, packageJson: inputs.packageJson, packageLock: inputs.packageLock, @@ -127,6 +131,12 @@ describe("reviewed npm audit receipt", () => { value.exceptionPolicySha256 = "a".repeat(64); }, ], + [ + "different npm integrity", + (value: any) => { + value.npmIntegrity = `sha512-${Buffer.alloc(64, 1).toString("base64")}`; + }, + ], ])("fails closed for %s", (_label, mutate) => { const value: any = receipt(); mutate(value); @@ -173,7 +183,7 @@ describe("reviewed npm audit receipt", () => { fs.writeFileSync(path.join(root, "raw.json"), inputs.rawResponse); fs.writeFileSync( path.join(root, "reviewed-npm-audit.json"), - JSON.stringify({ npmVersion: inputs.npmVersion }), + JSON.stringify({ npmIntegrity: inputs.npmIntegrity, npmVersion: inputs.npmVersion }), ); const auditReceipt = legacy ? { @@ -231,7 +241,7 @@ describe("reviewed npm audit receipt", () => { status: 0, }, { - error: "receipt identity does not match expected graph and npm", + error: "receipt identity does not match expected graph and npm artifact", label: "a mismatched npm version", npmVersion: "11.18.0", policy: undefined, @@ -255,7 +265,7 @@ describe("reviewed npm audit receipt", () => { ); fs.writeFileSync( path.join(root, "reviewed-npm-audit.json"), - JSON.stringify({ npmVersion }), + JSON.stringify({ npmIntegrity: inputs.npmIntegrity, npmVersion }), ); const result = spawnSync( 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-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 7c8d7a441e2..221c0289427 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -17,7 +17,6 @@ import { materializeSourceGraph, normalizeOpenClawSignatureAlias, parseAuditConfig, - reviewedArchiveGraphManifest, selectReviewedLockSha256, validateWechatRuntimeInputs, verifyMaterializedLockedGraph, @@ -133,6 +132,7 @@ function runConsolidatedAuditFixture( }, ], nodeVersion: process.version.slice(1), + npmArchiveSha256: REVIEWED_AUDIT_CONFIG.npmArchiveSha256, npmIntegrity: REVIEWED_AUDIT_CONFIG.npmIntegrity, npmVersion: REVIEWED_AUDIT_CONFIG.npmVersion, registryOrigin: "https://registry.npmjs.org/", @@ -340,6 +340,8 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { expect(cacheBucketStep.run).toContain( "const targetRoot = process.env.NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT;", ); + expect(cacheBucketStep.run).toContain("npmIntegrity: config.npmIntegrity"); + expect(cacheBucketStep.run).toContain("npmArchiveSha256: config.npmArchiveSha256"); expect(cacheBucketStep.run).not.toContain("${{ inputs.cache-directory }}"); expect(cacheBucketStep.run).not.toContain("${{ inputs.target-root }}"); }); @@ -450,6 +452,8 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { emitAuditReceipt({ artifactDirectory: root, graphId: "temporary-graph", + npmIntegrity: + "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw==", npmVersion: "12.0.2", packageJsonFile, packageLockFile, @@ -656,154 +660,6 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { ); }); - it("rejects an affected tar release for the reviewed archive graph", () => { - expect(() => reviewedArchiveGraphManifest("7.5.20")).toThrow( - "reviewed archive graph tar version must be exactly 7.5.21", - ); - }); - - it("rejects a mismatched npm bootstrap archive before installation (#8253)", () => { - 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 npmStub = path.join(bin, "npm"); - const bootstrap = path.join( - REPO_ROOT, - ".github", - "actions", - "ci-reviewed-npm-audit", - "verify-and-install-npm.sh", - ); - - try { - fs.mkdirSync(bin); - fs.writeFileSync( - npmStub, - `#!/usr/bin/env bash -set -euo pipefail -printf '%s\\n' "$1" >> "$NEMOCLAW_TEST_NPM_LOG" -case "$1" in - pack) - shift - download_dir="" - while [ "$#" -gt 0 ]; do - if [ "$1" = "--pack-destination" ]; then - download_dir="$2" - break - fi - shift - done - [ -n "$download_dir" ] - printf 'tampered archive\\n' > "$download_dir/npm-12.0.2.tgz" - ;; - install) - : > "$NEMOCLAW_TEST_INSTALL_MARKER" - ;; - *) - exit 2 - ;; -esac -`, - { mode: 0o755 }, - ); - - const result = spawnSync("bash", [bootstrap], { - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_REVIEWED_NPM_INTEGRITY: "sha512-invalid", - NEMOCLAW_REVIEWED_NPM_VERSION: "12.0.2", - NEMOCLAW_TEST_INSTALL_MARKER: installMarker, - NEMOCLAW_TEST_NPM_LOG: npmLog, - PATH: `${bin}:${process.env.PATH ?? ""}`, - RUNNER_TEMP: root, - }, - }); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("npm@12.0.2 archive integrity mismatch"); - expect(fs.readFileSync(npmLog, "utf8")).toBe("pack\n"); - expect(fs.existsSync(installMarker)).toBe(false); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it("installs a matching npm bootstrap archive offline (#8253)", () => { - 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 npmStub = path.join(bin, "npm"); - const archiveContents = "verified archive\n"; - const bootstrap = path.join( - REPO_ROOT, - ".github", - "actions", - "ci-reviewed-npm-audit", - "verify-and-install-npm.sh", - ); - - try { - fs.mkdirSync(bin); - fs.writeFileSync( - npmStub, - `#!/usr/bin/env bash -set -euo pipefail -printf '%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" -case "$1" in - pack) - shift - download_dir="" - while [ "$#" -gt 0 ]; do - if [ "$1" = "--pack-destination" ]; then - download_dir="$2" - break - fi - shift - done - [ -n "$download_dir" ] - printf 'verified archive\\n' > "$download_dir/npm-12.0.2.tgz" - ;; - install) - ;; - --version) - printf '12.0.2\n' - ;; - *) - exit 2 - ;; -esac -`, - { mode: 0o755 }, - ); - - const integrity = `sha512-${createHash("sha512").update(archiveContents).digest("base64")}`; - const result = spawnSync("bash", [bootstrap], { - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_REVIEWED_NPM_INTEGRITY: integrity, - NEMOCLAW_REVIEWED_NPM_VERSION: "12.0.2", - NEMOCLAW_TEST_NPM_LOG: npmLog, - PATH: `${bin}:${process.env.PATH ?? ""}`, - RUNNER_TEMP: root, - }, - }); - - const npmInvocations = fs.readFileSync(npmLog, "utf8").trim().split("\n"); - expect(result.status).toBe(0); - expect(npmInvocations).toHaveLength(3); - expect(npmInvocations[0]).toContain("pack npm@12.0.2 --pack-destination"); - 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 { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - it("materializes the NemoClaw production graph without changing its lock (#8116)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-source-graph-")); const source = path.join(root, "source"); @@ -1393,6 +1249,8 @@ esac artifactDirectory: "/artifacts", directory: "/materialized", exceptionFile: "/exceptions.json", + npmIntegrity: + "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw==", npmVersion: "12.0.2", packageSpec: "nemoclaw@0.0.0", threshold: "high", @@ -1406,6 +1264,8 @@ esac graph: "nemoclaw-cli", provenance: { label: "NemoClaw CLI locked production graph", + npmIntegrity: + "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw==", npmVersion: "12.0.2", packageSpecs: ["nemoclaw@0.0.0"], }, diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index fbbd3eacf40..efb774bd4f9 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, @@ -37,11 +41,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>, @@ -481,7 +523,8 @@ describe("reviewed npm audit raw cache", () => { try { const input = buildAuditCacheInput( directory, - "10.9.7", + NPM_INTEGRITY, + "12.0.2", "https://user:secret@registry.npmjs.org/private/path?token=query#fragment", ); const filename = path.join(directory, "cache.json"); @@ -491,7 +534,7 @@ describe("reviewed npm audit raw cache", () => { fs.writeFileSync( filename, JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, createdAt: "2026-07-21T11:00:00.000Z", input, result: { stdout, exitCode: 0 }, @@ -518,12 +561,17 @@ describe("reviewed npm audit raw cache", () => { ])("rejects a %s record", (_label, createdOffset) => { const directory = fixture(); try { - const input = buildAuditCacheInput(directory, "10.9.7", "https://registry.npmjs.org/"); + const input = buildAuditCacheInput( + directory, + NPM_INTEGRITY, + "12.0.2", + "https://registry.npmjs.org/", + ); const filename = path.join(directory, "cache.json"); fs.writeFileSync( filename, JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, createdAt: new Date(NOW.valueOf() + createdOffset).toISOString(), input, result: { stdout: "{}", exitCode: 0 }, @@ -538,14 +586,19 @@ describe("reviewed npm audit raw cache", () => { it("rejects malformed, extra-field, and input-mismatched records", () => { const directory = fixture(); try { - const input = buildAuditCacheInput(directory, "10.9.7", "https://registry.npmjs.org/"); + const input = buildAuditCacheInput( + directory, + NPM_INTEGRITY, + "12.0.2", + "https://registry.npmjs.org/", + ); const filename = path.join(directory, "cache.json"); fs.writeFileSync(filename, "not json"); expect(readAuditCache(filename, input, NOW)).toBeNull(); fs.writeFileSync( filename, JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, createdAt: NOW.toISOString(), input, result: { stdout: "{}", exitCode: 0 }, @@ -553,21 +606,33 @@ describe("reviewed npm audit raw cache", () => { }), ); expect(readAuditCache(filename, input, NOW)).toBeNull(); - const changed = { ...input, npmVersion: "11.0.0" }; + const changed = { ...input, npmVersion: "11.18.0" }; fs.writeFileSync( filename, JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, createdAt: NOW.toISOString(), input, result: { stdout: "{}", exitCode: 0 }, }), ); expect(readAuditCache(filename, changed, NOW)).toBeNull(); + expect( + readAuditCache( + filename, + { ...input, npmIntegrity: `sha512-${Buffer.alloc(64, 1).toString("base64")}` }, + NOW, + ), + ).toBeNull(); fs.writeFileSync(path.join(directory, "package.json"), '{"name":"changed"}\n'); - expect(buildAuditCacheInput(directory, "10.9.7", "https://registry.npmjs.org/")).not.toEqual( - input, - ); + expect( + buildAuditCacheInput( + directory, + NPM_INTEGRITY, + "12.0.2", + "https://registry.npmjs.org/", + ), + ).not.toEqual(input); } finally { fs.rmSync(directory, { recursive: true, force: true }); } @@ -646,8 +711,9 @@ describe("reviewed 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/", @@ -655,8 +721,13 @@ describe("reviewed 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: { @@ -674,8 +745,9 @@ describe("reviewed 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/", @@ -731,8 +803,9 @@ describe("reviewed 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 new file mode 100644 index 00000000000..d6c8f7b49ef --- /dev/null +++ b/test/automation/releases/reviewed-npm-bootstrap.test.ts @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const REPO_ROOT = path.join(import.meta.dirname, "../../.."); +const BOOTSTRAP = path.join( + REPO_ROOT, + ".github", + "actions", + "setup-reviewed-npm", + "verify-and-install-npm.sh", +); + +function identity(archive: string): Record { + return { + npmArchiveSha256: createHash("sha256").update(archive).digest("hex"), + npmIntegrity: `sha512-${createHash("sha512").update(archive).digest("base64")}`, + npmVersion: "12.0.2", + }; +} + +describe("reviewed npm bootstrap", () => { + it("rejects a mismatched archive before installation (#8253)", () => { + 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 npmStub = path.join(bin, "npm"); + const identityPath = path.join(root, "reviewed-npm-audit.json"); + + try { + fs.mkdirSync(bin); + fs.writeFileSync(identityPath, `${JSON.stringify(identity("reviewed archive\n"))}\n`); + fs.writeFileSync( + npmStub, + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$1" >> "$NEMOCLAW_TEST_NPM_LOG" +case "$1" in + pack) + shift + download_dir="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--pack-destination" ]; then + download_dir="$2" + break + fi + shift + done + [ -n "$download_dir" ] + printf 'tampered archive\\n' > "$download_dir/npm-12.0.2.tgz" + ;; + install) + : > "$NEMOCLAW_TEST_INSTALL_MARKER" + ;; + *) + exit 2 + ;; +esac +`, + { mode: 0o755 }, + ); + + const result = spawnSync("bash", [BOOTSTRAP, identityPath], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_TEST_INSTALL_MARKER: installMarker, + NEMOCLAW_TEST_NPM_LOG: npmLog, + PATH: `${bin}:${process.env.PATH ?? ""}`, + RUNNER_TEMP: root, + }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("npm@12.0.2 archive integrity mismatch"); + expect(fs.readFileSync(npmLog, "utf8")).toBe("pack\n"); + expect(fs.existsSync(installMarker)).toBe(false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("installs a matching archive offline (#8253)", () => { + 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 npmStub = path.join(bin, "npm"); + const identityPath = path.join(root, "reviewed-npm-audit.json"); + const archive = "verified archive\n"; + + try { + fs.mkdirSync(bin); + fs.writeFileSync( + npmStub, + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" +case "$1" in + pack) + shift + download_dir="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--pack-destination" ]; then + download_dir="$2" + break + fi + shift + done + [ -n "$download_dir" ] + printf 'verified archive\\n' > "$download_dir/npm-12.0.2.tgz" + ;; + install) + ;; + --version) + printf '12.0.2\n' + ;; + *) + exit 2 + ;; +esac +`, + { mode: 0o755 }, + ); + fs.writeFileSync(identityPath, `${JSON.stringify(identity(archive))}\n`); + const result = spawnSync("bash", [BOOTSTRAP, identityPath], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_TEST_NPM_LOG: npmLog, + PATH: `${bin}:${process.env.PATH ?? ""}`, + RUNNER_TEMP: root, + }, + }); + + const npmInvocations = fs.readFileSync(npmLog, "utf8").trim().split("\n"); + expect(result.status).toBe(0); + expect(npmInvocations).toHaveLength(3); + expect(npmInvocations[0]).toContain("pack npm@12.0.2 --pack-destination"); + 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 { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/e2e/README.md b/test/e2e/README.md index df2970c63ba..607e375df49 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -673,7 +673,8 @@ then uploads the verified bytes under a content-addressed name with the shared 14-day E2E retention policy. 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/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts b/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts index e2e9fb85eb5..ca007a18d54 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 @@ -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@667c431859bf8d0a75b7f6a4946e635640e7734d", }, { name: "Run OpenClaw cross-device plugin lifecycle live test", diff --git a/test/e2e/support/prepare-e2e-workflow-boundary.test.ts b/test/e2e/support/prepare-e2e-workflow-boundary.test.ts index 825fc492633..aebd1f652e4 100644 --- a/test/e2e/support/prepare-e2e-workflow-boundary.test.ts +++ b/test/e2e/support/prepare-e2e-workflow-boundary.test.ts @@ -42,13 +42,14 @@ describe("prepare-e2e workflow boundary", () => { const action = YAML.parse(source) as Record; const runs = action.runs as { steps: WorkflowStep[] }; runs.steps.find((step) => step.name === "Set up Node")!.uses = "actions/setup-node@v7"; + runs.steps.find((step) => step.name === "Install reviewed npm")!.run = "npm install npm@latest"; runs.steps.find((step) => step.name === "Install root dependencies")!.run = "npm install"; runs.steps.find((step) => step.name === "Build CLI")!.run = "echo skipped"; fs.writeFileSync(actionPath, YAML.stringify(action)); 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 }); diff --git a/test/e2e/support/sandbox-images-workflow-boundary.test.ts b/test/e2e/support/sandbox-images-workflow-boundary.test.ts index 47726bf6000..6b5825928d9 100644 --- a/test/e2e/support/sandbox-images-workflow-boundary.test.ts +++ b/test/e2e/support/sandbox-images-workflow-boundary.test.ts @@ -636,7 +636,7 @@ describe("sandbox image workflow boundary", () => { "messaging plan image boundary must retain its 30-minute budget", "messaging plan image boundary must remain isolated from canonical image jobs", "messaging plan image boundary must set up Node exactly once", - "messaging plan image boundary must use Node 22.19.0", + "messaging plan image boundary must use Node 24.18.1", 'openclaw messaging plan image boundary must include scripts/check-production-build-args.sh "${build_args[@]}"', 'hermes messaging plan image boundary must include scripts/check-production-build-args.sh "${build_args[@]}"', 'hermes messaging plan image boundary must include --build-arg "NEMOCLAW_CORPORATE_CA_B64=${corporate_ca_b64}"', 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/managed/managed-image-publication-workflow.test.ts b/test/inference/managed/managed-image-publication-workflow.test.ts index c20211d8e1a..c1ca22506b6 100644 --- a/test/inference/managed/managed-image-publication-workflow.test.ts +++ b/test/inference/managed/managed-image-publication-workflow.test.ts @@ -104,11 +104,11 @@ describe("complete managed-image publication workflow", () => { ]); expect(restores.map(({ with: inputs }) => inputs)).toEqual([ { - key: "reviewed-npm-audit-v1-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.current }}", + key: "reviewed-npm-audit-v2-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.current }}", path: "${{ inputs.cache-directory }}", }, { - key: "reviewed-npm-audit-v1-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.previous }}", + key: "reviewed-npm-audit-v2-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.previous }}", path: "${{ inputs.cache-directory }}", }, ]); @@ -485,7 +485,7 @@ 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?.includes("@"))) { expect(action.uses, action.name).toMatch(fullShaAction); } diff --git a/test/install/reviewed-npm-archive.test.ts b/test/install/reviewed-npm-archive.test.ts index 4d328266bf2..10ce44dd883 100644 --- a/test/install/reviewed-npm-archive.test.ts +++ b/test/install/reviewed-npm-archive.test.ts @@ -150,7 +150,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 +158,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 05b79eb07d2..c9c82eed3d4 100644 --- a/test/install/seed-reviewed-npm-cache.test.ts +++ b/test/install/seed-reviewed-npm-cache.test.ts @@ -217,7 +217,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, npm@11.18.0, or npm@12.0.2", + "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 4c0f674214f..cd0d8c5cb1b 100644 --- a/test/install/upgrade-bundled-npm.test.ts +++ b/test/install/upgrade-bundled-npm.test.ts @@ -13,6 +13,7 @@ import { verifyBundledNpmTar, } from "../../scripts/patch-bundled-npm-tar.mts"; import { + REVIEWED_NPM_ARCHIVE_SHA256, REVIEWED_NPM_INTEGRITY, REVIEWED_NPM_PACKAGES, REVIEWED_NPM_TARBALL, @@ -55,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, }); @@ -99,6 +100,9 @@ describe("reviewed bundled npm upgrade", () => { "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", () => { @@ -181,6 +185,15 @@ describe("reviewed bundled npm upgrade", () => { expect(commands).toEqual(["npm", "npx"]); }); + 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.1"); @@ -199,4 +212,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/platform/images/node-tar-dockerfile-contract.test.ts b/test/platform/images/node-tar-dockerfile-contract.test.ts index 462541c920d..d2952fd24d7 100644 --- a/test/platform/images/node-tar-dockerfile-contract.test.ts +++ b/test/platform/images/node-tar-dockerfile-contract.test.ts @@ -7,7 +7,11 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { FIXED_TAR_VERSION } from "../../../scripts/patch-bundled-npm-tar.mts"; -import { REVIEWED_NPM_VERSION } from "../../../scripts/upgrade-bundled-npm.mts"; +import { + REVIEWED_NPM_ARCHIVE_SHA256, + REVIEWED_NPM_TARBALL, + REVIEWED_NPM_VERSION, +} from "../../../scripts/upgrade-bundled-npm.mts"; import { dockerfileRunCommandPositions, requireReviewedDockerfileRunCommands, @@ -62,6 +66,12 @@ const dockerfiles = [ ] as const; const patchCommand = "node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts"; const npmRootArguments = ["--npm-root", "/usr/local/lib/node_modules/npm"] as const; +const reviewedNpmArchivePath = "/tmp/npm-12.0.2.tgz"; +const reviewedNpmUpgradeArguments = [ + ...npmRootArguments, + "--archive", + reviewedNpmArchivePath, +] as const; const hermesFinalArchivePath = "/tmp/nemoclaw-bundled-npm-tar.tgz"; const hermesFinalPatchArguments = [ ...npmRootArguments, @@ -429,6 +439,62 @@ 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 archiveSource = `ADD --chmod=0444 --checksum=sha256:${REVIEWED_NPM_ARCHIVE_SHA256} ${REVIEWED_NPM_TARBALL} /npm-${REVIEWED_NPM_VERSION}.tgz`; + const archiveCopy = `COPY --from=reviewed-npm-archive /npm-${REVIEWED_NPM_VERSION}.tgz ${reviewedNpmArchivePath}`; + const archiveCopyIndex = source.indexOf(archiveCopy); + const upgradeRun = requireSingleReviewedDockerfileRunCommand( + source, + "node --experimental-strip-types /scripts/upgrade-bundled-npm.mts", + reviewedNpmUpgradeArguments, + ).commandStart; + const firstPrivatePatch = source.indexOf(patchCommand); + + expect(archiveStage).toContain(archiveSource); + 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 --experimental-strip-types /scripts/upgrade-bundled-npm.mts", + reviewedNpmUpgradeArguments, + ).commandStart; + const tarRun = requireSingleReviewedDockerfileRunCommand( + npm12.source, + patchCommand, + npmRootArguments, + ).commandStart; + const braceRun = npm12.source.indexOf( + "node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts", + ); + const ipAddressRun = npm12.source.indexOf( + "node --experimental-strip-types /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: 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", () => { const stages = directNodeDockerfiles.flatMap((file) => diff --git a/test/repository/github-actions-node-npm-invariant.test.ts b/test/repository/github-actions-node-npm-invariant.test.ts index 3e188ea4ace..71938e5bf4e 100644 --- a/test/repository/github-actions-node-npm-invariant.test.ts +++ b/test/repository/github-actions-node-npm-invariant.test.ts @@ -6,14 +6,22 @@ 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 AUDIT_ACTION = ".github/actions/ci-reviewed-npm-audit/action.yaml"; +const IMMUTABLE_REVIEWED_NPM_ACTION = + "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e"; -type Step = { uses?: string; with?: Record }; +type Step = { if?: string; run?: string; uses?: string; with?: Record }; type ActionDocument = { runs?: { steps?: Step[] } }; type WorkflowDocument = { jobs?: Record }; type StepGroup = { file: string; label: string; steps: Step[] }; @@ -41,32 +49,171 @@ function stepGroups(file: string): StepGroup[] { const identity = JSON.parse( fs.readFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), -) as { nodeVersion: string; npmVersion: string }; -const setupNodeSteps = yamlFiles(GITHUB_ROOT) - .flatMap(stepGroups) - .flatMap(({ file, label, steps }) => - steps.flatMap((step, index) => - step.uses?.startsWith("actions/setup-node@") ? [{ file, label, steps, step, index }] : [], - ), +) 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.run?.includes("setup-reviewed-npm/verify-and-install-npm.sh"), ); +} describe("controlled setup-node environments", () => { - // source-shape-contract: security -- Every controlled setup-node environment must install the integrity-bound npm release before later workflow steps execute. + // 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"]), ]); - const reviewedNpmSteps = setupNodeSteps - .filter(({ file }) => path.relative(REPO_ROOT, file) !== AUDIT_ACTION) - .map(({ steps, index }) => steps[index + 1]?.uses); 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); - expect(reviewedNpmSteps.every((action) => action?.includes(REVIEWED_NPM_ACTION))).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((step) => + /(?:^|[\n;&|({])\s*(?:npm|npx)(?:\s|$)/mu.test(step.run ?? ""), + ); + 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([]); + }); + + 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 sparsePaths = String(matchingCheckout?.with?.["sparse-checkout"] ?? "") + .split("\n") + .map((entry) => entry.trim()); + const completeSparseCheckout = + sparsePaths.length === 1 || + (sparsePaths.includes(".github/actions/setup-reviewed-npm") && + sparsePaths.includes("ci/reviewed-npm-audit.json")); + 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([]); + }); + + 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", + ])("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(({ file, steps, index }) => { + const reviewed = steps.slice(index + 1).find(installsReviewedNpm); + return path.relative(REPO_ROOT, file) === ".github/actions/prepare-e2e/action.yaml" + ? reviewed?.uses !== IMMUTABLE_REVIEWED_NPM_ACTION + : reviewed?.uses !== undefined || + !reviewed?.run?.includes( + "$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh", + ) || + !reviewed?.run?.includes("$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json"); + }) + .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"); + 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/"); + }); + + // 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/runtime/sandbox/sandbox-build-context.test.ts b/test/runtime/sandbox/sandbox-build-context.test.ts index 8818962410e..1cf5e22310a 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", @@ -286,6 +290,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); diff --git a/test/security/fetch-guard-patch-regression.test.ts b/test/security/fetch-guard-patch-regression.test.ts index 403a5bf0a4b..84094998adc 100644 --- a/test/security/fetch-guard-patch-regression.test.ts +++ b/test/security/fetch-guard-patch-regression.test.ts @@ -404,7 +404,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/tools/e2e/llama-cpp-dgx-spark-qualification-workflow-boundary.mts b/tools/e2e/llama-cpp-dgx-spark-qualification-workflow-boundary.mts index c00df4b7c98..b4d1f027176 100644 --- a/tools/e2e/llama-cpp-dgx-spark-qualification-workflow-boundary.mts +++ b/tools/e2e/llama-cpp-dgx-spark-qualification-workflow-boundary.mts @@ -24,7 +24,7 @@ const QUALIFICATION_SELECTOR = `\${{ inputs.${RUNNER_QUEUE_INPUT} && github.repo const CHECKOUT = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"; const BUILDX = "docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c"; const PREPARE = - "NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75"; + "NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d"; function record(value: unknown): RecordValue { return value && typeof value === "object" && !Array.isArray(value) ? (value as RecordValue) : {}; diff --git a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts index fb45be8baa7..7aac55ba195 100644 --- a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts +++ b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts @@ -234,7 +234,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@667c431859bf8d0a75b7f6a4946e635640e7734d" ) { errors.push(`${JOB_ID} must pin the trusted E2E preparation action`); } diff --git a/tools/e2e/prepare-e2e-workflow-boundary.mts b/tools/e2e/prepare-e2e-workflow-boundary.mts index 0553c256786..55ed3553b59 100644 --- a/tools/e2e/prepare-e2e-workflow-boundary.mts +++ b/tools/e2e/prepare-e2e-workflow-boundary.mts @@ -77,7 +77,12 @@ 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@470a5417558c65260d59cdb5eabb01d35834535e", }, { name: "Install root dependencies", @@ -92,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; } @@ -177,7 +184,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/sandbox-images-workflow-boundary.mts b/tools/e2e/sandbox-images-workflow-boundary.mts index 0d6b043be35..7dcbc19738e 100644 --- a/tools/e2e/sandbox-images-workflow-boundary.mts +++ b/tools/e2e/sandbox-images-workflow-boundary.mts @@ -628,8 +628,8 @@ function validateMessagingPlanImageBoundary( if (nodeSetupSteps.length !== 1) { errors.push("messaging plan image boundary must set up Node exactly once"); } - if (record(nodeSetupSteps[0]?.with)["node-version"] !== "22.19.0") { - errors.push("messaging plan image boundary must use Node 22.19.0"); + if (record(nodeSetupSteps[0]?.with)["node-version"] !== "24.18.1") { + errors.push("messaging plan image boundary must use Node 24.18.1"); } for (const [stepName, action] of [ ["Resolve sandbox base image", "./.github/actions/resolve-sandbox-base-image"], diff --git a/tools/e2e/workflow-boundary-policy.mts b/tools/e2e/workflow-boundary-policy.mts index 045fec6a3fc..e87d3b27b02 100644 --- a/tools/e2e/workflow-boundary-policy.mts +++ b/tools/e2e/workflow-boundary-policy.mts @@ -4,8 +4,8 @@ export const E2E_ACTION_PROVENANCE = { prepareWorkspace: { reference: - "NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75", - contentSha256: "1283c2eadfbc38ccb3b795684ba5ced9c89ae2040fffbb6b81854a9d1926802b", + "NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d", + contentSha256: "852afadf535895ce781a44725250190fbe0743088303e6819e8e22a9f03017a2", }, nativePodmanRuntime: { reference: diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index 8391bdda24e..cd8151cb3ad 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -2706,6 +2706,7 @@ function validateTrustedE2ePlannerBoundary( generateSteps, "Install trusted E2E planner dependencies", ); + const trustedNpmInstall = requireStep(errors, generateSteps, "Install reviewed npm"); requireFullShaAction(errors, trustedPlannerCheckout, "trusted E2E planner checkout"); if ( !isDeepStrictEqual(asRecord(trustedPlannerCheckout?.with), { @@ -2720,10 +2721,13 @@ function validateTrustedE2ePlannerBoundary( requireFullShaAction(errors, trustedPlannerSetup, "trusted E2E planner Node setup"); if ( !isDeepStrictEqual(asRecord(trustedPlannerSetup?.with), { - "node-version": 22, + "node-version": "24.18.1", }) ) { - errors.push("trusted E2E planner must use Node 22"); + errors.push("trusted E2E planner must use reviewed Node 24.18.1"); + } + if (trustedNpmInstall?.uses !== "./.github/actions/setup-reviewed-npm") { + errors.push("trusted E2E planner must install reviewed npm from the trusted checkout"); } if (trustedPlannerInstall?.run !== "npm ci --ignore-scripts --no-audit --no-fund") { errors.push("trusted E2E planner dependencies must install without lifecycle scripts"); @@ -2732,6 +2736,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; @@ -2740,7 +2745,8 @@ function validateTrustedE2ePlannerBoundary( if ( trustedPlannerIndex < 0 || trustedSetupIndex <= trustedPlannerIndex || - trustedInstallIndex <= trustedSetupIndex || + trustedNpmIndex <= trustedSetupIndex || + trustedInstallIndex <= trustedNpmIndex || generateIndex <= trustedInstallIndex || candidateCheckoutIndex <= generateIndex ) { diff --git a/tools/mcp-tool-discovery-runtime/dependency-review.md b/tools/mcp-tool-discovery-runtime/dependency-review.md index 15088956d2f..3b7a15b70af 100644 --- a/tools/mcp-tool-discovery-runtime/dependency-review.md +++ b/tools/mcp-tool-discovery-runtime/dependency-review.md @@ -48,7 +48,7 @@ Concern ledger: Issue #8253 showed that image-build audits made sandbox creation depend on current registry and Sigstore TUF data instead of only the committed build inputs. Bundle regeneration uses the shared installer without live advisory or registry-signature queries. It installs the exact lock with lifecycle scripts disabled from integrity-pinned archives, runs the runtime tests and typecheck, and verifies the exact bundle inputs. -The reviewed npm audit CI check now owns production advisory enforcement for this lock. Its trusted action verifies the downloaded `npm@10.9.4` archive against the reviewed SHA-512 before installing it. The check verifies the lock SHA-256 and SDK package integrity, installs the production graph with lifecycle scripts disabled, records audit provenance and policy results, verifies registry signatures, and fails on unaccepted findings at the repository's configured threshold. +The reviewed npm audit CI check now owns production advisory enforcement for this lock. Its trusted action verifies the downloaded `npm@12.0.2` archive against the reviewed SHA-512 before installing it. The check verifies the lock SHA-256 and SDK package integrity, installs the production graph with lifecycle scripts disabled, records audit provenance and policy results, verifies registry signatures, and fails on unaccepted findings at the repository's configured threshold. ## 1.29.0 to 1.30.0 migration review 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 '@ From e9778cbe718da18ece4276dd1d8e804b0642365d Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Fri, 4 Sep 2026 22:41:52 -0700 Subject: [PATCH 04/30] fix(ci): make reviewed npm installation deterministic Signed-off-by: Charan Jagwani --- .../workflows/candidate-compatibility.yaml | 2 +- .github/workflows/e2e-standard-profile.yaml | 2 +- .github/workflows/e2e.yaml | 13 +- .github/workflows/installer-hash-check.yaml | 2 +- .github/workflows/managed-images.yaml | 10 +- .github/workflows/pr.yaml | 2 +- scripts/brev-launchable-ci-cpu.sh | 66 ++++--- scripts/e2e/restore-cli-artifact.sh | 8 +- scripts/upgrade-bundled-npm.mts | 5 +- .../onboard/runtime-provider/podman.test.ts | 10 +- .../brev-launchable-ci-cpu-checksum.test.ts | 28 ++- .../releases/reviewed-npm-bootstrap.test.ts | 168 +++++++++--------- test/e2e/README.md | 2 +- .../cli-artifact-workflow-boundary.test.ts | 23 ++- .../jetson-managed-revision-boundary.test.ts | 7 +- .../support/jetson-workflow-boundary.test.ts | 16 +- ...in-runtime-exdev-workflow-boundary.test.ts | 4 +- ...ay-auth-contract-workflow-boundary.test.ts | 2 +- test/install/upgrade-bundled-npm.test.ts | 37 ++++ .../node-tar-dockerfile-contract.test.ts | 6 +- .../github-actions-node-npm-invariant.test.ts | 4 +- .../hermes-gpu-startup-workflow-boundary.mts | 9 +- tools/e2e/mcp-workflow-boundary.mts | 2 +- ...plugin-runtime-exdev-workflow-boundary.mts | 2 +- ...ateway-auth-contract-workflow-boundary.mts | 2 +- tools/e2e/operations-workflow-boundary.mts | 6 +- .../standard-profile-workflow-boundary.mts | 2 +- tools/e2e/workflow-boundary-policy.mts | 4 + tools/e2e/workflow-boundary.mts | 25 ++- 29 files changed, 299 insertions(+), 170 deletions(-) diff --git a/.github/workflows/candidate-compatibility.yaml b/.github/workflows/candidate-compatibility.yaml index 98f0874f439..f5cff0292c5 100644 --- a/.github/workflows/candidate-compatibility.yaml +++ b/.github/workflows/candidate-compatibility.yaml @@ -313,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/e2e-standard-profile.yaml b/.github/workflows/e2e-standard-profile.yaml index 5521002bb9d..fbd3b26d633 100644 --- a/.github/workflows/e2e-standard-profile.yaml +++ b/.github/workflows/e2e-standard-profile.yaml @@ -433,7 +433,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 573ecaf90fb..42fd37aafab 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -681,7 +681,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm for trusted E2E planning - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e - name: Install trusted E2E planner dependencies run: npm ci --ignore-scripts --no-audit --no-fund @@ -3340,7 +3340,7 @@ jobs: NEMOCLAW_NON_INTERACTIVE: "1" NEMOCLAW_CANDIDATE_VERSION: "0.0.106" NEMOCLAW_OPENSHELL_PIN_VERSION: "0.0.106" - 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: @@ -4066,6 +4066,9 @@ jobs: - *dockerhub-auth + - name: Install reviewed npm for trusted OpenShell verification + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + - name: Checkout trusted OpenShell dev tooling uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -5462,7 +5465,7 @@ jobs: node-version: "24.18.1" - name: Reinstall reviewed npm after Node reassertion - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e - name: Prepare native Podman E2E runtime uses: NVIDIA/NemoClaw/.github/actions/setup-native-podman-e2e@c87144de2c8e2d90b14cf11b31718846e32c65de @@ -5637,7 +5640,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e - name: Dispatch exact commit to Jetson through operator backend env: @@ -5992,7 +5995,7 @@ 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() diff --git a/.github/workflows/installer-hash-check.yaml b/.github/workflows/installer-hash-check.yaml index 13195cee6ef..78b2768e7d8 100644 --- a/.github/workflows/installer-hash-check.yaml +++ b/.github/workflows/installer-hash-check.yaml @@ -72,7 +72,7 @@ jobs: - name: Install immutable reviewed npm if: github.event_name == 'pull_request' - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e - name: Install trusted-event reviewed npm if: github.event_name != 'pull_request' diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index f3222ad03db..5afba4f8c20 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -164,7 +164,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e - name: Validate staging QA source shell: bash @@ -494,7 +494,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e - name: Validate Deep Agents PR base build arguments if: matrix.agent == 'langchain-deepagents-code' @@ -969,7 +969,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e - name: Bind E2E correlation identity shell: bash @@ -1074,7 +1074,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e - name: Bind E2E correlation identity shell: bash @@ -1210,7 +1210,7 @@ jobs: # 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@470a5417558c65260d59cdb5eabb01d35834535e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e - name: Install trusted-event reviewed npm if: github.event_name != 'pull_request' diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 57bb8dcf9c1..9c3a8420d39 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -487,7 +487,7 @@ jobs: nemoclaw/package-lock.json - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e - name: Install dependencies run: bash .trusted-ci-actions/.github/actions/ci-install-dependencies.sh diff --git a/scripts/brev-launchable-ci-cpu.sh b/scripts/brev-launchable-ci-cpu.sh index 8119ff96e93..98f1cab019d 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 # @@ -251,42 +251,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 @@ -321,6 +323,12 @@ fi info "Installing npm dependencies..." cd "$NEMOCLAW_CLONE_DIR" +reviewed_npm_tmp="$(mktemp -d)" +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" +[[ "$(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/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/upgrade-bundled-npm.mts b/scripts/upgrade-bundled-npm.mts index a84b331b111..ae130b691eb 100755 --- a/scripts/upgrade-bundled-npm.mts +++ b/scripts/upgrade-bundled-npm.mts @@ -203,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", ]); } diff --git a/src/lib/onboard/runtime-provider/podman.test.ts b/src/lib/onboard/runtime-provider/podman.test.ts index 4a789b18298..7090f1bd09b 100644 --- a/src/lib/onboard/runtime-provider/podman.test.ts +++ b/src/lib/onboard/runtime-provider/podman.test.ts @@ -390,7 +390,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, ); }); @@ -435,7 +441,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/test/automation/e2e/brev-launchable-ci-cpu-checksum.test.ts b/test/automation/e2e/brev-launchable-ci-cpu-checksum.test.ts index fbd63e3a2c5..643ca060df5 100644 --- a/test/automation/e2e/brev-launchable-ci-cpu-checksum.test.ts +++ b/test/automation/e2e/brev-launchable-ci-cpu-checksum.test.ts @@ -114,14 +114,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" : "v24.18.1"}\\n'; exit 0; fi exit 0 `, ); writeExecutable( path.join(fakeBin, "npm"), `#!/usr/bin/env bash +if [ "\${1:-}" = "--version" ]; then printf '12.0.2\\n'; exit 0; fi printf 'npm stub %s\\n' "$*" exit 0 `, @@ -287,6 +287,22 @@ describe("brev-launchable-ci-cpu.sh OpenShell checksum gate", { timeout: 30_000 expect(fs.statSync(SCRIPT).size).toBeLessThanOrEqual(BREV_LIFECYCLE_SCRIPT_MAX_BYTES); }); + it("pins both Node.js 24.18.1 archives and installs the canonical reviewed npm", () => { + const source = fs.readFileSync(SCRIPT, "utf8"); + expect(source).toContain('NODE_VERSION="24.18.1"'); + 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)" == "12.0.2" ]]'); + 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 +354,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 +365,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/v24.18.1/node-v24.18.1-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 v24.18.1 installed"); } finally { fake.cleanup(); } diff --git a/test/automation/releases/reviewed-npm-bootstrap.test.ts b/test/automation/releases/reviewed-npm-bootstrap.test.ts index d6c8f7b49ef..985f6af655f 100644 --- a/test/automation/releases/reviewed-npm-bootstrap.test.ts +++ b/test/automation/releases/reviewed-npm-bootstrap.test.ts @@ -25,23 +25,25 @@ function identity(archive: string): Record { }; } -describe("reviewed npm bootstrap", () => { - it("rejects a mismatched archive before installation (#8253)", () => { - 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 npmStub = path.join(bin, "npm"); - const identityPath = path.join(root, "reviewed-npm-audit.json"); +type BootstrapFixtureOptions = { + archive: string; + installedVersion?: string; + reviewedIdentity?: Record; +}; - try { - fs.mkdirSync(bin); - fs.writeFileSync(identityPath, `${JSON.stringify(identity("reviewed archive\n"))}\n`); - fs.writeFileSync( - npmStub, - `#!/usr/bin/env bash +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"); + + fs.mkdirSync(bin); + fs.writeFileSync( + path.join(bin, "npm"), + `#!/usr/bin/env bash set -euo pipefail -printf '%s\\n' "$1" >> "$NEMOCLAW_TEST_NPM_LOG" +printf '%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" case "$1" in pack) shift @@ -54,92 +56,88 @@ case "$1" in shift done [ -n "$download_dir" ] - printf 'tampered archive\\n' > "$download_dir/npm-12.0.2.tgz" + printf '%s' "$NEMOCLAW_TEST_ARCHIVE" > "$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 }, - ); + { mode: 0o755 }, + ); + fs.writeFileSync( + identityPath, + `${JSON.stringify(options.reviewedIdentity ?? identity(options.archive))}\n`, + ); + const result = spawnSync("bash", [BOOTSTRAP, identityPath], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_TEST_ARCHIVE: options.archive, + 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.readFileSync(npmLog, "utf8").trim().split("\n"), + result, + }; +} - const result = spawnSync("bash", [BOOTSTRAP, identityPath], { - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_TEST_INSTALL_MARKER: installMarker, - NEMOCLAW_TEST_NPM_LOG: npmLog, - PATH: `${bin}:${process.env.PATH ?? ""}`, - RUNNER_TEMP: root, - }, - }); +describe("reviewed npm bootstrap", () => { + const archive = "verified archive\n"; + + it.each([ + ["SHA-256", { ...identity(archive), npmArchiveSha256: "0".repeat(64) }], + [ + "SHA-512 SRI", + { ...identity(archive), npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}` }, + ], + ])( + "rejects an independent %s mismatch before installation (#8253)", + (_digest, reviewedIdentity) => { + const fixture = runBootstrapFixture({ archive, reviewedIdentity }); + try { + expect(fixture.result.status).toBe(1); + expect(fixture.result.stderr).toContain("npm@12.0.2 archive integrity mismatch"); + expect(fixture.npmInvocations).toHaveLength(1); + expect(fixture.npmInvocations[0]).toContain("pack npm@12.0.2 --pack-destination"); + expect(fixture.installCalled).toBe(false); + } finally { + fixture.cleanup(); + } + }, + ); - expect(result.status).toBe(1); - expect(result.stderr).toContain("npm@12.0.2 archive integrity mismatch"); - expect(fs.readFileSync(npmLog, "utf8")).toBe("pack\n"); - expect(fs.existsSync(installMarker)).toBe(false); + 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 { - fs.rmSync(root, { recursive: true, force: true }); + fixture.cleanup(); } }); it("installs a matching archive offline (#8253)", () => { - 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 npmStub = path.join(bin, "npm"); - const identityPath = path.join(root, "reviewed-npm-audit.json"); - const archive = "verified archive\n"; - + const fixture = runBootstrapFixture({ archive }); try { - fs.mkdirSync(bin); - fs.writeFileSync( - npmStub, - `#!/usr/bin/env bash -set -euo pipefail -printf '%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" -case "$1" in - pack) - shift - download_dir="" - while [ "$#" -gt 0 ]; do - if [ "$1" = "--pack-destination" ]; then - download_dir="$2" - break - fi - shift - done - [ -n "$download_dir" ] - printf 'verified archive\\n' > "$download_dir/npm-12.0.2.tgz" - ;; - install) - ;; - --version) - printf '12.0.2\n' - ;; - *) - exit 2 - ;; -esac -`, - { mode: 0o755 }, - ); - fs.writeFileSync(identityPath, `${JSON.stringify(identity(archive))}\n`); - const result = spawnSync("bash", [BOOTSTRAP, identityPath], { - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_TEST_NPM_LOG: npmLog, - PATH: `${bin}:${process.env.PATH ?? ""}`, - RUNNER_TEMP: root, - }, - }); - - const npmInvocations = fs.readFileSync(npmLog, "utf8").trim().split("\n"); + const { npmInvocations, result } = fixture; expect(result.status).toBe(0); expect(npmInvocations).toHaveLength(3); expect(npmInvocations[0]).toContain("pack npm@12.0.2 --pack-destination"); @@ -148,7 +146,7 @@ esac ); expect(npmInvocations[2]).toBe("--version"); } finally { - fs.rmSync(root, { recursive: true, force: true }); + fixture.cleanup(); } }); }); diff --git a/test/e2e/README.md b/test/e2e/README.md index 607e375df49..35111b64c25 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -81,7 +81,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. diff --git a/test/e2e/support/cli-artifact-workflow-boundary.test.ts b/test/e2e/support/cli-artifact-workflow-boundary.test.ts index 42d2dfc4028..4882cd95f1d 100644 --- a/test/e2e/support/cli-artifact-workflow-boundary.test.ts +++ b/test/e2e/support/cli-artifact-workflow-boundary.test.ts @@ -65,7 +65,10 @@ type RestoreFixtureOptions = { buildIdentitySha?: string; consumerRunAttempt?: string; expectedPayloadSha256?: string; + consumerNodeVersion?: string; manifestCandidateSha?: string; + manifestNodeVersion?: string; + manifestNpmVersion?: string; manifestRunAttempt?: string; preexistingDist?: | "dangling-symlink" @@ -335,8 +338,8 @@ function runRestoreValidation(options: RestoreFixtureOptions = {}) { 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", }, @@ -348,7 +351,7 @@ function runRestoreValidation(options: RestoreFixtureOptions = {}) { 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")); @@ -623,6 +626,20 @@ describe("exact-commit CLI artifact restore", () => { ); }); + it.each([ + ["Node", { manifestNodeVersion: "v24.18.0" }], + ["npm", { manifestNpmVersion: "12.0.1" }], + ])("rejects a CLI artifact built with a different %s version", (_tool, options) => { + expectRestoreFailure(options, "exact-commit CLI artifact provenance mismatch"); + }); + + it("rejects restoration under a different Node version", () => { + expectRestoreFailure( + { 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)", () => { expectRestoreFailure( { expectedPayloadSha256: "f".repeat(64) }, diff --git a/test/e2e/support/jetson-managed-revision-boundary.test.ts b/test/e2e/support/jetson-managed-revision-boundary.test.ts index 3c4c7b77ac6..96540ff6be4 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 }, + 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..afbe371ac6e 100644 --- a/test/e2e/support/jetson-workflow-boundary.test.ts +++ b/test/e2e/support/jetson-workflow-boundary.test.ts @@ -156,7 +156,21 @@ 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", ); }); 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 ca007a18d54..88365263505 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 + " }}"; @@ -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 095d68b4472..e5ec763db9a 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.106", "openshell-gateway-auth-contract must set NEMOCLAW_OPENSHELL_PIN_VERSION=0.0.106", diff --git a/test/install/upgrade-bundled-npm.test.ts b/test/install/upgrade-bundled-npm.test.ts index cd0d8c5cb1b..07da14800d3 100644 --- a/test/install/upgrade-bundled-npm.test.ts +++ b/test/install/upgrade-bundled-npm.test.ts @@ -170,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[] = []; diff --git a/test/platform/images/node-tar-dockerfile-contract.test.ts b/test/platform/images/node-tar-dockerfile-contract.test.ts index d2952fd24d7..c0d4c25b5b6 100644 --- a/test/platform/images/node-tar-dockerfile-contract.test.ts +++ b/test/platform/images/node-tar-dockerfile-contract.test.ts @@ -378,9 +378,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, @@ -405,7 +403,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", diff --git a/test/repository/github-actions-node-npm-invariant.test.ts b/test/repository/github-actions-node-npm-invariant.test.ts index 71938e5bf4e..24c94dff723 100644 --- a/test/repository/github-actions-node-npm-invariant.test.ts +++ b/test/repository/github-actions-node-npm-invariant.test.ts @@ -19,6 +19,8 @@ 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@7363df49a5f25b0dd1c20c80905917c31760a27e"; +const IMMUTABLE_PREPARE_E2E_NPM_ACTION = "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e"; type Step = { if?: string; run?: string; uses?: string; with?: Record }; @@ -173,7 +175,7 @@ describe("controlled setup-node environments", () => { .filter(({ file, steps, index }) => { const reviewed = steps.slice(index + 1).find(installsReviewedNpm); return path.relative(REPO_ROOT, file) === ".github/actions/prepare-e2e/action.yaml" - ? reviewed?.uses !== IMMUTABLE_REVIEWED_NPM_ACTION + ? reviewed?.uses !== IMMUTABLE_PREPARE_E2E_NPM_ACTION : reviewed?.uses !== undefined || !reviewed?.run?.includes( "$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh", diff --git a/tools/e2e/hermes-gpu-startup-workflow-boundary.mts b/tools/e2e/hermes-gpu-startup-workflow-boundary.mts index da1c67e0e69..d457ac34568 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 nativePodmanRuntime = steps[ni + 1]; + const reviewedNpm = steps[ni + 1]; + const nativePodmanRuntime = steps[ni + 2]; if ( runStep.shell !== BASH || !trustedEnv(runStep) || pi < 0 || restoreI <= pi || ni !== restoreI + 1 || - ni + 2 !== steps.indexOf(runStep) || + ni + 3 !== steps.indexOf(runStep) || node?.uses !== "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020" || - asRecord(node?.with)["node-version"] !== "22" || + asRecord(node?.with)["node-version"] !== "24.18.1" || !trustedEnv(node) || asRecord(node?.env).NODE_OPTIONS !== "" || + reviewedNpm?.name !== "Reinstall reviewed npm after Node reassertion" || + reviewedNpm?.uses !== E2E_ACTION_PROVENANCE.reviewedNpmSetup.reference || nativePodmanRuntime?.name !== "Prepare native Podman E2E runtime" || nativePodmanRuntime?.uses !== E2E_ACTION_PROVENANCE.nativePodmanRuntime.reference || asRecord(nativePodmanRuntime?.with).enabled !== diff --git a/tools/e2e/mcp-workflow-boundary.mts b/tools/e2e/mcp-workflow-boundary.mts index 3f549ca23f4..b24173e675f 100644 --- a/tools/e2e/mcp-workflow-boundary.mts +++ b/tools/e2e/mcp-workflow-boundary.mts @@ -795,7 +795,7 @@ function validateDevArtifactJob(errors: string[], job: UnknownRecord): void { if (!/^actions\/setup-node@[a-f0-9]{40}$/u.test(asString(setup.uses))) { errors.push(`${DEV_ARTIFACT_JOB} must use a SHA-pinned Node setup`); } - if (!hasExactEntries(asRecord(setup.with), { "node-version": 22 })) { + if (!hasExactEntries(asRecord(setup.with), { "node-version": "24.18.1" })) { errors.push(`${DEV_ARTIFACT_JOB} must use only the reviewed Node version`); } const resolve = namedStep(job, "Resolve immutable OpenShell dev artifact"); 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 10b5245d463..47037d59add 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 fc78d641d82..1191bb4d761 100644 --- a/tools/e2e/operations-workflow-boundary.mts +++ b/tools/e2e/operations-workflow-boundary.mts @@ -739,9 +739,13 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): name: "Set up Node for publication verification", uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", with: { - "node-version": 22, + "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", diff --git a/tools/e2e/standard-profile-workflow-boundary.mts b/tools/e2e/standard-profile-workflow-boundary.mts index 51d7813142b..ab267ee00fd 100644 --- a/tools/e2e/standard-profile-workflow-boundary.mts +++ b/tools/e2e/standard-profile-workflow-boundary.mts @@ -499,7 +499,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/workflow-boundary-policy.mts b/tools/e2e/workflow-boundary-policy.mts index e87d3b27b02..fe6bd31035f 100644 --- a/tools/e2e/workflow-boundary-policy.mts +++ b/tools/e2e/workflow-boundary-policy.mts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 export const E2E_ACTION_PROVENANCE = { + reviewedNpmSetup: { + reference: + "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e", + }, prepareWorkspace: { reference: "NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d", diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index cd8151cb3ad..d480663c55e 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -1986,10 +1986,14 @@ function validateJetsonControllerBoundary(errors: string[], jobs: WorkflowRecord errors.push("jetson-nvmap-gpu controller must set up Node.js"); } else { requireFullShaAction(errors, setupNode, "jetson-nvmap-gpu Node setup"); - if (asRecord(setupNode.with)["node-version"] !== 22) { - errors.push("jetson-nvmap-gpu controller must use Node.js 22"); + if (asRecord(setupNode.with)["node-version"] !== "24.18.1") { + errors.push("jetson-nvmap-gpu controller must use reviewed Node.js 24.18.1"); } } + 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 !== @@ -2017,9 +2021,12 @@ 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", ); } } @@ -2706,7 +2713,11 @@ function validateTrustedE2ePlannerBoundary( generateSteps, "Install trusted E2E planner dependencies", ); - const trustedNpmInstall = requireStep(errors, generateSteps, "Install reviewed npm"); + const trustedNpmInstall = requireStep( + errors, + generateSteps, + "Install reviewed npm for trusted E2E planning", + ); requireFullShaAction(errors, trustedPlannerCheckout, "trusted E2E planner checkout"); if ( !isDeepStrictEqual(asRecord(trustedPlannerCheckout?.with), { @@ -2726,8 +2737,8 @@ function validateTrustedE2ePlannerBoundary( ) { errors.push("trusted E2E planner must use reviewed Node 24.18.1"); } - if (trustedNpmInstall?.uses !== "./.github/actions/setup-reviewed-npm") { - errors.push("trusted E2E planner must install reviewed npm from the trusted checkout"); + 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"); From 8232315137b8c9cc64a6ffc7eb53f14024b4bd50 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 00:17:38 -0700 Subject: [PATCH 05/30] fix(ci): close npm 12 migration regressions Signed-off-by: Charan Jagwani --- .github/actions/ci-install-dependencies.sh | 4 +- .github/workflows/code-scanning.yaml | 4 +- .github/workflows/managed-images.yaml | 7 ++ .github/workflows/pr.yaml | 2 + agents/hermes/Dockerfile | 2 +- ci/source-shape-test-budget.json | 5 ++ scripts/upgrade-bundled-npm.mts | 9 +- .../dockerfile-node-runtime-refresh.test.ts | 7 +- .../hermes-portable-build-context-files.ts | 3 + .../hermes-portable-build-context.test.ts | 7 +- .../hermes-portable-build-context.ts | 17 +++- .../openclaw-2026-6-npm-remediation.test.ts | 6 +- ...w-security-audit-suppressions-real.test.ts | 4 +- ...e-conflict-fixer-workflow-boundary.test.ts | 9 +- .../pr-workflow-contract.test.ts | 19 ++-- test/e2e/live/messaging-providers-helpers.ts | 2 +- test/e2e/mock-parity.json | 1 + ...mage-publication-workflow-boundary.test.ts | 31 +++++-- ...messaging-providers-runtime-proofs.test.ts | 7 ++ ...e-qualification-collector-workflow.test.ts | 6 +- ...teway-auth-source-contract-helpers.test.ts | 7 ++ test/helpers/npm-pack-result.ts | 27 ++++++ .../llama/llama-cpp-image-workflow.test.ts | 11 ++- .../managed-image-staging-qa-workflow.test.ts | 11 ++- test/install/seed-reviewed-npm-cache.test.ts | 21 ++++- test/install/upgrade-bundled-npm.test.ts | 29 +++++-- test/install/wechat-locked-install.test.ts | 86 +++++++++---------- test/package-contract/config-schema.test.ts | 6 +- .../managed-inference-catalog.test.ts | 10 +-- .../nemoclaw-plugin-metadata.test.ts | 8 +- .../openshell-policy-boundary.test.ts | 14 +-- ...race-expansion-dockerfile-contract.test.ts | 58 ++++++++----- ...npm-ip-address-dockerfile-contract.test.ts | 33 ++++--- .../node-tar-dockerfile-contract.test.ts | 3 +- .../ci-install-dependencies.test.ts | 4 +- .../repository/code-scanning-workflow.test.ts | 7 ++ .../repository/prepare-ci-npm-install.test.ts | 13 +-- .../messaging-build-applier-integrity.test.ts | 14 +-- .../messaging/messaging-build-applier.test.ts | 28 +++--- test/security/mcporter-supply-chain.test.ts | 3 + .../e2e/mcp-dev-workflow-boundary-digests.mts | 6 +- tools/e2e/mcp-workflow-boundary.mts | 18 +++- tools/e2e/target-catalogue.mts | 8 +- 43 files changed, 382 insertions(+), 195 deletions(-) create mode 100644 test/helpers/npm-pack-result.ts diff --git a/.github/actions/ci-install-dependencies.sh b/.github/actions/ci-install-dependencies.sh index d0748c5c37c..2a112a6add4 100755 --- a/.github/actions/ci-install-dependencies.sh +++ b/.github/actions/ci-install-dependencies.sh @@ -56,5 +56,5 @@ 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 --prefix nemoclaw 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" +npm --prefix nemoclaw ci --allow-remote=root --ignore-scripts --prefer-offline --no-audit --no-fund --cache "$npm_cache" diff --git a/.github/workflows/code-scanning.yaml b/.github/workflows/code-scanning.yaml index f5413c42e88..b8d88f16457 100644 --- a/.github/workflows/code-scanning.yaml +++ b/.github/workflows/code-scanning.yaml @@ -63,8 +63,6 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.workflow_sha }} path: trusted-shellcheck-converter sparse-checkout: | - .github/actions/setup-reviewed-npm - ci/reviewed-npm-audit.json scripts/shellcheck-json1-to-sarif.mts sparse-checkout-cone-mode: false @@ -74,7 +72,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: ./trusted-shellcheck-converter/.github/actions/setup-reviewed-npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e - name: Install ShellCheck run: | diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 5afba4f8c20..b310a0e14ee 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -205,7 +205,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" diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 9c3a8420d39..bdee8b95fcd 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -491,6 +491,8 @@ jobs: - 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 diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index e777d2d8345..e1025f25c48 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -14,7 +14,7 @@ ARG NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=0 ARG TARGETARCH 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 +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. diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 7e8e80c74f2..9c82573820f 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -106,6 +106,11 @@ "test": "upgrades and verifies reviewed npm before every direct Node stage npm boundary", "category": "security" }, + { + "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", diff --git a/scripts/upgrade-bundled-npm.mts b/scripts/upgrade-bundled-npm.mts index ae130b691eb..d4f121b1bce 100755 --- a/scripts/upgrade-bundled-npm.mts +++ b/scripts/upgrade-bundled-npm.mts @@ -225,16 +225,15 @@ export function upgradeBundledNpm( throw new Error("reviewed npm upgrade cannot select both archivePath and prepareArchive"); } - if (currentVersion === REVIEWED_NPM_VERSION) { - if (dependencies.archivePath !== undefined) { - verifyReviewedNpmArchive(resolve(dependencies.archivePath)); - } + 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}`, ); diff --git a/src/lib/onboard/dockerfile-node-runtime-refresh.test.ts b/src/lib/onboard/dockerfile-node-runtime-refresh.test.ts index c22b33bfbf8..3cb41e9381f 100644 --- a/src/lib/onboard/dockerfile-node-runtime-refresh.test.ts +++ b/src/lib/onboard/dockerfile-node-runtime-refresh.test.ts @@ -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:24\.18\.1-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/experimental/hermes-portable-build-context-files.ts b/src/lib/onboard/experimental/hermes-portable-build-context-files.ts index cd0063068bb..89d6498e34b 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" }, { @@ -135,6 +136,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" }, @@ -143,6 +145,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.106.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 774b3ec71b4..6ed5ed86b4b 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/", @@ -177,9 +180,7 @@ describe("Hermes portable staged build context", testTimeoutOptions(30_000), () fs.existsSync(path.join(first.buildContextPath, "agents/hermes/plugin/__pycache__")), ).toBe(false); expect( - fs.existsSync( - path.join(first.buildContextPath, "agents/hermes/security-dependencies.patch"), - ), + fs.existsSync(path.join(first.buildContextPath, "agents/hermes/security-dependencies.patch")), ).toBe(false); const reused = plan.materialize(contextInput()); diff --git a/src/lib/onboard/experimental/hermes-portable-build-context.ts b/src/lib/onboard/experimental/hermes-portable-build-context.ts index 5073befbfe7..af10a55be3d 100644 --- a/src/lib/onboard/experimental/hermes-portable-build-context.ts +++ b/src/lib/onboard/experimental/hermes-portable-build-context.ts @@ -10,6 +10,10 @@ import { renderHermesPortableDockerfileBuildSettings, type HermesPortableDockerfileBuildSettings, } from "../dockerfile-patch"; +import { + REVIEWED_NPM_ARCHIVE_SHA256, + REVIEWED_NPM_TARBALL, +} from "../../../../scripts/lib/reviewed-npm-identity.mts"; import { HERMES_PORTABLE_BUILD_CONTEXT_FILES } from "./hermes-portable-build-context-files"; const CONTEXT_SCHEMA_VERSION = 1 as const; @@ -77,6 +81,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 +90,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.106.json", "src/lib/hermes-managed-route.ts", "src/lib/messaging/", @@ -301,11 +308,17 @@ 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 === REVIEWED_NPM_TARBALL && + checksum === `--checksum=sha256:${REVIEWED_NPM_ARCHIVE_SHA256}`; 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"); } 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-security-audit-suppressions-real.test.ts b/test/agents/openclaw/openclaw-security-audit-suppressions-real.test.ts index 8df5a42799d..3552493c75e 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"; @@ -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/pull-requests/pr-merge-conflict-fixer-workflow-boundary.test.ts b/test/automation/pull-requests/pr-merge-conflict-fixer-workflow-boundary.test.ts index cdab3a70590..6ce524919a9 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,15 @@ 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( - 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 f2e871942eb..d92f57ea17e 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -81,6 +81,7 @@ const trustedPrActionPaths = { const trustedCheckoutAction = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"; const trustedSetupNodeAction = "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020"; +const reviewedNpmAction = "./.github/actions/setup-reviewed-npm"; const trustedActionDirs = [ ".github/actions/ci-static-checks", ".github/actions/ci-build-typecheck", @@ -466,9 +467,7 @@ describe("pull request and main workflow contracts", () => { ) as TypeScriptConfig; const sharedActions = { staticChecks: readYaml(".github/actions/ci-static-checks/action.yaml"), - compileArtifacts: readYaml( - ".github/actions/ci-compile-artifacts/action.yaml", - ), + compileArtifacts: readYaml(".github/actions/ci-compile-artifacts/action.yaml"), buildTypecheck: readYaml(".github/actions/ci-build-typecheck/action.yaml"), cliCoverageShard: readYaml( ".github/actions/ci-cli-coverage-shard/action.yaml", @@ -493,10 +492,7 @@ describe("pull request and main workflow contracts", () => { it("verifies changed Hugging Face catalog references without credentials", () => { const job = prWorkflow.jobs["hugging-face-models"]; const filterStep = prWorkflow.jobs.changes.steps?.find((step) => step.id === "filter"); - const filters = YAML.parse(String(filterStep?.with?.filters ?? "")) as Record< - string, - string[] - >; + const filters = YAML.parse(String(filterStep?.with?.filters ?? "")) as Record; const huggingFaceModelFilters = filters.hugging_face_models ?? []; expect( @@ -511,7 +507,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", @@ -577,6 +577,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/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 978e3dcd935..245b3a90b40 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -43,7 +43,7 @@ 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 = +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; diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index b68d8fd1d0b..99dec86f09b 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -251,6 +251,7 @@ }, { "live": "test/e2e/live/openshell-gateway-auth-source-contract.test.ts", + "liveSources": ["test/e2e/live/openshell-gateway-auth-probe.ts"], "fast": [ "test/e2e/support/openshell-gateway-auth-source-contract-helpers.test.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 5260d0ae127..9c956a988ef 100644 --- a/test/e2e/support/base-image-publication-workflow-boundary.test.ts +++ b/test/e2e/support/base-image-publication-workflow-boundary.test.ts @@ -206,20 +206,36 @@ 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 version", (value) => (gateSteps(value)[2].with!["node-version"] = 20)], - ["verifier condition", (value) => (gateSteps(value)[3].if = "${{ always() }}")], - ["verifier token", (value) => (gateSteps(value)[3].env!.GITHUB_TOKEN = "${{ secrets.TOKEN }}")], + [ + "verifier condition", + (value) => + (gateStep(value, "Select base and optional managed-image publication").if = + "${{ always() }}"), + ], + [ + "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"; }, ], [ @@ -242,10 +258,7 @@ describe("base-image publication workflow boundary (#7372)", () => { "node tools/e2e/dcode-base-image-contract.mts contract.json"), ], ["step count", (value) => gateSteps(value).push({ name: "Unreviewed step", run: "true" })], - [ - "matrix publication dependency", - (value) => (value.jobs["generate-matrix"].needs = []), - ], + ["matrix publication dependency", (value) => (value.jobs["generate-matrix"].needs = [])], ["live publication dependency", (value) => (value.jobs.live.needs = ["generate-matrix"])], [ "live managed-image revision", diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index aacee435803..15d3ae0d421 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, @@ -44,6 +45,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 49d74188119..8a0294a7a39 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,11 @@ 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", + ...localImportClosure(COLLECTOR_ENTRYPOINT), + ].sort(), ); expect( (collect as WorkflowStep & { readonly "working-directory"?: string })["working-directory"], 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/helpers/npm-pack-result.ts b/test/helpers/npm-pack-result.ts new file mode 100644 index 00000000000..9c5f51a09b5 --- /dev/null +++ b/test/helpers/npm-pack-result.ts @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type NpmPackResult = Readonly<{ + filename?: string; + files?: ReadonlyArray>; + integrity?: string; +}>; + +export function parseSingleNpmPackResult(source: string): NpmPackResult { + const parsed: unknown = JSON.parse(source); + const entries = Array.isArray(parsed) + ? parsed + : typeof parsed === "object" && parsed !== null + ? Object.values(parsed) + : []; + if (entries.length !== 1 || typeof entries[0] !== "object" || entries[0] === null) { + throw new Error("npm pack must return exactly one package result"); + } + return entries[0] as NpmPackResult; +} + +export function npmPackFilePaths(source: string): string[] { + return (parseSingleNpmPackResult(source).files ?? []).flatMap(({ path }) => + typeof path === "string" ? [path] : [], + ); +} diff --git a/test/inference/llama/llama-cpp-image-workflow.test.ts b/test/inference/llama/llama-cpp-image-workflow.test.ts index 45676ac026f..6386b0e645d 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-staging-qa-workflow.test.ts b/test/inference/managed/managed-image-staging-qa-workflow.test.ts index 06f262254d5..0682d36e67f 100644 --- a/test/inference/managed/managed-image-staging-qa-workflow.test.ts +++ b/test/inference/managed/managed-image-staging-qa-workflow.test.ts @@ -81,7 +81,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", ); diff --git a/test/install/seed-reviewed-npm-cache.test.ts b/test/install/seed-reviewed-npm-cache.test.ts index c9c82eed3d4..7318924b2f5 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"; @@ -21,6 +22,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; @@ -173,10 +183,13 @@ describe("reviewed npm cache seed", () => { it("serves npm view offline from lock-derived packuments", async () => { const input = fixture(); - await seedReviewedNpmCache({ - ...request(input, new Map()), - packumentsOnly: true, - }); + await seedReviewedNpmCache( + { + ...request(input, new Map()), + packumentsOnly: true, + }, + cachePutFromInstalledNpm(), + ); const integrity = execFileSync( "npm", diff --git a/test/install/upgrade-bundled-npm.test.ts b/test/install/upgrade-bundled-npm.test.ts index 07da14800d3..0e2f2f74b50 100644 --- a/test/install/upgrade-bundled-npm.test.ts +++ b/test/install/upgrade-bundled-npm.test.ts @@ -214,21 +214,38 @@ 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", - ); + expect(() => upgradeBundledNpm(reviewedNpm(), { archivePath })).toThrow("integrity mismatch"); }); it("fails closed on reviewed-package drift", () => { diff --git a/test/install/wechat-locked-install.test.ts b/test/install/wechat-locked-install.test.ts index 4e43470aba0..9e20f6ac415 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' "$3" "$4" "$5" "$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( @@ -192,51 +192,51 @@ printf 'verify|%s|%s|openclaw=%s|offline=%s|cache=%s\n' "$3" "$4" "$5" "$NPM_CON } }); - it.each(INVALID_INSTALL_CACHE_CASES)("rejects $name before package tooling runs", ({ - prepare, - expected, - }) => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-wechat-invalid-cache-")); - const trace = path.join(tmp, "trace"); - executable(path.join(tmp, "npm"), '#!/bin/sh\nprintf "npm\\n" >> "$TRACE"\n'); - executable(path.join(tmp, "openclaw"), '#!/bin/sh\nprintf "openclaw\\n" >> "$TRACE"\n'); - executable(path.join(tmp, "node"), '#!/bin/sh\nprintf "node\\n" >> "$TRACE"\n'); + it.each(INVALID_INSTALL_CACHE_CASES)( + "rejects $name before package tooling runs", + ({ prepare, expected }) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-wechat-invalid-cache-")); + const trace = path.join(tmp, "trace"); + executable(path.join(tmp, "npm"), '#!/bin/sh\nprintf "npm\\n" >> "$TRACE"\n'); + executable(path.join(tmp, "openclaw"), '#!/bin/sh\nprintf "openclaw\\n" >> "$TRACE"\n'); + executable(path.join(tmp, "node"), '#!/bin/sh\nprintf "node\\n" >> "$TRACE"\n'); - const plan = { - schemaVersion: 1, - sandboxName: "wechat-invalid-cache", - agent: "openclaw", - channels: [{ channelId: "wechat", active: true }], - credentialBindings: [], - agentRender: [], - buildSteps: [ - { - channelId: "wechat", - kind: "package-install", - outputId: "openclawPluginPackage", - required: true, - value: { - manager: "openclaw-plugin", - spec: "npm:@tencent-weixin/openclaw-weixin@2.4.3", + const plan = { + schemaVersion: 1, + sandboxName: "wechat-invalid-cache", + agent: "openclaw", + channels: [{ channelId: "wechat", active: true }], + credentialBindings: [], + agentRender: [], + buildSteps: [ + { + channelId: "wechat", + kind: "package-install", + outputId: "openclawPluginPackage", + required: true, + value: { + manager: "openclaw-plugin", + spec: "npm:@tencent-weixin/openclaw-weixin@2.4.3", + }, }, - }, - ], - }; - const env = { - PATH: `${tmp}:${process.env.PATH ?? "/usr/bin:/bin"}`, - TRACE: trace, - NEMOCLAW_WECHAT_NPM_INSTALL_CACHE: prepare(tmp), - NEMOCLAW_MESSAGING_PLAN_B64: Buffer.from(JSON.stringify(plan)).toString("base64"), - }; + ], + }; + const env = { + PATH: `${tmp}:${process.env.PATH ?? "/usr/bin:/bin"}`, + TRACE: trace, + NEMOCLAW_WECHAT_NPM_INSTALL_CACHE: prepare(tmp), + NEMOCLAW_MESSAGING_PLAN_B64: Buffer.from(JSON.stringify(plan)).toString("base64"), + }; - try { - const serialized = readMessagingBuildPlanFromEnv(env, "openclaw"); - expect(() => applyMessagingBuildPhase(serialized, "agent-install", env)).toThrow(expected); - expect(fs.existsSync(trace)).toBe(false); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); + try { + const serialized = readMessagingBuildPlanFromEnv(env, "openclaw"); + expect(() => applyMessagingBuildPhase(serialized, "agent-install", env)).toThrow(expected); + expect(fs.existsSync(trace)).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + ); it.each(TRUSTED_INSTALL_CACHE_CASES)("rejects $name", ({ prepare }) => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-wechat-trusted-cache-")); diff --git a/test/package-contract/config-schema.test.ts b/test/package-contract/config-schema.test.ts index 1b21762d818..7009ce45f19 100644 --- a/test/package-contract/config-schema.test.ts +++ b/test/package-contract/config-schema.test.ts @@ -6,6 +6,7 @@ import { rmSync } 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, "..", ".."); @@ -17,13 +18,12 @@ describe("compiled config schema consumer", () => { entries: ["dist/lib/config", "schemas"], }); 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 39b8811d7a1..1b377b0ad42 100644 --- a/test/package-contract/managed-inference-catalog.test.ts +++ b/test/package-contract/managed-inference-catalog.test.ts @@ -6,10 +6,9 @@ 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 catalogSchema from "../../managed-inference/schemas/catalog.schema.json" with { type: "json" }; import modelSchema from "../../managed-inference/schemas/model.schema.json" with { type: "json" }; import presetSchema from "../../managed-inference/schemas/preset.schema.json" with { type: "json" }; import recipeSchema from "../../managed-inference/schemas/recipe.schema.json" with { type: "json" }; @@ -37,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 4b87e5e0a1a..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,10 +120,11 @@ 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(true); + expect(extensions.every((extension) => packedPaths.has(extension.replace(/^\.\//, "")))).toBe( + true, + ); }); }); diff --git a/test/package-contract/openshell-policy-boundary.test.ts b/test/package-contract/openshell-policy-boundary.test.ts index 97df6563199..68a146ad556 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 }); } @@ -118,7 +116,9 @@ describe("OpenShell policy boundary package contract", () => { network_policies: { safe: {} }, }; expect(YAML.parse(cliPolicy.stripProviderComposedPolicies(policy))).toEqual(expectedPolicy); - expect(YAML.parse(pluginBoundary.stripProviderComposedPolicies(policy))).toEqual(expectedPolicy); + expect(YAML.parse(pluginBoundary.stripProviderComposedPolicies(policy))).toEqual( + expectedPolicy, + ); expect(() => cliPolicy.stripProviderComposedPolicies("version: [unterminated")).toThrow(); expect(() => pluginBoundary.stripProviderComposedPolicies("version: [unterminated")).toThrow(); 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 9d0bcb6808c..92440a81afb 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,10 @@ 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 { + requireReviewedDockerfileRunCommands, + requireSingleReviewedDockerfileRunCommand, +} from "../../helpers/dockerfile-run-commands"; const repoRoot = path.resolve(import.meta.dirname, "../../.."); const baseDockerfiles = [ @@ -25,8 +28,7 @@ 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 copiedSource = "scripts/patch-bundled-npm-brace-expansion.mts"; const patchInstruction = "node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts"; const npmRootArguments = ["--npm-root", "/usr/local/lib/node_modules/npm"] as const; @@ -40,6 +42,11 @@ const tarPatchArgumentsByDockerfile = { "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", () => { @@ -54,7 +61,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 = source.indexOf(copiedSource); const upgrade = requireSingleReviewedDockerfileRunCommand( source, "node --experimental-strip-types /scripts/upgrade-bundled-npm.mts", @@ -71,24 +78,29 @@ describe("bundled npm brace-expansion image remediation contract", () => { expect(patch.commandStart, file).toBeGreaterThan(upgrade); }); - it.each( - finalDockerfiles, - )("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( - source, - "node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", - tarPatchArgumentsByDockerfile[file], - ).commandStart; - const bracePatch = requireSingleReviewedDockerfileRunCommand( - source, - patchInstruction, - npmRootArguments, - ); + it.each(finalDockerfiles)( + "reasserts the private package fix in the completed %s filesystem", + (file) => { + const source = fs.readFileSync(path.join(repoRoot, file), "utf8"); + const copy = source.indexOf(copiedSource); + const tarPatches = requireReviewedDockerfileRunCommands( + source, + "node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", + tarPatchArgumentsByDockerfile[file], + 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); - }); + 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 a6a2d3e09c9..faa40d5cf42 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,10 @@ 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 { + requireReviewedDockerfileRunCommands, + requireSingleReviewedDockerfileRunCommand, +} from "../../helpers/dockerfile-run-commands"; const repoRoot = path.resolve(import.meta.dirname, "../../.."); const baseDockerfiles = [ @@ -25,8 +28,7 @@ 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 copiedSource = "scripts/lib/patch-bundled-npm-ip-address.mts"; const patchCommand = "node --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts"; const npmRootArguments = ["--npm-root", "/usr/local/lib/node_modules/npm"] as const; @@ -40,6 +42,11 @@ const tarPatchArgumentsByDockerfile = { "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", () => { @@ -56,7 +63,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 = source.indexOf(copiedSource); const upgrade = requireSingleReviewedDockerfileRunCommand( source, "node --experimental-strip-types /scripts/upgrade-bundled-npm.mts", @@ -71,22 +78,28 @@ 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 = source.indexOf(copiedSource); + const tarPatches = requireReviewedDockerfileRunCommands( source, "node --experimental-strip-types /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 --experimental-strip-types /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); diff --git a/test/platform/images/node-tar-dockerfile-contract.test.ts b/test/platform/images/node-tar-dockerfile-contract.test.ts index c0d4c25b5b6..afb24782ef0 100644 --- a/test/platform/images/node-tar-dockerfile-contract.test.ts +++ b/test/platform/images/node-tar-dockerfile-contract.test.ts @@ -446,7 +446,8 @@ describe("reviewed npm image remediation contract", () => { const dockerfile = fs.readFileSync(path.join(repoRoot, file), "utf8"); const archiveStage = namedStage(dockerfile, "reviewed-npm-archive"); const source = completedStage(dockerfile); - const archiveSource = `ADD --chmod=0444 --checksum=sha256:${REVIEWED_NPM_ARCHIVE_SHA256} ${REVIEWED_NPM_TARBALL} /npm-${REVIEWED_NPM_VERSION}.tgz`; + 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 archiveCopy = `COPY --from=reviewed-npm-archive /npm-${REVIEWED_NPM_VERSION}.tgz ${reviewedNpmArchivePath}`; const archiveCopyIndex = source.indexOf(archiveCopy); const upgradeRun = requireSingleReviewedDockerfileRunCommand( diff --git a/test/repository/ci-install-dependencies.test.ts b/test/repository/ci-install-dependencies.test.ts index c6e7e718937..62943380b38 100644 --- a/test/repository/ci-install-dependencies.test.ts +++ b/test/repository/ci-install-dependencies.test.ts @@ -66,8 +66,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")}`, ]); }); diff --git a/test/repository/code-scanning-workflow.test.ts b/test/repository/code-scanning-workflow.test.ts index 579616117f5..b931add8791 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@7363df49a5f25b0dd1c20c80905917c31760a27e", + ); + }); + it("keeps a preinstalled ShellCheck only when its json1 formatter works (#7684)", () => { const { calls, result } = runShellCheckInstall({ preinstalledSupportsJson1: true }); diff --git a/test/repository/prepare-ci-npm-install.test.ts b/test/repository/prepare-ci-npm-install.test.ts index a6076a63ab3..37257ff814e 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, @@ -79,8 +80,9 @@ function reviewedConfigSource(packageIdentity: ReviewedSourceRegistryPackage = r exceptionFile: "ci/npm-audit-exceptions.json", lockedGraphs: [], nodeVersion: "24.18.1", + npmArchiveSha256: "5dbb86c71d07a1957f2e90734092dd6a58bdcd9ebc2d8d41ca1c6e6a21d364e1", npmIntegrity: - "sha512-OnUGvKW3lJs/ooPKDKUNfz1UmMfF48YWbjNA20QdiWrCVnZaAPppOfHPnfGiPb+1lKIsxjKXQ4UAfDI7PcvLPg==", + "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw==", npmVersion: "12.0.2", registryOrigin: "https://registry.npmjs.org/", schemaVersion: 2, @@ -132,15 +134,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! }; @@ -300,7 +300,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( @@ -311,6 +311,7 @@ describe("trusted OpenShell SDK archive preparation", () => { "npm", [ "ci", + "--allow-remote=root", "--offline", "--ignore-scripts", "--no-audit", diff --git a/test/runtime/messaging/messaging-build-applier-integrity.test.ts b/test/runtime/messaging/messaging-build-applier-integrity.test.ts index 14b91c14481..fad9bdeaa46 100644 --- a/test/runtime/messaging/messaging-build-applier-integrity.test.ts +++ b/test/runtime/messaging/messaging-build-applier-integrity.test.ts @@ -90,9 +90,11 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-applier-boundary-")); const messagingRoot = path.join(root, "src", "lib", "messaging"); try { - [...dockerfile.matchAll( - /^COPY (src\/lib\/messaging\/|scripts\/lib\/(?:openclaw-npm-remediation|reviewed-npm-archive)\.mts) (\/\S+)$/gm, - )].forEach((copy) => { + [ + ...dockerfile.matchAll( + /^COPY (src\/lib\/messaging\/|scripts\/lib\/(?:openclaw-npm-remediation|reviewed-npm-archive)\.mts) (\/\S+)$/gm, + ), + ].forEach((copy) => { const source = copy[1] ?? ""; const destination = copy[2] ?? ""; const sourcePath = path.join(REPO_ROOT, source); @@ -166,7 +168,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 { @@ -285,7 +287,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 }); @@ -348,7 +350,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|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 ee2d827aa38..9d3d81b0011 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";', @@ -688,9 +688,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 { @@ -718,7 +716,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');", @@ -758,9 +756,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( @@ -986,7 +982,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}||||`); @@ -1043,9 +1039,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( @@ -1271,7 +1265,7 @@ describe("messaging-build-applier.mts: agent-install", () => { 'if (args[0] !== "doctor" || args[1] !== "--fix" || args[2] !== "--non-interactive") process.exit(46);', 'const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");', 'const config = JSON.parse(fs.readFileSync(configPath, "utf8"));', - 'if (config.channels?.telegram?.accounts?.default?.botToken !== undefined) process.exit(40);', + "if (config.channels?.telegram?.accounts?.default?.botToken !== undefined) process.exit(40);", "if (config.channels?.discord?.enabled !== true) process.exit(41);", "if (config.plugins?.entries?.discord?.enabled !== true) process.exit(42);", "if (config.plugins?.entries?.slack?.enabled !== true) process.exit(43);", diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index e48a3208400..9f023073857 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -199,6 +199,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/npm-audit-receipt.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", ), diff --git a/tools/e2e/mcp-dev-workflow-boundary-digests.mts b/tools/e2e/mcp-dev-workflow-boundary-digests.mts index 89c39161f0b..8e33e06862a 100644 --- a/tools/e2e/mcp-dev-workflow-boundary-digests.mts +++ b/tools/e2e/mcp-dev-workflow-boundary-digests.mts @@ -8,11 +8,11 @@ export const MCP_DEV_WORKFLOW_EXECUTION_CONTEXT_SHA256 = export const MCP_DEV_JOB_EXECUTION_CONTEXT_SHA256 = "9b70d22accbd7b413932e73b7e865097291af95eb3bacd4f862ff3f574325ab4"; export const MCP_DEV_TRUSTED_NODE_SETUP_CONTENT_SHA256 = - "504821ad93c57971d0281ef1130ed6008fadd331bd56acb1a6b5e6a3358f3e49"; + "ce61717952372d66af48dbb481889f97eeb53fb65c196ce990b871cfaccb670c"; export const MCP_DEV_TRUSTED_PREFIX_CONTENT_SHA256 = - "4c03445d26a30aabef34ab12879c95c01d5bc48ee1d192f588d47782935104cf"; + "9259b3d10f72fdb3c2e0083aceb56327d5d8cf7e998a4691bb5c8113cba1c249"; export const MCP_DEV_POST_INSTALL_TRANSITION_CONTENT_SHA256 = - "9fae24e2a586143abeb36916b556e924d268300d03375bfe6936c5ad97aab9d5"; + "2e06fcab9090de7fbb91063a5ad5a008cdc1a162c3dcfd5c17a08607c019d8e8"; 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 b24173e675f..fd381691630 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\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`; @@ -566,6 +567,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); @@ -574,18 +576,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@7363df49a5f25b0dd1c20c80905917c31760a27e", + "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/target-catalogue.mts b/tools/e2e/target-catalogue.mts index e094a3980a6..a3114a417c1 100644 --- a/tools/e2e/target-catalogue.mts +++ b/tools/e2e/target-catalogue.mts @@ -259,11 +259,13 @@ const nonInteractive = { 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", @@ -1230,8 +1232,7 @@ export const E2E_TARGET_CATALOGUE: readonly E2eCatalogueTarget[] = [ }), ...GATEWAY_UPGRADE_TARGETS, dockerOnlyTarget("shields-retirement-upgrade", { - displayName: - "Upgrade: migrates a v0.0.115 Shields sandbox to the candidate image", + displayName: "Upgrade: migrates a v0.0.115 Shields sandbox to the candidate image", agentRuntime: "openclaw", environmentOrInferenceEndpoint: "x86-64 Ubuntu; pinned v0.0.115 install and candidate managed image; local compatible endpoint", @@ -1254,8 +1255,7 @@ export const E2E_TARGET_CATALOGUE: readonly E2eCatalogueTarget[] = [ ...nonInteractive, NEMOCLAW_AGENT: "openclaw", NEMOCLAW_OLD_NEMOCLAW_REF: "v0.0.115", - NEMOCLAW_OLD_NEMOCLAW_TAG_OBJECT: - "7503e700808655df1303ddc51888bb596c9afa34", + NEMOCLAW_OLD_NEMOCLAW_TAG_OBJECT: "7503e700808655df1303ddc51888bb596c9afa34", NEMOCLAW_OLD_NEMOCLAW_COMMIT: "324a886fd05b01f6756bae0371ea503c651fbd11", NEMOCLAW_OLD_INSTALLER_SHA256: "0ed77ba8cf176641bd3b22cfd89b4977b3d9a6f47b76da8b03bf4091a20d1251", From 4eb0ce2b7d9276ab5d071e3b4d4ec8df3dee5e6c Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 00:34:25 -0700 Subject: [PATCH 06/30] fix(ci): finalize npm 12 qualification Signed-off-by: Charan Jagwani --- ci/pi-agent-qualification-v1-linux-amd64.json | 8 ++-- ci/pi-agent-qualification-v1-linux-arm64.json | 8 ++-- src/lib/agent/candidate-authority.ts | 4 +- .../hermes-portable-build-context.test.ts | 12 +++++ .../hermes-portable-build-context.ts | 45 +++++++++++++++---- 5 files changed, 59 insertions(+), 18 deletions(-) diff --git a/ci/pi-agent-qualification-v1-linux-amd64.json b/ci/pi-agent-qualification-v1-linux-amd64.json index dcc4973bd90..0dc0beee1db 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:3072694e31b8b67c29e20070735c51d036dec93b4cf268681ceaaaf26f8d043f", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:3072694e31b8b67c29e20070735c51d036dec93b4cf268681ceaaaf26f8d043f", + "digest": "sha256:a90591898732111fab6beea22b329815cd6713905580abcadd279d6b47af438a", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:a90591898732111fab6beea22b329815cd6713905580abcadd279d6b47af438a", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "470a5417558c65260d59cdb5eabb01d35834535e", + "revision": "8232315137b8c9cc64a6ffc7eb53f14024b4bd50", "release": "v0.1.0", - "cohort": "ghrun-33916258004-1" + "cohort": "ghrun-33952213105-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 55ee9d9e228..6861e425e5c 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:1ffe05f2976b2e1b21b5324a9b373a0679bb6d6824634c314b210ee097901e24", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:1ffe05f2976b2e1b21b5324a9b373a0679bb6d6824634c314b210ee097901e24", + "digest": "sha256:af277c779585b13585c736bb6a7f015f827d72027df3a94c6f3702861e162195", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:af277c779585b13585c736bb6a7f015f827d72027df3a94c6f3702861e162195", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "470a5417558c65260d59cdb5eabb01d35834535e", + "revision": "8232315137b8c9cc64a6ffc7eb53f14024b4bd50", "release": "v0.1.0", - "cohort": "ghrun-33916258004-1" + "cohort": "ghrun-33952213105-1" }, "startupProfileContractVersion": 1, "capabilityContractVersion": 1 diff --git a/src/lib/agent/candidate-authority.ts b/src/lib/agent/candidate-authority.ts index 3d458ff9a26..f5005ae36cd 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([ - "2feec3bde072ff79fd0e7005d52637780de6e7d1b3f1d72a505457d455dd7654", - "65798aa3a608ac9eb7a2240514309211585ef46104f719321633276a6b59da69", + "b68875347613a3efdc977c757fd5929ae10fab036c1efccc0a4a2a9b80bafec9", + "a559414a4569575afe5be2992474034930c59c53a56c01373e6952b0c746e972", ]), }); 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 6ed5ed86b4b..8dd95c57421 100644 --- a/src/lib/onboard/experimental/hermes-portable-build-context.test.ts +++ b/src/lib/onboard/experimental/hermes-portable-build-context.test.ts @@ -415,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 af10a55be3d..3c0c49f6715 100644 --- a/src/lib/onboard/experimental/hermes-portable-build-context.ts +++ b/src/lib/onboard/experimental/hermes-portable-build-context.ts @@ -10,10 +10,6 @@ import { renderHermesPortableDockerfileBuildSettings, type HermesPortableDockerfileBuildSettings, } from "../dockerfile-patch"; -import { - REVIEWED_NPM_ARCHIVE_SHA256, - REVIEWED_NPM_TARBALL, -} from "../../../../scripts/lib/reviewed-npm-identity.mts"; import { HERMES_PORTABLE_BUILD_CONTEXT_FILES } from "./hermes-portable-build-context-files"; const CONTEXT_SCHEMA_VERSION = 1 as const; @@ -138,6 +134,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; @@ -250,7 +251,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); @@ -312,8 +316,7 @@ function parseDockerfileSources(bytes: Buffer): readonly string[] { const checksum = options[0]; const isReviewedPythonArchive = source?.startsWith("https://files.pythonhosted.org/"); const isReviewedNpmArchive = - source === REVIEWED_NPM_TARBALL && - checksum === `--checksum=sha256:${REVIEWED_NPM_ARCHIVE_SHA256}`; + source === reviewedNpm.source && checksum === reviewedNpm.checksum; if ( sources.length !== 1 || options.length !== 1 || @@ -342,6 +345,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]; @@ -785,7 +814,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), From 73d442598b8f616e2c5ff0e3fa6dcfbcc22f14e2 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 01:07:47 -0700 Subject: [PATCH 07/30] fix(ci): complete npm 12 bootstrap Signed-off-by: Charan Jagwani --- .github/workflows/docs-preview-pr.yaml | 2 +- .github/workflows/pr.yaml | 180 +----------------- Dockerfile | 4 +- Dockerfile.base | 4 +- agents/hermes/Dockerfile | 52 +++-- ci/source-shape-test-budget.json | 15 ++ .../pr-workflow-contract.test.ts | 50 ++++- .../node-tar-dockerfile-contract.test.ts | 49 ++++- .../github-actions-node-npm-invariant.test.ts | 63 ++++++ 9 files changed, 203 insertions(+), 216 deletions(-) diff --git a/.github/workflows/docs-preview-pr.yaml b/.github/workflows/docs-preview-pr.yaml index d5af06da5af..89bd15e1998 100644 --- a/.github/workflows/docs-preview-pr.yaml +++ b/.github/workflows/docs-preview-pr.yaml @@ -57,7 +57,7 @@ jobs: node-version: "24.18.1" - name: Install reviewed npm - uses: ./.github/actions/setup-reviewed-npm + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e - name: Install docs dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index bdee8b95fcd..9d55e97ea13 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -113,33 +113,6 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Checkout trusted CI actions - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: .trusted-ci-actions - persist-credentials: false - sparse-checkout: | - .github/actions/setup-reviewed-npm - ci/reviewed-npm-audit.json - .github/actions/ci-static-checks - .github/actions/ci-build-typecheck - .github/actions/ci-compile-artifacts - .github/actions/ci-cli-coverage-shard - .github/actions/ci-cli-coverage-merge - .github/actions/ci-plugin-coverage - .github/actions/ci-installer-integration - .github/actions/ci-install-dependencies.sh - ci/reviewed-npm-audit.json - scripts/audit-reviewed-npm-graph.mts - scripts/checks/prepare-ci-npm-install.mts - scripts/lib/openclaw-npm-remediation.mts - scripts/lib/reviewed-npm-archive.mts - scripts/lib/reviewed-npm-audit.mts - scripts/lib/npm-audit-receipt.mts - scripts/lib/repository-input-path.mts - sparse-checkout-cone-mode: false - - name: Download verified OpenShell SDK archive if: needs.openshell-sdk-package.outputs.required == 'true' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -148,7 +121,7 @@ jobs: path: ${{ runner.temp }}/openshell-sdk - name: Run static checks - uses: ./.trusted-ci-actions/.github/actions/ci-static-checks + uses: NVIDIA/NemoClaw/.github/actions/ci-static-checks@7363df49a5f25b0dd1c20c80905917c31760a27e hugging-face-models: needs: changes @@ -376,31 +349,6 @@ jobs: with: persist-credentials: false - - name: Checkout trusted CI actions - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: .trusted-ci-actions - persist-credentials: false - sparse-checkout: | - .github/actions/ci-static-checks - .github/actions/ci-build-typecheck - .github/actions/ci-compile-artifacts - .github/actions/ci-cli-coverage-shard - .github/actions/ci-cli-coverage-merge - .github/actions/ci-plugin-coverage - .github/actions/ci-installer-integration - .github/actions/ci-install-dependencies.sh - ci/reviewed-npm-audit.json - scripts/audit-reviewed-npm-graph.mts - scripts/checks/prepare-ci-npm-install.mts - scripts/lib/openclaw-npm-remediation.mts - scripts/lib/reviewed-npm-archive.mts - scripts/lib/reviewed-npm-audit.mts - scripts/lib/npm-audit-receipt.mts - scripts/lib/repository-input-path.mts - sparse-checkout-cone-mode: false - - name: Download verified OpenShell SDK archive if: needs.openshell-sdk-package.outputs.required == 'true' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -409,7 +357,7 @@ jobs: path: ${{ runner.temp }}/openshell-sdk - name: Compile and verify CLI and plugin outputs - uses: ./.trusted-ci-actions/.github/actions/ci-compile-artifacts + uses: NVIDIA/NemoClaw/.github/actions/ci-compile-artifacts@7363df49a5f25b0dd1c20c80905917c31760a27e - name: Upload compiled test inputs uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -450,8 +398,6 @@ jobs: path: .trusted-ci-actions persist-credentials: false sparse-checkout: | - .github/actions/setup-reviewed-npm - ci/reviewed-npm-audit.json .github/actions/ci-build-typecheck .github/actions/ci-install-dependencies.sh ci/reviewed-npm-audit.json @@ -510,25 +456,6 @@ jobs: with: persist-credentials: false - - name: Checkout trusted CI actions - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: .trusted-ci-actions - persist-credentials: false - sparse-checkout: | - .github/actions/ci-installer-integration - .github/actions/ci-install-dependencies.sh - ci/reviewed-npm-audit.json - scripts/audit-reviewed-npm-graph.mts - scripts/checks/prepare-ci-npm-install.mts - scripts/lib/openclaw-npm-remediation.mts - scripts/lib/reviewed-npm-archive.mts - scripts/lib/reviewed-npm-audit.mts - scripts/lib/npm-audit-receipt.mts - scripts/lib/repository-input-path.mts - sparse-checkout-cone-mode: false - - name: Download verified OpenShell SDK archive if: needs.openshell-sdk-package.outputs.required == 'true' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -537,7 +464,7 @@ jobs: path: ${{ runner.temp }}/openshell-sdk - name: Run installer integration tests - uses: ./.trusted-ci-actions/.github/actions/ci-installer-integration + uses: NVIDIA/NemoClaw/.github/actions/ci-installer-integration@7363df49a5f25b0dd1c20c80905917c31760a27e reviewed-npm-audit: needs: changes @@ -550,26 +477,8 @@ jobs: with: persist-credentials: false - - name: Checkout trusted reviewed npm audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: .trusted-reviewed-npm-audit - persist-credentials: false - sparse-checkout: | - .github/actions/ci-reviewed-npm-audit - ci/npm-audit-exceptions.json - ci/reviewed-npm-audit.json - scripts/audit-reviewed-npm-graph.mts - scripts/lib/openclaw-npm-remediation.mts - scripts/lib/reviewed-npm-archive.mts - scripts/lib/reviewed-npm-audit.mts - scripts/lib/npm-audit-receipt.mts - scripts/lib/repository-input-path.mts - 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@7363df49a5f25b0dd1c20c80905917c31760a27e with: target-root: ${{ github.workspace }} report-dir: artifacts/reviewed-npm-audit @@ -596,31 +505,6 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Checkout trusted CI actions - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: .trusted-ci-actions - persist-credentials: false - sparse-checkout: | - .github/actions/ci-static-checks - .github/actions/ci-build-typecheck - .github/actions/ci-compile-artifacts - .github/actions/ci-cli-coverage-shard - .github/actions/ci-cli-coverage-merge - .github/actions/ci-plugin-coverage - .github/actions/ci-installer-integration - .github/actions/ci-install-dependencies.sh - ci/reviewed-npm-audit.json - scripts/audit-reviewed-npm-graph.mts - scripts/checks/prepare-ci-npm-install.mts - scripts/lib/openclaw-npm-remediation.mts - scripts/lib/reviewed-npm-archive.mts - scripts/lib/reviewed-npm-audit.mts - scripts/lib/npm-audit-receipt.mts - scripts/lib/repository-input-path.mts - sparse-checkout-cone-mode: false - - name: Download verified OpenShell SDK archive if: needs.openshell-sdk-package.outputs.required == 'true' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -643,7 +527,7 @@ jobs: test -s nemoclaw/dist/shared/sandbox-name.cjs - name: Run CLI coverage shard - uses: ./.trusted-ci-actions/.github/actions/ci-cli-coverage-shard + uses: NVIDIA/NemoClaw/.github/actions/ci-cli-coverage-shard@7363df49a5f25b0dd1c20c80905917c31760a27e with: shard: ${{ matrix.shard }} shard-count: "12" @@ -724,31 +608,6 @@ jobs: with: persist-credentials: false - - name: Checkout trusted CI actions - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: .trusted-ci-actions - persist-credentials: false - sparse-checkout: | - .github/actions/ci-static-checks - .github/actions/ci-build-typecheck - .github/actions/ci-compile-artifacts - .github/actions/ci-cli-coverage-shard - .github/actions/ci-cli-coverage-merge - .github/actions/ci-plugin-coverage - .github/actions/ci-installer-integration - .github/actions/ci-install-dependencies.sh - ci/reviewed-npm-audit.json - scripts/audit-reviewed-npm-graph.mts - scripts/checks/prepare-ci-npm-install.mts - scripts/lib/openclaw-npm-remediation.mts - scripts/lib/reviewed-npm-archive.mts - scripts/lib/reviewed-npm-audit.mts - scripts/lib/npm-audit-receipt.mts - scripts/lib/repository-input-path.mts - sparse-checkout-cone-mode: false - - name: Download verified OpenShell SDK archive if: needs.openshell-sdk-package.outputs.required == 'true' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -757,7 +616,7 @@ jobs: path: ${{ runner.temp }}/openshell-sdk - name: Merge CLI coverage - uses: ./.trusted-ci-actions/.github/actions/ci-cli-coverage-merge + uses: NVIDIA/NemoClaw/.github/actions/ci-cli-coverage-merge@7363df49a5f25b0dd1c20c80905917c31760a27e with: shard-count: "12" @@ -776,31 +635,6 @@ jobs: with: persist-credentials: false - - name: Checkout trusted CI actions - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: .trusted-ci-actions - persist-credentials: false - sparse-checkout: | - .github/actions/ci-static-checks - .github/actions/ci-build-typecheck - .github/actions/ci-compile-artifacts - .github/actions/ci-cli-coverage-shard - .github/actions/ci-cli-coverage-merge - .github/actions/ci-plugin-coverage - .github/actions/ci-installer-integration - .github/actions/ci-install-dependencies.sh - ci/reviewed-npm-audit.json - scripts/audit-reviewed-npm-graph.mts - scripts/checks/prepare-ci-npm-install.mts - scripts/lib/openclaw-npm-remediation.mts - scripts/lib/reviewed-npm-archive.mts - scripts/lib/reviewed-npm-audit.mts - scripts/lib/npm-audit-receipt.mts - scripts/lib/repository-input-path.mts - sparse-checkout-cone-mode: false - - name: Download verified OpenShell SDK archive if: needs.openshell-sdk-package.outputs.required == 'true' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -809,7 +643,7 @@ jobs: path: ${{ runner.temp }}/openshell-sdk - name: Run plugin coverage - uses: ./.trusted-ci-actions/.github/actions/ci-plugin-coverage + uses: NVIDIA/NemoClaw/.github/actions/ci-plugin-coverage@7363df49a5f25b0dd1c20c80905917c31760a27e checks: needs: diff --git a/Dockerfile b/Dockerfile index 49a1db6951c..670ccabcbfa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -169,7 +169,7 @@ RUN --network=none set -eu; \ 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/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-identity.mts scripts/lib/seed-reviewed-npm-cache.mts /opt/nemoclaw-build-tools/ 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 --experimental-strip-types /opt/nemoclaw-build-tools/seed-reviewed-npm-cache.mts \ @@ -501,7 +501,7 @@ ENV NPM_CONFIG_AUDIT=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/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-identity.mts scripts/lib/seed-reviewed-npm-cache.mts /opt/nemoclaw-build-tools/lib/ COPY --from=openclaw-managed-messaging-npm-archives / /opt/nemoclaw-build-tools/npm-cache-seed/ RUN --network=none set -eu; \ case "$TARGETARCH" in \ diff --git a/Dockerfile.base b/Dockerfile.base index b31cdadfa19..914bff5db03 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -40,8 +40,8 @@ # 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:24.18.1-trixie-slim digest rotated — update-docker-pin.sh updates all -# Dockerfile and Dockerfile.base +# 4. node:24.18.1-trixie-slim digest rotated — run update-docker-pin.sh once +# for each 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 diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index e1025f25c48..030cabe6e32 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -74,6 +74,8 @@ RUN set -eu; \ FROM scratch AS hermes-npm-patch-payload +COPY --from=reviewed-npm-archive /npm-12.0.2.tgz /tmp/npm-12.0.2.tgz +COPY tools/mcp-tool-discovery-runtime/npm-cache-seed/tar-7.5.21.tgz /tmp/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 @@ -261,7 +263,6 @@ RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ # Cross-stage root copies are accepted by Docker's legacy builder and create # one final-image layer while preserving metadata on existing parent paths. COPY --from=hermes-npm-patch-payload / / -COPY --from=reviewed-npm-archive /npm-12.0.2.tgz /tmp/npm-12.0.2.tgz # A published base can lag the base-image rollout. Install the complete # reviewed npm tree from immutable local bytes before private-tree patches. @@ -349,7 +350,6 @@ 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 --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm \ --archive /tmp/nemoclaw-bundled-npm-tar.tgz @@ -375,28 +375,28 @@ RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ node --experimental-strip-types /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; \ @@ -423,15 +423,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 { expect(workflow.jobs["cli-test-shards"]?.["timeout-minutes"]).toBe(cliShardTimeoutMinutes); }); + // source-shape-contract: security -- npm-consuming pull request composites must execute from the reviewed immutable migration revision until the base contains npm 12. + it("pins npm-consuming pull request composites to the reviewed migration revision", () => { + expect([ + requiredWorkflowStep(prWorkflow.jobs["static-checks"], "Run static checks").uses, + requiredWorkflowStep( + prWorkflow.jobs["compile-artifacts"], + "Compile and verify CLI and plugin outputs", + ).uses, + requiredWorkflowStep( + prWorkflow.jobs["installer-integration"], + "Run installer integration tests", + ).uses, + requiredWorkflowStep( + prWorkflow.jobs["reviewed-npm-audit"], + "Audit reviewed production npm graphs", + ).uses, + requiredWorkflowStep(prWorkflow.jobs["cli-test-shards"], "Run CLI coverage shard").uses, + requiredWorkflowStep(prWorkflow.jobs["cli-tests"], "Merge CLI coverage").uses, + requiredWorkflowStep(prWorkflow.jobs["plugin-tests"], "Run plugin coverage").uses, + ]).toEqual([ + trustedPrActionPaths.staticChecks, + trustedPrActionPaths.compileArtifacts, + trustedPrActionPaths.installerIntegration, + trustedPrActionPaths.reviewedNpmAudit, + trustedPrActionPaths.cliCoverageShard, + trustedPrActionPaths.cliCoverageMerge, + trustedPrActionPaths.pluginCoverage, + ]); + }); + // source-shape-contract: security -- Credential-free workflow structure prevents pull request code from receiving Hugging Face or checkout credentials it("verifies changed Hugging Face catalog references without credentials", () => { const job = prWorkflow.jobs["hugging-face-models"]; diff --git a/test/platform/images/node-tar-dockerfile-contract.test.ts b/test/platform/images/node-tar-dockerfile-contract.test.ts index afb24782ef0..3902173e0c7 100644 --- a/test/platform/images/node-tar-dockerfile-contract.test.ts +++ b/test/platform/images/node-tar-dockerfile-contract.test.ts @@ -13,6 +13,7 @@ import { REVIEWED_NPM_VERSION, } from "../../../scripts/upgrade-bundled-npm.mts"; import { + dockerfileInstructions, dockerfileRunCommandPositions, requireReviewedDockerfileRunCommands, requireSingleReviewedDockerfileRunCommand, @@ -390,10 +391,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 } @@ -448,7 +450,11 @@ describe("reviewed npm image remediation contract", () => { 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 archiveCopy = `COPY --from=reviewed-npm-archive /npm-${REVIEWED_NPM_VERSION}.tgz ${reviewedNpmArchivePath}`; + const directArchiveCopy = `COPY --from=reviewed-npm-archive /npm-${REVIEWED_NPM_VERSION}.tgz ${reviewedNpmArchivePath}`; + const archiveCopy = + file === "agents/hermes/Dockerfile" + ? "COPY --from=hermes-npm-patch-payload / /" + : directArchiveCopy; const archiveCopyIndex = source.indexOf(archiveCopy); const upgradeRun = requireSingleReviewedDockerfileRunCommand( source, @@ -458,6 +464,10 @@ describe("reviewed npm image remediation contract", () => { 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); @@ -494,8 +504,33 @@ describe("reviewed npm image remediation contract", () => { 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 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 ${reviewedNpmArchivePath}`, + ); + expect(payload).toContain( + `COPY tools/mcp-tool-discovery-runtime/npm-cache-seed/tar-${FIXED_TAR_VERSION}.tgz ${hermesFinalArchivePath}`, + ); + 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", () => { + const rootDockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); const stages = directNodeDockerfiles.flatMap((file) => directNodeStages(file, fs.readFileSync(path.join(repoRoot, file), "utf8")), ); @@ -522,6 +557,12 @@ describe("reviewed npm image remediation contract", () => { "agents/pi/Dockerfile.base:native-security-builder", "agents/pi/Dockerfile.base:", ]); + expect(rootDockerfile).toContain( + "COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-identity.mts scripts/lib/seed-reviewed-npm-cache.mts /opt/nemoclaw-build-tools/", + ); + expect(rootDockerfile).toContain( + "COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-identity.mts scripts/lib/seed-reviewed-npm-cache.mts /opt/nemoclaw-build-tools/lib/", + ); expect(invokingStages.map(({ file, name }) => `${file}:${name}`)).toEqual([ "Dockerfile:builder", "Dockerfile:codex-acp-runtime", diff --git a/test/repository/github-actions-node-npm-invariant.test.ts b/test/repository/github-actions-node-npm-invariant.test.ts index 24c94dff723..929c123671d 100644 --- a/test/repository/github-actions-node-npm-invariant.test.ts +++ b/test/repository/github-actions-node-npm-invariant.test.ts @@ -153,6 +153,68 @@ describe("controlled setup-node environments", () => { 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", @@ -160,6 +222,7 @@ describe("controlled setup-node environments", () => { ".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, From d44835b2c3fcd47f1adcb8839580b5bd72077a29 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 01:26:24 -0700 Subject: [PATCH 08/30] fix(ci): preserve rootless tmp permissions Signed-off-by: Charan Jagwani --- agents/hermes/Dockerfile | 10 +++---- .../node-tar-dockerfile-contract.test.ts | 27 ++++++++++++++++--- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 030cabe6e32..82e4186bde1 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -74,8 +74,8 @@ RUN set -eu; \ FROM scratch AS hermes-npm-patch-payload -COPY --from=reviewed-npm-archive /npm-12.0.2.tgz /tmp/npm-12.0.2.tgz -COPY tools/mcp-tool-discovery-runtime/npm-cache-seed/tar-7.5.21.tgz /tmp/nemoclaw-bundled-npm-tar.tgz +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 @@ -268,9 +268,9 @@ COPY --from=hermes-npm-patch-payload / / # reviewed npm tree from immutable local bytes before private-tree patches. RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts \ --npm-root /usr/local/lib/node_modules/npm \ - --archive /tmp/npm-12.0.2.tgz + --archive /scripts/npm-12.0.2.tgz # hadolint ignore=DL3059 -RUN rm /tmp/npm-12.0.2.tgz +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 @@ -352,7 +352,7 @@ RUN grep -Fq 'from tools.lazy_deps import install_specs' /opt/hermes/hermes_cli/ # locked cache seed so protected rebuilds remain network-disabled. RUN node --experimental-strip-types /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 diff --git a/test/platform/images/node-tar-dockerfile-contract.test.ts b/test/platform/images/node-tar-dockerfile-contract.test.ts index 3902173e0c7..d97b75fcd67 100644 --- a/test/platform/images/node-tar-dockerfile-contract.test.ts +++ b/test/platform/images/node-tar-dockerfile-contract.test.ts @@ -73,7 +73,13 @@ const reviewedNpmUpgradeArguments = [ "--archive", reviewedNpmArchivePath, ] as const; -const hermesFinalArchivePath = "/tmp/nemoclaw-bundled-npm-tar.tgz"; +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", @@ -450,7 +456,11 @@ describe("reviewed npm image remediation contract", () => { 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 directArchiveCopy = `COPY --from=reviewed-npm-archive /npm-${REVIEWED_NPM_VERSION}.tgz ${reviewedNpmArchivePath}`; + 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 / /" @@ -459,7 +469,9 @@ describe("reviewed npm image remediation contract", () => { const upgradeRun = requireSingleReviewedDockerfileRunCommand( source, "node --experimental-strip-types /scripts/upgrade-bundled-npm.mts", - reviewedNpmUpgradeArguments, + file === "agents/hermes/Dockerfile" + ? hermesReviewedNpmUpgradeArguments + : reviewedNpmUpgradeArguments, ).commandStart; const firstPrivatePatch = source.indexOf(patchCommand); @@ -508,16 +520,23 @@ describe("reviewed npm image remediation contract", () => { 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 --experimental-strip-types /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 ${reviewedNpmArchivePath}`, + `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( [ From 6f109e3c1365497b2215edf7039544d4fc3f4944 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 02:01:22 -0700 Subject: [PATCH 09/30] fix(ci): complete npm 12 consumer migration Signed-off-by: Charan Jagwani --- Dockerfile | 15 ++++++------ .../hermes-dashboard-provisioning.test.ts | 17 ++++++++++---- .../openclaw-dependency-review.test.ts | 2 +- test/helpers/dockerfile-run-shell.ts | 13 +++++++++++ ...race-expansion-dockerfile-contract.test.ts | 2 +- ...npm-ip-address-dockerfile-contract.test.ts | 2 +- .../node-tar-dockerfile-contract.test.ts | 17 +++++++++++--- test/repository/plugin-vitest-project.test.ts | 7 ++++-- .../sandbox/sandbox-provisioning.test.ts | 23 ++++++++++++++----- 9 files changed, 73 insertions(+), 25 deletions(-) diff --git a/Dockerfile b/Dockerfile index 670ccabcbfa..fdacba95f94 100644 --- a/Dockerfile +++ b/Dockerfile @@ -142,7 +142,8 @@ ADD --chmod=0444 --checksum=sha256:b1b01eb1522aea8f652cc7b692d1c417195713deb12b3 # hadolint ignore=DL3006 FROM codex-acp-${TARGETARCH}-archive AS codex-acp-platform-archive -# Enforce checksum-addressed archives, SRI, offline install, and target architecture (#5896). +# Reviewed-archive invariants (#5896): checksum-addressed source archives, +# committed SRI verification, offline installation, and architecture selection. FROM npm12 AS codex-acp-runtime ARG TARGETARCH ARG CODEX_ACP_0_11_1_INTEGRITY @@ -169,10 +170,10 @@ RUN --network=none set -eu; \ 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/reviewed-npm-identity.mts scripts/lib/seed-reviewed-npm-cache.mts /opt/nemoclaw-build-tools/ +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 --experimental-strip-types /opt/nemoclaw-build-tools/seed-reviewed-npm-cache.mts \ + && node --experimental-strip-types /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/ \ @@ -184,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 --experimental-strip-types /opt/nemoclaw-build-tools/reviewed-npm-archive.mts \ + node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts \ --lockfile /opt/wechat-runtime/package-lock.json \ --cache /out/wechat-npm-cache \ --registry-origin https://registry.npmjs.org/ \ @@ -501,7 +502,7 @@ ENV NPM_CONFIG_AUDIT=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/reviewed-npm-identity.mts scripts/lib/seed-reviewed-npm-cache.mts /opt/nemoclaw-build-tools/lib/ +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 \ @@ -510,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 --experimental-strip-types /opt/nemoclaw-build-tools/lib/seed-reviewed-npm-cache.mts \ + node --experimental-strip-types /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/ \ @@ -520,7 +521,7 @@ 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 --experimental-strip-types /opt/nemoclaw-build-tools/lib/seed-reviewed-npm-cache.mts \ + node --experimental-strip-types /scripts/lib/seed-reviewed-npm-cache.mts \ --packuments-only \ --lockfile /opt/managed-image-messaging-runtime/package-lock.json \ --cache /out/npm-cache \ 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-dependency-review.test.ts b/test/agents/openclaw/openclaw-dependency-review.test.ts index 933c8f14aa3..451e6a419d5 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/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/platform/images/bundled-npm-brace-expansion-dockerfile-contract.test.ts b/test/platform/images/bundled-npm-brace-expansion-dockerfile-contract.test.ts index 92440a81afb..a6d01772e24 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 @@ -35,7 +35,7 @@ const npmRootArguments = ["--npm-root", "/usr/local/lib/node_modules/npm"] as co const hermesTarCacheSeedArguments = [ ...npmRootArguments, "--archive", - "/tmp/nemoclaw-bundled-npm-tar.tgz", + "/scripts/nemoclaw-bundled-npm-tar.tgz", ] as const; const tarPatchArgumentsByDockerfile = { Dockerfile: npmRootArguments, 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 faa40d5cf42..7fcf5593679 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 @@ -35,7 +35,7 @@ const npmRootArguments = ["--npm-root", "/usr/local/lib/node_modules/npm"] as co const hermesTarCacheSeedArguments = [ ...npmRootArguments, "--archive", - "/tmp/nemoclaw-bundled-npm-tar.tgz", + "/scripts/nemoclaw-bundled-npm-tar.tgz", ] as const; const tarPatchArgumentsByDockerfile = { Dockerfile: npmRootArguments, diff --git a/test/platform/images/node-tar-dockerfile-contract.test.ts b/test/platform/images/node-tar-dockerfile-contract.test.ts index d97b75fcd67..03814831079 100644 --- a/test/platform/images/node-tar-dockerfile-contract.test.ts +++ b/test/platform/images/node-tar-dockerfile-contract.test.ts @@ -576,11 +576,22 @@ describe("reviewed npm image remediation contract", () => { "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( + /node --experimental-strip-types \/scripts\/lib\/seed-reviewed-npm-cache[.]mts/gmu, + ), + ).toHaveLength(3); expect(rootDockerfile).toContain( - "COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-identity.mts scripts/lib/seed-reviewed-npm-cache.mts /opt/nemoclaw-build-tools/", + "node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts", ); - expect(rootDockerfile).toContain( - "COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-identity.mts scripts/lib/seed-reviewed-npm-cache.mts /opt/nemoclaw-build-tools/lib/", + 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", diff --git a/test/repository/plugin-vitest-project.test.ts b/test/repository/plugin-vitest-project.test.ts index 23d7e6098d8..b9d2119dd45 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"; @@ -40,15 +40,18 @@ describe("plugin Vitest project contract", () => { it("typechecks plugin production and test sources without emitting tests", () => { const productionFiles = listedTypeScriptFiles("nemoclaw/tsconfig.json"); const testFiles = listedTypeScriptFiles("nemoclaw/tsconfig.test.json"); - const typecheckOutput = execFileSync("npm", ["--prefix", "nemoclaw", "run", "typecheck"], { + 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("tsc --noEmit -p tsconfig.test.json"); }); }); diff --git a/test/runtime/sandbox/sandbox-provisioning.test.ts b/test/runtime/sandbox/sandbox-provisioning.test.ts index 253328971dd..2688bf2dbd5 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, "../../.."); @@ -1039,10 +1040,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 { @@ -1211,10 +1217,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")) From 96a3bc6699dfda5b9a88066bad6197d79df7accb Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 02:24:18 -0700 Subject: [PATCH 10/30] fix(images): include npm cache helper closure Signed-off-by: Charan Jagwani --- Dockerfile | 4 ++-- test/platform/images/node-tar-dockerfile-contract.test.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index fdacba95f94..ec44961c1b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -169,7 +169,7 @@ RUN --network=none set -eu; \ 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/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 \ @@ -501,7 +501,7 @@ 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/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; \ diff --git a/test/platform/images/node-tar-dockerfile-contract.test.ts b/test/platform/images/node-tar-dockerfile-contract.test.ts index 03814831079..91aee041a9d 100644 --- a/test/platform/images/node-tar-dockerfile-contract.test.ts +++ b/test/platform/images/node-tar-dockerfile-contract.test.ts @@ -581,6 +581,11 @@ describe("reviewed npm image remediation contract", () => { /^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 --experimental-strip-types \/scripts\/lib\/seed-reviewed-npm-cache[.]mts/gmu, From f40a3fb99f8a80a81ad09d7929c95c0f9c4c2e97 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 02:30:15 -0700 Subject: [PATCH 11/30] test(images): cover platform-qualified Node stages Signed-off-by: Charan Jagwani --- test/platform/images/node-tar-dockerfile-contract.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/platform/images/node-tar-dockerfile-contract.test.ts b/test/platform/images/node-tar-dockerfile-contract.test.ts index 91aee041a9d..a8bc0dabb41 100644 --- a/test/platform/images/node-tar-dockerfile-contract.test.ts +++ b/test/platform/images/node-tar-dockerfile-contract.test.ts @@ -117,7 +117,7 @@ interface DirectNodeStage { } function directNodeStages(file: string, source: string): DirectNodeStage[] { - const starts = [...source.matchAll(/^FROM\s+([^\s]+)(?:\s+AS\s+(\S+))?$/gmu)]; + 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" ? [ @@ -549,6 +549,12 @@ describe("reviewed npm image remediation contract", () => { // 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")), From 2356580ed05fc4eb20197f61dd1c6cdba2c3f2c6 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 03:06:20 -0700 Subject: [PATCH 12/30] fix(images): regenerate messaging lock with npm 12 Signed-off-by: Charan Jagwani --- Dockerfile | 4 +- .../package-lock.json | 64 ++++++++------- .../materialize-locked-npm-cache-seed.mts | 80 +++++++++++++++---- .../materialize-locked-npm-cache-seed.test.ts | 27 ++++++- 4 files changed, 124 insertions(+), 51 deletions(-) diff --git a/Dockerfile b/Dockerfile index ec44961c1b0..b31f0690693 100644 --- a/Dockerfile +++ b/Dockerfile @@ -272,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 @@ -409,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 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/scripts/checks/materialize-locked-npm-cache-seed.mts b/scripts/checks/materialize-locked-npm-cache-seed.mts index c446f44be70..fcdb5f7cfbe 100644 --- a/scripts/checks/materialize-locked-npm-cache-seed.mts +++ b/scripts/checks/materialize-locked-npm-cache-seed.mts @@ -123,9 +123,26 @@ 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 +171,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 +199,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 +216,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/test/install/materialize-locked-npm-cache-seed.test.ts b/test/install/materialize-locked-npm-cache-seed.test.ts index ee2bdd41f6d..5481202faa5 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" }, ]), ]), }, @@ -186,6 +186,31 @@ describe("locked npm cache seed materialization", () => { expect(readdirSync(seed).sort()).toEqual(["alpha-1.0.0.tgz", "manifest.json"]); }); + 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, [ From 018d4269d367d25c9b8ab3b297d636855d6f7084 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 03:18:34 -0700 Subject: [PATCH 13/30] test(images): verify npm identity staging Signed-off-by: Charan Jagwani --- test/runtime/sandbox/sandbox-build-context.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/runtime/sandbox/sandbox-build-context.test.ts b/test/runtime/sandbox/sandbox-build-context.test.ts index 3b575299dde..7ee08b2c7b2 100644 --- a/test/runtime/sandbox/sandbox-build-context.test.ts +++ b/test/runtime/sandbox/sandbox-build-context.test.ts @@ -518,16 +518,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 +696,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 +717,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); } From a8f178a57e33092b654b04058bcf02d3598935fe Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 03:40:29 -0700 Subject: [PATCH 14/30] fix(ci): close remaining npm bootstrap gaps --- .../workflows/codebase-growth-guardrails.yaml | 8 +++ .github/workflows/managed-images.yaml | 8 +++ .../growth-guardrails-workflow-boundary.mts | 10 ++++ ...rowth-guardrails-workflow-boundary.test.ts | 18 +++++-- .../releases/reviewed-npm-bootstrap.test.ts | 26 +++++++++- .../github-actions-node-npm-invariant.test.ts | 50 +++++++++++++++++-- 6 files changed, 111 insertions(+), 9 deletions(-) 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/managed-images.yaml b/.github/workflows/managed-images.yaml index b310a0e14ee..c269dca80fb 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -1710,6 +1710,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/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/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/releases/reviewed-npm-bootstrap.test.ts b/test/automation/releases/reviewed-npm-bootstrap.test.ts index 985f6af655f..11f334646ee 100644 --- a/test/automation/releases/reviewed-npm-bootstrap.test.ts +++ b/test/automation/releases/reviewed-npm-bootstrap.test.ts @@ -27,6 +27,7 @@ function identity(archive: string): Record { type BootstrapFixtureOptions = { archive: string; + environment?: NodeJS.ProcessEnv; installedVersion?: string; reviewedIdentity?: Record; }; @@ -46,6 +47,7 @@ set -euo pipefail printf '%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" case "$1" in pack) + pack_args="$*" shift download_dir="" while [ "$#" -gt 0 ]; do @@ -56,6 +58,7 @@ case "$1" in 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" ] printf '%s' "$NEMOCLAW_TEST_ARCHIVE" > "$download_dir/npm-12.0.2.tgz" ;; install) @@ -79,6 +82,7 @@ esac encoding: "utf8", env: { ...process.env, + ...options.environment, NEMOCLAW_TEST_ARCHIVE: options.archive, NEMOCLAW_TEST_INSTALL_MARKER: installMarker, NEMOCLAW_TEST_INSTALLED_VERSION: options.installedVersion ?? "12.0.2", @@ -140,7 +144,9 @@ describe("reviewed npm bootstrap", () => { const { npmInvocations, result } = fixture; expect(result.status).toBe(0); expect(npmInvocations).toHaveLength(3); - expect(npmInvocations[0]).toContain("pack npm@12.0.2 --pack-destination"); + 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$/, ); @@ -149,4 +155,22 @@ describe("reviewed npm bootstrap", () => { 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).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$/, + ); + } finally { + fixture.cleanup(); + } + }); }); diff --git a/test/repository/github-actions-node-npm-invariant.test.ts b/test/repository/github-actions-node-npm-invariant.test.ts index 929c123671d..bacbf0096c1 100644 --- a/test/repository/github-actions-node-npm-invariant.test.ts +++ b/test/repository/github-actions-node-npm-invariant.test.ts @@ -23,7 +23,13 @@ const IMMUTABLE_REVIEWED_NPM_ACTION = const IMMUTABLE_PREPARE_E2E_NPM_ACTION = "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e"; -type Step = { if?: string; run?: string; uses?: string; with?: Record }; +type Step = { + if?: string; + name?: string; + run?: string; + uses?: string; + with?: Record; +}; type ActionDocument = { runs?: { steps?: Step[] } }; type WorkflowDocument = { jobs?: Record }; type StepGroup = { file: string; label: string; steps: Step[] }; @@ -67,10 +73,24 @@ const setupNodeSteps = groups.flatMap(({ file, label, steps }) => 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 runsNpm(step: Step): boolean { + return /(?:^|[\n;&|({])\s*(?:npm|npx)(?:\s|$)/mu.test(step.run ?? ""); +} + describe("controlled setup-node environments", () => { // 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", () => { @@ -90,9 +110,7 @@ describe("controlled setup-node environments", () => { .map(({ file, label, steps, step, index }) => { const laterSteps = steps.slice(index + 1); const reviewedIndex = laterSteps.findIndex(installsReviewedNpm); - const npmConsumerIndex = laterSteps.findIndex((step) => - /(?:^|[\n;&|({])\s*(?:npm|npx)(?:\s|$)/mu.test(step.run ?? ""), - ); + const npmConsumerIndex = laterSteps.findIndex(runsNpm); const matchingCondition = step.if === undefined || laterSteps.some( @@ -109,6 +127,30 @@ describe("controlled setup-node environments", () => { .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", () => { From 54b38f51c2749cf5a9bcbcd1bfacacef56d96d7b Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 04:13:37 -0700 Subject: [PATCH 15/30] fix(ci): bind PR audit receipts to npm 12 --- .github/workflows/managed-images.yaml | 8 +- Dockerfile | 2 +- Dockerfile.base | 1 - scripts/lib/npm-audit-receipt.mts | 35 ++--- .../releases/npm-audit-receipt.test.ts | 132 +++++++----------- ...managed-image-publication-workflow.test.ts | 4 +- test/security/mcporter-supply-chain.test.ts | 5 +- 7 files changed, 68 insertions(+), 119 deletions(-) diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index c269dca80fb..fb5d1a504c4 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -91,7 +91,7 @@ jobs: - name: Checkout trusted reviewed npm audit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.pull_request.base.sha }} + ref: 7363df49a5f25b0dd1c20c80905917c31760a27e path: .trusted-reviewed-npm-audit persist-credentials: false sparse-checkout: | @@ -110,13 +110,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: 7363df49a5f25b0dd1c20c80905917c31760a27e 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 diff --git a/Dockerfile b/Dockerfile index b31f0690693..a4567c92efa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -992,7 +992,7 @@ node --experimental-strip-types /scripts/lib/npm-audit-receipt.mts \ --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ --raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ ---registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true; \ +--registry https://registry.yarnpkg.com --threshold high; \ else \ node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts \ --directory /usr/local/lib/nemoclaw/mcporter-runtime \ diff --git a/Dockerfile.base b/Dockerfile.base index 914bff5db03..b23e5cbe699 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -597,7 +597,6 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep --raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ --registry https://registry.yarnpkg.com --threshold high \ - --legacy-npmjs true \ --result /tmp/mcporter-npm-audit-policy.json \ && cp "$MCPORTER_RAW_REPORT" /tmp/mcporter-npm-audit.json; \ else \ diff --git a/scripts/lib/npm-audit-receipt.mts b/scripts/lib/npm-audit-receipt.mts index 399517e07ae..74419d05e7f 100755 --- a/scripts/lib/npm-audit-receipt.mts +++ b/scripts/lib/npm-audit-receipt.mts @@ -14,10 +14,6 @@ import { } from "./reviewed-npm-audit.mts"; export const AUDIT_ARGV = NPM_AUDIT_ARGV; -// Remove this PR-only compatibility after main produces Yarn-bound audit receipts. -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-11T00: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"]); @@ -148,7 +144,6 @@ export function parseAndVerifyAuditReceipt( rawResponse: string | Buffer; registryOrigin: string; now?: Date; - allowLegacyNpmjsReceipt?: boolean; }>, ): AuditReceipt { let parsed: unknown; @@ -170,20 +165,13 @@ export function parseAndVerifyAuditReceipt( ) throw new Error("receipt identity does not match expected graph and npm artifact"); const now = (expected.now ?? new Date()).getTime(); - 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.allowLegacyNpmjsReceipt === 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"); @@ -260,7 +248,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-npmjs 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); } @@ -275,11 +263,9 @@ function cli(args: readonly string[]): void { "--registry", "--threshold", ]; - const allowed = [...required, "--result", "--legacy-npmjs"]; + const allowed = [...required, "--result"]; exactKeys( - Object.fromEntries( - [...values].filter(([key]) => key !== "--result" && key !== "--legacy-npmjs"), - ), + Object.fromEntries([...values].filter(([key]) => key !== "--result")), required, "verifier arguments", ); @@ -301,7 +287,6 @@ function cli(args: readonly string[]): void { packageLock, rawResponse, registryOrigin: values.get("--registry")!, - allowLegacyNpmjsReceipt: values.get("--legacy-npmjs") === "true", }); const policyResult = evaluateAuditPolicy({ directory: path.dirname(values.get("--package-json")!), diff --git a/test/automation/releases/npm-audit-receipt.test.ts b/test/automation/releases/npm-audit-receipt.test.ts index c3836f66817..38b636aa4c9 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, sha256, } from "../../../scripts/lib/npm-audit-receipt.mts"; @@ -61,7 +60,7 @@ describe("reviewed 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", () => { + it("rejects a legacy npmjs receipt", () => { const legacy = { ...receipt(), argv: ["audit", "--omit=dev", "--json"], @@ -70,19 +69,6 @@ describe("reviewed npm audit receipt", () => { expect(() => parseAndVerifyAuditReceipt(canonicalAuditReceipt(legacy), inputs)).toThrow( /registry/, ); - expect( - parseAndVerifyAuditReceipt(canonicalAuditReceipt(legacy), { - ...inputs, - allowLegacyNpmjsReceipt: true, - }).registryOrigin, - ).toBe("https://registry.npmjs.org/"); - expect(() => - parseAndVerifyAuditReceipt(canonicalAuditReceipt(legacy), { - ...inputs, - allowLegacyNpmjsReceipt: true, - now: new Date(LEGACY_NPM_AUDIT_RECEIPT_DEADLINE), - }), - ).toThrow(/allowed contract/); }); it("rejects a receipt whose registry identity differs from its audit command", () => { @@ -164,72 +150,47 @@ describe("reviewed npm audit receipt", () => { ).toThrow(/packageLockSha256/); }); - 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({ npmIntegrity: inputs.npmIntegrity, npmVersion: inputs.npmVersion }), - ); - 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 = [ - "--experimental-strip-types", - 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", - registry, - "--threshold", - inputs.severityThreshold, - ...(legacy ? ["--legacy-npmjs", "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 }); - } - }, - ); + 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({ npmIntegrity: inputs.npmIntegrity, npmVersion: inputs.npmVersion }), + ); + fs.writeFileSync(path.join(root, "receipt.json"), canonicalAuditReceipt(receipt(new Date()))); + const verifierArgs = [ + "--experimental-strip-types", + 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 a receipt produced by a different npm artifact before writing a result", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "receipt-npm-identity-")); @@ -284,7 +245,10 @@ describe("reviewed npm audit receipt", () => { } }); - it("rejects the removed raw-copy option before writing either output", () => { + it.each([ + ["--raw-copy", "copied-raw.json"], + ["--legacy-npmjs", "true"], + ])("rejects the removed %s option before writing output", (option, optionValue) => { 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"); @@ -324,8 +288,8 @@ describe("reviewed npm audit receipt", () => { inputs.severityThreshold, "--result", resultFile, - "--raw-copy", - rawCopyFile, + option, + option === "--raw-copy" ? rawCopyFile : optionValue, ], { encoding: "utf8" }, ); diff --git a/test/inference/managed/managed-image-publication-workflow.test.ts b/test/inference/managed/managed-image-publication-workflow.test.ts index c1ca22506b6..f2c20c335ec 100644 --- a/test/inference/managed/managed-image-publication-workflow.test.ts +++ b/test/inference/managed/managed-image-publication-workflow.test.ts @@ -420,7 +420,7 @@ describe("complete managed-image publication workflow", () => { }); const trustedCheckout = step(reviewedAudit, "Checkout trusted reviewed npm audit"); expect(trustedCheckout.with).toMatchObject({ - ref: "${{ github.event.pull_request.base.sha }}", + ref: "7363df49a5f25b0dd1c20c80905917c31760a27e", path: ".trusted-reviewed-npm-audit", "persist-credentials": false, "sparse-checkout-cone-mode": false, @@ -431,8 +431,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: "7363df49a5f25b0dd1c20c80905917c31760a27e", }); expect(verifyAuditIdentities.run).toContain( "git -C .trusted-reviewed-npm-audit rev-parse --verify HEAD", diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index 9f023073857..9fe431a9146 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -70,7 +70,7 @@ function extractIntegrityGate(contents: string): string { function extractAuditReceiptInvocation(contents: string): string { const startMarker = "node --experimental-strip-types /scripts/lib/npm-audit-receipt.mts"; - const endMarker = "--legacy-npmjs true"; + const endMarker = "--threshold high"; const start = contents.indexOf(startMarker); const end = contents.indexOf(endMarker, start); expect(start).toBeGreaterThanOrEqual(0); @@ -223,8 +223,9 @@ describe("mcporter image supply-chain controls", () => { "--package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json --raw-report", ); expect(auditReceiptInvocation).toContain( - "--exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json --registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true", + "--exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json --registry https://registry.yarnpkg.com --threshold high", ); + expect(contents).not.toContain("--legacy-npmjs"); expect(expectedReviewedNpmVersion).toMatch(/^[0-9]+\.[0-9]+\.[0-9]+$/); expect(auditReceiptInvocation).not.toContain("--npm-version"); expect(contents).not.toContain("--raw-copy"); From 859923a9506a9fd218eb850b0fec4b6ba56df8d9 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 05:19:12 -0700 Subject: [PATCH 16/30] fix(openclaw): accept npm 12 pack metadata --- Dockerfile | 10 +- Dockerfile.base | 8 ++ .../lib/patch-openclaw-npm12-pack-json.mts | 83 +++++++++++++ src/lib/sandbox/build-context.ts | 4 + .../openclaw/openclaw-npm12-pack-json.test.ts | 114 ++++++++++++++++++ test/onboarding/onboard.test.ts | 3 + .../sandbox/sandbox-build-context.test.ts | 1 + 7 files changed, 219 insertions(+), 4 deletions(-) create mode 100755 scripts/lib/patch-openclaw-npm12-pack-json.mts create mode 100644 test/agents/openclaw/openclaw-npm12-pack-json.test.ts diff --git a/Dockerfile b/Dockerfile index a4567c92efa..ce9c44362e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -559,6 +559,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 @@ -791,6 +792,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 \ @@ -1459,12 +1461,11 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-mcp-t RUN node --experimental-strip-types /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 --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.mts \ - /usr/local/lib/node_modules/openclaw/dist + /usr/local/lib/node_modules/openclaw/dist \ + && node --experimental-strip-types /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 +2340,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 b23e5cbe699..07a524d0237 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -431,6 +431,7 @@ COPY scripts/lib/reviewed-npm-archive.mts \ scripts/lib/openclaw-npm-remediation.mts \ /scripts/lib/ COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts +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 @@ -636,6 +637,13 @@ 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" +# 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 --experimental-strip-types /scripts/lib/patch-openclaw-npm12-pack-json.mts \ + /usr/local/lib/node_modules/openclaw/dist "$OPENCLAW_VERSION"; \ + fi + # Baseline health check. The base image runs no service, so this only # verifies the Node.js runtime is functional. Child images that expose 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..b7266146f5b --- /dev/null +++ b/scripts/lib/patch-openclaw-npm12-pack-json.mts @@ -0,0 +1,83 @@ +#!/usr/bin/env -S node --experimental-strip-types +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 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/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index e42ea261d81..d7f04c1a85a 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -374,6 +374,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"), 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..8a40760f852 --- /dev/null +++ b/test/agents/openclaw/openclaw-npm12-pack-json.test.ts @@ -0,0 +1,114 @@ +// 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 --experimental-strip-types /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 --experimental-strip-types /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/onboarding/onboard.test.ts b/test/onboarding/onboard.test.ts index 4c25917b398..691a1a47051 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/runtime/sandbox/sandbox-build-context.test.ts b/test/runtime/sandbox/sandbox-build-context.test.ts index 7ee08b2c7b2..05fd0ba7555 100644 --- a/test/runtime/sandbox/sandbox-build-context.test.ts +++ b/test/runtime/sandbox/sandbox-build-context.test.ts @@ -274,6 +274,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")); From ec04fe1ece34280ce7eecc4e2daff5981df5d194 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 06:30:10 -0700 Subject: [PATCH 17/30] fix(images): preserve npm 12 offline metadata --- Dockerfile | 2 +- scripts/lib/seed-reviewed-npm-cache.mts | 4 + ...aw-managed-messaging-offline-build.test.ts | 6 + test/install/seed-reviewed-npm-cache.test.ts | 108 ++++++++++++++---- 4 files changed, 98 insertions(+), 22 deletions(-) diff --git a/Dockerfile b/Dockerfile index ce9c44362e0..d75dbbf1a40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -521,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; \ + npm cache verify --cache /out/npm-cache; \ node --experimental-strip-types /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; \ diff --git a/scripts/lib/seed-reviewed-npm-cache.mts b/scripts/lib/seed-reviewed-npm-cache.mts index 6fbbc47b92f..108570ee7e7 100755 --- a/scripts/lib/seed-reviewed-npm-cache.mts +++ b/scripts/lib/seed-reviewed-npm-cache.mts @@ -322,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/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/install/seed-reviewed-npm-cache.test.ts b/test/install/seed-reviewed-npm-cache.test.ts index 7318924b2f5..749072f4c99 100644 --- a/test/install/seed-reviewed-npm-cache.test.ts +++ b/test/install/seed-reviewed-npm-cache.test.ts @@ -14,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`; @@ -148,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 () => { @@ -181,8 +195,53 @@ 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(); + 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", + }, + }, + }), + ); + + await seedReviewedNpmCache( + request(input, new Map([[PACKAGE_SPEC, archivePath]])), + cachePutFromInstalledNpm(), + ); + execFileSync("npm", ["cache", "verify", "--cache", input.cacheDirectory]); await seedReviewedNpmCache( { ...request(input, new Map()), @@ -190,29 +249,36 @@ describe("reviewed npm cache seed", () => { }, cachePutFromInstalledNpm(), ); - - const integrity = execFileSync( + const npmEnv = { + ...process.env, + NPM_CONFIG_CACHE: input.cacheDirectory, + NPM_CONFIG_OFFLINE: "true", + }; + const viewedIntegrity = execFileSync( "npm", - [ - "view", - PACKAGE_SPEC, - "dist.integrity", - "--userconfig", - "/dev/null", - "--registry", - REGISTRY_ORIGIN, - ], - { - encoding: "utf8", - env: { - ...process.env, - NPM_CONFIG_CACHE: input.cacheDirectory, - NPM_CONFIG_OFFLINE: "true", - }, - }, + ["view", PACKAGE_SPEC, "dist.integrity", "--registry", REGISTRY_ORIGIN], + { encoding: "utf8", env: npmEnv }, + ).trim(); + const viewedTarball = execFileSync( + "npm", + ["view", PACKAGE_SPEC, "dist.tarball", "--registry", REGISTRY_ORIGIN], + { 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 () => { From 5e414b11aee29c3132cc9d0316b926498834c0bd Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 07:05:48 -0700 Subject: [PATCH 18/30] fix(ci): close npm 12 review gaps --- .github/workflows/base-image.yaml | 1 + .github/workflows/managed-images.yaml | 1 + Dockerfile.base | 15 ++++++------- ci/source-shape-test-budget.json | 5 +++++ scripts/lib/openclaw-npm-remediation.mts | 12 +--------- scripts/lib/reviewed-npm-archive.mts | 19 +++++++++++----- ...ckerfile-remote-dashboard-bind-contract.ts | 3 ++- .../openclaw/openclaw-npm-remediation.test.ts | 9 -------- test/install/reviewed-npm-archive.test.ts | 15 +++++++++++++ .../github-actions-node-npm-invariant.test.ts | 22 ++++++++++++++++++- 10 files changed, 66 insertions(+), 36 deletions(-) diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 395bf57bd6f..6223048a499 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -26,6 +26,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/managed-images.yaml b/.github/workflows/managed-images.yaml index fb5d1a504c4..6eb43666c1a 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" diff --git a/Dockerfile.base b/Dockerfile.base index 07a524d0237..6083017eccc 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -637,14 +637,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" -# 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 --experimental-strip-types /scripts/lib/patch-openclaw-npm12-pack-json.mts \ - /usr/local/lib/node_modules/openclaw/dist "$OPENCLAW_VERSION"; \ - fi - - # 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 @@ -653,6 +645,13 @@ RUN if [ "$OPENCLAW_VERSION" = "2026.7.1" ]; then \ 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 --experimental-strip-types /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/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 2d492e2e279..a4fa64bd04e 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -121,6 +121,11 @@ "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", diff --git a/scripts/lib/openclaw-npm-remediation.mts b/scripts/lib/openclaw-npm-remediation.mts index 3cf9639fc65..70d76fbfdd0 100755 --- a/scripts/lib/openclaw-npm-remediation.mts +++ b/scripts/lib/openclaw-npm-remediation.mts @@ -20,20 +20,10 @@ 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; -export function singleNpmPackResult(value: unknown): JsonObject | 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 JsonObject) : undefined; -} - type Remediation = Readonly<{ expectedPatchedMetadataIntegrity?: string; expectedPatchedTreeIntegrity?: string; diff --git a/scripts/lib/reviewed-npm-archive.mts b/scripts/lib/reviewed-npm-archive.mts index 44f48f223ee..79f160b7f3c 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", @@ -220,12 +232,7 @@ export function packReviewedNpmArchive( } catch (error) { throw new Error(`npm pack ${request.packageSpec} did not return JSON: ${String(error)}`); } - const entries = Array.isArray(parsed) - ? parsed - : typeof parsed === "object" && parsed !== null - ? Object.values(parsed) - : []; - const entry = entries.length === 1 ? entries[0] : undefined; + const entry = singleNpmPackResult(parsed); const filename = typeof entry === "object" && entry !== null && "filename" in entry ? String(entry.filename ?? "") diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index abd2b99374a..0db2c4d6ffb 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -71,12 +71,13 @@ const CANONICAL_POST_GENERATOR_INSTRUCTION_SHA256 = new Set([ "c682148fc7efec9f947c326c6029181cd879b7cba3e8361246aba7d0e6fe70a3", "2801e488822e10a39a5586bd150279e54df4612e30c2fa782453534a466def59", "8f0861e48c0cec37faa662fccd130ab21f972ac3ed2a0ce5f4e5a1e9ec223130", - // Reviewed late messaging inputs, metadata setup, and runtime assertions. + // Reviewed late messaging inputs, metadata setup, npm 12 helper, and runtime assertions. "7e5f7e1dfb90e5e4b863afdfb9ba58e57e3693bdc6f47ac8c13e80bdc9eff56b", "8f5966da093ef75cefd35c2b7f1361fbf5b32e63a4a8a34cb3ac7f76a1330e5e", "c6b042ac2cc3d5570ae43f1e387a951cbe89fbb22e8cd9df486f99719bb32939", "ba29b499af923b4331cf7abf14648f187dc0e0b8f3dc2c33dac61f079981c187", "9c39e3aefbfefb7a48c6ba6879701954ad300f3593d18713063a12056f71af93", + "f6a70c469bc8658fd78c2e07adfb7fb9d78aa1189b0966c0bbf5f1d1f1b49e32", // COPY --from=openclaw-runtime-payload / / // The reviewed scratch payload has no /sandbox/.openclaw content, so this // exact late copy preserves the generated remote-dashboard configuration. diff --git a/test/agents/openclaw/openclaw-npm-remediation.test.ts b/test/agents/openclaw/openclaw-npm-remediation.test.ts index 771f1ddcee3..9d48addd575 100644 --- a/test/agents/openclaw/openclaw-npm-remediation.test.ts +++ b/test/agents/openclaw/openclaw-npm-remediation.test.ts @@ -24,7 +24,6 @@ import { patchOpenClawDiagnosticsOtelPackageGraph, patchOpenClawDiscordPackageGraph, patchOpenClawPluginPackageGraph, - singleNpmPackResult, } from "../../../scripts/lib/openclaw-npm-remediation.mts"; const temporaryDirectories: string[] = []; @@ -393,14 +392,6 @@ afterEach(() => { }); describe("OpenClaw npm remediation", () => { - it("normalizes legacy array and npm 12 package-keyed pack results", () => { - const result = { filename: "openclaw-2026.7.1.tgz" }; - - expect(singleNpmPackResult([result])).toEqual(result); - expect(singleNpmPackResult({ openclaw: result })).toEqual(result); - expect(singleNpmPackResult({ first: result, second: result })).toBeUndefined(); - }); - it("hashes package entries through opened file descriptors", () => { const directory = mkdtempSync(path.join(tmpdir(), "nemoclaw-openclaw-tree-integrity-")); temporaryDirectories.push(directory); diff --git a/test/install/reviewed-npm-archive.test.ts b/test/install/reviewed-npm-archive.test.ts index 10ce44dd883..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) => { diff --git a/test/repository/github-actions-node-npm-invariant.test.ts b/test/repository/github-actions-node-npm-invariant.test.ts index bacbf0096c1..e68d5d9bb67 100644 --- a/test/repository/github-actions-node-npm-invariant.test.ts +++ b/test/repository/github-actions-node-npm-invariant.test.ts @@ -31,7 +31,13 @@ type Step = { with?: Record; }; type ActionDocument = { runs?: { steps?: Step[] } }; -type WorkflowDocument = { jobs?: Record }; +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[] { @@ -92,6 +98,20 @@ function runsNpm(step: Step): boolean { } describe("controlled setup-node environments", () => { + // 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 }) => [ From 38bbaf29f34a7bf0a2d2caf59943db073129c72c Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 07:45:26 -0700 Subject: [PATCH 19/30] fix(ci): align npm 12 qualification evidence --- ci/pi-agent-qualification-v1-linux-amd64.json | 8 +++--- ci/pi-agent-qualification-v1-linux-arm64.json | 8 +++--- ci/source-shape-test-budget.json | 10 +++++++ src/lib/agent/candidate-authority.ts | 4 +-- ...openclaw-real-patched-dist-harness.test.ts | 27 +++++++++++-------- 5 files changed, 36 insertions(+), 21 deletions(-) diff --git a/ci/pi-agent-qualification-v1-linux-amd64.json b/ci/pi-agent-qualification-v1-linux-amd64.json index 0dc0beee1db..7b1a8f4bfc3 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:a90591898732111fab6beea22b329815cd6713905580abcadd279d6b47af438a", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:a90591898732111fab6beea22b329815cd6713905580abcadd279d6b47af438a", + "digest": "sha256:be546e2241f5ac0a6c2b4f7367189b15a7dafbd3f6ff9b85bbd75f4cdbb96542", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:be546e2241f5ac0a6c2b4f7367189b15a7dafbd3f6ff9b85bbd75f4cdbb96542", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "8232315137b8c9cc64a6ffc7eb53f14024b4bd50", + "revision": "5e414b11aee29c3132cc9d0316b926498834c0bd", "release": "v0.1.0", - "cohort": "ghrun-33952213105-1" + "cohort": "ghrun-33970895553-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 6861e425e5c..4987294f20e 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:af277c779585b13585c736bb6a7f015f827d72027df3a94c6f3702861e162195", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:af277c779585b13585c736bb6a7f015f827d72027df3a94c6f3702861e162195", + "digest": "sha256:a19bd8319dd1d36c13a25c2e92931452f57cde23766603110ddd60b986340a76", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:a19bd8319dd1d36c13a25c2e92931452f57cde23766603110ddd60b986340a76", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "8232315137b8c9cc64a6ffc7eb53f14024b4bd50", + "revision": "5e414b11aee29c3132cc9d0316b926498834c0bd", "release": "v0.1.0", - "cohort": "ghrun-33952213105-1" + "cohort": "ghrun-33970895553-1" }, "startupProfileContractVersion": 1, "capabilityContractVersion": 1 diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index a4fa64bd04e..2d45a5e2419 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -186,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/src/lib/agent/candidate-authority.ts b/src/lib/agent/candidate-authority.ts index f5005ae36cd..10e3ba6c88c 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([ - "b68875347613a3efdc977c757fd5929ae10fab036c1efccc0a4a2a9b80bafec9", - "a559414a4569575afe5be2992474034930c59c53a56c01373e6952b0c746e972", + "ba552e1583be1ff1ee4915fd809120202da67bb66fefc7813adee4097eaf79c9", + "00e181a8f301cdd15a9b1a5d2aaed73dafe703ff71d1bf4bf468f3e983c7cb7a", ]), }); 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 ea68b070d06..31e2f5cb8f4 100644 --- a/test/agents/openclaw/openclaw-real-patched-dist-harness.test.ts +++ b/test/agents/openclaw/openclaw-real-patched-dist-harness.test.ts @@ -38,6 +38,11 @@ const PATCH_OPENCLAW_MCP_RELIABILITY = path.join( ); const OPENCLAW_VERSION_EXTRACTOR = path.join(REPO_ROOT, "scripts", "extract-semver.sh"); const REAL_OPENCLAW_NODE_ENV = "NEMOCLAW_REAL_OPENCLAW_NODE"; +const REVIEWED_NODE_VERSION = ( + JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"), "utf8"), + ) as { nodeVersion: string } +).nodeVersion; // 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 +170,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, ); @@ -299,6 +300,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 +308,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 }); From 138205d079d797ba8de4174dca7019d0f8e197aa Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 07:54:12 -0700 Subject: [PATCH 20/30] docs(images): clarify Docker pin refresh command --- Dockerfile.base | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile.base b/Dockerfile.base index 6083017eccc..2faa0dff3bb 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -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:24.18.1-trixie-slim digest rotated — run update-docker-pin.sh once -# for each Dockerfile containing the pin +# 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 From faed9221dc0289e202284a855208a51d27e75494 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 08:27:21 -0700 Subject: [PATCH 21/30] fix(openclaw): prove npm 12 real parser compatibility --- agents/hermes/Dockerfile.base | 4 +- ...openclaw-real-patched-dist-harness.test.ts | 231 ++++++++++++++++-- ...w-security-audit-suppressions-real.test.ts | 9 +- 3 files changed, 224 insertions(+), 20 deletions(-) diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 640decff07e..11ec5ebae00 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -3,7 +3,7 @@ # # Hermes sandbox base image — expensive, rarely-changing layers. # -# Contains: node:24-trixie-slim (OpenShell needs Node), apt packages, setpriv, +# Contains: node:24.18.1-trixie-slim (OpenShell needs Node), apt packages, setpriv, # user/group setup, .hermes directory structure, Hermes CLI, and the # dependencies for NemoClaw-supported Hermes integrations. # @@ -14,7 +14,7 @@ # 1. Hermes version bump — run scripts/update-hermes-agent.sh # 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:24-trixie-slim digest rot — update-docker-pin.sh updates all +# 4. node:24.18.1-trixie-slim digest rot — update-docker-pin.sh updates that tag's digest # 5. npm upgrade — update upgrade-bundled-npm.mts # 6. New .hermes subdirectory — add mkdir/chmod below # ──────────────────────────────────────────────────────────────── 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 31e2f5cb8f4..beb29c44237 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,13 +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_NODE_VERSION = ( - JSON.parse( - fs.readFileSync(path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"), "utf8"), - ) as { nodeVersion: string } -).nodeVersion; +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. @@ -236,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(); } @@ -408,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"); @@ -415,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); @@ -460,6 +511,152 @@ 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, + ["--experimental-strip-types", 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, + ["--experimental-strip-types", 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 expectedMetadata = { + filename: path.basename(tarballPath), + id: `openclaw@${version}`, + integrity, + name: "openclaw", + version, + }; + 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", + version, + "npm 12 pack JSON real parser array package version", + ); + + 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", + version, + "npm 12 pack JSON real parser direct-object package version", + ); + } 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 3552493c75e..4cb34231a10 100644 --- a/test/agents/openclaw/openclaw-security-audit-suppressions-real.test.ts +++ b/test/agents/openclaw/openclaw-security-audit-suppressions-real.test.ts @@ -72,7 +72,14 @@ 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, From 4e6b1fbb1f4b8e953309a373b743287ba734648f Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 09:09:25 -0700 Subject: [PATCH 22/30] fix(ci): close npm 12 review findings --- docs/security/advisory-early-warning.md | 2 +- test/helpers/npm-pack-result.ts | 12 +++++------- .../github-actions-node-npm-invariant.test.ts | 7 +++++++ tools/e2e/base-image-publication.mts | 1 + 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/security/advisory-early-warning.md b/docs/security/advisory-early-warning.md index 07fb6468e3a..7068bc037a4 100644 --- a/docs/security/advisory-early-warning.md +++ b/docs/security/advisory-early-warning.md @@ -95,7 +95,7 @@ The same #7338 sign-off gate applies to this work. Each reviewed 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, exact npm version and verified archive integrity, fixed Yarn audit registry origin, command arguments, and parser identity match. Until 2026-09-11, image builds may accept a still-current npmjs receipt only through the explicit legacy transition. Remove the legacy option and verifier path after Yarn-bound receipts replace the retained npmjs receipts. +A configured cache reuses a response only when the package and lock bytes, exact npm version and verified archive integrity, fixed Yarn audit registry origin, command arguments, and parser identity match. Image builds accept only current schema-version-2 receipts bound to the fixed Yarn audit 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: diff --git a/test/helpers/npm-pack-result.ts b/test/helpers/npm-pack-result.ts index 9c5f51a09b5..3403c0e92f0 100644 --- a/test/helpers/npm-pack-result.ts +++ b/test/helpers/npm-pack-result.ts @@ -1,6 +1,8 @@ // 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>; @@ -9,15 +11,11 @@ export type NpmPackResult = Readonly<{ export function parseSingleNpmPackResult(source: string): NpmPackResult { const parsed: unknown = JSON.parse(source); - const entries = Array.isArray(parsed) - ? parsed - : typeof parsed === "object" && parsed !== null - ? Object.values(parsed) - : []; - if (entries.length !== 1 || typeof entries[0] !== "object" || entries[0] === null) { + const result = singleNpmPackResult(parsed); + if (result === undefined) { throw new Error("npm pack must return exactly one package result"); } - return entries[0] as NpmPackResult; + return result as NpmPackResult; } export function npmPackFilePaths(source: string): string[] { diff --git a/test/repository/github-actions-node-npm-invariant.test.ts b/test/repository/github-actions-node-npm-invariant.test.ts index e68d5d9bb67..2a08cb47b28 100644 --- a/test/repository/github-actions-node-npm-invariant.test.ts +++ b/test/repository/github-actions-node-npm-invariant.test.ts @@ -326,10 +326,17 @@ describe("controlled setup-node environments", () => { 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, + ); }); // source-shape-contract: security -- Every npm-mutating script must import the canonical reviewed identity so independent version or digest pins cannot drift diff --git a/tools/e2e/base-image-publication.mts b/tools/e2e/base-image-publication.mts index d010fd6a718..774d2aa2685 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, From afffe9cdedd168bfd7116c53846ddffe32eadd4c Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Thu, 10 Sep 2026 21:33:30 -0700 Subject: [PATCH 23/30] ci: finalize npm 12 migration evidence Signed-off-by: Charan Jagwani --- .github/actions/ci-cli-coverage-merge/action.yaml | 2 +- .github/actions/ci-cli-coverage-shard/action.yaml | 2 +- .github/actions/ci-compile-artifacts/action.yaml | 2 +- .../actions/ci-installer-integration/action.yaml | 2 +- .github/actions/ci-plugin-coverage/action.yaml | 2 +- .github/actions/ci-static-checks/action.yaml | 2 +- .github/actions/prepare-e2e/action.yaml | 2 +- .github/workflows/code-scanning.yaml | 2 +- .github/workflows/docs-preview-pr.yaml | 2 +- .github/workflows/e2e.yaml | 8 ++++---- .github/workflows/installer-hash-check.yaml | 2 +- .github/workflows/managed-images.yaml | 14 +++++++------- .github/workflows/pr.yaml | 4 ++-- ci/pi-agent-qualification-v1-linux-amd64.json | 8 ++++---- ci/pi-agent-qualification-v1-linux-arm64.json | 8 ++++---- .../checks/pi-qualification-receipt-refresh.mts | 14 ++++++++++---- src/lib/agent/candidate-authority.ts | 4 ++-- .../pull-requests/pr-workflow-contract.test.ts | 2 +- test/e2e/mock-parity.json | 1 + .../managed-image-publication-workflow.test.ts | 4 ++-- test/repository/code-scanning-workflow.test.ts | 2 +- .../github-actions-node-npm-invariant.test.ts | 4 ++-- .../pi-qualification-receipt-refresh.test.ts | 14 +++++++++++++- test/repository/plugin-vitest-project.test.ts | 3 ++- 24 files changed, 65 insertions(+), 45 deletions(-) diff --git a/.github/actions/ci-cli-coverage-merge/action.yaml b/.github/actions/ci-cli-coverage-merge/action.yaml index b088cb38b67..9dff906b96e 100644 --- a/.github/actions/ci-cli-coverage-merge/action.yaml +++ b/.github/actions/ci-cli-coverage-merge/action.yaml @@ -38,7 +38,7 @@ runs: cache: npm - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Install dependencies shell: bash diff --git a/.github/actions/ci-cli-coverage-shard/action.yaml b/.github/actions/ci-cli-coverage-shard/action.yaml index c08429d2d85..dd0d898eb51 100644 --- a/.github/actions/ci-cli-coverage-shard/action.yaml +++ b/.github/actions/ci-cli-coverage-shard/action.yaml @@ -51,7 +51,7 @@ runs: nemoclaw/package-lock.json - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Install pinned Pi search tools shell: bash diff --git a/.github/actions/ci-compile-artifacts/action.yaml b/.github/actions/ci-compile-artifacts/action.yaml index 09cb77cfec1..684e0c28494 100644 --- a/.github/actions/ci-compile-artifacts/action.yaml +++ b/.github/actions/ci-compile-artifacts/action.yaml @@ -17,7 +17,7 @@ runs: nemoclaw/package-lock.json - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Identify compiled output id: identity diff --git a/.github/actions/ci-installer-integration/action.yaml b/.github/actions/ci-installer-integration/action.yaml index 272ee92f6c6..4f29fed36a2 100644 --- a/.github/actions/ci-installer-integration/action.yaml +++ b/.github/actions/ci-installer-integration/action.yaml @@ -17,7 +17,7 @@ runs: nemoclaw/package-lock.json - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Install dependencies shell: bash diff --git a/.github/actions/ci-plugin-coverage/action.yaml b/.github/actions/ci-plugin-coverage/action.yaml index 087af008c93..77cbf2040c9 100644 --- a/.github/actions/ci-plugin-coverage/action.yaml +++ b/.github/actions/ci-plugin-coverage/action.yaml @@ -17,7 +17,7 @@ runs: nemoclaw/package-lock.json - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Install dependencies shell: bash diff --git a/.github/actions/ci-static-checks/action.yaml b/.github/actions/ci-static-checks/action.yaml index 868ead9693c..484349662d7 100644 --- a/.github/actions/ci-static-checks/action.yaml +++ b/.github/actions/ci-static-checks/action.yaml @@ -17,7 +17,7 @@ runs: nemoclaw/package-lock.json - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Install base-trusted createRequire verifier dependencies shell: bash diff --git a/.github/actions/prepare-e2e/action.yaml b/.github/actions/prepare-e2e/action.yaml index 96fbade3fcf..357a8a85069 100644 --- a/.github/actions/prepare-e2e/action.yaml +++ b/.github/actions/prepare-e2e/action.yaml @@ -20,7 +20,7 @@ runs: cache: npm - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Install root dependencies shell: bash diff --git a/.github/workflows/code-scanning.yaml b/.github/workflows/code-scanning.yaml index 6bfee435f3d..f0cb505d602 100644 --- a/.github/workflows/code-scanning.yaml +++ b/.github/workflows/code-scanning.yaml @@ -72,7 +72,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Install ShellCheck run: | diff --git a/.github/workflows/docs-preview-pr.yaml b/.github/workflows/docs-preview-pr.yaml index 89bd15e1998..fe333531285 100644 --- a/.github/workflows/docs-preview-pr.yaml +++ b/.github/workflows/docs-preview-pr.yaml @@ -57,7 +57,7 @@ jobs: node-version: "24.18.1" - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Install docs dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 2141985262c..3292ac5cfef 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -658,7 +658,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm for trusted E2E planning - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Install trusted E2E planner dependencies run: npm ci --ignore-scripts --no-audit --no-fund @@ -4088,7 +4088,7 @@ jobs: - *dockerhub-auth - name: Install reviewed npm for trusted OpenShell verification - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Checkout trusted OpenShell dev tooling uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -5556,7 +5556,7 @@ jobs: node-version: "24.18.1" - name: Reinstall reviewed npm after Node reassertion - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Recover Docker CLI before native Podman E2E if: ${{ matrix.runtime_provider == 'podman' }} @@ -5743,7 +5743,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Dispatch exact commit to Jetson through operator backend env: diff --git a/.github/workflows/installer-hash-check.yaml b/.github/workflows/installer-hash-check.yaml index 78b2768e7d8..2c0bf1e3c2f 100644 --- a/.github/workflows/installer-hash-check.yaml +++ b/.github/workflows/installer-hash-check.yaml @@ -72,7 +72,7 @@ jobs: - name: Install immutable reviewed npm if: github.event_name == 'pull_request' - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Install trusted-event reviewed npm if: github.event_name != 'pull_request' diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 8f23740d9f3..0a4fbf83a11 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -93,7 +93,7 @@ jobs: - name: Checkout npm audit code from the base commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: 7363df49a5f25b0dd1c20c80905917c31760a27e + ref: 98669f24d35f18e49b6b2769cd68709509ea24f2 path: .trusted-reviewed-npm-audit persist-credentials: false sparse-checkout: | @@ -113,7 +113,7 @@ jobs: shell: bash env: CANDIDATE_SHA: ${{ github.event.pull_request.head.sha }} - REVIEWED_AUDIT_SHA: 7363df49a5f25b0dd1c20c80905917c31760a27e + REVIEWED_AUDIT_SHA: 98669f24d35f18e49b6b2769cd68709509ea24f2 run: | set -euo pipefail [[ "$CANDIDATE_SHA" =~ ^[a-f0-9]{40}$ ]] @@ -166,7 +166,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Validate staging QA source shell: bash @@ -515,7 +515,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Validate Deep Agents PR base build arguments if: matrix.agent == 'langchain-deepagents-code' @@ -1002,7 +1002,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Bind E2E correlation identity shell: bash @@ -1107,7 +1107,7 @@ jobs: node-version: 24.18.1 - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Bind E2E correlation identity shell: bash @@ -1243,7 +1243,7 @@ jobs: # 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@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Install trusted-event reviewed npm if: github.event_name != 'pull_request' diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 93d1d2c05c2..f2f35f2f118 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -492,7 +492,7 @@ jobs: nemoclaw/package-lock.json - name: Install reviewed npm - uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2 - name: Install dependencies run: bash .trusted-ci-actions/.github/actions/ci-install-dependencies.sh @@ -575,7 +575,7 @@ jobs: sparse-checkout-cone-mode: false - name: Audit reviewed production npm graphs - uses: NVIDIA/NemoClaw/.github/actions/ci-reviewed-npm-audit@7363df49a5f25b0dd1c20c80905917c31760a27e + uses: NVIDIA/NemoClaw/.github/actions/ci-reviewed-npm-audit@98669f24d35f18e49b6b2769cd68709509ea24f2 with: target-root: ${{ github.workspace }} report-dir: artifacts/reviewed-npm-audit diff --git a/ci/pi-agent-qualification-v1-linux-amd64.json b/ci/pi-agent-qualification-v1-linux-amd64.json index bcaf1570a26..0c6649e4af0 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:0c3da77572f279ea86a3973cb66fa4838df357c9be8bae1eea9bf2ce06b357a7", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:0c3da77572f279ea86a3973cb66fa4838df357c9be8bae1eea9bf2ce06b357a7", + "digest": "sha256:b9c905419bfd0489dea81b255dffbcbecf2f24b1e322785bbf85d46871e43024", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:b9c905419bfd0489dea81b255dffbcbecf2f24b1e322785bbf85d46871e43024", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "d4f59bc21bcbdc1522e7a7f82b1c6202c1dcec7c", + "revision": "98669f24d35f18e49b6b2769cd68709509ea24f2", "release": "v0.1.0", - "cohort": "ghrun-34448331045-1" + "cohort": "ghrun-34561207867-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 1fd2e185329..b5b263550cd 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:b1289f1ccb5a2c346bf5f2c2f53446c30083feb77430eac1f70a1459db4564ed", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:b1289f1ccb5a2c346bf5f2c2f53446c30083feb77430eac1f70a1459db4564ed", + "digest": "sha256:e628642fd52cbb4d62c99be2bb5bd732c71b00e9d08cec1d59fd50f74dd85a1d", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:e628642fd52cbb4d62c99be2bb5bd732c71b00e9d08cec1d59fd50f74dd85a1d", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "d4f59bc21bcbdc1522e7a7f82b1c6202c1dcec7c", + "revision": "98669f24d35f18e49b6b2769cd68709509ea24f2", "release": "v0.1.0", - "cohort": "ghrun-34448331045-1" + "cohort": "ghrun-34561207867-1" }, "startupProfileContractVersion": 1, "capabilityContractVersion": 1 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/src/lib/agent/candidate-authority.ts b/src/lib/agent/candidate-authority.ts index 04a1ec32dc7..9c35958db7c 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([ - "4be5167cb3975147b7828a417e42a4f6ca5e669aca7d37fa0d41b70122ce2251", - "09f7026a9b147af47b3cc601cbf43d940feb814c734a56d213813703c10775fa", + "388d116f235e1ba9245dc3c4fa873d37e5ae20ccc45519923d9ee3259da28a83", + "1696ec86810de1d6111e83b099ecf89fbaaa6aecb051c6cd7b1a0dd339b7779d", ]), }); diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index 93a7ad17b45..93610bdbf48 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -78,7 +78,7 @@ const trustedPrActionPaths = { pluginCoverage: "./.trusted-ci-actions/.github/actions/ci-plugin-coverage", installerIntegration: "./.trusted-ci-actions/.github/actions/ci-installer-integration", reviewedNpmAudit: - "NVIDIA/NemoClaw/.github/actions/ci-reviewed-npm-audit@7363df49a5f25b0dd1c20c80905917c31760a27e", + "NVIDIA/NemoClaw/.github/actions/ci-reviewed-npm-audit@98669f24d35f18e49b6b2769cd68709509ea24f2", } as const; const trustedCheckoutAction = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"; diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 362a9181d79..046d6e6684e 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -266,6 +266,7 @@ }, { "live": "test/e2e/live/openshell-gateway-auth-source-contract.test.ts", + "liveSources": ["test/e2e/live/openshell-gateway-auth-probe.ts"], "fast": ["test/e2e/support/openshell-gateway-auth-source-contract-helpers.test.ts"] }, { diff --git a/test/inference/managed/managed-image-publication-workflow.test.ts b/test/inference/managed/managed-image-publication-workflow.test.ts index 82b9cc755d3..5b9804261ba 100644 --- a/test/inference/managed/managed-image-publication-workflow.test.ts +++ b/test/inference/managed/managed-image-publication-workflow.test.ts @@ -458,7 +458,7 @@ describe("complete managed-image publication workflow", () => { }); const trustedCheckout = step(reviewedAudit, "Checkout npm audit code from the base commit"); expect(trustedCheckout.with).toMatchObject({ - ref: "7363df49a5f25b0dd1c20c80905917c31760a27e", + ref: "98669f24d35f18e49b6b2769cd68709509ea24f2", path: ".trusted-reviewed-npm-audit", "persist-credentials": false, "sparse-checkout-cone-mode": false, @@ -470,7 +470,7 @@ describe("complete managed-image publication workflow", () => { const verifyAuditIdentities = step(reviewedAudit, "Verify exact audit source and target"); expect(verifyAuditIdentities.env).toEqual({ CANDIDATE_SHA: "${{ github.event.pull_request.head.sha }}", - REVIEWED_AUDIT_SHA: "7363df49a5f25b0dd1c20c80905917c31760a27e", + REVIEWED_AUDIT_SHA: "98669f24d35f18e49b6b2769cd68709509ea24f2", }); expect(verifyAuditIdentities.run).toContain( "git -C .trusted-reviewed-npm-audit rev-parse --verify HEAD", diff --git a/test/repository/code-scanning-workflow.test.ts b/test/repository/code-scanning-workflow.test.ts index b931add8791..ecbe80ccf4a 100644 --- a/test/repository/code-scanning-workflow.test.ts +++ b/test/repository/code-scanning-workflow.test.ts @@ -88,7 +88,7 @@ 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@7363df49a5f25b0dd1c20c80905917c31760a27e", + "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2", ); }); diff --git a/test/repository/github-actions-node-npm-invariant.test.ts b/test/repository/github-actions-node-npm-invariant.test.ts index e08252a9055..be0ed439955 100644 --- a/test/repository/github-actions-node-npm-invariant.test.ts +++ b/test/repository/github-actions-node-npm-invariant.test.ts @@ -19,9 +19,9 @@ 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@7363df49a5f25b0dd1c20c80905917c31760a27e"; + "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2"; const IMMUTABLE_PREPARE_E2E_NPM_ACTION = - "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e"; + "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2"; type Step = { if?: string; 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 bfa5e1cc8f4..3c3f5d7bd35 100644 --- a/test/repository/plugin-vitest-project.test.ts +++ b/test/repository/plugin-vitest-project.test.ts @@ -86,6 +86,7 @@ describe("plugin Vitest project contract", () => { expect(testFiles).toContain(path.join(repositoryRoot, "nemoclaw", "vitest.project.ts")); expect(typecheck.error).toBeUndefined(); expect(typecheck.status, typecheckOutput).toBe(0); - expect(typecheckOutput).toContain("tsc --noEmit -p tsconfig.test.json"); + expect(typecheckOutput).toContain("-p tsconfig.json"); + expect(typecheckOutput).toContain("-p tsconfig.test.json"); }); }); From 81fbda04fc3c9329adfe8c7a008afd21d014764b Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Thu, 10 Sep 2026 21:55:58 -0700 Subject: [PATCH 24/30] fix(ci): address npm 12 review feedback Signed-off-by: Charan Jagwani --- .github/workflows/e2e.yaml | 4 ++++ .../native-runtime-qualification-collector.yaml | 1 + .github/workflows/staging-launchable-full.yaml | 1 + scripts/lib/reviewed-npm-audit.mts | 2 -- .../releases/npm-audit-receipt.test.ts | 12 +----------- .../reviewed-npm-audit-workflow.test.ts | 2 +- .../releases/reviewed-npm-audit.test.ts | 6 ++++++ ...ime-qualification-collector-workflow.test.ts | 1 + ...otstrap.sh => brev-node22-bootstrap.fixture} | 6 ------ test/install/installer-hash-check.test.ts | 17 +++++++++++++++-- .../github-actions-node-npm-invariant.test.ts | 12 ++++++++++-- 11 files changed, 40 insertions(+), 24 deletions(-) rename test/fixtures/{brev-node22-bootstrap.sh => brev-node22-bootstrap.fixture} (98%) mode change 100755 => 100644 diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 3292ac5cfef..a5ddddfac25 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -915,6 +915,7 @@ jobs: 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 @@ -1269,6 +1270,7 @@ jobs: 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 @@ -2487,6 +2489,7 @@ jobs: 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 @@ -4006,6 +4009,7 @@ jobs: 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 diff --git a/.github/workflows/native-runtime-qualification-collector.yaml b/.github/workflows/native-runtime-qualification-collector.yaml index 8944de53523..a8545a0e7be 100644 --- a/.github/workflows/native-runtime-qualification-collector.yaml +++ b/.github/workflows/native-runtime-qualification-collector.yaml @@ -64,6 +64,7 @@ jobs: 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 diff --git a/.github/workflows/staging-launchable-full.yaml b/.github/workflows/staging-launchable-full.yaml index 98ccc9f4f91..d203f40dbfa 100644 --- a/.github/workflows/staging-launchable-full.yaml +++ b/.github/workflows/staging-launchable-full.yaml @@ -29,6 +29,7 @@ jobs: 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/ diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index 219d7ffe28d..002d88a591f 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -904,7 +904,6 @@ export function runReviewedNpmAudit( directory: string; exceptionFile: string; graph: string; - npmIntegrity?: string; provenance?: AuditProvenanceContext; reviewedNpmIdentity?: ReviewedNpmIdentity; reportFile?: string; @@ -1031,7 +1030,6 @@ export function parseReviewedNpmAuditCliArgs( "--directory", "--exceptions", "--graph", - "--npm-integrity", "--report", "--result", "--threshold", diff --git a/test/automation/releases/npm-audit-receipt.test.ts b/test/automation/releases/npm-audit-receipt.test.ts index 14b358a9c78..3f3704a66a3 100644 --- a/test/automation/releases/npm-audit-receipt.test.ts +++ b/test/automation/releases/npm-audit-receipt.test.ts @@ -64,17 +64,7 @@ describe("npm audit receipt", () => { }); it("rejects a receipt that omits the reviewed npm archive identity", () => { - const { - npmArchiveSha256: _npmArchiveSha256, - npmIntegrity: _npmIntegrity, - ...versionOnly - } = receipt(); - const incomplete = { - ...versionOnly, - argv: ["audit", "--omit=dev", "--json"], - registryOrigin: "https://registry.npmjs.org/", - schemaVersion: 1, - } as const; + const { npmArchiveSha256: _npmArchiveSha256, ...incomplete } = receipt(); expect(() => parseAndVerifyAuditReceipt(JSON.stringify(incomplete), inputs)).toThrow( /unexpected or missing keys/, ); diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index eb478ee4a00..d7992178a6c 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -1268,7 +1268,7 @@ describe("trusted npm audit workflow (#5896)", () => { provenance: { label: "NemoClaw CLI locked production graph", npmIntegrity: REVIEWED_AUDIT_CONFIG.npmIntegrity, - npmVersion: "12.0.2", + npmVersion: REVIEWED_AUDIT_CONFIG.npmVersion, packageSpecs: ["nemoclaw@0.0.0"], }, reviewedNpmIdentity: REVIEWED_AUDIT_CONFIG, diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index 0f1e73f1e64..f5053a52bf0 100644 --- a/test/automation/releases/reviewed-npm-audit.test.ts +++ b/test/automation/releases/reviewed-npm-audit.test.ts @@ -565,6 +565,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", 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 26ceb9f921b..289e885dfdd 100644 --- a/test/e2e/support/native-runtime-qualification-collector-workflow.test.ts +++ b/test/e2e/support/native-runtime-qualification-collector-workflow.test.ts @@ -115,6 +115,7 @@ describe("native runtime qualification collector workflow", () => { [ ".github/actions/setup-reviewed-npm", "ci/reviewed-npm-audit.json", + "scripts/lib/reviewed-npm-audit.mts", ...localImportClosure(COLLECTOR_ENTRYPOINT), ].sort(), ); diff --git a/test/fixtures/brev-node22-bootstrap.sh b/test/fixtures/brev-node22-bootstrap.fixture old mode 100755 new mode 100644 similarity index 98% rename from test/fixtures/brev-node22-bootstrap.sh rename to test/fixtures/brev-node22-bootstrap.fixture index cb6201c2775..b56a431505b --- a/test/fixtures/brev-node22-bootstrap.sh +++ b/test/fixtures/brev-node22-bootstrap.fixture @@ -1,12 +1,6 @@ -#!/usr/bin/env bash # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# FIXTURE — DO NOT EXECUTE, DO NOT UPDATE. -# Frozen pre-migration copy of scripts/brev-launchable-ci-cpu.sh at Node.js 22. -# Consumed by test/install/installer-hash-check.test.ts for historical scenarios. -# Keep the Node.js 22 pins unchanged; the live script owns the current runtime. -# # Brev launchable startup script — CI-Ready CPU # # Pre-bakes a VM with everything needed for NemoClaw E2E tests so that diff --git a/test/install/installer-hash-check.test.ts b/test/install/installer-hash-check.test.ts index bce0c3f3074..387631d5703 100644 --- a/test/install/installer-hash-check.test.ts +++ b/test/install/installer-hash-check.test.ts @@ -45,8 +45,12 @@ const INSTALLER_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 = fs.readFileSync( - path.join(REPO_ROOT, "test/fixtures/brev-node22-bootstrap.sh"), +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", ); const ASSET_DIGESTS = V0072_CONSUMED_ASSET_DIGESTS; @@ -134,6 +138,7 @@ type FixtureMode = | "partial" | "partial-asset-missing" | "partial-manifest-missing" + | "production-brev" | "pr-checker-bypass" | "pr-parser-bypass" | "brev-stable-version-drift" @@ -157,6 +162,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) => @@ -966,6 +972,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.106"); + + expect(result.status, result.stdout).toBe(0); + expect(result.stdout).toContain("All installer hashes are current"); + }); + it("verifies each complete installer release table independently", () => { const result = runFixture("complete-multiple-installer-versions", undefined, true); diff --git a/test/repository/github-actions-node-npm-invariant.test.ts b/test/repository/github-actions-node-npm-invariant.test.ts index be0ed439955..13f8189e865 100644 --- a/test/repository/github-actions-node-npm-invariant.test.ts +++ b/test/repository/github-actions-node-npm-invariant.test.ts @@ -103,10 +103,17 @@ function runsSanitizedReviewedNpmBootstrap(step: Step): boolean { } function runsNpm(step: Step): boolean { - return /(?:^|[\n;&|({])\s*(?:npm|npx)(?:\s|$)/mu.test(step.run ?? ""); + 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/**"; @@ -215,7 +222,8 @@ describe("controlled setup-node environments", () => { const completeSparseCheckout = sparseCheckout === undefined || (sparsePaths.includes(".github/actions/setup-reviewed-npm") && - sparsePaths.includes("ci/reviewed-npm-audit.json")); + 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, From c9a7862f090a6248f4aa9bfa0cc718ce2417cd83 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Thu, 10 Sep 2026 22:39:45 -0700 Subject: [PATCH 25/30] fix(ci): finish npm 12 action migration Signed-off-by: Charan Jagwani --- .github/workflows/e2e-standard-profile.yaml | 2 +- .github/workflows/e2e.yaml | 32 ++++++++--------- .github/workflows/portable-profile-e2e.yaml | 2 +- .github/workflows/pr-self-hosted.yaml | 2 +- .../materialize-locked-npm-cache-seed.mts | 4 +-- ...w-security-audit-suppressions-real.test.ts | 9 +---- .../support/jetson-workflow-boundary.test.ts | 4 +-- ...in-runtime-exdev-workflow-boundary.test.ts | 2 +- .../openshell-sdk-loading.test.ts | 15 ++++---- .../node-tar-dockerfile-contract.test.ts | 20 +++-------- .../github-actions-node-npm-invariant.test.ts | 35 +++++++++++++++++++ ...-spark-qualification-workflow-boundary.mts | 2 +- ...ge-protected-runtime-workflow-boundary.mts | 2 +- .../e2e/mcp-dev-workflow-boundary-digests.mts | 4 +-- tools/e2e/mcp-workflow-boundary.mts | 4 +-- tools/e2e/prepare-e2e-workflow-boundary.mts | 3 +- tools/e2e/workflow-boundary-policy.mts | 6 ++-- 17 files changed, 79 insertions(+), 69 deletions(-) diff --git a/.github/workflows/e2e-standard-profile.yaml b/.github/workflows/e2e-standard-profile.yaml index 26e65d8aad8..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@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index a5ddddfac25..ae9a21b3df9 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -2588,7 +2588,7 @@ jobs: persist-credentials: false - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -2834,7 +2834,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@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -3051,7 +3051,7 @@ jobs: persist-credentials: false - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -3357,7 +3357,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -3433,7 +3433,7 @@ jobs: persist-credentials: false - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -3731,7 +3731,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -3888,7 +3888,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -4168,7 +4168,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@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -4389,7 +4389,7 @@ jobs: http = true - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -4885,7 +4885,7 @@ jobs: driver: docker - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -5095,7 +5095,7 @@ jobs: http = true - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c - name: Validate protected runtime activation contract env: @@ -5367,7 +5367,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -5540,7 +5540,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -5828,7 +5828,7 @@ jobs: packages: expect - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -5971,7 +5971,7 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" @@ -6069,7 +6069,7 @@ jobs: uses: NVIDIA/NemoClaw/.github/actions/docker-auth-cleanup@d5f37099766ca82a4516e7d8f0de117cda197fe3 - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c with: build-cli: "false" diff --git a/.github/workflows/portable-profile-e2e.yaml b/.github/workflows/portable-profile-e2e.yaml index e5a44eb8ffd..67c368462ca 100644 --- a/.github/workflows/portable-profile-e2e.yaml +++ b/.github/workflows/portable-profile-e2e.yaml @@ -209,7 +209,7 @@ jobs: persist-credentials: false - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c - name: Provision restricted rootless Linux runtime shell: bash diff --git a/.github/workflows/pr-self-hosted.yaml b/.github/workflows/pr-self-hosted.yaml index 9d83e2126ab..a849c21a717 100644 --- a/.github/workflows/pr-self-hosted.yaml +++ b/.github/workflows/pr-self-hosted.yaml @@ -164,7 +164,7 @@ jobs: ref: ${{ github.sha }} - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c - name: Bind E2E correlation identity shell: bash diff --git a/scripts/checks/materialize-locked-npm-cache-seed.mts b/scripts/checks/materialize-locked-npm-cache-seed.mts index fcdb5f7cfbe..88ad1e8286f 100644 --- a/scripts/checks/materialize-locked-npm-cache-seed.mts +++ b/scripts/checks/materialize-locked-npm-cache-seed.mts @@ -138,9 +138,7 @@ function dependencyEntries( } 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, - ) + return /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/u.test(requested) ? requested : undefined; } 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 4cb34231a10..9385ec9f952 100644 --- a/test/agents/openclaw/openclaw-security-audit-suppressions-real.test.ts +++ b/test/agents/openclaw/openclaw-security-audit-suppressions-real.test.ts @@ -72,14 +72,7 @@ function installReviewedOpenClaw(workspace: string): string { }; const packed = spawnSync( "npm", - [ - "pack", - reviewed.tarball, - "--allow-remote=all", - "--pack-destination", - workspace, - "--json", - ], + ["pack", reviewed.tarball, "--allow-remote=all", "--pack-destination", workspace, "--json"], { encoding: "utf-8", env: childEnv, diff --git a/test/e2e/support/jetson-workflow-boundary.test.ts b/test/e2e/support/jetson-workflow-boundary.test.ts index afbe371ac6e..d1062189f56 100644 --- a/test/e2e/support/jetson-workflow-boundary.test.ts +++ b/test/e2e/support/jetson-workflow-boundary.test.ts @@ -169,9 +169,7 @@ describe("Jetson nvmap GPU E2E workflow boundary", () => { setup.uses = "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@main"; }); - expect(errors).toContain( - "jetson-nvmap-gpu controller must install reviewed npm immutably", - ); + expect(errors).toContain("jetson-nvmap-gpu controller must install reviewed npm immutably"); }); it("keeps the runner temporary artifact path on the dispatch step (#8142)", () => { 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 88365263505..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 @@ -52,7 +52,7 @@ function validWorkflow(): OpenClawPluginRuntimeExdevWorkflow { }, { name: "Prepare E2E workspace", - uses: "NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d", + uses: "NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c", }, { name: "Run OpenClaw cross-device plugin lifecycle live test", 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/node-tar-dockerfile-contract.test.ts b/test/platform/images/node-tar-dockerfile-contract.test.ts index 1da4f8e9d7c..1dfdd88d09b 100644 --- a/test/platform/images/node-tar-dockerfile-contract.test.ts +++ b/test/platform/images/node-tar-dockerfile-contract.test.ts @@ -457,9 +457,7 @@ describe("reviewed npm image remediation contract", () => { 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; + 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" @@ -503,12 +501,8 @@ describe("reviewed npm image remediation contract", () => { 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", - ); + 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); @@ -593,13 +587,9 @@ describe("reviewed npm image remediation contract", () => { ), ).toHaveLength(2); expect( - rootDockerfile.match( - /node \/scripts\/lib\/seed-reviewed-npm-cache[.]mts/gmu, - ), + 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).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", diff --git a/test/repository/github-actions-node-npm-invariant.test.ts b/test/repository/github-actions-node-npm-invariant.test.ts index 13f8189e865..aea75d6ed22 100644 --- a/test/repository/github-actions-node-npm-invariant.test.ts +++ b/test/repository/github-actions-node-npm-invariant.test.ts @@ -22,6 +22,8 @@ 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; @@ -51,6 +53,19 @@ function yamlFiles(directory: string): string[] { }); } +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 [ @@ -354,6 +369,26 @@ describe("controlled setup-node environments", () => { 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 diff --git a/tools/e2e/llama-cpp-dgx-spark-qualification-workflow-boundary.mts b/tools/e2e/llama-cpp-dgx-spark-qualification-workflow-boundary.mts index b4d1f027176..988af9ff0ba 100644 --- a/tools/e2e/llama-cpp-dgx-spark-qualification-workflow-boundary.mts +++ b/tools/e2e/llama-cpp-dgx-spark-qualification-workflow-boundary.mts @@ -24,7 +24,7 @@ const QUALIFICATION_SELECTOR = `\${{ inputs.${RUNNER_QUEUE_INPUT} && github.repo const CHECKOUT = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"; const BUILDX = "docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c"; const PREPARE = - "NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d"; + "NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c"; function record(value: unknown): RecordValue { return value && typeof value === "object" && !Array.isArray(value) ? (value as RecordValue) : {}; diff --git a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts index 944394d83ad..d84f6c2e2f9 100644 --- a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts +++ b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts @@ -247,7 +247,7 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR const prepare = requireStep(errors, workflowSteps, "Prepare E2E workspace"); if ( prepare?.uses !== - "NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d" + "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 47b733f4183..259dcc16ab9 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 = - "5fdff11b540872a75cdf2660e002bb5958b73a260c9b85feedce74f53fa40dac"; + "a99862977077321de6f23184485a71e8dbbc2a40afb6d69aa39c82c38410646d"; export const MCP_DEV_POST_INSTALL_TRANSITION_CONTENT_SHA256 = - "2e06fcab9090de7fbb91063a5ad5a008cdc1a162c3dcfd5c17a08607c019d8e8"; + "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 71307e0618c..69ef7b290be 100644 --- a/tools/e2e/mcp-workflow-boundary.mts +++ b/tools/e2e/mcp-workflow-boundary.mts @@ -51,7 +51,7 @@ const DEV_ARTIFACT_TRUSTED_CHECKOUT = ".trusted-openshell-dev-artifact"; const DEV_ARTIFACT_COPY_HELPER = ".github/scripts/copy-openshell-dev-asset.sh"; const DEV_ARTIFACT_TOOL_PATHS = "scripts/install-openshell.sh\ntools/e2e/openshell-dev-artifact.mts\n"; -const DEV_ARTIFACT_TRUSTED_PATHS = `.github/actions/setup-reviewed-npm\nci/reviewed-npm-audit.json\n${DEV_ARTIFACT_TOOL_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}`; @@ -592,7 +592,7 @@ function validateJobExecution( requireEqual( errors, reviewedNpm.uses, - "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e", + "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2", "mcp-bridge-dev must install reviewed npm from the immutable trusted action", ); if ( diff --git a/tools/e2e/prepare-e2e-workflow-boundary.mts b/tools/e2e/prepare-e2e-workflow-boundary.mts index 525a4bba839..7ba95f4784c 100644 --- a/tools/e2e/prepare-e2e-workflow-boundary.mts +++ b/tools/e2e/prepare-e2e-workflow-boundary.mts @@ -83,8 +83,7 @@ export function validatePrepareE2eAction(actionPath = DEFAULT_ACTION_PATH): stri }, { name: "Install reviewed npm", - uses: - "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@470a5417558c65260d59cdb5eabb01d35834535e", + uses: "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2", }, { name: "Install root dependencies", diff --git a/tools/e2e/workflow-boundary-policy.mts b/tools/e2e/workflow-boundary-policy.mts index bcb9117eea5..6a3bc25f140 100644 --- a/tools/e2e/workflow-boundary-policy.mts +++ b/tools/e2e/workflow-boundary-policy.mts @@ -4,12 +4,12 @@ export const E2E_ACTION_PROVENANCE = { reviewedNpmSetup: { reference: - "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@7363df49a5f25b0dd1c20c80905917c31760a27e", + "NVIDIA/NemoClaw/.github/actions/setup-reviewed-npm@98669f24d35f18e49b6b2769cd68709509ea24f2", }, prepareWorkspace: { reference: - "NVIDIA/NemoClaw/.github/actions/prepare-e2e@667c431859bf8d0a75b7f6a4946e635640e7734d", - contentSha256: "852afadf535895ce781a44725250190fbe0743088303e6819e8e22a9f03017a2", + "NVIDIA/NemoClaw/.github/actions/prepare-e2e@afffe9cdedd168bfd7116c53846ddffe32eadd4c", + contentSha256: "4458b3491e5e01097db99a212c4a7bf5ae0cc62cdeda7fef8e3862ed572d2c2b", }, nativePodmanRuntime: { reference: From b7e8c2d1bcf92eacd0b1502e84f3630694fecc09 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Fri, 11 Sep 2026 09:40:51 -0700 Subject: [PATCH 26/30] chore: refresh Pi qualification receipts --- ci/pi-agent-qualification-v1-linux-amd64.json | 8 ++++---- ci/pi-agent-qualification-v1-linux-arm64.json | 8 ++++---- src/lib/agent/candidate-authority.ts | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ci/pi-agent-qualification-v1-linux-amd64.json b/ci/pi-agent-qualification-v1-linux-amd64.json index 5e523f261f2..3aff9d72130 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:50ae66020249633503f6e21650b4807a94bffdaffd1545d1aa6fd52bb6d9b44f", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:50ae66020249633503f6e21650b4807a94bffdaffd1545d1aa6fd52bb6d9b44f", + "digest": "sha256:3bf1c001050610f7ed7ebc3094d0a734c7abae879806d021fed97214376c3609", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:3bf1c001050610f7ed7ebc3094d0a734c7abae879806d021fed97214376c3609", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "a2d6e9d7658c54389eb3924ed48ae185d01875e6", + "revision": "89896da6796c28d1ed47474de78b14169c402d8d", "release": "v0.1.0", - "cohort": "ghrun-34593121049-1" + "cohort": "ghrun-34621870790-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 79647eaec03..c1d0c138f46 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:8c571a0c90f45feff53ca4b4e7a15d5be5aabd5ba4b98a6c62eb6ec1fcb4f4f9", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:8c571a0c90f45feff53ca4b4e7a15d5be5aabd5ba4b98a6c62eb6ec1fcb4f4f9", + "digest": "sha256:1dd3311bd1e060a7da93eb1136ae1909801753b74c1851a1986facb4a93f7321", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:1dd3311bd1e060a7da93eb1136ae1909801753b74c1851a1986facb4a93f7321", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "a2d6e9d7658c54389eb3924ed48ae185d01875e6", + "revision": "89896da6796c28d1ed47474de78b14169c402d8d", "release": "v0.1.0", - "cohort": "ghrun-34593121049-1" + "cohort": "ghrun-34621870790-1" }, "startupProfileContractVersion": 1, "capabilityContractVersion": 1 diff --git a/src/lib/agent/candidate-authority.ts b/src/lib/agent/candidate-authority.ts index 48a86372f4d..6b2b916a572 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([ - "0149d48ac4098137be673fea0100b2a7d487652c6548d8927f2c6fa1d0283767", - "53470135b2a9d772bf7e8ed2b638a63e8f127b180ee05dacb6119f39db4ba216", + "c93f3abae7cec499ca8ac5562b9ded71a7f9214d367fb06b9d5d019a3fbafcfb", + "2330fd59655c0189d60d91653f9b83bcc9d2e63ff8e3db71d45409ba217345b7", ]), }); From 3efbb2fd394758c1c51bb395327dc458cf812f7a Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 12 Sep 2026 00:54:28 -0700 Subject: [PATCH 27/30] chore: refresh Pi qualification receipts Signed-off-by: Charan Jagwani --- ci/pi-agent-qualification-v1-linux-amd64.json | 8 ++++---- ci/pi-agent-qualification-v1-linux-arm64.json | 8 ++++---- src/lib/agent/candidate-authority.ts | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ci/pi-agent-qualification-v1-linux-amd64.json b/ci/pi-agent-qualification-v1-linux-amd64.json index b5e4a379db2..498f85ff554 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:943605332e2f14ae41af8a665eeaebcf281e85a91c41219ef5f9f12294ab5eba", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:943605332e2f14ae41af8a665eeaebcf281e85a91c41219ef5f9f12294ab5eba", + "digest": "sha256:26892197731e1bfc8d2efe223780c167dded5ff92908c32f063010a5521c9c61", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:26892197731e1bfc8d2efe223780c167dded5ff92908c32f063010a5521c9c61", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "322bd36725ed1c6b2d5993fb2e1a727591ccdf90", + "revision": "8e57d6d0e02ea87d2b7e108e70dc0600b27e1f0b", "release": "v0.1.0", - "cohort": "ghrun-34634582790-1" + "cohort": "ghrun-34681243488-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 c39ff8d8ab1..4190be956bb 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:5c10876276dd9c4492925a0a68efb0b447a6d390076110cf6bd883ab7d44c5cd", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:5c10876276dd9c4492925a0a68efb0b447a6d390076110cf6bd883ab7d44c5cd", + "digest": "sha256:44b23db5da6bafee702e95ca36c5bc50065d8fdb6fc8db47e3a4f35a7736ae84", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:44b23db5da6bafee702e95ca36c5bc50065d8fdb6fc8db47e3a4f35a7736ae84", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "322bd36725ed1c6b2d5993fb2e1a727591ccdf90", + "revision": "8e57d6d0e02ea87d2b7e108e70dc0600b27e1f0b", "release": "v0.1.0", - "cohort": "ghrun-34634582790-1" + "cohort": "ghrun-34681243488-1" }, "startupProfileContractVersion": 1, "capabilityContractVersion": 1 diff --git a/src/lib/agent/candidate-authority.ts b/src/lib/agent/candidate-authority.ts index 5dc82151f67..ff266b5fb11 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([ - "f3f3dd129db2cd0902a0072336f871979c72324fcd8296ad9c1e226e27b1c052", - "d9a727ce41822cb95250459f01bcc75b7105bf76d24f259fbbfff5521cda4b94", + "dfc0fdc8e6fa290f208d881e29e4a45a414d6c54ed473916370a5f55c3aa0146", + "a577d7f87bf517491d0c4dc2b6da87943a6143ce9ae891cbdd91d7abae580fb1", ]), }); From fc721b7740110b425587c657f8ceb9ecca7c2039 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sun, 13 Sep 2026 22:04:20 -0700 Subject: [PATCH 28/30] chore(ci): refresh Pi qualification receipts --- ci/pi-agent-qualification-v1-linux-amd64.json | 8 ++++---- ci/pi-agent-qualification-v1-linux-arm64.json | 8 ++++---- src/lib/agent/candidate-authority.ts | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ci/pi-agent-qualification-v1-linux-amd64.json b/ci/pi-agent-qualification-v1-linux-amd64.json index 4efc2d351b7..be782e3c0e1 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:476bc35e24120f82ee6583040ac09c0526030a5f1047b2443ae59ad653039927", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:476bc35e24120f82ee6583040ac09c0526030a5f1047b2443ae59ad653039927", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "719771e2f8b992ad15c7adbc6b8a2c46d34f664b", + "revision": "3fdc1713f7e5aa9d4202b327dd4a35c5c18a83ee", "release": "v0.1.0", - "cohort": "ghrun-34740252459-2" + "cohort": "ghrun-34807309731-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..d06edd1d118 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:eac0ab06cc819d9d31e3efcb2c8c97d75201ba5d77ede83af2739d3d66f56ab8", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:eac0ab06cc819d9d31e3efcb2c8c97d75201ba5d77ede83af2739d3d66f56ab8", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "719771e2f8b992ad15c7adbc6b8a2c46d34f664b", + "revision": "3fdc1713f7e5aa9d4202b327dd4a35c5c18a83ee", "release": "v0.1.0", - "cohort": "ghrun-34740252459-2" + "cohort": "ghrun-34807309731-1" }, "startupProfileContractVersion": 1, "capabilityContractVersion": 1 diff --git a/src/lib/agent/candidate-authority.ts b/src/lib/agent/candidate-authority.ts index 84c2b633118..e3f5fd348ce 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", + "639141373dce67c4506eef69fd33fff00db389c4c130115efdfc7f582a86796b", + "84d052a75e6ea5abdaf6183fac250f982e95651e2a42f4965aa6282ae01516b2", ]), }); From 6d8363792382d2cfb70ef20cb82a76a9df814d52 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sun, 13 Sep 2026 22:45:06 -0700 Subject: [PATCH 29/30] fix(ci): refresh npm image inputs --- Dockerfile | 2 +- .../managed-image-staging-qa-workflow.test.ts | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2aae251757f..5336b61bf14 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ ARG CODEX_ACP_LINUX_ARM64_0_11_1_INTEGRITY=sha512-I1f6WoSLbLlsWq4zH+vtwdoc4Y41mq 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+deb13u4 && rm -rf /var/lib/apt/lists/* +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 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 ce4ee2c0828..f0b2a5e4079 100644 --- a/test/inference/managed/managed-image-staging-qa-workflow.test.ts +++ b/test/inference/managed/managed-image-staging-qa-workflow.test.ts @@ -118,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", From 478d77165e94133ae163a0b8fff7690d0b9d7193 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sun, 13 Sep 2026 23:09:40 -0700 Subject: [PATCH 30/30] chore(ci): refresh Pi qualification receipts --- ci/pi-agent-qualification-v1-linux-amd64.json | 8 ++++---- ci/pi-agent-qualification-v1-linux-arm64.json | 8 ++++---- src/lib/agent/candidate-authority.ts | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ci/pi-agent-qualification-v1-linux-amd64.json b/ci/pi-agent-qualification-v1-linux-amd64.json index be782e3c0e1..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:476bc35e24120f82ee6583040ac09c0526030a5f1047b2443ae59ad653039927", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:476bc35e24120f82ee6583040ac09c0526030a5f1047b2443ae59ad653039927", + "digest": "sha256:c951b70bc0ed34f320107727a4f90fe66d07c3fb356c7ad660ca8b99b58ee24c", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:c951b70bc0ed34f320107727a4f90fe66d07c3fb356c7ad660ca8b99b58ee24c", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "3fdc1713f7e5aa9d4202b327dd4a35c5c18a83ee", + "revision": "6d8363792382d2cfb70ef20cb82a76a9df814d52", "release": "v0.1.0", - "cohort": "ghrun-34807309731-1" + "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 d06edd1d118..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:eac0ab06cc819d9d31e3efcb2c8c97d75201ba5d77ede83af2739d3d66f56ab8", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:eac0ab06cc819d9d31e3efcb2c8c97d75201ba5d77ede83af2739d3d66f56ab8", + "digest": "sha256:cff13951b1a6fd57d20f8bf148ed6785890c24d957dc5a5ebb0c0d33271e93e3", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:cff13951b1a6fd57d20f8bf148ed6785890c24d957dc5a5ebb0c0d33271e93e3", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "3fdc1713f7e5aa9d4202b327dd4a35c5c18a83ee", + "revision": "6d8363792382d2cfb70ef20cb82a76a9df814d52", "release": "v0.1.0", - "cohort": "ghrun-34807309731-1" + "cohort": "ghrun-34811379655-1" }, "startupProfileContractVersion": 1, "capabilityContractVersion": 1 diff --git a/src/lib/agent/candidate-authority.ts b/src/lib/agent/candidate-authority.ts index e3f5fd348ce..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([ - "639141373dce67c4506eef69fd33fff00db389c4c130115efdfc7f582a86796b", - "84d052a75e6ea5abdaf6183fac250f982e95651e2a42f4965aa6282ae01516b2", + "2c01a70366bf0e25e26d57c27cfa5471df6b447cd9bbfe00b804770870ceee77", + "2ed5286974d22321220a7cdab0aee5ffdb7f3f30423f8660cbccb67a6a20fcb9", ]), });