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
18 changes: 15 additions & 3 deletions agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,14 @@ RUN set -eu; \
elif grep -q '"web"' /opt/hermes/package-lock.json; then \
npm ci --prefix /opt/hermes --prefer-offline --no-audit --no-fund; \
npm run build --prefix /opt/hermes --workspace web; \
npm ci --omit=dev --prefix /opt/hermes --prefer-offline --no-audit --no-fund; \
npm ci --omit=dev --workspaces=false --prefix /opt/hermes \
--prefer-offline --no-audit --no-fund; \
else \
echo "ERROR: Hermes dashboard at $hermes_web_dir is not covered by a pinned lockfile" >&2; \
exit 1; \
fi; \
fi; \
rm -rf /root/.npm /root/.cache/electron /root/.cache/node-gyp; \
test -d "$hermes_web_dist"

ENV HERMES_WEB_DIST="/opt/hermes/hermes_cli/web_dist"
Expand Down Expand Up @@ -489,7 +491,8 @@ RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \
# SECURITY: Uses a separate script file instead of inline code to avoid
# code injection via build-arg interpolation (same concern as OpenClaw C-2).
RUN HERMES_HOME=/sandbox/.hermes /usr/local/bin/hermes doctor --fix \
&& node --experimental-strip-types /opt/nemoclaw-hermes-config/generate-config.ts
&& node --experimental-strip-types /opt/nemoclaw-hermes-config/generate-config.ts \
&& rm -rf /sandbox/.cache

# Install NemoClaw plugin into Hermes
# hadolint ignore=DL3059
Expand Down Expand Up @@ -741,7 +744,16 @@ RUN check_metadata() { \
[ "$actual" = "$expected" ] \
|| { echo "ERROR: $path metadata is $actual, expected $expected" >&2; return 1; }; \
}; \
check_metadata /scripts/patch-bundled-npm-tar.mts 'root:root 444' \
check_absent() { \
path="$1"; \
{ [ ! -e "$path" ] && [ ! -L "$path" ]; } \
|| { echo "ERROR: build-only Hermes cache leaked into the final image: $path" >&2; return 1; }; \
}; \
check_absent /root/.npm \
&& check_absent /root/.cache/electron \
&& check_absent /root/.cache/node-gyp \
&& check_absent /sandbox/.cache \
&& check_metadata /scripts/patch-bundled-npm-tar.mts 'root:root 444' \
&& check_metadata /opt/nemoclaw-hermes-config/generate-config.ts 'root:root 444' \
&& check_metadata /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py 'root:root 755' \
&& check_metadata /usr/local/bin/nemoclaw-gateway-control 'root:root 700' \
Expand Down
46 changes: 39 additions & 7 deletions agents/hermes/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,9 @@ RUN set -eu; \
echo "Error: Hermes ${HERMES_VERSION} tarball declares version ${tarball_semver}, expected ${HERMES_SEMVER}" >&2; \
exit 1; \
fi; \
registry_integrity=$(npm view "hermes-agent@${HERMES_SEMVER}" dist.integrity); \
npm_integrity_cache=/tmp/hermes-npm-integrity-cache; \
registry_integrity=$(npm_config_cache="$npm_integrity_cache" npm view "hermes-agent@${HERMES_SEMVER}" dist.integrity); \
rm -rf "$npm_integrity_cache"; \
if [ "$registry_integrity" != "${HERMES_NPM_INTEGRITY}" ]; then \
echo "Error: hermes-agent ${HERMES_SEMVER} npm integrity mismatch" >&2; \
echo "Expected: ${HERMES_NPM_INTEGRITY}" >&2; \
Expand All @@ -247,9 +249,13 @@ WORKDIR /opt/hermes
# the sha256-verified tarball — the workspace branch below additionally
# requires the UI dir to appear in the pinned root lockfile before building.
# Re-review the lockfiles on every HERMES_VERSION/HERMES_TARBALL_SHA256 bump.
# The final `npm ci --omit=dev` drops the UI build toolchain (vite, tsc,
# eslint) from node_modules again — the dashboard bundle is already in
# hermes_cli/web_dist and the TUI keeps its runtime (non-dev) deps.
# A root `npm ci --omit=dev` still installs production dependencies for every
# workspace. The dashboard and TUI are self-contained build artifacts, so
# retaining their workspace trees only increases the image exported by every
# Hermes E2E. Recreate root node_modules from the pinned lockfile with
# workspaces disabled; this keeps browser tooling without either UI build tree.
# Root npm, Electron, and node-gyp caches are build-only. Remove them in the
# same RUN that creates them so Docker cannot retain their bytes in this layer.
# hadolint ignore=SC2086
RUN set -eu; \
set --; \
Expand All @@ -276,8 +282,15 @@ RUN set -eu; \
else \
echo "Skipping optional Hermes bridge ${bridge_dir}: no package manifest found"; \
fi \
&& npm ci --omit=dev --prefer-offline --no-audit --no-fund \
&& rm -rf ui-tui/node_modules web/node_modules /tmp/camoufox-* \
&& rm -rf node_modules ui-tui/node_modules web/node_modules \
&& npm ci --omit=dev --workspaces=false --prefer-offline --no-audit --no-fund \
&& rm -rf \
ui-tui/node_modules \
web/node_modules \
/root/.npm \
/root/.cache/electron \
/root/.cache/node-gyp \
/tmp/camoufox-* \
&& ln -sf /opt/hermes/.venv/bin/hermes /usr/local/bin/hermes \
&& ln -sf /opt/hermes/.venv/bin/hermes-agent /usr/local/bin/hermes-agent \
&& ln -sf /opt/hermes/.venv/bin/hermes-acp /usr/local/bin/hermes-acp
Expand Down Expand Up @@ -309,6 +322,18 @@ ENV PATH="/usr/local/bin:/opt/hermes/.venv/bin:${PATH}" \
HERMES_TUI_DIR="/opt/hermes/ui-tui" \
HERMES_WEB_DIST="/opt/hermes/hermes_cli/web_dist"
RUN /usr/local/bin/hermes --version \
&& test -x /opt/hermes/node_modules/.bin/agent-browser \
&& /opt/hermes/node_modules/.bin/agent-browser --version \
&& /opt/hermes/.venv/bin/python -c \
'from tools import browser_tool; expected = "/opt/hermes/node_modules/.bin/agent-browser"; assert browser_tool._find_agent_browser() == expected' \
&& test -s "${HERMES_TUI_DIR}/dist/entry.js" \
&& test -s "${HERMES_WEB_DIST}/index.html" \
&& test ! -e /opt/hermes/.node_modules.runtime \
&& mv /opt/hermes/node_modules /opt/hermes/.node_modules.runtime \
&& mkdir /opt/hermes/node_modules \
&& timeout 30s node "${HERMES_TUI_DIR}/dist/entry.js" --help \
&& rmdir /opt/hermes/node_modules \
&& mv /opt/hermes/.node_modules.runtime /opt/hermes/node_modules \
&& /opt/hermes/.venv/bin/python -c \
'import mcp; from tools import mcp_tool; assert getattr(mcp_tool, "_MCP_AVAILABLE", False), "Hermes MCP client runtime is unavailable"; assert getattr(mcp_tool, "_MCP_HTTP_AVAILABLE", False), "Hermes MCP Streamable HTTP runtime is unavailable"'

