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: 18 additions & 0 deletions agents/hermes/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,16 @@ RUN printf '%s\n' \
# New Hermes integrations should be installed by the agent workflow when they
# are enabled rather than shipped in the base image by default.
# Root Node dependencies provide Hermes browser tooling such as agent-browser.
# The WhatsApp adapter ships a separate Node project under
# scripts/whatsapp-bridge whose dependencies Hermes otherwise installs lazily
# on the first `hermes whatsapp` run. That lazy `npm install` targets
# /opt/hermes/scripts/whatsapp-bridge/node_modules, which is root-owned and
# read-only for the sandbox user at runtime, so it fails with EACCES before the
# QR pairing screen is reached (#4764). Bake the bridge's node_modules into the
# image here, alongside the ui-tui/web installs, so runtime pairing needs no
# writes under /opt/hermes. If a future Hermes tarball removes the lockfile,
# skip rather than doing a nondeterministic dependency resolve during image
# build.
RUN pip3 install --no-cache-dir --break-system-packages "uv==${UV_VERSION}"
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 \
Expand Down Expand Up @@ -192,6 +202,14 @@ RUN set -eu; \
echo "Skipping optional Hermes UI package ${ui_dir}: package-lock.json not found"; \
fi; \
done \
&& bridge_dir=scripts/whatsapp-bridge \
&& if [ -f "${bridge_dir}/package-lock.json" ]; then \
npm ci --prefix "${bridge_dir}" --prefer-offline --no-audit --no-fund; \
elif [ -f "${bridge_dir}/package.json" ]; then \
echo "Skipping optional Hermes bridge ${bridge_dir}: package-lock.json not found"; \
else \
echo "Skipping optional Hermes bridge ${bridge_dir}: no package manifest found"; \
fi \
&& rm -rf ui-tui/node_modules web/node_modules /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 \
Expand Down
85 changes: 82 additions & 3 deletions test/hermes-share-mount-deps.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// 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 { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";

const ROOT = path.resolve(import.meta.dirname, "..");
Expand Down Expand Up @@ -42,13 +42,25 @@ function runLoggedShell(command: string, tmp: string) {
return { result, calls };
}

function runHermesInstallLayer(command: string, tmp: string) {
function runHermesInstallLayer(
command: string,
tmp: string,
opts: { whatsappBridge?: "lockfile" | "package-json" } = {},
) {
const fixture = path.join(tmp, "hermes");
const logPath = path.join(tmp, "calls.log");
const scriptPath = path.join(tmp, "run-hermes-install-layer.sh");
fs.mkdirSync(path.join(fixture, "web"), { recursive: true });
fs.writeFileSync(path.join(fixture, "package-lock.json"), "{}\n");
fs.writeFileSync(path.join(fixture, "web", "package-lock.json"), "{}\n");
if (opts.whatsappBridge) {
const bridgeDir = path.join(fixture, "scripts", "whatsapp-bridge");
fs.mkdirSync(bridgeDir, { recursive: true });
fs.writeFileSync(path.join(bridgeDir, "package.json"), "{}\n");
if (opts.whatsappBridge === "lockfile") {
fs.writeFileSync(path.join(bridgeDir, "package-lock.json"), "{}\n");
}
}

const script = [
"#!/usr/bin/env bash",
Expand All @@ -72,9 +84,10 @@ function runHermesInstallLayer(command: string, tmp: string) {
' echo "missing lockfile for ${prefix}" >&2',
" return 42",
" }",
' mkdir -p "${prefix}/node_modules"',
" fi",
"}",
'rm() { printf "rm %s\\n" "$*" >> "$call_log"; }',
'rm() { printf "rm %s\\n" "$*" >> "$call_log"; command rm "$@"; }',
'ln() { printf "ln %s\\n" "$*" >> "$call_log"; }',
'export HERMES_UV_EXTRAS="messaging"',
command,
Expand Down Expand Up @@ -124,4 +137,70 @@ describe("Hermes share mount package parity (#2947)", () => {
fs.rmSync(tmp, { recursive: true, force: true });
}
});

it("pre-installs the WhatsApp bridge node_modules with npm ci when a lockfile ships (#4764)", () => {
const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wa-bridge-"));
const bridgeNodeModules = path.join(
tmp,
"hermes",
"scripts",
"whatsapp-bridge",
"node_modules",
);
const webNodeModules = path.join(tmp, "hermes", "web", "node_modules");

try {
const command = extractHermesInstallCommand(dockerfile);
const { result, calls } = runHermesInstallLayer(command, tmp, {
whatsappBridge: "lockfile",
});

expect(result.status, result.stderr).toBe(0);
// Baking the bridge deps at build time means the runtime `hermes whatsapp`
// never needs to mkdir node_modules under read-only /opt/hermes.
expect(calls).toContain(
"npm ci --prefix scripts/whatsapp-bridge --prefer-offline --no-audit --no-fund",
);
expect(fs.existsSync(bridgeNodeModules)).toBe(true);
expect(fs.existsSync(webNodeModules)).toBe(false);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});

it("skips the WhatsApp bridge install when package.json ships without a lockfile (#4764)", () => {
const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wa-bridge-nolock-"));

try {
const command = extractHermesInstallCommand(dockerfile);
const { result, calls } = runHermesInstallLayer(command, tmp, {
whatsappBridge: "package-json",
});

expect(result.status, result.stderr).toBe(0);
expect(calls).not.toContain("--prefix scripts/whatsapp-bridge");
expect(result.stdout).toContain(
"Skipping optional Hermes bridge scripts/whatsapp-bridge: package-lock.json not found",
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});

it("skips the WhatsApp bridge install when the project is absent (#4764)", () => {
const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wa-bridge-skip-"));

try {
const command = extractHermesInstallCommand(dockerfile);
const { result, calls } = runHermesInstallLayer(command, tmp);

expect(result.status, result.stderr).toBe(0);
expect(calls).not.toContain("--prefix scripts/whatsapp-bridge");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
Loading