Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
10 changes: 7 additions & 3 deletions agents/hermes/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -344,12 +344,16 @@ 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.
COPY agents/hermes/security-dependencies.patch /tmp/hermes-security-dependencies.patch
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 \
&& git -C /opt/hermes apply --check /tmp/hermes-security-dependencies.patch \
&& git -C /opt/hermes apply /tmp/hermes-security-dependencies.patch \
&& rm /tmp/hermes.tar.gz /tmp/hermes.tar.gz.sha256 /tmp/hermes-security-dependencies.patch
Expand Down Expand Up @@ -486,9 +490,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; \
Expand Down
12 changes: 6 additions & 6 deletions test/e2e/live/hermes-root-entrypoint-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,12 +238,12 @@ async function assertRuntimeLayout(probe: DockerProbe, container: string): Promi
);
}

async function assertBuildCachesAbsent(probe: DockerProbe, container: string): Promise<void> {
async function assertBuildOnlyPathsAbsent(probe: DockerProbe, container: string): Promise<void> {
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',
);
}

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions test/hermes-final-image-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 \\");
});

Expand Down
89 changes: 89 additions & 0 deletions test/hermes-share-mount-deps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -201,6 +213,83 @@ 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 securityPatch = path.join(tmp, "hermes-security-dependencies.patch");
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(securityPatch, "test patch fixture\n");
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-security-dependencies.patch", securityPatch)
.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"',
"}",
`target_root=${JSON.stringify(targetRoot)}`,
`security_patch=${JSON.stringify(securityPatch)}`,
"git() {",
' [ "$1" = "-C" ]',
' [ "$2" = "$target_root" ]',
' [ "$3" = "apply" ]',
' if [ "$4" = "--check" ]; then',
' [ "$5" = "$security_patch" ]',
" else",
' [ "$4" = "$security_patch" ]',
" fi",
"}",
'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-"));
Expand Down
Loading