Repository navigation
fix(hermes): detect the renamed gateway entrypoint in status #7885
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
prekshivyas
merged 20 commits into
main
from
fix/hermes-status-detects-renamed-gateway-7804
Aug 1, 2026
Merged
Changes from 1 commit
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 576006b
test(hermes): drop the unused Dockerfile path constant
yanyunl1991 0b07af6
merge: refresh PR #7885 from upstream/main
senthilr-nv dd5daee
fix(onboard): classify the Hermes identity patch pin
senthilr-nv 1d32468
merge: refresh PR #7885 from upstream/main
senthilr-nv ff3cb86
merge: refresh PR #7885 from upstream/main
senthilr-nv ca4ccc7
merge: refresh PR #7885 from upstream/main
senthilr-nv ab37920
Merge branch 'main' into fix/hermes-status-detects-renamed-gateway-7804
senthilr-nv 2b740e1
Merge branch 'main' into fix/hermes-status-detects-renamed-gateway-7804
senthilr-nv 1cd9b31
Merge remote-tracking branch 'upstream/main' into codex/pr7885-review…
senthilr-nv 37422ae
Merge remote-tracking branch 'upstream/main' into codex/pr7885-review…
senthilr-nv 8c67383
fix(hermes): run process identity probe with legacy builders
senthilr-nv a2a9d6e
Merge remote-tracking branch 'upstream/main' into codex/pr7885-review…
senthilr-nv d4957c0
Merge remote-tracking branch 'upstream/fix/hermes-status-detects-rena…
senthilr-nv 8ca4e27
Merge remote-tracking branch 'upstream/main' into codex/pr7885-review…
senthilr-nv 9a42cff
Merge remote-tracking branch 'upstream/main' into codex/pr7885-review…
senthilr-nv f1cfeb1
Merge branch 'main' into fix/hermes-status-detects-renamed-gateway-7804
senthilr-nv 504b365
Merge remote-tracking branch 'upstream/pr/7885' into codex/pr7885-rev…
senthilr-nv e157c9b
Merge branch 'main' into fix/hermes-status-detects-renamed-gateway-7804
senthilr-nv 3f69c6b
Merge branch 'main' into fix/hermes-status-detects-renamed-gateway-7804
senthilr-nv File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,191 @@ | ||
| // 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"); | ||
| const DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
|
|
||
| // 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 }); | ||
| } | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.