Expand All @@ -318,7 +343,14 @@ 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 install -d -m 0755 /usr/local/share/nemoclaw \
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; \
exit 1; \
fi; \
done; \
install -d -m 0755 /usr/local/share/nemoclaw \
&& node --experimental-strip-types /scripts/checks/node-tar-image-scan.mts \
--root / --image build:hermes-base \
> /usr/local/share/nemoclaw/node-tar-inventory.json \
Expand Down
12 changes: 12 additions & 0 deletions test/e2e/live/hermes-root-entrypoint-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,15 @@ async function assertRuntimeLayout(probe: DockerProbe, container: string): Promi
);
}

async function assertBuildCachesAbsent(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',
);
}

async function assertBearerAuth(probe: DockerProbe, container: string): Promise<void> {
await expectContainerSh(
probe,
Expand Down Expand Up @@ -366,6 +375,7 @@ async function runCleanVariant(
await assertGatewayProcess(probe, container);
await assertGatewayLogClean(probe, container);
await assertRuntimeLayout(probe, container);
await assertBuildCachesAbsent(probe, container);
await assertBearerAuth(probe, container);
await assertDashboardHome(probe, container);
}
Expand Down Expand Up @@ -437,6 +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",
"gateway.pid is migrated to a regular top-level file",
"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 @@ -479,6 +490,7 @@ test("hermes root-entrypoint smoke preserves runtime layout and legacy pid migra
cleanStartupHealthy: true,
legacyStartupHealthy: true,
runtimeLayoutVerified: true,
buildCachesAbsent: true,
gatewayPrivilegeSeparationVerified: true,
bearerAuthVerified: true,
dashboardHomeVerified: true,
Expand Down
38 changes: 29 additions & 9 deletions test/hermes-dashboard-provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,17 @@ function runLoggedDockerShell(
return { result, calls };
}

function dashboardBuildCommand(hermesRoot: string): string {
function dashboardBuildCommand(hermesRoot: string, rootCache: string): string {
const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8");
return dockerRunCommandBetween(
dockerfile,
"# Published base images can lag Dockerfile.base",
"# Harden: remove unnecessary build tools",
).replaceAll("/opt/hermes", hermesRoot);
)
.replaceAll("/opt/hermes", hermesRoot)
.replaceAll("/root/.npm", path.join(rootCache, "npm"))
.replaceAll("/root/.cache/electron", path.join(rootCache, "electron"))
.replaceAll("/root/.cache/node-gyp", path.join(rootCache, "node-gyp"));
}

describe("Hermes dashboard provisioning", () => {
Expand All @@ -54,20 +58,33 @@ describe("Hermes dashboard provisioning", () => {
const hermesRoot = path.join(tmp, "hermes");
const hermesWebDir = path.join(hermesRoot, "web");
const hermesWebDist = path.join(hermesRoot, "hermes_cli", "web_dist");
const rootCache = path.join(tmp, "root-cache");
fs.mkdirSync(hermesWebDir, { recursive: true });
fs.writeFileSync(path.join(hermesRoot, "package-lock.json"), '{"packages":{"web":{}}}\n');
fs.writeFileSync(path.join(hermesWebDir, "package.json"), "{}\n");
for (const cache of ["npm", "electron", "node-gyp"]) {
const cachePath = path.join(rootCache, cache);
fs.mkdirSync(cachePath, { recursive: true });
fs.writeFileSync(path.join(cachePath, "build-only-cache"), "unused after image assembly\n");
}

try {
const { result, calls } = runLoggedDockerShell(dashboardBuildCommand(hermesRoot), tmp, [
'npm() { printf "npm %s\\n" "$*" >> "$call_log"; if [ -n "${hermes_web_dist:-}" ] && [ "${1:-}" = "run" ] && [ "${2:-}" = "build" ]; then mkdir -p "$hermes_web_dist"; fi; }',
]);
const { result, calls } = runLoggedDockerShell(
dashboardBuildCommand(hermesRoot, rootCache),
tmp,
[
'npm() { printf "npm %s\\n" "$*" >> "$call_log"; if [ -n "${hermes_web_dist:-}" ] && [ "${1:-}" = "run" ] && [ "${2:-}" = "build" ]; then mkdir -p "$hermes_web_dist"; fi; }',
],
);

expect(result.status, result.stderr).toBe(0);
expect(calls).toContain(`npm ci --prefix ${hermesRoot}`);
expect(calls).toContain(`npm run build --prefix ${hermesRoot} --workspace web`);
expect(calls).toContain(`npm ci --omit=dev --prefix ${hermesRoot}`);
expect(calls).toContain(`npm ci --omit=dev --workspaces=false --prefix ${hermesRoot}`);
expect(fs.existsSync(hermesWebDist)).toBe(true);
for (const cache of ["npm", "electron", "node-gyp"]) {
expect(() => fs.lstatSync(path.join(rootCache, cache))).toThrow();
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
Expand All @@ -77,14 +94,17 @@ describe("Hermes dashboard provisioning", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-dashboard-unpinned-"));
const hermesRoot = path.join(tmp, "hermes");
const hermesWebDir = path.join(hermesRoot, "web");
const rootCache = path.join(tmp, "root-cache");
fs.mkdirSync(hermesWebDir, { recursive: true });
fs.writeFileSync(path.join(hermesRoot, "package-lock.json"), "{}\n");
fs.writeFileSync(path.join(hermesWebDir, "package.json"), "{}\n");

try {
const { result, calls } = runLoggedDockerShell(dashboardBuildCommand(hermesRoot), tmp, [
'npm() { printf "npm %s\\n" "$*" >> "$call_log"; }',
]);
const { result, calls } = runLoggedDockerShell(
dashboardBuildCommand(hermesRoot, rootCache),
tmp,
['npm() { printf "npm %s\\n" "$*" >> "$call_log"; }'],
);

expect(result.status).toBe(1);
expect(result.stderr).toContain("not covered by a pinned lockfile");
Expand Down
10 changes: 10 additions & 0 deletions test/hermes-final-image-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ describe("Hermes final image layout", () => {
// source-shape-contract: compatibility -- Grouped payload layers preserve the measured Hermes layer budget without invalidating earlier build work
it("keeps repository payload layers at their cache boundaries (#7144)", () => {
const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8");
const doctorLayer = dockerRunCommandBetween(
dockerfile,
"# Run Hermes' upstream repair",
"# Install NemoClaw plugin into Hermes",
);
const stages = dockerfile.split(/(?=^FROM )/mu).filter((stage) => stage.startsWith("FROM "));
const finalStageIndex = stages.findIndex((stage) => stage.startsWith("FROM ${BASE_IMAGE}"));
const finalStage = stages[finalStageIndex] ?? "";
Expand Down Expand Up @@ -210,6 +215,11 @@ describe("Hermes final image layout", () => {
expect(finalStage.indexOf("RUN check_metadata()")).toBeLessThan(
finalStage.indexOf("node --experimental-strip-types /scripts/checks/node-tar-image-scan.mts"),
);
expect(doctorLayer).toContain(
"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 /sandbox/.cache \\");
});

// source-shape-contract: security -- Exact source-to-image digests keep the reviewed Hermes runtime entrypoints bound to the files copied into the sandbox image
Expand Down
Loading
Loading