Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
ea21a24
fix(hermes): append resumed one-shot turns
chengjiew Jul 6, 2026
28a9a4d
fix(hermes): harden resumed oneshot rewrite
chengjiew Jul 6, 2026
a191f8e
test(sandbox): accept rebuild notice in recovery tests
chengjiew Jul 6, 2026
1b98ed3
Merge remote-tracking branch 'origin/main' into fix/5254_hermes_resum…
chengjiew Jul 6, 2026
beb88ff
fix(hermes): reject empty continue selectors
chengjiew Jul 6, 2026
32dc421
fix(hermes): fail closed on malformed resume selectors
chengjiew Jul 6, 2026
d088c3d
fix(hermes): require runtime resume persistence evidence
chengjiew Jul 6, 2026
3dde0cd
test(hermes): move resume persistence proof to live e2e
chengjiew Jul 6, 2026
9ca86b6
fix(hermes): harden wrapper drift check
chengjiew Jul 6, 2026
dac1634
test(hermes): export resumed session to file
chengjiew Jul 6, 2026
d2e6cd4
test(hermes): keep e2e file within budget
chengjiew Jul 6, 2026
9203f73
test(hermes): add resumed session contract coverage
chengjiew Jul 6, 2026
74444f5
test(hermes): cover plain block scalar secret keys
chengjiew Jul 6, 2026
db9ba8b
test(hermes): assert resumed session list preview
chengjiew Jul 6, 2026
d876892
Merge remote-tracking branch 'origin/main' into fix/5254_hermes_resum…
chengjiew Jul 6, 2026
a4c8cbb
fix(hermes): harden wrapper failure diagnostics
chengjiew Jul 6, 2026
43aa351
test(hermes): tolerate truncated session previews
chengjiew Jul 6, 2026
3f11c80
test(hermes): rely on export for full session turn markers
chengjiew Jul 6, 2026
87aeba4
test(hermes): extend live one-shot timeout
chengjiew Jul 6, 2026
58045f4
test(hermes): assert resumed session list freshness
chengjiew Jul 6, 2026
c9ed6e9
fix(hermes): refresh session list preview
chengjiew Jul 6, 2026
d8f8db0
chore(hermes): mark preview patch executable
chengjiew Jul 6, 2026
5057d03
test(hermes): update Dockerfile replay fixtures
chengjiew Jul 6, 2026
9e5a002
test(hermes): keep replay fixtures within budget
chengjiew Jul 6, 2026
b8d6738
fix(hermes): harden resume workaround follow-up
chengjiew Jul 6, 2026
1f9788f
test(hermes): assert resumed session activity refresh
chengjiew Jul 6, 2026
5d603a8
test(hermes): keep session metadata probe argv safe
chengjiew Jul 6, 2026
72b70ac
test(hermes): anchor workaround guard to installed version
chengjiew Jul 7, 2026
5c57a3b
chore(hermes): satisfy workaround guard static checks
chengjiew Jul 7, 2026
402ad59
test(hermes): reject stale final version guards
chengjiew Jul 7, 2026
da286e4
fix(hermes): reject empty continue selectors
chengjiew Jul 7, 2026
bc519a3
fix(hermes): update wrapper hash
chengjiew Jul 7, 2026
e82ffbf
fix(hermes): detect preview patcher guard
chengjiew Jul 7, 2026
6a9a95e
test(hermes): keep guard regression under budget
chengjiew Jul 7, 2026
39d3878
docs(hermes): document resume parser risk
chengjiew Jul 7, 2026
a071804
test(hermes): verify preview patch source
chengjiew Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 164 additions & 3 deletions agents/hermes/hermes-wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -216,6 +215,165 @@ 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",
}
# 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",
"--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.

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):
arg = argv[i]

if arg == "--":
return None

split = _split_flag_value(arg)
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 not value:
return None
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 not value:
return None
if saw_resume or saw_continue:
return None
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) or argv[i + 1].startswith("-"):
return None
if saw_oneshot:
return None
saw_oneshot = True
oneshot_prompt = argv[i + 1]
i += 2
continue

if arg in _VALUE_FLAGS:
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
saw_resume = True
resume_args.extend([canonical, value])
else:
passthrough.extend([canonical, value])
i += 2
continue

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")
resume_args.append(argv[i + 1])
i += 2
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()
Expand All @@ -225,6 +383,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

Expand Down
17 changes: 5 additions & 12 deletions src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,17 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

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 restoreSandboxEnv = snapshotEnv(["NEMOCLAW_SANDBOX_NAME"]);

describe("prepared rebuild recovery", () => {
beforeEach(() => {
delete process.env.NEMOCLAW_SANDBOX_NAME;
});

afterEach(() => {
vi.restoreAllMocks();
restoreSandboxEnv();
});
beforeEach(resetRebuildFlowTestEnvironment);
afterEach(restoreRebuildFlowTestEnvironment);

it("restores the validated pre-upgrade manifest without taking a second backup (#6114)", async () => {
const harness = createRebuildFlowHarness({
Expand Down
Loading