From 8c0374d05bb43f22d935cd226984238d9e1c8fe3 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 21 Jul 2026 20:32:27 -0700 Subject: [PATCH 1/3] fix(e2e): restore image regression coverage --- Dockerfile | 11 +- Dockerfile.base | 9 +- .../langchain-deepagents-code/Dockerfile.base | 11 +- .../openclaw-2026.6.10-dependency-review.md | 10 +- scripts/lib/openclaw-npm-remediation.mts | 32 +++++- ...openshell-gateway-upgrade-old-installer.ts | 88 +++++++++++++++ .../live/openshell-gateway-upgrade.test.ts | 72 +----------- ...hell-gateway-upgrade-old-installer.test.ts | 106 ++++++++++++++++++ test/node-tar-dockerfile-contract.test.ts | 30 +++-- test/openclaw-integrity-pin-suite.ts | 3 +- test/openclaw-npm-remediation.test.ts | 68 +++++++++++ 11 files changed, 344 insertions(+), 96 deletions(-) create mode 100644 test/e2e/live/openshell-gateway-upgrade-old-installer.ts create mode 100644 test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts diff --git a/Dockerfile b/Dockerfile index 554c37a5c48..247026ed825 100644 --- a/Dockerfile +++ b/Dockerfile @@ -297,7 +297,7 @@ RUN set -eu; \ echo "ERROR: OpenClaw ${OPENCLAW_VERSION} has no committed npm integrity pin" >&2; exit 1; \ fi; \ OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle-v1'; \ - if [ "$OPENCLAW_VERSION" = "2026.6.10" ]; then OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle+transitive-remediation-v1'; fi; \ + if [ "$OPENCLAW_VERSION" = "2026.3.11" ] || [ "$OPENCLAW_VERSION" = "2026.6.10" ]; then OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle+transitive-remediation-v1'; fi; \ MCPORTER_EXPECTED_INTEGRITY=""; \ 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; \ @@ -353,7 +353,7 @@ RUN set -eu; \ --tarball-url "$EXPECTED_TARBALL" --label "OpenClaw ${OPENCLAW_VERSION}")"; \ OPENCLAW_PACK_DIR="$(dirname "$OPENCLAW_SOURCE_PACK_PATH")"; \ OPENCLAW_PACK_PATH="$OPENCLAW_SOURCE_PACK_PATH"; \ - if [ "$OPENCLAW_VERSION" = "2026.6.10" ]; then \ + if [ "$OPENCLAW_VERSION" = "2026.3.11" ] || [ "$OPENCLAW_VERSION" = "2026.6.10" ]; then \ OPENCLAW_PACK_PATH="$(node --experimental-strip-types /scripts/lib/openclaw-npm-remediation.mts \ --archive "$OPENCLAW_SOURCE_PACK_PATH" --package-spec "openclaw@${OPENCLAW_VERSION}" \ --working-directory "$OPENCLAW_PACK_DIR")"; \ @@ -370,9 +370,10 @@ RUN set -eu; \ esac; \ rm -rf "$OPENCLAW_PACK_DIR"; \ fi; \ - if [ "$OPENCLAW_VERSION" = "2026.6.10" ]; then \ - npm ls -g --depth=1 openclaw @openclaw/fs-safe tar jszip >/dev/null; \ - fi; \ + case "$OPENCLAW_VERSION" in \ + 2026.3.11) npm ls -g --depth=1 openclaw tar >/dev/null ;; \ + 2026.6.10) npm ls -g --depth=1 openclaw @openclaw/fs-safe tar jszip >/dev/null ;; \ + esac; \ if [ "$USE_REVIEWED_BASE_RUNTIME" = "1" ]; then \ echo "INFO: Reusing reviewed base mcporter $CUR_MCPORTER_VER with exact lock provenance"; \ else \ diff --git a/Dockerfile.base b/Dockerfile.base index f8269a8802e..3e67522663c 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -282,7 +282,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep OPENCLAW_PACK_DIR="$(dirname "$OPENCLAW_SOURCE_PACK_PATH")"; \ OPENCLAW_PACK_PATH="$OPENCLAW_SOURCE_PACK_PATH"; \ OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle-v1'; \ - if [ "$OPENCLAW_VERSION" = "2026.6.10" ]; then \ + if [ "$OPENCLAW_VERSION" = "2026.3.11" ] || [ "$OPENCLAW_VERSION" = "2026.6.10" ]; then \ OPENCLAW_PACK_PATH="$(node --experimental-strip-types /scripts/lib/openclaw-npm-remediation.mts \ --archive "$OPENCLAW_SOURCE_PACK_PATH" --package-spec "openclaw@${OPENCLAW_VERSION}" \ --working-directory "$OPENCLAW_PACK_DIR")"; \ @@ -299,9 +299,10 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep && if [ "$OPENCLAW_INSTALLED_VERSION" != "$OPENCLAW_VERSION" ]; then \ echo "Error: Installed OpenClaw ${OPENCLAW_INSTALLED_VERSION:-unknown} does not match reviewed target ${OPENCLAW_VERSION}"; exit 1; \ fi \ - && if [ "$OPENCLAW_VERSION" = "2026.6.10" ]; then \ - npm ls -g --depth=1 openclaw @openclaw/fs-safe tar jszip >/dev/null; \ - fi \ + && case "$OPENCLAW_VERSION" in \ + 2026.3.11) npm ls -g --depth=1 openclaw tar >/dev/null ;; \ + 2026.6.10) npm ls -g --depth=1 openclaw @openclaw/fs-safe tar jszip >/dev/null ;; \ + esac \ && MCPORTER_EXPECTED_INTEGRITY="" \ && 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 \ diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index 8eb8457a302..c68423ddabb 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -12,11 +12,6 @@ FROM node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0 COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts -# Node remains available to the managed terminal at runtime, so remediate -# npm's private node-tar copy even though Deep Agents Code itself is Python. -RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ - --npm-root /usr/local/lib/node_modules/npm - ENV DEBIAN_FRONTEND=noninteractive \ VIRTUAL_ENV=/opt/venv \ PATH="/usr/local/bin:/opt/venv/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin" @@ -40,6 +35,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ vim-tiny=2:9.1.1230-2 \ && rm -rf /var/lib/apt/lists/* +# Node remains available to the managed terminal at runtime, so remediate +# npm's private node-tar copy even though Deep Agents Code itself is Python. +# The verified archive downloader is installed in the preceding layer. +RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ + --npm-root /usr/local/lib/node_modules/npm + RUN groupadd -r sandbox \ && useradd -r -g sandbox -d /sandbox -s /bin/bash sandbox \ && usermod -a -G sandbox root \ diff --git a/docs/security/openclaw-2026.6.10-dependency-review.md b/docs/security/openclaw-2026.6.10-dependency-review.md index 33e2d71617a..f4874a5a598 100644 --- a/docs/security/openclaw-2026.6.10-dependency-review.md +++ b/docs/security/openclaw-2026.6.10-dependency-review.md @@ -77,7 +77,7 @@ The OpenClaw 2026.6.10 bump does not newly introduce an unfrozen OpenClaw transi ### Transitive Remediation Boundary -`scripts/lib/openclaw-npm-remediation.mts` remediates only four exact reviewed identities. +`scripts/lib/openclaw-npm-remediation.mts` remediates only five exact reviewed identities. It rejects an unexpected source dependency shape before it changes or installs an archive. The helper verifies every replacement package by exact registry SRI and tarball URL. It also rejects unsafe archive members before extraction and after repacking. @@ -89,6 +89,9 @@ For `openclaw@2026.6.10`, the helper makes these changes: - Bundles the reviewed `@openclaw/fs-safe@0.3.0` package and removes its duplicate optional `tar` and `jszip` declarations. The bundled package resolves OpenClaw's reviewed direct `tar@7.5.19` and `jszip@3.10.1` dependencies instead, including during a global npm install. - Verifies the installed global dependency tree before either the reviewed base image or production image can complete. +For the E2E-only `openclaw@2026.3.11` identity, the helper replaces the exact `tar@7.5.11` declaration with reviewed `tar@7.5.19`. +It rejects a source archive that has different dependency metadata or an unexpected npm shrinkwrap. + For `@openclaw/slack@2026.6.10` and `@openclaw/msteams@2026.6.10`, the helper makes these changes: - Replaces bundled `axios@1.16.0` with `axios@1.18.0`. @@ -123,6 +126,7 @@ It binds each patched package manifest and shrinkwrap to a committed SHA-512 met The core value also covers the bundled `@openclaw/fs-safe` package manifest. The diagnostics value also covers the bundled SDK, Jaeger propagator, and nested core package manifests. The expected values are `sha512-B5O6Gu3YGY52w+Px8diL5zBtk8mj0u7E1ZvVK7KOLWX9H+S3B7kYUxnGfyB239mVYSluecfiWGvFFMk5eFhwKg==` for OpenClaw core, `sha512-ByLYBs3KXz3u0mPuj9DcP/xPTJNgQaLTPxazybhyIC1VjyftEmKQuoZufPZ8z8CjwBsOPm6NbjMQB2BfX36TTg==` for diagnostics OTEL, `sha512-AXllGzI+m33jUq3w1nCVXngLA1m9kH8c9XryHSoPzuVhGP6xwWpzgKl3yyfOMoIykN0GKcka59ZZbjEwkxFudQ==` for Slack, and `sha512-eTTIpA8HzcBwXBLt6UZDoFgOUmkRgIhcZFBOwg+5Jfgt8HDwtfPnqKo6vm2DdDdPMPhu08FbEzU5Gt3RoL5fIw==` for Microsoft Teams. +The E2E-only `openclaw@2026.3.11` value is `sha512-c+3QxBJidAFb8xZSmz4azC7KHFvXUAY9vN1AlXJ243LwMCFN5it5MW0r6FBuxIFvlBCnGlzcqRCvU5ghUec/ng==`. Both the library and command-line entry points enforce the same committed values. `Dockerfile.base` records `ignore-scripts+reviewed-lifecycle+transitive-remediation-v1` in its protected provenance marker. The production Dockerfile rejects stale base provenance and repeats the remediation when the marker does not match. @@ -251,6 +255,10 @@ The reviewed `@openclaw/diagnostics-otel@2026.6.10` package dist imports `OTLPTr The legacy `2026.3.11` and `2026.4.24` OpenClaw pins are retained only for stale-upgrade fixture builds. Production Dockerfile install blocks now reject those versions unless `NEMOCLAW_E2E_FIXTURE_LEGACY_OPENCLAW=1` is set explicitly. The E2E-scoped name is intentionally noisy so production build workflows do not treat it as a general override. Production image workflows run `scripts/check-production-build-args.sh` before production Docker builds so the fixture flag, both legacy version values, and every integrity/tarball Docker ARG declared by a production Dockerfile cannot be overridden through production build args or their corresponding environment variables. The guard also rejects future positional `*_INTEGRITY` and `*_TARBALL` names, keeping reviewed pin values repository-controlled even before the Dockerfile's registry and downloaded-archive checks run. The stale-upgrade E2E build contexts pass their fixture values only on fixture-specific build paths, and the integrity-pin contract suite verifies the default rejection, the explicit fixture opt-in, and the production workflow guard. +Frozen OpenShell gateway-upgrade fixtures retain npm registry signature verification. +Their E2E adapter skips only the current advisory audit for the immutable historical mcporter lock and rejects an ambiguous audit boundary. +`test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts` verifies both constraints. + Invalid state: a production image build overriding `OPENCLAW_VERSION` to an old fixture pin or replacing any repository-reviewed integrity/tarball value while still passing the workflow boundary. Source boundary: Dockerfile and Dockerfile.base install blocks plus the guard that precedes every production image build. Source-fix constraint: keep stale-upgrade E2Es able to build old images without normalizing those pins or accepting caller-controlled production package identity. Regression tests: the integrity-pin contract suite rejects the flag, both legacy versions, all declared integrity/tarball ARG overrides through direct, `--build-arg`, and environment paths, and a future-shaped positional pin name; `test/openclaw-dependency-review.test.ts` proves all seven production image builds are guard-protected and carry no literal fixture selectors. Removal condition: issue #5896 section 9 retires the old-base fixture strategy and fixture flag; the general repository-owned production pin guard remains until production builds no longer expose package identity as Docker ARGs. ### OpenClaw Device Approval Convergence Boundary diff --git a/scripts/lib/openclaw-npm-remediation.mts b/scripts/lib/openclaw-npm-remediation.mts index 5193b72c37f..5a593ef1a12 100755 --- a/scripts/lib/openclaw-npm-remediation.mts +++ b/scripts/lib/openclaw-npm-remediation.mts @@ -20,7 +20,7 @@ import { packReviewedNpmArchive } from "./reviewed-npm-archive.mts"; type JsonObject = Record; type Remediation = Readonly<{ - kind: "core" | "diagnostics-otel" | "plugin"; + kind: "core" | "diagnostics-otel" | "legacy-core" | "plugin"; expectedPatchedMetadataIntegrity: string; }>; @@ -105,6 +105,11 @@ const REMEDIATIONS: Readonly> = Object.freeze({ expectedPatchedMetadataIntegrity: "sha512-B5O6Gu3YGY52w+Px8diL5zBtk8mj0u7E1ZvVK7KOLWX9H+S3B7kYUxnGfyB239mVYSluecfiWGvFFMk5eFhwKg==", }, + "openclaw@2026.3.11": { + kind: "legacy-core", + expectedPatchedMetadataIntegrity: + "sha512-c+3QxBJidAFb8xZSmz4azC7KHFvXUAY9vN1AlXJ243LwMCFN5it5MW0r6FBuxIFvlBCnGlzcqRCvU5ghUec/ng==", + }, }); function run(command: string, args: readonly string[], cwd: string, env: NodeJS.ProcessEnv) { @@ -183,7 +188,10 @@ function writeJson(path: string, value: JsonObject): void { function hashPatchedMetadata(packageDirectory: string): string { const hash = createHash("sha512"); - const names = ["package.json", "npm-shrinkwrap.json"]; + const names = ["package.json"]; + if (existsSync(join(packageDirectory, "npm-shrinkwrap.json"))) { + names.push("npm-shrinkwrap.json"); + } const bundledFsSafePackageJson = "node_modules/@openclaw/fs-safe/package.json"; if (existsSync(join(packageDirectory, bundledFsSafePackageJson))) { names.push(bundledFsSafePackageJson); @@ -384,6 +392,24 @@ export function patchOpenClawCorePackageGraph(packageDirectory: string): void { writeJson(shrinkwrapPath, shrinkwrap); } +export function patchLegacyOpenClawCorePackageGraph(packageDirectory: string): void { + const packageJsonPath = join(packageDirectory, "package.json"); + const packageJson = readJson(packageJsonPath); + requirePackageIdentity(packageJson, "openclaw", "2026.3.11", "Legacy OpenClaw core"); + if (packageJson.dependencies?.tar !== "7.5.11") { + throw new Error("openclaw@2026.3.11 must declare reviewed tar@7.5.11 before remediation"); + } + if (packageJson.bundledDependencies !== undefined) { + throw new Error("openclaw@2026.3.11 unexpectedly declares bundled dependencies"); + } + if (existsSync(join(packageDirectory, "npm-shrinkwrap.json"))) { + throw new Error("openclaw@2026.3.11 unexpectedly ships an npm shrinkwrap"); + } + + packageJson.dependencies.tar = TAR_VERSION; + writeJson(packageJsonPath, packageJson); +} + export function patchOpenClawDiagnosticsPackageGraph(packageDirectory: string): void { const packageJsonPath = join(packageDirectory, "package.json"); const shrinkwrapPath = join(packageDirectory, "npm-shrinkwrap.json"); @@ -565,6 +591,8 @@ export function buildRemediatedOpenClawArchive(request: BuildRequest): Remediate join(sourcePackage, "node_modules", "@openclaw", "fs-safe"), ); patchOpenClawCorePackageGraph(sourcePackage); + } else if (remediation.kind === "legacy-core") { + patchLegacyOpenClawCorePackageGraph(sourcePackage); } else if (remediation.kind === "diagnostics-otel") { const jaegerArchive = packReplacement( `@opentelemetry/propagator-jaeger@${OTEL_PROPAGATOR_JAEGER_VERSION}`, diff --git a/test/e2e/live/openshell-gateway-upgrade-old-installer.ts b/test/e2e/live/openshell-gateway-upgrade-old-installer.ts new file mode 100644 index 00000000000..9350bb0f799 --- /dev/null +++ b/test/e2e/live/openshell-gateway-upgrade-old-installer.ts @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +// The frozen release installers are the source of truth, but their embedded +// Dockerfiles predate the fixture pins needed for a deterministic upgrade test. +// Keep this adapter scoped to the frozen historical lanes and retire it with +// them; changing the tagged release payloads is not viable. +export function patchOldInstallerFixture(installer: string): void { + const needle = ' legacy_script="${source_root}/install.sh"\n'; + const hook = + String.raw` if [[ -n "\${NEMOCLAW_OLD_OPENCLAW_VERSION:-}" && -f "$payload_script" ]]; then + python3 - "$payload_script" <<'NEMOCLAW_OLD_PAYLOAD_PIN_PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +text = path.read_text(encoding="utf-8") +needle = ' spin "Cloning \${_CLI_DISPLAY} source" clone_nemoclaw_ref "$release_ref" "$nemoclaw_src"\n' +hook = r''' if [[ -n "\${NEMOCLAW_OLD_OPENCLAW_VERSION:-}" ]]; then + python3 - "$nemoclaw_src/Dockerfile" "$NEMOCLAW_OLD_OPENCLAW_VERSION" <<'NEMOCLAW_OLD_DOCKERFILE_PIN_PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +version = sys.argv[2] +text = path.read_text(encoding="utf-8") +injection = ( + "# E2E old-upgrade fixture: force the historical OpenClaw before the old Dockerfile's version gate.\n" + "RUN rm -rf /usr/local/lib/node_modules/openclaw /usr/local/bin/openclaw \\\n" + f" && npm install -g --no-audit --no-fund --no-progress \"openclaw@{version}\" \\\n" + " && openclaw --version\n\n" +) +if injection not in text: + arg_markers = [ + line for line in text.splitlines(keepends=True) + if line.startswith("ARG OPENCLAW_VERSION=") + ] + if len(arg_markers) == 1: + marker = arg_markers[0] + text = text.replace(marker, marker + "\n" + injection, 1) + elif len(arg_markers) > 1: + raise SystemExit( + f"{path}: found {len(arg_markers)} OpenClaw version ARGs; expected exactly one" + ) + else: + marker = "RUN set -eu; \\\n MIN_VER=$(grep -m 1 'min_openclaw_version'" + if marker not in text: + raise SystemExit(f"{path}: old OpenClaw version gate not found") + text = text.replace(marker, injection + marker, 1) + +advisory_audit = ' npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit --omit=dev --audit-level=low; \\\n' +advisory_audit_count = text.count(advisory_audit) +if advisory_audit_count > 1: + raise SystemExit( + f"{path}: found {advisory_audit_count} historical mcporter advisory audits; expected at most one" + ) +if advisory_audit_count == 1: + audit_fixture_note = ( + ' echo "INFO: Skipping current advisory audit for the immutable historical mcporter lock"; \\\n' + ) + text = text.replace(advisory_audit, audit_fixture_note, 1) + +path.write_text(text, encoding="utf-8") +print(f"INFO: Forced OpenClaw {version} in old upgrade fixture Dockerfile", flush=True) +NEMOCLAW_OLD_DOCKERFILE_PIN_PY + fi +''' +if hook not in text: + if needle not in text: + raise SystemExit(f"{path}: old source clone hook not found") + text = text.replace(needle, needle + hook, 1) + path.write_text(text, encoding="utf-8") +NEMOCLAW_OLD_PAYLOAD_PIN_PY + fi +`.replaceAll("\\${", "${"); + + const text = fs.readFileSync(installer, "utf8"); + const patchedText = text.includes(hook) + ? text + : text.includes(needle) + ? text.replace(needle, needle + hook) + : (() => { + throw new Error(`${installer}: old bootstrap payload hook not found`); + })(); + fs.writeFileSync(installer, patchedText, "utf8"); +} diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index d2e66f11cb6..73e1514ff4b 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -40,6 +40,7 @@ import { upgradeGatewayStateCleanupScript, validateLegacyGatewayUpgradeFixture, } from "./openshell-gateway-upgrade-helpers.ts"; +import { patchOldInstallerFixture } from "./openshell-gateway-upgrade-old-installer.ts"; const INSTALL_OPENSHELL = path.join(REPO_ROOT, "scripts", "install-openshell.sh"); const STATE_DIR = path.join( @@ -185,77 +186,6 @@ async function bash( ); } -// The frozen release installers are the source of truth, but their embedded -// Dockerfiles predate the fixture pins needed for a deterministic upgrade test. -// Keep this adapter scoped to the frozen historical lanes and retire it with -// them; changing the tagged release payloads is not viable. -function patchOldInstallerFixture(installer: string): void { - const needle = ' legacy_script="${source_root}/install.sh"\n'; - const hook = - String.raw` if [[ -n "\${NEMOCLAW_OLD_OPENCLAW_VERSION:-}" && -f "$payload_script" ]]; then - python3 - "$payload_script" <<'NEMOCLAW_OLD_PAYLOAD_PIN_PY' -from pathlib import Path -import sys - -path = Path(sys.argv[1]) -text = path.read_text(encoding="utf-8") -needle = ' spin "Cloning \${_CLI_DISPLAY} source" clone_nemoclaw_ref "$release_ref" "$nemoclaw_src"\n' -hook = r''' if [[ -n "\${NEMOCLAW_OLD_OPENCLAW_VERSION:-}" ]]; then - python3 - "$nemoclaw_src/Dockerfile" "$NEMOCLAW_OLD_OPENCLAW_VERSION" <<'NEMOCLAW_OLD_DOCKERFILE_PIN_PY' -from pathlib import Path -import sys - -path = Path(sys.argv[1]) -version = sys.argv[2] -text = path.read_text(encoding="utf-8") -injection = ( - "# E2E old-upgrade fixture: force the historical OpenClaw before the old Dockerfile's version gate.\n" - "RUN rm -rf /usr/local/lib/node_modules/openclaw /usr/local/bin/openclaw \\\n" - f" && npm install -g --no-audit --no-fund --no-progress \"openclaw@{version}\" \\\n" - " && openclaw --version\n\n" -) -if injection not in text: - arg_markers = [ - line for line in text.splitlines(keepends=True) - if line.startswith("ARG OPENCLAW_VERSION=") - ] - if len(arg_markers) == 1: - marker = arg_markers[0] - text = text.replace(marker, marker + "\n" + injection, 1) - elif len(arg_markers) > 1: - raise SystemExit( - f"{path}: found {len(arg_markers)} OpenClaw version ARGs; expected exactly one" - ) - else: - marker = "RUN set -eu; \\\n MIN_VER=$(grep -m 1 'min_openclaw_version'" - if marker not in text: - raise SystemExit(f"{path}: old OpenClaw version gate not found") - text = text.replace(marker, injection + marker, 1) - path.write_text(text, encoding="utf-8") -print(f"INFO: Forced OpenClaw {version} in old upgrade fixture Dockerfile", flush=True) -NEMOCLAW_OLD_DOCKERFILE_PIN_PY - fi -''' -if hook not in text: - if needle not in text: - raise SystemExit(f"{path}: old source clone hook not found") - text = text.replace(needle, needle + hook, 1) - path.write_text(text, encoding="utf-8") -NEMOCLAW_OLD_PAYLOAD_PIN_PY - fi -`.replaceAll("\\${", "${"); - - const text = fs.readFileSync(installer, "utf8"); - const patchedText = text.includes(hook) - ? text - : text.includes(needle) - ? text.replace(needle, needle + hook) - : (() => { - throw new Error(`${installer}: old bootstrap payload hook not found`); - })(); - fs.writeFileSync(installer, patchedText, "utf8"); -} - function createOldDockerWrapper(artifacts: ArtifactSink): string { const wrapperDir = artifacts.pathFor("old-docker-wrapper"); const logFile = artifacts.pathFor("old-docker-wrapper.log"); diff --git a/test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts b/test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts new file mode 100644 index 00000000000..ffd9dc1370b --- /dev/null +++ b/test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts @@ -0,0 +1,106 @@ +// 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 { afterEach, describe, expect, it } from "vitest"; +import { patchOldInstallerFixture } from "../live/openshell-gateway-upgrade-old-installer.ts"; + +const temporaryDirectories: string[] = []; + +function writeHistoricalFixture(advisoryAuditCount = 1): { + dockerfile: string; + installer: string; +} { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-old-upgrade-installer-")); + temporaryDirectories.push(root); + const sourceRoot = path.join(root, "source"); + const dockerfile = path.join(sourceRoot, "Dockerfile"); + const payload = path.join(root, "payload.sh"); + const installer = path.join(root, "install.sh"); + fs.mkdirSync(sourceRoot); + + const advisoryAudit = + " npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit --omit=dev --audit-level=low; \\\n"; + fs.writeFileSync( + dockerfile, + [ + "FROM fixture", + "ARG OPENCLAW_VERSION=2026.5.27", + ...Array.from({ length: advisoryAuditCount }, () => advisoryAudit.trimEnd()), + " npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit signatures; \\", + " true", + "", + ].join("\n"), + ); + fs.writeFileSync( + payload, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `nemoclaw_src=${JSON.stringify(sourceRoot)}`, + "_CLI_DISPLAY=NemoClaw", + "release_ref=fixture", + 'spin() { shift; "$@"; }', + "clone_nemoclaw_ref() { :; }", + ' spin "Cloning ${_CLI_DISPLAY} source" clone_nemoclaw_ref "$release_ref" "$nemoclaw_src"', + "", + ].join("\n"), + { mode: 0o700 }, + ); + fs.writeFileSync( + installer, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `payload_script=${JSON.stringify(payload)}`, + `source_root=${JSON.stringify(sourceRoot)}`, + ' legacy_script="${source_root}/install.sh"', + '"$payload_script"', + "", + ].join("\n"), + { mode: 0o700 }, + ); + return { dockerfile, installer }; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { force: true, recursive: true }); + } +}); + +describe("historical OpenShell gateway upgrade installer adapter", () => { + it("keeps signature verification while isolating current advisory drift", () => { + const fixture = writeHistoricalFixture(); + patchOldInstallerFixture(fixture.installer); + + const result = spawnSync("bash", [fixture.installer], { + encoding: "utf8", + env: { ...process.env, NEMOCLAW_OLD_OPENCLAW_VERSION: "2026.5.27" }, + }); + expect(result.status, result.stderr).toBe(0); + + const dockerfile = fs.readFileSync(fixture.dockerfile, "utf8"); + expect(dockerfile).toContain('openclaw@2026.5.27"'); + expect(dockerfile).not.toContain("audit --omit=dev --audit-level=low"); + expect(dockerfile).toContain( + "Skipping current advisory audit for the immutable historical mcporter lock", + ); + expect(dockerfile).toContain("audit signatures"); + }); + + it("rejects an ambiguous historical advisory boundary", () => { + const fixture = writeHistoricalFixture(2); + patchOldInstallerFixture(fixture.installer); + + const result = spawnSync("bash", [fixture.installer], { + encoding: "utf8", + env: { ...process.env, NEMOCLAW_OLD_OPENCLAW_VERSION: "2026.5.27" }, + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("historical mcporter advisory audits; expected at most one"); + }); +}); diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index 15191f92fe5..b6496110443 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -10,12 +10,24 @@ import { NODE_BASES_REQUIRING_BUNDLED_NPM_TAR_PATCH } from "../scripts/patch-bun const repoRoot = path.resolve(import.meta.dirname, ".."); const dockerfiles = [ - { file: "Dockerfile.base", installsWithNpm: true }, - { file: "Dockerfile", installsWithNpm: true }, - { file: "agents/hermes/Dockerfile.base", installsWithNpm: true }, - { file: "agents/hermes/Dockerfile", installsWithNpm: true }, - { file: "agents/langchain-deepagents-code/Dockerfile.base", installsWithNpm: false }, - { file: "agents/langchain-deepagents-code/Dockerfile", installsWithNpm: false }, + { file: "Dockerfile.base", installsPatchDownloader: false, installsWithNpm: true }, + { file: "Dockerfile", installsPatchDownloader: false, installsWithNpm: true }, + { + file: "agents/hermes/Dockerfile.base", + installsPatchDownloader: false, + installsWithNpm: true, + }, + { file: "agents/hermes/Dockerfile", installsPatchDownloader: false, installsWithNpm: true }, + { + file: "agents/langchain-deepagents-code/Dockerfile.base", + installsPatchDownloader: true, + installsWithNpm: false, + }, + { + file: "agents/langchain-deepagents-code/Dockerfile", + installsPatchDownloader: false, + installsWithNpm: false, + }, ] as const; function completedStage(source: string): string { @@ -38,7 +50,7 @@ describe("node-tar image remediation contract", () => { it.each( dockerfiles, )("patches npm before use and scans the completed $file filesystem", (entry) => { - const { file, installsWithNpm } = entry; + const { file, installsPatchDownloader, installsWithNpm } = entry; const source = completedStage(fs.readFileSync(path.join(repoRoot, file), "utf8")); const reviewedCopy = source.indexOf( "COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts", @@ -59,6 +71,10 @@ describe("node-tar image remediation contract", () => { expect(reviewedCopy, file).toBeGreaterThanOrEqual(0); expect(patchCopy, file).toBeGreaterThan(reviewedCopy); expect(patchRun, file).toBeGreaterThan(patchCopy); + if (installsPatchDownloader) { + expect(source.indexOf("curl="), file).toBeGreaterThan(patchCopy); + expect(source.indexOf("curl="), file).toBeLessThan(patchRun); + } expect(scanCopy, file).toBeGreaterThan(patchRun); expect(scanRun, file).toBeGreaterThan(scanCopy); expect(source, file).toContain("> /usr/local/share/nemoclaw/node-tar-inventory.json"); diff --git a/test/openclaw-integrity-pin-suite.ts b/test/openclaw-integrity-pin-suite.ts index ce05cb10276..e497fdb6589 100644 --- a/test/openclaw-integrity-pin-suite.ts +++ b/test/openclaw-integrity-pin-suite.ts @@ -99,7 +99,7 @@ function openClawBaseProvenance( tarball = PINNED_OPENCLAW_TARBALL, ): string { const recipe = - version === PINNED_OPENCLAW_VERSION + version === PINNED_OPENCLAW_VERSION || version === LEGACY_REBUILD_OPENCLAW_VERSION ? "ignore-scripts+reviewed-lifecycle+transitive-remediation-v1" : "ignore-scripts+reviewed-lifecycle-v1"; return [ @@ -1229,6 +1229,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes ); expect(fixtureBase.calls).toContain(`openclaw-${LEGACY_REBUILD_OPENCLAW_VERSION}.tgz`); expect(fixtureBase.calls).toContain("npm install -g --ignore-scripts "); + expect(fixtureBase.calls).toContain("openclaw-remediated.tgz"); expect(fixtureBase.calls).not.toContain("postinstall-bundled-plugins.mjs"); expect(gatewayFixtureBase.result.status).toBe(0); expect(gatewayFixtureBase.calls).toContain("npm install -g --ignore-scripts "); diff --git a/test/openclaw-npm-remediation.test.ts b/test/openclaw-npm-remediation.test.ts index b6ee0eccc60..2504887cf7c 100644 --- a/test/openclaw-npm-remediation.test.ts +++ b/test/openclaw-npm-remediation.test.ts @@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process"; import { chmodSync, cpSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -16,6 +17,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { buildRemediatedOpenClawArchive, + patchLegacyOpenClawCorePackageGraph, patchOpenClawCorePackageGraph, patchOpenClawPluginPackageGraph, } from "../scripts/lib/openclaw-npm-remediation.mts"; @@ -142,6 +144,24 @@ function writeCoreFixture(tarVersion = "7.5.16"): string { return directory; } +function writeLegacyCoreFixture(tarVersion = "7.5.11"): string { + const directory = mkdtempSync(path.join(tmpdir(), "nemoclaw-legacy-openclaw-core-remediation-")); + temporaryDirectories.push(directory); + writeFileSync( + path.join(directory, "package.json"), + `${JSON.stringify( + { + name: "openclaw", + version: "2026.3.11", + dependencies: { commander: "14.0.3", tar: tarVersion }, + }, + null, + 2, + )}\n`, + ); + return directory; +} + function readJson(file: string): T { return JSON.parse(readFileSync(file, "utf-8")) as T; } @@ -412,6 +432,54 @@ describe("OpenClaw npm remediation", () => { ); }); + it("rejects a legacy rebuild fixture tar graph that changed after review", () => { + const directory = writeLegacyCoreFixture("7.5.12"); + + expect(() => patchLegacyOpenClawCorePackageGraph(directory)).toThrow( + "must declare reviewed tar@7.5.11 before remediation", + ); + }); + + it("rebuilds the legacy fixture archive without adding mutable lock metadata", () => { + const directory = writeLegacyCoreFixture(); + const root = mkdtempSync(path.join(tmpdir(), "nemoclaw-legacy-openclaw-build-remediation-")); + temporaryDirectories.push(root); + const archivePath = path.join(root, "openclaw-2026.3.11.tgz"); + packFixture(directory, archivePath); + const request = { + archivePath, + packageSpec: "openclaw@2026.3.11", + workingDirectory: path.join(root, "work"), + }; + let metadataIntegrity = ""; + try { + buildRemediatedOpenClawArchive({ + ...request, + expectedPatchedMetadataIntegrity: "sha512-deliberate-mismatch", + }); + } catch (error) { + metadataIntegrity = String(error).match(/got (sha512-\S+)/u)?.[1] ?? ""; + } + expect(metadataIntegrity).toMatch(/^sha512-/u); + + const remediated = buildRemediatedOpenClawArchive({ + ...request, + expectedPatchedMetadataIntegrity: metadataIntegrity, + }); + const extracted = path.join(root, "asserted"); + mkdirSync(extracted, { recursive: true }); + const extraction = spawnSync("tar", ["-xzf", remediated.archivePath, "-C", extracted], { + encoding: "utf8", + }); + expect(extraction.status, extraction.stderr).toBe(0); + expect(existsSync(path.join(extracted, "package", "npm-shrinkwrap.json"))).toBe(false); + expect( + readJson<{ dependencies?: Record }>( + path.join(extracted, "package", "package.json"), + ).dependencies?.tar, + ).toBe("7.5.19"); + }); + // source-shape-contract: security -- Archive metadata proves the rebuilt package carries the reviewed bundled fs-safe remediation it("rebuilds a guarded core archive with the patched fs-safe package bundled", () => { const fixture = writeCoreArchiveFixtures(); From 5fb8812b9da3418c81da42257f2199d2bfd0478e Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 21 Jul 2026 20:37:17 -0700 Subject: [PATCH 2/3] test(e2e): keep Dockerfile contract linear --- test/node-tar-dockerfile-contract.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index b6496110443..2d7161ecd0d 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -71,10 +71,10 @@ describe("node-tar image remediation contract", () => { expect(reviewedCopy, file).toBeGreaterThanOrEqual(0); expect(patchCopy, file).toBeGreaterThan(reviewedCopy); expect(patchRun, file).toBeGreaterThan(patchCopy); - if (installsPatchDownloader) { - expect(source.indexOf("curl="), file).toBeGreaterThan(patchCopy); - expect(source.indexOf("curl="), file).toBeLessThan(patchRun); - } + const patchDownloader = source.indexOf("curl="); + expect(patchDownloader > patchCopy && patchDownloader < patchRun, file).toBe( + installsPatchDownloader, + ); expect(scanCopy, file).toBeGreaterThan(patchRun); expect(scanRun, file).toBeGreaterThan(scanCopy); expect(source, file).toContain("> /usr/local/share/nemoclaw/node-tar-inventory.json"); From 609735706b4e9317e8deca3e14ada0aefd663f5e Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 21 Jul 2026 20:52:12 -0700 Subject: [PATCH 3/3] fix(e2e): verify historical fixture archives --- .../openclaw-2026.6.10-dependency-review.md | 10 ++- ...openshell-gateway-upgrade-old-installer.ts | 65 +++++++++++++-- .../live/openshell-gateway-upgrade.test.ts | 30 +++++-- ...hell-gateway-upgrade-old-installer.test.ts | 82 +++++++++++++++++-- 4 files changed, 161 insertions(+), 26 deletions(-) diff --git a/docs/security/openclaw-2026.6.10-dependency-review.md b/docs/security/openclaw-2026.6.10-dependency-review.md index f4874a5a598..9ca1cb9f3c2 100644 --- a/docs/security/openclaw-2026.6.10-dependency-review.md +++ b/docs/security/openclaw-2026.6.10-dependency-review.md @@ -255,9 +255,13 @@ The reviewed `@openclaw/diagnostics-otel@2026.6.10` package dist imports `OTLPTr The legacy `2026.3.11` and `2026.4.24` OpenClaw pins are retained only for stale-upgrade fixture builds. Production Dockerfile install blocks now reject those versions unless `NEMOCLAW_E2E_FIXTURE_LEGACY_OPENCLAW=1` is set explicitly. The E2E-scoped name is intentionally noisy so production build workflows do not treat it as a general override. Production image workflows run `scripts/check-production-build-args.sh` before production Docker builds so the fixture flag, both legacy version values, and every integrity/tarball Docker ARG declared by a production Dockerfile cannot be overridden through production build args or their corresponding environment variables. The guard also rejects future positional `*_INTEGRITY` and `*_TARBALL` names, keeping reviewed pin values repository-controlled even before the Dockerfile's registry and downloaded-archive checks run. The stale-upgrade E2E build contexts pass their fixture values only on fixture-specific build paths, and the integrity-pin contract suite verifies the default rejection, the explicit fixture opt-in, and the production workflow guard. -Frozen OpenShell gateway-upgrade fixtures retain npm registry signature verification. -Their E2E adapter skips only the current advisory audit for the immutable historical mcporter lock and rejects an ambiguous audit boundary. -`test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts` verifies both constraints. +Frozen OpenShell gateway-upgrade fixtures select only the SRI-pinned OpenClaw `2026.4.24`, `2026.5.22`, or `2026.5.27` archive. +The live test uses `packReviewedNpmArchive` to verify exact registry metadata, the reviewed tarball URL, and the downloaded SRI. +The adapter copies only that verified local archive into the historical build context. +It installs the archive with lifecycle scripts disabled and invokes `postinstall-bundled-plugins.mjs` directly. +The fixtures retain npm registry signature verification for the historical mcporter lock. +The adapter requires exactly one advisory audit statement before it replaces that statement with a test-only skip. +`test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts` verifies these constraints. Invalid state: a production image build overriding `OPENCLAW_VERSION` to an old fixture pin or replacing any repository-reviewed integrity/tarball value while still passing the workflow boundary. Source boundary: Dockerfile and Dockerfile.base install blocks plus the guard that precedes every production image build. Source-fix constraint: keep stale-upgrade E2Es able to build old images without normalizing those pins or accepting caller-controlled production package identity. Regression tests: the integrity-pin contract suite rejects the flag, both legacy versions, all declared integrity/tarball ARG overrides through direct, `--build-arg`, and environment paths, and a future-shaped positional pin name; `test/openclaw-dependency-review.test.ts` proves all seven production image builds are guard-protected and carry no literal fixture selectors. Removal condition: issue #5896 section 9 retires the old-base fixture strategy and fixture flag; the general repository-owned production pin guard remains until production builds no longer expose package identity as Docker ARGs. diff --git a/test/e2e/live/openshell-gateway-upgrade-old-installer.ts b/test/e2e/live/openshell-gateway-upgrade-old-installer.ts index 9350bb0f799..31e4a7932a8 100644 --- a/test/e2e/live/openshell-gateway-upgrade-old-installer.ts +++ b/test/e2e/live/openshell-gateway-upgrade-old-installer.ts @@ -3,6 +3,46 @@ import fs from "node:fs"; +export type ReviewedOldOpenClawArchive = Readonly<{ + expectedIntegrity: string; + label: string; + packageSpec: string; + tarballUrl: string; +}>; + +const REVIEWED_OLD_OPENCLAW_ARCHIVES: Readonly> = + Object.freeze({ + "2026.4.24": { + expectedIntegrity: + "sha512-W6u4XeIIP4+uG4DYV9G3JeS6QNuKwfhQIej1GIoL4BdcnUFgrnB8kHYNXL3MxiHRKuhZB9OYwUMGs8jKFZR/Vg==", + label: "historical fixture OpenClaw 2026.4.24", + packageSpec: "openclaw@2026.4.24", + tarballUrl: "https://registry.npmjs.org/openclaw/-/openclaw-2026.4.24.tgz", + }, + "2026.5.22": { + expectedIntegrity: + "sha512-m+zgBELGbCHjWB1IWF5WSWNPr480cMKOMff2OF72c8A0AMD4hC/9+qwYtzjYmGkETcffnB711JymlVsQnh2Tow==", + label: "historical fixture OpenClaw 2026.5.22", + packageSpec: "openclaw@2026.5.22", + tarballUrl: "https://registry.npmjs.org/openclaw/-/openclaw-2026.5.22.tgz", + }, + "2026.5.27": { + expectedIntegrity: + "sha512-2N93zhdAo88KAbHt6T7KvYXf4s7XIkYXBgv1npYpn7e1Y9FvrtgtpsA38my9rtFW+70uXEojRPX5/OqnuDqJPw==", + label: "historical fixture OpenClaw 2026.5.27", + packageSpec: "openclaw@2026.5.27", + tarballUrl: "https://registry.npmjs.org/openclaw/-/openclaw-2026.5.27.tgz", + }, + }); + +export function reviewedOldOpenClawArchive(version: string): ReviewedOldOpenClawArchive { + const reviewedArchive = REVIEWED_OLD_OPENCLAW_ARCHIVES[version]; + if (!reviewedArchive) { + throw new Error(`Historical gateway upgrade OpenClaw ${version} has no reviewed archive pin`); + } + return reviewedArchive; +} + // The frozen release installers are the source of truth, but their embedded // Dockerfiles predate the fixture pins needed for a deterministic upgrade test. // Keep this adapter scoped to the frozen historical lanes and retire it with @@ -19,6 +59,11 @@ path = Path(sys.argv[1]) text = path.read_text(encoding="utf-8") needle = ' spin "Cloning \${_CLI_DISPLAY} source" clone_nemoclaw_ref "$release_ref" "$nemoclaw_src"\n' hook = r''' if [[ -n "\${NEMOCLAW_OLD_OPENCLAW_VERSION:-}" ]]; then + if [[ -z "\${NEMOCLAW_OLD_OPENCLAW_ARCHIVE:-}" || ! -f "$NEMOCLAW_OLD_OPENCLAW_ARCHIVE" ]]; then + echo "ERROR: reviewed historical OpenClaw archive is missing" >&2 + exit 1 + fi + cp -- "$NEMOCLAW_OLD_OPENCLAW_ARCHIVE" "$nemoclaw_src/.nemoclaw-e2e-old-openclaw.tgz" python3 - "$nemoclaw_src/Dockerfile" "$NEMOCLAW_OLD_OPENCLAW_VERSION" <<'NEMOCLAW_OLD_DOCKERFILE_PIN_PY' from pathlib import Path import sys @@ -28,9 +73,12 @@ version = sys.argv[2] text = path.read_text(encoding="utf-8") injection = ( "# E2E old-upgrade fixture: force the historical OpenClaw before the old Dockerfile's version gate.\n" + "COPY .nemoclaw-e2e-old-openclaw.tgz /tmp/nemoclaw-e2e-old-openclaw.tgz\n" "RUN rm -rf /usr/local/lib/node_modules/openclaw /usr/local/bin/openclaw \\\n" - f" && npm install -g --no-audit --no-fund --no-progress \"openclaw@{version}\" \\\n" - " && openclaw --version\n\n" + " && npm install -g --ignore-scripts --no-audit --no-fund --no-progress /tmp/nemoclaw-e2e-old-openclaw.tgz \\\n" + " && node /usr/local/lib/node_modules/openclaw/scripts/postinstall-bundled-plugins.mjs \\\n" + f" && test \"$(openclaw --version | awk '{{print $2}}')\" = \"{version}\" \\\n" + " && rm -f /tmp/nemoclaw-e2e-old-openclaw.tgz\n\n" ) if injection not in text: arg_markers = [ @@ -52,15 +100,14 @@ if injection not in text: advisory_audit = ' npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit --omit=dev --audit-level=low; \\\n' advisory_audit_count = text.count(advisory_audit) -if advisory_audit_count > 1: +if advisory_audit_count != 1: raise SystemExit( - f"{path}: found {advisory_audit_count} historical mcporter advisory audits; expected at most one" - ) -if advisory_audit_count == 1: - audit_fixture_note = ( - ' echo "INFO: Skipping current advisory audit for the immutable historical mcporter lock"; \\\n' + f"{path}: found {advisory_audit_count} historical mcporter advisory audits; expected exactly one" ) - text = text.replace(advisory_audit, audit_fixture_note, 1) +audit_fixture_note = ( + ' echo "INFO: Skipping current advisory audit for the immutable historical mcporter lock"; \\\n' +) +text = text.replace(advisory_audit, audit_fixture_note, 1) path.write_text(text, encoding="utf-8") print(f"INFO: Forced OpenClaw {version} in old upgrade fixture Dockerfile", flush=True) diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index 73e1514ff4b..22bfd102fe5 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -21,6 +21,10 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { + packReviewedNpmArchive, + removeReviewedNpmArchive, +} from "../../../scripts/lib/reviewed-npm-archive.mts"; import { shellQuote } from "../../../src/lib/core/shell-quote"; import { type ArtifactSink } from "../fixtures/artifacts.ts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; @@ -40,7 +44,10 @@ import { upgradeGatewayStateCleanupScript, validateLegacyGatewayUpgradeFixture, } from "./openshell-gateway-upgrade-helpers.ts"; -import { patchOldInstallerFixture } from "./openshell-gateway-upgrade-old-installer.ts"; +import { + patchOldInstallerFixture, + reviewedOldOpenClawArchive, +} from "./openshell-gateway-upgrade-old-installer.ts"; const INSTALL_OPENSHELL = path.join(REPO_ROOT, "scripts", "install-openshell.sh"); const STATE_DIR = path.join( @@ -398,12 +405,15 @@ async function installOldNemoclawAndClaw( fs.chmodSync(oldInstaller, 0o755); patchOldInstallerFixture(oldInstaller); + const reviewedOpenClaw = packReviewedNpmArchive(reviewedOldOpenClawArchive(OLD_OPENCLAW_VERSION)); + const installEnv = liveEnv({ PATH: `${wrapperDir}:${process.env.PATH ?? "/usr/bin:/bin"}`, COMPATIBLE_API_KEY: "dummy", NEMOCLAW_REAL_DOCKER: process.env.NEMOCLAW_REAL_DOCKER ?? "/usr/bin/docker", NEMOCLAW_SANDBOX_BASE_IMAGE_REF: OLD_SANDBOX_BASE_IMAGE_REF, NEMOCLAW_OLD_SANDBOX_BASE_IMAGE_REF: OLD_SANDBOX_BASE_IMAGE_REF, + NEMOCLAW_OLD_OPENCLAW_ARCHIVE: reviewedOpenClaw.archivePath, NEMOCLAW_OLD_OPENCLAW_VERSION: OLD_OPENCLAW_VERSION, NEMOCLAW_OLD_DOCKER_WRAPPER_LOG: oldDockerLog, NEMOCLAW_ACCEPT_EXPERIMENTAL_OPENSHELL_UPGRADE: "1", @@ -422,13 +432,17 @@ async function installOldNemoclawAndClaw( // A transient gateway import failure leaves the old installer session in a // failed state. Keep Vitest retries independent without applying --fresh to // the later current-version upgrade, which must preserve the survivor. - await runInstallerPayload( - host, - `old-${OLD_NEMOCLAW_REF}`, - oldGatewayUpgradeInstallerArgs(oldInstaller), - oldInstallLog, - installEnv, - ); + try { + await runInstallerPayload( + host, + `old-${OLD_NEMOCLAW_REF}`, + oldGatewayUpgradeInstallerArgs(oldInstaller), + oldInstallLog, + installEnv, + ); + } finally { + removeReviewedNpmArchive(reviewedOpenClaw); + } await artifacts.writeText( "old-docker-wrapper.log", fs.existsSync(oldDockerLog) ? fs.readFileSync(oldDockerLog, "utf8") : "", diff --git a/test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts b/test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts index ffd9dc1370b..df3567bb5b6 100644 --- a/test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts +++ b/test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts @@ -6,11 +6,15 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { patchOldInstallerFixture } from "../live/openshell-gateway-upgrade-old-installer.ts"; +import { + patchOldInstallerFixture, + reviewedOldOpenClawArchive, +} from "../live/openshell-gateway-upgrade-old-installer.ts"; const temporaryDirectories: string[] = []; function writeHistoricalFixture(advisoryAuditCount = 1): { + archive: string; dockerfile: string; installer: string; } { @@ -18,9 +22,11 @@ function writeHistoricalFixture(advisoryAuditCount = 1): { temporaryDirectories.push(root); const sourceRoot = path.join(root, "source"); const dockerfile = path.join(sourceRoot, "Dockerfile"); + const archive = path.join(root, "reviewed-openclaw.tgz"); const payload = path.join(root, "payload.sh"); const installer = path.join(root, "install.sh"); fs.mkdirSync(sourceRoot); + fs.writeFileSync(archive, "reviewed fixture archive"); const advisoryAudit = " npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit --omit=dev --audit-level=low; \\\n"; @@ -63,7 +69,7 @@ function writeHistoricalFixture(advisoryAuditCount = 1): { ].join("\n"), { mode: 0o700 }, ); - return { dockerfile, installer }; + return { archive, dockerfile, installer }; } afterEach(() => { @@ -79,12 +85,28 @@ describe("historical OpenShell gateway upgrade installer adapter", () => { const result = spawnSync("bash", [fixture.installer], { encoding: "utf8", - env: { ...process.env, NEMOCLAW_OLD_OPENCLAW_VERSION: "2026.5.27" }, + env: { + ...process.env, + NEMOCLAW_OLD_OPENCLAW_ARCHIVE: fixture.archive, + NEMOCLAW_OLD_OPENCLAW_VERSION: "2026.5.27", + }, }); expect(result.status, result.stderr).toBe(0); const dockerfile = fs.readFileSync(fixture.dockerfile, "utf8"); - expect(dockerfile).toContain('openclaw@2026.5.27"'); + expect( + fs.readFileSync( + path.join(path.dirname(fixture.dockerfile), ".nemoclaw-e2e-old-openclaw.tgz"), + "utf8", + ), + ).toBe("reviewed fixture archive"); + expect(dockerfile).toContain( + "COPY .nemoclaw-e2e-old-openclaw.tgz /tmp/nemoclaw-e2e-old-openclaw.tgz", + ); + expect(dockerfile).toContain( + "npm install -g --ignore-scripts --no-audit --no-fund --no-progress /tmp/nemoclaw-e2e-old-openclaw.tgz", + ); + expect(dockerfile).not.toMatch(/npm install -g [^\n]*openclaw@/u); expect(dockerfile).not.toContain("audit --omit=dev --audit-level=low"); expect(dockerfile).toContain( "Skipping current advisory audit for the immutable historical mcporter lock", @@ -98,9 +120,57 @@ describe("historical OpenShell gateway upgrade installer adapter", () => { const result = spawnSync("bash", [fixture.installer], { encoding: "utf8", - env: { ...process.env, NEMOCLAW_OLD_OPENCLAW_VERSION: "2026.5.27" }, + env: { + ...process.env, + NEMOCLAW_OLD_OPENCLAW_ARCHIVE: fixture.archive, + NEMOCLAW_OLD_OPENCLAW_VERSION: "2026.5.27", + }, }); expect(result.status).not.toBe(0); - expect(result.stderr).toContain("historical mcporter advisory audits; expected at most one"); + expect(result.stderr).toContain("historical mcporter advisory audits; expected exactly one"); + }); + + it("rejects a missing historical advisory boundary", () => { + const fixture = writeHistoricalFixture(0); + patchOldInstallerFixture(fixture.installer); + + const result = spawnSync("bash", [fixture.installer], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_OLD_OPENCLAW_ARCHIVE: fixture.archive, + NEMOCLAW_OLD_OPENCLAW_VERSION: "2026.5.27", + }, + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "found 0 historical mcporter advisory audits; expected exactly one", + ); + }); + + it.each([ + [ + "2026.4.24", + "sha512-W6u4XeIIP4+uG4DYV9G3JeS6QNuKwfhQIej1GIoL4BdcnUFgrnB8kHYNXL3MxiHRKuhZB9OYwUMGs8jKFZR/Vg==", + ], + [ + "2026.5.22", + "sha512-m+zgBELGbCHjWB1IWF5WSWNPr480cMKOMff2OF72c8A0AMD4hC/9+qwYtzjYmGkETcffnB711JymlVsQnh2Tow==", + ], + [ + "2026.5.27", + "sha512-2N93zhdAo88KAbHt6T7KvYXf4s7XIkYXBgv1npYpn7e1Y9FvrtgtpsA38my9rtFW+70uXEojRPX5/OqnuDqJPw==", + ], + ])("binds historical OpenClaw %s to its reviewed archive", (version, expectedIntegrity) => { + expect(reviewedOldOpenClawArchive(version)).toEqual({ + expectedIntegrity, + label: `historical fixture OpenClaw ${version}`, + packageSpec: `openclaw@${version}`, + tarballUrl: `https://registry.npmjs.org/openclaw/-/openclaw-${version}.tgz`, + }); + }); + + it("rejects an unreviewed historical OpenClaw version", () => { + expect(() => reviewedOldOpenClawArchive("2026.5.28")).toThrow(/no reviewed archive pin/); }); });