diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index c7d67d7a909..674b957f690 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -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" @@ -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 @@ -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' \ diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 8afbecdc144..971ffbbc4b9 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -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; \ @@ -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 --; \ @@ -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 @@ -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"' @@ -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 \ diff --git a/test/e2e/live/hermes-root-entrypoint-smoke.test.ts b/test/e2e/live/hermes-root-entrypoint-smoke.test.ts index 7856561565a..72842de61cd 100644 --- a/test/e2e/live/hermes-root-entrypoint-smoke.test.ts +++ b/test/e2e/live/hermes-root-entrypoint-smoke.test.ts @@ -238,6 +238,15 @@ async function assertRuntimeLayout(probe: DockerProbe, container: string): Promi ); } +async function assertBuildCachesAbsent(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', + ); +} + async function assertBearerAuth(probe: DockerProbe, container: string): Promise { await expectContainerSh( probe, @@ -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); } @@ -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", @@ -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, diff --git a/test/hermes-dashboard-provisioning.test.ts b/test/hermes-dashboard-provisioning.test.ts index 329f8d04c02..4a7ca85ec3b 100644 --- a/test/hermes-dashboard-provisioning.test.ts +++ b/test/hermes-dashboard-provisioning.test.ts @@ -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", () => { @@ -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 }); } @@ -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"); diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index cdccc95482c..a40e8e8425e 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -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] ?? ""; @@ -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 diff --git a/test/hermes-share-mount-deps.test.ts b/test/hermes-share-mount-deps.test.ts index cba9d509027..f10e1426adc 100644 --- a/test/hermes-share-mount-deps.test.ts +++ b/test/hermes-share-mount-deps.test.ts @@ -30,6 +30,27 @@ function extractHermesInstallCommand(dockerfile: string): string { return match![0].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); + expect(integrityStart).toBeGreaterThanOrEqual(0); + expect(installStart).toBeGreaterThan(integrityStart); + const match = dockerfile.slice(integrityStart, installStart).match(/RUN\s+set -eu;[\s\S]*$/m); + expect(match).not.toBeNull(); + return match![0].replace(/^RUN\s+/, "").replace(/\\\n/g, " "); +} + +function extractHermesRuntimeGuard(dockerfile: string): string { + const guardStart = dockerfile.indexOf("RUN /usr/local/bin/hermes --version"); + const nextRun = dockerfile.indexOf("\nRUN chmod -R", guardStart); + expect(guardStart).toBeGreaterThanOrEqual(0); + expect(nextRun).toBeGreaterThan(guardStart); + return dockerfile + .slice(guardStart, nextRun) + .replace(/^RUN\s+/, "") + .replace(/\\\n/g, " "); +} + function runLoggedShell(command: string, tmp: string) { const logPath = path.join(tmp, "calls.log"); const scriptPath = path.join(tmp, "run-hermes-apt-layer.sh"); @@ -50,27 +71,64 @@ function runHermesInstallLayer( command: string, tmp: string, opts: { + uiTuiLockfile?: "directory" | "workspace" | "missing"; webLockfile?: "directory" | "workspace" | "missing"; whatsappBridge?: "lockfile" | "package-json"; } = {}, ) { const fixture = path.join(tmp, "hermes"); const logPath = path.join(tmp, "calls.log"); + const npmCache = path.join(tmp, "root-cache", "npm"); + const electronCache = path.join(tmp, "root-cache", "electron"); + const nodeGypCache = path.join(tmp, "root-cache", "node-gyp"); const scriptPath = path.join(tmp, "run-hermes-install-layer.sh"); + const uiTuiLockfile = opts.uiTuiLockfile ?? "missing"; const webLockfile = opts.webLockfile ?? "directory"; + const rootLockPackages = { + ...(uiTuiLockfile === "workspace" ? { "ui-tui": {} } : {}), + ...(webLockfile === "workspace" ? { web: {} } : {}), + }; fs.mkdirSync(path.join(fixture, "web"), { recursive: true }); fs.writeFileSync(path.join(fixture, "pyproject.toml"), 'version = "0.16.0"\n'); fs.writeFileSync( path.join(fixture, "package-lock.json"), - webLockfile === "workspace" ? '{"packages":{"web":{}}}\n' : "{}\n", + `${JSON.stringify({ + packages: rootLockPackages, + })}\n`, ); fs.writeFileSync(path.join(fixture, "web", "package.json"), "{}\n"); + const writeUiTuiLockfile = { + directory: () => { + fs.mkdirSync(path.join(fixture, "ui-tui"), { recursive: true }); + fs.writeFileSync(path.join(fixture, "ui-tui", "package.json"), "{}\n"); + fs.writeFileSync(path.join(fixture, "ui-tui", "package-lock.json"), "{}\n"); + }, + missing: () => undefined, + workspace: () => { + fs.mkdirSync(path.join(fixture, "ui-tui"), { recursive: true }); + fs.writeFileSync(path.join(fixture, "ui-tui", "package.json"), "{}\n"); + }, + } satisfies Record void>; + writeUiTuiLockfile[uiTuiLockfile](); const writeWebLockfile = { directory: () => fs.writeFileSync(path.join(fixture, "web", "package-lock.json"), "{}\n"), missing: () => undefined, workspace: () => undefined, } satisfies Record void>; writeWebLockfile[webLockfile](); + const workspaceBuildTrees = [ + ...(uiTuiLockfile === "workspace" ? ["ui-tui"] : []), + ...(webLockfile === "workspace" ? ["web"] : []), + ]; + for (const uiDir of workspaceBuildTrees) { + const nodeModules = path.join(fixture, uiDir, "node_modules"); + fs.mkdirSync(nodeModules, { recursive: true }); + fs.writeFileSync(path.join(nodeModules, "build-only-dependency"), `${uiDir}\n`); + } + for (const cache of [npmCache, electronCache, nodeGypCache]) { + fs.mkdirSync(cache, { recursive: true }); + fs.writeFileSync(path.join(cache, "build-only-cache"), "unused after image assembly\n"); + } if (opts.whatsappBridge) { const bridgeDir = path.join(fixture, "scripts", "whatsapp-bridge"); fs.mkdirSync(bridgeDir, { recursive: true }); @@ -114,12 +172,16 @@ function runHermesInstallLayer( 'export HERMES_SEMVER="0.16.0"', 'export HERMES_NPM_INTEGRITY="sha512-test"', 'export HERMES_UV_EXTRAS="messaging mcp"', - command.replaceAll("/opt/hermes", fixture), + command + .replaceAll("/opt/hermes", fixture) + .replaceAll("/root/.npm", npmCache) + .replaceAll("/root/.cache/electron", electronCache) + .replaceAll("/root/.cache/node-gyp", nodeGypCache), ].join("\n"); fs.writeFileSync(scriptPath, script, { mode: 0o700 }); const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); const calls = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : ""; - return { result, calls }; + return { cachePaths: [npmCache, electronCache, nodeGypCache], result, calls }; } describe("Hermes share mount package parity (#2947)", () => { @@ -157,18 +219,23 @@ describe("Hermes share mount package parity (#2947)", () => { expect(calls).not.toContain("--prefix ui-tui"); expect(calls).toContain("npm ci --prefix web --prefer-offline --no-audit --no-fund"); expect(calls).toContain("npm run build --prefix web"); + expect(calls).toContain( + "npm ci --omit=dev --workspaces=false --prefer-offline --no-audit --no-fund", + ); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); - it("builds the Hermes web workspace from the root package-lock.json", () => { + it("keeps only root runtime dependencies after building workspace UIs (#7144)", () => { const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-ui-workspace-")); + const hermesRoot = path.join(tmp, "hermes"); try { const command = extractHermesInstallCommand(dockerfile); const { result, calls } = runHermesInstallLayer(command, tmp, { + uiTuiLockfile: "workspace", webLockfile: "workspace", }); @@ -176,7 +243,149 @@ describe("Hermes share mount package parity (#2947)", () => { expect(calls).toContain("npm ci --prefer-offline --no-audit --no-fund"); expect(calls).not.toContain("npm ci --prefix web"); expect(calls).toContain("npm run build --workspace web"); - expect(calls).toContain("npm ci --omit=dev --prefer-offline --no-audit --no-fund"); + const cleanInstall = "rm -rf node_modules ui-tui/node_modules web/node_modules"; + const runtimeInstall = + "npm ci --omit=dev --workspaces=false --prefer-offline --no-audit --no-fund"; + expect(calls).toContain(cleanInstall); + expect(calls).toContain(runtimeInstall); + expect(calls.indexOf(cleanInstall)).toBeLessThan(calls.indexOf(runtimeInstall)); + expect(calls).not.toContain("npm ci --omit=dev --prefer-offline --no-audit --no-fund"); + expect(calls).not.toContain("--workspace=ui-tui --include-workspace-root"); + expect(fs.existsSync(path.join(hermesRoot, "node_modules"))).toBe(true); + expect(fs.existsSync(path.join(hermesRoot, "ui-tui", "node_modules"))).toBe(false); + expect(fs.existsSync(path.join(hermesRoot, "web", "node_modules"))).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("removes a legacy TUI build tree after bundling from its own lockfile", () => { + const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-ui-legacy-")); + const uiTuiNodeModules = path.join(tmp, "hermes", "ui-tui", "node_modules"); + + try { + const command = extractHermesInstallCommand(dockerfile); + const { result, calls } = runHermesInstallLayer(command, tmp, { + uiTuiLockfile: "directory", + }); + + expect(result.status, result.stderr).toBe(0); + expect(calls).toContain("npm ci --prefix ui-tui --prefer-offline --no-audit --no-fund"); + expect(calls).toContain( + "npm ci --omit=dev --workspaces=false --prefer-offline --no-audit --no-fund", + ); + expect(fs.existsSync(uiTuiNodeModules)).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("removes build-only caches in the Hermes dependency layer (#7144)", () => { + const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-build-cache-")); + + try { + const command = extractHermesInstallCommand(dockerfile); + const { cachePaths, result } = runHermesInstallLayer(command, tmp); + + expect(result.status, result.stderr).toBe(0); + for (const cachePath of cachePaths) { + expect(() => fs.lstatSync(cachePath)).toThrow(); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("smokes the prebuilt Hermes TUI without runtime node_modules (#7144)", () => { + const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-tui-runtime-")); + const hermesRoot = path.join(tmp, "hermes"); + const fakeHermes = path.join(tmp, "hermes-cli"); + const agentBrowser = path.join(hermesRoot, "node_modules", ".bin", "agent-browser"); + const python = path.join(hermesRoot, ".venv", "bin", "python"); + const tuiEntry = path.join(hermesRoot, "ui-tui", "dist", "entry.js"); + const webIndex = path.join(hermesRoot, "hermes_cli", "web_dist", "index.html"); + const scriptPath = path.join(tmp, "run-hermes-runtime-guard.sh"); + + try { + for (const file of [agentBrowser, python, tuiEntry, webIndex]) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + } + fs.writeFileSync(fakeHermes, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + fs.writeFileSync(agentBrowser, "#!/bin/sh\nprintf 'agent-browser test\\n'\n", { + mode: 0o700, + }); + fs.writeFileSync(python, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + fs.writeFileSync( + tuiEntry, + [ + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const runtimeModules = path.resolve(__dirname, "../../node_modules");', + "if (fs.readdirSync(runtimeModules).length !== 0) process.exit(41);", + 'console.error("hermes-tui: no TTY");', + ].join("\n"), + ); + fs.writeFileSync(webIndex, "\n"); + + const command = extractHermesRuntimeGuard(dockerfile) + .replaceAll("/usr/local/bin/hermes", fakeHermes) + .replaceAll("/opt/hermes", hermesRoot); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'timeout() { shift; "$@"; }', + `export HERMES_TUI_DIR=${JSON.stringify(path.join(hermesRoot, "ui-tui"))}`, + `export HERMES_WEB_DIST=${JSON.stringify(path.join(hermesRoot, "hermes_cli", "web_dist"))}`, + command, + ].join("\n"), + { mode: 0o700 }, + ); + + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toContain("hermes-tui: no TTY"); + expect(fs.existsSync(agentBrowser)).toBe(true); + expect(fs.existsSync(path.join(hermesRoot, ".node_modules.runtime"))).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("uses and removes a disposable cache for the Hermes npm integrity lookup (#7144)", () => { + const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-integrity-cache-")); + const hermesRoot = path.join(tmp, "hermes"); + const integrityCache = path.join(tmp, "integrity-cache"); + const scriptPath = path.join(tmp, "run-hermes-integrity-layer.sh"); + fs.mkdirSync(hermesRoot, { recursive: true }); + fs.writeFileSync(path.join(hermesRoot, "pyproject.toml"), 'version = "0.16.0"\n'); + + try { + const command = extractHermesIntegrityCommand(dockerfile) + .replaceAll("/opt/hermes", hermesRoot) + .replaceAll("/tmp/hermes-npm-integrity-cache", integrityCache); + const script = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `expected_cache=${JSON.stringify(integrityCache)}`, + 'npm() { [ "${npm_config_cache:-}" = "$expected_cache" ] || return 41; mkdir -p "$npm_config_cache"; printf "lookup cache\\n" > "$npm_config_cache/entry"; printf "%s\\n" "$HERMES_NPM_INTEGRITY"; }', + 'export HERMES_VERSION="v0.16.0"', + 'export HERMES_SEMVER="0.16.0"', + 'export HERMES_NPM_INTEGRITY="sha512-test"', + command, + ].join("\n"); + fs.writeFileSync(scriptPath, script, { mode: 0o700 }); + + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + + expect(result.status, result.stderr).toBe(0); + expect(() => fs.lstatSync(integrityCache)).toThrow(); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 9d8d36994a9..973f2e77c19 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -1387,15 +1387,25 @@ describe("Hermes sandbox 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(hermesWebDir, "package.json"), "{}\n"); fs.writeFileSync(path.join(hermesWebDir, "package-lock.json"), "{}\n"); fs.mkdirSync(path.join(hermesWebDir, "node_modules"), { recursive: true }); + 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"); + } const command = 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")); try { const { result, calls } = runLoggedDockerShell(command, tmp, [ 'npm() { printf "npm %s\\n" "$*" >> "$call_log"; if [ -n "${hermes_web_dist:-}" ] && [ "${1:-}" = "run" ] && [ "${2:-}" = "build" ]; then mkdir -p "$hermes_web_dist"; fi; }', @@ -1406,6 +1416,9 @@ describe("Hermes sandbox provisioning", () => { expect(calls).toContain(`npm run build --prefix ${hermesWebDir}`); expect(fs.existsSync(hermesWebDist)).toBe(true); expect(fs.existsSync(path.join(hermesWebDir, "node_modules"))).toBe(false); + for (const cache of ["npm", "electron", "node-gyp"]) { + expect(() => fs.lstatSync(path.join(rootCache, cache))).toThrow(); + } } finally { fs.rmSync(tmp, { recursive: true, force: true }); }