diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 04802acdf35..30b824ab38d 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -34,6 +34,7 @@ COPY agents/hermes/generate-config.ts /opt/nemoclaw-hermes-config/generate-confi COPY agents/hermes/config/ /opt/nemoclaw-hermes-config/config/ COPY agents/hermes/image-build-probes.py /opt/nemoclaw-hermes-config/image-build-probes.py COPY agents/hermes/patch-gateway-runtime-metadata.py /opt/nemoclaw-hermes-config/patch-gateway-runtime-metadata.py +COPY agents/hermes/patch-gateway-process-identity.py /opt/nemoclaw-hermes-config/patch-gateway-process-identity.py COPY agents/hermes/patch-cron-execution-runtime.py /opt/nemoclaw-hermes-config/patch-cron-execution-runtime.py COPY agents/hermes/host/managed-tool-gateway-matrix.json /opt/nemoclaw-hermes-config/managed-tool-gateway-matrix.json COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts @@ -194,7 +195,7 @@ RUN find /opt/nemoclaw-hermes-config -type d -exec chmod 755 {} + \ /scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-tar.mts \ && chmod -R a+rX /src/lib/messaging -ARG NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256=b21db6d098920a6bb410cccca4778e2817a57b66307212ef3422e6cafdfd67b4 +ARG NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256=50f5cf638e2c11868fe5128dccc0f6289082ba11ea116efcd030b07286591c00 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256" /opt/nemoclaw-hermes-config/image-build-probes.py \ @@ -306,6 +307,23 @@ RUN /usr/bin/python3 -I \ && HERMES_HOME="$(mktemp -d)" /opt/hermes/.venv/bin/python -I \ /opt/nemoclaw-hermes-config/image-build-probes.py gateway-runtime-metadata +# NemoClaw installs its wrapper as `hermes` and moves the real entrypoint to +# `hermes.real`, so every managed gateway command line carries that basename. +# Hermes only accepts `hermes`/`hermes.exe` as the entry token, which defeats +# both the PID-file liveness re-check and the process-table fallback, so +# `hermes status` reports a running foreground gateway as stopped (#7804). +ARG NEMOCLAW_HERMES_GATEWAY_PROCESS_IDENTITY_PATCHER_SHA256=1720f557e57c4755b9de47eece2c9c8ad182e1196c40d2783f1768fc5a426681 +# hadolint ignore=DL4006 +RUN printf '%s %s\n' \ + "$NEMOCLAW_HERMES_GATEWAY_PROCESS_IDENTITY_PATCHER_SHA256" /opt/nemoclaw-hermes-config/patch-gateway-process-identity.py \ + | sha256sum -c - \ + || { echo "ERROR: patch-gateway-process-identity.py hash mismatch (update NEMOCLAW_HERMES_GATEWAY_PROCESS_IDENTITY_PATCHER_SHA256)" >&2; exit 1; } +RUN /usr/bin/python3 -I \ + /opt/nemoclaw-hermes-config/patch-gateway-process-identity.py \ + /opt/hermes/gateway/status.py \ + && /opt/hermes/.venv/bin/python -I \ + /opt/nemoclaw-hermes-config/image-build-probes.py gateway-process-identity + # Hermes v0.19.0 creates its writable cron execution-history database below # the high-risk directory that contains cron job definitions. When Shields up # is active, NemoClaw seals that directory, so a managed gateway restart cannot diff --git a/agents/hermes/image-build-probes.py b/agents/hermes/image-build-probes.py index 9c1d41b1527..ccc4636292f 100644 --- a/agents/hermes/image-build-probes.py +++ b/agents/hermes/image-build-probes.py @@ -94,6 +94,36 @@ def verify_gateway_runtime_metadata() -> None: assert isinstance(home, Path) +def verify_gateway_process_identity() -> None: + from gateway.status import ( + _gateway_command_subcommand, + looks_like_gateway_command_line, + looks_like_gateway_runtime_command_line, + ) + + renamed = "/opt/hermes/.venv/bin/python /usr/local/bin/hermes.real gateway run" + upstream = "/opt/hermes/.venv/bin/python /usr/local/bin/hermes gateway run" + + assert looks_like_gateway_command_line(renamed) + assert looks_like_gateway_runtime_command_line(renamed) + assert _gateway_command_subcommand(renamed) == "run" + assert _gateway_command_subcommand( + "/opt/hermes/.venv/bin/python /usr/local/bin/hermes.real gateway restart" + ) == "restart" + + assert looks_like_gateway_command_line(upstream) + assert not looks_like_gateway_command_line( + "/opt/hermes/.venv/bin/python /usr/local/bin/hermes.real gateway status" + ) + assert not looks_like_gateway_command_line( + "/opt/hermes/.venv/bin/python /usr/local/bin/hermes.real dashboard" + ) + assert not looks_like_gateway_command_line("python -m tui_gateway run") + assert not looks_like_gateway_command_line( + "/opt/hermes/.venv/bin/python /usr/local/bin/hermes.realish gateway run" + ) + + def verify_cron_runtime_source() -> None: from cron.executions import EXECUTIONS_FILE from hermes_cli.backup import _QUICK_STATE_FILES @@ -384,6 +414,7 @@ def reopen_probe(conn): "discord-create": verify_discord_create, "discord-recovery-source": verify_discord_recovery_source, "discord-reopen": verify_discord_reopen, + "gateway-process-identity": verify_gateway_process_identity, "gateway-runtime-metadata": verify_gateway_runtime_metadata, "langfuse-credentials": verify_langfuse_credentials, "profile-policy": verify_profile_policy, diff --git a/agents/hermes/patch-gateway-process-identity.py b/agents/hermes/patch-gateway-process-identity.py new file mode 100755 index 00000000000..06bd5f6196a --- /dev/null +++ b/agents/hermes/patch-gateway-process-identity.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Teach the pinned Hermes gateway matcher about NemoClaw's renamed entrypoint. + +NemoClaw installs its own wrapper at ``/usr/local/bin/hermes`` and moves the +real Hermes entrypoint to ``/usr/local/bin/hermes.real``, so every managed +gateway runs as ``... /usr/local/bin/hermes.real gateway run``. + +``gateway.status._gateway_command_subcommand`` only accepts a command line whose +entry token has the basename ``hermes`` or ``hermes.exe``. The renamed +entrypoint therefore fails that check, and the matcher is the single gate in +front of both ways Hermes recognises its own gateway: the liveness re-check +inside ``get_running_pid()`` (so a valid PID file is discarded) and the +process-table fallback used by ``hermes_cli.gateway.find_gateway_pids()``. With +both defeated, ``hermes status`` reports the Gateway Service as stopped while +the foreground gateway is running and serving (#7804). + +Patch only the entry-token allowlist. The subcommand grammar around it is +unchanged, so a match still requires a real ``gateway run`` command line and no +other process becomes visible as a gateway. NemoClaw's own supervisor already +carries the same compensation for the rename in ``agents/hermes/start.sh``. + +Remove this patch when the minimum supported Hermes release resolves its +entrypoint by content or configuration rather than by executable basename. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +OLD_ENTRY_ALLOWLIST = ''' or any(t.rsplit("/", 1)[-1] in ("hermes", "hermes.exe") for t in tokens) +''' +NEW_ENTRY_ALLOWLIST = ''' or any( + t.rsplit("/", 1)[-1] in ("hermes", "hermes.exe", "hermes.real") + for t in tokens + ) +''' + + +def patch_file(path: Path) -> None: + source = path.read_text(encoding="utf-8") + + if source.count(OLD_ENTRY_ALLOWLIST) == 0 and source.count(NEW_ENTRY_ALLOWLIST) == 1: + return + + old_count = source.count(OLD_ENTRY_ALLOWLIST) + new_count = source.count(NEW_ENTRY_ALLOWLIST) + if old_count != 1 or new_count != 0: + raise SystemExit( + "ERROR: Hermes gateway entry-token allowlist source shape changed; " + f"expected one unpatched allowlist, found {old_count} " + f"(already patched allowlists: {new_count})" + ) + + path.write_text( + source.replace(OLD_ENTRY_ALLOWLIST, NEW_ENTRY_ALLOWLIST), encoding="utf-8" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "path", + nargs="?", + default="/opt/hermes/gateway/status.py", + help="Hermes gateway status module to patch", + ) + args = parser.parse_args() + patch_file(Path(args.path)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/lib/onboard/managed-startup/profile.ts b/src/lib/onboard/managed-startup/profile.ts index ca38731c495..a655ab2b519 100644 --- a/src/lib/onboard/managed-startup/profile.ts +++ b/src/lib/onboard/managed-startup/profile.ts @@ -740,6 +740,7 @@ export const MANAGED_STARTUP_PROFILE_EXCLUDED_DOCKER_INPUTS = { { input: "NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION", reason: "release-composition" }, { input: "NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256", reason: "integrity-pin" }, { input: "NEMOCLAW_HERMES_GATEWAY_RUNTIME_METADATA_PATCHER_SHA256", reason: "integrity-pin" }, + { input: "NEMOCLAW_HERMES_GATEWAY_PROCESS_IDENTITY_PATCHER_SHA256", reason: "integrity-pin" }, { input: "NEMOCLAW_HERMES_CRON_RUNTIME_PATCHER_SHA256", reason: "integrity-pin" }, { input: "NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256", reason: "integrity-pin" }, { input: "NEMOCLAW_HERMES_CRON_EXECUTIONS_SOURCE_SHA256", reason: "integrity-pin" }, diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index 6b6be64be86..27d4e1016cf 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -51,6 +51,11 @@ const HERMES_INTEGRITY_FILES = [ source: "agents/hermes/patch-gateway-runtime-metadata.py", target: "/opt/nemoclaw-hermes-config/patch-gateway-runtime-metadata.py", }, + { + arg: "NEMOCLAW_HERMES_GATEWAY_PROCESS_IDENTITY_PATCHER_SHA256", + source: "agents/hermes/patch-gateway-process-identity.py", + target: "/opt/nemoclaw-hermes-config/patch-gateway-process-identity.py", + }, { arg: "NEMOCLAW_HERMES_CRON_RUNTIME_PATCHER_SHA256", source: "agents/hermes/patch-cron-execution-runtime.py", @@ -214,6 +219,7 @@ describe("Hermes final image layout", () => { "COPY agents/hermes/config/ /opt/nemoclaw-hermes-config/config/", "COPY agents/hermes/image-build-probes.py /opt/nemoclaw-hermes-config/image-build-probes.py", "COPY agents/hermes/patch-gateway-runtime-metadata.py /opt/nemoclaw-hermes-config/patch-gateway-runtime-metadata.py", + "COPY agents/hermes/patch-gateway-process-identity.py /opt/nemoclaw-hermes-config/patch-gateway-process-identity.py", "COPY agents/hermes/patch-cron-execution-runtime.py /opt/nemoclaw-hermes-config/patch-cron-execution-runtime.py", "COPY agents/hermes/host/managed-tool-gateway-matrix.json /opt/nemoclaw-hermes-config/managed-tool-gateway-matrix.json", "COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts", diff --git a/test/hermes-gateway-process-identity-patch.test.ts b/test/hermes-gateway-process-identity-patch.test.ts new file mode 100644 index 00000000000..8aac341bab5 --- /dev/null +++ b/test/hermes-gateway-process-identity-patch.test.ts @@ -0,0 +1,190 @@ +// 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 { describe, expect, it } from "vitest"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const PATCHER = path.join(ROOT, "agents", "hermes", "patch-gateway-process-identity.py"); + +// The pinned Hermes matcher, reduced to the tokenizing/allowlist shape the +// patcher rewrites. Keeping the real grammar here means the assertions below +// exercise the actual decision, not a restatement of the patch. +const UPSTREAM_FIXTURE = `import shlex + + +def _gateway_command_subcommand(command): + if not command: + return None + + try: + raw_tokens = shlex.split(command, posix=False) + except ValueError: + raw_tokens = command.split() + tokens = [t.strip("\\"'").replace("\\\\", "/").lower() for t in raw_tokens] + if not tokens: + return None + + for token in tokens: + if token == "gateway/run.py" or token.endswith("/gateway/run.py"): + return "run" + basename = token.rsplit("/", 1)[-1] + if basename in ("hermes-gateway", "hermes-gateway.exe"): + return "run" + + joined = " ".join(tokens) + has_gateway_entry = ( + "hermes_cli.main" in joined + or "hermes_cli/main.py" in joined + or any(t.rsplit("/", 1)[-1] in ("hermes", "hermes.exe") for t in tokens) + ) + if not has_gateway_entry: + return None + + filtered = [] + skip_next = False + for token in tokens: + if skip_next: + skip_next = False + continue + if token in ("--profile", "-p"): + skip_next = True + continue + if token.startswith("--profile=") or token.startswith("-p="): + continue + filtered.append(token) + + for i, token in enumerate(filtered): + if token != "gateway": + continue + if i + 1 >= len(filtered): + return "run" + return filtered[i + 1] + return None + + +def looks_like_gateway_command_line(command): + return _gateway_command_subcommand(command) == "run" + + +def looks_like_gateway_runtime_command_line(command): + return _gateway_command_subcommand(command) in {"run", "restart"} + + +if __name__ == "__main__": + import json + import sys + + print( + json.dumps( + { + "subcommand": _gateway_command_subcommand(sys.argv[1]), + "run": looks_like_gateway_command_line(sys.argv[1]), + "runtime": looks_like_gateway_runtime_command_line(sys.argv[1]), + } + ) + ) +`; + +const RENAMED = "/opt/hermes/.venv/bin/python /usr/local/bin/hermes.real gateway run"; +const UPSTREAM_NAME = "/opt/hermes/.venv/bin/python /usr/local/bin/hermes gateway run"; + +function writeFixture(): { statusPath: string; tmp: string } { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-process-identity-")); + const statusPath = path.join(tmp, "status.py"); + fs.writeFileSync(statusPath, UPSTREAM_FIXTURE); + return { statusPath, tmp }; +} + +function runPatcher(statusPath: string) { + return spawnSync("python3", ["-I", PATCHER, statusPath], { + encoding: "utf-8", + timeout: 5000, + }); +} + +function classify(statusPath: string, commandLine: string) { + const result = spawnSync("python3", ["-I", statusPath, commandLine], { + encoding: "utf-8", + timeout: 5000, + }); + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout) as { + subcommand: string | null; + run: boolean; + runtime: boolean; + }; +} + +describe("Hermes gateway process identity", () => { + it("recognises the renamed entrypoint on both detection paths and stays idempotent", () => { + const { statusPath, tmp } = writeFixture(); + try { + // The unpatched matcher is what makes `hermes status` report a running + // foreground gateway as stopped (#7804): it gates the PID-file liveness + // re-check and the process-table fallback alike. + expect(classify(statusPath, RENAMED)).toEqual({ + subcommand: null, + run: false, + runtime: false, + }); + + for (const pass of [1, 2]) { + const result = runPatcher(statusPath); + expect(result.status, `pass ${pass}: ${result.stderr}`).toBe(0); + } + + expect(classify(statusPath, RENAMED)).toEqual({ + subcommand: "run", + run: true, + runtime: true, + }); + expect( + classify( + statusPath, + "/opt/hermes/.venv/bin/python /usr/local/bin/hermes.real gateway restart", + ), + ).toEqual({ subcommand: "restart", run: false, runtime: true }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("keeps the upstream name and the subcommand grammar intact", () => { + const { statusPath, tmp } = writeFixture(); + try { + expect(runPatcher(statusPath).status).toBe(0); + + // Widening the entry-token allowlist must not widen what counts as a + // gateway: only a real `gateway run` command line matches, and a + // look-alike basename is still rejected. + expect(classify(statusPath, UPSTREAM_NAME).run).toBe(true); + expect(classify(statusPath, "/usr/local/bin/hermes.real gateway status").run).toBe(false); + expect(classify(statusPath, "/usr/local/bin/hermes.real dashboard").run).toBe(false); + expect(classify(statusPath, "python -m tui_gateway run").run).toBe(false); + expect(classify(statusPath, "/usr/local/bin/hermes.realish gateway run").run).toBe(false); + expect( + classify(statusPath, "/usr/local/bin/hermes.real --profile alpha gateway run").run, + ).toBe(true); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("fails closed when the pinned allowlist shape changes", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-identity-drift-")); + try { + const drifted = path.join(tmp, "status.py"); + fs.writeFileSync(drifted, 'or any(t.rsplit("/", 1)[-1] in ("hermes",) for t in tokens)\n'); + const result = runPatcher(drifted); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("entry-token allowlist source shape changed"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/test/hermes-image-build-probes.test.ts b/test/hermes-image-build-probes.test.ts index 8715b53033d..035f48acda3 100644 --- a/test/hermes-image-build-probes.test.ts +++ b/test/hermes-image-build-probes.test.ts @@ -21,6 +21,7 @@ const commands = [ "discord-create", "discord-recovery-source", "discord-reopen", + "gateway-process-identity", "gateway-runtime-metadata", "langfuse-credentials", "profile-policy",