From bec46e555c09b882e7bca803a75670027e8dffc3 Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Sun, 12 Jul 2026 19:46:33 -0700 Subject: [PATCH 1/3] ci(deps): audit reviewed npm archive graph Signed-off-by: Ho Lim --- .../actions/ci-reviewed-npm-audit/action.yaml | 25 ++ .github/workflows/main.yaml | 15 ++ .github/workflows/pr.yaml | 18 ++ Dockerfile | 121 ++-------- Dockerfile.base | 50 ++-- ci/reviewed-npm-audit.json | 71 ++++++ .../openclaw-2026.6.10-dependency-review.md | 22 +- scripts/audit-reviewed-npm-graph.mts | 198 ++++++++++++++++ scripts/lib/reviewed-npm-archive.mts | 222 ++++++++++++++++++ .../applier/build/messaging-build-applier.mts | 150 +----------- ...ckerfile-remote-dashboard-bind-contract.ts | 2 +- src/lib/sandbox/build-context.ts | 5 + test/fetch-guard-patch-regression.test.ts | 113 +++------ test/helpers/reviewed-npm-fixture.ts | 45 ++++ test/mcporter-supply-chain.test.ts | 32 ++- .../messaging-build-applier-integrity.test.ts | 10 +- test/messaging-build-applier.test.ts | 64 +++-- test/openclaw-dependency-review.test.ts | 88 ++++--- test/openclaw-integrity-pin-suite.ts | 104 +++++++- test/openclaw-optional-plugin-build.test.ts | 78 ++++++ test/pr-workflow-contract.test.ts | 3 + test/reviewed-npm-archive.test.ts | 125 ++++++++++ test/reviewed-npm-audit.test.ts | 30 +++ test/sandbox-build-context.test.ts | 1 + test/sandbox-provisioning.test.ts | 55 ----- 25 files changed, 1146 insertions(+), 501 deletions(-) create mode 100644 .github/actions/ci-reviewed-npm-audit/action.yaml create mode 100644 ci/reviewed-npm-audit.json create mode 100755 scripts/audit-reviewed-npm-graph.mts create mode 100755 scripts/lib/reviewed-npm-archive.mts create mode 100644 test/helpers/reviewed-npm-fixture.ts create mode 100644 test/openclaw-optional-plugin-build.test.ts create mode 100644 test/reviewed-npm-archive.test.ts create mode 100644 test/reviewed-npm-audit.test.ts diff --git a/.github/actions/ci-reviewed-npm-audit/action.yaml b/.github/actions/ci-reviewed-npm-audit/action.yaml new file mode 100644 index 0000000000..c6aec7245e --- /dev/null +++ b/.github/actions/ci-reviewed-npm-audit/action.yaml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: ci-reviewed-npm-audit +description: Materialize and audit the reviewed production npm graphs. + +runs: + using: composite + steps: + - name: Setup production-compatible Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "22.22.2" + + - name: Materialize and audit reviewed npm graphs + shell: bash + run: node --experimental-strip-types scripts/audit-reviewed-npm-graph.mts + + - name: Upload reviewed npm audit reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: reviewed-npm-audit + path: coverage/reviewed-npm-audit/*.json + if-no-files-found: error diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index f832aa17e8..4645e543fd 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -57,6 +57,18 @@ jobs: - name: Run installer integration tests uses: ./.github/actions/ci-installer-integration + reviewed-npm-audit: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Audit reviewed production npm graphs + uses: ./.github/actions/ci-reviewed-npm-audit + real-openclaw-dist-harness: runs-on: ubuntu-latest timeout-minutes: 20 @@ -213,6 +225,7 @@ jobs: - static-checks - build-typecheck - installer-integration + - reviewed-npm-audit - real-openclaw-dist-harness - cli-tests - plugin-tests @@ -227,6 +240,7 @@ jobs: STATIC_RESULT: ${{ needs['static-checks'].result }} BUILD_TYPECHECK_RESULT: ${{ needs['build-typecheck'].result }} INSTALLER_INTEGRATION_RESULT: ${{ needs['installer-integration'].result }} + REVIEWED_NPM_AUDIT_RESULT: ${{ needs['reviewed-npm-audit'].result }} REAL_OPENCLAW_DIST_HARNESS_RESULT: ${{ needs['real-openclaw-dist-harness'].result }} CLI_TESTS_RESULT: ${{ needs['cli-tests'].result }} PLUGIN_TESTS_RESULT: ${{ needs['plugin-tests'].result }} @@ -247,6 +261,7 @@ jobs: require_success "static-checks" "$STATIC_RESULT" require_success "build-typecheck" "$BUILD_TYPECHECK_RESULT" require_success "installer-integration" "$INSTALLER_INTEGRATION_RESULT" + require_success "reviewed-npm-audit" "$REVIEWED_NPM_AUDIT_RESULT" require_success "real-openclaw-dist-harness" "$REAL_OPENCLAW_DIST_HARNESS_RESULT" require_success "cli-tests" "$CLI_TESTS_RESULT" require_success "plugin-tests" "$PLUGIN_TESTS_RESULT" diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index f90bc98f32..b12ada24d6 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -192,6 +192,20 @@ jobs: shell: bash run: CI=true npx vitest run --project installer-integration + reviewed-npm-audit: + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Audit reviewed production npm graphs + uses: ./.github/actions/ci-reviewed-npm-audit + cli-test-shards: needs: changes if: needs.changes.outputs.code == 'true' @@ -366,6 +380,7 @@ jobs: - static-checks - build-typecheck - installer-integration + - reviewed-npm-audit - cli-tests - plugin-tests - e2e-support @@ -382,6 +397,7 @@ jobs: STATIC_RESULT: ${{ needs['static-checks'].result }} BUILD_TYPECHECK_RESULT: ${{ needs['build-typecheck'].result }} INSTALLER_INTEGRATION_RESULT: ${{ needs['installer-integration'].result }} + REVIEWED_NPM_AUDIT_RESULT: ${{ needs['reviewed-npm-audit'].result }} CLI_TESTS_RESULT: ${{ needs['cli-tests'].result }} PLUGIN_TESTS_RESULT: ${{ needs['plugin-tests'].result }} E2E_SUPPORT_RESULT: ${{ needs['e2e-support'].result }} @@ -417,6 +433,7 @@ jobs: require_success "static-checks" "$STATIC_RESULT" require_success "build-typecheck" "$BUILD_TYPECHECK_RESULT" require_success "installer-integration" "$INSTALLER_INTEGRATION_RESULT" + require_success "reviewed-npm-audit" "$REVIEWED_NPM_AUDIT_RESULT" require_success "cli-tests" "$CLI_TESTS_RESULT" require_success "plugin-tests" "$PLUGIN_TESTS_RESULT" require_success "e2e-support" "$E2E_SUPPORT_RESULT" @@ -426,6 +443,7 @@ jobs: allow_success_or_skipped "static-checks" "$STATIC_RESULT" allow_success_or_skipped "build-typecheck" "$BUILD_TYPECHECK_RESULT" allow_success_or_skipped "installer-integration" "$INSTALLER_INTEGRATION_RESULT" + allow_success_or_skipped "reviewed-npm-audit" "$REVIEWED_NPM_AUDIT_RESULT" allow_success_or_skipped "cli-tests" "$CLI_TESTS_RESULT" allow_success_or_skipped "plugin-tests" "$PLUGIN_TESTS_RESULT" allow_success_or_skipped "e2e-support" "$E2E_SUPPORT_RESULT" diff --git a/Dockerfile b/Dockerfile index f0f9c883be..78d5cc56a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -64,8 +64,10 @@ ARG CODEX_ACP_0_11_1_INTEGRITY=sha512-My2VSlBtvJipJhImHjFDej2ut/p00QqOISRnZgLgLr # synchronized with agents/openclaw/dependency-review.md. ARG MCPORTER_VERSION=0.7.3 ARG MCPORTER_0_7_3_INTEGRITY=sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA== +ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz COPY agents/openclaw/mcporter-runtime/package.json /usr/local/lib/nemoclaw/mcporter-runtime/package.json COPY agents/openclaw/mcporter-runtime/package-lock.json /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json +COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts # OpenShell blocks the link-local EC2 Instance Metadata Service. Keep AWS SDK # credential chains from attempting an impossible metadata discovery path. @@ -173,36 +175,10 @@ RUN chmod 755 /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.js \ RUN set -eu; \ CODEX_ACP_SPEC='@zed-industries/codex-acp@0.11.1'; \ CODEX_ACP_TARBALL='https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.11.1.tgz'; \ - pack_reviewed_npm_tarball() { \ - pack_spec="$1"; expected_integrity="$2"; pack_dir="$3"; label="$4"; \ - pack_json="$(npm pack "$pack_spec" --pack-destination "$pack_dir" --json)"; \ - pack_integrity="$(printf '%s' "$pack_json" | node -e 'const p = JSON.parse(require("node:fs").readFileSync(0, "utf8")); process.stdout.write(String(p[0]?.integrity ?? ""));')"; \ - pack_filename="$(printf '%s' "$pack_json" | node -e 'const p = JSON.parse(require("node:fs").readFileSync(0, "utf8")); process.stdout.write(String(p[0]?.filename ?? ""));')"; \ - if [ -z "$pack_integrity" ] || [ -z "$pack_filename" ]; then \ - echo "ERROR: ${label} npm pack did not report filename and integrity" >&2; exit 1; \ - fi; \ - if [ "$pack_integrity" != "$expected_integrity" ]; then \ - echo "ERROR: ${label} downloaded tarball integrity mismatch" >&2; \ - echo "Expected: ${expected_integrity}" >&2; \ - echo "Actual: ${pack_integrity}" >&2; exit 1; \ - fi; \ - if ! pack_archive="$(node -e 'const path = require("node:path"); const [dir, filename, label] = process.argv.slice(1); const parts = filename.split(/[\\/]+/); const unsafe = !filename || path.isAbsolute(filename) || filename === "." || filename === ".." || filename.includes("/") || filename.includes("\\") || parts.includes("..") || parts.includes(""); if (unsafe) { console.error("ERROR: " + label + " npm pack reported unsafe archive filename: " + filename); process.exit(1); } const root = path.resolve(dir); const archive = path.resolve(root, filename); if (!archive.startsWith(root + path.sep)) { console.error("ERROR: " + label + " npm pack archive escaped pack directory: " + filename); process.exit(1); } process.stdout.write(archive);' "$pack_dir" "$pack_filename" "$label")"; then exit 1; fi; \ - printf '%s\n' "$pack_archive"; \ - }; \ - REGISTRY_CODEX_ACP_INTEGRITY=$(npm view "${CODEX_ACP_SPEC}" dist.integrity); \ - REGISTRY_CODEX_ACP_TARBALL=$(npm view "${CODEX_ACP_SPEC}" dist.tarball); \ - if [ "$REGISTRY_CODEX_ACP_INTEGRITY" != "$CODEX_ACP_0_11_1_INTEGRITY" ]; then \ - echo "ERROR: ${CODEX_ACP_SPEC} npm integrity mismatch" >&2; \ - echo "Expected: ${CODEX_ACP_0_11_1_INTEGRITY}" >&2; \ - echo "Actual: ${REGISTRY_CODEX_ACP_INTEGRITY}" >&2; exit 1; \ - fi; \ - if [ "$REGISTRY_CODEX_ACP_TARBALL" != "$CODEX_ACP_TARBALL" ]; then \ - echo "ERROR: ${CODEX_ACP_SPEC} npm tarball URL mismatch" >&2; \ - echo "Expected: ${CODEX_ACP_TARBALL}" >&2; \ - echo "Actual: ${REGISTRY_CODEX_ACP_TARBALL}" >&2; exit 1; \ - fi; \ - CODEX_ACP_PACK_DIR="$(mktemp -d)"; \ - CODEX_ACP_PACK_PATH="$(pack_reviewed_npm_tarball "$CODEX_ACP_TARBALL" "$CODEX_ACP_0_11_1_INTEGRITY" "$CODEX_ACP_PACK_DIR" "$CODEX_ACP_SPEC")"; \ + CODEX_ACP_PACK_PATH="$(node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts \ + --package-spec "$CODEX_ACP_SPEC" --integrity "$CODEX_ACP_0_11_1_INTEGRITY" \ + --tarball-url "$CODEX_ACP_TARBALL" --label "$CODEX_ACP_SPEC")"; \ + CODEX_ACP_PACK_DIR="$(dirname "$CODEX_ACP_PACK_PATH")"; \ npm install -g --no-audit --no-fund --no-progress --ignore-scripts \ "$CODEX_ACP_PACK_PATH"; \ rm -rf "$CODEX_ACP_PACK_DIR"; \ @@ -246,29 +222,14 @@ RUN set -eu; \ echo "ERROR: OpenClaw ${OPENCLAW_VERSION} has no committed npm integrity pin" >&2; exit 1; \ fi; \ MCPORTER_EXPECTED_INTEGRITY=""; \ - if [ "$MCPORTER_VERSION" = "0.7.3" ]; then MCPORTER_EXPECTED_INTEGRITY="$MCPORTER_0_7_3_INTEGRITY"; fi; \ + MCPORTER_EXPECTED_TARBALL=""; \ + if [ "$MCPORTER_VERSION" = "0.7.3" ]; then MCPORTER_EXPECTED_INTEGRITY="$MCPORTER_0_7_3_INTEGRITY"; MCPORTER_EXPECTED_TARBALL="$MCPORTER_0_7_3_TARBALL"; fi; \ if [ -z "$MCPORTER_EXPECTED_INTEGRITY" ]; then \ echo "ERROR: mcporter ${MCPORTER_VERSION} has no committed npm integrity pin" >&2; exit 1; \ fi; \ MCPORTER_LOCK_SHA256="$(sha256sum /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json | awk '{print $1}')"; \ [ -n "$MCPORTER_LOCK_SHA256" ] \ || { echo "ERROR: Could not hash the committed mcporter lockfile" >&2; exit 1; }; \ - pack_reviewed_npm_tarball() { \ - pack_spec="$1"; expected_integrity="$2"; pack_dir="$3"; label="$4"; \ - pack_json="$(npm pack "$pack_spec" --pack-destination "$pack_dir" --json)"; \ - pack_integrity="$(printf '%s' "$pack_json" | node -e 'const p = JSON.parse(require("node:fs").readFileSync(0, "utf8")); process.stdout.write(String(p[0]?.integrity ?? ""));')"; \ - pack_filename="$(printf '%s' "$pack_json" | node -e 'const p = JSON.parse(require("node:fs").readFileSync(0, "utf8")); process.stdout.write(String(p[0]?.filename ?? ""));')"; \ - if [ -z "$pack_integrity" ] || [ -z "$pack_filename" ]; then \ - echo "ERROR: ${label} npm pack did not report filename and integrity" >&2; exit 1; \ - fi; \ - if [ "$pack_integrity" != "$expected_integrity" ]; then \ - echo "ERROR: ${label} downloaded tarball integrity mismatch" >&2; \ - echo "Expected: ${expected_integrity}" >&2; \ - echo "Actual: ${pack_integrity}" >&2; exit 1; \ - fi; \ - if ! pack_archive="$(node -e 'const path = require("node:path"); const [dir, filename, label] = process.argv.slice(1); const parts = filename.split(/[\\/]+/); const unsafe = !filename || path.isAbsolute(filename) || filename === "." || filename === ".." || filename.includes("/") || filename.includes("\\") || parts.includes("..") || parts.includes(""); if (unsafe) { console.error("ERROR: " + label + " npm pack reported unsafe archive filename: " + filename); process.exit(1); } const root = path.resolve(dir); const archive = path.resolve(root, filename); if (!archive.startsWith(root + path.sep)) { console.error("ERROR: " + label + " npm pack archive escaped pack directory: " + filename); process.exit(1); } process.stdout.write(archive);' "$pack_dir" "$pack_filename" "$label")"; then exit 1; fi; \ - printf '%s\n' "$pack_archive"; \ - }; \ CUR_VER=$(openclaw --version 2>/dev/null | awk '{print $2}' || true); \ CUR_VER="${CUR_VER:-0.0.0}"; \ CUR_MCPORTER_VER=$(mcporter --version 2>/dev/null || true); \ @@ -283,6 +244,7 @@ RUN set -eu; \ 'recipe=ignore-scripts+reviewed-lifecycle-v1' \ "mcporter-package=mcporter@${MCPORTER_VERSION}" \ "mcporter-integrity=${MCPORTER_EXPECTED_INTEGRITY}" \ + "mcporter-tarball=${MCPORTER_EXPECTED_TARBALL}" \ "mcporter-lock-sha256=${MCPORTER_LOCK_SHA256}" \ 'mcporter-recipe=locked-ci+audit-signatures-v1' \ > "$OPENCLAW_EXPECTED_PROVENANCE"; \ @@ -309,20 +271,10 @@ RUN set -eu; \ 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 exact reviewed provenance; installing $OPENCLAW_VERSION"; \ - REGISTRY_INTEGRITY=$(npm view "openclaw@${OPENCLAW_VERSION}" dist.integrity); \ - if [ "$REGISTRY_INTEGRITY" != "$EXPECTED_INTEGRITY" ]; then \ - echo "ERROR: OpenClaw ${OPENCLAW_VERSION} npm integrity mismatch" >&2; \ - echo "Expected: ${EXPECTED_INTEGRITY}" >&2; \ - echo "Actual: ${REGISTRY_INTEGRITY}" >&2; exit 1; \ - fi; \ - REGISTRY_TARBALL=$(npm view "openclaw@${OPENCLAW_VERSION}" dist.tarball); \ - if [ "$REGISTRY_TARBALL" != "$EXPECTED_TARBALL" ]; then \ - echo "ERROR: OpenClaw ${OPENCLAW_VERSION} npm tarball URL mismatch" >&2; \ - echo "Expected: ${EXPECTED_TARBALL}" >&2; \ - echo "Actual: ${REGISTRY_TARBALL}" >&2; exit 1; \ - fi; \ - OPENCLAW_PACK_DIR="$(mktemp -d)"; \ - OPENCLAW_PACK_PATH="$(pack_reviewed_npm_tarball "$EXPECTED_TARBALL" "$EXPECTED_INTEGRITY" "$OPENCLAW_PACK_DIR" "OpenClaw ${OPENCLAW_VERSION}")"; \ + OPENCLAW_PACK_PATH="$(node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts \ + --package-spec "openclaw@${OPENCLAW_VERSION}" --integrity "$EXPECTED_INTEGRITY" \ + --tarball-url "$EXPECTED_TARBALL" --label "OpenClaw ${OPENCLAW_VERSION}")"; \ + OPENCLAW_PACK_DIR="$(dirname "$OPENCLAW_PACK_PATH")"; \ # npm 10'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. @@ -338,12 +290,9 @@ RUN set -eu; \ if [ "$USE_REVIEWED_BASE_RUNTIME" = "1" ]; then \ echo "INFO: Reusing reviewed base mcporter $CUR_MCPORTER_VER with exact lock provenance"; \ else \ - MCPORTER_REGISTRY_INTEGRITY=$(npm view "mcporter@${MCPORTER_VERSION}" dist.integrity); \ - if [ "$MCPORTER_REGISTRY_INTEGRITY" != "$MCPORTER_EXPECTED_INTEGRITY" ]; then \ - echo "ERROR: mcporter ${MCPORTER_VERSION} npm integrity mismatch" >&2; \ - echo "Expected: ${MCPORTER_EXPECTED_INTEGRITY}" >&2; \ - echo "Actual: ${MCPORTER_REGISTRY_INTEGRITY}" >&2; exit 1; \ - fi; \ + node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts --verify-only \ + --package-spec "mcporter@${MCPORTER_VERSION}" --integrity "$MCPORTER_EXPECTED_INTEGRITY" \ + --tarball-url "$MCPORTER_EXPECTED_TARBALL" --label "mcporter ${MCPORTER_VERSION}"; \ # Reinstall from the committed lock when exact protected base provenance # is unavailable; matching top-level versions can hide transitive drift. echo "INFO: Installing locked mcporter $MCPORTER_VERSION dependency graph"; \ @@ -1050,45 +999,17 @@ RUN set -eu; \ if [ -z "$expected_integrity" ]; then \ echo "ERROR: OpenClaw plugin ${plugin_spec} has no committed npm integrity pin" >&2; exit 1; \ fi; \ - registry_integrity="$(npm view "$plugin_spec" dist.integrity)"; \ - if [ -z "$registry_integrity" ]; then \ - echo "ERROR: OpenClaw plugin ${plugin_spec} registry integrity missing" >&2; exit 1; \ - fi; \ - if [ "$registry_integrity" != "$expected_integrity" ]; then \ - echo "ERROR: OpenClaw plugin ${plugin_spec} npm integrity mismatch" >&2; \ - echo "Expected: $expected_integrity" >&2; \ - echo "Actual: $registry_integrity" >&2; \ - exit 1; \ - fi; \ - registry_tarball="$(npm view "$plugin_spec" dist.tarball)"; \ - if [ "$registry_tarball" != "$expected_tarball" ]; then \ - echo "ERROR: OpenClaw plugin ${plugin_spec} npm tarball URL mismatch" >&2; \ - echo "Expected: $expected_tarball" >&2; \ - echo "Actual: $registry_tarball" >&2; \ - exit 1; \ - fi; \ - plugin_pack_json="$(npm pack "$expected_tarball" --pack-destination "$NEMOCLAW_OPENCLAW_PLUGIN_PACK_DIR" --json)"; \ - plugin_pack_integrity="$(printf '%s' "$plugin_pack_json" | node -e 'const p = JSON.parse(require("node:fs").readFileSync(0, "utf8")); process.stdout.write(String(p[0]?.integrity ?? ""));')"; \ - plugin_pack_filename="$(printf '%s' "$plugin_pack_json" | node -e 'const p = JSON.parse(require("node:fs").readFileSync(0, "utf8")); process.stdout.write(String(p[0]?.filename ?? ""));')"; \ - if [ "$plugin_pack_integrity" != "$expected_integrity" ]; then \ - echo "ERROR: OpenClaw plugin ${plugin_spec} downloaded tarball integrity mismatch" >&2; \ - echo "Expected: $expected_integrity" >&2; \ - echo "Actual: $plugin_pack_integrity" >&2; \ - exit 1; \ - fi; \ - if [ -z "$plugin_pack_filename" ]; then \ - echo "ERROR: OpenClaw plugin ${plugin_spec} npm pack did not report a filename" >&2; exit 1; \ - fi; \ - if ! plugin_pack_archive="$(node -e 'const path = require("node:path"); const [dir, filename, label] = process.argv.slice(1); const parts = filename.split(/[\\/]+/); const unsafe = !filename || path.isAbsolute(filename) || filename === "." || filename === ".." || filename.includes("/") || filename.includes("\\") || parts.includes("..") || parts.includes(""); if (unsafe) { console.error("ERROR: " + label + " npm pack reported unsafe archive filename: " + filename); process.exit(1); } const root = path.resolve(dir); const archive = path.resolve(root, filename); if (!archive.startsWith(root + path.sep)) { console.error("ERROR: " + label + " npm pack archive escaped pack directory: " + filename); process.exit(1); } process.stdout.write(archive);' "$NEMOCLAW_OPENCLAW_PLUGIN_PACK_DIR" "$plugin_pack_filename" "OpenClaw plugin ${plugin_spec}")"; then exit 1; fi; \ - printf '%s\n' "$plugin_pack_archive"; \ + node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts \ + --package-spec "$plugin_spec" --integrity "$expected_integrity" \ + --tarball-url "$expected_tarball" --label "OpenClaw plugin ${plugin_spec}"; \ }; \ install_reviewed_openclaw_plugin() { \ plugin_spec="${1}@${OPENCLAW_VERSION}"; \ plugin_archive="$(verify_openclaw_plugin_integrity "$plugin_spec")"; \ NPM_CONFIG_IGNORE_SCRIPTS=true npm_config_ignore_scripts=true \ openclaw plugins install "npm-pack:${plugin_archive}"; \ + rm -rf "$(dirname "$plugin_archive")"; \ }; \ - NEMOCLAW_OPENCLAW_PLUGIN_PACK_DIR="$(mktemp -d)"; \ if [ "$NEMOCLAW_OPENCLAW_OTEL" = "1" ] || [ "$NEMOCLAW_WEB_SEARCH_ENABLED" = "1" ]; then \ test -n "$OPENCLAW_VERSION"; \ fi; \ @@ -1113,7 +1034,7 @@ RUN set -eu; \ elif [ "$NEMOCLAW_OPENCLAW_OTEL" = "1" ]; then \ openclaw doctor --fix --non-interactive; \ fi; \ - rm -rf "$NEMOCLAW_OPENCLAW_PLUGIN_PACK_DIR" + : # hadolint ignore=DL3059,DL4006 RUN OPENCLAW_VERSION="${OPENCLAW_VERSION}" node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent openclaw --phase agent-install diff --git a/Dockerfile.base b/Dockerfile.base index a2eb9948d0..224879013d 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -220,8 +220,10 @@ ARG OPENCLAW_2026_4_24_TARBALL=https://registry.npmjs.org/openclaw/-/openclaw-20 # synchronized with agents/openclaw/dependency-review.md. ARG MCPORTER_VERSION=0.7.3 ARG MCPORTER_0_7_3_INTEGRITY=sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA== +ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz COPY agents/openclaw/mcporter-runtime/package.json /usr/local/lib/nemoclaw/mcporter-runtime/package.json COPY agents/openclaw/mcporter-runtime/package-lock.json /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json +COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts # Keep OpenClaw's jiti-generated source cache out of /tmp so provider marker # names do not persist in runtime snapshots or leak-scan inputs. @@ -262,36 +264,13 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep if [ -z "$EXPECTED_INTEGRITY" ]; then \ echo "Error: OpenClaw ${OPENCLAW_VERSION} has no committed npm integrity pin"; exit 1; \ fi; \ - REGISTRY_INTEGRITY=$(npm view "openclaw@${OPENCLAW_VERSION}" dist.integrity); \ - if [ "$REGISTRY_INTEGRITY" != "$EXPECTED_INTEGRITY" ]; then \ - echo "Error: OpenClaw ${OPENCLAW_VERSION} npm integrity mismatch"; \ - echo "Expected: ${EXPECTED_INTEGRITY}"; \ - echo "Actual: ${REGISTRY_INTEGRITY}"; exit 1; \ + OPENCLAW_PACK_PATH="$(node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts \ + --package-spec "openclaw@${OPENCLAW_VERSION}" --integrity "$EXPECTED_INTEGRITY" \ + --tarball-url "$EXPECTED_TARBALL" --label "OpenClaw ${OPENCLAW_VERSION}")"; \ + if [ -z "$OPENCLAW_PACK_PATH" ] || [ ! -f "$OPENCLAW_PACK_PATH" ] || [ -L "$OPENCLAW_PACK_PATH" ]; then \ + echo "Error: reviewed OpenClaw archive path is empty or invalid"; exit 1; \ fi; \ - REGISTRY_TARBALL=$(npm view "openclaw@${OPENCLAW_VERSION}" dist.tarball); \ - if [ "$REGISTRY_TARBALL" != "$EXPECTED_TARBALL" ]; then \ - echo "Error: OpenClaw ${OPENCLAW_VERSION} npm tarball URL mismatch"; \ - echo "Expected: ${EXPECTED_TARBALL}"; \ - echo "Actual: ${REGISTRY_TARBALL}"; exit 1; \ - fi; \ - pack_reviewed_npm_tarball() { \ - pack_spec="$1"; expected_integrity="$2"; pack_dir="$3"; label="$4"; \ - pack_json="$(npm pack "$pack_spec" --pack-destination "$pack_dir" --json)"; \ - pack_integrity="$(printf '%s' "$pack_json" | node -e 'const p = JSON.parse(require("node:fs").readFileSync(0, "utf8")); process.stdout.write(String(p[0]?.integrity ?? ""));')"; \ - pack_filename="$(printf '%s' "$pack_json" | node -e 'const p = JSON.parse(require("node:fs").readFileSync(0, "utf8")); process.stdout.write(String(p[0]?.filename ?? ""));')"; \ - if [ -z "$pack_integrity" ] || [ -z "$pack_filename" ]; then \ - echo "Error: ${label} npm pack did not report filename and integrity" >&2; exit 1; \ - fi; \ - if [ "$pack_integrity" != "$expected_integrity" ]; then \ - echo "Error: ${label} downloaded tarball integrity mismatch" >&2; \ - echo "Expected: ${expected_integrity}" >&2; \ - echo "Actual: ${pack_integrity}" >&2; exit 1; \ - fi; \ - if ! pack_archive="$(node -e 'const path = require("node:path"); const [dir, filename, label] = process.argv.slice(1); const parts = filename.split(/[\\/]+/); const unsafe = !filename || path.isAbsolute(filename) || filename === "." || filename === ".." || filename.includes("/") || filename.includes("\\") || parts.includes("..") || parts.includes(""); if (unsafe) { console.error("Error: " + label + " npm pack reported unsafe archive filename: " + filename); process.exit(1); } const root = path.resolve(dir); const archive = path.resolve(root, filename); if (!archive.startsWith(root + path.sep)) { console.error("Error: " + label + " npm pack archive escaped pack directory: " + filename); process.exit(1); } process.stdout.write(archive);' "$pack_dir" "$pack_filename" "$label")"; then exit 1; fi; \ - printf '%s\n' "$pack_archive"; \ - }; \ - OPENCLAW_PACK_DIR="$(mktemp -d)"; \ - OPENCLAW_PACK_PATH="$(pack_reviewed_npm_tarball "$EXPECTED_TARBALL" "$EXPECTED_INTEGRITY" "$OPENCLAW_PACK_DIR" "OpenClaw ${OPENCLAW_VERSION}")"; \ + OPENCLAW_PACK_DIR="$(dirname "$OPENCLAW_PACK_PATH")"; \ npm install -g --ignore-scripts "$OPENCLAW_PACK_PATH" \ && case "$OPENCLAW_VERSION" in \ 2026.4.24|2026.6.10) node /usr/local/lib/node_modules/openclaw/scripts/postinstall-bundled-plugins.mjs ;; \ @@ -304,16 +283,14 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep echo "Error: Installed OpenClaw ${OPENCLAW_INSTALLED_VERSION:-unknown} does not match reviewed target ${OPENCLAW_VERSION}"; exit 1; \ fi \ && MCPORTER_EXPECTED_INTEGRITY="" \ - && if [ "$MCPORTER_VERSION" = "0.7.3" ]; then MCPORTER_EXPECTED_INTEGRITY="$MCPORTER_0_7_3_INTEGRITY"; fi \ + && MCPORTER_EXPECTED_TARBALL="" \ + && if [ "$MCPORTER_VERSION" = "0.7.3" ]; then MCPORTER_EXPECTED_INTEGRITY="$MCPORTER_0_7_3_INTEGRITY"; MCPORTER_EXPECTED_TARBALL="$MCPORTER_0_7_3_TARBALL"; fi \ && if [ -z "$MCPORTER_EXPECTED_INTEGRITY" ]; then \ echo "ERROR: mcporter ${MCPORTER_VERSION} has no committed npm integrity pin" >&2; exit 1; \ fi \ - && MCPORTER_REGISTRY_INTEGRITY=$(npm view "mcporter@${MCPORTER_VERSION}" dist.integrity) \ - && if [ "$MCPORTER_REGISTRY_INTEGRITY" != "$MCPORTER_EXPECTED_INTEGRITY" ]; then \ - echo "Error: mcporter ${MCPORTER_VERSION} npm integrity mismatch"; \ - echo "Expected: ${MCPORTER_EXPECTED_INTEGRITY}"; \ - echo "Actual: ${MCPORTER_REGISTRY_INTEGRITY}"; exit 1; \ - fi \ + && node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts --verify-only \ + --package-spec "mcporter@${MCPORTER_VERSION}" --integrity "$MCPORTER_EXPECTED_INTEGRITY" \ + --tarball-url "$MCPORTER_EXPECTED_TARBALL" --label "mcporter ${MCPORTER_VERSION}" \ && rm -rf /usr/local/lib/node_modules/mcporter /usr/local/bin/mcporter \ && npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime ci \ --ignore-scripts --omit=dev --no-audit --no-fund --no-progress \ @@ -335,6 +312,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep 'recipe=ignore-scripts+reviewed-lifecycle-v1' \ "mcporter-package=mcporter@${MCPORTER_VERSION}" \ "mcporter-integrity=${MCPORTER_EXPECTED_INTEGRITY}" \ + "mcporter-tarball=${MCPORTER_EXPECTED_TARBALL}" \ "mcporter-lock-sha256=${MCPORTER_LOCK_SHA256}" \ 'mcporter-recipe=locked-ci+audit-signatures-v1' \ > "$OPENCLAW_PROVENANCE_TMP" \ diff --git a/ci/reviewed-npm-audit.json b/ci/reviewed-npm-audit.json new file mode 100644 index 0000000000..3737c93c26 --- /dev/null +++ b/ci/reviewed-npm-audit.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": 1, + "nodeVersion": "22.22.2", + "severityThreshold": "high", + "artifactDirectory": "coverage/reviewed-npm-audit", + "archivePackages": [ + { + "label": "OpenClaw 2026.6.10", + "packageSpec": "openclaw@2026.6.10", + "integrity": "sha512-LcooND2tBQw8A+kc1Ujltu3lg30bJ0w7XaeRy7eYzobb8BBdcW6DOGbwJL4vpj1vl9+gjRceOtlh5nh9OARcug==", + "tarballUrl": "https://registry.npmjs.org/openclaw/-/openclaw-2026.6.10.tgz" + }, + { + "label": "Codex ACP 0.11.1", + "packageSpec": "@zed-industries/codex-acp@0.11.1", + "integrity": "sha512-My2VSlBtvJipJhImHjFDej2ut/p00QqOISRnZgLgLrSIzjgvdcQvAhaZviWj7XPhk4UIdIb0OoA+Lrls824uiQ==", + "tarballUrl": "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.11.1.tgz" + }, + { + "label": "OpenClaw diagnostics OTEL 2026.6.10", + "packageSpec": "@openclaw/diagnostics-otel@2026.6.10", + "integrity": "sha512-EJt0fjk4bcR3N/9u00f1pL0BJYG5yfC09DV3l6rWDmytpE2vUeBZWpx4pOmFDreGV+7DKxhCbQDgDAmvZGjLag==", + "tarballUrl": "https://registry.npmjs.org/@openclaw/diagnostics-otel/-/diagnostics-otel-2026.6.10.tgz" + }, + { + "label": "OpenClaw Brave plugin 2026.6.10", + "packageSpec": "@openclaw/brave-plugin@2026.6.10", + "integrity": "sha512-DDRnb4reL99O8kbISNbRFyk/xoUPYHsXG3UGikKAsVs+zIldYYA0hY0d3Z2aWoE+0vfda27mJUByCo7Xr15qdw==", + "tarballUrl": "https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.6.10.tgz" + }, + { + "label": "OpenClaw Discord plugin 2026.6.10", + "packageSpec": "@openclaw/discord@2026.6.10", + "integrity": "sha512-NKp/j00l+rk5PC0Lv/0fOIiiQJ1c/OpG9471zqXUDKQie6pQ1Fi9KUZUouyoTMmfLh/n4S0CkEMqrON40eBKXA==", + "tarballUrl": "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.6.10.tgz" + }, + { + "label": "OpenClaw Slack plugin 2026.6.10", + "packageSpec": "@openclaw/slack@2026.6.10", + "integrity": "sha512-OOsMLjPcbWhQRM5XDwfdrACjJmKqavFtpuIlhHAXWrLrd/p7SyIVE9AoKS0yxOx6bqGDIMJ9+knzdViHMLgBdA==", + "tarballUrl": "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.6.10.tgz" + }, + { + "label": "OpenClaw WhatsApp plugin 2026.6.10", + "packageSpec": "@openclaw/whatsapp@2026.6.10", + "integrity": "sha512-k/XrRdZY77SHrdaRwJOEB7/JRbjp4yVgGD/ZNyakjTMqo32XRVtwPBUnj7726rW8Kl5yyOMQQLKFiD9MDfhmPQ==", + "tarballUrl": "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.6.10.tgz" + }, + { + "label": "OpenClaw Microsoft Teams plugin 2026.6.10", + "packageSpec": "@openclaw/msteams@2026.6.10", + "integrity": "sha512-GjHnCPvjbnI0C7mEFcdT2uKDH4/WwOe2dZBfQiWxBtkE76m6TNG0J9dJjD4mc8/pk8rXSO0cWw+KV9jzWtF9VA==", + "tarballUrl": "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.6.10.tgz" + }, + { + "label": "Tencent WeChat plugin 2.4.3", + "packageSpec": "@tencent-weixin/openclaw-weixin@2.4.3", + "integrity": "sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw==", + "tarballUrl": "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz" + } + ], + "lockedGraphs": [ + { + "label": "mcporter 0.7.3 locked runtime graph", + "packageSpec": "mcporter@0.7.3", + "integrity": "sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA==", + "tarballUrl": "https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz", + "directory": "agents/openclaw/mcporter-runtime" + } + ] +} diff --git a/docs/security/openclaw-2026.6.10-dependency-review.md b/docs/security/openclaw-2026.6.10-dependency-review.md index df0283ea7d..39b788f7d8 100644 --- a/docs/security/openclaw-2026.6.10-dependency-review.md +++ b/docs/security/openclaw-2026.6.10-dependency-review.md @@ -36,7 +36,7 @@ Issue #5591 is the dependency-update umbrella, and its proposed design has three - WeChat channel plugin package: `@tencent-weixin/openclaw-weixin@2.4.3` - WeChat channel plugin npm integrity: `sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw==` -NemoClaw enforces the main `openclaw@2026.6.10`, `@zed-industries/codex-acp@0.11.1`, and each reviewed npm plugin registry integrity and reviewed registry tarball URL, including optional OTEL/brave plugins and messaging plugins, before install. `Dockerfile.base`, the production Dockerfile's stale/custom-base fallback, and the messaging build applier then run `npm pack --json`, require the downloaded tarball integrity to match the committed SRI, reject reported archive filenames that are absolute, contain path separators, equal `.` or `..`, include `..` path segments, or resolve outside the fresh pack directory, and install from the verified local `.tgz` archive. A production image may skip duplicate OpenClaw and locked-mcporter installation only when an official NemoClaw base carries the protected exact provenance marker described below. +NemoClaw enforces the main `openclaw@2026.6.10`, `@zed-industries/codex-acp@0.11.1`, and each reviewed npm plugin registry integrity and reviewed registry tarball URL, including optional OTEL/brave plugins and messaging plugins, before install. `scripts/lib/reviewed-npm-archive.mts` is the shared implementation used by the Docker build boundaries and the messaging build applier. It queries registry metadata by exact package spec, packs only the reviewed tarball URL with `npm pack --json`, requires the downloaded tarball integrity to match the committed SRI, rejects reported archive filenames that are absolute, contain path separators, equal `.` or `..`, resolve outside the fresh pack directory, are missing, or are not regular files, and returns only the verified local `.tgz` path. A production image may skip duplicate OpenClaw and locked-mcporter installation only when an official NemoClaw base carries the protected exact provenance marker described below. ## Upstream Release Boundary @@ -64,7 +64,7 @@ npm audit --omit=dev --json Revalidated on 2026-07-03: npm audit exited `0` and reported `0` info, `0` low, `0` moderate, `0` high, and `0` critical vulnerabilities across `763` total dependencies. The audit host used Node `22.16.0` and emitted npm `EBADENGINE` warnings for packages that require newer Node `22.x` builds. Production NemoClaw images use the digest-pinned `node:22-trixie-slim` image, which currently runs Node `v22.22.2` and satisfies the `openclaw@2026.6.10` engine requirement of `>=22.19.0`. The audit remains advisory vulnerability evidence for the locked dependency graph; the audit-host warning does not describe the production runtime. -This review is an advisory snapshot for the direct OpenClaw runtime package, Codex ACP runtime helper, optional plugins, messaging plugins, and their npm dependency graphs at review time. It complements, but does not replace, the committed npm integrity pins, Dockerfile install-time registry integrity checks, and plugin install-time registry integrity checks. +This review is an advisory snapshot for the direct OpenClaw runtime package, Codex ACP runtime helper, optional plugins, messaging plugins, and their npm dependency graphs at review time. Default PR and main CI now rematerialize those exact direct packages from SRI-verified reviewed local archives under Node `22.22.2`, install with lifecycle scripts disabled, run `npm audit --omit=dev --json`, and upload the raw reports from `coverage/reviewed-npm-audit`. The configured threshold in `ci/reviewed-npm-audit.json` is `high`. The same job independently installs and audits the committed mcporter production lock. This gate complements, but does not replace, the committed npm integrity pins and install-time archive checks. ## Transitive Dependency Graph Rationale @@ -100,9 +100,9 @@ The SRI-verified `openclaw@2026.6.10` artifact's `package/skills/weather/SKILL.m ### Installer Integrity Transaction Boundary -`Dockerfile`, `Dockerfile.base`, optional OpenClaw plugin installs, and `src/lib/messaging/applier/build/messaging-build-applier.mts` bind reviewed npm installs to verified local archives. The install blocks first verify `npm view ... dist.integrity` against the committed SRI and `npm view ... dist.tarball` against the reviewed tarball URL. The actual install input is then produced by `npm pack --json`; the reported downloaded tarball integrity must match the committed SRI and the reported filename must be contained inside the freshly created pack directory before `npm install -g ` or `openclaw plugins install --pin` runs. +`Dockerfile`, `Dockerfile.base`, optional OpenClaw plugin installs, and `src/lib/messaging/applier/build/messaging-build-applier.mts` bind reviewed npm installs to verified local archives through `scripts/lib/reviewed-npm-archive.mts`. The thin callers provide the exact package spec, committed SRI, reviewed tarball URL, and caller label. The helper verifies both `npm view` fields, packs the reviewed URL, validates the reported SRI and contained regular-file basename in a fresh directory, and returns the local archive before `npm install -g ` or `openclaw plugins install npm-pack:` runs. Runtime mcporter uses the helper's metadata-only path before retaining its committed-lock `npm ci` transaction. -After `Dockerfile.base` completes the OpenClaw archive transaction and reviewed lifecycle, installs mcporter from the committed lock, checks both installed versions, and passes mcporter advisory and signature audits, it atomically publishes a root-owned, read-only provenance marker. The marker binds the OpenClaw package, SRI, tarball, and lifecycle recipe plus the mcporter package, SRI, lockfile SHA-256, and audited-install recipe. The production Dockerfile may reuse both installs only for an official NemoClaw base reference (or the resolver's local base name) when the marker is a non-symlink regular file with exact `root:root` ownership, mode `0444`, byte-for-byte content, and both installed versions match. It removes the marker before applying NemoClaw patches so a derived image cannot claim pristine-base provenance. Missing, malformed, writable, symlinked, mismatched, custom-base, stale, or incomplete provenance takes the complete reviewed install fallback; a base newer than the reviewed OpenClaw target remains a hard failure. +After `Dockerfile.base` completes the OpenClaw archive transaction and reviewed lifecycle, installs mcporter from the committed lock, checks both installed versions, and passes mcporter advisory and signature audits, it atomically publishes a root-owned, read-only provenance marker. The marker binds the OpenClaw package, SRI, tarball, and lifecycle recipe plus the mcporter package, SRI, tarball URL, lockfile SHA-256, and audited-install recipe. The production Dockerfile may reuse both installs only for an official NemoClaw base reference (or the resolver's local base name) when the marker is a non-symlink regular file with exact `root:root` ownership, mode `0444`, byte-for-byte content, and both installed versions match. It removes the marker before applying NemoClaw patches so a derived image cannot claim pristine-base provenance. Missing, malformed, writable, symlinked, mismatched, custom-base, stale, or incomplete provenance takes the complete reviewed install fallback; a base newer than the reviewed OpenClaw target remains a hard failure. Invalid state: `npm view` returns the reviewed SRI but the downloaded artifact used for install has different bytes; `npm pack --json` reports a filename such as `../package.tgz`, `/tmp/package.tgz`, or a name containing path separators so the later install consumes a path outside the fresh pack directory; or the production image reuses OpenClaw or mcporter without every provenance, metadata, trusted-base, lock-hash, and installed-version check above. Source boundary: Dockerfile npm install and provenance blocks, `Dockerfile.base`, the committed mcporter lock, optional plugin install blocks, and `src/lib/messaging/applier/build/messaging-build-applier.mts`. Source-fix constraint: npm package installation must stay artifact-bound for reviewed pins rather than reverting to a later floating package-spec transaction, and local archive path validation must be enforced at NemoClaw's install boundary because npm's JSON filename is untrusted input. Regression tests: the integrity-pin plugin-install suite exercises registry drift, reviewed tarball URL drift, downloaded archive verification, and reviewed local-archive installation; the integrity-pin base suite exercises unsafe reported archive filenames, exact OpenClaw/mcporter provenance reuse, fifteen fallback states, marker consumption, and newer-base rejection. `test/messaging-build-applier.test.ts` verifies messaging plugins run through `npm pack --json` and install the verified archive path; `test/messaging-build-applier-integrity.test.ts` verifies the messaging plugin install fails closed when packed archive integrity drifts or the reported archive filename escapes the pack directory. Removal condition: keep this archive verification and delegated-base provenance until the repo moves the OpenClaw/plugin dependency set to a lockfile path where npm enforces the committed SRI directly and no installer code consumes raw `npm pack --json` filenames. @@ -138,16 +138,16 @@ It requires an exact npm package spec from a trusted built-in channel manifest, Its `registryTarballUrl` policy is `must-match-committed-url`; the trusted manifests carry exact tarball URLs for every messaging plugin installed by the reviewed OpenClaw 2026.6.10 image, including the unchanged Tencent WeChat plugin. Invalid state: a serialized plan selects the package identity, a trusted manifest uses a non-exact npm spec or lacks its SRI or exact tarball URL, registry `dist.integrity` or `dist.tarball` differs from the committed evidence, `npm pack` reports different bytes, or the reported archive path escapes its fresh pack directory. -Source boundary: the trusted built-in channel manifests, `OPENCLAW_MESSAGING_PLUGIN_ARCHIVE_PROVENANCE_POLICY`, `reviewedOpenClawPluginIntegrityByPackageSpec`, `reviewedOpenClawPluginTarballUrlByPackageSpec`, `packVerifiedOpenClawPluginArchive`, and `packNpmArchive`. +Source boundary: the trusted built-in channel manifests, `OPENCLAW_MESSAGING_PLUGIN_ARCHIVE_PROVENANCE_POLICY`, `reviewedOpenClawPluginIntegrityByPackageSpec`, `reviewedOpenClawPluginTarballUrlByPackageSpec`, `packVerifiedOpenClawPluginArchive`, and `scripts/lib/reviewed-npm-archive.mts`. Source-fix constraint: keep package identity, SRI, and exact tarball URL authority in code-owned manifests; registry metadata is verification input and cannot replace the reviewed values. Regression test: `test/messaging-build-applier-integrity.test.ts` executes the real applier with a fake registry, proves the expected URL permits `npm pack` and local archive installation, and proves a mismatched URL stops before either `npm pack` or `openclaw plugins install`. -Removal condition: keep these provenance checks when issue #5896 consolidates the archive installers, and update the machine-readable policy, manifests, and behavioral regressions together whenever a reviewed plugin version changes. +Removal condition: retain these provenance checks in the shared installer and update the machine-readable policy, manifests, audit inventory, and behavioral regressions together whenever a reviewed plugin version changes. -#### Deferred #5896 Archive Consolidation Contract +#### Shared #5896 Archive and Audit Contract -The four Docker shell boundaries (Codex ACP, runtime OpenClaw, base-image OpenClaw, and optional plugins) and the two-stage Node verifier shared by every messaging-plugin install deliberately keep the same install security matrix at their caller boundaries: exact reviewed package identity, registry SRI, reviewed registry tarball URL, packed-byte SRI, a nonempty basename contained in a fresh pack directory, install from the resolved local archive only, cleanup, and failure before install on any mismatch. Runtime OpenClaw either executes that full transaction or consumes the exact protected result of the base-image transaction under the bounded provenance checks above; it never substitutes a floating package-spec install. Runtime mcporter likewise either installs and audits the committed lock or consumes the marker-bound result of that exact locked and audited base-image transaction. +The Codex ACP, runtime OpenClaw, base-image OpenClaw, optional-plugin, and messaging-plugin boundaries consume one reviewed implementation with thin shell or TypeScript callers. Every archive boundary retains exact reviewed package identity, registry SRI, reviewed registry tarball URL, packed-byte SRI, a nonempty regular-file basename contained in a fresh pack directory, install from the resolved local archive only, cleanup, and failure before install on any mismatch. Runtime OpenClaw either executes that full transaction or consumes the exact protected result of the base-image transaction under the bounded provenance checks above; it never substitutes a floating package-spec install. Runtime mcporter verifies the same exact registry metadata and then either installs and audits the committed lock or consumes the marker-bound result of that exact locked and audited base-image transaction. -Invalid state: one local verifier drops a common invariant while the others retain it. Source boundary: the four Docker transactions plus `packVerifiedOpenClawPluginArchive`/`packNpmArchive`, which form one shared Node primitive for all messaging consumers. Source-fix constraint: consolidating shell build layers and a host-side Node installer changes every trusted install boundary together; issue #5896 section 2 requires that migration to retain thin caller wrappers and caller-specific regressions in one focused change. Regression tests: `test/openclaw-dependency-review.test.ts` names all five implementation boundaries and asserts the common invariant markers, fresh directories, cleanup, and local-archive-only install; the integrity-pin base and plugin-install suites plus `test/messaging-build-applier-integrity.test.ts` execute drift and unsafe-filename failures at both execution environments. Removal condition: close this deferral only when #5896 section 2 replaces the local implementations with a reviewed shared implementation while retaining every caller-boundary regression. +Invalid state: a caller bypasses the helper, the audit inventory diverges from a production pin, CI audits a graph other than the verified local archives and committed mcporter lock, or the raw report is lost when the threshold fails. Source boundary: `scripts/lib/reviewed-npm-archive.mts`, the thin Docker and messaging callers, `ci/reviewed-npm-audit.json`, `scripts/audit-reviewed-npm-graph.mts`, and `.github/actions/ci-reviewed-npm-audit/action.yaml`. Source-fix constraint: #5242 retains general dependency-pin and canary design ownership; this slice records only the current production audit inventory and tests it against the caller-owned pins. Regression tests: the integrity-pin suites and `test/messaging-build-applier-integrity.test.ts` retain malicious filename, registry drift, packed-SRI drift, and local-install proof at each caller; `test/reviewed-npm-archive.test.ts` tests the shared primitive; and `test/reviewed-npm-audit.test.ts` pins inventory alignment, Node version, threshold behavior, default workflow gating, and unconditional artifact upload. Removal condition: keep the shared helper and audit gate while reviewed npm archives remain production build inputs. ### OpenClaw Compiled-Dist Patch Runtime Boundary @@ -282,12 +282,12 @@ No real Microsoft Teams tenant proof is included in this PR. The work remains tr - The literal issue #2478 Local Ollama plus Telegram inbound recovery residual is explicitly accepted for this OpenClaw 2026.6.10 dependency bump only. `issue-2478-crash-loop-recovery` proves repeated gateway kill/respawn, guard-chain restoration, `inference.local` availability, and soak stability through a hermetic compatible endpoint; `messaging-providers` separately imports the installed Telegram `runtime-api.js`, sends through `sendMessageTelegram`, and verifies token rewrite plus fake Bot API capture. This does not reproduce `nemotron-3-super:120b` on Local Ollama or originate a Telegram inbound update after the crash, so agent/channel-specific inbound restart behavior remains a residual rather than proven equivalence. Do not claim the literal deployment scenario from these split lanes. Remove this acceptance when a stable CI fixture drives a Telegram inbound update through the recovered Local Ollama sandbox, or re-evaluate it on the next OpenClaw bump. - The transitive npm graph warning is dispositioned by package evidence rather than a new NemoClaw-owned lockfile in this dependency bump: the reviewed OpenClaw runtime and `@openclaw/*` plugin artifacts ship package-internal `npm-shrinkwrap.json` files with integrity metadata, `@zed-industries/codex-acp@0.11.1` has no npm dependency tree, and the only reviewed non-shrinkwrapped plugin is the pre-existing Tencent WeChat package whose top-level SRI is now enforced. A future installer-policy PR should add a NemoClaw-owned lock/audit gate for third-party messaging plugins without package-internal shrinkwraps. - `src/lib/messaging/channels/manifests.test.ts` remains below the shared `test-size:check` threshold and does not need extraction in this dependency bump. -- The npm audit result in this note is a manual snapshot for the reviewed lock-only graph. It is not a new CI gate; rerun the command in the Advisory Check section on the next OpenClaw/plugin bump or if npm advisory state changes before merge. Follow-up automation should add a CI job for `npm install --package-lock-only --ignore-scripts && npm audit --omit=dev --json` on the reviewed OpenClaw/plugin graph. +- The npm audit result in this note remains a point-in-time snapshot. Default PR and main CI now rematerialize the production-compatible graph from the reviewed local archives, audit it and the committed mcporter lock with `npm audit --omit=dev --json`, upload both raw reports, and fail at the configured `high` threshold. - The stale nonterminal rebuild-resume repair in `src/lib/actions/sandbox/rebuild-resume-session.ts` remains a migration compatibility shim tracked against #4533's onboard FSM/resume compatibility boundary. Its removal condition is to delete it after a session-version migration proves recreate sessions are always persisted at a resumable pre-sandbox boundary; `src/lib/actions/sandbox/rebuild-resume-session.test.ts` covers the helper directly, `test/onboard-resume-provider-recovery.test.ts` carries the onboard-suite producer-level regression for `machine.state='openclaw'`, and `src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts` owns the rebuild handoff regression. - Production OpenClaw image build paths call `scripts/check-production-build-args.sh` before production `docker build` or `docker/build-push-action` use. `test/openclaw-dependency-review.test.ts` keeps that workflow contract documented. - The rebuild-reasoning cases added by this PR live in the focused `rebuild-resume-reasoning.test.ts` file; the smaller route-provenance additions remain with their `rebuild-resume-config.ts` boundary tests. - `src/lib/state/sandbox.ts` is 100 lines smaller than current `main` in this PR. Managed-extension policy, restore exclusions, symlink predicates, and cleanup construction now live in `openclaw-managed-extensions.ts`; further decomposition of unrelated snapshot orchestration is outside this dependency bump. -- The shared archive-installer redesign remains explicitly deferred to issue #5896 section 2. Consolidating the reviewed archive helper would change the Codex ACP, OpenClaw core, base-image, optional-plugin, and messaging installation boundaries together; the named all-boundary parity contract keeps each copy on the same common security matrix until that focused cross-installer migration lands. +- Issue #5896 section 2 archive consolidation is implemented by `scripts/lib/reviewed-npm-archive.mts`; Codex ACP, OpenClaw core, base-image, optional-plugin, and messaging installation boundaries retain caller-specific behavior tests around the shared implementation. - Legacy Slack fixture retirement and broader setup/test refactors also remain deferred to #5896. The default 2026.6.10 lane cannot use the legacy helper; only an explicitly flagged isolated fixture can reach it. - `isAllowedStateSymlink` has direct source- and target-traversal vectors in `openclaw-managed-extensions.test.ts`, in addition to the snapshot/tar traversal integration suites. - Live gateway display output is treated as untrusted text: `gateway-provider-metadata.ts` bounds the complete output and each field, strips terminal decoration, requires one complete syntax-safe schema with unique environment-style binding keys, and returns only the exact requested provider. Recovery then requires exactly one expected credential key and endpoint-config key. Partial, oversized, duplicated, malformed, or ambiguous output fails closed in focused parser tests. diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts new file mode 100755 index 0000000000..d932cc2481 --- /dev/null +++ b/scripts/audit-reviewed-npm-graph.mts @@ -0,0 +1,198 @@ +#!/usr/bin/env -S node --experimental-strip-types +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + packReviewedNpmArchive, + verifyReviewedNpmMetadata, +} from "./lib/reviewed-npm-archive.mts"; + +type Severity = "info" | "low" | "moderate" | "high" | "critical"; +type ReviewedPackage = Readonly<{ + integrity: string; + label: string; + packageSpec: string; + tarballUrl: string; +}>; +type LockedGraph = ReviewedPackage & Readonly<{ directory: string }>; +type AuditConfig = Readonly<{ + archivePackages: readonly ReviewedPackage[]; + artifactDirectory: string; + lockedGraphs: readonly LockedGraph[]; + nodeVersion: string; + schemaVersion: 1; + severityThreshold: Severity; +}>; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const CONFIG_PATH = path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"); +const SEVERITIES: readonly Severity[] = ["info", "low", "moderate", "high", "critical"]; + +function run(command: string, args: readonly string[], cwd: string, allowAuditFindings = false) { + const result = spawnSync(command, args, { + cwd, + encoding: "utf-8", + env: { ...process.env, NPM_CONFIG_UPDATE_NOTIFIER: "false" }, + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error) throw result.error; + if (result.status !== 0 && !allowAuditFindings) { + throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr || result.stdout}`); + } + return result; +} + +function readConfig(): AuditConfig { + const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as AuditConfig; + if ( + parsed.schemaVersion !== 1 || + !SEVERITIES.includes(parsed.severityThreshold) || + !Array.isArray(parsed.archivePackages) || + !Array.isArray(parsed.lockedGraphs) + ) { + throw new Error("ci/reviewed-npm-audit.json is invalid"); + } + return parsed; +} + +function auditGraph(directory: string, reportPath: string): Record { + const result = run("npm", ["audit", "--omit=dev", "--json"], directory, true); + if (!result.stdout.trim()) { + throw new Error(`npm audit did not produce JSON: ${result.stderr}`); + } + fs.writeFileSync(reportPath, result.stdout); + try { + return JSON.parse(result.stdout) as Record; + } catch (error) { + throw new Error(`npm audit returned invalid JSON: ${String(error)}`); + } +} + +export function vulnerabilityCounts(report: Record): Record { + const metadata = report.metadata as Record | undefined; + const vulnerabilities = metadata?.vulnerabilities as Record | undefined; + return Object.fromEntries( + SEVERITIES.map((severity) => [severity, Number(vulnerabilities?.[severity] ?? 0)]), + ) as Record; +} + +export function exceedsAuditThreshold( + counts: Readonly>, + threshold: Severity, +): number { + return SEVERITIES.slice(SEVERITIES.indexOf(threshold)).reduce( + (total, severity) => total + counts[severity], + 0, + ); +} + +function materializeArchiveGraph( + packages: readonly ReviewedPackage[], + tempRoot: string, +): string { + const graphDirectory = path.join(tempRoot, "reviewed-archive-graph"); + fs.mkdirSync(graphDirectory); + fs.writeFileSync( + path.join(graphDirectory, "package.json"), + `${JSON.stringify({ name: "nemoclaw-reviewed-production-graph", private: true, version: "1.0.0" }, null, 2)}\n`, + ); + const archives = packages.map((reviewed) => + packReviewedNpmArchive({ + expectedIntegrity: reviewed.integrity, + label: reviewed.label, + packageSpec: reviewed.packageSpec, + tarballUrl: reviewed.tarballUrl, + tempDirectory: tempRoot, + }), + ); + run( + "npm", + [ + "install", + "--ignore-scripts", + "--omit=dev", + "--no-audit", + "--no-fund", + ...archives.map((archive) => archive.archivePath), + ], + graphDirectory, + ); + return graphDirectory; +} + +function materializeLockedGraph(graph: LockedGraph, tempRoot: string): string { + verifyReviewedNpmMetadata({ + expectedIntegrity: graph.integrity, + label: graph.label, + packageSpec: graph.packageSpec, + tarballUrl: graph.tarballUrl, + }); + const source = path.join(REPO_ROOT, graph.directory); + const destination = path.join(tempRoot, `locked-${path.basename(graph.directory)}`); + fs.mkdirSync(destination); + for (const filename of ["package.json", "package-lock.json"]) { + fs.copyFileSync(path.join(source, filename), path.join(destination, filename)); + } + run("npm", ["ci", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund"], destination); + return destination; +} + +function main(): void { + const config = readConfig(); + const expectedNode = `v${config.nodeVersion}`; + if (process.version !== expectedNode) { + throw new Error(`reviewed npm audit requires Node ${expectedNode}; running ${process.version}`); + } + const artifactDirectory = path.join(REPO_ROOT, config.artifactDirectory); + fs.rmSync(artifactDirectory, { recursive: true, force: true }); + fs.mkdirSync(artifactDirectory, { recursive: true }); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-audit-")); + try { + const reports = [ + { + label: "reviewed archive graph", + report: auditGraph( + materializeArchiveGraph(config.archivePackages, tempRoot), + path.join(artifactDirectory, "reviewed-archive-graph.json"), + ), + }, + ...config.lockedGraphs.map((graph, index) => ({ + label: graph.label, + report: auditGraph( + materializeLockedGraph(graph, tempRoot), + path.join(artifactDirectory, `locked-graph-${index + 1}.json`), + ), + })), + ]; + const failures: string[] = []; + for (const { label, report } of reports) { + const counts = vulnerabilityCounts(report); + const summary = SEVERITIES.map((severity) => `${severity}=${counts[severity]}`).join(" "); + console.log(`${label}: ${summary}`); + const blocked = exceedsAuditThreshold(counts, config.severityThreshold); + if (blocked > 0) failures.push(`${label}: ${blocked} at or above ${config.severityThreshold}`); + } + if (failures.length > 0) throw new Error(`reviewed npm audit threshold failed\n${failures.join("\n")}`); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function isMainModule(): boolean { + return process.argv[1] ? import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href : false; +} + +if (isMainModule()) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/lib/reviewed-npm-archive.mts b/scripts/lib/reviewed-npm-archive.mts new file mode 100755 index 0000000000..b0394599c9 --- /dev/null +++ b/scripts/lib/reviewed-npm-archive.mts @@ -0,0 +1,222 @@ +#!/usr/bin/env -S node --experimental-strip-types +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { existsSync, lstatSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, resolve, sep } from "node:path"; +import { pathToFileURL } from "node:url"; + +const NPM_OUTPUT_MAX_BUFFER = 16 * 1024 * 1024; +const EXACT_NPM_PACKAGE_SPEC = /^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)@[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/; + +export type ReviewedNpmArchiveRequest = Readonly<{ + env?: NodeJS.ProcessEnv; + expectedIntegrity: string; + label: string; + npmExecutable?: string; + packageSpec: string; + tarballUrl: string; + tempDirectory?: string; +}>; + +export type ReviewedNpmMetadata = Readonly<{ + integrity: string; + tarballUrl: string; +}>; + +export type ReviewedNpmArchive = Readonly<{ + archivePath: string; + rootDirectory: string; +}>; + +type NpmRunner = (args: readonly string[], request: ReviewedNpmArchiveRequest) => string; + +function runNpm(args: readonly string[], request: ReviewedNpmArchiveRequest): string { + const result = spawnSync(request.npmExecutable ?? "npm", args, { + encoding: "utf-8", + env: request.env, + maxBuffer: NPM_OUTPUT_MAX_BUFFER, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + throw new Error( + `${request.label} npm ${args[0] ?? "command"} failed${detail ? `: ${detail}` : ""}`, + ); + } + return String(result.stdout ?? ""); +} + +function requireReviewedRequest(request: ReviewedNpmArchiveRequest): void { + if (!EXACT_NPM_PACKAGE_SPEC.test(request.packageSpec)) { + throw new Error(`${request.label} must use an exact npm package spec: ${request.packageSpec}`); + } + if (!request.expectedIntegrity.startsWith("sha512-")) { + throw new Error(`${request.label} must use a committed sha512 npm integrity value`); + } + if (!request.tarballUrl) { + throw new Error(`${request.label} must use a committed npm tarball URL`); + } +} + +export function verifyReviewedNpmMetadata( + request: ReviewedNpmArchiveRequest, + npmRunner: NpmRunner = runNpm, +): ReviewedNpmMetadata { + requireReviewedRequest(request); + const integrity = npmRunner(["view", request.packageSpec, "dist.integrity"], request).trim(); + if (integrity !== request.expectedIntegrity) { + throw new Error( + `${request.label} npm integrity mismatch\nExpected: ${request.expectedIntegrity}\nActual: ${integrity}`, + ); + } + + const tarballUrl = npmRunner(["view", request.packageSpec, "dist.tarball"], request).trim(); + if (tarballUrl !== request.tarballUrl) { + throw new Error( + `${request.label} npm tarball URL mismatch\nExpected: ${request.tarballUrl}\nActual: ${tarballUrl}`, + ); + } + return { integrity, tarballUrl }; +} + +export function resolveReviewedNpmArchivePath( + packageSpec: string, + rootDirectory: string, + filename: string, +): string { + if ( + !filename || + isAbsolute(filename) || + filename === "." || + filename === ".." || + filename.includes("/") || + filename.includes("\\") + ) { + throw new Error(`npm pack ${packageSpec} reported unsafe archive filename: ${filename}`); + } + + const root = resolve(rootDirectory); + const archivePath = resolve(root, filename); + if (!archivePath.startsWith(`${root}${sep}`)) { + throw new Error( + `npm pack ${packageSpec} reported archive path outside pack directory: ${filename}`, + ); + } + if (!existsSync(archivePath)) { + throw new Error(`npm pack ${packageSpec} did not create reported archive: ${filename}`); + } + const archive = lstatSync(archivePath); + if (!archive.isFile() || archive.isSymbolicLink()) { + throw new Error(`npm pack ${packageSpec} reported a non-file archive: ${filename}`); + } + return archivePath; +} + +export function packReviewedNpmArchive( + request: ReviewedNpmArchiveRequest, + npmRunner: NpmRunner = runNpm, +): ReviewedNpmArchive { + verifyReviewedNpmMetadata(request, npmRunner); + const rootDirectory = mkdtempSync( + join(request.tempDirectory ?? tmpdir(), "nemoclaw-reviewed-npm-pack-"), + ); + try { + const packJson = npmRunner( + ["pack", request.tarballUrl, "--pack-destination", rootDirectory, "--json"], + request, + ); + let parsed: unknown; + try { + parsed = JSON.parse(packJson); + } 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 filename = + typeof entry === "object" && entry !== null && "filename" in entry + ? String(entry.filename ?? "") + : ""; + const actualIntegrity = + typeof entry === "object" && entry !== null && "integrity" in entry + ? String(entry.integrity ?? "") + : ""; + if (!filename || !actualIntegrity) { + throw new Error(`npm pack ${request.packageSpec} did not report filename and integrity`); + } + if (actualIntegrity !== request.expectedIntegrity) { + throw new Error( + `${request.label} downloaded tarball integrity mismatch\nExpected: ${request.expectedIntegrity}\nActual: ${actualIntegrity}`, + ); + } + return { + archivePath: resolveReviewedNpmArchivePath( + request.packageSpec, + rootDirectory, + filename, + ), + rootDirectory, + }; + } catch (error) { + rmSync(rootDirectory, { recursive: true, force: true }); + throw error; + } +} + +export function removeReviewedNpmArchive(archive: ReviewedNpmArchive): void { + rmSync(archive.rootDirectory, { recursive: true, force: true }); +} + +type CliOptions = ReviewedNpmArchiveRequest & Readonly<{ verifyOnly: boolean }>; + +function parseCliOptions(argv: readonly string[]): CliOptions { + const values = new Map(); + let verifyOnly = false; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--verify-only") { + verifyOnly = true; + continue; + } + if (!arg?.startsWith("--")) throw new Error(`Unknown argument: ${arg ?? ""}`); + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value`); + values.set(arg, value); + index += 1; + } + const required = (name: string): string => { + const value = values.get(name); + if (!value) throw new Error(`${name} is required`); + return value; + }; + return { + expectedIntegrity: required("--integrity"), + label: required("--label"), + npmExecutable: process.env.NEMOCLAW_REVIEWED_NPM_EXECUTABLE, + packageSpec: required("--package-spec"), + tarballUrl: required("--tarball-url"), + tempDirectory: values.get("--temp-directory"), + verifyOnly, + }; +} + +function isMainModule(): boolean { + return process.argv[1] ? import.meta.url === pathToFileURL(resolve(process.argv[1])).href : false; +} + +if (isMainModule()) { + try { + const options = parseCliOptions(process.argv.slice(2)); + if (options.verifyOnly) { + verifyReviewedNpmMetadata(options); + } else { + process.stdout.write(`${packReviewedNpmArchive(options).archivePath}\n`); + } + } catch (error) { + console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } +} diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index 1e5460f1b6..2ad0b7afb4 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -3,18 +3,11 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve, sep } from "node:path"; import { pathToFileURL } from "node:url"; +import { packReviewedNpmArchive } from "../../../../../scripts/lib/reviewed-npm-archive.mts"; import { discordManifest } from "../../channels/discord/manifest.ts"; import { slackManifest } from "../../channels/slack/manifest.ts"; import { teamsManifest } from "../../channels/teams/manifest.ts"; @@ -138,8 +131,6 @@ export const OPENCLAW_MESSAGING_PLUGIN_ARCHIVE_PROVENANCE_POLICY = Object.freeze registryTarballUrl: "must-match-committed-url", } as const); -const NPM_METADATA_MAX_BUFFER = 16 * 1024 * 1024; - type HermesUvPackageInstall = { readonly spec: string; }; @@ -1162,110 +1153,6 @@ function runCommand(args: readonly string[], env: Env): void { } } -function npmViewString(packageSpec: string, field: string, env: Env): string { - const result = spawnSync("npm", ["view", packageSpec, field], { - encoding: "utf-8", - env: env as NodeJS.ProcessEnv, - maxBuffer: NPM_METADATA_MAX_BUFFER, - stdio: ["ignore", "pipe", "pipe"], - }); - if (result.error) throw result.error; - if (result.status !== 0) { - const detail = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); - throw new MessagingBuildApplierError( - `npm view ${packageSpec} ${field} failed${detail ? `: ${detail}` : ""}`, - ); - } - return String(result.stdout ?? "").trim(); -} - -function resolveNpmPackArchivePath(packageSpec: string, rootDir: string, filename: string): string { - const filenameSegments = filename.split(/[\\/]+/); - if ( - !filename || - isAbsolute(filename) || - filename === "." || - filename === ".." || - filename.includes("/") || - filename.includes("\\") || - filenameSegments.includes("..") || - filenameSegments.includes("") - ) { - throw new MessagingBuildApplierError( - `npm pack ${packageSpec} reported unsafe archive filename: ${filename}`, - ); - } - - const root = resolve(rootDir); - const archivePath = resolve(root, filename); - if (!archivePath.startsWith(root + sep)) { - throw new MessagingBuildApplierError( - `npm pack ${packageSpec} reported archive path outside pack directory: ${filename}`, - ); - } - return archivePath; -} - -// Reviewed-archive invariants (#5896): registry SRI at the caller, packed-byte -// SRI, a contained basename in a fresh directory, local-archive-only install, -// and cleanup. This Node primitive is shared by all messaging plugin installs. -function packNpmArchive( - packageSpec: string, - expectedIntegrity: string, - env: Env, -): { readonly archivePath: string; readonly rootDir: string } { - const rootDir = mkdtempSync(join(tmpdir(), "nemoclaw-openclaw-plugin-pack-")); - const result = spawnSync("npm", ["pack", packageSpec, "--pack-destination", rootDir, "--json"], { - encoding: "utf-8", - env: env as NodeJS.ProcessEnv, - maxBuffer: NPM_METADATA_MAX_BUFFER, - stdio: ["ignore", "pipe", "pipe"], - }); - if (result.error) { - rmSync(rootDir, { recursive: true, force: true }); - throw result.error; - } - if (result.status !== 0) { - const detail = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); - rmSync(rootDir, { recursive: true, force: true }); - throw new MessagingBuildApplierError( - `npm pack ${packageSpec} failed${detail ? `: ${detail}` : ""}`, - ); - } - - let packed: unknown; - try { - packed = JSON.parse(String(result.stdout ?? "")); - } catch (error) { - rmSync(rootDir, { recursive: true, force: true }); - throw new MessagingBuildApplierError( - `npm pack ${packageSpec} did not return JSON: ${String(error)}`, - ); - } - const [entry] = Array.isArray(packed) ? packed : []; - const filename = isObject(entry) && typeof entry.filename === "string" ? entry.filename : ""; - const actualIntegrity = - isObject(entry) && typeof entry.integrity === "string" ? entry.integrity : ""; - if (!filename || !actualIntegrity) { - rmSync(rootDir, { recursive: true, force: true }); - throw new MessagingBuildApplierError( - `npm pack ${packageSpec} did not report filename and integrity`, - ); - } - if (actualIntegrity !== expectedIntegrity) { - rmSync(rootDir, { recursive: true, force: true }); - throw new MessagingBuildApplierError( - `OpenClaw plugin ${packageSpec} downloaded tarball integrity mismatch. Expected: ${expectedIntegrity}. Actual: ${actualIntegrity}`, - ); - } - try { - return { archivePath: resolveNpmPackArchivePath(packageSpec, rootDir, filename), rootDir }; - } catch (error) { - rmSync(rootDir, { recursive: true, force: true }); - throw error; - } -} - function packVerifiedOpenClawPluginArchive( install: OpenClawPluginInstall, env: Env, @@ -1285,27 +1172,14 @@ function packVerifiedOpenClawPluginArchive( `OpenClaw plugin ${install.npmPackageSpec} has no committed npm tarball URL`, ); } - const actual = npmViewString( - install.npmPackageSpec, - OPENCLAW_MESSAGING_PLUGIN_ARCHIVE_PROVENANCE_POLICY.registryIntegrityField, - env, - ); - if (actual !== install.integrity) { - throw new MessagingBuildApplierError( - `OpenClaw plugin ${install.npmPackageSpec} npm integrity mismatch. Expected: ${install.integrity}. Actual: ${actual}`, - ); - } - const actualTarballUrl = npmViewString( - install.npmPackageSpec, - OPENCLAW_MESSAGING_PLUGIN_ARCHIVE_PROVENANCE_POLICY.registryTarballField, - env, - ); - if (actualTarballUrl !== install.tarballUrl) { - throw new MessagingBuildApplierError( - `OpenClaw plugin ${install.npmPackageSpec} npm tarball URL mismatch. Expected: ${install.tarballUrl}. Actual: ${actualTarballUrl}`, - ); - } - return packNpmArchive(install.npmPackageSpec, install.integrity, env); + const archive = packReviewedNpmArchive({ + env: env as NodeJS.ProcessEnv, + expectedIntegrity: install.integrity, + label: `OpenClaw plugin ${install.npmPackageSpec}`, + packageSpec: install.npmPackageSpec, + tarballUrl: install.tarballUrl, + }); + return { archivePath: archive.archivePath, rootDir: archive.rootDirectory }; } type CredentialPlaceholderRule = { diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index 1c99e899a4..91cd8b8696 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -34,7 +34,7 @@ const EXACT_CUSTOM_POST_GENERATOR_RUN_RE = [ // A lifecycle test verifies these digests against the checked-in Dockerfile. const CANONICAL_POST_GENERATOR_RUN_SHA256 = new Set([ "e7256f12c618bb424f53fec801378d92446d880c5935965ebb3b548694866b63", - "121d7732831a75b20dd31a58c65a5fdf3b6ff56ed24d61802ee4b0cca806d4e1", + "862807dd20a2879f49862a7d9d02fbdc2aa1be00539d05c86814b23f451b4a29", "737edaaa69f80cf10d42fd349e0be068c1ef6e7375d5dcb4055b012420b58736", "5b814e92449a6778385f588877fe72ebed80e601f8eb0c90c2842b17a489f3da", "0e1a9a7bab2fab0a974577c3af8785157b4b9be2b4db32d5f4f9e5aa3c8c8171", diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index fa2e713a58..e5c938dc09 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -249,6 +249,11 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "patch-openclaw-device-self-approval.ts"), path.join(stagedScriptsDir, "patch-openclaw-device-self-approval.ts"), ); + fs.mkdirSync(path.join(stagedScriptsDir, "lib"), { recursive: true }); + fs.copyFileSync( + path.join(rootDir, "scripts", "lib", "reviewed-npm-archive.mts"), + path.join(stagedScriptsDir, "lib", "reviewed-npm-archive.mts"), + ); return { buildCtx, stagedDockerfile }; } diff --git a/test/fetch-guard-patch-regression.test.ts b/test/fetch-guard-patch-regression.test.ts index 1c8c40d995..22eaca757a 100644 --- a/test/fetch-guard-patch-regression.test.ts +++ b/test/fetch-guard-patch-regression.test.ts @@ -40,60 +40,6 @@ const REVIEWED_OPENCLAW_2026_6_10_WEB_FETCH_SHAPE = [ ].join("\n"); const REVIEWED_OPENCLAW_2026_6_10_MANAGED_PROXY_SHAPE = "const canUseManagedProxy = mode === GUARDED_FETCH_MODE.STRICT && isManagedProxyActive() && hasProxyEnvConfigured();"; -const REVIEWED_OPENCLAW_2026_6_10_SSRF_POLICY_SHAPE = [ - "function shouldSkipPrivateNetworkChecks(hostname, policy) {", - " return isPrivateNetworkAllowedByPolicy(policy) || normalizeHostnameSet(policy?.allowedHostnames).has(hostname);", - "}", - "function resolveHostnamePolicyChecks(hostname, policy) {", - " const normalized = normalizeHostname(hostname);", - ' if (!normalized) throw new Error("Invalid hostname");', - " const hostnameAllowlist = normalizeHostnameAllowlist(policy?.hostnameAllowlist);", - " const skipPrivateNetworkChecks = shouldSkipPrivateNetworkChecks(normalized, policy);", - " if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`);", - " if (!skipPrivateNetworkChecks) assertAllowedHostOrIpOrThrow(normalized, policy);", - " return {", - " normalized,", - " skipPrivateNetworkChecks", - " };", - "}", -].join("\n"); - -function loadReviewedOpenClaw20260610SsrfPolicyShape() { - return new Function(` -class SsrFBlockedError extends Error {} -function normalizeHostname(value) { - return String(value || "").toLowerCase().replace(/\\.+$/, ""); -} -function normalizeHostnameSet(values) { - if (!values || values.length === 0) return new Set(); - return new Set(values.map((value) => normalizeHostname(value)).filter(Boolean)); -} -function normalizeHostnameAllowlist(values) { - if (!values || values.length === 0) return []; - return Array.from(new Set(values.map((value) => normalizeHostname(value)).filter((value) => value !== "*" && value !== "*." && value.length > 0))); -} -function isPrivateNetworkAllowedByPolicy(policy) { - return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true; -} -function matchesHostnameAllowlist(hostname, allowlist) { - return allowlist.length === 0 || allowlist.includes(hostname); -} -function assertAllowedHostOrIpOrThrow(hostnameOrIp) { - if (hostnameOrIp === "host.openshell.internal" || hostnameOrIp.endsWith(".internal") || hostnameOrIp === "10.0.0.1") { - throw new SsrFBlockedError("blocked " + hostnameOrIp); - } -} -${REVIEWED_OPENCLAW_2026_6_10_SSRF_POLICY_SHAPE} -return { shouldSkipPrivateNetworkChecks, resolveHostnamePolicyChecks }; - `)() as { - shouldSkipPrivateNetworkChecks: (hostname: string, policy?: Record) => boolean; - resolveHostnamePolicyChecks: ( - hostname: string, - policy?: Record, - ) => { normalized: string; skipPrivateNetworkChecks: boolean }; - }; -} - function readRequiredMatch(file: string, pattern: RegExp, description: string): string { const match = fs.readFileSync(file, "utf-8").match(pattern); if (!match?.[1]) { @@ -193,16 +139,25 @@ function runOpenClawUpgradeBlock(currentVersion: string) { const mcporterInstall = path.join(tmp, "mcporter-runtime"); const mcporterShim = path.join(tmp, "mcporter-bin"); const openclawVersion = readDockerfileOpenClawVersion(); + const reviewedArchiveDir = path.join(tmp, "reviewed-pack"); + const reviewedArchive = path.join(reviewedArchiveDir, `openclaw-${openclawVersion}.tgz`); const expectedMcporterVersion = readDockerfileMcporterVersion(); const openclawIntegrity = readDockerfileOpenClawIntegrity(); const openclawTarball = readDockerfileOpenClawTarball(); const mcporterIntegrity = readDockerfileMcporterIntegrity(); + const mcporterTarball = readRequiredMatch( + DOCKERFILE, + /^ARG MCPORTER_0_7_3_TARBALL=([^\s]+)/m, + "mcporter runtime tarball", + ); fs.writeFileSync(blueprint, `min_openclaw_version: "${readBlueprintMinOpenClawVersion()}"\n`); fs.mkdirSync(openclawInstall, { recursive: true }); fs.mkdirSync(mcporterInstall, { recursive: true }); + fs.mkdirSync(reviewedArchiveDir); fs.writeFileSync(path.join(mcporterInstall, "package-lock.json"), "{}"); fs.writeFileSync(openclawShim, ""); fs.writeFileSync(mcporterShim, ""); + fs.writeFileSync(reviewedArchive, "fake reviewed OpenClaw archive"); const command = dockerRunCommandBetween( "# OPENCLAW_VERSION is the NemoClaw runtime build target", "# Patch OpenClaw media fetch", @@ -219,14 +174,31 @@ function runOpenClawUpgradeBlock(currentVersion: string) { `call_log=${JSON.stringify(log)}`, `real_node=${JSON.stringify(process.execPath)}`, `postinstall_path=${JSON.stringify(path.join(openclawInstall, "scripts/postinstall-bundled-plugins.mjs"))}`, + `reviewed_archive=${JSON.stringify(reviewedArchive)}`, `OPENCLAW_VERSION=${JSON.stringify(openclawVersion)}`, `BASE_IMAGE=${JSON.stringify("registry.example/nemoclaw-test-base:latest")}`, `MCPORTER_VERSION=${JSON.stringify(expectedMcporterVersion)}`, `OPENCLAW_2026_6_10_INTEGRITY=${JSON.stringify(openclawIntegrity)}`, `OPENCLAW_2026_6_10_TARBALL=${JSON.stringify(openclawTarball)}`, `MCPORTER_0_7_3_INTEGRITY=${JSON.stringify(mcporterIntegrity)}`, + `MCPORTER_0_7_3_TARBALL=${JSON.stringify(mcporterTarball)}`, "node() {", ' if [ "${1:-}" = "$postinstall_path" ]; then printf "node %s\\n" "$*" >> "$call_log"; return 0; fi', + ' if [ "${2:-}" = "/scripts/lib/reviewed-npm-archive.mts" ]; then', + ' if [ "${3:-}" = "--verify-only" ]; then', + ' [ "$#" -eq 11 ] && [ "${4:-}" = "--package-spec" ] && [ "${5:-}" = "mcporter@${MCPORTER_VERSION}" ] || return 91;', + ' [ "${6:-}" = "--integrity" ] && [ "${7:-}" = "$MCPORTER_0_7_3_INTEGRITY" ] || return 92;', + ' [ "${8:-}" = "--tarball-url" ] && [ "${9:-}" = "$MCPORTER_0_7_3_TARBALL" ] || return 93;', + ' [ "${10:-}" = "--label" ] && [ "${11:-}" = "mcporter ${MCPORTER_VERSION}" ] || return 94;', + " return 0;", + " fi", + ' [ "$#" -eq 10 ] && [ "${3:-}" = "--package-spec" ] && [ "${4:-}" = "openclaw@${OPENCLAW_VERSION}" ] || return 95;', + ' [ "${5:-}" = "--integrity" ] && [ "${6:-}" = "$OPENCLAW_2026_6_10_INTEGRITY" ] || return 96;', + ' [ "${7:-}" = "--tarball-url" ] && [ "${8:-}" = "$OPENCLAW_2026_6_10_TARBALL" ] || return 97;', + ' [ "${9:-}" = "--label" ] && [ "${10:-}" = "OpenClaw ${OPENCLAW_VERSION}" ] || return 98;', + ' printf "npm pack %s --pack-destination reviewed-temp\\n" "${8:-}" >> "$call_log";', + ' printf "%s\\n" "$reviewed_archive"; return 0;', + " fi", ' "$real_node" "$@"', "}", `openclaw() { if [ "\${1:-}" = "--version" ]; then printf 'openclaw ${currentVersion}\\n'; else return 127; fi; }`, @@ -306,44 +278,13 @@ function webGuardedFetchFixtureSource(): string { } describe("fetch-guard patch regression guard", () => { - it("anchors web_fetch host-gateway policy to the reviewed OpenClaw 2026.6.10 SSRF contract", () => { + it("anchors web_fetch proxy mode to the reviewed OpenClaw 2026.6.10 contract", () => { expect(REVIEWED_OPENCLAW_2026_6_10_WEB_FETCH_SHAPE).toContain( "function fetchWithWebToolsNetworkGuard(params)", ); expect(REVIEWED_OPENCLAW_2026_6_10_WEB_FETCH_SHAPE).toContain( "withTrustedEnvProxyGuardedFetchMode(resolved)", ); - expect(REVIEWED_OPENCLAW_2026_6_10_SSRF_POLICY_SHAPE).toContain( - "normalizeHostnameSet(policy?.allowedHostnames).has(hostname)", - ); - expect(REVIEWED_OPENCLAW_2026_6_10_SSRF_POLICY_SHAPE).toContain( - "normalizeHostnameAllowlist(policy?.hostnameAllowlist)", - ); - - const reviewed = loadReviewedOpenClaw20260610SsrfPolicyShape(); - expect( - reviewed.shouldSkipPrivateNetworkChecks("host.openshell.internal", { - allowedHostnames: ["HOST.OPENSHELL.INTERNAL."], - }), - ).toBe(true); - expect( - reviewed.shouldSkipPrivateNetworkChecks("host.openshell.internal", { - hostnameAllowlist: ["host.openshell.internal"], - }), - ).toBe(false); - expect( - reviewed.resolveHostnamePolicyChecks("host.openshell.internal", { - allowedHostnames: ["host.openshell.internal"], - }), - ).toEqual({ - normalized: "host.openshell.internal", - skipPrivateNetworkChecks: true, - }); - expect(() => - reviewed.resolveHostnamePolicyChecks("host.openshell.internal", { - hostnameAllowlist: ["host.openshell.internal"], - }), - ).toThrow(/blocked host\.openshell\.internal/); }); it("fails the image build when the NemoClaw OpenClaw plugin cannot install", () => { diff --git a/test/helpers/reviewed-npm-fixture.ts b/test/helpers/reviewed-npm-fixture.ts new file mode 100644 index 0000000000..3e37a5e5bc --- /dev/null +++ b/test/helpers/reviewed-npm-fixture.ts @@ -0,0 +1,45 @@ +// 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"; + +type ReviewedPackageFixture = Readonly<{ + integrity: string; + packageSpec: string; + tarballUrl: string; +}>; + +export function writeReviewedNpmFixture( + fixturePath: string, + logPath: string, + packages: readonly ReviewedPackageFixture[], +): void { + const metadataCases = packages.flatMap((reviewed) => [ + ` ${JSON.stringify(`${reviewed.packageSpec}|dist.integrity`)}) printf '%s\\n' ${JSON.stringify(reviewed.integrity)} ;;`, + ` ${JSON.stringify(`${reviewed.packageSpec}|dist.tarball`)}) printf '%s\\n' ${JSON.stringify(reviewed.tarballUrl)} ;;`, + ]); + 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)} ;;`; + }); + fs.writeFileSync( + fixturePath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `printf 'npm %s\\n' "$*" >> ${JSON.stringify(logPath)}`, + 'if [ "${1:-}" = "view" ]; then case "${2:-}|${3:-}" in', + ...metadataCases, + " *) exit 1 ;;", + "esac; exit 0; fi", + 'if [ "${1:-}" = "pack" ]; then pack_dir="${4:-}"; case "${2:-}" in', + ...packCases, + " *) exit 1 ;;", + "esac; exit 0; fi", + "exit 1", + "", + ].join("\n"), + { mode: 0o755 }, + ); +} diff --git a/test/mcporter-supply-chain.test.ts b/test/mcporter-supply-chain.test.ts index ce2423bf57..fd34ae32b5 100644 --- a/test/mcporter-supply-chain.test.ts +++ b/test/mcporter-supply-chain.test.ts @@ -16,21 +16,25 @@ const dockerfiles = ["Dockerfile.base", "Dockerfile"].map((name) => ({ const expectedVersion = "0.7.3"; const expectedIntegrity = "sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA=="; +const expectedTarball = "https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz"; const runtimePrefix = "npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime"; function extractIntegrityGate(contents: string): string { const startMarker = 'MCPORTER_EXPECTED_INTEGRITY=""'; const start = contents.indexOf(startMarker); - const [end = -1] = [ - contents.indexOf('MCPORTER_LOCK_SHA256="', start), - contents.indexOf("&& MCPORTER_REGISTRY_INTEGRITY=", start), - ] + const helperMarker = + "node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts --verify-only"; + const helperStart = contents.indexOf(helperMarker, start); + const helperEndMarker = '--label "mcporter ${MCPORTER_VERSION}"'; + const helperEnd = contents.indexOf(helperEndMarker, helperStart) + helperEndMarker.length; + const [end = -1] = [contents.indexOf('MCPORTER_LOCK_SHA256="', start), helperStart] .filter((index) => index > start) .sort((left, right) => left - right); expect(start).toBeGreaterThanOrEqual(0); expect(end).toBeGreaterThan(start); - return contents - .slice(start, end) + expect(helperStart).toBeGreaterThanOrEqual(end); + expect(helperEnd).toBeGreaterThan(helperStart); + return `${contents.slice(start, end)}\n${contents.slice(helperStart, helperEnd)}` .replace(/\\\s*\n/g, " ") .trim(); } @@ -40,7 +44,18 @@ function runIntegrityGate(contents: string, version: string) { "set -euo pipefail", `MCPORTER_VERSION=${JSON.stringify(version)}`, `MCPORTER_0_7_3_INTEGRITY=${JSON.stringify(expectedIntegrity)}`, + `MCPORTER_0_7_3_TARBALL=${JSON.stringify(expectedTarball)}`, `npm() { printf '%s\\n' ${JSON.stringify(expectedIntegrity)}; }`, + "node() {", + ' [ "$#" -eq 11 ] && [ "${1:-}" = "--experimental-strip-types" ] || return 81', + ' [ "${2:-}" = "/scripts/lib/reviewed-npm-archive.mts" ] && [ "${3:-}" = "--verify-only" ] || return 82', + ' [ "${4:-}" = "--package-spec" ] && [ "${5:-}" = "mcporter@${MCPORTER_VERSION}" ] || return 83', + ' [ "${6:-}" = "--integrity" ] && [ "${7:-}" = ' + + `${JSON.stringify(expectedIntegrity)} ] || return 84`, + ' [ "${8:-}" = "--tarball-url" ] && [ "${9:-}" = ' + + `${JSON.stringify(expectedTarball)} ] || return 85`, + ' [ "${10:-}" = "--label" ] && [ "${11:-}" = "mcporter ${MCPORTER_VERSION}" ] || return 86', + "}", extractIntegrityGate(contents), "printf 'gate-passed\\n'", ].join("\n"); @@ -68,7 +83,10 @@ describe("mcporter image supply-chain controls", () => { expect(contents).toContain(`ARG MCPORTER_VERSION=${expectedVersion}`); expect(contents).toContain(`ARG MCPORTER_0_7_3_INTEGRITY=${expectedIntegrity}`); - expect(contents).toContain('npm view "mcporter@${MCPORTER_VERSION}" dist.integrity'); + expect(contents).toContain(`ARG MCPORTER_0_7_3_TARBALL=${expectedTarball}`); + expect(flattenedContents).toContain( + '--verify-only --package-spec "mcporter@${MCPORTER_VERSION}" --integrity "$MCPORTER_EXPECTED_INTEGRITY" --tarball-url "$MCPORTER_EXPECTED_TARBALL"', + ); expect(contents).toContain( "COPY agents/openclaw/mcporter-runtime/package.json /usr/local/lib/nemoclaw/mcporter-runtime/package.json", ); diff --git a/test/messaging-build-applier-integrity.test.ts b/test/messaging-build-applier-integrity.test.ts index 988bdb0900..c57c2f6bc9 100644 --- a/test/messaging-build-applier-integrity.test.ts +++ b/test/messaging-build-applier-integrity.test.ts @@ -107,7 +107,7 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { const trace = fs.readFileSync(tracePath, "utf-8"); expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.integrity"); expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.tarball"); - expect(trace).toContain("npm|pack|@openclaw/slack@2026.6.10|--pack-destination"); + expect(trace).toContain(`npm|pack|${OPENCLAW_SLACK_2026_6_10_TARBALL}|--pack-destination`); expect(trace).toContain("openclaw|plugins|install|npm-pack:"); expect(trace).toContain("slack-2026.6.10.tgz|"); } finally { @@ -172,7 +172,7 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { ); expect(message).toContain(`Expected: ${OPENCLAW_SLACK_2026_6_10_TARBALL}`); expect(message).toContain( - "Actual: https://unexpected.invalid/openclaw/slack-2026.6.10.tgz", + "Actual: https://unexpected.invalid/openclaw/slack-2026.6.10.tgz", ); const trace = fs.readFileSync(tracePath, "utf-8"); expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.integrity"); @@ -222,10 +222,10 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { "OpenClaw plugin @openclaw/slack@2026.6.10 downloaded tarball integrity mismatch", ); expect(message).toContain(`Expected: ${OPENCLAW_SLACK_2026_6_10_INTEGRITY}`); - expect(message).toContain("Actual: sha512-packed-drift"); + expect(message).toContain("Actual: sha512-packed-drift"); const trace = fs.readFileSync(tracePath, "utf-8"); expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.integrity"); - expect(trace).toContain("npm|pack|@openclaw/slack@2026.6.10|--pack-destination"); + expect(trace).toContain(`npm|pack|${OPENCLAW_SLACK_2026_6_10_TARBALL}|--pack-destination`); expect(trace).not.toContain("openclaw|plugins|install"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); @@ -288,7 +288,7 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { ); const trace = fs.readFileSync(tracePath, "utf-8"); expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.integrity"); - expect(trace).toContain("npm|pack|@openclaw/slack@2026.6.10|--pack-destination"); + expect(trace).toContain(`npm|pack|${OPENCLAW_SLACK_2026_6_10_TARBALL}|--pack-destination`); expect(trace).not.toContain("openclaw|plugins|install"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index 8c05a6cf0c..5294f56112 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -60,11 +60,11 @@ function fakeOpenClawPluginNpmPackScriptLines(): string[] { 'if [ "${1:-}" = "pack" ]; then', ' pack_dir="${4:-}";', ' case "${2:-}" in', - ' "@openclaw/discord@2026.6.10") pack_file="discord-2026.6.10.tgz"; pack_integrity="${OPENCLAW_DISCORD_INTEGRITY:-${OPENCLAW_DISCORD_2026_6_10_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.6.10") pack_file="slack-2026.6.10.tgz"; pack_integrity="${OPENCLAW_SLACK_INTEGRITY:-${OPENCLAW_SLACK_2026_6_10_INTEGRITY:-}}" ;;', - ' "@openclaw/whatsapp@2026.6.10") pack_file="whatsapp-2026.6.10.tgz"; pack_integrity="${OPENCLAW_WHATSAPP_2026_6_10_INTEGRITY:-}" ;;', - ' "@openclaw/msteams@2026.6.10") pack_file="msteams-2026.6.10.tgz"; pack_integrity="${OPENCLAW_MSTEAMS_2026_6_10_INTEGRITY:-}" ;;', + ` "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.6.10.tgz") pack_file="discord-2026.6.10.tgz"; pack_integrity="\${OPENCLAW_DISCORD_INTEGRITY:-\${OPENCLAW_DISCORD_2026_6_10_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.6.10.tgz") pack_file="slack-2026.6.10.tgz"; pack_integrity="\${OPENCLAW_SLACK_INTEGRITY:-\${OPENCLAW_SLACK_2026_6_10_INTEGRITY:-}}" ;;`, + ` "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.6.10.tgz") pack_file="whatsapp-2026.6.10.tgz"; pack_integrity="\${OPENCLAW_WHATSAPP_2026_6_10_INTEGRITY:-}" ;;`, + ` "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.6.10.tgz") pack_file="msteams-2026.6.10.tgz"; pack_integrity="\${OPENCLAW_MSTEAMS_2026_6_10_INTEGRITY:-}" ;;`, " *) exit 1 ;;", " esac", ' test -n "$pack_dir"; test -n "$pack_integrity";', @@ -605,7 +605,9 @@ 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.6.10|dist.integrity"); - expect(trace).toContain("npm|pack|@openclaw/discord@2026.6.10|--pack-destination"); + expect(trace).toContain( + "npm|pack|https://registry.npmjs.org/@openclaw/discord/-/discord-2026.6.10.tgz|--pack-destination", + ); expect(trace).toContain("plugins|install|npm-pack:"); expect(trace).toContain("discord-2026.6.10.tgz|ignore-scripts=true/true"); } finally { @@ -633,7 +635,7 @@ describe("messaging-build-applier.mts: agent-install", () => { " process.stdout.write('https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.6.10.tgz\\n');", " process.exit(0);", "}", - "if (command === 'pack' && packageSpec === '@openclaw/msteams@2026.6.10') {", + "if (command === 'pack' && packageSpec === 'https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.6.10.tgz') {", " const packFile = 'msteams-2026.6.10.tgz';", " fs.writeFileSync(path.join(destination, packFile), 'fake plugin tarball');", " process.stdout.write(JSON.stringify([{ filename: packFile, integrity: process.env.OPENCLAW_MSTEAMS_2026_6_10_INTEGRITY }]) + '\\n');", @@ -673,7 +675,9 @@ describe("messaging-build-applier.mts: agent-install", () => { const trace = fs.readFileSync(tracePath, "utf-8"); expect(trace).toContain("npm|view|@openclaw/msteams@2026.6.10|dist.integrity"); expect(trace).toContain("npm|view|@openclaw/msteams@2026.6.10|dist.tarball"); - expect(trace).toContain("npm|pack|@openclaw/msteams@2026.6.10|--pack-destination"); + expect(trace).toContain( + "npm|pack|https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.6.10.tgz|--pack-destination", + ); expect(trace).toContain("openclaw|plugins|install|npm-pack:"); expect(trace).toContain("msteams-2026.6.10.tgz|"); } finally { @@ -855,16 +859,36 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(applyMessagingBuildPhase(plan, "agent-install", planEnv)).toEqual([]); const trace = fs.readFileSync(tracePath, "utf-8"); - for (const [packageSpec, archiveName] of [ - ["@openclaw/discord@2026.6.10", "discord-2026.6.10.tgz"], - ["@tencent-weixin/openclaw-weixin@2.4.3", "openclaw-weixin-2.4.3.tgz"], - ["@openclaw/slack@2026.6.10", "slack-2026.6.10.tgz"], - ["@openclaw/whatsapp@2026.6.10", "whatsapp-2026.6.10.tgz"], - ["@openclaw/msteams@2026.6.10", "msteams-2026.6.10.tgz"], + for (const [packageSpec, tarballUrl, archiveName] of [ + [ + "@openclaw/discord@2026.6.10", + "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.6.10.tgz", + "discord-2026.6.10.tgz", + ], + [ + "@tencent-weixin/openclaw-weixin@2.4.3", + "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz", + "openclaw-weixin-2.4.3.tgz", + ], + [ + "@openclaw/slack@2026.6.10", + "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.6.10.tgz", + "slack-2026.6.10.tgz", + ], + [ + "@openclaw/whatsapp@2026.6.10", + "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.6.10.tgz", + "whatsapp-2026.6.10.tgz", + ], + [ + "@openclaw/msteams@2026.6.10", + "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.6.10.tgz", + "msteams-2026.6.10.tgz", + ], ] as const) { expect(trace).toContain(`npm|view|${packageSpec}|dist.integrity`); expect(trace).toContain(`npm|view|${packageSpec}|dist.tarball`); - expect(trace).toContain(`npm|pack|${packageSpec}|--pack-destination`); + expect(trace).toContain(`npm|pack|${tarballUrl}|--pack-destination`); expect(trace).toContain("plugins|install|npm-pack:"); expect(trace).toContain(`${archiveName}||||`); } @@ -918,7 +942,9 @@ describe("messaging-build-applier.mts: agent-install", () => { const trace = fs.readFileSync(tracePath, "utf-8"); expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.integrity"); expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.tarball"); - expect(trace).toContain("npm|pack|@openclaw/slack@2026.6.10|--pack-destination"); + expect(trace).toContain( + "npm|pack|https://registry.npmjs.org/@openclaw/slack/-/slack-2026.6.10.tgz|--pack-destination", + ); expect(trace).toContain("openclaw|plugins|install|npm-pack:"); expect(trace).toContain("slack-2026.6.10.tgz|"); } finally { @@ -966,7 +992,7 @@ describe("messaging-build-applier.mts: agent-install", () => { const message = thrownMessage(() => applyMessagingBuildPhase(plan, "agent-install", env)); expect(message).toContain("OpenClaw plugin @openclaw/slack@2026.6.10 npm integrity mismatch"); expect(message).toContain(`Expected: ${OPENCLAW_SLACK_2026_6_10_INTEGRITY}`); - expect(message).toContain("Actual: sha512-drift"); + expect(message).toContain("Actual: sha512-drift"); expect(fs.readFileSync(tracePath, "utf-8").trim()).toBe( "npm|view|@openclaw/slack@2026.6.10|dist.integrity", ); @@ -1132,7 +1158,9 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(postInstallResult.status, postInstallResult.stderr).toBe(0); const trace = fs.readFileSync(tracePath, "utf-8"); expect(trace).toContain("npm|view|@openclaw/discord@2026.6.10|dist.integrity||"); - expect(trace).toContain("npm|pack|@openclaw/discord@2026.6.10|--pack-destination||"); + expect(trace).toContain( + "npm|pack|https://registry.npmjs.org/@openclaw/discord/-/discord-2026.6.10.tgz|--pack-destination||", + ); expect(trace).toContain("plugins|install|npm-pack:"); expect(trace).toContain("discord-2026.6.10.tgz||"); expect(trace).toContain( diff --git a/test/openclaw-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index 9f8d9769cb..1cc901b685 100644 --- a/test/openclaw-dependency-review.test.ts +++ b/test/openclaw-dependency-review.test.ts @@ -136,9 +136,7 @@ describe("OpenClaw 2026.6.10 dependency review contract", () => { expect(review).toContain("downloaded tarball integrity"); expect(review).toContain("npm pack --json"); expect(review).toContain("install the verified archive path"); - expect(review).toContain( - "reported filename must be contained inside the freshly created pack directory", - ); + expect(review).toContain("contained regular-file basename in a fresh directory"); expect(review).toContain("unsafe reported archive filenames"); expect(review).toContain("no installer code consumes raw `npm pack --json` filenames"); expect(review).toContain("The #4434 compatibility-shim disposition is explicitly accepted"); @@ -224,14 +222,14 @@ describe("OpenClaw 2026.6.10 dependency review contract", () => { expect(review).toContain("Issue #4434 full live acceptance"); expect(review).toContain("code-backed for the reviewed `openclaw@2026.6.10` artifact"); expect(review).toContain("src/lib/messaging/channels/manifests.test.ts"); - expect(review).toContain("npm audit result in this note is a manual snapshot"); + expect(review).toContain("npm audit result in this note remains a point-in-time snapshot"); expect(review).toContain("Advisory audit revalidated: 2026-07-03"); expect(review).toContain("0` critical vulnerabilities across `763` total dependencies"); expect(review).toContain("Node `v22.22.2`"); expect(review).toContain("engine requirement of `>=22.19.0`"); - expect(review).toContain( - "CI job for `npm install --package-lock-only --ignore-scripts && npm audit --omit=dev --json`", - ); + expect(review).toContain("Default PR and main CI now rematerialize"); + expect(review).toContain("`npm audit --omit=dev --json`"); + expect(review).toContain("configured threshold in `ci/reviewed-npm-audit.json` is `high`"); expect(review).toContain("Transitive Dependency Graph Rationale"); expect(review).toContain( "The OpenClaw 2026.6.10 bump does not newly introduce an unfrozen OpenClaw transitive graph", @@ -283,13 +281,13 @@ describe("OpenClaw 2026.6.10 dependency review contract", () => { expect(review).toContain("issue #5896"); expect(review).toContain("route-provenance additions remain with their"); expect(review).toContain("`src/lib/state/sandbox.ts` is 100 lines smaller"); - expect(review).toContain("shared archive-installer redesign remains explicitly deferred"); - expect(review).toContain("Deferred #5896 Archive Consolidation Contract"); + expect(review).toContain("Shared #5896 Archive and Audit Contract"); + expect(review).toContain("`scripts/lib/reviewed-npm-archive.mts`"); expect(review).toContain("protected exact provenance marker"); - expect(review).toContain("mcporter package, SRI, lockfile SHA-256"); + expect(review).toContain("mcporter package, SRI, tarball URL, lockfile SHA-256"); expect(review).toContain("removes the marker before applying NemoClaw patches"); expect(review).toContain("fifteen fallback states"); - expect(review).toContain("issue #5896 section 2"); + expect(review).toContain("Issue #5896 section 2"); expect(review).toContain("issue #5896 section 9"); expect(review).toContain("direct source- and target-traversal vectors"); expect(review).toContain("Live gateway display output is treated as untrusted text"); @@ -303,7 +301,7 @@ describe("OpenClaw 2026.6.10 dependency review contract", () => { expect(review).toContain("test/onboard-resume-provider-recovery.test.ts"); }); - it("keeps every reviewed archive boundary on the deferred invariant matrix (#5896)", () => { + it("keeps every reviewed archive boundary on the shared invariant matrix (#5896)", () => { const result = spawnSync( "bash", [ @@ -312,9 +310,10 @@ describe("OpenClaw 2026.6.10 dependency review contract", () => { set -euo pipefail messaging_build_applier=${JSON.stringify(MESSAGING_BUILD_APPLIER)} +reviewed_archive_helper=scripts/lib/reviewed-npm-archive.mts boundary_marker_count="$(grep -hF 'Reviewed-archive invariants (#5896):' Dockerfile Dockerfile.base "$messaging_build_applier" | wc -l | tr -d ' ')" -test "$boundary_marker_count" -eq 5 +test "$boundary_marker_count" -eq 4 check_contains() { haystack="$1" @@ -326,16 +325,25 @@ check_contains() { esac } +check_not_contains() { + haystack="$1" + needle="$2" + label="$3" + case "$haystack" in + *"$needle"*) echo "superseded $label remains: $needle" >&2; exit 1 ;; + *) ;; + esac +} + codex_acp_block="$(sed -n '/# Pre-install the codex-acp package/,/# Upgrade OpenClaw if the base image is stale./p' Dockerfile)" check_contains "$codex_acp_block" "CODEX_ACP_TARBALL='${CODEX_ACP_TARBALL}'" "codex-acp tarball" -check_contains "$codex_acp_block" 'npm view "\${CODEX_ACP_SPEC}" dist.integrity' "codex-acp registry integrity" -check_contains "$codex_acp_block" 'npm view "\${CODEX_ACP_SPEC}" dist.tarball' "codex-acp registry tarball" -check_contains "$codex_acp_block" 'npm pack "$pack_spec" --pack-destination "$pack_dir" --json' "codex-acp pack" -check_contains "$codex_acp_block" 'CODEX_ACP_PACK_PATH="$(pack_reviewed_npm_tarball "$CODEX_ACP_TARBALL" "$CODEX_ACP_0_11_1_INTEGRITY" "$CODEX_ACP_PACK_DIR" "$CODEX_ACP_SPEC")"' "codex-acp pack path" +check_contains "$codex_acp_block" '/scripts/lib/reviewed-npm-archive.mts' "codex-acp shared helper" +check_contains "$codex_acp_block" '--package-spec "$CODEX_ACP_SPEC" --integrity "$CODEX_ACP_0_11_1_INTEGRITY"' "codex-acp reviewed identity" +check_contains "$codex_acp_block" '--tarball-url "$CODEX_ACP_TARBALL"' "codex-acp reviewed tarball" check_contains "$codex_acp_block" '"$CODEX_ACP_PACK_PATH"' "codex-acp local install path" -check_contains "$codex_acp_block" 'reported unsafe archive filename' "codex-acp unsafe filename guard" -check_contains "$codex_acp_block" 'CODEX_ACP_PACK_DIR="$(mktemp -d)"' "codex-acp fresh pack directory" +check_contains "$codex_acp_block" 'CODEX_ACP_PACK_DIR="$(dirname "$CODEX_ACP_PACK_PATH")"' "codex-acp pack directory" check_contains "$codex_acp_block" 'rm -rf "$CODEX_ACP_PACK_DIR"' "codex-acp cleanup" +check_not_contains "$codex_acp_block" 'pack_reviewed_npm_tarball' "codex-acp inline pack helper" for dockerfile in Dockerfile Dockerfile.base; do case "$dockerfile" in @@ -344,13 +352,17 @@ for dockerfile in Dockerfile Dockerfile.base; do esac openclaw_block="$(sed -n "/ARG OPENCLAW_VERSION=2026.6.10/,/$end_marker/p" "$dockerfile")" check_contains "$openclaw_block" "ARG OPENCLAW_2026_6_10_TARBALL=${OPENCLAW_TARBALL}" "$dockerfile tarball arg" - check_contains "$openclaw_block" 'npm view "openclaw@\${OPENCLAW_VERSION}" dist.integrity' "$dockerfile registry integrity" - check_contains "$openclaw_block" 'npm view "openclaw@\${OPENCLAW_VERSION}" dist.tarball' "$dockerfile registry tarball" - check_contains "$openclaw_block" 'OPENCLAW_PACK_PATH="$(pack_reviewed_npm_tarball "$EXPECTED_TARBALL" "$EXPECTED_INTEGRITY" "$OPENCLAW_PACK_DIR"' "$dockerfile pack path" + check_contains "$openclaw_block" '/scripts/lib/reviewed-npm-archive.mts' "$dockerfile shared helper" + check_contains "$openclaw_block" '--package-spec "openclaw@\${OPENCLAW_VERSION}" --integrity "$EXPECTED_INTEGRITY"' "$dockerfile reviewed identity" + check_contains "$openclaw_block" '--tarball-url "$EXPECTED_TARBALL"' "$dockerfile reviewed tarball" check_contains "$openclaw_block" '"$OPENCLAW_PACK_PATH"' "$dockerfile local install path" - check_contains "$openclaw_block" 'reported unsafe archive filename' "$dockerfile unsafe filename guard" - check_contains "$openclaw_block" 'OPENCLAW_PACK_DIR="$(mktemp -d)"' "$dockerfile fresh pack directory" + check_contains "$openclaw_block" 'OPENCLAW_PACK_DIR="$(dirname "$OPENCLAW_PACK_PATH")"' "$dockerfile pack directory" + if [ "$dockerfile" = Dockerfile.base ]; then + check_contains "$openclaw_block" '[ ! -f "$OPENCLAW_PACK_PATH" ]' "$dockerfile archive path guard" + fi check_contains "$openclaw_block" 'rm -rf "$OPENCLAW_PACK_DIR"' "$dockerfile cleanup" + check_not_contains "$openclaw_block" 'REGISTRY_INTEGRITY=$(npm view' "$dockerfile inline integrity lookup" + check_not_contains "$openclaw_block" 'pack_reviewed_npm_tarball' "$dockerfile inline pack helper" check_contains "$openclaw_block" 'openclaw-base-provenance-v1' "$dockerfile base provenance path" check_contains "$openclaw_block" 'recipe=ignore-scripts+reviewed-lifecycle-v1' "$dockerfile base provenance recipe" check_contains "$openclaw_block" 'mcporter-package=mcporter@' "$dockerfile mcporter provenance package" @@ -365,22 +377,24 @@ check_contains "$(cat Dockerfile)" '0:0:444' "runtime provenance exact metadata" check_contains "$(cat Dockerfile)" 'rm -rf "$OPENCLAW_PROVENANCE_PATH"' "runtime provenance consumption" optional_plugin_block="$(sed -n '/# Install non-messaging OpenClaw plugins that need to match the runtime./,/^RUN OPENCLAW_VERSION=/p' Dockerfile)" -check_contains "$optional_plugin_block" 'npm view "$plugin_spec" dist.integrity' "optional plugin registry integrity" -check_contains "$optional_plugin_block" 'npm view "$plugin_spec" dist.tarball' "optional plugin registry tarball" -check_contains "$optional_plugin_block" 'npm pack "$expected_tarball" --pack-destination "$NEMOCLAW_OPENCLAW_PLUGIN_PACK_DIR" --json' "optional plugin pack" +check_contains "$optional_plugin_block" '/scripts/lib/reviewed-npm-archive.mts' "optional plugin shared helper" +check_contains "$optional_plugin_block" '--package-spec "$plugin_spec" --integrity "$expected_integrity"' "optional plugin reviewed identity" +check_contains "$optional_plugin_block" '--tarball-url "$expected_tarball"' "optional plugin reviewed tarball" check_contains "$optional_plugin_block" 'openclaw plugins install "npm-pack:\${plugin_archive}"' "optional plugin npm-pack install" -check_contains "$optional_plugin_block" 'reported unsafe archive filename' "optional plugin unsafe filename guard" -check_contains "$optional_plugin_block" 'NEMOCLAW_OPENCLAW_PLUGIN_PACK_DIR="$(mktemp -d)"' "optional plugin fresh pack directory" -check_contains "$optional_plugin_block" 'rm -rf "$NEMOCLAW_OPENCLAW_PLUGIN_PACK_DIR"' "optional plugin cleanup" +check_contains "$optional_plugin_block" 'rm -rf "$(dirname "$plugin_archive")"' "optional plugin cleanup" +check_not_contains "$optional_plugin_block" 'pack_reviewed_npm_tarball' "optional plugin inline pack helper" - grep -Fq 'spawnSync("npm", ["pack", packageSpec, "--pack-destination", rootDir, "--json"]' "$messaging_build_applier" + grep -Fq 'packReviewedNpmArchive({' "$messaging_build_applier" grep -Fq '["openclaw", "plugins", "install", \`npm-pack:\${packed.archivePath}\`]' "$messaging_build_applier" - grep -Fq 'OPENCLAW_MESSAGING_PLUGIN_ARCHIVE_PROVENANCE_POLICY.registryIntegrityField' "$messaging_build_applier" - grep -Fq 'downloaded tarball integrity mismatch' "$messaging_build_applier" - grep -Fq 'mkdtempSync(join(tmpdir(), "nemoclaw-openclaw-plugin-pack-"))' "$messaging_build_applier" - grep -Fq 'rmSync(rootDir, { recursive: true, force: true })' "$messaging_build_applier" - grep -Fq 'resolveNpmPackArchivePath(packageSpec, rootDir, filename)' "$messaging_build_applier" - grep -Fq 'reported unsafe archive filename' "$messaging_build_applier" + grep -Fq 'rmSync(packed.rootDir, { recursive: true, force: true })' "$messaging_build_applier" + grep -Fq 'from "../../../../../scripts/lib/reviewed-npm-archive.mts"' "$messaging_build_applier" + 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 'reported unsafe archive filename' "$reviewed_archive_helper" + ! grep -Fq 'npmViewString(' "$messaging_build_applier" + ! grep -Fq 'resolveNpmPackArchivePath(' "$messaging_build_applier" issue_4434_patch=${JSON.stringify(ISSUE_4434_PATCH)} grep -Fq 'formatRawAssistantErrorForUi' "$issue_4434_patch" grep -Fq 'OPENSHELL_SANDBOX !== "1"' "$issue_4434_patch" diff --git a/test/openclaw-integrity-pin-suite.ts b/test/openclaw-integrity-pin-suite.ts index d8789d4a5c..b351e83e6b 100644 --- a/test/openclaw-integrity-pin-suite.ts +++ b/test/openclaw-integrity-pin-suite.ts @@ -33,6 +33,12 @@ const PRODUCTION_BUILD_ARG_GUARD = path.join( "scripts", "check-production-build-args.sh", ); +const REVIEWED_NPM_ARCHIVE_HELPER = path.join( + REPO_ROOT, + "scripts", + "lib", + "reviewed-npm-archive.mts", +); const UNPINNED_OPENCLAW_VERSION = "2026.6.11"; const PINNED_OPENCLAW_VERSION = "2026.6.10"; const PINNED_OPENCLAW_INTEGRITY = @@ -46,6 +52,7 @@ const PINNED_CODEX_ACP_INTEGRITY = const PINNED_MCPORTER_VERSION = "0.7.3"; const PINNED_MCPORTER_INTEGRITY = "sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA=="; +const PINNED_MCPORTER_TARBALL = "https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz"; const MCPORTER_LOCKFILE = path.join( REPO_ROOT, "agents", @@ -99,6 +106,7 @@ function openClawBaseProvenance( "recipe=ignore-scripts+reviewed-lifecycle-v1", `mcporter-package=mcporter@${PINNED_MCPORTER_VERSION}`, `mcporter-integrity=${PINNED_MCPORTER_INTEGRITY}`, + `mcporter-tarball=${PINNED_MCPORTER_TARBALL}`, `mcporter-lock-sha256=${PINNED_MCPORTER_LOCK_SHA256}`, "mcporter-recipe=locked-ci+audit-signatures-v1", "", @@ -173,10 +181,45 @@ function runInstallBlock( const provenancePath = path.join(tmp, "openclaw-base-provenance-v1"); const mcporterRuntime = path.join(tmp, "mcporter-runtime"); const mcporterBin = path.join(tmp, "bin", "mcporter"); + const reviewedNpmExecutable = path.join(tmp, "bin", "reviewed-npm-fixture"); fs.mkdirSync(path.dirname(mcporterBin), { recursive: true }); fs.mkdirSync(mcporterRuntime, { recursive: true }); fs.copyFileSync(MCPORTER_LOCKFILE, path.join(mcporterRuntime, "package-lock.json")); fs.writeFileSync(blueprint, fs.readFileSync(BLUEPRINT, "utf-8")); + fs.writeFileSync( + reviewedNpmExecutable, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `printf 'npm %s\\n' "$*" >> ${JSON.stringify(log)}`, + 'if [ "${1:-}" = "view" ]; then', + ` if [ "\${2:-}" = "@zed-industries/codex-acp@${PINNED_CODEX_ACP_VERSION}" ]; then`, + ` if [ "\${3:-}" = "dist.integrity" ]; then printf '%s\\n' ${JSON.stringify(codexAcpRegistryIntegrity)}; else printf '%s\\n' ${JSON.stringify(codexAcpRegistryTarball)}; fi`, + ` elif [ "\${2:-}" = "mcporter@${PINNED_MCPORTER_VERSION}" ]; then`, + ` if [ "\${3:-}" = "dist.integrity" ]; then printf '%s\\n' ${JSON.stringify(PINNED_MCPORTER_INTEGRITY)}; else printf '%s\\n' ${JSON.stringify(PINNED_MCPORTER_TARBALL)}; fi`, + " else", + ` if [ "\${3:-}" = "dist.integrity" ]; then printf '%s\\n' ${JSON.stringify(registryIntegrity)}; else printf '%s\\n' ${JSON.stringify(registryTarball)}; fi`, + " fi", + " exit 0", + "fi", + '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")"', + ` reported_pack_file=${JSON.stringify(packFilename ?? "")}`, + ...(packFilename === null + ? [] + : [' reported_pack_file="${reported_pack_file:-$pack_file}"']), + ' printf "fake tarball" > "$pack_dir/$pack_file"', + ` case "$pack_spec" in *"codex-acp"*) pack_integrity=${JSON.stringify(codexAcpPackIntegrity)} ;; *) pack_integrity=${JSON.stringify(packIntegrity)} ;; esac`, + ' printf \'[{"filename":"%s","integrity":"%s"}]\\n\' "$reported_pack_file" "$pack_integrity"', + " exit 0", + "fi", + "exit 1", + "", + ].join("\n"), + { mode: 0o755 }, + ); const writeProvenanceFile = () => { fs.writeFileSync(provenancePath, baseProvenance as string, { mode: 0o444 }); }; @@ -209,6 +252,9 @@ function runInstallBlock( `CODEX_ACP_0_11_1_INTEGRITY=${JSON.stringify(codexAcpCommittedIntegrity)}`, `MCPORTER_VERSION=${JSON.stringify(PINNED_MCPORTER_VERSION)}`, `MCPORTER_0_7_3_INTEGRITY=${JSON.stringify(PINNED_MCPORTER_INTEGRITY)}`, + `MCPORTER_0_7_3_TARBALL=${JSON.stringify(PINNED_MCPORTER_TARBALL)}`, + `export NEMOCLAW_REVIEWED_NPM_EXECUTABLE=${JSON.stringify(reviewedNpmExecutable)}`, + "export NODE_OPTIONS=", `installed_openclaw_version=${JSON.stringify(installedOpenClawVersion)}`, `installed_mcporter_version=${JSON.stringify(installedMcporterVersion)}`, "node() {", @@ -257,7 +303,8 @@ function runInstallBlock( .replaceAll("/tmp/blueprint.yaml", blueprint) .replaceAll(OPENCLAW_BASE_PROVENANCE_PATH, provenancePath) .replaceAll("/usr/local/lib/nemoclaw/mcporter-runtime", mcporterRuntime) - .replaceAll("/usr/local/bin/mcporter", mcporterBin), + .replaceAll("/usr/local/bin/mcporter", mcporterBin) + .replaceAll("/scripts/lib/reviewed-npm-archive.mts", REVIEWED_NPM_ARCHIVE_HELPER), ].join("\n"); const scriptPath = path.join(tmp, "run.sh"); fs.writeFileSync(scriptPath, script, { mode: 0o700 }); @@ -323,6 +370,37 @@ function runOptionalOpenClawPluginBlock( ); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-plugin-integrity-")); const log = path.join(tmp, "calls.log"); + const reviewedNpmExecutable = path.join(tmp, "reviewed-npm-fixture"); + fs.writeFileSync( + reviewedNpmExecutable, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `printf 'npm %s\\n' "$*" >> ${JSON.stringify(log)}`, + '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")"', + ` reported_pack_file=${JSON.stringify(pluginPackFilename)}`, + ' reported_pack_file="${reported_pack_file:-$pack_file}"', + ' printf "fake plugin tarball" > "$pack_dir/$pack_file"', + ' case "$pack_spec" in', + ` *"diagnostics-otel"*) printf '[{"filename":"%s","integrity":"%s"}]\\n' "$reported_pack_file" ${JSON.stringify(diagnosticsRegistryIntegrity)} ;;`, + ` *"brave-plugin"*) printf '[{"filename":"%s","integrity":"%s"}]\\n' "$reported_pack_file" ${JSON.stringify(braveRegistryIntegrity)} ;;`, + " *) exit 1 ;;", + " esac", + " exit 0", + "fi", + 'if [ "${1:-}" != "view" ]; then exit 1; fi', + 'case "${2:-}" in', + ` "@openclaw/diagnostics-otel@${PINNED_OPENCLAW_VERSION}") if [ "\${3:-}" = "dist.integrity" ]; then printf '%s\\n' ${JSON.stringify(diagnosticsRegistryIntegrity)}; else printf '%s\\n' ${JSON.stringify(diagnosticsRegistryTarball)}; fi ;;`, + ` "@openclaw/brave-plugin@${PINNED_OPENCLAW_VERSION}") if [ "\${3:-}" = "dist.integrity" ]; then printf '%s\\n' ${JSON.stringify(braveRegistryIntegrity)}; else printf '%s\\n' ${JSON.stringify(braveRegistryTarball)}; fi ;;`, + " *) exit 1 ;;", + "esac", + "", + ].join("\n"), + { mode: 0o755 }, + ); const script = [ "#!/usr/bin/env bash", "set -euo pipefail", @@ -332,6 +410,8 @@ function runOptionalOpenClawPluginBlock( `OPENCLAW_BRAVE_PLUGIN_2026_6_10_INTEGRITY=${JSON.stringify(PINNED_OPENCLAW_BRAVE_PLUGIN_INTEGRITY)}`, `NEMOCLAW_OPENCLAW_OTEL=${otel ? "1" : "0"}`, `NEMOCLAW_WEB_SEARCH_ENABLED=${webSearch ? "1" : "0"}`, + `export NEMOCLAW_REVIEWED_NPM_EXECUTABLE=${JSON.stringify(reviewedNpmExecutable)}`, + "export NODE_OPTIONS=", 'openclaw() { printf \'openclaw %s\\nopenclaw-env %s %s\\n\' "$*" "${NPM_CONFIG_IGNORE_SCRIPTS:-}" "${npm_config_ignore_scripts:-}" >> "$call_log"; }', "npm() {", ' printf "npm %s\\n" "$*" >> "$call_log";', @@ -358,7 +438,7 @@ function runOptionalOpenClawPluginBlock( " esac", " return 1", "}", - command, + command.replaceAll("/scripts/lib/reviewed-npm-archive.mts", REVIEWED_NPM_ARCHIVE_HELPER), ].join("\n"); const scriptPath = path.join(tmp, "run.sh"); fs.writeFileSync(scriptPath, script, { mode: 0o700 }); @@ -399,10 +479,10 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(reviewNote).toContain("downloaded tarball integrity"); expect(reviewNote).toContain("bind reviewed npm installs to verified local archives"); expect(reviewNote).toContain("npm pack --json"); - expect(reviewNote).toContain("reject reported archive filenames"); - expect(reviewNote).toContain("unsafe reported archive filenames"); + expect(reviewNote).toContain("rejects reported archive filenames"); + expect(reviewNote).toContain("unsafe archive paths"); expect(reviewNote).toContain("each reviewed npm plugin registry integrity"); - expect(reviewNote).toContain("install the verified archive path"); + expect(reviewNote).toContain("returns only the verified local `.tgz` path"); expect(reviewNote).toContain("OpenClaw Compiled-Dist Patch Runtime Boundary"); expect(reviewNote).toContain( "The long-term source of truth for these behaviors remains upstream OpenClaw", @@ -800,6 +880,15 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes ), }, ], + [ + "wrong mcporter tarball", + { + baseProvenance: openClawBaseProvenance().replace( + `mcporter-tarball=${PINNED_MCPORTER_TARBALL}`, + "mcporter-tarball=https://registry.npmjs.org/mcporter/-/mcporter-0.7.2.tgz", + ), + }, + ], [ "wrong mcporter lock", { @@ -974,7 +1063,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect( `${item.outcome.result.stdout}${item.outcome.result.stderr}`, item.label, - ).toContain(`npm pack reported unsafe archive filename: ${item.unsafeFilename}`); + ).toContain(`reported unsafe archive filename: ${item.unsafeFilename}`); expect(item.outcome.calls, item.label).toContain("npm pack"); expect(item.outcome.calls, item.label).not.toContain(item.blockedCommand); } @@ -994,7 +1083,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes packFilename: null, }, ); - const diagnostic = `OpenClaw ${PINNED_OPENCLAW_VERSION} npm pack did not report filename and integrity`; + const diagnostic = `npm pack openclaw@${PINNED_OPENCLAW_VERSION} did not report filename and integrity`; expect(result.status).not.toBe(0); expect(result.stderr).toContain(diagnostic); @@ -1163,6 +1252,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes "CODEX_ACP_0_11_1_INTEGRITY", "HERMES_NPM_INTEGRITY", "MCPORTER_0_7_3_INTEGRITY", + "MCPORTER_0_7_3_TARBALL", "OPENCLAW_2026_3_11_INTEGRITY", "OPENCLAW_2026_3_11_TARBALL", "OPENCLAW_2026_4_24_INTEGRITY", diff --git a/test/openclaw-optional-plugin-build.test.ts b/test/openclaw-optional-plugin-build.test.ts new file mode 100644 index 0000000000..67d4c732ee --- /dev/null +++ b/test/openclaw-optional-plugin-build.test.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { writeReviewedNpmFixture } from "./helpers/reviewed-npm-fixture"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const BRAVE_INTEGRITY = + "sha512-DDRnb4reL99O8kbISNbRFyk/xoUPYHsXG3UGikKAsVs+zIldYYA0hY0d3Z2aWoE+0vfda27mJUByCo7Xr15qdw=="; +const BRAVE_TARBALL = + "https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.6.10.tgz"; + +it("pins Brave web-search and preserves its placeholder during build-time doctor", () => { + const dockerfile = fs.readFileSync(path.join(ROOT, "Dockerfile"), "utf-8"); + const start = dockerfile.indexOf("# Install non-messaging OpenClaw plugins"); + const end = dockerfile.indexOf( + 'RUN OPENCLAW_VERSION="${OPENCLAW_VERSION}" node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent openclaw --phase agent-install', + start, + ); + const command = dockerfile + .slice(dockerfile.indexOf("RUN ", start) + 4, end) + .split("\n") + .filter((line) => !line.trimStart().startsWith("#")) + .join("\n") + .replace(/\\\s*\n/g, " ") + .trim(); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-brave-plugin-install-")); + const log = path.join(tmp, "calls.log"); + try { + const npmFixture = path.join(tmp, "npm-fixture"); + writeReviewedNpmFixture(npmFixture, log, [ + { + integrity: BRAVE_INTEGRITY, + packageSpec: "@openclaw/brave-plugin@2026.6.10", + tarballUrl: BRAVE_TARBALL, + }, + ]); + const script = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `call_log=${JSON.stringify(log)}`, + 'openclaw() { printf "%s|BRAVE_API_KEY=%s\\n" "$*" "${BRAVE_API_KEY:-}" >> "$call_log"; }', + command.replaceAll( + "/scripts/lib/reviewed-npm-archive.mts", + path.join(ROOT, "scripts", "lib", "reviewed-npm-archive.mts"), + ), + ].join("\n"); + const scriptPath = path.join(tmp, "run.sh"); + fs.writeFileSync(scriptPath, script, { mode: 0o700 }); + const result = spawnSync("bash", [scriptPath], { + encoding: "utf-8", + env: { + ...process.env, + NEMOCLAW_OPENCLAW_OTEL: "0", + NEMOCLAW_REVIEWED_NPM_EXECUTABLE: npmFixture, + NEMOCLAW_WEB_SEARCH_ENABLED: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "brave", + NODE_OPTIONS: "", + OPENCLAW_BRAVE_PLUGIN_2026_6_10_INTEGRITY: BRAVE_INTEGRITY, + OPENCLAW_VERSION: "2026.6.10", + }, + }); + const calls = fs.readFileSync(log, "utf-8"); + expect(result.status, result.stderr).toBe(0); + expect(calls).toContain("npm view @openclaw/brave-plugin@2026.6.10 dist.integrity"); + expect(calls).toContain(`npm pack ${BRAVE_TARBALL} --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", + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/test/pr-workflow-contract.test.ts b/test/pr-workflow-contract.test.ts index 1b423b633d..d4128d2ac0 100644 --- a/test/pr-workflow-contract.test.ts +++ b/test/pr-workflow-contract.test.ts @@ -992,6 +992,7 @@ describe("pull request and main workflow contracts", () => { E2E_SUPPORT_RESULT: "success", INSTALLER_INTEGRATION_RESULT: "success", PLUGIN_TESTS_RESULT: "success", + REVIEWED_NPM_AUDIT_RESULT: "success", STATIC_RESULT: "success", }; const successfulMain = { @@ -1001,6 +1002,7 @@ describe("pull request and main workflow contracts", () => { E2E_SUPPORT_RESULT: "success", INSTALLER_INTEGRATION_RESULT: "success", PLUGIN_TESTS_RESULT: "success", + REVIEWED_NPM_AUDIT_RESULT: "success", REAL_OPENCLAW_DIST_HARNESS_RESULT: "success", STATIC_RESULT: "success", }; @@ -1020,6 +1022,7 @@ describe("pull request and main workflow contracts", () => { E2E_SUPPORT_RESULT: "skipped", INSTALLER_INTEGRATION_RESULT: "skipped", PLUGIN_TESTS_RESULT: "skipped", + REVIEWED_NPM_AUDIT_RESULT: "skipped", STATIC_RESULT: "skipped", }); const mainSuccess = runWorkflowShellStep(mainGate, successfulMain); diff --git a/test/reviewed-npm-archive.test.ts b/test/reviewed-npm-archive.test.ts new file mode 100644 index 0000000000..596db0a22a --- /dev/null +++ b/test/reviewed-npm-archive.test.ts @@ -0,0 +1,125 @@ +// 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 { afterEach, describe, expect, it } from "vitest"; +import { + packReviewedNpmArchive, + removeReviewedNpmArchive, + type ReviewedNpmArchiveRequest, + resolveReviewedNpmArchivePath, + verifyReviewedNpmMetadata, +} from "../scripts/lib/reviewed-npm-archive.mts"; + +const INTEGRITY = `sha512-${"a".repeat(88)}`; +const PACKAGE_SPEC = "@example/reviewed@1.2.3"; +const TARBALL_URL = "https://registry.npmjs.org/@example/reviewed/-/reviewed-1.2.3.tgz"; +const roots: string[] = []; + +function request(): ReviewedNpmArchiveRequest { + const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-npm-archive-test-")); + roots.push(tempDirectory); + return { + expectedIntegrity: INTEGRITY, + label: `reviewed package ${PACKAGE_SPEC}`, + packageSpec: PACKAGE_SPEC, + tarballUrl: TARBALL_URL, + tempDirectory, + }; +} + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("reviewed npm archive", () => { + it("verifies exact registry metadata and returns only a contained local archive", () => { + const calls: string[][] = []; + const archive = packReviewedNpmArchive(request(), (args, reviewed) => { + calls.push([...args]); + const metadata = new Map([ + ["view|dist.integrity", `${INTEGRITY}\n`], + ["view|dist.tarball", `${TARBALL_URL}\n`], + ]).get(`${args[0]}|${args[2]}`); + return ( + metadata ?? + (() => { + const destination = args[3] as string; + fs.writeFileSync(path.join(destination, "reviewed-1.2.3.tgz"), "reviewed bytes"); + return JSON.stringify([ + { filename: "reviewed-1.2.3.tgz", integrity: reviewed.expectedIntegrity }, + ]); + })() + ); + }); + + expect(calls).toEqual([ + ["view", PACKAGE_SPEC, "dist.integrity"], + ["view", PACKAGE_SPEC, "dist.tarball"], + ["pack", TARBALL_URL, "--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); + removeReviewedNpmArchive(archive); + expect(fs.existsSync(archive.rootDirectory)).toBe(false); + }); + + it("fails before packing when registry integrity or tarball metadata drifts", () => { + for (const [field, actual] of [ + ["dist.integrity", "sha512-drift"], + ["dist.tarball", "https://unexpected.invalid/reviewed.tgz"], + ] as const) { + const calls: string[][] = []; + expect(() => + verifyReviewedNpmMetadata(request(), (args) => { + calls.push([...args]); + return args[2] === field + ? actual + : (new Map([ + ["dist.integrity", INTEGRITY], + ["dist.tarball", TARBALL_URL], + ]).get(args[2] as string) ?? ""); + }), + ).toThrow(field === "dist.integrity" ? "npm integrity mismatch" : "npm tarball URL mismatch"); + expect(calls.some((args) => args[0] === "pack")).toBe(false); + } + }); + + it("removes the fresh directory when packed SRI drifts", () => { + const reviewed = request(); + let packDirectory = ""; + expect(() => + packReviewedNpmArchive(reviewed, (args) => { + return args[0] === "view" + ? args[2] === "dist.integrity" + ? INTEGRITY + : TARBALL_URL + : (() => { + packDirectory = args[3] as string; + fs.writeFileSync(path.join(packDirectory, "reviewed-1.2.3.tgz"), "drifted bytes"); + return JSON.stringify([ + { filename: "reviewed-1.2.3.tgz", integrity: "sha512-drift" }, + ]); + })(); + }), + ).toThrow("downloaded tarball integrity mismatch"); + expect(fs.existsSync(packDirectory)).toBe(false); + }); + + it.each([ + "../reviewed.tgz", + "/tmp/reviewed.tgz", + "nested/reviewed.tgz", + "nested\\reviewed.tgz", + ".", + "..", + ])("rejects malicious npm pack filename %s", (filename) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-npm-path-test-")); + roots.push(root); + expect(() => resolveReviewedNpmArchivePath(PACKAGE_SPEC, root, filename)).toThrow( + `reported unsafe archive filename: ${filename}`, + ); + }); +}); diff --git a/test/reviewed-npm-audit.test.ts b/test/reviewed-npm-audit.test.ts new file mode 100644 index 0000000000..54223cb142 --- /dev/null +++ b/test/reviewed-npm-audit.test.ts @@ -0,0 +1,30 @@ +// 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 { + exceedsAuditThreshold, + vulnerabilityCounts, +} from "../scripts/audit-reviewed-npm-graph.mts"; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const CONFIG = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"), "utf-8"), +) as { + severityThreshold: "info" | "low" | "moderate" | "high" | "critical"; +}; + +describe("reviewed npm audit gate", () => { + it("fails at high or critical findings while retaining lower severities", () => { + const report = { + metadata: { + vulnerabilities: { info: 3, low: 2, moderate: 1, high: 4, critical: 5 }, + }, + }; + const counts = vulnerabilityCounts(report); + expect(exceedsAuditThreshold(counts, CONFIG.severityThreshold)).toBe(9); + expect(exceedsAuditThreshold(counts, "critical")).toBe(5); + }); +}); diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index 117abad18e..a51f7f5e60 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -102,6 +102,7 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "patch-openclaw-mcp-npx.mts")); writeFixture(path.join("scripts", "patch-openclaw-issue-4434-diagnostics.ts")); writeFixture(path.join("scripts", "patch-openclaw-device-self-approval.ts")); + writeFixture(path.join("scripts", "lib", "reviewed-npm-archive.mts")); } function expectDockerfileScriptCopiesExist(buildCtx: string, stagedDockerfile: string) { diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index a89560aca8..f486839df8 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -297,61 +297,6 @@ describe("sandbox provisioning: runtime npm online state", () => { }); }); -describe("sandbox provisioning: non-messaging OpenClaw plugins", () => { - it("pins Brave web-search and preserves its placeholder during build-time doctor", () => { - const braveIntegrity = - "sha512-DDRnb4reL99O8kbISNbRFyk/xoUPYHsXG3UGikKAsVs+zIldYYA0hY0d3Z2aWoE+0vfda27mJUByCo7Xr15qdw=="; - const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); - const command = dockerRunCommandBetween( - dockerfile, - "# Install non-messaging OpenClaw plugins", - '# hadolint ignore=DL3059,DL4006\nRUN OPENCLAW_VERSION="${OPENCLAW_VERSION}" node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent openclaw --phase agent-install', - ); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-brave-plugin-install-")); - try { - const { result, calls } = runLoggedDockerShell( - command, - tmp, - [ - [ - "npm() {", - ' printf "npm %s|BRAVE_API_KEY=%s\\n" "$*" "${BRAVE_API_KEY:-}" >> "$call_log"', - ` if [ "$1 $2 $3" = "view @openclaw/brave-plugin@2026.6.10 dist.integrity" ]; then printf "%s\\n" "${braveIntegrity}"; return 0; fi`, - ' if [ "$1 $2 $3" = "view @openclaw/brave-plugin@2026.6.10 dist.tarball" ]; then printf "%s\\n" "https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.6.10.tgz"; return 0; fi', - ` if [ "$1" = "pack" ]; then pack_dir="\${4:-}"; test -n "$pack_dir"; printf "fake brave plugin tarball" > "$pack_dir/brave-plugin-2026.6.10.tgz"; printf '[{"filename":"brave-plugin-2026.6.10.tgz","integrity":"%s"}]\\n' "${braveIntegrity}"; return 0; fi`, - " return 1", - "}", - "openclaw() {", - ' printf "%s|BRAVE_API_KEY=%s\\n" "$*" "${BRAVE_API_KEY:-}" >> "$call_log"', - "}", - ].join("\n"), - ], - { - NEMOCLAW_OPENCLAW_OTEL: "0", - NEMOCLAW_WEB_SEARCH_ENABLED: "1", - NEMOCLAW_WEB_SEARCH_PROVIDER: "brave", - OPENCLAW_VERSION: "2026.6.10", - OPENCLAW_BRAVE_PLUGIN_2026_6_10_INTEGRITY: braveIntegrity, - }, - ); - - expect(result.status, `stderr: ${result.stderr}`).toBe(0); - expect(calls).toContain("npm view @openclaw/brave-plugin@2026.6.10 dist.integrity"); - expect(calls).toContain("npm view @openclaw/brave-plugin@2026.6.10 dist.tarball"); - expect(calls).toContain( - "npm pack https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.6.10.tgz --pack-destination", - ); - expect(calls).toContain("plugins install npm-pack:"); - expect(calls).toContain("brave-plugin-2026.6.10.tgz|BRAVE_API_KEY="); - expect(calls).toContain( - "doctor --fix --non-interactive|BRAVE_API_KEY=openshell:resolve:env:BRAVE_API_KEY", - ); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); -}); - function dockerfileEnvDirectives(text: string): string[] { const lines = text.split("\n"); const directives: string[] = []; From ca8bf53512d1dfd07eb20888e4aa5bdd9cabaa5c Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Mon, 13 Jul 2026 13:15:58 -0700 Subject: [PATCH 2/3] fix(ci): fail closed on npm audit transport errors Signed-off-by: Ho Lim --- agents/hermes/Dockerfile | 2 + scripts/audit-reviewed-npm-graph.mts | 69 ++++++++++++++----- .../messaging-build-applier-integrity.test.ts | 48 +++++++++++++ test/reviewed-npm-audit.test.ts | 36 ++++++++++ 4 files changed, 139 insertions(+), 16 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 1efcef6cff..030ce46e7f 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -106,9 +106,11 @@ COPY agents/hermes/config/ /opt/nemoclaw-hermes-config/config/ COPY agents/hermes/host/managed-tool-gateway-matrix.json /opt/nemoclaw-hermes-config/managed-tool-gateway-matrix.json COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts COPY src/lib/messaging/ /src/lib/messaging/ +COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts RUN find /opt/nemoclaw-hermes-config -type d -exec chmod 755 {} + \ && find /opt/nemoclaw-hermes-config -type f -exec chmod 444 {} + \ && chmod 444 /src/lib/tool-disclosure.ts \ + && chmod 444 /scripts/lib/reviewed-npm-archive.mts \ && chmod -R a+rX /src/lib/messaging # Copy blueprint (shared infrastructure) diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index d932cc2481..83d43f7a3c 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -7,10 +7,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { - packReviewedNpmArchive, - verifyReviewedNpmMetadata, -} from "./lib/reviewed-npm-archive.mts"; +import { packReviewedNpmArchive, verifyReviewedNpmMetadata } from "./lib/reviewed-npm-archive.mts"; type Severity = "info" | "low" | "moderate" | "high" | "critical"; type ReviewedPackage = Readonly<{ @@ -63,23 +60,62 @@ function readConfig(): AuditConfig { function auditGraph(directory: string, reportPath: string): Record { const result = run("npm", ["audit", "--omit=dev", "--json"], directory, true); + fs.writeFileSync(reportPath, result.stdout); + return parseAuditReport(result); +} + +export function parseAuditReport(result: { + status: number | null; + stderr: string; + stdout: string; +}): Record { if (!result.stdout.trim()) { throw new Error(`npm audit did not produce JSON: ${result.stderr}`); } - fs.writeFileSync(reportPath, result.stdout); + let report: Record; try { - return JSON.parse(result.stdout) as Record; + report = JSON.parse(result.stdout) as Record; } catch (error) { throw new Error(`npm audit returned invalid JSON: ${String(error)}`); } + let counts: Record; + try { + counts = vulnerabilityCounts(report); + } catch (error) { + const detail = report.error === undefined ? result.stderr : JSON.stringify(report.error); + throw new Error( + `npm audit failed without a complete vulnerability report: ${error instanceof Error ? error.message : String(error)}${detail ? `; ${detail}` : ""}`, + ); + } + const findingCount = SEVERITIES.reduce((total, severity) => total + counts[severity], 0); + if ( + report.error !== undefined || + result.status === null || + result.status > 1 || + (result.status !== 0 && findingCount === 0) + ) { + const detail = report.error === undefined ? result.stderr : JSON.stringify(report.error); + throw new Error( + `npm audit failed without vulnerability findings${detail ? `: ${detail}` : ""}`, + ); + } + return report; } export function vulnerabilityCounts(report: Record): Record { const metadata = report.metadata as Record | undefined; const vulnerabilities = metadata?.vulnerabilities as Record | undefined; - return Object.fromEntries( - SEVERITIES.map((severity) => [severity, Number(vulnerabilities?.[severity] ?? 0)]), - ) as Record; + if (!vulnerabilities || Array.isArray(vulnerabilities)) { + throw new Error("npm audit report is missing metadata.vulnerabilities"); + } + const entries = SEVERITIES.map((severity) => { + const value = vulnerabilities[severity]; + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`npm audit report has invalid ${severity} vulnerability count`); + } + return [severity, value] as const; + }); + return Object.fromEntries(entries) as Record; } export function exceedsAuditThreshold( @@ -92,10 +128,7 @@ export function exceedsAuditThreshold( ); } -function materializeArchiveGraph( - packages: readonly ReviewedPackage[], - tempRoot: string, -): string { +function materializeArchiveGraph(packages: readonly ReviewedPackage[], tempRoot: string): string { const graphDirectory = path.join(tempRoot, "reviewed-archive-graph"); fs.mkdirSync(graphDirectory); fs.writeFileSync( @@ -176,16 +209,20 @@ function main(): void { const summary = SEVERITIES.map((severity) => `${severity}=${counts[severity]}`).join(" "); console.log(`${label}: ${summary}`); const blocked = exceedsAuditThreshold(counts, config.severityThreshold); - if (blocked > 0) failures.push(`${label}: ${blocked} at or above ${config.severityThreshold}`); + if (blocked > 0) + failures.push(`${label}: ${blocked} at or above ${config.severityThreshold}`); } - if (failures.length > 0) throw new Error(`reviewed npm audit threshold failed\n${failures.join("\n")}`); + if (failures.length > 0) + throw new Error(`reviewed npm audit threshold failed\n${failures.join("\n")}`); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } } function isMainModule(): boolean { - return process.argv[1] ? import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href : false; + return process.argv[1] + ? import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href + : false; } if (isMainModule()) { diff --git a/test/messaging-build-applier-integrity.test.ts b/test/messaging-build-applier-integrity.test.ts index c57c2f6bc9..520e6229fa 100644 --- a/test/messaging-build-applier-integrity.test.ts +++ b/test/messaging-build-applier-integrity.test.ts @@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process"; 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 { applyMessagingBuildPhase, @@ -29,6 +30,7 @@ const OPENCLAW_SLACK_2026_6_10_INTEGRITY = "sha512-OOsMLjPcbWhQRM5XDwfdrACjJmKqavFtpuIlhHAXWrLrd/p7SyIVE9AoKS0yxOx6bqGDIMJ9+knzdViHMLgBdA=="; const OPENCLAW_SLACK_2026_6_10_TARBALL = "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.6.10.tgz"; +const REPO_ROOT = path.join(import.meta.dirname, ".."); function channelsB64(channels: string[]): string { return Buffer.from(JSON.stringify(channels)).toString("base64"); @@ -63,6 +65,52 @@ function thrownMessage(run: () => void): string { } describe("messaging-build-applier.mts: plugin archive integrity", () => { + it("loads the real build applier from the Hermes image module boundary", () => { + const dockerfile = fs.readFileSync( + path.join(REPO_ROOT, "agents", "hermes", "Dockerfile"), + "utf8", + ); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-applier-boundary-")); + const messagingRoot = path.join(root, "src", "lib", "messaging"); + try { + for (const line of dockerfile.split(/\r?\n/)) { + const copy = line.match( + /^COPY (src\/lib\/messaging\/|scripts\/lib\/reviewed-npm-archive\.mts) (\/\S+)$/, + ); + if (!copy) continue; + const [, source, destination] = copy; + if (!source || !destination) continue; + const sourcePath = path.join(REPO_ROOT, source); + const destinationPath = path.join(root, destination.replace(/^\//, "")); + if (fs.statSync(sourcePath).isDirectory()) { + fs.cpSync(sourcePath, destinationPath, { recursive: true }); + } else { + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.copyFileSync(sourcePath, destinationPath); + } + } + const stagedApplier = path.join( + messagingRoot, + "applier", + "build", + "messaging-build-applier.mts", + ); + const result = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + "--input-type=module", + "--eval", + `await import(${JSON.stringify(pathToFileURL(stagedApplier).href)})`, + ], + { encoding: "utf8", timeout: 10_000 }, + ); + expect(result.status, result.stderr).toBe(0); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it( "accepts the reviewed messaging plugin registry tarball URL before install", async () => { diff --git a/test/reviewed-npm-audit.test.ts b/test/reviewed-npm-audit.test.ts index 54223cb142..2fbdee5264 100644 --- a/test/reviewed-npm-audit.test.ts +++ b/test/reviewed-npm-audit.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { exceedsAuditThreshold, + parseAuditReport, vulnerabilityCounts, } from "../scripts/audit-reviewed-npm-graph.mts"; @@ -27,4 +28,39 @@ describe("reviewed npm audit gate", () => { expect(exceedsAuditThreshold(counts, CONFIG.severityThreshold)).toBe(9); expect(exceedsAuditThreshold(counts, "critical")).toBe(5); }); + + it("accepts npm's nonzero audit status when a complete finding report explains it", () => { + const report = { + metadata: { + vulnerabilities: { info: 0, low: 1, moderate: 0, high: 0, critical: 0 }, + }, + }; + expect(parseAuditReport({ status: 1, stderr: "", stdout: JSON.stringify(report) })).toEqual( + report, + ); + }); + + it("rejects a parseable npm transport failure instead of treating it as clean", () => { + expect(() => + parseAuditReport({ + status: 1, + stderr: "npm registry unavailable", + stdout: JSON.stringify({ + error: { code: "ECONNREFUSED", summary: "request to registry failed" }, + }), + }), + ).toThrow(/ECONNREFUSED/); + }); + + it.each([ + ["missing metadata", {}], + [ + "invalid severity count", + { metadata: { vulnerabilities: { info: 0, low: 0, moderate: 0, high: "0", critical: 0 } } }, + ], + ])("rejects %s", (_label, report) => { + expect(() => + parseAuditReport({ status: 0, stderr: "", stdout: JSON.stringify(report) }), + ).toThrow(/vulnerability report|vulnerability count/); + }); }); From acccfb5fc71a87991ed03d9e171da12cf9adb378 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Mon, 13 Jul 2026 13:30:44 -0700 Subject: [PATCH 3/3] test(ci): keep Hermes boundary setup linear Signed-off-by: Charan Jagwani --- .../messaging-build-applier-integrity.test.ts | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/test/messaging-build-applier-integrity.test.ts b/test/messaging-build-applier-integrity.test.ts index 520e6229fa..3aab16739b 100644 --- a/test/messaging-build-applier-integrity.test.ts +++ b/test/messaging-build-applier-integrity.test.ts @@ -73,21 +73,15 @@ 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 { - for (const line of dockerfile.split(/\r?\n/)) { - const copy = line.match( - /^COPY (src\/lib\/messaging\/|scripts\/lib\/reviewed-npm-archive\.mts) (\/\S+)$/, - ); - if (!copy) continue; - const [, source, destination] = copy; - if (!source || !destination) continue; + for (const copy of dockerfile.matchAll( + /^COPY (src\/lib\/messaging\/|scripts\/lib\/reviewed-npm-archive\.mts) (\/\S+)$/gm, + )) { + const source = copy[1] ?? ""; + const destination = copy[2] ?? ""; const sourcePath = path.join(REPO_ROOT, source); const destinationPath = path.join(root, destination.replace(/^\//, "")); - if (fs.statSync(sourcePath).isDirectory()) { - fs.cpSync(sourcePath, destinationPath, { recursive: true }); - } else { - fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); - fs.copyFileSync(sourcePath, destinationPath); - } + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.cpSync(sourcePath, destinationPath, { recursive: true }); } const stagedApplier = path.join( messagingRoot,