From 5e8a3cc1367e4ad3e1843dd2b56c0966db19f90f Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 07:26:36 -0700 Subject: [PATCH 1/2] fix(hermes): omit upstream tests from base image Signed-off-by: Senthil Ravichandran --- agents/hermes/Dockerfile.base | 10 ++- .../live/hermes-root-entrypoint-smoke.test.ts | 12 +-- test/hermes-share-mount-deps.test.ts | 74 +++++++++++++++++++ 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 21d4a369fd8..d3f038f453c 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -337,11 +337,15 @@ RUN printf '%s\n' \ # skip rather than doing a nondeterministic dependency resolve during image # build. RUN pip3 install --no-cache-dir --break-system-packages "uv==${UV_VERSION}" +# Upstream tests are not part of the production runtime and can contain +# intentionally hostile security-test fixtures. Remove them in the extraction +# RUN so their bytes never enter a published image layer. RUN mkdir -p /opt/hermes \ && curl -fsSL "https://github.com/NousResearch/hermes-agent/archive/refs/tags/${HERMES_VERSION}.tar.gz" -o /tmp/hermes.tar.gz \ && printf '%s /tmp/hermes.tar.gz\n' "${HERMES_TARBALL_SHA256}" > /tmp/hermes.tar.gz.sha256 \ && sha256sum -c /tmp/hermes.tar.gz.sha256 \ && tar -xzf /tmp/hermes.tar.gz -C /opt/hermes --strip-components=1 \ + && rm -rf /opt/hermes/tests \ && rm /tmp/hermes.tar.gz /tmp/hermes.tar.gz.sha256 # Cross-check the pinned release against two independent sources before @@ -471,9 +475,9 @@ RUN chmod -R a+rX /opt/hermes/.venv \ # Gate the exact completed base filesystem before it can be published. COPY scripts/checks/node-tar-image-scan.mts /scripts/checks/node-tar-image-scan.mts RUN set -eu; \ - for build_cache in /root/.npm /root/.cache/electron /root/.cache/node-gyp; do \ - if [ -e "$build_cache" ] || [ -L "$build_cache" ]; then \ - echo "ERROR: build-only Hermes cache leaked into the base image: $build_cache" >&2; \ + for build_only_path in /opt/hermes/tests /root/.npm /root/.cache/electron /root/.cache/node-gyp; do \ + if [ -e "$build_only_path" ] || [ -L "$build_only_path" ]; then \ + echo "ERROR: build-only Hermes path leaked into the base image: $build_only_path" >&2; \ exit 1; \ fi; \ done; \ diff --git a/test/e2e/live/hermes-root-entrypoint-smoke.test.ts b/test/e2e/live/hermes-root-entrypoint-smoke.test.ts index 1f5fd480637..60f4270a350 100644 --- a/test/e2e/live/hermes-root-entrypoint-smoke.test.ts +++ b/test/e2e/live/hermes-root-entrypoint-smoke.test.ts @@ -238,12 +238,12 @@ async function assertRuntimeLayout(probe: DockerProbe, container: string): Promi ); } -async function assertBuildCachesAbsent(probe: DockerProbe, container: string): Promise { +async function assertBuildOnlyPathsAbsent(probe: DockerProbe, container: string): Promise { await expectContainerSh( probe, container, - "build-only Hermes caches are present in the runtime image", - 'for path in /root/.npm /root/.cache/electron /root/.cache/node-gyp; do test ! -e "$path" && test ! -L "$path"; done', + "build-only Hermes paths are present in the runtime image", + 'for path in /opt/hermes/tests /root/.npm /root/.cache/electron /root/.cache/node-gyp; do test ! -e "$path" && test ! -L "$path"; done', ); } @@ -375,7 +375,7 @@ async function runCleanVariant( await assertGatewayProcess(probe, container); await assertGatewayLogClean(probe, container); await assertRuntimeLayout(probe, container); - await assertBuildCachesAbsent(probe, container); + await assertBuildOnlyPathsAbsent(probe, container); await assertBearerAuth(probe, container); await assertDashboardHome(probe, container); } @@ -447,7 +447,7 @@ test("hermes root-entrypoint smoke preserves runtime layout and legacy pid migra "gateway process runs as gateway user", "gateway log has no PID race or config load failure", "Hermes v0.14 writable runtime directories are present", - "build-only root caches are absent from the runtime image", + "build-only upstream tests and root caches are absent from the runtime image", "gateway.pid is stored as a regular file below the writable runtime directory", "gateway user cannot remove config.yaml from sticky config root", "Hermes API denies missing/wrong bearer tokens and accepts API_SERVER_KEY", @@ -490,7 +490,7 @@ test("hermes root-entrypoint smoke preserves runtime layout and legacy pid migra cleanStartupHealthy: true, legacyStartupHealthy: true, runtimeLayoutVerified: true, - buildCachesAbsent: true, + buildOnlyPathsAbsent: true, gatewayPrivilegeSeparationVerified: true, bearerAuthVerified: true, dashboardHomeVerified: true, diff --git a/test/hermes-share-mount-deps.test.ts b/test/hermes-share-mount-deps.test.ts index db190e0c8a4..d4e5558a8e4 100644 --- a/test/hermes-share-mount-deps.test.ts +++ b/test/hermes-share-mount-deps.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -38,6 +39,17 @@ function extractHermesInstallCommand(dockerfile: string): string { return match![0].replace(/^RUN\s+/, "").replace(/\\\n/g, " "); } +function extractHermesArchiveCommand(dockerfile: string): string { + const archiveStart = dockerfile.indexOf("RUN mkdir -p /opt/hermes"); + const archiveEnd = dockerfile.indexOf("\n\n# Cross-check the pinned release", archiveStart); + expect(archiveStart).toBeGreaterThanOrEqual(0); + expect(archiveEnd).toBeGreaterThan(archiveStart); + return dockerfile + .slice(archiveStart, archiveEnd) + .replace(/^RUN\s+/, "") + .replace(/\\\n/g, " "); +} + function extractHermesIntegrityCommand(dockerfile: string): string { const integrityStart = dockerfile.indexOf("# Cross-check the pinned release"); const installStart = dockerfile.indexOf("WORKDIR /opt/hermes", integrityStart); @@ -194,6 +206,68 @@ function runHermesInstallLayer( } describe("Hermes share mount package parity (#2947)", () => { + it("removes upstream tests in the Hermes archive extraction layer", () => { + const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-archive-")); + const sourceRoot = path.join(tmp, "source"); + const archiveRoot = path.join(sourceRoot, "hermes-agent-test"); + const sourceTarball = path.join(tmp, "source.tar.gz"); + const targetRoot = path.join(tmp, "target", "hermes"); + const downloadedTarball = path.join(tmp, "download", "hermes.tar.gz"); + const checksumFile = `${downloadedTarball}.sha256`; + const scriptPath = path.join(tmp, "run-hermes-archive-layer.sh"); + + try { + fs.mkdirSync(path.join(archiveRoot, "tests"), { recursive: true }); + fs.mkdirSync(path.dirname(downloadedTarball), { recursive: true }); + fs.writeFileSync(path.join(archiveRoot, "pyproject.toml"), 'version = "test"\n'); + fs.writeFileSync( + path.join(archiveRoot, "tests", "security-fixture.txt"), + "intentionally hostile test-only URL\n", + ); + const packed = spawnSync( + "tar", + ["-czf", sourceTarball, "-C", sourceRoot, "hermes-agent-test"], + { encoding: "utf-8" }, + ); + expect(packed.status, packed.stderr).toBe(0); + const checksum = createHash("sha256").update(fs.readFileSync(sourceTarball)).digest("hex"); + const command = extractHermesArchiveCommand(dockerfile) + .replaceAll("/tmp/hermes.tar.gz.sha256", checksumFile) + .replaceAll("/tmp/hermes.tar.gz", downloadedTarball) + .replaceAll("/opt/hermes", targetRoot); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `source_tarball=${JSON.stringify(sourceTarball)}`, + "curl() {", + " output=", + ' while [ "$#" -gt 0 ]; do', + ' if [ "$1" = "-o" ]; then shift; output="$1"; fi', + " shift", + " done", + ' cp "$source_tarball" "$output"', + "}", + 'export HERMES_VERSION="vtest"', + `export HERMES_TARBALL_SHA256=${JSON.stringify(checksum)}`, + command, + ].join("\n"), + { mode: 0o700 }, + ); + + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + expect(result.status, result.stderr).toBe(0); + expect(fs.readFileSync(path.join(targetRoot, "pyproject.toml"), "utf-8")).toContain( + 'version = "test"', + ); + expect(() => fs.lstatSync(path.join(targetRoot, "tests"))).toThrow(); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("requests gnupg, procps, e2fsprogs, and openssh-sftp-server from the Hermes base apt layer", () => { const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-share-apt-")); From e980f2a936b640dee83b67e27e0dfde62c775eea Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 09:58:54 -0700 Subject: [PATCH 2/2] fix(hermes): pin pruned base image Signed-off-by: Senthil Ravichandran --- agents/hermes/Dockerfile | 7 ++++--- test/hermes-final-image-layout.test.ts | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index e93c4580bd3..843545fff2f 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -6,7 +6,7 @@ # Layers PR-specific code (plugin, config, startup script) on top of the # pre-built Hermes base image. Mirrors the OpenClaw Dockerfile structure. -ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:c4aee5c9b087840da6e1eb2127fef9f4a2eab0862992008d1741dc09f632422e +ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:61feb0e33fae77ad2fcd1ae3aca2d6c484d7ef2c6ed8ebd431d3e514eab6cf2b ARG NEMOCLAW_CORPORATE_CA_B64= FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba AS mcp-tool-discovery-runtime @@ -1147,9 +1147,10 @@ RUN check_metadata() { \ check_absent() { \ path="$1"; \ { [ ! -e "$path" ] && [ ! -L "$path" ]; } \ - || { echo "ERROR: build-only Hermes cache leaked into the final image: $path" >&2; return 1; }; \ + || { echo "ERROR: build-only Hermes path leaked into the final image: $path" >&2; return 1; }; \ }; \ - check_absent /root/.npm \ + check_absent /opt/hermes/tests \ + && check_absent /root/.npm \ && check_absent /root/.cache/electron \ && check_absent /root/.cache/node-gyp \ && check_absent /sandbox/.cache \ diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index aa4a38c442f..9b2c99aac18 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -335,6 +335,7 @@ describe("Hermes final image layout", () => { "HERMES_HOME=/sandbox/.hermes /usr/local/bin/hermes doctor --fix", ); expect(doctorLayer).toMatch(/generate-config[.]ts\s+&& rm -rf \/sandbox\/[.]cache$/u); + expect(finalStage).toContain("check_absent /opt/hermes/tests \\"); expect(finalStage).toContain("&& check_absent /sandbox/.cache \\"); });