diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 76248bf340a..f8be3ab8569 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -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 \ @@ -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 \ diff --git a/test/hermes-share-mount-deps.test.ts b/test/hermes-share-mount-deps.test.ts index 35183141210..f5b27b66eae 100644 --- a/test/hermes-share-mount-deps.test.ts +++ b/test/hermes-share-mount-deps.test.ts @@ -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, ".."); @@ -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", @@ -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, @@ -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 }); + } + }); });