Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
23bd5c7
fix(hermes): detect the renamed gateway entrypoint in status
yanyunl1991 Jul 30, 2026
576006b
test(hermes): drop the unused Dockerfile path constant
yanyunl1991 Jul 30, 2026
0b07af6
merge: refresh PR #7885 from upstream/main
senthilr-nv Jul 31, 2026
dd5daee
fix(onboard): classify the Hermes identity patch pin
senthilr-nv Jul 31, 2026
1d32468
merge: refresh PR #7885 from upstream/main
senthilr-nv Jul 31, 2026
ff3cb86
merge: refresh PR #7885 from upstream/main
senthilr-nv Jul 31, 2026
ca4ccc7
merge: refresh PR #7885 from upstream/main
senthilr-nv Jul 31, 2026
ab37920
Merge branch 'main' into fix/hermes-status-detects-renamed-gateway-7804
senthilr-nv Jul 31, 2026
2b740e1
Merge branch 'main' into fix/hermes-status-detects-renamed-gateway-7804
senthilr-nv Jul 31, 2026
1cd9b31
Merge remote-tracking branch 'upstream/main' into codex/pr7885-review…
senthilr-nv Jul 31, 2026
37422ae
Merge remote-tracking branch 'upstream/main' into codex/pr7885-review…
senthilr-nv Jul 31, 2026
8c67383
fix(hermes): run process identity probe with legacy builders
senthilr-nv Jul 31, 2026
a2a9d6e
Merge remote-tracking branch 'upstream/main' into codex/pr7885-review…
senthilr-nv Jul 31, 2026
d4957c0
Merge remote-tracking branch 'upstream/fix/hermes-status-detects-rena…
senthilr-nv Jul 31, 2026
8ca4e27
Merge remote-tracking branch 'upstream/main' into codex/pr7885-review…
senthilr-nv Jul 31, 2026
9a42cff
Merge remote-tracking branch 'upstream/main' into codex/pr7885-review…
senthilr-nv Jul 31, 2026
f1cfeb1
Merge branch 'main' into fix/hermes-status-detects-renamed-gateway-7804
senthilr-nv Jul 31, 2026
504b365
Merge remote-tracking branch 'upstream/pr/7885' into codex/pr7885-rev…
senthilr-nv Jul 31, 2026
e157c9b
Merge branch 'main' into fix/hermes-status-detects-renamed-gateway-7804
senthilr-nv Jul 31, 2026
3f69c6b
Merge branch 'main' into fix/hermes-status-detects-renamed-gateway-7804
senthilr-nv Aug 1, 2026
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
20 changes: 19 additions & 1 deletion agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions agents/hermes/image-build-probes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
76 changes: 76 additions & 0 deletions agents/hermes/patch-gateway-process-identity.py
Original file line number Diff line number Diff line change
@@ -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())
1 change: 1 addition & 0 deletions src/lib/onboard/managed-startup/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
6 changes: 6 additions & 0 deletions test/hermes-final-image-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
190 changes: 190 additions & 0 deletions test/hermes-gateway-process-identity-patch.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
1 change: 1 addition & 0 deletions test/hermes-image-build-probes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const commands = [
"discord-create",
"discord-recovery-source",
"discord-reopen",
"gateway-process-identity",
"gateway-runtime-metadata",
"langfuse-credentials",
"profile-policy",
Expand Down
Loading