diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index bbe4b726792..89bc3f368cc 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,27 @@ 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 \ + && 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 + +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 @@ -181,7 +203,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=34ef50ea993c776f28312bcf659e908eeae3c07e4094a49e513cb320eee6538f ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=970d7ff03bc409ff1d5ca46bfdbd2a42ac28a32a810ccc147a508301bff38496 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ @@ -204,6 +226,22 @@ 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 \ + 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 '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 +# 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))' +# hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_WRAPPER_SHA256" /usr/local/lib/nemoclaw/hermes-wrapper.py \ | sha256sum -c - \ diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index a33fb4b043d..a707f5e4035 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -50,14 +50,36 @@ # 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 # 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`) @@ -68,13 +90,13 @@ # 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 import sys +import tempfile _INSTALLED_REAL = "/usr/local/bin/hermes.real" _INSTALLED_GUARD = "/usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py" @@ -121,6 +143,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: @@ -154,42 +189,68 @@ 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, + ) + 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: + masker_stderr.stdin.close() + proc.wait() + masker_stdout.wait() + masker_stderr.wait() + 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, @@ -216,6 +277,169 @@ 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. 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 + 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] = [] + 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 + value = argv[i + 1] + if not value: + return None + saw_continue = True + resume_args.append("--continue") + resume_args.append(value) + 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"] + 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,8 +449,20 @@ def main(argv: list[str]) -> int: rc = _run_gateway_guard(guard_path) if rc != 0: return rc - os.execv(real_hermes, [real_hermes, *argv]) - return 1 + translated = _translate_resumed_oneshot(argv) + if translated is not None: + 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/agents/hermes/patch-session-list-preview.py b/agents/hermes/patch-session-list-preview.py new file mode 100755 index 00000000000..6203513911a --- /dev/null +++ b/agents/hermes/patch-session-list-preview.py @@ -0,0 +1,64 @@ +#!/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. + +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, 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 + natively uses the latest user turn for `sessions list` previews. +""" + +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()) 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/e2e/fixtures/hermes-session.ts b/test/e2e/fixtures/hermes-session.ts new file mode 100644 index 00000000000..39716524850 --- /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; 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, + 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 0463235494b..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, @@ -23,12 +24,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); @@ -183,6 +178,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 +454,89 @@ test.skipIf(!shouldRunLiveE2E())( expect(configProbe.exitCode, resultText(configProbe)).toBe(0); expect(configProbe.stdout).toContain("OK"); + const runHermesCli = async (args: string[], artifactName: string, timeoutMs = 6 * 60_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 listHermesSessionsText = (artifactName: string) => + 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); + 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); + expect( + await sessionLastActive(expectedSessionId, `${afterArtifact}-metadata`), + ).toBeGreaterThan(beforeActivity); + }; + + 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 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`; + await exportHermesSession( + sandbox, + SANDBOX_NAME, + seedSessionId, + exportPath, + [seedPrompt, resumePrompt, continuePrompt], + { + artifactName: "phase-4-issue-5254-export-session", + env: commandEnv(), + redactionValues, + timeoutMs: 60_000, + }, + ); + if (hermesDashboardE2eEnabled()) { const entry = registryEntry(SANDBOX_NAME); expect(entry, `registry missing ${SANDBOX_NAME}`).toBeTruthy(); diff --git a/test/hermes-doctor-config-hash.test.ts b/test/hermes-doctor-config-hash.test.ts index b244bf624e7..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-")); @@ -42,6 +87,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/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index 4154a45e996..174a53d828c 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -47,20 +47,11 @@ type WrapperRun = { stderr: string; realInvoked: boolean; realArgs: string; + 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, @@ -68,6 +59,7 @@ function runWrapper( shadowPython?: boolean; shadowHelpers?: Record; stub?: StubBehaviour; + stubMode?: number; validatorScript?: string; } = {}, ): WrapperRun { @@ -75,11 +67,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, }); @@ -91,7 +79,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__` @@ -99,12 +87,9 @@ 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 }); - // 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" } : {}), @@ -126,12 +111,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 }); @@ -214,6 +201,272 @@ 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.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 provider/skill flags (#5254)", () => { + const run = runWrapper( + [ + "-c", + "daily check", + "--oneshot=Summarize the latest turn", + "--provider=custom", + "--skills=memory,session_search", + "--ignore-rules", + ], + {}, + ); + + expect(run.status).toBe(0); + expect(run.realArgv).toEqual([ + "chat", + "--query", + "Summarize the latest turn", + "--quiet", + "--continue", + "daily check", + "--provider", + "custom", + "--skills", + "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.realArgv).toEqual([ + "chat", + "--query", + "Repeat it", + "--quiet", + "--resume", + "20260612_050401_aa9d27", + "--yolo", + "--accept-hooks", + ]); + }); + + 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")); + 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", + ]); + 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 }); + } + }); + + 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("routes equals-style resumed one-shot invocations through chat query (#5254)", () => { + 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 a=b --quiet --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 mixed resume selectors through instead of translating ambiguous targets (#5254)", () => { + const run = runWrapper( + [ + "--continue", + "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( + "--continue 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 --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 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"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + 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("passes --version through (build assertion path) without invoking the guard", () => { const run = runWrapper(["--version"], { SLACK_BOT_TOKEN: "xoxb-real-1234567890" }); @@ -398,6 +651,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 } }); @@ -561,24 +822,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 = [ + "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("fails closed when the config masker succeeds with oversized stderr", () => { + const validatorScript = [ + "#!/usr/bin/env python3", + "import sys", + "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 stderr exceeded"); + 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 { diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index faebe8884cd..d0cd904d957 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, @@ -1231,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 }); 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) diff --git a/test/update-hermes-agent-script.test.ts b/test/update-hermes-agent-script.test.ts index d173dee170d..6d91dea7e1e 100644 --- a/test/update-hermes-agent-script.test.ts +++ b/test/update-hermes-agent-script.test.ts @@ -309,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 }); + } + }); });