From ea21a24adf7f739b5684d16fde5e988448ac7532 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Mon, 6 Jul 2026 16:29:47 +0800 Subject: [PATCH 01/34] fix(hermes): append resumed one-shot turns --- agents/hermes/hermes-wrapper.py | 128 +++++++++++++++++++++++++++- test/hermes-gateway-wrapper.test.ts | 45 ++++++++++ 2 files changed, 170 insertions(+), 3 deletions(-) diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index a33fb4b043d..643fe6cea29 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -68,9 +68,8 @@ # bypass: every path that launches the gateway now passes through the same # single-source-of-truth validator before the port is bound. # -# Only the `gateway` and `config show` subcommands are intercepted; all -# other hermes subcommands (dashboard, --version, ...) pass straight -# through unchanged. +# Only a small set of top-level commands are intercepted; all other hermes +# subcommands (dashboard, --version, ...) pass straight through unchanged. import os import subprocess @@ -216,6 +215,126 @@ def _run_gateway_guard(guard_path: str) -> int: return subprocess.call([python3, "-I", guard_path, "runtime-env"]) +_VALUE_FLAGS = { + "-m": "--model", + "--model": "--model", + "--provider": "--provider", + "-t": "--toolsets", + "--toolsets": "--toolsets", + "-s": "--skills", + "--skills": "--skills", + "-r": "--resume", + "--resume": "--resume", +} +_BOOLEAN_FLAGS = { + "--worktree", + "-w", + "--accept-hooks", + "--yolo", + "--pass-session-id", + "--ignore-user-config", + "--ignore-rules", +} + + +def _split_flag_value(arg: str) -> tuple[str, str] | None: + if not arg.startswith("--") or "=" not in arg: + return None + name, value = arg.split("=", 1) + return name, value + + +def _translate_resumed_oneshot(argv: list[str]) -> list[str] | None: + """Route resumed oneshot invocations through Hermes' native chat resume path. + + Upstream Hermes handles top-level `-z/--oneshot` before the normal + `--resume`/`--continue` chat shortcut. In affected versions the resumed + session is available as context, but the one-shot turn is persisted under a + newly generated session id. The `chat --query --quiet --resume ...` path is + the native non-interactive route that appends to the selected session, so + translate only the composed top-level form and leave plain one-shot + invocations untouched. + """ + oneshot_prompt: str | None = None + resume_args: list[str] = [] + passthrough: list[str] = [] + saw_resume = False + saw_continue = False + + i = 0 + while i < len(argv): + arg = argv[i] + + if arg == "--": + return None + + split = _split_flag_value(arg) + if split is not None: + name, value = split + if name == "--oneshot": + oneshot_prompt = value + elif name == "--continue": + saw_continue = True + resume_args.extend(["--continue", value]) + elif name in _VALUE_FLAGS: + canonical = _VALUE_FLAGS[name] + if canonical == "--resume": + saw_resume = True + resume_args.extend([canonical, value]) + else: + passthrough.extend([canonical, value]) + else: + return None + i += 1 + continue + + if arg in ("-z", "--oneshot"): + if i + 1 >= len(argv): + return None + oneshot_prompt = argv[i + 1] + i += 2 + continue + + if arg in _VALUE_FLAGS: + if i + 1 >= len(argv): + return None + canonical = _VALUE_FLAGS[arg] + value = argv[i + 1] + if canonical == "--resume": + saw_resume = True + resume_args.extend([canonical, value]) + else: + passthrough.extend([canonical, value]) + i += 2 + continue + + if arg in ("-c", "--continue"): + saw_continue = True + resume_args.append("--continue") + if i + 1 < len(argv) and not argv[i + 1].startswith("-"): + resume_args.append(argv[i + 1]) + i += 2 + else: + i += 1 + continue + + if arg in _BOOLEAN_FLAGS: + passthrough.append(arg) + i += 1 + continue + + # A positional command means this is not the top-level one-shot form. + return None + + if not oneshot_prompt or not (saw_resume or saw_continue): + return None + + translated = ["chat", "--query", oneshot_prompt, "--quiet", "--yolo", "--accept-hooks"] + translated.extend(resume_args) + translated.extend(passthrough) + return translated + + def main(argv: list[str]) -> int: real_hermes = _resolve_real_hermes() guard_path = _resolve_guard() @@ -225,6 +344,9 @@ def main(argv: list[str]) -> int: rc = _run_gateway_guard(guard_path) if rc != 0: return rc + translated = _translate_resumed_oneshot(argv) + if translated is not None: + os.execv(real_hermes, [real_hermes, *translated]) os.execv(real_hermes, [real_hermes, *argv]) return 1 diff --git a/test/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index 4154a45e996..d5c2ac1d0ec 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -214,6 +214,51 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(run.realArgs).toBe("dashboard"); }); + it("routes resumed one-shot invocations through chat query so Hermes appends to the target session (#5254)", () => { + const run = runWrapper( + ["--resume", "20260612_050401_aa9d27", "-z", "What secret number did I give you?"], + {}, + ); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe( + "chat --query What secret number did I give you? --quiet --yolo --accept-hooks --resume 20260612_050401_aa9d27", + ); + }); + + it("routes continued one-shot invocations through chat query while preserving model/tool flags (#5254)", () => { + const run = runWrapper( + [ + "-c", + "daily check", + "--oneshot=Summarize the latest turn", + "--model", + "anthropic/claude-sonnet-4", + "--toolsets=memory,session_search", + "--ignore-rules", + ], + {}, + ); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe( + "chat --query Summarize the latest turn --quiet --yolo --accept-hooks --continue daily check --model anthropic/claude-sonnet-4 --toolsets memory,session_search --ignore-rules", + ); + }); + + it("leaves plain one-shot invocations on the upstream one-shot path (#5254)", () => { + const run = runWrapper(["-z", "Reply pong"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("-z Reply pong"); + }); + it("passes --version through (build assertion path) without invoking the guard", () => { const run = runWrapper(["--version"], { SLACK_BOT_TOKEN: "xoxb-real-1234567890" }); From 28a9a4d6a1e3daa359ce6c4791c1aeec496dbf61 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 00:43:11 +0800 Subject: [PATCH 02/34] fix(hermes): harden resumed oneshot rewrite --- agents/hermes/hermes-wrapper.py | 32 +++++++++++ test/hermes-gateway-wrapper.test.ts | 83 +++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index 643fe6cea29..2f0a5667c97 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -226,6 +226,9 @@ def _run_gateway_guard(guard_path: str) -> int: "-r": "--resume", "--resume": "--resume", } +# Keep this allowlist aligned with the top-level flags accepted by the Hermes +# Agent CLI used in the sandbox image. Unknown flags deliberately fail closed by +# passing the original argv through to upstream Hermes. _BOOLEAN_FLAGS = { "--worktree", "-w", @@ -254,12 +257,27 @@ def _translate_resumed_oneshot(argv: list[str]) -> list[str] | None: the native non-interactive route that appends to the selected session, so translate only the composed top-level form and leave plain one-shot invocations untouched. + + NemoClaw owns this installed wrapper, not the prebuilt Hermes Agent binary + inside the sandbox base image, so the wrapper is the smallest compatibility + boundary available here. Delete this translation once Hermes top-level + `--resume/-c` plus `-z/--oneshot` natively appends the new user/assistant + turn to the selected session without creating a fresh session id. Until + then, wrapper argv tests cover the routed form and the fail-closed cases; + live sandbox validation should verify the persisted `sessions list/export` + behavior when a matching Hermes runtime is available. + + Top-level one-shot is already non-interactive: Hermes' one-shot runner sets + `HERMES_YOLO_MODE=1` and `HERMES_ACCEPT_HOOKS=1`. The translated chat query + therefore includes `--yolo --accept-hooks` to preserve that approval and + hook policy rather than to broaden user intent. """ oneshot_prompt: str | None = None resume_args: list[str] = [] passthrough: list[str] = [] saw_resume = False saw_continue = False + saw_oneshot = False i = 0 while i < len(argv): @@ -272,13 +290,20 @@ def _translate_resumed_oneshot(argv: list[str]) -> list[str] | None: if split is not None: name, value = split if name == "--oneshot": + if saw_oneshot: + return None + saw_oneshot = True oneshot_prompt = value elif name == "--continue": + if saw_resume or saw_continue: + return None saw_continue = True resume_args.extend(["--continue", value]) elif name in _VALUE_FLAGS: canonical = _VALUE_FLAGS[name] if canonical == "--resume": + if saw_resume or saw_continue: + return None saw_resume = True resume_args.extend([canonical, value]) else: @@ -291,6 +316,9 @@ def _translate_resumed_oneshot(argv: list[str]) -> list[str] | None: if arg in ("-z", "--oneshot"): if i + 1 >= len(argv): return None + if saw_oneshot: + return None + saw_oneshot = True oneshot_prompt = argv[i + 1] i += 2 continue @@ -301,6 +329,8 @@ def _translate_resumed_oneshot(argv: list[str]) -> list[str] | None: canonical = _VALUE_FLAGS[arg] value = argv[i + 1] if canonical == "--resume": + if saw_resume or saw_continue: + return None saw_resume = True resume_args.extend([canonical, value]) else: @@ -309,6 +339,8 @@ def _translate_resumed_oneshot(argv: list[str]) -> list[str] | None: continue if arg in ("-c", "--continue"): + if saw_resume or saw_continue: + return None saw_continue = True resume_args.append("--continue") if i + 1 < len(argv) and not argv[i + 1].startswith("-"): diff --git a/test/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index d5c2ac1d0ec..8dd9dd1a873 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -259,6 +259,89 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(run.realArgs).toBe("-z Reply pong"); }); + it("routes equals-style resumed one-shot invocations through chat query (#5254)", () => { + const run = runWrapper(["--resume=20260612_050401_aa9d27", "--oneshot=Repeat it"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe( + "chat --query Repeat it --quiet --yolo --accept-hooks --resume 20260612_050401_aa9d27", + ); + }); + + it("passes positional subcommands through instead of translating nested one-shot flags (#5254)", () => { + const run = runWrapper(["chat", "--resume", "20260612_050401_aa9d27", "-z", "Repeat it"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("chat --resume 20260612_050401_aa9d27 -z Repeat it"); + }); + + it("passes unknown flags through instead of translating a partial allowlist match (#5254)", () => { + const run = runWrapper( + ["--resume", "20260612_050401_aa9d27", "--unknown", "-z", "Repeat it"], + {}, + ); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--resume 20260612_050401_aa9d27 --unknown -z Repeat it"); + }); + + it("passes argv with -- marker through instead of translating after argument termination (#5254)", () => { + const run = runWrapper(["--resume", "20260612_050401_aa9d27", "--", "-z", "Repeat it"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--resume 20260612_050401_aa9d27 -- -z Repeat it"); + }); + + it("passes multiple resume selectors through instead of translating ambiguous targets (#5254)", () => { + const run = runWrapper( + [ + "--resume", + "20260612_050401_aa9d27", + "--resume", + "20260612_050446_924bd8", + "-z", + "Repeat it", + ], + {}, + ); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe( + "--resume 20260612_050401_aa9d27 --resume 20260612_050446_924bd8 -z Repeat it", + ); + }); + + it("passes multiple one-shot prompts through instead of dropping an earlier prompt (#5254)", () => { + const run = runWrapper( + ["-z", "First prompt", "-z", "Second prompt", "--resume", "20260612_050401_aa9d27"], + {}, + ); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("-z First prompt -z Second prompt --resume 20260612_050401_aa9d27"); + }); + + it("passes empty one-shot prompts through instead of translating an invalid query (#5254)", () => { + const run = runWrapper(["--oneshot=", "--resume", "20260612_050401_aa9d27"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--oneshot= --resume 20260612_050401_aa9d27"); + }); + it("passes --version through (build assertion path) without invoking the guard", () => { const run = runWrapper(["--version"], { SLACK_BOT_TOKEN: "xoxb-real-1234567890" }); From a191f8e650709d548207dd039ff29c44d293ca57 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 01:03:03 +0800 Subject: [PATCH 03/34] test(sandbox): accept rebuild notice in recovery tests --- .../sandbox/rebuild-prepared-recovery.test.ts | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts b/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts index 3f0935d67de..5a751f28026 100644 --- a/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts +++ b/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts @@ -1,29 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { createRequire } from "node:module"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createRebuildFlowHarness, makePreparedRecoveryManifest, - snapshotEnv, + resetRebuildFlowTestEnvironment, + restoreRebuildFlowTestEnvironment, } from "../../../../test/helpers/rebuild-flow-harness"; -const requireDist = createRequire(import.meta.url); -const rebuildModulePath = "./rebuild.js"; -const restoreSandboxEnv = snapshotEnv(["NEMOCLAW_SANDBOX_NAME"]); - describe("prepared rebuild recovery", () => { - beforeEach(() => { - delete process.env.NEMOCLAW_SANDBOX_NAME; - }); - - afterEach(() => { - vi.restoreAllMocks(); - delete require.cache[requireDist.resolve(rebuildModulePath)]; - restoreSandboxEnv(); - }); + beforeEach(resetRebuildFlowTestEnvironment); + afterEach(restoreRebuildFlowTestEnvironment); it("restores the validated pre-upgrade manifest without taking a second backup (#6114)", async () => { const harness = createRebuildFlowHarness({ From beb88ff0e0be5b9c1670559b7e59311b6e52f041 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 01:38:04 +0800 Subject: [PATCH 04/34] fix(hermes): reject empty continue selectors --- agents/hermes/hermes-wrapper.py | 13 ++++++++----- test/hermes-gateway-wrapper.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index 2f0a5667c97..3e2cea1f046 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -295,6 +295,8 @@ def _translate_resumed_oneshot(argv: list[str]) -> list[str] | None: saw_oneshot = True oneshot_prompt = value elif name == "--continue": + if not value: + return None if saw_resume or saw_continue: return None saw_continue = True @@ -302,6 +304,8 @@ def _translate_resumed_oneshot(argv: list[str]) -> list[str] | None: elif name in _VALUE_FLAGS: canonical = _VALUE_FLAGS[name] if canonical == "--resume": + if not value: + return None if saw_resume or saw_continue: return None saw_resume = True @@ -341,13 +345,12 @@ def _translate_resumed_oneshot(argv: list[str]) -> list[str] | None: if arg in ("-c", "--continue"): if saw_resume or saw_continue: return None + if i + 1 >= len(argv) or argv[i + 1].startswith("-"): + return None saw_continue = True resume_args.append("--continue") - if i + 1 < len(argv) and not argv[i + 1].startswith("-"): - resume_args.append(argv[i + 1]) - i += 2 - else: - i += 1 + resume_args.append(argv[i + 1]) + i += 2 continue if arg in _BOOLEAN_FLAGS: diff --git a/test/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index 8dd9dd1a873..4cd6695c639 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -342,6 +342,33 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(run.realArgs).toBe("--oneshot= --resume 20260612_050401_aa9d27"); }); + it("passes --continue without a value through instead of translating a bare selector (#5254)", () => { + const run = runWrapper(["--continue", "-z", "Repeat it"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--continue -z Repeat it"); + }); + + it("passes empty --continue values through instead of translating an invalid selector (#5254)", () => { + const run = runWrapper(["--continue=", "-z", "Repeat it"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--continue= -z Repeat it"); + }); + + it("passes empty --resume values through instead of translating an invalid selector (#5254)", () => { + const run = runWrapper(["--resume=", "-z", "Repeat it"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--resume= -z Repeat it"); + }); + it("passes --version through (build assertion path) without invoking the guard", () => { const run = runWrapper(["--version"], { SLACK_BOT_TOKEN: "xoxb-real-1234567890" }); From 32dc4216e2dea0248e1d2e45e14f79eca6804be5 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 02:06:50 +0800 Subject: [PATCH 05/34] fix(hermes): fail closed on malformed resume selectors --- agents/hermes/hermes-wrapper.py | 14 +++-- test/hermes-gateway-wrapper.test.ts | 94 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index 3e2cea1f046..31cd555e560 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -226,9 +226,11 @@ def _run_gateway_guard(guard_path: str) -> int: "-r": "--resume", "--resume": "--resume", } -# Keep this allowlist aligned with the top-level flags accepted by the Hermes -# Agent CLI used in the sandbox image. Unknown flags deliberately fail closed by -# passing the original argv through to upstream Hermes. +# Keep this allowlist aligned with the top-level flags accepted by the pinned +# Hermes Agent CLI in agents/hermes/Dockerfile.base (HERMES_VERSION=v2026.6.19, +# HERMES_SEMVER=0.17.0) and agents/hermes/manifest.yaml (expected_version +# "0.17.0"). Unknown flags deliberately fail closed by passing the original argv +# through to upstream Hermes. _BOOLEAN_FLAGS = { "--worktree", "-w", @@ -318,7 +320,7 @@ def _translate_resumed_oneshot(argv: list[str]) -> list[str] | None: continue if arg in ("-z", "--oneshot"): - if i + 1 >= len(argv): + if i + 1 >= len(argv) or argv[i + 1].startswith("-"): return None if saw_oneshot: return None @@ -328,10 +330,12 @@ def _translate_resumed_oneshot(argv: list[str]) -> list[str] | None: continue if arg in _VALUE_FLAGS: - if i + 1 >= len(argv): + if i + 1 >= len(argv) or argv[i + 1].startswith("-"): return None canonical = _VALUE_FLAGS[arg] value = argv[i + 1] + if not value: + return None if canonical == "--resume": if saw_resume or saw_continue: return None diff --git a/test/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index 4cd6695c639..62a74ebfc25 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -55,6 +55,20 @@ type StubBehaviour = { exitCode?: number; }; +function truthyEnv(value: string | undefined): boolean { + return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? ""); +} + +function sessionIds(output: string): Set { + return new Set(output.match(/\b[0-9]{8}_[0-9]{6}_[a-zA-Z0-9]+\b/g) ?? []); +} + +function onlyNewSessionId(before: Set, after: Set): string { + const created = [...after].filter((id) => !before.has(id)); + expect(created).toHaveLength(1); + return created[0]; +} + // Run the wrapper against a temp install: a copy of the wrapper alongside the // real validator and a `hermes.real` stub. The wrapper's dev fallback resolves // both from its own directory because the /usr/local install paths are absent. @@ -369,6 +383,86 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(run.realArgs).toBe("--resume= -z Repeat it"); }); + it("passes space-form one-shot without a prompt through instead of treating a flag as the prompt (#5254)", () => { + const run = runWrapper(["-z", "--resume", "20260612_050401_aa9d27"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("-z --resume 20260612_050401_aa9d27"); + }); + + it("passes separated --resume with an empty value through instead of translating an invalid selector (#5254)", () => { + const run = runWrapper(["--resume", "", "-z", "Repeat it"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--resume -z Repeat it"); + }); + + it("passes separated --resume with a flag-like value through instead of translating an invalid selector (#5254)", () => { + const run = runWrapper(["--resume", "-z", "--oneshot=Repeat it"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--resume -z --oneshot=Repeat it"); + }); + + it("passes value flags without required arguments through instead of translating partial argv (#5254)", () => { + const run = runWrapper( + ["--model", "--resume", "20260612_050401_aa9d27", "-z", "Repeat it"], + {}, + ); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--model --resume 20260612_050401_aa9d27 -z Repeat it"); + }); + + it.skipIf(!truthyEnv(process.env.NEMOCLAW_HERMES_SESSION_PERSISTENCE_RUNTIME))( + "validates real resumed one-shot session persistence when a live Hermes runtime is available (#5254)", + () => { + const hermesBin = process.env.NEMOCLAW_HERMES_BIN ?? "hermes"; + const timeout = Number(process.env.NEMOCLAW_HERMES_SESSION_PERSISTENCE_TIMEOUT_MS ?? 120_000); + const env = { ...process.env }; + const marker = `NEMOCLAW_5254_${Date.now()}`; + const runHermes = (args: string[]) => { + const run = spawnSync(hermesBin, args, { + encoding: "utf-8", + timeout, + env, + }); + expect(run.status, `${hermesBin} ${args.join(" ")}\n${run.stderr}`).toBe(0); + return `${run.stdout ?? ""}\n${run.stderr ?? ""}`; + }; + const listSessions = () => sessionIds(runHermes(["sessions", "list"])); + + const beforeSeed = listSessions(); + const seedPrompt = `Remember this exact token: ${marker}. Reply with acknowledged.`; + runHermes(["-z", seedPrompt]); + const seedSessionId = onlyNewSessionId(beforeSeed, listSessions()); + + const beforeResume = listSessions(); + const resumePrompt = `Repeat this exact token: ${marker}.`; + runHermes(["--resume", seedSessionId, "-z", resumePrompt]); + expect([...listSessions()].filter((id) => !beforeResume.has(id))).toEqual([]); + + const beforeContinue = listSessions(); + const continuePrompt = `Confirm this exact token again: ${marker}.`; + runHermes(["-c", seedSessionId, "-z", continuePrompt]); + expect([...listSessions()].filter((id) => !beforeContinue.has(id))).toEqual([]); + + const exported = runHermes(["sessions", "export", "--session-id", seedSessionId]); + expect(exported).toContain(seedPrompt); + expect(exported).toContain(resumePrompt); + expect(exported).toContain(continuePrompt); + }, + 5 * 60_000, + ); + it("passes --version through (build assertion path) without invoking the guard", () => { const run = runWrapper(["--version"], { SLACK_BOT_TOKEN: "xoxb-real-1234567890" }); From d088c3dcb9624ee89fb62eb08527b4c050c07a4c Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 02:28:48 +0800 Subject: [PATCH 06/34] fix(hermes): require runtime resume persistence evidence --- agents/hermes/Dockerfile | 3 +- agents/hermes/hermes-wrapper.py | 10 ++-- test/e2e/live/hermes-e2e.test.ts | 72 +++++++++++++++++++++++++++++ test/hermes-gateway-wrapper.test.ts | 59 +++++++++++++++++++---- 4 files changed, 130 insertions(+), 14 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index bbe4b726792..e65a7a12aff 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -181,7 +181,7 @@ RUN test -x /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py \ # chain tampering of the build context (an attacker rewriting the file has to # also rewrite the Dockerfile-committed hash, which reviewers gate). Regenerate # with `sha256sum agents/hermes/{hermes-wrapper.py,validate-env-secret-boundary.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=03e0afbe00e352d0dfcf14b99ea1821f9fd29f87dad49ce19add2ec96d1941cc +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=901c5c17a0c12eacb5958a7558f84f4da83a8a10a4bd80b560671e04718e1bd2 ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=970d7ff03bc409ff1d5ca46bfdbd2a42ac28a32a810ccc147a508301bff38496 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ @@ -203,6 +203,7 @@ RUN printf '%s %s\n' \ COPY agents/hermes/hermes-wrapper.py /usr/local/lib/nemoclaw/hermes-wrapper.py RUN test -x /usr/bin/python3 \ || { echo "ERROR: /usr/bin/python3 missing or not executable; hermes-wrapper shebang would ENOEXEC" >&2; exit 1; } +RUN /usr/bin/python3 -I -c 'import ast, pathlib, subprocess, sys; source = pathlib.Path("/usr/local/lib/nemoclaw/hermes-wrapper.py").read_text(); tree = ast.parse(source); constants = {node.targets[0].id: ast.literal_eval(node.value) for node in tree.body if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name) and node.targets[0].id in {"_VALUE_FLAGS", "_BOOLEAN_FLAGS"}}; expected = set(constants["_VALUE_FLAGS"]) | set(constants["_VALUE_FLAGS"].values()) | set(constants["_BOOLEAN_FLAGS"]); help_text = subprocess.check_output(["/usr/local/bin/hermes", "--help"], text=True); missing = sorted(flag for flag in expected if flag not in help_text); sys.exit("ERROR: Hermes wrapper flag allowlist drifted from pinned hermes --help: " + ", ".join(missing) if missing else 0)' # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_WRAPPER_SHA256" /usr/local/lib/nemoclaw/hermes-wrapper.py \ diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index 31cd555e560..0693fef6a26 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -269,10 +269,10 @@ def _translate_resumed_oneshot(argv: list[str]) -> list[str] | None: live sandbox validation should verify the persisted `sessions list/export` behavior when a matching Hermes runtime is available. - Top-level one-shot is already non-interactive: Hermes' one-shot runner sets - `HERMES_YOLO_MODE=1` and `HERMES_ACCEPT_HOOKS=1`. The translated chat query - therefore includes `--yolo --accept-hooks` to preserve that approval and - hook policy rather than to broaden user intent. + Preserve approval-related user intent instead of inferring it here: + `--yolo` and `--accept-hooks` are forwarded only when the original argv + included those flags. The underlying Hermes one-shot policy can change + across releases, so this compatibility layer avoids broadening approvals. """ oneshot_prompt: str | None = None resume_args: list[str] = [] @@ -368,7 +368,7 @@ def _translate_resumed_oneshot(argv: list[str]) -> list[str] | None: if not oneshot_prompt or not (saw_resume or saw_continue): return None - translated = ["chat", "--query", oneshot_prompt, "--quiet", "--yolo", "--accept-hooks"] + translated = ["chat", "--query", oneshot_prompt, "--quiet"] translated.extend(resume_args) translated.extend(passthrough) return translated diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index 0463235494b..02037f040ef 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -183,6 +183,16 @@ function stripAnsi(value: string): string { return value.replace(/\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g, ""); } +function hermesSessionIds(output: string): Set { + return new Set(output.match(/\b[0-9]{8}_[0-9]{6}_[a-zA-Z0-9]+\b/g) ?? []); +} + +function onlyNewHermesSessionId(before: Set, after: Set): string { + const created = [...after].filter((id) => !before.has(id)); + expect(created).toHaveLength(1); + return created[0]; +} + function forwardListHasRunningPort(output: string, sandboxName: string, port: string): boolean { return output .split("\n") @@ -449,6 +459,68 @@ test.skipIf(!shouldRunLiveE2E())( expect(configProbe.exitCode, resultText(configProbe)).toBe(0); expect(configProbe.stdout).toContain("OK"); + // Regression coverage for #5254 against the real sandbox-installed Hermes + // runtime: top-level resumed/continued one-shot invocations must append to + // the selected session instead of creating a new follow-up session. + const runHermesCli = async (args: string[], artifactName: string, timeoutMs = 180_000) => { + const result = await sandbox.exec(SANDBOX_NAME, ["hermes", ...args], { + artifactName, + env: commandEnv(), + redactionValues, + timeoutMs, + }); + expect(result.exitCode, resultText(result)).toBe(0); + return resultText(result); + }; + const listHermesSessions = async (artifactName: string) => + hermesSessionIds(await runHermesCli(["sessions", "list"], artifactName, 60_000)); + + const issue5254Marker = `NEMOCLAW_5254_${Date.now()}`; + const beforeSeedSessions = await listHermesSessions("phase-4-issue-5254-sessions-before-seed"); + const seedPrompt = `Remember this exact token: ${issue5254Marker}. Reply with acknowledged.`; + await runHermesCli(["-z", seedPrompt], "phase-4-issue-5254-seed-oneshot"); + const seedSessionId = onlyNewHermesSessionId( + beforeSeedSessions, + await listHermesSessions("phase-4-issue-5254-sessions-after-seed"), + ); + + const beforeResumeSessions = await listHermesSessions( + "phase-4-issue-5254-sessions-before-resume", + ); + const resumePrompt = `Repeat this exact token: ${issue5254Marker}.`; + await runHermesCli( + ["--resume", seedSessionId, "-z", resumePrompt], + "phase-4-issue-5254-resume-oneshot", + ); + expect( + [...(await listHermesSessions("phase-4-issue-5254-sessions-after-resume"))].filter( + (id) => !beforeResumeSessions.has(id), + ), + ).toEqual([]); + + const beforeContinueSessions = await listHermesSessions( + "phase-4-issue-5254-sessions-before-continue", + ); + const continuePrompt = `Confirm this exact token again: ${issue5254Marker}.`; + await runHermesCli( + ["-c", seedSessionId, "-z", continuePrompt], + "phase-4-issue-5254-continue-oneshot", + ); + expect( + [...(await listHermesSessions("phase-4-issue-5254-sessions-after-continue"))].filter( + (id) => !beforeContinueSessions.has(id), + ), + ).toEqual([]); + + const exportedSession = await runHermesCli( + ["sessions", "export", "--session-id", seedSessionId], + "phase-4-issue-5254-export-session", + 60_000, + ); + expect(exportedSession).toContain(seedPrompt); + expect(exportedSession).toContain(resumePrompt); + expect(exportedSession).toContain(continuePrompt); + if (hermesDashboardE2eEnabled()) { const entry = registryEntry(SANDBOX_NAME); expect(entry, `registry missing ${SANDBOX_NAME}`).toBeTruthy(); diff --git a/test/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index 62a74ebfc25..726f8a211f1 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -47,6 +47,7 @@ type WrapperRun = { stderr: string; realInvoked: boolean; realArgs: string; + realArgv: string[]; }; type StubBehaviour = { @@ -105,7 +106,7 @@ function runWrapper( const stubExit = opts.stub?.exitCode ?? 0; const stubScript = [ "#!/usr/bin/env bash", - `printf '%s' "$*" > ${JSON.stringify(marker)}`, + `node -e 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)))' ${JSON.stringify(marker)} "$@"`, stubStdout ? `cat <<'__NEMOCLAW_STUB_EOF__'\n${stubStdout}\n__NEMOCLAW_STUB_EOF__` : "", stubStderr ? `cat <<'__NEMOCLAW_STUB_ERR_EOF__' >&2\n${stubStderr}\n__NEMOCLAW_STUB_ERR_EOF__` @@ -140,12 +141,14 @@ function runWrapper( }); const realInvoked = fs.existsSync(marker); + const realArgv = realInvoked ? JSON.parse(fs.readFileSync(marker, "utf-8")) : []; return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "", realInvoked, - realArgs: realInvoked ? fs.readFileSync(marker, "utf-8") : "", + realArgs: realArgv.join(" "), + realArgv, }; } finally { fs.rmSync(dir, { recursive: true, force: true }); @@ -238,8 +241,16 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(run.stderr).toBe(""); expect(run.realInvoked).toBe(true); expect(run.realArgs).toBe( - "chat --query What secret number did I give you? --quiet --yolo --accept-hooks --resume 20260612_050401_aa9d27", + "chat --query What secret number did I give you? --quiet --resume 20260612_050401_aa9d27", ); + expect(run.realArgv).toEqual([ + "chat", + "--query", + "What secret number did I give you?", + "--quiet", + "--resume", + "20260612_050401_aa9d27", + ]); }); it("routes continued one-shot invocations through chat query while preserving model/tool flags (#5254)", () => { @@ -260,8 +271,42 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(run.stderr).toBe(""); expect(run.realInvoked).toBe(true); expect(run.realArgs).toBe( - "chat --query Summarize the latest turn --quiet --yolo --accept-hooks --continue daily check --model anthropic/claude-sonnet-4 --toolsets memory,session_search --ignore-rules", + "chat --query Summarize the latest turn --quiet --continue daily check --model anthropic/claude-sonnet-4 --toolsets memory,session_search --ignore-rules", + ); + expect(run.realArgv).toEqual([ + "chat", + "--query", + "Summarize the latest turn", + "--quiet", + "--continue", + "daily check", + "--model", + "anthropic/claude-sonnet-4", + "--toolsets", + "memory,session_search", + "--ignore-rules", + ]); + }); + + it("preserves explicit approval flags without adding them to ordinary resumed one-shot invocations (#5254)", () => { + const run = runWrapper( + ["--resume", "20260612_050401_aa9d27", "-z", "Repeat it", "--yolo", "--accept-hooks"], + {}, ); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgv).toEqual([ + "chat", + "--query", + "Repeat it", + "--quiet", + "--resume", + "20260612_050401_aa9d27", + "--yolo", + "--accept-hooks", + ]); }); it("leaves plain one-shot invocations on the upstream one-shot path (#5254)", () => { @@ -279,9 +324,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(run.status).toBe(0); expect(run.stderr).toBe(""); expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe( - "chat --query Repeat it --quiet --yolo --accept-hooks --resume 20260612_050401_aa9d27", - ); + expect(run.realArgs).toBe("chat --query Repeat it --quiet --resume 20260612_050401_aa9d27"); }); it("passes positional subcommands through instead of translating nested one-shot flags (#5254)", () => { @@ -460,7 +503,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(exported).toContain(resumePrompt); expect(exported).toContain(continuePrompt); }, - 5 * 60_000, + Number(process.env.NEMOCLAW_HERMES_SESSION_PERSISTENCE_TIMEOUT_MS ?? 120_000) * 6, ); it("passes --version through (build assertion path) without invoking the guard", () => { From 3dde0cd920487177dad785e6a3bca8e28bf76109 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 02:31:46 +0800 Subject: [PATCH 07/34] test(hermes): move resume persistence proof to live e2e --- test/hermes-gateway-wrapper.test.ts | 55 ----------------------------- 1 file changed, 55 deletions(-) diff --git a/test/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index 726f8a211f1..7187eda73e2 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -56,20 +56,6 @@ type StubBehaviour = { exitCode?: number; }; -function truthyEnv(value: string | undefined): boolean { - return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? ""); -} - -function sessionIds(output: string): Set { - return new Set(output.match(/\b[0-9]{8}_[0-9]{6}_[a-zA-Z0-9]+\b/g) ?? []); -} - -function onlyNewSessionId(before: Set, after: Set): string { - const created = [...after].filter((id) => !before.has(id)); - expect(created).toHaveLength(1); - return created[0]; -} - // Run the wrapper against a temp install: a copy of the wrapper alongside the // real validator and a `hermes.real` stub. The wrapper's dev fallback resolves // both from its own directory because the /usr/local install paths are absent. @@ -465,47 +451,6 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(run.realArgs).toBe("--model --resume 20260612_050401_aa9d27 -z Repeat it"); }); - it.skipIf(!truthyEnv(process.env.NEMOCLAW_HERMES_SESSION_PERSISTENCE_RUNTIME))( - "validates real resumed one-shot session persistence when a live Hermes runtime is available (#5254)", - () => { - const hermesBin = process.env.NEMOCLAW_HERMES_BIN ?? "hermes"; - const timeout = Number(process.env.NEMOCLAW_HERMES_SESSION_PERSISTENCE_TIMEOUT_MS ?? 120_000); - const env = { ...process.env }; - const marker = `NEMOCLAW_5254_${Date.now()}`; - const runHermes = (args: string[]) => { - const run = spawnSync(hermesBin, args, { - encoding: "utf-8", - timeout, - env, - }); - expect(run.status, `${hermesBin} ${args.join(" ")}\n${run.stderr}`).toBe(0); - return `${run.stdout ?? ""}\n${run.stderr ?? ""}`; - }; - const listSessions = () => sessionIds(runHermes(["sessions", "list"])); - - const beforeSeed = listSessions(); - const seedPrompt = `Remember this exact token: ${marker}. Reply with acknowledged.`; - runHermes(["-z", seedPrompt]); - const seedSessionId = onlyNewSessionId(beforeSeed, listSessions()); - - const beforeResume = listSessions(); - const resumePrompt = `Repeat this exact token: ${marker}.`; - runHermes(["--resume", seedSessionId, "-z", resumePrompt]); - expect([...listSessions()].filter((id) => !beforeResume.has(id))).toEqual([]); - - const beforeContinue = listSessions(); - const continuePrompt = `Confirm this exact token again: ${marker}.`; - runHermes(["-c", seedSessionId, "-z", continuePrompt]); - expect([...listSessions()].filter((id) => !beforeContinue.has(id))).toEqual([]); - - const exported = runHermes(["sessions", "export", "--session-id", seedSessionId]); - expect(exported).toContain(seedPrompt); - expect(exported).toContain(resumePrompt); - expect(exported).toContain(continuePrompt); - }, - Number(process.env.NEMOCLAW_HERMES_SESSION_PERSISTENCE_TIMEOUT_MS ?? 120_000) * 6, - ); - it("passes --version through (build assertion path) without invoking the guard", () => { const run = runWrapper(["--version"], { SLACK_BOT_TOKEN: "xoxb-real-1234567890" }); From 9ca86b62e0acb2d8f0091aeeadab816c4fdfcb60 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 02:50:31 +0800 Subject: [PATCH 08/34] fix(hermes): harden wrapper drift check --- agents/hermes/Dockerfile | 4 +++- test/e2e/live/hermes-e2e.test.ts | 28 ++++++++++++++++------------ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index e65a7a12aff..56693dc6c13 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -203,7 +203,9 @@ RUN printf '%s %s\n' \ COPY agents/hermes/hermes-wrapper.py /usr/local/lib/nemoclaw/hermes-wrapper.py RUN test -x /usr/bin/python3 \ || { echo "ERROR: /usr/bin/python3 missing or not executable; hermes-wrapper shebang would ENOEXEC" >&2; exit 1; } -RUN /usr/bin/python3 -I -c 'import ast, pathlib, subprocess, sys; source = pathlib.Path("/usr/local/lib/nemoclaw/hermes-wrapper.py").read_text(); tree = ast.parse(source); constants = {node.targets[0].id: ast.literal_eval(node.value) for node in tree.body if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name) and node.targets[0].id in {"_VALUE_FLAGS", "_BOOLEAN_FLAGS"}}; expected = set(constants["_VALUE_FLAGS"]) | set(constants["_VALUE_FLAGS"].values()) | set(constants["_BOOLEAN_FLAGS"]); help_text = subprocess.check_output(["/usr/local/bin/hermes", "--help"], text=True); missing = sorted(flag for flag in expected if flag not in help_text); sys.exit("ERROR: Hermes wrapper flag allowlist drifted from pinned hermes --help: " + ", ".join(missing) if missing else 0)' +# This runs before `/usr/local/bin/hermes` is moved to `hermes.real`, so the +# help probe checks the pinned Hermes binary, not the wrapper installed below. +RUN /usr/bin/python3 -I -c 'import ast, pathlib, subprocess, sys; source = pathlib.Path("/usr/local/lib/nemoclaw/hermes-wrapper.py").read_text(); tree = ast.parse(source); constants = {node.targets[0].id: ast.literal_eval(node.value) for node in tree.body if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name) and node.targets[0].id in {"_VALUE_FLAGS", "_BOOLEAN_FLAGS"}}; expected = set(constants["_VALUE_FLAGS"]) | set(constants["_VALUE_FLAGS"].values()) | set(constants["_BOOLEAN_FLAGS"]) | {"-z", "--oneshot", "-c", "--continue"}; help_text = subprocess.check_output(["/usr/local/bin/hermes", "--help"], text=True, timeout=30); missing = sorted(flag for flag in expected if flag not in help_text); sys.exit("ERROR: Hermes wrapper flag allowlist drifted from pinned hermes --help: " + ", ".join(missing) if missing else 0)' # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_WRAPPER_SHA256" /usr/local/lib/nemoclaw/hermes-wrapper.py \ diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index 02037f040ef..cf35477bec2 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -474,6 +474,16 @@ test.skipIf(!shouldRunLiveE2E())( }; const listHermesSessions = async (artifactName: string) => hermesSessionIds(await runHermesCli(["sessions", "list"], artifactName, 60_000)); + const expectNoNewHermesSessions = async ( + before: Set, + args: string[], + runArtifact: string, + afterArtifact: string, + ) => { + await runHermesCli(args, runArtifact); + const after = await listHermesSessions(afterArtifact); + expect([...after].filter((id) => !before.has(id))).toEqual([]); + }; const issue5254Marker = `NEMOCLAW_5254_${Date.now()}`; const beforeSeedSessions = await listHermesSessions("phase-4-issue-5254-sessions-before-seed"); @@ -488,29 +498,23 @@ test.skipIf(!shouldRunLiveE2E())( "phase-4-issue-5254-sessions-before-resume", ); const resumePrompt = `Repeat this exact token: ${issue5254Marker}.`; - await runHermesCli( + await expectNoNewHermesSessions( + beforeResumeSessions, ["--resume", seedSessionId, "-z", resumePrompt], "phase-4-issue-5254-resume-oneshot", + "phase-4-issue-5254-sessions-after-resume", ); - expect( - [...(await listHermesSessions("phase-4-issue-5254-sessions-after-resume"))].filter( - (id) => !beforeResumeSessions.has(id), - ), - ).toEqual([]); const beforeContinueSessions = await listHermesSessions( "phase-4-issue-5254-sessions-before-continue", ); const continuePrompt = `Confirm this exact token again: ${issue5254Marker}.`; - await runHermesCli( + await expectNoNewHermesSessions( + beforeContinueSessions, ["-c", seedSessionId, "-z", continuePrompt], "phase-4-issue-5254-continue-oneshot", + "phase-4-issue-5254-sessions-after-continue", ); - expect( - [...(await listHermesSessions("phase-4-issue-5254-sessions-after-continue"))].filter( - (id) => !beforeContinueSessions.has(id), - ), - ).toEqual([]); const exportedSession = await runHermesCli( ["sessions", "export", "--session-id", seedSessionId], From dac163446df130a22d058834d684b6e309f7ed72 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 02:59:28 +0800 Subject: [PATCH 09/34] test(hermes): export resumed session to file --- test/e2e/live/hermes-e2e.test.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index cf35477bec2..3d103f65579 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -516,11 +516,27 @@ test.skipIf(!shouldRunLiveE2E())( "phase-4-issue-5254-sessions-after-continue", ); - const exportedSession = await runHermesCli( - ["sessions", "export", "--session-id", seedSessionId], - "phase-4-issue-5254-export-session", - 60_000, + const exportPath = `/tmp/nemoclaw-issue-5254-${issue5254Marker}.jsonl`; + const exportResult = await sandbox.execShell( + SANDBOX_NAME, + trustedSandboxShellScript( + [ + `rm -f ${shellQuote(exportPath)}`, + `hermes sessions export --session-id ${shellQuote(seedSessionId)} ${shellQuote( + exportPath, + )}`, + `cat ${shellQuote(exportPath)}`, + ].join(" && "), + ), + { + artifactName: "phase-4-issue-5254-export-session", + env: commandEnv(), + redactionValues, + timeoutMs: 60_000, + }, ); + expect(exportResult.exitCode, resultText(exportResult)).toBe(0); + const exportedSession = resultText(exportResult); expect(exportedSession).toContain(seedPrompt); expect(exportedSession).toContain(resumePrompt); expect(exportedSession).toContain(continuePrompt); From d2e6cd47d0b56f8c7d7355c38366688887a2e43c Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 03:00:33 +0800 Subject: [PATCH 10/34] test(hermes): keep e2e file within budget --- test/e2e/live/hermes-e2e.test.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index 3d103f65579..afcb337b64e 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -517,17 +517,14 @@ test.skipIf(!shouldRunLiveE2E())( ); const exportPath = `/tmp/nemoclaw-issue-5254-${issue5254Marker}.jsonl`; + const exportScript = [ + `rm -f ${shellQuote(exportPath)}`, + `hermes sessions export --session-id ${shellQuote(seedSessionId)} ${shellQuote(exportPath)}`, + `cat ${shellQuote(exportPath)}`, + ].join(" && "); const exportResult = await sandbox.execShell( SANDBOX_NAME, - trustedSandboxShellScript( - [ - `rm -f ${shellQuote(exportPath)}`, - `hermes sessions export --session-id ${shellQuote(seedSessionId)} ${shellQuote( - exportPath, - )}`, - `cat ${shellQuote(exportPath)}`, - ].join(" && "), - ), + trustedSandboxShellScript(exportScript), { artifactName: "phase-4-issue-5254-export-session", env: commandEnv(), From 9203f736e68d7b1679d74bbf1b01daa51409ac5e Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 03:29:18 +0800 Subject: [PATCH 11/34] test(hermes): add resumed session contract coverage --- agents/hermes/Dockerfile | 4 +- agents/hermes/hermes-wrapper.py | 92 +++++++++++++++-------------- test/e2e/live/hermes-e2e.test.ts | 2 +- test/hermes-gateway-wrapper.test.ts | 72 ++++++++++++++++++---- 4 files changed, 111 insertions(+), 59 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 56693dc6c13..c4dc7d28c85 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -181,7 +181,7 @@ RUN test -x /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py \ # chain tampering of the build context (an attacker rewriting the file has to # also rewrite the Dockerfile-committed hash, which reviewers gate). Regenerate # with `sha256sum agents/hermes/{hermes-wrapper.py,validate-env-secret-boundary.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=901c5c17a0c12eacb5958a7558f84f4da83a8a10a4bd80b560671e04718e1bd2 +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=f8f5a21d8869c52d16cfe8e8ea5f256a2ec5669b3e8c2ce050528c6144765073 ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=970d7ff03bc409ff1d5ca46bfdbd2a42ac28a32a810ccc147a508301bff38496 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ @@ -205,7 +205,7 @@ RUN test -x /usr/bin/python3 \ || { echo "ERROR: /usr/bin/python3 missing or not executable; hermes-wrapper shebang would ENOEXEC" >&2; exit 1; } # This runs before `/usr/local/bin/hermes` is moved to `hermes.real`, so the # help probe checks the pinned Hermes binary, not the wrapper installed below. -RUN /usr/bin/python3 -I -c 'import ast, pathlib, subprocess, sys; source = pathlib.Path("/usr/local/lib/nemoclaw/hermes-wrapper.py").read_text(); tree = ast.parse(source); constants = {node.targets[0].id: ast.literal_eval(node.value) for node in tree.body if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name) and node.targets[0].id in {"_VALUE_FLAGS", "_BOOLEAN_FLAGS"}}; expected = set(constants["_VALUE_FLAGS"]) | set(constants["_VALUE_FLAGS"].values()) | set(constants["_BOOLEAN_FLAGS"]) | {"-z", "--oneshot", "-c", "--continue"}; help_text = subprocess.check_output(["/usr/local/bin/hermes", "--help"], text=True, timeout=30); missing = sorted(flag for flag in expected if flag not in help_text); sys.exit("ERROR: Hermes wrapper flag allowlist drifted from pinned hermes --help: " + ", ".join(missing) if missing else 0)' +RUN /usr/bin/python3 -I -c 'import ast, pathlib, subprocess, sys; source = pathlib.Path("/usr/local/lib/nemoclaw/hermes-wrapper.py").read_text(); tree = ast.parse(source); constants = {node.targets[0].id: ast.literal_eval(node.value) for node in tree.body if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name) and node.targets[0].id in {"_VALUE_FLAGS", "_BOOLEAN_FLAGS"}}; missing_constants = sorted({"_VALUE_FLAGS", "_BOOLEAN_FLAGS"} - set(constants)); missing_constants and sys.exit("ERROR: Hermes wrapper flag constants not found in AST: " + ", ".join(missing_constants)); top_expected = set(constants["_VALUE_FLAGS"]) | set(constants["_VALUE_FLAGS"].values()) | set(constants["_BOOLEAN_FLAGS"]) | {"-z", "--oneshot", "-c", "--continue"}; top_help = subprocess.check_output(["/usr/local/bin/hermes", "--help"], text=True, timeout=30); top_missing = sorted(flag for flag in top_expected if flag not in top_help); top_missing and sys.exit("ERROR: Hermes wrapper flag allowlist drifted from pinned hermes --help: " + ", ".join(top_missing)); chat_expected = set(constants["_VALUE_FLAGS"].values()) | set(constants["_BOOLEAN_FLAGS"]) | {"--query", "--quiet", "--resume", "-r", "--continue", "-c"}; chat_help = subprocess.check_output(["/usr/local/bin/hermes", "chat", "--help"], text=True, timeout=30); chat_missing = sorted(flag for flag in chat_expected if flag not in chat_help); chat_missing and sys.exit("ERROR: Hermes wrapper forwarded flags drifted from pinned hermes chat --help: " + ", ".join(chat_missing))' # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_WRAPPER_SHA256" /usr/local/lib/nemoclaw/hermes-wrapper.py \ diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index 0693fef6a26..d0b3aa103cc 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -54,10 +54,11 @@ # api_secret, access_token, auth_token, client_secret, secret_key, secret, # token, password, bearer, authorization, credential — including # hyphen/underscore/camelCase variants) in Python-dict, JSON, YAML key:value, -# env-style key=value, and YAML block-scalar shapes; plus, as defence in -# depth, every free-form `sk-`-prefixed token of length >= 8. Non-`sk-` -# token families in free prose are not redacted — that is the upstream -# Hermes CLI's responsibility. +# env-style key=value, and YAML block-scalar shapes (`|`, `|-`, `|+`, `|2`, +# `|2-`, `|2+`, `|-2`, and folded `>` equivalents); plus, as defence in depth, +# every free-form `sk-`-prefixed token of length >= 8. Non-`sk-` token families +# in free prose are not redacted — that is the upstream Hermes CLI's +# responsibility. # # The same gateway runtime-env guard also runs in the nemoclaw-start # entrypoint (`agents/hermes/start.sh:validate_hermes_runtime_env_secret_boundary`) @@ -74,6 +75,7 @@ import os import subprocess import sys +import tempfile _INSTALLED_REAL = "/usr/local/bin/hermes.real" _INSTALLED_GUARD = "/usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py" @@ -153,42 +155,45 @@ def _run_config_show(real_hermes: str, guard_path: str, argv: list[str]) -> int: # EOF when Hermes finishes writing. The masker itself buffers in # memory and only writes on success, so a mid-stream crash never # produces a partial secret on either stream. Each masker's own stderr - # is captured to a pipe so we can filter it before forwarding — a + # is captured to a temporary file so we can filter it before forwarding — a # raw `stderr=sys.stderr.fileno()` would leak Python tracebacks on an - # unhandled exception. + # unhandled exception, while a pipe could deadlock if a masker writes a + # large diagnostic before the parent drains it. masker_argv = [python3, "-I", guard_path, "mask-config-output"] - masker_stdout = subprocess.Popen( - masker_argv, - stdin=subprocess.PIPE, - stdout=sys.stdout.fileno(), - stderr=subprocess.PIPE, - ) - masker_stderr = subprocess.Popen( - masker_argv, - stdin=subprocess.PIPE, - stdout=sys.stderr.fileno(), - stderr=subprocess.PIPE, - ) - try: - proc = subprocess.Popen( - [real_hermes, *argv], - stdout=masker_stdout.stdin, - stderr=masker_stderr.stdin, + with ( + tempfile.TemporaryFile() as stdout_masker_stderr_file, + tempfile.TemporaryFile() as stderr_masker_stderr_file, + ): + masker_stdout = subprocess.Popen( + masker_argv, + stdin=subprocess.PIPE, + stdout=sys.stdout.fileno(), + stderr=stdout_masker_stderr_file, + ) + masker_stderr = subprocess.Popen( + masker_argv, + stdin=subprocess.PIPE, + stdout=sys.stderr.fileno(), + stderr=stderr_masker_stderr_file, ) - finally: - if masker_stdout.stdin is not None: - masker_stdout.stdin.close() - if masker_stderr.stdin is not None: - masker_stderr.stdin.close() - proc.wait() - # Read each masker's captured stderr before wait() returns so the - # pipe drains and the masker is not blocked writing into a full buffer. - # communicate() cannot be used here because the stdin pipe was already - # closed for ownership transfer. - stdout_masker_stderr = masker_stdout.stderr.read() if masker_stdout.stderr else b"" - stderr_masker_stderr = masker_stderr.stderr.read() if masker_stderr.stderr else b"" - masker_stdout.wait() - masker_stderr.wait() + try: + proc = subprocess.Popen( + [real_hermes, *argv], + stdout=masker_stdout.stdin, + stderr=masker_stderr.stdin, + ) + finally: + if masker_stdout.stdin is not None: + masker_stdout.stdin.close() + if masker_stderr.stdin is not None: + masker_stderr.stdin.close() + proc.wait() + masker_stdout.wait() + masker_stderr.wait() + stdout_masker_stderr_file.seek(0) + stderr_masker_stderr_file.seek(0) + stdout_masker_stderr = stdout_masker_stderr_file.read() + stderr_masker_stderr = stderr_masker_stderr_file.read() if masker_stdout.returncode != 0: _forward_sanitised_masker_stderr( stdout_masker_stderr, @@ -262,12 +267,13 @@ def _translate_resumed_oneshot(argv: list[str]) -> list[str] | None: NemoClaw owns this installed wrapper, not the prebuilt Hermes Agent binary inside the sandbox base image, so the wrapper is the smallest compatibility - boundary available here. Delete this translation once Hermes top-level - `--resume/-c` plus `-z/--oneshot` natively appends the new user/assistant - turn to the selected session without creating a fresh session id. Until - then, wrapper argv tests cover the routed form and the fail-closed cases; - live sandbox validation should verify the persisted `sessions list/export` - behavior when a matching Hermes runtime is available. + boundary available here. NemoClaw #5254 is the local removal tracker; avoid + adding unofficial upstream repository links here per the repo's no external + project links rule. Delete this translation once the pinned Hermes runtime + natively appends top-level `--resume/-c` plus `-z/--oneshot` turns to the + selected session without creating a fresh session id. Until then, wrapper + argv tests cover the routed form and the fail-closed cases; live sandbox + validation verifies the persisted `sessions list/export` behavior. Preserve approval-related user intent instead of inferring it here: `--yolo` and `--accept-hooks` are forwarded only when the original argv diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index afcb337b64e..5701ce935eb 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -500,7 +500,7 @@ test.skipIf(!shouldRunLiveE2E())( const resumePrompt = `Repeat this exact token: ${issue5254Marker}.`; await expectNoNewHermesSessions( beforeResumeSessions, - ["--resume", seedSessionId, "-z", resumePrompt], + ["--resume", seedSessionId, "-z", resumePrompt, "--pass-session-id", "--ignore-rules"], "phase-4-issue-5254-resume-oneshot", "phase-4-issue-5254-sessions-after-resume", ); diff --git a/test/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index 7187eda73e2..6628b1cc993 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -224,11 +224,6 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { ); expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe( - "chat --query What secret number did I give you? --quiet --resume 20260612_050401_aa9d27", - ); expect(run.realArgv).toEqual([ "chat", "--query", @@ -254,11 +249,6 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { ); expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe( - "chat --query Summarize the latest turn --quiet --continue daily check --model anthropic/claude-sonnet-4 --toolsets memory,session_search --ignore-rules", - ); expect(run.realArgv).toEqual([ "chat", "--query", @@ -281,8 +271,6 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { ); expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); expect(run.realArgv).toEqual([ "chat", "--query", @@ -295,6 +283,43 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { ]); }); + it("keeps translated resumed one-shot turns on the same fake session (#5254)", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wrapper-session-")); + try { + fs.copyFileSync(WRAPPER, path.join(dir, "hermes")); + fs.chmodSync(path.join(dir, "hermes"), 0o755); + const statePath = path.join(dir, "sessions.json"); + fs.writeFileSync( + path.join(dir, "hermes.real"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "-z" ]; then printf "seed:%s\\n" "$2" > "$NEMOCLAW_FAKE_SESSIONS"; exit 0; fi', + 'if [ "$1" = "chat" ] && [ "$2" = "--query" ] && [ "$4" = "--quiet" ] && { [ "$5" = "--resume" ] || [ "$5" = "--continue" ]; } && [ "$6" = "seed" ]; then printf "seed:%s\\n" "$3" >> "$NEMOCLAW_FAKE_SESSIONS"; exit 0; fi', + "exit 3", + "", + ].join("\n"), + { mode: 0o755 }, + ); + const invoke = (args: string[]) => + spawnSync(path.join(dir, "hermes"), args, { + encoding: "utf-8", + env: { PATH: process.env.PATH ?? "", HOME: dir, NEMOCLAW_FAKE_SESSIONS: statePath }, + timeout: 10_000, + }); + + expect(invoke(["-z", "seed prompt"]).status).toBe(0); + expect(invoke(["--resume", "seed", "-z", "resume prompt"]).status).toBe(0); + expect(invoke(["-c", "seed", "-z", "continue prompt"]).status).toBe(0); + expect(fs.readFileSync(statePath, "utf-8").trim().split("\n")).toEqual([ + "seed:seed prompt", + "seed:resume prompt", + "seed:continue prompt", + ]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("leaves plain one-shot invocations on the upstream one-shot path (#5254)", () => { const run = runWrapper(["-z", "Reply pong"], {}); @@ -798,24 +823,45 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(run.stdout).toContain("passwords: sk-****"); }); - it("masks multi-digit and reversed-order YAML block-scalar headers (|2-, |-2, >5+)", () => { + it("masks YAML block-scalar headers with indentation and chomping indicators", () => { const fixture = [ + "provider_token: |2", + " leaked-yaml-indent-12345", "api_key: |2-", " leaked-yaml-indent-trail-12345", "access_token: |-2", " leaked-yaml-trail-indent-12345", + "auth_token: >2", + " leaked-yaml-folded-indent-12345", "client_secret: >5+", " leaked-yaml-folded-12345", ].join("\n"); const run = runWrapper(["config", "show"], {}, { stub: { stdout: fixture, exitCode: 0 } }); expect(run.status).toBe(0); + expect(run.stdout).not.toContain("leaked-yaml-indent-12345"); expect(run.stdout).not.toContain("leaked-yaml-indent-trail-12345"); expect(run.stdout).not.toContain("leaked-yaml-trail-indent-12345"); + expect(run.stdout).not.toContain("leaked-yaml-folded-indent-12345"); expect(run.stdout).not.toContain("leaked-yaml-folded-12345"); expect(run.stdout).toContain("sk-****"); }); + it("does not hang when the config masker emits large stderr", () => { + const validatorScript = [ + "#!/usr/bin/env python3", + "import sys", + "sys.stderr.write('x' * 70000)", + "raise SystemExit(1)", + "", + ].join("\n"); + const run = runWrapper(["config", "show"], {}, { validatorScript }); + + expect(run.status).toBe(1); + expect(run.stderr).toContain("output masker failed"); + expect(run.stderr).not.toContain("xxxxxxxxxxxxxxxx"); + }); + it("fails closed with a stable error when config show stdout exceeds the 4 MiB masker cap", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wrapper-oversize-")); try { From 74444f5c9b21a04ea076e95c06226b03ef1b408e Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 03:39:55 +0800 Subject: [PATCH 12/34] test(hermes): cover plain block scalar secret keys --- test/hermes-gateway-wrapper.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index 6628b1cc993..ecad224ba32 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -825,7 +825,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { it("masks YAML block-scalar headers with indentation and chomping indicators", () => { const fixture = [ - "provider_token: |2", + "token: |2", " leaked-yaml-indent-12345", "api_key: |2-", " leaked-yaml-indent-trail-12345", From db9ba8b3bfafb52a70e4cf9ee17930da1b11a479 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 03:51:14 +0800 Subject: [PATCH 13/34] test(hermes): assert resumed session list preview --- test/e2e/live/hermes-e2e.test.ts | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index 5701ce935eb..bf071b264ca 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -459,9 +459,6 @@ test.skipIf(!shouldRunLiveE2E())( expect(configProbe.exitCode, resultText(configProbe)).toBe(0); expect(configProbe.stdout).toContain("OK"); - // Regression coverage for #5254 against the real sandbox-installed Hermes - // runtime: top-level resumed/continued one-shot invocations must append to - // the selected session instead of creating a new follow-up session. const runHermesCli = async (args: string[], artifactName: string, timeoutMs = 180_000) => { const result = await sandbox.exec(SANDBOX_NAME, ["hermes", ...args], { artifactName, @@ -472,17 +469,22 @@ test.skipIf(!shouldRunLiveE2E())( expect(result.exitCode, resultText(result)).toBe(0); return resultText(result); }; + const listHermesSessionsText = (artifactName: string) => + runHermesCli(["sessions", "list"], artifactName, 60_000); const listHermesSessions = async (artifactName: string) => - hermesSessionIds(await runHermesCli(["sessions", "list"], artifactName, 60_000)); + hermesSessionIds(await listHermesSessionsText(artifactName)); const expectNoNewHermesSessions = async ( before: Set, args: string[], runArtifact: string, afterArtifact: string, + previewNeedle: string, ) => { await runHermesCli(args, runArtifact); - const after = await listHermesSessions(afterArtifact); + const afterText = await listHermesSessionsText(afterArtifact); + const after = hermesSessionIds(afterText); expect([...after].filter((id) => !before.has(id))).toEqual([]); + expect(stripAnsi(afterText)).toContain(previewNeedle); }; const issue5254Marker = `NEMOCLAW_5254_${Date.now()}`; @@ -494,26 +496,22 @@ test.skipIf(!shouldRunLiveE2E())( await listHermesSessions("phase-4-issue-5254-sessions-after-seed"), ); - const beforeResumeSessions = await listHermesSessions( - "phase-4-issue-5254-sessions-before-resume", - ); - const resumePrompt = `Repeat this exact token: ${issue5254Marker}.`; + const resumePrompt = `Repeat this exact token: ${issue5254Marker}_RESUME.`; await expectNoNewHermesSessions( - beforeResumeSessions, + await listHermesSessions("phase-4-issue-5254-sessions-before-resume"), ["--resume", seedSessionId, "-z", resumePrompt, "--pass-session-id", "--ignore-rules"], "phase-4-issue-5254-resume-oneshot", "phase-4-issue-5254-sessions-after-resume", + `${issue5254Marker}_RESUME`, ); - const beforeContinueSessions = await listHermesSessions( - "phase-4-issue-5254-sessions-before-continue", - ); - const continuePrompt = `Confirm this exact token again: ${issue5254Marker}.`; + const continuePrompt = `Confirm this exact token again: ${issue5254Marker}_CONTINUE.`; await expectNoNewHermesSessions( - beforeContinueSessions, + await listHermesSessions("phase-4-issue-5254-sessions-before-continue"), ["-c", seedSessionId, "-z", continuePrompt], "phase-4-issue-5254-continue-oneshot", "phase-4-issue-5254-sessions-after-continue", + `${issue5254Marker}_CONTINUE`, ); const exportPath = `/tmp/nemoclaw-issue-5254-${issue5254Marker}.jsonl`; From a4c8cbb08c4a4d6e41e698cd22c0137113a600d2 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 04:23:37 +0800 Subject: [PATCH 14/34] fix(hermes): harden wrapper failure diagnostics --- agents/hermes/Dockerfile | 4 +-- agents/hermes/hermes-wrapper.py | 42 ++++++++++++++++++++++++----- test/hermes-gateway-wrapper.test.ts | 25 ++++++++--------- 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index c4dc7d28c85..83907fe91eb 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -181,7 +181,7 @@ RUN test -x /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py \ # chain tampering of the build context (an attacker rewriting the file has to # also rewrite the Dockerfile-committed hash, which reviewers gate). Regenerate # with `sha256sum agents/hermes/{hermes-wrapper.py,validate-env-secret-boundary.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=f8f5a21d8869c52d16cfe8e8ea5f256a2ec5669b3e8c2ce050528c6144765073 +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=fced7ed33ac2d61d3c0852a9884ef352a9b92ec645dbed52732c04785e20c760 ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=970d7ff03bc409ff1d5ca46bfdbd2a42ac28a32a810ccc147a508301bff38496 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ @@ -205,7 +205,7 @@ RUN test -x /usr/bin/python3 \ || { echo "ERROR: /usr/bin/python3 missing or not executable; hermes-wrapper shebang would ENOEXEC" >&2; exit 1; } # This runs before `/usr/local/bin/hermes` is moved to `hermes.real`, so the # help probe checks the pinned Hermes binary, not the wrapper installed below. -RUN /usr/bin/python3 -I -c 'import ast, pathlib, subprocess, sys; source = pathlib.Path("/usr/local/lib/nemoclaw/hermes-wrapper.py").read_text(); tree = ast.parse(source); constants = {node.targets[0].id: ast.literal_eval(node.value) for node in tree.body if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name) and node.targets[0].id in {"_VALUE_FLAGS", "_BOOLEAN_FLAGS"}}; missing_constants = sorted({"_VALUE_FLAGS", "_BOOLEAN_FLAGS"} - set(constants)); missing_constants and sys.exit("ERROR: Hermes wrapper flag constants not found in AST: " + ", ".join(missing_constants)); top_expected = set(constants["_VALUE_FLAGS"]) | set(constants["_VALUE_FLAGS"].values()) | set(constants["_BOOLEAN_FLAGS"]) | {"-z", "--oneshot", "-c", "--continue"}; top_help = subprocess.check_output(["/usr/local/bin/hermes", "--help"], text=True, timeout=30); top_missing = sorted(flag for flag in top_expected if flag not in top_help); top_missing and sys.exit("ERROR: Hermes wrapper flag allowlist drifted from pinned hermes --help: " + ", ".join(top_missing)); chat_expected = set(constants["_VALUE_FLAGS"].values()) | set(constants["_BOOLEAN_FLAGS"]) | {"--query", "--quiet", "--resume", "-r", "--continue", "-c"}; chat_help = subprocess.check_output(["/usr/local/bin/hermes", "chat", "--help"], text=True, timeout=30); chat_missing = sorted(flag for flag in chat_expected if flag not in chat_help); chat_missing and sys.exit("ERROR: Hermes wrapper forwarded flags drifted from pinned hermes chat --help: " + ", ".join(chat_missing))' +RUN /usr/bin/python3 -I -c 'import ast, pathlib, subprocess, sys; source = pathlib.Path("/usr/local/lib/nemoclaw/hermes-wrapper.py").read_text(); tree = ast.parse(source); constants = {node.targets[0].id: ast.literal_eval(node.value) for node in tree.body if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name) and node.targets[0].id in {"_VALUE_FLAGS", "_BOOLEAN_FLAGS"}}; missing_constants = sorted({"_VALUE_FLAGS", "_BOOLEAN_FLAGS"} - set(constants)); missing_constants and sys.exit("ERROR: Hermes wrapper flag constants not found in AST: " + ", ".join(missing_constants)); not constants["_VALUE_FLAGS"] and sys.exit("ERROR: Hermes wrapper _VALUE_FLAGS is empty"); not constants["_BOOLEAN_FLAGS"] and sys.exit("ERROR: Hermes wrapper _BOOLEAN_FLAGS is empty"); top_expected = set(constants["_VALUE_FLAGS"]) | set(constants["_VALUE_FLAGS"].values()) | set(constants["_BOOLEAN_FLAGS"]) | {"-z", "--oneshot", "-c", "--continue"}; top_help = subprocess.check_output(["/usr/local/bin/hermes", "--help"], text=True, timeout=30); top_missing = sorted(flag for flag in top_expected if flag not in top_help); top_missing and sys.exit("ERROR: Hermes wrapper flag allowlist drifted from pinned hermes --help: " + ", ".join(top_missing)); chat_expected = set(constants["_VALUE_FLAGS"].values()) | set(constants["_BOOLEAN_FLAGS"]) | {"--query", "--quiet", "--resume", "-r", "--continue", "-c"}; chat_help = subprocess.check_output(["/usr/local/bin/hermes", "chat", "--help"], text=True, timeout=30); chat_missing = sorted(flag for flag in chat_expected if flag not in chat_help); chat_missing and sys.exit("ERROR: Hermes wrapper forwarded flags drifted from pinned hermes chat --help: " + ", ".join(chat_missing))' # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_WRAPPER_SHA256" /usr/local/lib/nemoclaw/hermes-wrapper.py \ diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index d0b3aa103cc..68d91b7a1b5 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -122,6 +122,19 @@ def _resolve_trusted_python3() -> str | None: _MASKER_STDERR_ALLOWED_PREFIX = "[SECURITY]" +_MASKER_STDERR_MAX_BYTES = 10 * 1024 * 1024 + + +def _read_masker_stderr(file_obj, stream_name: str) -> tuple[bytes, bool]: + file_obj.seek(0) + raw = file_obj.read(_MASKER_STDERR_MAX_BYTES + 1) + if len(raw) > _MASKER_STDERR_MAX_BYTES: + print( + f"[SECURITY] Refusing hermes config show: output masker stderr exceeded {_MASKER_STDERR_MAX_BYTES} bytes ({stream_name})", + file=sys.stderr, + ) + return raw[:_MASKER_STDERR_MAX_BYTES], True + return raw, False def _forward_sanitised_masker_stderr(raw: bytes, fallback: str) -> None: @@ -190,10 +203,16 @@ def _run_config_show(real_hermes: str, guard_path: str, argv: list[str]) -> int: proc.wait() masker_stdout.wait() masker_stderr.wait() - stdout_masker_stderr_file.seek(0) - stderr_masker_stderr_file.seek(0) - stdout_masker_stderr = stdout_masker_stderr_file.read() - stderr_masker_stderr = stderr_masker_stderr_file.read() + stdout_masker_stderr, stdout_masker_stderr_too_large = _read_masker_stderr( + stdout_masker_stderr_file, + "stdout", + ) + stderr_masker_stderr, stderr_masker_stderr_too_large = _read_masker_stderr( + stderr_masker_stderr_file, + "stderr", + ) + if stdout_masker_stderr_too_large or stderr_masker_stderr_too_large: + return 1 if masker_stdout.returncode != 0: _forward_sanitised_masker_stderr( stdout_masker_stderr, @@ -391,9 +410,18 @@ def main(argv: list[str]) -> int: return rc translated = _translate_resumed_oneshot(argv) if translated is not None: - os.execv(real_hermes, [real_hermes, *translated]) - os.execv(real_hermes, [real_hermes, *argv]) - return 1 + exec_argv = translated + else: + exec_argv = argv + try: + os.execv(real_hermes, [real_hermes, *exec_argv]) + except OSError as exc: + print( + f"[SECURITY] Refusing to run hermes: failed to exec Hermes binary at {real_hermes}: {exc}", + file=sys.stderr, + ) + return 126 + return 126 if __name__ == "__main__": diff --git a/test/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index ecad224ba32..9bc00499ca4 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -76,11 +76,7 @@ function runWrapper( try { fs.copyFileSync(WRAPPER, path.join(dir, "hermes")); const validatorContent = opts.validatorScript ?? fs.readFileSync(VALIDATOR, "utf-8"); - // Write with the source-layout filename so the wrapper's dev fallback - // (_resolve_guard() -> _self_dir()/validate-env-secret-boundary.py) picks - // it up; the installed-layout tests further down write to the - // /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py install - // path instead. + // Source-layout filename lets the wrapper's dev fallback pick it up. fs.writeFileSync(path.join(dir, "validate-env-secret-boundary.py"), validatorContent, { mode: 0o755, }); @@ -102,10 +98,7 @@ function runWrapper( ].join("\n"); fs.writeFileSync(path.join(dir, "hermes.real"), stubScript, { mode: 0o755 }); - // Optionally plant malicious helpers earlier on PATH that would subvert the - // wrapper. The wrapper must ignore them and resolve each helper from a - // trusted absolute path. `shadowPython` covers the python3 interpreter; - // `shadowHelpers` lets a test plant arbitrary scripts (e.g. mktemp / rm). + // Plant malicious helpers earlier on PATH; the wrapper must ignore them. const planted: Record = { ...(opts.shadowHelpers ?? {}), ...(opts.shadowPython ? { python3: "#!/usr/bin/env bash\nexit 0\n" } : {}), @@ -283,7 +276,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { ]); }); - it("keeps translated resumed one-shot turns on the same fake session (#5254)", () => { + it("keeps translated resumed one-shot turns on the same fake session and reports exec failures (#5254)", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wrapper-session-")); try { fs.copyFileSync(WRAPPER, path.join(dir, "hermes")); @@ -315,6 +308,10 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { "seed:resume prompt", "seed:continue prompt", ]); + fs.chmodSync(path.join(dir, "hermes.real"), 0o644); + const blocked = invoke(["--resume", "seed", "-z", "after chmod"]); + expect(blocked.status).toBe(126); + expect(blocked.stderr).toContain("[SECURITY] Refusing to run hermes: failed to exec Hermes"); } finally { fs.rmSync(dir, { recursive: true, force: true }); } @@ -847,18 +844,18 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(run.stdout).toContain("sk-****"); }); - it("does not hang when the config masker emits large stderr", () => { + it("fails closed when the config masker succeeds with oversized stderr", () => { const validatorScript = [ "#!/usr/bin/env python3", "import sys", - "sys.stderr.write('x' * 70000)", - "raise SystemExit(1)", + "sys.stderr.write('x' * (11 * 1024 * 1024))", + "raise SystemExit(0)", "", ].join("\n"); const run = runWrapper(["config", "show"], {}, { validatorScript }); expect(run.status).toBe(1); - expect(run.stderr).toContain("output masker failed"); + expect(run.stderr).toContain("output masker stderr exceeded"); expect(run.stderr).not.toContain("xxxxxxxxxxxxxxxx"); }); From 43aa3517d9ad9ba95483da5b2222a0f7ce7852a6 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 04:41:05 +0800 Subject: [PATCH 15/34] test(hermes): tolerate truncated session previews --- test/e2e/live/hermes-e2e.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index bf071b264ca..386d83c0225 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -478,13 +478,13 @@ test.skipIf(!shouldRunLiveE2E())( args: string[], runArtifact: string, afterArtifact: string, - previewNeedle: string, + listNeedle: string, ) => { await runHermesCli(args, runArtifact); const afterText = await listHermesSessionsText(afterArtifact); const after = hermesSessionIds(afterText); expect([...after].filter((id) => !before.has(id))).toEqual([]); - expect(stripAnsi(afterText)).toContain(previewNeedle); + expect(stripAnsi(afterText)).toContain(listNeedle); }; const issue5254Marker = `NEMOCLAW_5254_${Date.now()}`; @@ -502,7 +502,7 @@ test.skipIf(!shouldRunLiveE2E())( ["--resume", seedSessionId, "-z", resumePrompt, "--pass-session-id", "--ignore-rules"], "phase-4-issue-5254-resume-oneshot", "phase-4-issue-5254-sessions-after-resume", - `${issue5254Marker}_RESUME`, + issue5254Marker.slice(0, 24), ); const continuePrompt = `Confirm this exact token again: ${issue5254Marker}_CONTINUE.`; @@ -511,7 +511,7 @@ test.skipIf(!shouldRunLiveE2E())( ["-c", seedSessionId, "-z", continuePrompt], "phase-4-issue-5254-continue-oneshot", "phase-4-issue-5254-sessions-after-continue", - `${issue5254Marker}_CONTINUE`, + issue5254Marker.slice(0, 24), ); const exportPath = `/tmp/nemoclaw-issue-5254-${issue5254Marker}.jsonl`; From 3f11c80baca7d7f8c762b5abbdcf43bf24121234 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 04:55:23 +0800 Subject: [PATCH 16/34] test(hermes): rely on export for full session turn markers --- test/e2e/live/hermes-e2e.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index 386d83c0225..88ad8b4c7b3 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -475,16 +475,16 @@ test.skipIf(!shouldRunLiveE2E())( hermesSessionIds(await listHermesSessionsText(artifactName)); const expectNoNewHermesSessions = async ( before: Set, + expectedSessionId: string, args: string[], runArtifact: string, afterArtifact: string, - listNeedle: string, ) => { await runHermesCli(args, runArtifact); const afterText = await listHermesSessionsText(afterArtifact); const after = hermesSessionIds(afterText); expect([...after].filter((id) => !before.has(id))).toEqual([]); - expect(stripAnsi(afterText)).toContain(listNeedle); + expect(after.has(expectedSessionId), stripAnsi(afterText)).toBe(true); }; const issue5254Marker = `NEMOCLAW_5254_${Date.now()}`; @@ -499,19 +499,19 @@ test.skipIf(!shouldRunLiveE2E())( const resumePrompt = `Repeat this exact token: ${issue5254Marker}_RESUME.`; await expectNoNewHermesSessions( await listHermesSessions("phase-4-issue-5254-sessions-before-resume"), + seedSessionId, ["--resume", seedSessionId, "-z", resumePrompt, "--pass-session-id", "--ignore-rules"], "phase-4-issue-5254-resume-oneshot", "phase-4-issue-5254-sessions-after-resume", - issue5254Marker.slice(0, 24), ); const continuePrompt = `Confirm this exact token again: ${issue5254Marker}_CONTINUE.`; await expectNoNewHermesSessions( await listHermesSessions("phase-4-issue-5254-sessions-before-continue"), + seedSessionId, ["-c", seedSessionId, "-z", continuePrompt], "phase-4-issue-5254-continue-oneshot", "phase-4-issue-5254-sessions-after-continue", - issue5254Marker.slice(0, 24), ); const exportPath = `/tmp/nemoclaw-issue-5254-${issue5254Marker}.jsonl`; From 87aeba4ab6ee6d2ac3bfb3e33576e5869075bb6b Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 05:24:05 +0800 Subject: [PATCH 17/34] test(hermes): extend live one-shot timeout --- test/e2e/live/hermes-e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index 88ad8b4c7b3..2a57dabcc37 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -459,7 +459,7 @@ test.skipIf(!shouldRunLiveE2E())( expect(configProbe.exitCode, resultText(configProbe)).toBe(0); expect(configProbe.stdout).toContain("OK"); - const runHermesCli = async (args: string[], artifactName: string, timeoutMs = 180_000) => { + const runHermesCli = async (args: string[], artifactName: string, timeoutMs = 6 * 60_000) => { const result = await sandbox.exec(SANDBOX_NAME, ["hermes", ...args], { artifactName, env: commandEnv(), From 58045f422080b509b523726ead0323e10591be66 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 05:47:26 +0800 Subject: [PATCH 18/34] test(hermes): assert resumed session list freshness --- test/e2e/live/hermes-e2e.test.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index 2a57dabcc37..e66196e078a 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -23,12 +23,6 @@ import { } from "../fixtures/security-posture.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; -// This is intentionally a direct live Vitest test, not a new registry layer: -// the contract is the real installer/onboard/runtime boundary for Hermes. -// Vitest owns artifacts, cleanup, redaction, and timeouts while still spawning -// `bash install.sh --non-interactive --fresh`, `nemoclaw`, `openshell`, sandbox exec, -// direct NVIDIA Endpoints curl, and inference.local probes. - const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-hermes"; validateSandboxName(SANDBOX_NAME); @@ -476,6 +470,7 @@ test.skipIf(!shouldRunLiveE2E())( const expectNoNewHermesSessions = async ( before: Set, expectedSessionId: string, + expectedRowToken: string, args: string[], runArtifact: string, afterArtifact: string, @@ -485,6 +480,10 @@ test.skipIf(!shouldRunLiveE2E())( const after = hermesSessionIds(afterText); expect([...after].filter((id) => !before.has(id))).toEqual([]); expect(after.has(expectedSessionId), stripAnsi(afterText)).toBe(true); + const row = stripAnsi(afterText) + .split("\n") + .find((line) => line.includes(expectedSessionId)); + expect(row, stripAnsi(afterText)).toContain(expectedRowToken); }; const issue5254Marker = `NEMOCLAW_5254_${Date.now()}`; @@ -496,19 +495,21 @@ test.skipIf(!shouldRunLiveE2E())( await listHermesSessions("phase-4-issue-5254-sessions-after-seed"), ); - const resumePrompt = `Repeat this exact token: ${issue5254Marker}_RESUME.`; + const resumePrompt = `N5254_${Date.now().toString(36)}_RESUME`; await expectNoNewHermesSessions( await listHermesSessions("phase-4-issue-5254-sessions-before-resume"), seedSessionId, + resumePrompt, ["--resume", seedSessionId, "-z", resumePrompt, "--pass-session-id", "--ignore-rules"], "phase-4-issue-5254-resume-oneshot", "phase-4-issue-5254-sessions-after-resume", ); - const continuePrompt = `Confirm this exact token again: ${issue5254Marker}_CONTINUE.`; + const continuePrompt = `N5254_${Date.now().toString(36)}_CONTINUE`; await expectNoNewHermesSessions( await listHermesSessions("phase-4-issue-5254-sessions-before-continue"), seedSessionId, + continuePrompt, ["-c", seedSessionId, "-z", continuePrompt], "phase-4-issue-5254-continue-oneshot", "phase-4-issue-5254-sessions-after-continue", @@ -532,9 +533,8 @@ test.skipIf(!shouldRunLiveE2E())( ); expect(exportResult.exitCode, resultText(exportResult)).toBe(0); const exportedSession = resultText(exportResult); - expect(exportedSession).toContain(seedPrompt); - expect(exportedSession).toContain(resumePrompt); - expect(exportedSession).toContain(continuePrompt); + for (const prompt of [seedPrompt, resumePrompt, continuePrompt]) + expect(exportedSession).toContain(prompt); if (hermesDashboardE2eEnabled()) { const entry = registryEntry(SANDBOX_NAME); From c9ed6e9016e353a9d7a0fe270a555c59c111971d Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 06:13:16 +0800 Subject: [PATCH 19/34] fix(hermes): refresh session list preview --- agents/hermes/Dockerfile | 23 ++++++++++- agents/hermes/patch-session-list-preview.py | 45 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 agents/hermes/patch-session-list-preview.py diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 83907fe91eb..87b6727dfe4 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -126,6 +126,7 @@ COPY agents/hermes/start.sh /usr/local/bin/nemoclaw-start COPY scripts/gateway-control.sh /usr/local/bin/nemoclaw-gateway-control COPY scripts/managed-gateway-control.py /usr/local/lib/nemoclaw/managed-gateway-control.py COPY agents/hermes/validate-env-secret-boundary.py /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py +COPY agents/hermes/patch-session-list-preview.py /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py COPY agents/hermes/seed-dashboard-config.py /usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py COPY agents/hermes/runtime-config-guard.py /usr/local/lib/nemoclaw/hermes-runtime-config-guard.py COPY agents/hermes/build-mcp-digest.py /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py @@ -138,7 +139,7 @@ COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/ # profile hook, bashrc hook, or root-owned helper mode. Remove it once the # minimum supported Hermes sandbox base tag guarantees those artifacts and # test/sandbox-rlimit-hooks.test.ts covers that base. -RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/sandbox-init.sh /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py /usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py /usr/local/lib/nemoclaw/hermes-runtime-config-guard.py /usr/local/lib/nemoclaw/hermes-mcp-config-transaction.py \ +RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/sandbox-init.sh /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py /usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py /usr/local/lib/nemoclaw/hermes-runtime-config-guard.py /usr/local/lib/nemoclaw/hermes-mcp-config-transaction.py \ && chown root:root /usr/local/bin/nemoclaw-gateway-control /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/state-dir-guard.py /usr/local/lib/nemoclaw/managed-gateway-control.py /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.72.json \ && chmod 700 /usr/local/bin/nemoclaw-gateway-control \ && chmod 500 /usr/local/lib/nemoclaw/state-dir-guard.py /usr/local/lib/nemoclaw/managed-gateway-control.py \ @@ -174,6 +175,26 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/sandbox-init RUN test -x /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py \ || { echo "ERROR: validate-hermes-env-secret-boundary.py missing or not executable" >&2; exit 1; } +# Hermes v0.17.0 computes `sessions list` preview from the first user message, +# while #5254's user-facing expectation is that the existing row reflects the +# latest resumed/continued one-shot turn. Patch only the pinned query shape and +# prove the SessionDB list contract at build time so a Hermes update cannot +# silently drift. +RUN /usr/bin/python3 -I /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py \ + && HERMES_HOME="$(mktemp -d)" /opt/hermes/.venv/bin/python - <<'PY' +from hermes_state import SessionDB + +db = SessionDB() +session_id = "nemoclaw-preview-smoke" +db.create_session(session_id, "cli") +db.append_message(session_id, "user", "NEMOCLAW_PREVIEW_FIRST") +db.append_message(session_id, "assistant", "ack") +db.append_message(session_id, "user", "NEMOCLAW_PREVIEW_LATEST") +rows = db.list_sessions_rich(limit=1) +assert rows and rows[0]["id"] == session_id, rows +assert rows[0]["preview"] == "NEMOCLAW_PREVIEW_LATEST", rows +PY + # Cryptographic integrity gate for the two security-critical Python entrypoints # — the wrapper that enforces the runtime env secret boundary and the validator # it delegates to. Any content change to either file MUST be accompanied by an diff --git a/agents/hermes/patch-session-list-preview.py b/agents/hermes/patch-session-list-preview.py new file mode 100644 index 00000000000..7c6a835aeff --- /dev/null +++ b/agents/hermes/patch-session-list-preview.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Patch pinned Hermes v0.17.0 session-list previews to show the latest user turn.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +OLD = "ORDER BY m.timestamp, m.id LIMIT 1" +NEW = "ORDER BY m.timestamp DESC, m.id DESC LIMIT 1" +EXPECTED_OCCURRENCES = 6 + + +def patch_file(path: Path) -> None: + source = path.read_text(encoding="utf-8") + old_count = source.count(OLD) + new_count = source.count(NEW) + if old_count == 0 and new_count == EXPECTED_OCCURRENCES: + return + if old_count != EXPECTED_OCCURRENCES: + raise SystemExit( + "ERROR: Hermes session preview query shape changed; " + f"expected {EXPECTED_OCCURRENCES} unpatched occurrences, found {old_count} " + f"(already patched occurrences: {new_count})" + ) + path.write_text(source.replace(OLD, NEW), encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "path", + nargs="?", + default="/opt/hermes/hermes_state.py", + help="Hermes state module to patch", + ) + args = parser.parse_args() + patch_file(Path(args.path)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From d8f8db0b05284ccca16115220d0b30180c8db8c7 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 06:22:22 +0800 Subject: [PATCH 20/34] chore(hermes): mark preview patch executable --- agents/hermes/patch-session-list-preview.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 agents/hermes/patch-session-list-preview.py diff --git a/agents/hermes/patch-session-list-preview.py b/agents/hermes/patch-session-list-preview.py old mode 100644 new mode 100755 From 5057d03f20fd9b692bb399f56a25df7a67e552c8 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 06:35:34 +0800 Subject: [PATCH 21/34] test(hermes): update Dockerfile replay fixtures --- test/hermes-doctor-config-hash.test.ts | 1 + test/sandbox-provisioning.test.ts | 1 + test/sandbox-rlimit-hooks.test.ts | 6 ++++++ 3 files changed, 8 insertions(+) diff --git a/test/hermes-doctor-config-hash.test.ts b/test/hermes-doctor-config-hash.test.ts index b244bf624e7..94ea6767b80 100644 --- a/test/hermes-doctor-config-hash.test.ts +++ b/test/hermes-doctor-config-hash.test.ts @@ -42,6 +42,7 @@ describe("Hermes doctor and config hash boundary", () => { path.join(libDir, "sandbox-init.sh"), path.join(libDir, "gateway-supervisor.sh"), path.join(libDir, "validate-hermes-env-secret-boundary.py"), + path.join(libDir, "patch-hermes-session-list-preview.py"), path.join(libDir, "seed-hermes-dashboard-config.py"), path.join(libDir, "hermes-runtime-config-guard.py"), buildMcpDigestPath, diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index faebe8884cd..82355b84e65 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -1212,6 +1212,7 @@ describe("Hermes sandbox provisioning", () => { gatewayControlPath, path.join(localLib, "sandbox-init.sh"), path.join(localLib, "validate-hermes-env-secret-boundary.py"), + path.join(localLib, "patch-hermes-session-list-preview.py"), path.join(localLib, "seed-hermes-dashboard-config.py"), path.join(localLib, "hermes-runtime-config-guard.py"), buildMcpDigestPath, diff --git a/test/sandbox-rlimit-hooks.test.ts b/test/sandbox-rlimit-hooks.test.ts index dbf861969ea..f6dc36c7117 100644 --- a/test/sandbox-rlimit-hooks.test.ts +++ b/test/sandbox-rlimit-hooks.test.ts @@ -406,6 +406,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { const rlimitLib = path.join(localLib, "sandbox-rlimits.sh"); const initLib = path.join(localLib, "sandbox-init.sh"); const validator = path.join(localLib, "validate-hermes-env-secret-boundary.py"); + const sessionListPreviewPatcher = path.join(localLib, "patch-hermes-session-list-preview.py"); const dashboardSeeder = path.join(localLib, "seed-hermes-dashboard-config.py"); const runtimeGuard = path.join(localLib, "hermes-runtime-config-guard.py"); const buildMcpDigest = path.join(localLib, "build-hermes-mcp-digest.py"); @@ -431,6 +432,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { copyRlimitFixture(rlimitLib); fs.writeFileSync(initLib, "# init fixture\n"); fs.writeFileSync(validator, "# validator fixture\n"); + fs.writeFileSync(sessionListPreviewPatcher, "# session list preview patcher fixture\n"); fs.writeFileSync(dashboardSeeder, "# dashboard seeder fixture\n"); fs.writeFileSync(runtimeGuard, "# runtime guard fixture\n"); fs.writeFileSync(buildMcpDigest, "# build MCP digest fixture\n"); @@ -459,6 +461,10 @@ describe("sandbox rlimit system hooks (#2173)", () => { .replaceAll("/usr/local/lib/nemoclaw/sandbox-init.sh", initLib) .replaceAll("/usr/local/lib/nemoclaw/gateway-supervisor.sh", gatewaySupervisor) .replaceAll("/usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py", validator) + .replaceAll( + "/usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py", + sessionListPreviewPatcher, + ) .replaceAll("/usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py", dashboardSeeder) .replaceAll("/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py", runtimeGuard) .replaceAll("/usr/local/lib/nemoclaw/build-hermes-mcp-digest.py", buildMcpDigest) From 9e5a002105f5cbb8d58a6cc50bf5fa66840ac435 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 06:37:42 +0800 Subject: [PATCH 22/34] test(hermes): keep replay fixtures within budget --- test/sandbox-provisioning.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 82355b84e65..d0cd904d957 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -1232,7 +1232,6 @@ describe("Hermes sandbox provisioning", () => { .replaceAll("/usr/local/lib/nemoclaw", localLib) .replaceAll("/etc/profile.d", profileDir) .replaceAll("/etc/bash.bashrc", bashrcPath); - try { fs.mkdirSync(localBin, { recursive: true }); fs.mkdirSync(localLib, { recursive: true }); From b8d673833fa8c841410c7c134d2dec6dc10a9e96 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 07:03:26 +0800 Subject: [PATCH 23/34] fix(hermes): harden resume workaround follow-up --- agents/hermes/Dockerfile | 10 ++++++++- agents/hermes/hermes-wrapper.py | 19 ++++++++++++++++- agents/hermes/patch-session-list-preview.py | 20 +++++++++++++++++- test/e2e/live/hermes-e2e.test.ts | 1 + test/hermes-gateway-wrapper.test.ts | 23 ++++++++++----------- 5 files changed, 58 insertions(+), 15 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 87b6727dfe4..fb29f90ba4a 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -11,6 +11,8 @@ ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:d6ce792eb302a7 # hadolint ignore=DL3006 FROM ${BASE_IMAGE} +ARG HERMES_SEMVER=0.17.0 + # Keep the final image contract explicit even when the published base image # changes independently of this Dockerfile. RUN set -eu; \ @@ -202,7 +204,7 @@ PY # chain tampering of the build context (an attacker rewriting the file has to # also rewrite the Dockerfile-committed hash, which reviewers gate). Regenerate # with `sha256sum agents/hermes/{hermes-wrapper.py,validate-env-secret-boundary.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=fced7ed33ac2d61d3c0852a9884ef352a9b92ec645dbed52732c04785e20c760 +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=78b5e8eb6d4073329adbf4eb9d27d2ef7d9a7844c3a09644bebf342a15345823 ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=970d7ff03bc409ff1d5ca46bfdbd2a42ac28a32a810ccc147a508301bff38496 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ @@ -224,6 +226,12 @@ RUN printf '%s %s\n' \ COPY agents/hermes/hermes-wrapper.py /usr/local/lib/nemoclaw/hermes-wrapper.py RUN test -x /usr/bin/python3 \ || { echo "ERROR: /usr/bin/python3 missing or not executable; hermes-wrapper shebang would ENOEXEC" >&2; exit 1; } +RUN if [ "$HERMES_SEMVER" != "0.17.0" ] \ + && { grep -q '_translate_resumed_oneshot' /usr/local/lib/nemoclaw/hermes-wrapper.py \ + || grep -q 'patch-session-list-preview' /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py; }; then \ + echo "ERROR: HERMES_SEMVER=${HERMES_SEMVER} but Hermes v0.17.0 compatibility workarounds are still installed; re-review #5254 workaround removal before upgrading Hermes" >&2; \ + exit 1; \ + fi # This runs before `/usr/local/bin/hermes` is moved to `hermes.real`, so the # help probe checks the pinned Hermes binary, not the wrapper installed below. RUN /usr/bin/python3 -I -c 'import ast, pathlib, subprocess, sys; source = pathlib.Path("/usr/local/lib/nemoclaw/hermes-wrapper.py").read_text(); tree = ast.parse(source); constants = {node.targets[0].id: ast.literal_eval(node.value) for node in tree.body if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name) and node.targets[0].id in {"_VALUE_FLAGS", "_BOOLEAN_FLAGS"}}; missing_constants = sorted({"_VALUE_FLAGS", "_BOOLEAN_FLAGS"} - set(constants)); missing_constants and sys.exit("ERROR: Hermes wrapper flag constants not found in AST: " + ", ".join(missing_constants)); not constants["_VALUE_FLAGS"] and sys.exit("ERROR: Hermes wrapper _VALUE_FLAGS is empty"); not constants["_BOOLEAN_FLAGS"] and sys.exit("ERROR: Hermes wrapper _BOOLEAN_FLAGS is empty"); top_expected = set(constants["_VALUE_FLAGS"]) | set(constants["_VALUE_FLAGS"].values()) | set(constants["_BOOLEAN_FLAGS"]) | {"-z", "--oneshot", "-c", "--continue"}; top_help = subprocess.check_output(["/usr/local/bin/hermes", "--help"], text=True, timeout=30); top_missing = sorted(flag for flag in top_expected if flag not in top_help); top_missing and sys.exit("ERROR: Hermes wrapper flag allowlist drifted from pinned hermes --help: " + ", ".join(top_missing)); chat_expected = set(constants["_VALUE_FLAGS"].values()) | set(constants["_BOOLEAN_FLAGS"]) | {"--query", "--quiet", "--resume", "-r", "--continue", "-c"}; chat_help = subprocess.check_output(["/usr/local/bin/hermes", "chat", "--help"], text=True, timeout=30); chat_missing = sorted(flag for flag in chat_expected if flag not in chat_help); chat_missing and sys.exit("ERROR: Hermes wrapper forwarded flags drifted from pinned hermes chat --help: " + ", ".join(chat_missing))' diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index 68d91b7a1b5..a3a24e9a6c9 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -195,7 +195,24 @@ def _run_config_show(real_hermes: str, guard_path: str, argv: list[str]) -> int: stdout=masker_stdout.stdin, stderr=masker_stderr.stdin, ) - finally: + except OSError as exc: + if masker_stdout.stdin is not None: + masker_stdout.stdin.close() + if masker_stderr.stdin is not None: + masker_stderr.stdin.close() + for masker in (masker_stdout, masker_stderr): + try: + masker.wait(timeout=5) + except subprocess.TimeoutExpired: + masker.terminate() + masker.wait(timeout=5) + print( + "[SECURITY] Refusing hermes config show: failed to exec Hermes " + f"({exc.__class__.__name__})", + file=sys.stderr, + ) + return 126 + else: if masker_stdout.stdin is not None: masker_stdout.stdin.close() if masker_stderr.stdin is not None: diff --git a/agents/hermes/patch-session-list-preview.py b/agents/hermes/patch-session-list-preview.py index 7c6a835aeff..f4f089a3a3f 100755 --- a/agents/hermes/patch-session-list-preview.py +++ b/agents/hermes/patch-session-list-preview.py @@ -1,7 +1,25 @@ #!/usr/bin/env python3 # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Patch pinned Hermes v0.17.0 session-list previews to show the latest user turn.""" +"""Patch pinned Hermes v0.17.0 session-list previews to show the latest user turn. + +Source-of-truth note for this localized Hermes runtime patch: + - Invalid state: Hermes v0.17.0 computes `sessions list` preview text from + the first user message, but #5254's resumed/continued one-shot UX expects + the original row to reflect the latest appended turn. + - Value being patched: pinned/prebuilt `/opt/hermes/hermes_state.py` + occurrences of `ORDER BY m.timestamp, m.id LIMIT 1` inside + `SessionDB.list_sessions_rich()`. + - Source-fix constraint: NemoClaw layers a sandbox image on top of the + published Hermes runtime; the source fix belongs upstream in Hermes, not in + NemoClaw's TypeScript or wrapper code. + - Regression test: this script's exact occurrence count fails closed when the + pinned source shape drifts, and the Dockerfile smoke test creates a + `SessionDB`, appends first/latest user turns, and asserts the list preview + returns `NEMOCLAW_PREVIEW_LATEST`. + - Removal condition: delete this patch when the pinned Hermes runtime + natively uses the latest user turn for `sessions list` previews. +""" from __future__ import annotations diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index e66196e078a..a82ef9a58f9 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -519,6 +519,7 @@ test.skipIf(!shouldRunLiveE2E())( const exportScript = [ `rm -f ${shellQuote(exportPath)}`, `hermes sessions export --session-id ${shellQuote(seedSessionId)} ${shellQuote(exportPath)}`, + `python3 -c ${shellQuote("import json,sys\nraw=open(sys.argv[1],encoding='utf-8').read()\ntry:\n docs=[json.loads(raw)]\nexcept Exception:\n docs=[json.loads(line) for line in raw.splitlines() if line.strip()]\nmsgs=[]\ndef walk(v):\n if isinstance(v,dict) and isinstance(v.get('messages'),list):\n [walk(item) for item in v['messages']]\n elif isinstance(v,dict) and isinstance(v.get('role'),str) and 'content' in v:\n content=v['content'] if isinstance(v['content'],str) else json.dumps(v['content'],sort_keys=True)\n msgs.append((v['role'],content))\n elif isinstance(v,dict):\n [walk(item) for item in v.values()]\n elif isinstance(v,list):\n [walk(item) for item in v]\n[walk(doc) for doc in docs]\ndef pos(prompt):\n return next((i for i,(role,content) in enumerate(msgs) if role=='user' and prompt in content),-1)\ns,r,c=[pos(prompt) for prompt in sys.argv[2:5]]\nassert 0 <= s < r < c, msgs\nassert any(role=='assistant' for role,_ in msgs[r+1:c]), msgs\nassert any(role=='assistant' for role,_ in msgs[c+1:]), msgs")} ${shellQuote(exportPath)} ${shellQuote(seedPrompt)} ${shellQuote(resumePrompt)} ${shellQuote(continuePrompt)}`, `cat ${shellQuote(exportPath)}`, ].join(" && "); const exportResult = await sandbox.execShell( diff --git a/test/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index 9bc00499ca4..c841c35cecb 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -50,18 +50,8 @@ type WrapperRun = { realArgv: string[]; }; -type StubBehaviour = { - stdout?: string; - stderr?: string; - exitCode?: number; -}; +type StubBehaviour = { stdout?: string; stderr?: string; exitCode?: number }; -// Run the wrapper against a temp install: a copy of the wrapper alongside the -// real validator and a `hermes.real` stub. The wrapper's dev fallback resolves -// both from its own directory because the /usr/local install paths are absent. -// The stub records the args it was exec'd with so we can prove pass-through vs. -// refusal. `env` fully replaces the process env so CI-injected secret-shaped -// vars (e.g. GITHUB_TOKEN) cannot perturb the validator. function runWrapper( args: string[], env: Record, @@ -69,6 +59,7 @@ function runWrapper( shadowPython?: boolean; shadowHelpers?: Record; stub?: StubBehaviour; + stubMode?: number; validatorScript?: string; } = {}, ): WrapperRun { @@ -96,7 +87,7 @@ function runWrapper( `exit ${stubExit}`, "", ].join("\n"); - fs.writeFileSync(path.join(dir, "hermes.real"), stubScript, { mode: 0o755 }); + fs.writeFileSync(path.join(dir, "hermes.real"), stubScript, { mode: opts.stubMode ?? 0o755 }); // Plant malicious helpers earlier on PATH; the wrapper must ignore them. const planted: Record = { @@ -657,6 +648,14 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(run.stdout).toContain("api_key: sk-****"); }); + it("fails closed without a traceback when config show cannot exec Hermes", () => { + const run = runWrapper(["config", "show"], {}, { stubMode: 0o644 }); + expect(run.status).toBe(126); + expect(run.stderr).toContain("[SECURITY] Refusing hermes config show: failed to exec Hermes"); + expect(run.stderr).not.toContain("Traceback"); + expect(run.realInvoked).toBe(false); + }); + it("leaves non-`config show` output untouched even when api_key shapes appear", () => { const fixture = "providers:\n nemoclaw-inference:\n api_key: sk-OPENSHELL-PROXY-REWRITE"; const run = runWrapper(["config", "list"], {}, { stub: { stdout: fixture, exitCode: 0 } }); From 1f9788f2f7f3a72752b16c6b7d4d1b59d6d0ca23 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 07:34:03 +0800 Subject: [PATCH 24/34] test(hermes): assert resumed session activity refresh --- test/e2e/fixtures/hermes-session.ts | 70 +++++++++++++++++++++++++++++ test/e2e/live/hermes-e2e.test.ts | 30 ++++++------- 2 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 test/e2e/fixtures/hermes-session.ts diff --git a/test/e2e/fixtures/hermes-session.ts b/test/e2e/fixtures/hermes-session.ts new file mode 100644 index 00000000000..418cb7e67ec --- /dev/null +++ b/test/e2e/fixtures/hermes-session.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { resultText, shellQuote } from "./clients/command.ts"; +import { + type SandboxClient, + sandboxAccessEnv, + trustedSandboxShellScript, +} from "./clients/sandbox.ts"; +import type { ShellProbeRunOptions } from "./shell-probe.ts"; + +export interface HermesSessionRow { + id: string; + last_active: number; + message_count: number; + preview: string; +} + +const SESSION_ROW_SCRIPT = + "from hermes_state import SessionDB\nimport json,sys\nrow=next((r for r in SessionDB().list_sessions_rich(limit=200) if r['id']==sys.argv[1]), None)\nassert row is not None, sys.argv[1]\nprint(json.dumps({'id':row['id'],'last_active':row['last_active'],'message_count':row['message_count'],'preview':row['preview']}))"; + +export async function hermesSessionRow( + sandbox: SandboxClient, + sandboxName: string, + sessionId: string, + artifactName: string, +): Promise { + const result = await sandbox.exec( + sandboxName, + ["/opt/hermes/.venv/bin/python", "-c", SESSION_ROW_SCRIPT, sessionId], + { artifactName, env: sandboxAccessEnv(), timeoutMs: 30_000 }, + ); + if (result.exitCode !== 0) throw new Error(resultText(result)); + const row = JSON.parse(result.stdout) as HermesSessionRow; + if (typeof row.last_active !== "number") { + throw new Error(`Hermes session row missing numeric last_active: ${result.stdout}`); + } + return row; +} + +export async function hermesLastActive( + sandbox: SandboxClient, + sandboxName: string, + sessionId: string, + artifactName: string, +): Promise { + return (await hermesSessionRow(sandbox, sandboxName, sessionId, artifactName)).last_active; +} + +export async function exportHermesSession( + sandbox: SandboxClient, + sandboxName: string, + sessionId: string, + exportPath: string, + prompts: [string, string, string], + options: ShellProbeRunOptions, +): Promise { + const exportScript = [ + `rm -f ${shellQuote(exportPath)}`, + `hermes sessions export --session-id ${shellQuote(sessionId)} ${shellQuote(exportPath)}`, + `python3 -c ${shellQuote("import json,sys\nraw=open(sys.argv[1],encoding='utf-8').read()\ntry:\n docs=[json.loads(raw)]\nexcept Exception:\n docs=[json.loads(line) for line in raw.splitlines() if line.strip()]\nmsgs=[]\ndef walk(v):\n if isinstance(v,dict) and isinstance(v.get('messages'),list):\n [walk(item) for item in v['messages']]\n elif isinstance(v,dict) and isinstance(v.get('role'),str) and 'content' in v:\n content=v['content'] if isinstance(v['content'],str) else json.dumps(v['content'],sort_keys=True)\n msgs.append((v['role'],content))\n elif isinstance(v,dict):\n [walk(item) for item in v.values()]\n elif isinstance(v,list):\n [walk(item) for item in v]\n[walk(doc) for doc in docs]\ndef pos(prompt):\n return next((i for i,(role,content) in enumerate(msgs) if role=='user' and prompt in content),-1)\ns,r,c=[pos(prompt) for prompt in sys.argv[2:5]]\nassert 0 <= s < r < c, msgs\nassert any(role=='assistant' for role,_ in msgs[r+1:c]), msgs\nassert any(role=='assistant' for role,_ in msgs[c+1:]), msgs")} ${shellQuote(exportPath)} ${prompts.map(shellQuote).join(" ")}`, + `cat ${shellQuote(exportPath)}`, + ].join(" && "); + const result = await sandbox.execShell( + sandboxName, + trustedSandboxShellScript(exportScript), + options, + ); + if (result.exitCode !== 0) throw new Error(resultText(result)); +} diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index a82ef9a58f9..dc1a5af5ad5 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -11,6 +11,7 @@ import { shellQuote } from "../fixtures/clients/command.ts"; import { trustedProviderEndpoint } from "../fixtures/clients/provider.ts"; import { trustedSandboxShellScript, validateSandboxName } from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; +import { exportHermesSession, hermesLastActive } from "../fixtures/hermes-session.ts"; import { DEFAULT_HOSTED_INFERENCE_MODEL, requireHostedInferenceConfig, @@ -467,14 +468,18 @@ test.skipIf(!shouldRunLiveE2E())( runHermesCli(["sessions", "list"], artifactName, 60_000); const listHermesSessions = async (artifactName: string) => hermesSessionIds(await listHermesSessionsText(artifactName)); + const sessionLastActive = (id: string, artifactName: string) => + hermesLastActive(sandbox, SANDBOX_NAME, id, artifactName); const expectNoNewHermesSessions = async ( before: Set, + beforeActivityArtifact: string, expectedSessionId: string, expectedRowToken: string, args: string[], runArtifact: string, afterArtifact: string, ) => { + const beforeActivity = await sessionLastActive(expectedSessionId, beforeActivityArtifact); await runHermesCli(args, runArtifact); const afterText = await listHermesSessionsText(afterArtifact); const after = hermesSessionIds(afterText); @@ -484,6 +489,9 @@ test.skipIf(!shouldRunLiveE2E())( .split("\n") .find((line) => line.includes(expectedSessionId)); expect(row, stripAnsi(afterText)).toContain(expectedRowToken); + expect( + await sessionLastActive(expectedSessionId, `${afterArtifact}-metadata`), + ).toBeGreaterThan(beforeActivity); }; const issue5254Marker = `NEMOCLAW_5254_${Date.now()}`; @@ -494,37 +502,33 @@ test.skipIf(!shouldRunLiveE2E())( beforeSeedSessions, await listHermesSessions("phase-4-issue-5254-sessions-after-seed"), ); - const resumePrompt = `N5254_${Date.now().toString(36)}_RESUME`; await expectNoNewHermesSessions( await listHermesSessions("phase-4-issue-5254-sessions-before-resume"), + "phase-4-issue-5254-session-before-resume-metadata", seedSessionId, resumePrompt, ["--resume", seedSessionId, "-z", resumePrompt, "--pass-session-id", "--ignore-rules"], "phase-4-issue-5254-resume-oneshot", "phase-4-issue-5254-sessions-after-resume", ); - const continuePrompt = `N5254_${Date.now().toString(36)}_CONTINUE`; await expectNoNewHermesSessions( await listHermesSessions("phase-4-issue-5254-sessions-before-continue"), + "phase-4-issue-5254-session-before-continue-metadata", seedSessionId, continuePrompt, ["-c", seedSessionId, "-z", continuePrompt], "phase-4-issue-5254-continue-oneshot", "phase-4-issue-5254-sessions-after-continue", ); - const exportPath = `/tmp/nemoclaw-issue-5254-${issue5254Marker}.jsonl`; - const exportScript = [ - `rm -f ${shellQuote(exportPath)}`, - `hermes sessions export --session-id ${shellQuote(seedSessionId)} ${shellQuote(exportPath)}`, - `python3 -c ${shellQuote("import json,sys\nraw=open(sys.argv[1],encoding='utf-8').read()\ntry:\n docs=[json.loads(raw)]\nexcept Exception:\n docs=[json.loads(line) for line in raw.splitlines() if line.strip()]\nmsgs=[]\ndef walk(v):\n if isinstance(v,dict) and isinstance(v.get('messages'),list):\n [walk(item) for item in v['messages']]\n elif isinstance(v,dict) and isinstance(v.get('role'),str) and 'content' in v:\n content=v['content'] if isinstance(v['content'],str) else json.dumps(v['content'],sort_keys=True)\n msgs.append((v['role'],content))\n elif isinstance(v,dict):\n [walk(item) for item in v.values()]\n elif isinstance(v,list):\n [walk(item) for item in v]\n[walk(doc) for doc in docs]\ndef pos(prompt):\n return next((i for i,(role,content) in enumerate(msgs) if role=='user' and prompt in content),-1)\ns,r,c=[pos(prompt) for prompt in sys.argv[2:5]]\nassert 0 <= s < r < c, msgs\nassert any(role=='assistant' for role,_ in msgs[r+1:c]), msgs\nassert any(role=='assistant' for role,_ in msgs[c+1:]), msgs")} ${shellQuote(exportPath)} ${shellQuote(seedPrompt)} ${shellQuote(resumePrompt)} ${shellQuote(continuePrompt)}`, - `cat ${shellQuote(exportPath)}`, - ].join(" && "); - const exportResult = await sandbox.execShell( + await exportHermesSession( + sandbox, SANDBOX_NAME, - trustedSandboxShellScript(exportScript), + seedSessionId, + exportPath, + [seedPrompt, resumePrompt, continuePrompt], { artifactName: "phase-4-issue-5254-export-session", env: commandEnv(), @@ -532,10 +536,6 @@ test.skipIf(!shouldRunLiveE2E())( timeoutMs: 60_000, }, ); - expect(exportResult.exitCode, resultText(exportResult)).toBe(0); - const exportedSession = resultText(exportResult); - for (const prompt of [seedPrompt, resumePrompt, continuePrompt]) - expect(exportedSession).toContain(prompt); if (hermesDashboardE2eEnabled()) { const entry = registryEntry(SANDBOX_NAME); From 5d603a8b7ce194397b2d518e3e19eaa492198ff7 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 07:44:16 +0800 Subject: [PATCH 25/34] test(hermes): keep session metadata probe argv safe --- test/e2e/fixtures/hermes-session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/fixtures/hermes-session.ts b/test/e2e/fixtures/hermes-session.ts index 418cb7e67ec..39716524850 100644 --- a/test/e2e/fixtures/hermes-session.ts +++ b/test/e2e/fixtures/hermes-session.ts @@ -17,7 +17,7 @@ export interface HermesSessionRow { } const SESSION_ROW_SCRIPT = - "from hermes_state import SessionDB\nimport json,sys\nrow=next((r for r in SessionDB().list_sessions_rich(limit=200) if r['id']==sys.argv[1]), None)\nassert row is not None, sys.argv[1]\nprint(json.dumps({'id':row['id'],'last_active':row['last_active'],'message_count':row['message_count'],'preview':row['preview']}))"; + "from hermes_state import SessionDB; import json, sys; row = next((r for r in SessionDB().list_sessions_rich(limit=200) if r['id'] == sys.argv[1]), None); assert row is not None, sys.argv[1]; print(json.dumps({'id': row['id'], 'last_active': row['last_active'], 'message_count': row['message_count'], 'preview': row['preview']}))"; export async function hermesSessionRow( sandbox: SandboxClient, From 72b70acda42c1aba11295eaac2afa1ce1db89dd8 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 08:18:28 +0800 Subject: [PATCH 26/34] test(hermes): anchor workaround guard to installed version --- agents/hermes/Dockerfile | 12 ++++++++---- test/update-hermes-agent-script.test.ts | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index fb29f90ba4a..a653869054f 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -11,8 +11,6 @@ ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:d6ce792eb302a7 # hadolint ignore=DL3006 FROM ${BASE_IMAGE} -ARG HERMES_SEMVER=0.17.0 - # Keep the final image contract explicit even when the published base image # changes independently of this Dockerfile. RUN set -eu; \ @@ -226,10 +224,16 @@ RUN printf '%s %s\n' \ COPY agents/hermes/hermes-wrapper.py /usr/local/lib/nemoclaw/hermes-wrapper.py RUN test -x /usr/bin/python3 \ || { echo "ERROR: /usr/bin/python3 missing or not executable; hermes-wrapper shebang would ENOEXEC" >&2; exit 1; } -RUN if [ "$HERMES_SEMVER" != "0.17.0" ] \ +RUN hermes_version_output="$(/usr/local/bin/hermes --version)" \ + && hermes_semver="$(printf '%s\n' "$hermes_version_output" | sed -n 's/.*v\([0-9][0-9]*[.][0-9][0-9]*[.][0-9][0-9]*\).*/\1/p; s/^\([0-9][0-9]*[.][0-9][0-9]*[.][0-9][0-9]*\)$/\1/p' | head -1)" \ + && if [ -z "$hermes_semver" ]; then \ + echo "ERROR: could not parse Hermes semver from: $hermes_version_output" >&2; \ + exit 1; \ + fi \ + && if [ "$hermes_semver" != "0.17.0" ] \ && { grep -q '_translate_resumed_oneshot' /usr/local/lib/nemoclaw/hermes-wrapper.py \ || grep -q 'patch-session-list-preview' /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py; }; then \ - echo "ERROR: HERMES_SEMVER=${HERMES_SEMVER} but Hermes v0.17.0 compatibility workarounds are still installed; re-review #5254 workaround removal before upgrading Hermes" >&2; \ + echo "ERROR: installed Hermes ${hermes_semver} but Hermes v0.17.0 compatibility workarounds are still installed; re-review #5254 workaround removal before upgrading Hermes" >&2; \ exit 1; \ fi # This runs before `/usr/local/bin/hermes` is moved to `hermes.real`, so the diff --git a/test/update-hermes-agent-script.test.ts b/test/update-hermes-agent-script.test.ts index d173dee170d..625c33a5947 100644 --- a/test/update-hermes-agent-script.test.ts +++ b/test/update-hermes-agent-script.test.ts @@ -15,6 +15,7 @@ const HERMES_BASE_DOCKERFILE = path.join( "hermes", "Dockerfile.base", ); +const HERMES_DOCKERFILE = path.join(import.meta.dirname, "..", "agents", "hermes", "Dockerfile"); const HERMES_MANIFEST = path.join(import.meta.dirname, "..", "agents", "hermes", "manifest.yaml"); const TARGET_TAG = "v2026.6.19"; @@ -54,6 +55,21 @@ function writeExecutable(file: string, body: string) { } describe("scripts/update-hermes-agent.sh", () => { + it("keeps the final #5254 workaround-removal guard tied to the installed Hermes version", () => { + const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); + const guardStart = dockerfile.indexOf( + "installed Hermes ${hermes_semver} but Hermes v0.17.0 compatibility workarounds", + ); + const guardBlock = dockerfile.slice(Math.max(0, guardStart - 900), guardStart + 500); + + expect(dockerfile).not.toMatch(/^ARG HERMES_SEMVER=/m); + expect(guardStart).toBeGreaterThanOrEqual(0); + expect(guardBlock).toContain("/usr/local/bin/hermes --version"); + expect(guardBlock).toContain("hermes_semver="); + expect(guardBlock).toContain("_translate_resumed_oneshot"); + expect(guardBlock).toContain("patch-session-list-preview"); + }); + it("pins rebuild overrides to the accepted full image-ID local tag family", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-update-rebuild-")); const repo = path.join(tmp, "repo"); From 5c57a3be9a79719052a9ec66764ac3e62060994b Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 08:24:47 +0800 Subject: [PATCH 27/34] chore(hermes): satisfy workaround guard static checks --- agents/hermes/Dockerfile | 1 + test/update-hermes-agent-script.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index a653869054f..b68e98f964e 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -224,6 +224,7 @@ RUN printf '%s %s\n' \ COPY agents/hermes/hermes-wrapper.py /usr/local/lib/nemoclaw/hermes-wrapper.py RUN test -x /usr/bin/python3 \ || { echo "ERROR: /usr/bin/python3 missing or not executable; hermes-wrapper shebang would ENOEXEC" >&2; exit 1; } +# hadolint ignore=DL4006 RUN hermes_version_output="$(/usr/local/bin/hermes --version)" \ && hermes_semver="$(printf '%s\n' "$hermes_version_output" | sed -n 's/.*v\([0-9][0-9]*[.][0-9][0-9]*[.][0-9][0-9]*\).*/\1/p; s/^\([0-9][0-9]*[.][0-9][0-9]*[.][0-9][0-9]*\)$/\1/p' | head -1)" \ && if [ -z "$hermes_semver" ]; then \ diff --git a/test/update-hermes-agent-script.test.ts b/test/update-hermes-agent-script.test.ts index 625c33a5947..67400ad7338 100644 --- a/test/update-hermes-agent-script.test.ts +++ b/test/update-hermes-agent-script.test.ts @@ -55,7 +55,7 @@ function writeExecutable(file: string, body: string) { } describe("scripts/update-hermes-agent.sh", () => { - it("keeps the final #5254 workaround-removal guard tied to the installed Hermes version", () => { + it("keeps the final workaround-removal guard tied to the installed Hermes version (#5254)", () => { const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); const guardStart = dockerfile.indexOf( "installed Hermes ${hermes_semver} but Hermes v0.17.0 compatibility workarounds", From 402ad595230b6472317941d9bd06d7bfb004921d Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 08:34:21 +0800 Subject: [PATCH 28/34] test(hermes): reject stale final version guards --- scripts/update-hermes-agent.sh | 3 ++ test/update-hermes-agent-script.test.ts | 63 ++++++++++++++++++------- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/scripts/update-hermes-agent.sh b/scripts/update-hermes-agent.sh index 8cb5f09b294..820bb8b29fb 100755 --- a/scripts/update-hermes-agent.sh +++ b/scripts/update-hermes-agent.sh @@ -207,6 +207,9 @@ installed_copy_schema_error() { "/sandbox/.hermes/dashboard-home"; do grep -Fq "$item" "$dockerfile" || missing+=("marker ${item}") done + if grep -q '^ARG HERMES_SEMVER=' "$dockerfile"; then + missing+=("final Dockerfile #5254 guard must derive Hermes version from installed hermes --version") + fi fi if ((${#missing[@]} == 0)); then diff --git a/test/update-hermes-agent-script.test.ts b/test/update-hermes-agent-script.test.ts index 67400ad7338..6d91dea7e1e 100644 --- a/test/update-hermes-agent-script.test.ts +++ b/test/update-hermes-agent-script.test.ts @@ -15,7 +15,6 @@ const HERMES_BASE_DOCKERFILE = path.join( "hermes", "Dockerfile.base", ); -const HERMES_DOCKERFILE = path.join(import.meta.dirname, "..", "agents", "hermes", "Dockerfile"); const HERMES_MANIFEST = path.join(import.meta.dirname, "..", "agents", "hermes", "manifest.yaml"); const TARGET_TAG = "v2026.6.19"; @@ -55,21 +54,6 @@ function writeExecutable(file: string, body: string) { } describe("scripts/update-hermes-agent.sh", () => { - it("keeps the final workaround-removal guard tied to the installed Hermes version (#5254)", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); - const guardStart = dockerfile.indexOf( - "installed Hermes ${hermes_semver} but Hermes v0.17.0 compatibility workarounds", - ); - const guardBlock = dockerfile.slice(Math.max(0, guardStart - 900), guardStart + 500); - - expect(dockerfile).not.toMatch(/^ARG HERMES_SEMVER=/m); - expect(guardStart).toBeGreaterThanOrEqual(0); - expect(guardBlock).toContain("/usr/local/bin/hermes --version"); - expect(guardBlock).toContain("hermes_semver="); - expect(guardBlock).toContain("_translate_resumed_oneshot"); - expect(guardBlock).toContain("patch-session-list-preview"); - }); - it("pins rebuild overrides to the accepted full image-ID local tag family", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-update-rebuild-")); const repo = path.join(tmp, "repo"); @@ -325,4 +309,51 @@ fi fs.rmSync(tmpHome, { recursive: true, force: true }); } }); + + it("refuses installed copies with an independently pinned final workaround guard (#5254)", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-update-final-guard-")); + const installedDockerfile = path.join( + tmpHome, + ".nemoclaw", + "source", + "agents", + "hermes", + "Dockerfile.base", + ); + const installedAgentDockerfile = path.join(path.dirname(installedDockerfile), "Dockerfile"); + const staleGuardDockerfile = [ + CURRENT_INSTALLED_DOCKERFILE, + "ARG HERMES_SEMVER=0.17.0", + 'RUN if [ "$HERMES_SEMVER" != "0.17.0" ]; then exit 1; fi', + "", + ].join("\n"); + fs.mkdirSync(path.dirname(installedDockerfile), { recursive: true }); + fs.writeFileSync(installedDockerfile, CURRENT_INSTALLED_BASE); + fs.writeFileSync(installedAgentDockerfile, staleGuardDockerfile); + + const run = spawnSync( + "bash", + [SCRIPT, "--tag", TARGET_TAG, "--check", "--update-installed-copies"], + { + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpHome, + NEMOCLAW_SOURCE_ROOT: undefined, + }, + timeout: 5000, + }, + ); + + try { + expect(run.status).toBe(1); + expect(run.stdout).toContain("INVALID: installed copy"); + expect(run.stdout).toContain("final Dockerfile #5254 guard"); + expect(run.stdout).toContain("installed hermes --version"); + expect(fs.readFileSync(installedDockerfile, "utf-8")).toBe(CURRENT_INSTALLED_BASE); + expect(fs.readFileSync(installedAgentDockerfile, "utf-8")).toBe(staleGuardDockerfile); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); }); From da286e4dba89594ef81360cd6e188d9697acd54f Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 08:51:31 +0800 Subject: [PATCH 29/34] fix(hermes): reject empty continue selectors --- agents/hermes/hermes-wrapper.py | 5 ++++- test/hermes-gateway-wrapper.test.ts | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index a3a24e9a6c9..9baee11a78b 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -393,9 +393,12 @@ def _translate_resumed_oneshot(argv: list[str]) -> list[str] | None: return None if i + 1 >= len(argv) or argv[i + 1].startswith("-"): return None + value = argv[i + 1] + if not value: + return None saw_continue = True resume_args.append("--continue") - resume_args.append(argv[i + 1]) + resume_args.append(value) i += 2 continue diff --git a/test/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index c841c35cecb..10d539d2e88 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -416,6 +416,10 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(run.realArgs).toBe("--continue= -z Repeat it"); }); + it("passes separated --continue with an empty value through instead of translating an invalid selector (#5254)", () => { + const run = runWrapper(["--continue", "", "-z", "Repeat it"], {}); + expect(run.realArgs).toBe("--continue -z Repeat it"); + }); it("passes empty --resume values through instead of translating an invalid selector (#5254)", () => { const run = runWrapper(["--resume=", "-z", "Repeat it"], {}); From bc519a335755c6cd4effa768f8356f5312cbc3a3 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 09:08:52 +0800 Subject: [PATCH 30/34] fix(hermes): update wrapper hash --- agents/hermes/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index b68e98f964e..2de890d89cb 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -202,7 +202,7 @@ PY # chain tampering of the build context (an attacker rewriting the file has to # also rewrite the Dockerfile-committed hash, which reviewers gate). Regenerate # with `sha256sum agents/hermes/{hermes-wrapper.py,validate-env-secret-boundary.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=78b5e8eb6d4073329adbf4eb9d27d2ef7d9a7844c3a09644bebf342a15345823 +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=100a1bcafeabf815d07b1d5dd707246a402111515936763af9d365b253d1a88f ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=970d7ff03bc409ff1d5ca46bfdbd2a42ac28a32a810ccc147a508301bff38496 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ From e82ffbf62a8ec4fcc24f247b5172516bf8dd7fe9 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 10:13:22 +0800 Subject: [PATCH 31/34] fix(hermes): detect preview patcher guard --- agents/hermes/Dockerfile | 2 +- test/sandbox-provisioning.test.ts | 40 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 2de890d89cb..1adb50a9f98 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -233,7 +233,7 @@ RUN hermes_version_output="$(/usr/local/bin/hermes --version)" \ fi \ && if [ "$hermes_semver" != "0.17.0" ] \ && { grep -q '_translate_resumed_oneshot' /usr/local/lib/nemoclaw/hermes-wrapper.py \ - || grep -q 'patch-session-list-preview' /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py; }; then \ + || grep -q 'EXPECTED_OCCURRENCES' /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py; }; then \ echo "ERROR: installed Hermes ${hermes_semver} but Hermes v0.17.0 compatibility workarounds are still installed; re-review #5254 workaround removal before upgrading Hermes" >&2; \ exit 1; \ fi diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index d0cd904d957..3c30bd94d37 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -1344,6 +1344,46 @@ describe("Hermes sandbox provisioning", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("hermes manifest version"); }); + it("Hermes upgrade guard detects a remaining session preview patcher (#5254)", () => { + const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-preview-guard-")); + const hermesBin = path.join(tmp, "usr", "local", "bin", "hermes"); + const wrapper = path.join(tmp, "usr", "local", "lib", "nemoclaw", "hermes-wrapper.py"); + const previewPatcher = path.join( + tmp, + "usr", + "local", + "lib", + "nemoclaw", + "patch-hermes-session-list-preview.py", + ); + const command = dockerRunCommandBetween( + dockerfile, + 'RUN hermes_version_output="$(/usr/local/bin/hermes --version)"', + "# This runs before `/usr/local/bin/hermes`", + ) + .replaceAll("/usr/local/bin/hermes", hermesBin) + .replaceAll("/usr/local/lib/nemoclaw/hermes-wrapper.py", wrapper) + .replaceAll("/usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py", previewPatcher); + try { + fs.mkdirSync(path.dirname(hermesBin), { recursive: true }); + fs.mkdirSync(path.dirname(wrapper), { recursive: true }); + fs.writeFileSync(hermesBin, "#!/usr/bin/env bash\nprintf 'hermes v0.18.0\\n'\n", { + mode: 0o755, + }); + fs.writeFileSync(wrapper, "# wrapper fixture without resumed oneshot marker\n"); + fs.writeFileSync(previewPatcher, "EXPECTED_OCCURRENCES = 6\n"); + + const { result } = runLoggedDockerShell(command, tmp); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "Hermes v0.17.0 compatibility workarounds are still installed", + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); function runHermesUvExtrasExpansion() { const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); const extras = dockerfile.match(/^ARG HERMES_UV_EXTRAS="([^"]*)"$/m)?.[1]; From 6a9a95e78749902a4fd693e179f7ebe49e2c5cb5 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 10:23:38 +0800 Subject: [PATCH 32/34] test(hermes): keep guard regression under budget --- test/hermes-doctor-config-hash.test.ts | 45 ++++++++++++++++++++++++++ test/sandbox-provisioning.test.ts | 40 ----------------------- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/test/hermes-doctor-config-hash.test.ts b/test/hermes-doctor-config-hash.test.ts index 94ea6767b80..35e8cf318e1 100644 --- a/test/hermes-doctor-config-hash.test.ts +++ b/test/hermes-doctor-config-hash.test.ts @@ -14,6 +14,51 @@ const HERMES_BUILD_MCP_DIGEST = path.join(ROOT, "agents", "hermes", "build-mcp-d const HERMES_RUNTIME_CONFIG_GUARD = path.join(ROOT, "agents", "hermes", "runtime-config-guard.py"); describe("Hermes doctor and config hash boundary", () => { + it("detects a remaining session preview patcher during Hermes upgrades (#5254)", () => { + const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-preview-guard-")); + const hermesBin = path.join(tmp, "usr", "local", "bin", "hermes"); + const wrapper = path.join(tmp, "usr", "local", "lib", "nemoclaw", "hermes-wrapper.py"); + const previewPatcher = path.join( + tmp, + "usr", + "local", + "lib", + "nemoclaw", + "patch-hermes-session-list-preview.py", + ); + const command = dockerRunCommandBetween( + dockerfile, + 'RUN hermes_version_output="$(/usr/local/bin/hermes --version)"', + "# This runs before `/usr/local/bin/hermes`", + ) + .replaceAll("/usr/local/bin/hermes", hermesBin) + .replaceAll("/usr/local/lib/nemoclaw/hermes-wrapper.py", wrapper) + .replaceAll("/usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py", previewPatcher); + try { + fs.mkdirSync(path.dirname(hermesBin), { recursive: true }); + fs.mkdirSync(path.dirname(wrapper), { recursive: true }); + fs.writeFileSync(hermesBin, "#!/usr/bin/env bash\nprintf 'hermes v0.18.0\\n'\n", { + mode: 0o755, + }); + fs.writeFileSync(wrapper, "# wrapper fixture without resumed oneshot marker\n"); + fs.writeFileSync(previewPatcher, "EXPECTED_OCCURRENCES = 6\n"); + + const result = spawnSync("bash", ["-c", ["set -euo pipefail", command].join("\n")], { + encoding: "utf-8", + cwd: tmp, + timeout: 5000, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "Hermes v0.17.0 compatibility workarounds are still installed", + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("locks trusted gateway recovery preloads as image-owned read-only files", () => { const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-preload-lock-")); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 3c30bd94d37..d0cd904d957 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -1344,46 +1344,6 @@ describe("Hermes sandbox provisioning", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("hermes manifest version"); }); - it("Hermes upgrade guard detects a remaining session preview patcher (#5254)", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-preview-guard-")); - const hermesBin = path.join(tmp, "usr", "local", "bin", "hermes"); - const wrapper = path.join(tmp, "usr", "local", "lib", "nemoclaw", "hermes-wrapper.py"); - const previewPatcher = path.join( - tmp, - "usr", - "local", - "lib", - "nemoclaw", - "patch-hermes-session-list-preview.py", - ); - const command = dockerRunCommandBetween( - dockerfile, - 'RUN hermes_version_output="$(/usr/local/bin/hermes --version)"', - "# This runs before `/usr/local/bin/hermes`", - ) - .replaceAll("/usr/local/bin/hermes", hermesBin) - .replaceAll("/usr/local/lib/nemoclaw/hermes-wrapper.py", wrapper) - .replaceAll("/usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py", previewPatcher); - try { - fs.mkdirSync(path.dirname(hermesBin), { recursive: true }); - fs.mkdirSync(path.dirname(wrapper), { recursive: true }); - fs.writeFileSync(hermesBin, "#!/usr/bin/env bash\nprintf 'hermes v0.18.0\\n'\n", { - mode: 0o755, - }); - fs.writeFileSync(wrapper, "# wrapper fixture without resumed oneshot marker\n"); - fs.writeFileSync(previewPatcher, "EXPECTED_OCCURRENCES = 6\n"); - - const { result } = runLoggedDockerShell(command, tmp); - - expect(result.status).toBe(1); - expect(result.stderr).toContain( - "Hermes v0.17.0 compatibility workarounds are still installed", - ); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); function runHermesUvExtrasExpansion() { const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); const extras = dockerfile.match(/^ARG HERMES_UV_EXTRAS="([^"]*)"$/m)?.[1]; From 39d38787bb23e7ce18b9971cc412266b72b80d38 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 11:12:18 +0800 Subject: [PATCH 33/34] docs(hermes): document resume parser risk --- agents/hermes/hermes-wrapper.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index 9baee11a78b..a707f5e4035 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -50,6 +50,27 @@ # redacts credential-shaped fields natively or `buildHermesConfig` stops # emitting an inline `api_key` value. # +# Source-of-truth note for the `_translate_resumed_oneshot` parser +# differential risk (NVIDIA/NemoClaw#5254): +# - Invalid state: upstream Hermes currently accepts top-level resumed or +# continued one-shot flags but persists the turn in a new session instead +# of appending to the selected session; the wrapper therefore parses a +# small allowlist of Hermes argv forms so it can route only those affected +# invocations through Hermes' native `chat --query` append path. +# - Risk accepted: upstream Hermes flag parsing may diverge from this +# wrapper's allowlist. The wrapper fails closed to unchanged passthrough on +# ambiguity, so the safe fallback is preserving Hermes' native behavior, +# but that may lose the resume/continue append workaround until the +# allowlist is updated. +# - Mitigations: the Dockerfile performs build-time AST validation of the +# wrapper flag constants, probes the pinned `hermes --help` surfaces, and +# the wrapper suite covers routed forms plus fail-closed cases with 20+ +# unit tests. +# - Tracking: keep monitoring upstream Hermes flag stability while this +# localized compatibility layer exists. +# - Removal condition: delete this translation when Hermes natively appends +# top-level resumed or continued one-shot turns to the selected session. +# # Scope of the masker: structured key-labelled secret fields (api_key, # api_secret, access_token, auth_token, client_secret, secret_key, secret, # token, password, bearer, authorization, credential — including From a071804911cc3d1241fcc728463f368d733c087f Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 7 Jul 2026 11:34:43 +0800 Subject: [PATCH 34/34] test(hermes): verify preview patch source --- agents/hermes/Dockerfile | 3 ++- agents/hermes/patch-session-list-preview.py | 3 ++- test/hermes-gateway-wrapper.test.ts | 23 ++++++++++----------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 1adb50a9f98..89bc3f368cc 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -181,6 +181,7 @@ RUN test -x /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py \ # prove the SessionDB list contract at build time so a Hermes update cannot # silently drift. RUN /usr/bin/python3 -I /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py \ + && grep -q 'ORDER BY m.timestamp DESC, m.id DESC LIMIT 1' /opt/hermes/hermes_state.py \ && HERMES_HOME="$(mktemp -d)" /opt/hermes/.venv/bin/python - <<'PY' from hermes_state import SessionDB @@ -202,7 +203,7 @@ PY # chain tampering of the build context (an attacker rewriting the file has to # also rewrite the Dockerfile-committed hash, which reviewers gate). Regenerate # with `sha256sum agents/hermes/{hermes-wrapper.py,validate-env-secret-boundary.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=100a1bcafeabf815d07b1d5dd707246a402111515936763af9d365b253d1a88f +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=34ef50ea993c776f28312bcf659e908eeae3c07e4094a49e513cb320eee6538f ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=970d7ff03bc409ff1d5ca46bfdbd2a42ac28a32a810ccc147a508301bff38496 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ diff --git a/agents/hermes/patch-session-list-preview.py b/agents/hermes/patch-session-list-preview.py index f4f089a3a3f..6203513911a 100755 --- a/agents/hermes/patch-session-list-preview.py +++ b/agents/hermes/patch-session-list-preview.py @@ -14,7 +14,8 @@ published Hermes runtime; the source fix belongs upstream in Hermes, not in NemoClaw's TypeScript or wrapper code. - Regression test: this script's exact occurrence count fails closed when the - pinned source shape drifts, and the Dockerfile smoke test creates a + pinned source shape drifts, the Dockerfile greps for the patched query + pattern after patching, and the Dockerfile smoke test creates a `SessionDB`, appends first/latest user turns, and asserts the list preview returns `NEMOCLAW_PREVIEW_LATEST`. - Removal condition: delete this patch when the pinned Hermes runtime diff --git a/test/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index 10d539d2e88..174a53d828c 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -218,15 +218,14 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { ]); }); - it("routes continued one-shot invocations through chat query while preserving model/tool flags (#5254)", () => { + it("routes continued one-shot invocations through chat query while preserving provider/skill flags (#5254)", () => { const run = runWrapper( [ "-c", "daily check", "--oneshot=Summarize the latest turn", - "--model", - "anthropic/claude-sonnet-4", - "--toolsets=memory,session_search", + "--provider=custom", + "--skills=memory,session_search", "--ignore-rules", ], {}, @@ -240,9 +239,9 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { "--quiet", "--continue", "daily check", - "--model", - "anthropic/claude-sonnet-4", - "--toolsets", + "--provider", + "custom", + "--skills", "memory,session_search", "--ignore-rules", ]); @@ -318,12 +317,12 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { }); it("routes equals-style resumed one-shot invocations through chat query (#5254)", () => { - const run = runWrapper(["--resume=20260612_050401_aa9d27", "--oneshot=Repeat it"], {}); + const run = runWrapper(["--resume=20260612_050401_aa9d27", "--oneshot=Repeat a=b"], {}); expect(run.status).toBe(0); expect(run.stderr).toBe(""); expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe("chat --query Repeat it --quiet --resume 20260612_050401_aa9d27"); + expect(run.realArgs).toBe("chat --query Repeat a=b --quiet --resume 20260612_050401_aa9d27"); }); it("passes positional subcommands through instead of translating nested one-shot flags (#5254)", () => { @@ -356,10 +355,10 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(run.realArgs).toBe("--resume 20260612_050401_aa9d27 -- -z Repeat it"); }); - it("passes multiple resume selectors through instead of translating ambiguous targets (#5254)", () => { + it("passes mixed resume selectors through instead of translating ambiguous targets (#5254)", () => { const run = runWrapper( [ - "--resume", + "--continue", "20260612_050401_aa9d27", "--resume", "20260612_050446_924bd8", @@ -373,7 +372,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(run.stderr).toBe(""); expect(run.realInvoked).toBe(true); expect(run.realArgs).toBe( - "--resume 20260612_050401_aa9d27 --resume 20260612_050446_924bd8 -z Repeat it", + "--continue 20260612_050401_aa9d27 --resume 20260612_050446_924bd8 -z Repeat it", ); });