From d69777499bd6e3359dcacae4e3d3e7c66da0988c Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 15:06:14 -0400 Subject: [PATCH 01/24] refactor(hermes): add versioned CLI adapter Signed-off-by: Julie Yaunches --- .../SKILL.md | 4 +- .../references/hermes-contract-map.md | 34 +- agents/hermes/Dockerfile | 48 +- agents/hermes/hermes-cli-adapter-v1.json | 144 +++ agents/hermes/hermes-wrapper.py | 817 +++++++----------- agents/hermes/image-build-probes.py | 60 -- agents/hermes/validate-cli-adapter.py | 173 ++++ test/helpers/hermes-wrapper-harness.ts | 15 + test/hermes-dependency-review.test.ts | 12 +- test/hermes-doctor-config-hash.test.ts | 2 +- test/hermes-final-image-layout.test.ts | 23 +- test/hermes-image-build-probes.test.ts | 1 - test/hermes-wrapper-oneshot-routing.test.ts | 4 +- test/hermes-wrapper-provider-merge.test.ts | 113 ++- 14 files changed, 814 insertions(+), 636 deletions(-) create mode 100644 agents/hermes/hermes-cli-adapter-v1.json create mode 100755 agents/hermes/validate-cli-adapter.py diff --git a/.agents/skills/nemoclaw-contributor-update-hermes/SKILL.md b/.agents/skills/nemoclaw-contributor-update-hermes/SKILL.md index bf6f4d6af16..e85d814b7db 100644 --- a/.agents/skills/nemoclaw-contributor-update-hermes/SKILL.md +++ b/.agents/skills/nemoclaw-contributor-update-hermes/SKILL.md @@ -105,7 +105,7 @@ Map each material change through these NemoClaw surfaces: - generated config schema, defaults, migrations, approval behavior, and config-less named-profile fallbacks; - isolated-home config mirrors, parse failures, stale policy, and secret-safe error reporting; -- wrapper flags, subcommands, argument translation, and help probes; +- CLI adapter options, managed argument translation, and public help probes; - session preview, Langfuse, and managed light-skin workarounds; - durable SQLite ledgers, state directories, backups, rebuilds, and rollback; - Python extras, the complete `uv.lock` closure, npm bridge packages, licenses, notices, advisories, and native builds; @@ -165,7 +165,7 @@ Do not use a local moving tag as PR or release evidence. Run concern-specific unit and integration tests. Build the final Hermes image against the locally built base. -Require the Dockerfile source-shape guards, wrapper help probes, patch smoke tests, generated-config checks, dependency audit, and installed-version checks to pass. +Require the Dockerfile source-shape guards, CLI adapter validation, public help probes, patch smoke tests, generated-config checks, dependency audit, and installed-version checks to pass. Use BuildKit for the final image build. `agents/hermes/Dockerfile` invokes the checked-in `image-build-probes.py` runner for source and diff --git a/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md b/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md index ee858f3d6fc..6fff123ed52 100644 --- a/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md +++ b/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md @@ -75,8 +75,8 @@ Audit each workaround against target source and its removal condition: | Contract | NemoClaw surface | |---|---| -| Resumed one-shot session append | `agents/hermes/hermes-wrapper.py` and `test/hermes-wrapper-oneshot-routing.test.ts`. | -| Provider plus model proxy routing | `agents/hermes/hermes-wrapper.py` and `test/hermes-wrapper-provider-merge.test.ts`. | +| Resumed one-shot session append | `agents/hermes/hermes-cli-adapter-v1.json`, `agents/hermes/hermes-wrapper.py`, and `test/hermes-wrapper-oneshot-routing.test.ts`. | +| Provider plus model proxy routing | `agents/hermes/hermes-cli-adapter-v1.json`, `agents/hermes/hermes-wrapper.py`, and `test/hermes-wrapper-provider-merge.test.ts`. | | Latest session-list preview | `agents/hermes/patch-session-list-preview.py` and the Dockerfile smoke test. | | Config-less profile policy defaults | `agents/hermes/patch-profile-policy-defaults.py`, `test/hermes-profile-policy-defaults.test.ts`, and the final-image named-profile probe. | | Writable managed gateway runtime metadata | `agents/hermes/patch-gateway-runtime-metadata.py`, `test/hermes-gateway-runtime-metadata-patch.test.ts`, and the final-image source-shape, integrity, and path probes. Preserve Hermes' process-scoped home selector while relocating central default-gateway PID, lock, and status helpers. Search the full pinned tree for explicit metadata paths before claiming broader support; patch and runtime-test each supported direct consumer or document inherited `--replace`, marker, profile/multiplexer, service/boot, and packaging residuals. | @@ -85,19 +85,23 @@ Audit each workaround against target source and its removal condition: | Managed light-terminal skin | `src/lib/domain/sandbox/connect-env.ts` and `test/hermes-light-skin-boundary.test.ts`. | | Config output masking and gateway secret boundary | `agents/hermes/hermes-wrapper.py`, validator scripts, and live secret-boundary tests. | -Compare top-level and `chat` help in target source. -Update wrapper value flags, boolean flags, subcommands, scan boundaries, tests, and the wrapper SHA-256 together. -Do not infer arity from help text alone: inspect the target parser and any argv preprocessing or -coalescing that runs before it. -Hermes 0.19 defines `-c/--continue` with an optional value, where the bare flag means the most -recent session, and coalesces unquoted multi-word names after all four continue/resume spellings. -The coalescer's boundary set can differ from the full command inventory, so bind each consumer to -the correct target-source set rather than deriving both from help. -Have the final image AST-compare the wrapper boundary constant with the pinned upstream -`_coalesce_session_name_args` local subcommand set; public help cannot prove this private parser -contract. -Test bare, quoted, and unquoted forms plus global profile selectors anywhere wrapper ordering or -one-shot routing is involved. +Review the top-level and `chat` parser metadata in the target source. +Update `hermes-cli-adapter-v1.json` only for a managed translation form. +Do not add an upstream subcommand to the adapter. +`validate-cli-adapter.py` compares the contract with Hermes' machine-readable parser metadata. +The top-level and `chat` help probes are runtime evidence and are not the compatibility authority. + +Hermes 0.19 defines `-c/--continue` with an optional session value. +The bare flag selects the most recent session. +The adapter owns the resumed one-shot forms that require translation, including unquoted multi-word +session names before the one-shot option. +Test bare, quoted, and unquoted forms plus global profile selectors. +Provider and model composition accepts a session name as one argument. +The adapter rejects an unquoted multi-word session plus provider and model flags before Hermes +runs because a later positional can be an upstream command. Quote the session name to make it one +argument. +Test that a new unrelated command passes through without an adapter change. +The wrapper must verify the upstream CLI version before it invokes a translated command. The final Dockerfile intentionally rejects a new semver while version-bound workarounds remain unreviewed. Retarget a patch comment only after confirming that its exact upstream source shape remains applicable. diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 04802acdf35..99017f9f6fb 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -67,6 +67,8 @@ COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/ FROM scratch AS hermes-wrapper-payload COPY agents/hermes/hermes-wrapper.py /usr/local/lib/nemoclaw/hermes-wrapper.py +COPY agents/hermes/validate-cli-adapter.py /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py +COPY agents/hermes/hermes-cli-adapter-v1.json /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json FROM scratch AS hermes-scan-payload @@ -194,7 +196,7 @@ RUN find /opt/nemoclaw-hermes-config -type d -exec chmod 755 {} + \ /scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-tar.mts \ && chmod -R a+rX /src/lib/messaging -ARG NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256=b21db6d098920a6bb410cccca4778e2817a57b66307212ef3422e6cafdfd67b4 +ARG NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256=ab372bf8d92e8084a9f698b8130fb400ed2799cfe779319d6a342518b60d9b53 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256" /opt/nemoclaw-hermes-config/image-build-probes.py \ @@ -377,13 +379,15 @@ RUN node --experimental-strip-types \ /opt/nemoclaw-hermes-config/image-build-probes.py langfuse-credentials # Cryptographic integrity gate for the security-critical Python entrypoints: -# the wrapper and validator that enforce the runtime env secret boundary, plus -# the descriptor-safe Tirith marker finalizer. Any content change MUST be +# the wrapper, its CLI adapter, the runtime env validator, and the descriptor- +# safe Tirith marker finalizer. Any content change MUST be # accompanied by an updated hash below; otherwise the build fails. This blocks # silent supply-chain tampering of the build context (an attacker rewriting a # 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,finalize-tirith-marker.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=cd851746da14162ac4701d56c274dac20024ea6a11f6ffcf2ce7fb89dff388a0 +# Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,hermes-cli-adapter-v1.json,validate-cli-adapter.py,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=b0f63272954e2bfb1383032aa86c7e8fed46b07707bd2cbd36d5edccf230a534 +ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=b78434e833d3b01933398a043241c4790baed468d88d62461050d00bf922864f +ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=cb7eab8953ba0fb5063cce6fbd8b6809604d70e6f86a0f7aad09afceb63aad1e ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 # hadolint ignore=DL4006 @@ -420,30 +424,30 @@ RUN hermes_version_output="$(/usr/local/bin/hermes --version)" \ exit 1; \ fi \ && if [ "$hermes_semver" != "0.19.0" ] \ - && { grep -q '_translate_resumed_oneshot' /usr/local/lib/nemoclaw/hermes-wrapper.py \ + && { grep -q '"resumed_oneshot"' /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json \ || grep -q 'EXPECTED_OCCURRENCES' /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py; }; then \ echo "ERROR: installed Hermes ${hermes_semver} but Hermes v0.19.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 -# probes check the pinned Hermes binary, not the wrapper installed below. -# Hermes consumes -p/--profile before argparse renders help, so validate those -# hidden global options behaviorally and parse every visible option token. -RUN /usr/bin/python3 -I -c 'import ast, pathlib, re, subprocess, sys; source = pathlib.Path("/usr/local/lib/nemoclaw/hermes-wrapper.py").read_text(); tree = ast.parse(source); expected_constants = {"_VALUE_FLAGS", "_TOP_LEVEL_VALUE_FLAGS", "_BOOLEAN_FLAGS", "_HERMES_SUBCOMMANDS", "_PROVIDER_MODEL_COMMAND_SCAN_REQUIRED_VALUE_FLAGS", "_PROVIDER_MODEL_COMMAND_SCAN_SESSION_FLAGS"}; 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 expected_constants}; missing_constants = sorted(expected_constants - set(constants)); missing_constants and sys.exit("ERROR: Hermes wrapper CLI constants not found in AST: " + ", ".join(missing_constants)); empty_constants = sorted(name for name in expected_constants if not constants[name]); empty_constants and sys.exit("ERROR: Hermes wrapper CLI constants are empty: " + ", ".join(empty_constants)); session_flags = set(constants["_PROVIDER_MODEL_COMMAND_SCAN_SESSION_FLAGS"]); required_value_flags = set(constants["_PROVIDER_MODEL_COMMAND_SCAN_REQUIRED_VALUE_FLAGS"]); profile_flags = {"-p", "--profile"}; profile_flags <= required_value_flags or sys.exit("ERROR: Hermes wrapper scanner must retain both hidden profile flags"); derived_required_value_flags = (set(constants["_VALUE_FLAGS"]) - session_flags) | set(constants["_TOP_LEVEL_VALUE_FLAGS"]) | {"-z", "--oneshot"} | profile_flags; required_value_flags != derived_required_value_flags and sys.exit("ERROR: Hermes wrapper required-value scanner flags drifted from forwarding/global flags: missing=" + ",".join(sorted(derived_required_value_flags - required_value_flags)) + " stale=" + ",".join(sorted(required_value_flags - derived_required_value_flags))); option_pattern = r"(?&2; exit 1; } + || { echo "ERROR: Hermes CLI adapter integrity mismatch" >&2; exit 1; } RUN mv /usr/local/bin/hermes /usr/local/bin/hermes.real \ && install -m 0755 /usr/local/lib/nemoclaw/hermes-wrapper.py /usr/local/bin/hermes \ + && chmod 755 /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py \ + && chmod 444 /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json \ && chmod 755 /usr/local/bin/hermes.real \ && /usr/local/bin/hermes --version @@ -863,7 +867,11 @@ COPY --from=hermes-scan-payload / / # under umask 002, and COPY carries that source mode into the image. Normalize # before the check_metadata gate below asserts root:root 755. A RUN chmod is # builder-independent; COPY --chmod requires BuildKit. -RUN chmod 755 /usr/local/lib/nemoclaw/hermes-wrapper.py /scripts/checks/node-tar-image-scan.mts +RUN chmod 755 \ + /usr/local/lib/nemoclaw/hermes-wrapper.py \ + /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py \ + /scripts/checks/node-tar-image-scan.mts \ + && chmod 444 /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json RUN check_metadata() { \ path="$1"; \ @@ -893,6 +901,8 @@ RUN check_metadata() { \ && check_metadata /usr/local/bin/nemoclaw-gateway-control 'root:root 700' \ && check_metadata /usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root 444' \ && check_metadata /usr/local/lib/nemoclaw/hermes-wrapper.py 'root:root 755' \ + && check_metadata /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py 'root:root 755' \ + && check_metadata /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json 'root:root 444' \ && check_metadata /scripts/checks/node-tar-image-scan.mts 'root:root 755' \ && install -d -m 0755 /usr/local/share/nemoclaw \ && node --experimental-strip-types /scripts/checks/node-tar-image-scan.mts \ diff --git a/agents/hermes/hermes-cli-adapter-v1.json b/agents/hermes/hermes-cli-adapter-v1.json new file mode 100644 index 00000000000..d6776a0fd52 --- /dev/null +++ b/agents/hermes/hermes-cli-adapter-v1.json @@ -0,0 +1,144 @@ +{ + "adapter_version": 1, + "upstream_cli_version": "0.19.0", + "managed_commands": ["chat"], + "options": [ + { + "id": "profile", + "names": ["-p", "--profile"], + "canonical": "--profile", + "arity": "required", + "surfaces": ["preparse"] + }, + { + "id": "oneshot", + "names": ["-z", "--oneshot"], + "canonical": "--oneshot", + "arity": "required", + "surfaces": ["top"] + }, + { + "id": "usage_file", + "names": ["--usage-file"], + "canonical": "--usage-file", + "arity": "required", + "surfaces": ["top"] + }, + { + "id": "model", + "names": ["-m", "--model"], + "canonical": "--model", + "arity": "required", + "surfaces": ["top", "chat"] + }, + { + "id": "provider", + "names": ["--provider"], + "canonical": "--provider", + "arity": "required", + "surfaces": ["top", "chat"] + }, + { + "id": "toolsets", + "names": ["-t", "--toolsets"], + "canonical": "--toolsets", + "arity": "required", + "surfaces": ["top", "chat"] + }, + { + "id": "skills", + "names": ["-s", "--skills"], + "canonical": "--skills", + "arity": "required", + "repeatable": true, + "surfaces": ["top", "chat"] + }, + { + "id": "resume", + "names": ["-r", "--resume"], + "canonical": "--resume", + "arity": "session", + "surfaces": ["top", "chat"] + }, + { + "id": "continue", + "names": ["-c", "--continue"], + "canonical": "--continue", + "arity": "optional_session", + "surfaces": ["top", "chat"] + }, + { + "id": "worktree", + "names": ["-w", "--worktree"], + "arity": "boolean", + "surfaces": ["top", "chat"] + }, + { + "id": "accept_hooks", + "names": ["--accept-hooks"], + "arity": "boolean", + "surfaces": ["top", "chat"] + }, + { + "id": "yolo", + "names": ["--yolo"], + "arity": "boolean", + "surfaces": ["top", "chat"] + }, + { + "id": "pass_session_id", + "names": ["--pass-session-id"], + "arity": "boolean", + "surfaces": ["top", "chat"] + }, + { + "id": "ignore_user_config", + "names": ["--ignore-user-config"], + "arity": "boolean", + "surfaces": ["top", "chat"] + }, + { + "id": "ignore_rules", + "names": ["--ignore-rules"], + "arity": "boolean", + "surfaces": ["top", "chat"] + }, + { + "id": "no_restore_cwd", + "names": ["--no-restore-cwd"], + "arity": "boolean", + "surfaces": ["top", "chat"] + }, + { + "id": "safe_mode", + "names": ["--safe-mode"], + "arity": "boolean", + "surfaces": ["top", "chat"] + } + ], + "translations": { + "resumed_oneshot": { + "issue": 5254, + "forms": [ + "hermes [(-p|--profile) PROFILE] ((-r|--resume) SESSION|(-c|--continue) [SESSION]) (-z|--oneshot) PROMPT [CHAT_OPTION ...]", + "hermes [--profile=PROFILE] (--resume=SESSION|--continue[=SESSION]) --oneshot=PROMPT [CHAT_OPTION ...]" + ], + "reason": "Hermes 0.19 stores a top-level resumed or continued one-shot turn in a new session instead of the selected or most recent session.", + "removal_condition": "Hermes appends top-level resumed and continued one-shot turns to the selected or most recent session." + }, + "provider_model": { + "issue": 7361, + "forms": [ + "PROVIDER_OPTION := --provider PROVIDER | --provider=PROVIDER", + "MODEL_OPTION := -m MODEL | --model MODEL | --model=MODEL", + "hermes [TOP_LEVEL_OPTION ...] PROVIDER_OPTION [TOP_LEVEL_OPTION ...] MODEL_OPTION [TOP_LEVEL_OPTION ...]", + "hermes [TOP_LEVEL_OPTION ...] MODEL_OPTION [TOP_LEVEL_OPTION ...] PROVIDER_OPTION [TOP_LEVEL_OPTION ...]", + "hermes [TOP_LEVEL_OPTION ...] chat [CHAT_OPTION ...] PROVIDER_OPTION [CHAT_OPTION ...] MODEL_OPTION [CHAT_OPTION ...]", + "hermes [TOP_LEVEL_OPTION ...] chat [CHAT_OPTION ...] MODEL_OPTION [CHAT_OPTION ...] PROVIDER_OPTION [CHAT_OPTION ...]" + ], + "ambiguity_rule": "A top-level session name must be one argv value. The adapter rejects separate provider and model flags after an unquoted multi-word session name because a later positional can be an upstream command.", + "reason": "Hermes 0.19 does not resolve the OpenShell credential placeholder when provider and model are separate.", + "removal_condition": "Hermes resolves OpenShell credential placeholders for separate provider and model flags." + } + } +} diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index bb844b7bd52..4b8d3e350bf 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -50,26 +50,11 @@ # 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. +# `hermes-cli-adapter-v1.json` owns the exact translated command forms, their +# upstream version, rationale, and removal conditions. The image build validates +# that contract against Hermes' machine-readable top-level and chat parser +# metadata. The wrapper parses a managed invocation once from that contract and +# passes all unrelated commands to Hermes without a subcommand inventory. # # Scope of the masker: structured key-labelled secret fields (api_key, # api_secret, access_token, auth_token, client_secret, secret_key, secret, @@ -93,18 +78,22 @@ # Only a small set of top-level commands are intercepted; all other hermes # subcommands (dashboard, --version, ...) pass straight through unchanged. +import json import os +import re 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" +_INSTALLED_CLI_ADAPTER = "/usr/local/share/nemoclaw/hermes-cli-adapter-v1.json" # The Dockerfile installs the validator under the hermes-prefixed name even # though the repository source stays at `validate-env-secret-boundary.py`. # Mirror the same dev-fallback `start.sh` uses so an ad-hoc bash invocation # over a checkout still finds the guard. _GUARD_DEV_FILENAME = "validate-env-secret-boundary.py" +_CLI_ADAPTER_DEV_FILENAME = "hermes-cli-adapter-v1.json" # Trusted absolute paths for the python3 interpreter, ordered most-preferred # first. The resolver returns the first executable match (first-wins); the # same priority is mirrored by `agents/hermes/start.sh:resolve_trusted_python3` @@ -135,6 +124,12 @@ def _resolve_guard() -> str: return os.path.join(_self_dir(), _GUARD_DEV_FILENAME) +def _resolve_cli_adapter() -> str: + if os.path.isfile(_INSTALLED_CLI_ADAPTER): + return _INSTALLED_CLI_ADAPTER + return os.path.join(_self_dir(), _CLI_ADAPTER_DEV_FILENAME) + + def _resolve_trusted_python3() -> str | None: for candidate in _TRUSTED_PYTHON3: if os.access(candidate, os.X_OK): @@ -277,536 +272,323 @@ 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", -} -# Hermes 0.19 consumes this global option only on its native one-shot path; -# `chat --query` accepts the global syntax but does not write the requested -# report. Recognize it while composing a resumed one-shot command so the -# wrapper can refuse that semantically incompatible combination instead of -# forwarding it to `chat` and silently losing the report. -_TOP_LEVEL_VALUE_FLAGS = ("--usage-file",) -# Keep this allowlist aligned with the top-level flags accepted by the pinned -# Hermes Agent CLI in agents/hermes/Dockerfile.base (HERMES_VERSION=v2026.7.20, -# HERMES_SEMVER=0.19.0) and agents/hermes/manifest.yaml (expected_version -# "0.19.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", - "--no-restore-cwd", - "--safe-mode", -} -_PROVIDER_MODEL_COMMAND_SCAN_SESSION_FLAGS = ("-c", "--continue", "-r", "--resume") -_PROVIDER_MODEL_COMMAND_SCAN_REQUIRED_VALUE_FLAGS = ( - "-m", - "--model", - "--provider", - "-t", - "--toolsets", - "-s", - "--skills", - "-z", - "--oneshot", - "-p", - "--profile", - "--usage-file", +_SUPPORTED_CLI_ADAPTER_VERSION = 1 +_CLI_VERSION_PROBE_ENV = "NEMOCLAW_HERMES_ADAPTER_VERSION_PROBE" +_CLI_VERSION_PATTERN = re.compile( + r"^(?:Hermes Agent )?v?([0-9]+[.][0-9]+[.][0-9]+)(?:\b|$)", + re.MULTILINE, ) -# Full top-level command inventory used only to decide whether provider/model -# flags belong to Hermes chat or to another command. -_HERMES_SUBCOMMANDS = ( - "acp", - "auth", - "backup", - "bundles", - "chat", - "checkpoints", - "claw", - "completion", - "computer-use", - "config", - "console", - "cron", - "curator", - "dashboard", - "debug", - "desktop", - "doctor", - "dump", - "fallback", - "gateway", - "gui", - "hooks", - "import", - "insights", - "journey", - "kanban", - "learning", - "login", - "logout", - "logs", - "lsp", - "mcp", - "memory", - "memory-graph", - "migrate", - "moa", - "model", - "pairing", - "pets", - "plugins", - "portal", - "postinstall", - "profile", - "project", - "prompt-size", - "proxy", - "secrets", - "security", - "send", - "serve", - "sessions", - "setup", - "skills", - "slack", - "status", - "tools", - "uninstall", - "update", - "version", - "webhook", - "whatsapp", - "whatsapp-cloud", -) - -# Mirror the pinned Hermes v0.19 `_coalesce_session_name_args` set exactly. -# It intentionally differs from full help (for example, `console` is not a -# boundary and can remain part of an unquoted session name). -_HERMES_SESSION_NAME_BOUNDARIES = frozenset( - { - "acp", - "auth", - "backup", - "chat", - "claw", - "completion", - "config", - "cron", - "dashboard", - "debug", - "desktop", - "doctor", - "dump", - "gateway", - "gui", - "honcho", - "import", - "insights", - "login", - "logout", - "logs", - "mcp", - "memory", - "model", - "pairing", - "plugins", - "profile", - "security", - "serve", - "sessions", - "setup", - "skills", - "status", - "tools", - "uninstall", - "update", - "version", - "webhook", - "whatsapp", - "whatsapp-cloud", - } -) -_PROFILE_VALUE_FLAGS = { - "-p": "--profile", - "--profile": "--profile", -} - +_CLI_ADAPTER_ARITIES = {"boolean", "optional_session", "required", "session"} -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 _consume_session_name(argv: list[str], start: int) -> tuple[str | None, int]: - """Mirror Hermes' unquoted multi-word session-name coalescing.""" - parts: list[str] = [] - i = start - while ( - i < len(argv) - and not argv[i].startswith("-") - and argv[i] not in _HERMES_SESSION_NAME_BOUNDARIES - ): - parts.append(argv[i]) - i += 1 - return (" ".join(parts) if parts else None), i +class _CliAdapterError(Exception): + """Signal an invalid adapter contract or incompatible upstream CLI.""" -class _UnsupportedResumedOneshotUsageFile(Exception): - """Signal a valid resumed one-shot form whose usage report would be lost.""" +def _load_cli_adapter(path: str) -> dict: + try: + with open(path, encoding="utf-8") as adapter_file: + adapter = json.load(adapter_file) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise _CliAdapterError( + f"could not read Hermes CLI adapter ({exc.__class__.__name__})" + ) from None + + if ( + not isinstance(adapter, dict) + or adapter.get("adapter_version") != _SUPPORTED_CLI_ADAPTER_VERSION + ): + version = adapter.get("adapter_version") if isinstance(adapter, dict) else None + raise _CliAdapterError(f"unsupported Hermes CLI adapter version: {version!r}") + if not isinstance(adapter.get("upstream_cli_version"), str): + raise _CliAdapterError("Hermes CLI adapter has no upstream version") + if adapter.get("managed_commands") != ["chat"]: + raise _CliAdapterError("Hermes CLI adapter has unsupported managed commands") + + options = adapter.get("options") + if not isinstance(options, list) or not options: + raise _CliAdapterError("Hermes CLI adapter has no managed options") + ids: set[str] = set() + names: set[str] = set() + for option in options: + if not isinstance(option, dict): + raise _CliAdapterError("Hermes CLI adapter option is not an object") + option_id = option.get("id") + option_names = option.get("names") + arity = option.get("arity") + if not isinstance(option_id, str) or not option_id or option_id in ids: + raise _CliAdapterError("Hermes CLI adapter has an invalid option id") + if ( + not isinstance(option_names, list) + or not option_names + or not all(isinstance(name, str) and name.startswith("-") for name in option_names) + ): + raise _CliAdapterError(f"Hermes CLI adapter option {option_id} has invalid names") + if arity not in _CLI_ADAPTER_ARITIES: + raise _CliAdapterError(f"Hermes CLI adapter option {option_id} has invalid arity") + if any(name in names for name in option_names): + raise _CliAdapterError("Hermes CLI adapter has duplicate option names") + if arity != "boolean" and not isinstance(option.get("canonical"), str): + raise _CliAdapterError(f"Hermes CLI adapter option {option_id} has no canonical name") + ids.add(option_id) + names.update(option_names) + + required = {"continue", "model", "oneshot", "profile", "provider", "resume", "usage_file"} + if not required <= ids: + raise _CliAdapterError("Hermes CLI adapter is missing a managed translation option") + translations = adapter.get("translations") + if not isinstance(translations, dict) or set(translations) != { + "provider_model", + "resumed_oneshot", + }: + raise _CliAdapterError("Hermes CLI adapter has invalid translation metadata") + return adapter + + +def _option_index(adapter: dict) -> tuple[dict[str, dict], dict[str, dict]]: + by_name: dict[str, dict] = {} + by_id: dict[str, dict] = {} + for option in adapter["options"]: + by_id[option["id"]] = option + for name in option["names"]: + by_name[name] = option + return by_name, by_id + + +def _has_option(argv: list[str], option: dict) -> bool: + for arg in argv: + if arg in option["names"]: + return True + if arg.startswith("--") and "=" in arg and arg.split("=", 1)[0] in option["names"]: + return True + return False -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 ...` and - bare `chat --query --quiet --continue` paths are the native non-interactive - routes that append to the selected or most recent 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 - global_prefix: list[str] = [] - resume_args: list[str] = [] - passthrough: list[str] = [] - saw_resume = False - saw_continue = False - saw_oneshot = False - saw_profile = False - usage_file_requested = False +def _parse_managed_invocation(argv: list[str], adapter: dict) -> dict | None: + """Parse one managed top-level or chat invocation from the adapter schema.""" + by_name, by_id = _option_index(adapter) + coalesce_session = _has_option(argv, by_id["oneshot"]) + occurrences: list[dict] = [] + occurrence_ids: dict[str, int] = {} + command: str | None = None + terminated = False i = 0 while i < len(argv): arg = argv[i] - if arg == "--": - return None + terminated = True + break - 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]) - elif name in _TOP_LEVEL_VALUE_FLAGS: - if not value: - return None - usage_file_requested = True - elif name == "--profile": - if not value or saw_profile: - return None - saw_profile = True - global_prefix.extend(["--profile", value]) - else: - return None - i += 1 - continue + option = None + value: str | None = None + equals_form = False + if arg.startswith("--") and "=" in arg: + name, value = arg.split("=", 1) + option = by_name.get(name) + equals_form = option is not None + else: + name = arg + option = by_name.get(name) - if arg in ("-z", "--oneshot"): - if i + 1 >= len(argv) or argv[i + 1].startswith("-"): + if option is not None: + option_id = option["id"] + if occurrence_ids.get(option_id, 0) and not option.get("repeatable", False): return None - if saw_oneshot: - return None - saw_oneshot = True - oneshot_prompt = argv[i + 1] - i += 2 - continue - - if arg in _VALUE_FLAGS: - canonical = _VALUE_FLAGS[arg] - if canonical == "--resume": - value, next_index = _consume_session_name(argv, i + 1) - if not value: + arity = option["arity"] + end = i + 1 + if equals_form: + if arity == "boolean" or not value: return None - if saw_resume or saw_continue: + elif arity == "boolean": + value = None + elif arity in {"session", "optional_session"}: + parts: list[str] = [] + cursor = i + 1 + if cursor < len(argv) and not argv[cursor].startswith("-"): + parts.append(argv[cursor]) + cursor += 1 + if coalesce_session and command is None: + while cursor < len(argv) and not argv[cursor].startswith("-"): + parts.append(argv[cursor]) + cursor += 1 + if not parts and arity == "session": return None - saw_resume = True - resume_args.extend([canonical, value]) - i = next_index + value = " ".join(parts) if parts else None + end = cursor else: - if i + 1 >= len(argv) or argv[i + 1].startswith("-"): + if i + 1 >= len(argv) or argv[i + 1].startswith("-") or not argv[i + 1]: return None value = argv[i + 1] - if not value: - return None - passthrough.extend([canonical, value]) - i += 2 - continue - - if arg in _TOP_LEVEL_VALUE_FLAGS: - if i + 1 >= len(argv) or argv[i + 1].startswith("-"): - return None - value = argv[i + 1] - if not value: - return None - usage_file_requested = True - i += 2 - continue - - if arg in _PROFILE_VALUE_FLAGS: - if i + 1 >= len(argv) or argv[i + 1].startswith("-") or saw_profile: - return None - value = argv[i + 1] - if not value: - return None - saw_profile = True - global_prefix.extend([_PROFILE_VALUE_FLAGS[arg], value]) - i += 2 - continue - - if arg in ("-c", "--continue"): - if saw_resume or saw_continue: - return None - value, next_index = _consume_session_name(argv, i + 1) - if value is None: - saw_continue = True - resume_args.append("--continue") - i += 1 - continue - if not value: - return None - saw_continue = True - resume_args.append("--continue") - resume_args.append(value) - i = next_index - 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 - - if usage_file_requested: - raise _UnsupportedResumedOneshotUsageFile - - translated = [*global_prefix, "chat", "--query", oneshot_prompt, "--quiet"] - translated.extend(resume_args) - translated.extend(passthrough) - return translated - - -# Source-of-truth note for the `_merge_provider_into_model` rewrite -# (NVIDIA/NemoClaw#7361): -# - Invalid state: separate --provider and -m/--model flags bypass the -# OpenShell proxy rewrite path; the raw .env placeholder is sent as the -# bearer token, causing a 401. -# - Fix: merge into the combined provider/model form at the wrapper boundary -# so the invocation routes through the proxy credential resolution path. -# - Upstream constraint: NemoClaw installs a pinned Hermes CLI rather than -# vendoring its credential-resolution implementation, so the source fix -# cannot be made safely in this repository. -# - Regression evidence: the checked-in `hermes-inference-switch` live target -# invokes that pinned CLI with separate provider/model flags backed by an -# OpenShell placeholder and requires a successful inference response. -# - Removal condition: delete this translation when Hermes natively resolves -# openshell: placeholders for separate --provider flag invocations. -# - Tracking: NVIDIA/NemoClaw#7361 - - -def _supports_provider_model_merge(argv: list[str]) -> bool: - """Return whether argv is a top-level or chat invocation. - - Hermes accepts provider/model selection at the top level and on `chat`. - Other positional commands own their remaining flags, so leave those - invocations untouched. Unknown options with separate values fail closed: - their first positional token is treated as a command. - """ - i = 0 - while i < len(argv): - arg = argv[i] - - if arg == "--": - return True - - if _split_flag_value(arg) is not None: - i += 1 - continue - - if arg in _PROVIDER_MODEL_COMMAND_SCAN_REQUIRED_VALUE_FLAGS: - i += 2 + end = i + 2 + + occurrences.append( + { + "canonical": option.get("canonical", name), + "end": end, + "equals": equals_form, + "id": option_id, + "name": name, + "start": i, + "value": value, + } + ) + occurrence_ids[option_id] = occurrence_ids.get(option_id, 0) + 1 + i = end continue - if arg in _PROVIDER_MODEL_COMMAND_SCAN_SESSION_FLAGS: + if command is not None: i += 1 - if i < len(argv) and not argv[i].startswith("-"): - i += 1 - while ( - i < len(argv) - and not argv[i].startswith("-") - and argv[i] not in _HERMES_SUBCOMMANDS - ): - i += 1 continue - if arg.startswith("-"): + return None + if arg in adapter["managed_commands"]: + command = arg i += 1 continue + if ( + occurrence_ids.get("continue", 0) + occurrence_ids.get("resume", 0) == 1 + and _has_option(argv, by_id["provider"]) + and _has_option(argv, by_id["model"]) + ): + raise _AmbiguousProviderModelSession + return None - return arg == "chat" - - return True + return { + "argv": argv, + "command": command, + "occurrences": occurrences, + "terminated": terminated, + } -def _merge_provider_into_model(argv: list[str]) -> list[str]: - """Merge separate --provider and -m/--model flags into the combined form. +def _occurrences(parsed: dict, option_id: str) -> list[dict]: + return [occurrence for occurrence in parsed["occurrences"] if occurrence["id"] == option_id] - When both --provider and -m/--model are present as separate - flags and the model value is not already prefixed by that provider, - rewrite to the combined 'provider/model' form so the invocation routes - through the OpenShell proxy rewrite path that resolves credential - placeholders. Model ids may contain their own namespace separator, such - as 'nvidia/nemotron', without already being provider-prefixed. - A model already prefixed by the selected provider keeps its value while the - redundant provider flag is removed. Returns argv unchanged for other - positional commands or on ambiguity (missing flag, empty values, - duplicates). Pure function, no side effects. - """ - if not _supports_provider_model_merge(argv): - return argv +def _merged_model(provider: str, model: str) -> str: + prefix = f"{provider}/" + return model if model.casefold().startswith(prefix.casefold()) else f"{provider}/{model}" - provider: str | None = None - provider_idx: int = -1 - provider_val_idx: int = -1 - model: str | None = None - model_idx: int = -1 - model_val_idx: int = -1 - i = 0 - while i < len(argv): - arg = argv[i] - - if arg == "--": - break +def _provider_model_values(parsed: dict) -> tuple[dict, dict, str] | None: + providers = _occurrences(parsed, "provider") + models = _occurrences(parsed, "model") + if len(providers) != 1 or len(models) != 1: + return None + provider = providers[0] + model = models[0] + if not provider["value"] or not model["value"]: + return None + return provider, model, _merged_model(provider["value"], model["value"]) + + +def _translate_resumed_oneshot( + parsed: dict, + provider_model: tuple[dict, dict, str] | None, +) -> list[str] | None: + oneshots = _occurrences(parsed, "oneshot") + resumes = _occurrences(parsed, "resume") + continues = _occurrences(parsed, "continue") + if ( + len(oneshots) != 1 + or len(resumes) + len(continues) != 1 + or parsed["command"] is not None + or parsed["terminated"] + ): + return None + if _occurrences(parsed, "usage_file"): + raise _UnsupportedResumedOneshotUsageFile - split = _split_flag_value(arg) - if split is not None: - name, value = split - if name == "--provider": - if provider is not None: - return argv - provider = value - provider_idx = i - provider_val_idx = -1 - elif name in ("--model", "-m"): - if model is not None: - return argv - model = value - model_idx = i - model_val_idx = -1 - i += 1 + translated: list[str] = [] + profiles = _occurrences(parsed, "profile") + if profiles: + translated.extend([profiles[0]["canonical"], profiles[0]["value"]]) + translated.extend(["chat", "--query", oneshots[0]["value"], "--quiet"]) + + session = resumes[0] if resumes else continues[0] + translated.append(session["canonical"]) + if session["value"] is not None: + translated.append(session["value"]) + + provider_occurrence = provider_model[0] if provider_model else None + model_occurrence = provider_model[1] if provider_model else None + merged_model = provider_model[2] if provider_model else None + excluded = {"continue", "oneshot", "profile", "resume", "usage_file"} + for occurrence in parsed["occurrences"]: + if occurrence["id"] in excluded or occurrence is provider_occurrence: continue + if occurrence is model_occurrence: + translated.extend([occurrence["canonical"], merged_model]) + elif occurrence["value"] is None: + translated.append(occurrence["name"]) + else: + translated.extend([occurrence["canonical"], occurrence["value"]]) + return translated - if arg == "--provider": - if provider is not None: - return argv - if i + 1 >= len(argv) or argv[i + 1].startswith("-"): - return argv - provider = argv[i + 1] - provider_idx = i - provider_val_idx = i + 1 - i += 2 - continue - if arg in ("-m", "--model"): - if model is not None: - return argv - if i + 1 >= len(argv) or argv[i + 1].startswith("-"): - return argv - model = argv[i + 1] - model_idx = i - model_val_idx = i + 1 - i += 2 - continue +class _UnsupportedResumedOneshotUsageFile(Exception): + """Signal a valid resumed one-shot form whose usage report would be lost.""" - i += 1 - if provider is None or model is None: - return argv - if not provider or not model: - return argv - provider_prefix = f"{provider}/" - merged_model = ( - model if model.casefold().startswith(provider_prefix.casefold()) else f"{provider}/{model}" - ) +class _AmbiguousProviderModelSession(Exception): + """Signal provider/model flags after an unquoted multi-word session name.""" - # Build new argv: remove provider flag+value, replace model value with merged - skip = {provider_idx} - if provider_val_idx >= 0: - skip.add(provider_val_idx) +def _merge_provider_model(parsed: dict, provider_model: tuple[dict, dict, str]) -> list[str]: + provider, model, merged_model = provider_model + skip = set(range(provider["start"], provider["end"])) result: list[str] = [] - for idx, val in enumerate(argv): - if idx in skip: + for index, arg in enumerate(parsed["argv"]): + if index in skip: continue - if idx == model_idx and model_val_idx < 0: - # Equals form: replace the whole --model=value token - result.append(f"--model={merged_model}") - elif idx == model_val_idx: + if index == model["start"] and model["equals"]: + result.append(f"{model['name']}={merged_model}") + elif index == model["start"] + 1 and not model["equals"]: result.append(merged_model) else: - result.append(val) + result.append(arg) return result +def _adapt_cli_argv(argv: list[str], adapter: dict) -> tuple[str, list[str]]: + parsed = _parse_managed_invocation(argv, adapter) + if parsed is None: + return "passthrough", argv + provider_model = _provider_model_values(parsed) + translated = _translate_resumed_oneshot(parsed, provider_model) + if translated is not None: + return "translated", translated + if provider_model is not None: + return "translated", _merge_provider_model(parsed, provider_model) + return "passthrough", argv + + +def _require_upstream_cli_version(real_hermes: str, expected: str) -> None: + env = dict(os.environ) + env[_CLI_VERSION_PROBE_ENV] = "1" + try: + result = subprocess.run( + [real_hermes, "--version"], + capture_output=True, + check=False, + env=env, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise _CliAdapterError( + f"could not verify the Hermes CLI version ({exc.__class__.__name__})" + ) from None + output = f"{result.stdout}\n{result.stderr}" + match = _CLI_VERSION_PATTERN.search(output) + actual = match.group(1) if match else None + if result.returncode != 0 or actual != expected: + raise _CliAdapterError( + f"adapter targets Hermes {expected}, installed CLI reports " + f"{actual or 'an unknown version'}" + ) + + def main(argv: list[str]) -> int: real_hermes = _resolve_real_hermes() guard_path = _resolve_guard() @@ -817,8 +599,16 @@ def main(argv: list[str]) -> int: if rc != 0: return rc try: - translated = _translate_resumed_oneshot(argv) + adapter = _load_cli_adapter(_resolve_cli_adapter()) + adapter_result, exec_argv = _adapt_cli_argv(argv, adapter) + if adapter_result == "translated": + _require_upstream_cli_version(real_hermes, adapter["upstream_cli_version"]) except _UnsupportedResumedOneshotUsageFile: + try: + _require_upstream_cli_version(real_hermes, adapter["upstream_cli_version"]) + except _CliAdapterError as exc: + print(f"[COMPATIBILITY] Refusing to run hermes: {exc}", file=sys.stderr) + return 2 print( "[COMPATIBILITY] Refusing resumed one-shot with --usage-file: " "Hermes 0.19 writes usage reports only on its native one-shot path, " @@ -828,11 +618,22 @@ def main(argv: list[str]) -> int: file=sys.stderr, ) return 2 - if translated is not None: - exec_argv = translated - else: - exec_argv = argv - exec_argv = _merge_provider_into_model(exec_argv) + except _AmbiguousProviderModelSession: + try: + _require_upstream_cli_version(real_hermes, adapter["upstream_cli_version"]) + except _CliAdapterError as exc: + print(f"[COMPATIBILITY] Refusing to run hermes: {exc}", file=sys.stderr) + return 2 + print( + "[COMPATIBILITY] Refusing provider/model translation after an " + "ambiguous session name. Pass a multi-word --resume or --continue " + "session name as one quoted argument.", + file=sys.stderr, + ) + return 2 + except _CliAdapterError as exc: + print(f"[COMPATIBILITY] Refusing to run hermes: {exc}", file=sys.stderr) + return 2 try: os.execv(real_hermes, [real_hermes, *exec_argv]) except OSError as exc: diff --git a/agents/hermes/image-build-probes.py b/agents/hermes/image-build-probes.py index 9c1d41b1527..beeb5531290 100644 --- a/agents/hermes/image-build-probes.py +++ b/agents/hermes/image-build-probes.py @@ -171,65 +171,6 @@ def verify_langfuse_credentials() -> None: ) -def verify_wrapper_session_boundaries() -> None: - import ast - - wrapper_tree = ast.parse( - Path("/usr/local/lib/nemoclaw/hermes-wrapper.py").read_text(encoding="utf-8") - ) - wrapper_assignments = [ - node - for node in wrapper_tree.body - if isinstance(node, ast.Assign) - and len(node.targets) == 1 - and isinstance(node.targets[0], ast.Name) - and node.targets[0].id == "_HERMES_SESSION_NAME_BOUNDARIES" - ] - if len(wrapper_assignments) != 1: - raise SystemExit("ERROR: expected one wrapper session-name boundary constant") - wrapper_value = wrapper_assignments[0].value - if not ( - isinstance(wrapper_value, ast.Call) - and isinstance(wrapper_value.func, ast.Name) - and wrapper_value.func.id == "frozenset" - and len(wrapper_value.args) == 1 - and not wrapper_value.keywords - ): - raise SystemExit("ERROR: wrapper session-name boundaries are not a literal frozenset") - wrapper_boundaries = set(ast.literal_eval(wrapper_value.args[0])) - - upstream_tree = ast.parse( - Path("/opt/hermes/hermes_cli/main.py").read_text(encoding="utf-8") - ) - coalescers = [ - node - for node in upstream_tree.body - if isinstance(node, ast.FunctionDef) - and node.name == "_coalesce_session_name_args" - ] - if len(coalescers) != 1: - raise SystemExit("ERROR: expected one pinned Hermes session-name coalescer") - upstream_assignments = [ - node - for node in coalescers[0].body - if isinstance(node, ast.Assign) - and len(node.targets) == 1 - and isinstance(node.targets[0], ast.Name) - and node.targets[0].id == "_SUBCOMMANDS" - ] - if len(upstream_assignments) != 1: - raise SystemExit("ERROR: expected one pinned Hermes coalescer boundary set") - upstream_boundaries = set(ast.literal_eval(upstream_assignments[0].value)) - - missing = sorted(upstream_boundaries - wrapper_boundaries) - stale = sorted(wrapper_boundaries - upstream_boundaries) - if missing or stale: - raise SystemExit( - "ERROR: Hermes wrapper session-name boundaries drifted from pinned coalescer: " - f"missing={','.join(missing)} stale={','.join(stale)}" - ) - - def verify_dashboard_policy(path: Path) -> None: import yaml @@ -388,7 +329,6 @@ def reopen_probe(conn): "langfuse-credentials": verify_langfuse_credentials, "profile-policy": verify_profile_policy, "session-preview": verify_session_preview, - "wrapper-session-boundaries": verify_wrapper_session_boundaries, } diff --git a/agents/hermes/validate-cli-adapter.py b/agents/hermes/validate-cli-adapter.py new file mode 100755 index 00000000000..7e58b9288dd --- /dev/null +++ b/agents/hermes/validate-cli-adapter.py @@ -0,0 +1,173 @@ +#!/usr/bin/python3 -I +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Validate the NemoClaw Hermes CLI adapter against upstream parser metadata.""" + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +_ADAPTER_VERSION = 1 +_ALLOWED_ARITIES = {"boolean", "optional_session", "required", "session"} + + +def _fail(message: str) -> None: + raise SystemExit(f"ERROR: {message}") + + +def _parser_actions(parser) -> dict[str, object]: + actions: dict[str, object] = {} + for action in parser._actions: + for name in action.option_strings: + actions[name] = action + return actions + + +def _validate_action(option: dict, action: object, surface: str) -> None: + arity = option["arity"] + nargs = getattr(action, "nargs", None) + if arity == "boolean": + valid = nargs == 0 + elif arity == "optional_session": + valid = nargs == "?" + else: + valid = nargs is None + if not valid: + _fail( + f"adapter option {option['id']} has arity {arity}, " + f"but {surface} parser metadata differs" + ) + + +def validate(contract_path: Path, hermes_binary: str) -> None: + try: + contract = json.loads(contract_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + _fail(f"could not read Hermes CLI adapter contract ({exc.__class__.__name__})") + + if contract.get("adapter_version") != _ADAPTER_VERSION: + _fail(f"unsupported Hermes CLI adapter version: {contract.get('adapter_version')!r}") + if contract.get("managed_commands") != ["chat"]: + _fail("Hermes CLI adapter has unsupported managed commands") + + from hermes_cli import __version__ as upstream_version + from hermes_cli._parser import PRE_ARGPARSE_INHERITED_FLAGS, build_top_level_parser + + if contract.get("upstream_cli_version") != upstream_version: + _fail( + "Hermes CLI adapter targets " + f"{contract.get('upstream_cli_version')!r}, installed Hermes is {upstream_version!r}" + ) + + parser, _subparsers, chat_parser = build_top_level_parser() + surfaces = { + "top": _parser_actions(parser), + "chat": _parser_actions(chat_parser), + } + preparse = {name: takes_value for name, takes_value in PRE_ARGPARSE_INHERITED_FLAGS} + + options = contract.get("options") + if not isinstance(options, list) or not options: + _fail("Hermes CLI adapter options must be a non-empty list") + + option_ids: set[str] = set() + option_names: set[str] = set() + for option in options: + if not isinstance(option, dict): + _fail("Hermes CLI adapter option must be an object") + option_id = option.get("id") + names = option.get("names") + arity = option.get("arity") + option_surfaces = option.get("surfaces") + if not isinstance(option_id, str) or not option_id: + _fail("Hermes CLI adapter option id must be a non-empty string") + if option_id in option_ids: + _fail(f"duplicate Hermes CLI adapter option id: {option_id}") + option_ids.add(option_id) + if ( + not isinstance(names, list) + or not names + or not all(isinstance(name, str) for name in names) + ): + _fail(f"adapter option {option_id} must declare names") + if arity not in _ALLOWED_ARITIES: + _fail(f"adapter option {option_id} has unsupported arity: {arity!r}") + if not isinstance(option_surfaces, list) or not option_surfaces: + _fail(f"adapter option {option_id} must declare parser surfaces") + for name in names: + if name in option_names: + _fail(f"duplicate Hermes CLI adapter option name: {name}") + option_names.add(name) + for surface in option_surfaces: + if surface == "preparse": + missing = sorted(name for name in names if preparse.get(name) is not True) + if missing: + _fail( + "adapter preparse option differs from upstream metadata: " + f"{', '.join(missing)}" + ) + continue + actions = surfaces.get(surface) + if actions is None: + _fail(f"adapter option {option_id} has unknown parser surface: {surface!r}") + for name in names: + action = actions.get(name) + if action is None: + _fail(f"adapter option {name} is absent from the upstream {surface} parser") + _validate_action(option, action, surface) + + required_ids = { + "accept_hooks", + "continue", + "ignore_rules", + "ignore_user_config", + "model", + "no_restore_cwd", + "oneshot", + "profile", + "provider", + "resume", + "safe_mode", + "usage_file", + "worktree", + "yolo", + } + if not required_ids <= option_ids: + missing = ", ".join(sorted(required_ids - option_ids)) + _fail(f"Hermes CLI adapter is missing managed options: {missing}") + + translations = contract.get("translations") + if not isinstance(translations, dict) or set(translations) != { + "provider_model", + "resumed_oneshot", + }: + _fail("Hermes CLI adapter must declare the two managed translations") + for name, translation in translations.items(): + if not isinstance(translation, dict): + _fail(f"adapter translation {name} must be an object") + for field in ("forms", "issue", "reason", "removal_condition"): + if not translation.get(field): + _fail(f"adapter translation {name} must declare {field}") + + # Help is runtime evidence that the owned public surfaces still start. Parser + # metadata above is the compatibility authority. + for argv in ([hermes_binary, "--help"], [hermes_binary, "chat", "--help"]): + result = subprocess.run(argv, stdout=subprocess.DEVNULL, timeout=30, check=False) + if result.returncode != 0: + _fail(f"Hermes public help probe failed: {' '.join(argv[1:])}") + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--contract", required=True, type=Path) + parser.add_argument("--hermes", required=True) + args = parser.parse_args(argv) + validate(args.contract, args.hermes) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/test/helpers/hermes-wrapper-harness.ts b/test/helpers/hermes-wrapper-harness.ts index 607d45a5826..3de85a00b1c 100644 --- a/test/helpers/hermes-wrapper-harness.ts +++ b/test/helpers/hermes-wrapper-harness.ts @@ -31,6 +31,14 @@ export const VALIDATOR = path.join( "hermes", "validate-env-secret-boundary.py", ); +export const ADAPTER = path.join( + import.meta.dirname, + "..", + "..", + "agents", + "hermes", + "hermes-cli-adapter-v1.json", +); export function python3Available(): boolean { try { @@ -60,12 +68,18 @@ export function runWrapper( shadowHelpers?: Record; stub?: StubBehaviour; stubMode?: number; + adapter?: object; + upstreamVersion?: string; validatorScript?: string; } = {}, ): WrapperRun { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wrapper-")); try { fs.copyFileSync(WRAPPER, path.join(dir, "hermes")); + const adapterContent = opts.adapter + ? `${JSON.stringify(opts.adapter, null, 2)}\n` + : fs.readFileSync(ADAPTER, "utf-8"); + fs.writeFileSync(path.join(dir, "hermes-cli-adapter-v1.json"), adapterContent); const validatorContent = opts.validatorScript ?? fs.readFileSync(VALIDATOR, "utf-8"); // Source-layout filename lets the wrapper's dev fallback pick it up. fs.writeFileSync(path.join(dir, "validate-env-secret-boundary.py"), validatorContent, { @@ -79,6 +93,7 @@ export function runWrapper( const stubExit = opts.stub?.exitCode ?? 0; const stubScript = [ "#!/usr/bin/env bash", + `if [ "\${NEMOCLAW_HERMES_ADAPTER_VERSION_PROBE:-}" = "1" ]; then printf 'Hermes Agent v${opts.upstreamVersion ?? "0.19.0"}\\n'; exit 0; fi`, `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 diff --git a/test/hermes-dependency-review.test.ts b/test/hermes-dependency-review.test.ts index 8273c2aa0c7..0906dabcf35 100644 --- a/test/hermes-dependency-review.test.ts +++ b/test/hermes-dependency-review.test.ts @@ -17,7 +17,9 @@ const config = fs.readFileSync( "utf8", ); const manifest = fs.readFileSync(path.join(root, "agents", "hermes", "manifest.yaml"), "utf8"); -const wrapper = fs.readFileSync(path.join(root, "agents", "hermes", "hermes-wrapper.py"), "utf8"); +const cliAdapter = JSON.parse( + fs.readFileSync(path.join(root, "agents", "hermes", "hermes-cli-adapter-v1.json"), "utf8"), +); const review = fs.readFileSync( path.join(root, "docs", "security", "hermes-0.19.0-dependency-review.md"), "utf8", @@ -96,10 +98,10 @@ describe("Hermes 0.19.0 dependency review", () => { expect(review).toContain("Unresolved upgrade-created high-impact concerns: `0`"); }); - it("keeps wrapper parsing aligned with the target CLI", () => { - for (const expected of ['"--usage-file"', '"--no-restore-cwd"', '"--safe-mode"', '"console"']) { - expect(wrapper).toContain(expected); - } + it("binds the CLI adapter to the target Hermes version", () => { + expect(cliAdapter.adapter_version).toBe(1); + expect(cliAdapter.upstream_cli_version).toBe("0.19.0"); + expect(cliAdapter.managed_commands).toEqual(["chat"]); }); it("accepts uv build metadata and rejects a different semantic version", () => { diff --git a/test/hermes-doctor-config-hash.test.ts b/test/hermes-doctor-config-hash.test.ts index ff32a19fabf..5628636de20 100644 --- a/test/hermes-doctor-config-hash.test.ts +++ b/test/hermes-doctor-config-hash.test.ts @@ -30,7 +30,7 @@ describe("Hermes doctor and config hash boundary", () => { const command = dockerRunCommandBetween( dockerfile, 'RUN hermes_version_output="$(/usr/local/bin/hermes --version)"', - "# This runs before `/usr/local/bin/hermes`", + "# Validate the versioned adapter", ) .replaceAll("/usr/local/bin/hermes", hermesBin) .replaceAll("/usr/local/lib/nemoclaw/hermes-wrapper.py", wrapper) diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index 6b6be64be86..a6ed56b5a6b 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -21,6 +21,16 @@ const HERMES_INTEGRITY_FILES = [ source: "agents/hermes/hermes-wrapper.py", target: "/usr/local/lib/nemoclaw/hermes-wrapper.py", }, + { + arg: "NEMOCLAW_HERMES_CLI_ADAPTER_SHA256", + source: "agents/hermes/hermes-cli-adapter-v1.json", + target: "/usr/local/share/nemoclaw/hermes-cli-adapter-v1.json", + }, + { + arg: "NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256", + source: "agents/hermes/validate-cli-adapter.py", + target: "/usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py", + }, { arg: "NEMOCLAW_HERMES_VALIDATOR_SHA256", source: "agents/hermes/validate-env-secret-boundary.py", @@ -249,7 +259,11 @@ describe("Hermes final image layout", () => { }, { stage: "hermes-wrapper-payload", - copies: ["COPY agents/hermes/hermes-wrapper.py /usr/local/lib/nemoclaw/hermes-wrapper.py"], + copies: [ + "COPY agents/hermes/hermes-wrapper.py /usr/local/lib/nemoclaw/hermes-wrapper.py", + "COPY agents/hermes/validate-cli-adapter.py /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py", + "COPY agents/hermes/hermes-cli-adapter-v1.json /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json", + ], }, { stage: "hermes-scan-payload", @@ -307,10 +321,7 @@ describe("Hermes final image layout", () => { 'RUN if [ "$NEMOCLAW_DARWIN_VM_COMPAT" = "1" ]', ); const metadataCheck = indexOfRequired(finalStage, "RUN check_metadata()"); - const modeNormalize = indexOfRequired( - finalStage, - "RUN chmod 755 /usr/local/lib/nemoclaw/hermes-wrapper.py /scripts/checks/node-tar-image-scan.mts", - ); + const modeNormalize = indexOfRequired(finalStage, "RUN chmod 755 \\"); const imageScan = indexOfRequired( finalStage, "node --experimental-strip-types /scripts/checks/node-tar-image-scan.mts", @@ -337,6 +348,8 @@ describe("Hermes final image layout", () => { "/usr/local/bin/nemoclaw-gateway-control 'root:root 700'", "/usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root 444'", "/usr/local/lib/nemoclaw/hermes-wrapper.py 'root:root 755'", + "/usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py 'root:root 755'", + "/usr/local/share/nemoclaw/hermes-cli-adapter-v1.json 'root:root 444'", "/scripts/checks/node-tar-image-scan.mts 'root:root 755'", ]) { expect(finalStage).toContain(`check_metadata ${metadataContract}`); diff --git a/test/hermes-image-build-probes.test.ts b/test/hermes-image-build-probes.test.ts index 8715b53033d..5bc0b3035f7 100644 --- a/test/hermes-image-build-probes.test.ts +++ b/test/hermes-image-build-probes.test.ts @@ -25,7 +25,6 @@ const commands = [ "langfuse-credentials", "profile-policy", "session-preview", - "wrapper-session-boundaries", ] as const; describe("Hermes image build probes", () => { diff --git a/test/hermes-wrapper-oneshot-routing.test.ts b/test/hermes-wrapper-oneshot-routing.test.ts index 3374fadcd24..ae73e3b3802 100644 --- a/test/hermes-wrapper-oneshot-routing.test.ts +++ b/test/hermes-wrapper-oneshot-routing.test.ts @@ -22,7 +22,7 @@ import path from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; -import { canRun, runWrapper, WRAPPER } from "./helpers/hermes-wrapper-harness.ts"; +import { ADAPTER, canRun, runWrapper, WRAPPER } from "./helpers/hermes-wrapper-harness.ts"; describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py one-shot routing", () => { // Surface a hard error in CI when the prerequisites are missing instead of @@ -244,12 +244,14 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py one-shot routing", () const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wrapper-session-")); try { fs.copyFileSync(WRAPPER, path.join(dir, "hermes")); + fs.copyFileSync(ADAPTER, path.join(dir, "hermes-cli-adapter-v1.json")); 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 [ "${NEMOCLAW_HERMES_ADAPTER_VERSION_PROBE:-}" = "1" ]; then printf "Hermes Agent v0.19.0\\n"; exit 0; fi', '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", diff --git a/test/hermes-wrapper-provider-merge.test.ts b/test/hermes-wrapper-provider-merge.test.ts index 53c7b61e52e..bea397542a4 100644 --- a/test/hermes-wrapper-provider-merge.test.ts +++ b/test/hermes-wrapper-provider-merge.test.ts @@ -13,9 +13,11 @@ // Windows does not see a spurious red on `npm test`. See `.github/workflows/` // for the canonical CI runner image. +import fs from "node:fs"; + import { describe, expect, it } from "vitest"; -import { canRun, runWrapper } from "./helpers/hermes-wrapper-harness.ts"; +import { ADAPTER, canRun, runWrapper } from "./helpers/hermes-wrapper-harness.ts"; describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py provider/model merge", () => { it("merges separate --provider and -m flags into the combined form (#7361)", () => { @@ -71,13 +73,29 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py provider/model merge", } }); - it("merges after an unquoted multi-word continue session name (#7361)", () => { + it("rejects an ambiguous unquoted multi-word continue form (#8011)", () => { + const argv = [ + "-c", + "Pokemon", + "Agent", + "Dev", + "--provider", + "nvidia-prod", + "--model", + "nvidia/nemotron-3-super-120b-a12b", + ]; + const run = runWrapper(argv, {}); + + expect(run.status).toBe(2); + expect(run.realInvoked).toBe(false); + expect(run.stderr).toContain("ambiguous session name"); + }); + + it("merges after a quoted multi-word continue session name (#8011)", () => { const run = runWrapper( [ "-c", - "Pokemon", - "Agent", - "Dev", + "Pokemon Agent Dev", "--provider", "nvidia-prod", "--model", @@ -89,9 +107,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py provider/model merge", expect(run.status).toBe(0); expect(run.realArgv).toEqual([ "-c", - "Pokemon", - "Agent", - "Dev", + "Pokemon Agent Dev", "--model", "nvidia-prod/nvidia/nemotron-3-super-120b-a12b", ]); @@ -119,13 +135,29 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py provider/model merge", ]); }); - it("merges after an unquoted multi-word resume session name (#7361)", () => { + it("rejects an ambiguous unquoted multi-word resume form (#8011)", () => { + const argv = [ + "-r", + "My", + "Session", + "Name", + "--provider", + "nvidia-prod", + "--model", + "nvidia/nemotron-3-super-120b-a12b", + ]; + const run = runWrapper(argv, {}); + + expect(run.status).toBe(2); + expect(run.realInvoked).toBe(false); + expect(run.stderr).toContain("ambiguous session name"); + }); + + it("merges after a quoted multi-word resume session name (#8011)", () => { const run = runWrapper( [ "-r", - "My", - "Session", - "Name", + "My Session Name", "--provider", "nvidia-prod", "--model", @@ -137,9 +169,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py provider/model merge", expect(run.status).toBe(0); expect(run.realArgv).toEqual([ "-r", - "My", - "Session", - "Name", + "My Session Name", "--model", "nvidia-prod/nvidia/nemotron-3-super-120b-a12b", ]); @@ -221,7 +251,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py provider/model merge", ]); }); - it("passes through provider/model flags owned by another command (#7361)", () => { + it("rejects ambiguous session text before provider/model flags owned by another command (#8011)", () => { const argv = [ "-c", "my", @@ -236,11 +266,55 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py provider/model merge", const run = runWrapper(argv, {}); + expect(run.status).toBe(2); + expect(run.realInvoked).toBe(false); + expect(run.stderr).toContain("ambiguous session name"); + }); + + it("passes a new upstream command through without an adapter release (#8011)", () => { + const argv = [ + "future-command", + "--provider", + "nvidia-prod", + "--model", + "nvidia/nemotron-3-super-120b-a12b", + ]; + + const run = runWrapper(argv, {}); + expect(run.status).toBe(0); expect(run.realArgv).toEqual(argv); }); - it("does not consume the Hermes 0.19 console subcommand as continuation text (#7361)", () => { + it("rejects an unknown adapter version before invoking Hermes (#8011)", () => { + const adapter = JSON.parse(fs.readFileSync(ADAPTER, "utf-8")); + adapter.adapter_version = 2; + + const run = runWrapper( + ["--provider", "nvidia-prod", "--model", "nvidia/nemotron-3-super-120b-a12b"], + {}, + { adapter }, + ); + + expect(run.status).toBe(2); + expect(run.realInvoked).toBe(false); + expect(run.stderr).toContain("unsupported Hermes CLI adapter version: 2"); + }); + + it("rejects an unknown upstream CLI version before translating (#8011)", () => { + const run = runWrapper( + ["--provider", "nvidia-prod", "--model", "nvidia/nemotron-3-super-120b-a12b"], + {}, + { upstreamVersion: "0.20.0" }, + ); + + expect(run.status).toBe(2); + expect(run.realInvoked).toBe(false); + expect(run.stderr).toContain("adapter targets Hermes 0.19.0"); + expect(run.stderr).toContain("installed CLI reports 0.20.0"); + }); + + it("rejects ambiguous continuation text before the Hermes 0.19 console command (#8011)", () => { const argv = [ "-c", "my", @@ -255,8 +329,9 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py provider/model merge", const run = runWrapper(argv, {}); - expect(run.status).toBe(0); - expect(run.realArgv).toEqual(argv); + expect(run.status).toBe(2); + expect(run.realInvoked).toBe(false); + expect(run.stderr).toContain("ambiguous session name"); }); it("passes through --provider alone without -m (#7361)", () => { From 06ea794233b3e5ef6c1dbc617e71407a2ea8e6c9 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 15:21:06 -0400 Subject: [PATCH 02/24] fix(hermes): preserve wrapper failure contracts Signed-off-by: Julie Yaunches --- agents/hermes/Dockerfile | 2 +- agents/hermes/hermes-wrapper.py | 29 ++++++++++++++++++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 99017f9f6fb..38f7398e217 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -385,7 +385,7 @@ RUN node --experimental-strip-types \ # silent supply-chain tampering of the build context (an attacker rewriting a # file has to also rewrite the Dockerfile-committed hash, which reviewers gate). # Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,hermes-cli-adapter-v1.json,validate-cli-adapter.py,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=b0f63272954e2bfb1383032aa86c7e8fed46b07707bd2cbd36d5edccf230a534 +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=e7bafd03585d494a5036b2f59e3b3a7a7bee2a6f1ad1f3acd42347132e1d5ce4 ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=b78434e833d3b01933398a043241c4790baed468d88d62461050d00bf922864f ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=cb7eab8953ba0fb5063cce6fbd8b6809604d70e6f86a0f7aad09afceb63aad1e ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index 4b8d3e350bf..afd2df968e7 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -285,6 +285,10 @@ class _CliAdapterError(Exception): """Signal an invalid adapter contract or incompatible upstream CLI.""" +class _CliBinaryExecutionError(_CliAdapterError): + """Signal that the fixed Hermes binary could not be executed.""" + + def _load_cli_adapter(path: str) -> dict: try: with open(path, encoding="utf-8") as adapter_file: @@ -404,6 +408,8 @@ def _parse_managed_invocation(argv: list[str], adapter: dict) -> dict | None: elif arity in {"session", "optional_session"}: parts: list[str] = [] cursor = i + 1 + if cursor < len(argv) and not argv[cursor]: + return None if cursor < len(argv) and not argv[cursor].startswith("-"): parts.append(argv[cursor]) cursor += 1 @@ -575,7 +581,11 @@ def _require_upstream_cli_version(real_hermes: str, expected: str) -> None: text=True, timeout=10, ) - except (OSError, subprocess.TimeoutExpired) as exc: + except OSError as exc: + raise _CliBinaryExecutionError( + f"failed to exec Hermes binary at {real_hermes}: {exc}" + ) from None + except subprocess.TimeoutExpired as exc: raise _CliAdapterError( f"could not verify the Hermes CLI version ({exc.__class__.__name__})" ) from None @@ -589,6 +599,14 @@ def _require_upstream_cli_version(real_hermes: str, expected: str) -> None: ) +def _report_cli_adapter_error(exc: _CliAdapterError) -> int: + if isinstance(exc, _CliBinaryExecutionError): + print(f"[SECURITY] Refusing to run hermes: {exc}", file=sys.stderr) + return 126 + print(f"[COMPATIBILITY] Refusing to run hermes: {exc}", file=sys.stderr) + return 2 + + def main(argv: list[str]) -> int: real_hermes = _resolve_real_hermes() guard_path = _resolve_guard() @@ -607,8 +625,7 @@ def main(argv: list[str]) -> int: try: _require_upstream_cli_version(real_hermes, adapter["upstream_cli_version"]) except _CliAdapterError as exc: - print(f"[COMPATIBILITY] Refusing to run hermes: {exc}", file=sys.stderr) - return 2 + return _report_cli_adapter_error(exc) print( "[COMPATIBILITY] Refusing resumed one-shot with --usage-file: " "Hermes 0.19 writes usage reports only on its native one-shot path, " @@ -622,8 +639,7 @@ def main(argv: list[str]) -> int: try: _require_upstream_cli_version(real_hermes, adapter["upstream_cli_version"]) except _CliAdapterError as exc: - print(f"[COMPATIBILITY] Refusing to run hermes: {exc}", file=sys.stderr) - return 2 + return _report_cli_adapter_error(exc) print( "[COMPATIBILITY] Refusing provider/model translation after an " "ambiguous session name. Pass a multi-word --resume or --continue " @@ -632,8 +648,7 @@ def main(argv: list[str]) -> int: ) return 2 except _CliAdapterError as exc: - print(f"[COMPATIBILITY] Refusing to run hermes: {exc}", file=sys.stderr) - return 2 + return _report_cli_adapter_error(exc) try: os.execv(real_hermes, [real_hermes, *exec_argv]) except OSError as exc: From da016998924621ec56fc06913c18dc7424f56031 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 15:32:29 -0400 Subject: [PATCH 03/24] fix(hermes): verify CLI adapter before execution Signed-off-by: Julie Yaunches --- agents/hermes/Dockerfile | 19 +++++++++---------- agents/hermes/hermes-cli-adapter-v1.json | 2 ++ agents/hermes/validate-cli-adapter.py | 8 +++++++- test/hermes-dependency-review.test.ts | 11 +++++++++++ test/hermes-final-image-layout.test.ts | 7 +++++++ 5 files changed, 36 insertions(+), 11 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 38f7398e217..1ef39106c0a 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -386,8 +386,8 @@ RUN node --experimental-strip-types \ # file has to also rewrite the Dockerfile-committed hash, which reviewers gate). # Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,hermes-cli-adapter-v1.json,validate-cli-adapter.py,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. ARG NEMOCLAW_HERMES_WRAPPER_SHA256=e7bafd03585d494a5036b2f59e3b3a7a7bee2a6f1ad1f3acd42347132e1d5ce4 -ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=b78434e833d3b01933398a043241c4790baed468d88d62461050d00bf922864f -ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=cb7eab8953ba0fb5063cce6fbd8b6809604d70e6f86a0f7aad09afceb63aad1e +ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=d7d8b0815072554ee4e5f34f55f813e263d74f949ea3f1053e49ba815d1c2496 +ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=e6d6906daf990c03c44a842e1184d75d79a4cac657ef65b2518f889738d0c75b ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 # hadolint ignore=DL4006 @@ -429,14 +429,6 @@ RUN hermes_version_output="$(/usr/local/bin/hermes --version)" \ echo "ERROR: installed Hermes ${hermes_semver} but Hermes v0.19.0 compatibility workarounds are still installed; re-review #5254 workaround removal before upgrading Hermes" >&2; \ exit 1; \ fi -# Validate the versioned adapter against Hermes' machine-readable top-level and -# chat parser metadata before the real binary moves behind the wrapper. Help -# probes remain runtime evidence for those two public surfaces. -RUN /opt/hermes/.venv/bin/python -I \ - /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py \ - --contract /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json \ - --hermes /usr/local/bin/hermes - # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_WRAPPER_SHA256" /usr/local/lib/nemoclaw/hermes-wrapper.py \ @@ -444,6 +436,13 @@ RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256" /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py \ | sha256sum -c - \ || { echo "ERROR: Hermes CLI adapter integrity mismatch" >&2; exit 1; } +# Validate the versioned adapter against Hermes' machine-readable top-level and +# chat parser metadata before the real binary moves behind the wrapper. Help +# probes remain runtime evidence for those two public surfaces. +RUN /opt/hermes/.venv/bin/python -I \ + /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py \ + --contract /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json \ + --hermes /usr/local/bin/hermes RUN mv /usr/local/bin/hermes /usr/local/bin/hermes.real \ && install -m 0755 /usr/local/lib/nemoclaw/hermes-wrapper.py /usr/local/bin/hermes \ && chmod 755 /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py \ diff --git a/agents/hermes/hermes-cli-adapter-v1.json b/agents/hermes/hermes-cli-adapter-v1.json index d6776a0fd52..fc3c8be655d 100644 --- a/agents/hermes/hermes-cli-adapter-v1.json +++ b/agents/hermes/hermes-cli-adapter-v1.json @@ -124,6 +124,7 @@ "hermes [--profile=PROFILE] (--resume=SESSION|--continue[=SESSION]) --oneshot=PROMPT [CHAT_OPTION ...]" ], "reason": "Hermes 0.19 stores a top-level resumed or continued one-shot turn in a new session instead of the selected or most recent session.", + "source_fix_constraint": "NemoClaw consumes pinned Hermes and cannot change its upstream session persistence implementation.", "removal_condition": "Hermes appends top-level resumed and continued one-shot turns to the selected or most recent session." }, "provider_model": { @@ -138,6 +139,7 @@ ], "ambiguity_rule": "A top-level session name must be one argv value. The adapter rejects separate provider and model flags after an unquoted multi-word session name because a later positional can be an upstream command.", "reason": "Hermes 0.19 does not resolve the OpenShell credential placeholder when provider and model are separate.", + "source_fix_constraint": "NemoClaw consumes pinned Hermes and cannot change its upstream credential-resolution implementation.", "removal_condition": "Hermes resolves OpenShell credential placeholders for separate provider and model flags." } } diff --git a/agents/hermes/validate-cli-adapter.py b/agents/hermes/validate-cli-adapter.py index 7e58b9288dd..e4801b34d82 100755 --- a/agents/hermes/validate-cli-adapter.py +++ b/agents/hermes/validate-cli-adapter.py @@ -148,7 +148,13 @@ def validate(contract_path: Path, hermes_binary: str) -> None: for name, translation in translations.items(): if not isinstance(translation, dict): _fail(f"adapter translation {name} must be an object") - for field in ("forms", "issue", "reason", "removal_condition"): + for field in ( + "forms", + "issue", + "reason", + "removal_condition", + "source_fix_constraint", + ): if not translation.get(field): _fail(f"adapter translation {name} must declare {field}") diff --git a/test/hermes-dependency-review.test.ts b/test/hermes-dependency-review.test.ts index 0906dabcf35..ab12f8485a0 100644 --- a/test/hermes-dependency-review.test.ts +++ b/test/hermes-dependency-review.test.ts @@ -102,6 +102,17 @@ describe("Hermes 0.19.0 dependency review", () => { expect(cliAdapter.adapter_version).toBe(1); expect(cliAdapter.upstream_cli_version).toBe("0.19.0"); expect(cliAdapter.managed_commands).toEqual(["chat"]); + expect( + ( + Object.values(cliAdapter.translations) as Array<{ + source_fix_constraint?: unknown; + }> + ).every( + (translation) => + typeof translation.source_fix_constraint === "string" && + translation.source_fix_constraint.length > 0, + ), + ).toBe(true); }); it("accepts uv build metadata and rejects a different semantic version", () => { diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index a6ed56b5a6b..cef0179701a 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -382,6 +382,13 @@ describe("Hermes final image layout", () => { expect(dockerfile).toContain(`COPY ${entry.source} ${entry.target}`); expect(declaredDigest, `${entry.arg} must match ${entry.source}`).toBe(digest); } + + const adapterIntegrityGate = dockerfile.indexOf("ERROR: Hermes CLI adapter integrity mismatch"); + const adapterValidation = dockerfile.indexOf( + "RUN /opt/hermes/.venv/bin/python -I \\\n /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py \\", + ); + expect(adapterIntegrityGate).toBeGreaterThan(-1); + expect(adapterValidation).toBeGreaterThan(adapterIntegrityGate); }); it("rejects retired OpenClaw state represented as a directory", () => { From 621102a2061edbc7af4a6fbc6ad224bc1cc6a9c2 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 15:36:36 -0400 Subject: [PATCH 04/24] test(hermes): clarify adapter security contracts Signed-off-by: Julie Yaunches --- agents/hermes/Dockerfile | 2 +- agents/hermes/hermes-cli-adapter-v1.json | 4 ++-- ci/source-shape-test-budget.json | 5 +++++ test/hermes-dependency-review.test.ts | 2 +- test/hermes-final-image-layout.test.ts | 5 +++++ 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 1ef39106c0a..99f0ddc8bef 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -386,7 +386,7 @@ RUN node --experimental-strip-types \ # file has to also rewrite the Dockerfile-committed hash, which reviewers gate). # Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,hermes-cli-adapter-v1.json,validate-cli-adapter.py,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. ARG NEMOCLAW_HERMES_WRAPPER_SHA256=e7bafd03585d494a5036b2f59e3b3a7a7bee2a6f1ad1f3acd42347132e1d5ce4 -ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=d7d8b0815072554ee4e5f34f55f813e263d74f949ea3f1053e49ba815d1c2496 +ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=59c6c508d57a40c503ef2c67065e079a288d1671107fb1a6dfc13bf35ca3b58c ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=e6d6906daf990c03c44a842e1184d75d79a4cac657ef65b2518f889738d0c75b ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 diff --git a/agents/hermes/hermes-cli-adapter-v1.json b/agents/hermes/hermes-cli-adapter-v1.json index fc3c8be655d..67f8486bfb9 100644 --- a/agents/hermes/hermes-cli-adapter-v1.json +++ b/agents/hermes/hermes-cli-adapter-v1.json @@ -124,7 +124,7 @@ "hermes [--profile=PROFILE] (--resume=SESSION|--continue[=SESSION]) --oneshot=PROMPT [CHAT_OPTION ...]" ], "reason": "Hermes 0.19 stores a top-level resumed or continued one-shot turn in a new session instead of the selected or most recent session.", - "source_fix_constraint": "NemoClaw consumes pinned Hermes and cannot change its upstream session persistence implementation.", + "source_fix_constraint": "The affected session persistence implementation is part of the pinned Hermes dependency, so NemoClaw cannot change its upstream source in this repository.", "removal_condition": "Hermes appends top-level resumed and continued one-shot turns to the selected or most recent session." }, "provider_model": { @@ -139,7 +139,7 @@ ], "ambiguity_rule": "A top-level session name must be one argv value. The adapter rejects separate provider and model flags after an unquoted multi-word session name because a later positional can be an upstream command.", "reason": "Hermes 0.19 does not resolve the OpenShell credential placeholder when provider and model are separate.", - "source_fix_constraint": "NemoClaw consumes pinned Hermes and cannot change its upstream credential-resolution implementation.", + "source_fix_constraint": "The affected credential-resolution implementation is part of the pinned Hermes dependency, so NemoClaw cannot change its upstream source in this repository.", "removal_condition": "Hermes resolves OpenShell credential placeholders for separate provider and model flags." } } diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 3dd45f3afe5..a8047fdaf1e 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -266,6 +266,11 @@ "test": "keeps security entrypoint hashes synchronized with the copied files", "category": "security" }, + { + "file": "test/hermes-final-image-layout.test.ts", + "test": "verifies CLI adapter integrity before executing its validator", + "category": "security" + }, { "file": "test/openclaw-final-image-layout.test.ts", "test": "uses grouped legacy-compatible payload layers at their cache boundaries (#7611)", diff --git a/test/hermes-dependency-review.test.ts b/test/hermes-dependency-review.test.ts index ab12f8485a0..95d0bca855c 100644 --- a/test/hermes-dependency-review.test.ts +++ b/test/hermes-dependency-review.test.ts @@ -98,7 +98,7 @@ describe("Hermes 0.19.0 dependency review", () => { expect(review).toContain("Unresolved upgrade-created high-impact concerns: `0`"); }); - it("binds the CLI adapter to the target Hermes version", () => { + it("binds the CLI adapter version and source-fix constraints to target Hermes", () => { expect(cliAdapter.adapter_version).toBe(1); expect(cliAdapter.upstream_cli_version).toBe("0.19.0"); expect(cliAdapter.managed_commands).toEqual(["chat"]); diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index cef0179701a..5e5537e5c4e 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -382,11 +382,16 @@ describe("Hermes final image layout", () => { expect(dockerfile).toContain(`COPY ${entry.source} ${entry.target}`); expect(declaredDigest, `${entry.arg} must match ${entry.source}`).toBe(digest); } + }); + // source-shape-contract: security -- Adapter bytes must pass their committed integrity gate before the image build executes validator code + it("verifies CLI adapter integrity before executing its validator", () => { + const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); const adapterIntegrityGate = dockerfile.indexOf("ERROR: Hermes CLI adapter integrity mismatch"); const adapterValidation = dockerfile.indexOf( "RUN /opt/hermes/.venv/bin/python -I \\\n /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py \\", ); + expect(adapterIntegrityGate).toBeGreaterThan(-1); expect(adapterValidation).toBeGreaterThan(adapterIntegrityGate); }); From 7b4c1cd901c4a1564a60364f5c8e2106f72413bf Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 15:38:32 -0400 Subject: [PATCH 05/24] docs(hermes): clarify adapter source ownership Signed-off-by: Julie Yaunches --- agents/hermes/Dockerfile | 2 +- agents/hermes/hermes-cli-adapter-v1.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 99f0ddc8bef..550547fbf09 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -386,7 +386,7 @@ RUN node --experimental-strip-types \ # file has to also rewrite the Dockerfile-committed hash, which reviewers gate). # Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,hermes-cli-adapter-v1.json,validate-cli-adapter.py,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. ARG NEMOCLAW_HERMES_WRAPPER_SHA256=e7bafd03585d494a5036b2f59e3b3a7a7bee2a6f1ad1f3acd42347132e1d5ce4 -ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=59c6c508d57a40c503ef2c67065e079a288d1671107fb1a6dfc13bf35ca3b58c +ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=dcb9f03b0d8cc00afe0ed57cf2b79b94f94d451ee450aa27115bae3f70197f8b ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=e6d6906daf990c03c44a842e1184d75d79a4cac657ef65b2518f889738d0c75b ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 diff --git a/agents/hermes/hermes-cli-adapter-v1.json b/agents/hermes/hermes-cli-adapter-v1.json index 67f8486bfb9..032575e13fc 100644 --- a/agents/hermes/hermes-cli-adapter-v1.json +++ b/agents/hermes/hermes-cli-adapter-v1.json @@ -124,7 +124,7 @@ "hermes [--profile=PROFILE] (--resume=SESSION|--continue[=SESSION]) --oneshot=PROMPT [CHAT_OPTION ...]" ], "reason": "Hermes 0.19 stores a top-level resumed or continued one-shot turn in a new session instead of the selected or most recent session.", - "source_fix_constraint": "The affected session persistence implementation is part of the pinned Hermes dependency, so NemoClaw cannot change its upstream source in this repository.", + "source_fix_constraint": "The affected session persistence implementation belongs to Hermes. The durable source fix must land in Hermes before NemoClaw removes this translation.", "removal_condition": "Hermes appends top-level resumed and continued one-shot turns to the selected or most recent session." }, "provider_model": { @@ -139,7 +139,7 @@ ], "ambiguity_rule": "A top-level session name must be one argv value. The adapter rejects separate provider and model flags after an unquoted multi-word session name because a later positional can be an upstream command.", "reason": "Hermes 0.19 does not resolve the OpenShell credential placeholder when provider and model are separate.", - "source_fix_constraint": "The affected credential-resolution implementation is part of the pinned Hermes dependency, so NemoClaw cannot change its upstream source in this repository.", + "source_fix_constraint": "The affected credential-resolution implementation belongs to Hermes. The durable source fix must land in Hermes before NemoClaw removes this translation.", "removal_condition": "Hermes resolves OpenShell credential placeholders for separate provider and model flags." } } From 4249214d826b95e6e6fe0126084d1f70a2653d0f Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 15:53:16 -0400 Subject: [PATCH 06/24] test(hermes): strengthen adapter integrity assertion Signed-off-by: Julie Yaunches --- test/hermes-final-image-layout.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index 5e5537e5c4e..c089b736c6a 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -387,13 +387,15 @@ describe("Hermes final image layout", () => { // source-shape-contract: security -- Adapter bytes must pass their committed integrity gate before the image build executes validator code it("verifies CLI adapter integrity before executing its validator", () => { const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); - const adapterIntegrityGate = dockerfile.indexOf("ERROR: Hermes CLI adapter integrity mismatch"); + const adapterIntegrityGate = dockerfile.match( + /RUN printf '%s %s\\n' \\\n\s+"\$NEMOCLAW_HERMES_WRAPPER_SHA256"[^]*?\| sha256sum -c - \\\n\s+\|\| \{ echo "ERROR: Hermes CLI adapter integrity mismatch" >&2; exit 1; \}/u, + ); const adapterValidation = dockerfile.indexOf( "RUN /opt/hermes/.venv/bin/python -I \\\n /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py \\", ); - expect(adapterIntegrityGate).toBeGreaterThan(-1); - expect(adapterValidation).toBeGreaterThan(adapterIntegrityGate); + expect(adapterIntegrityGate).not.toBeNull(); + expect(adapterValidation).toBeGreaterThan(adapterIntegrityGate?.index ?? -1); }); it("rejects retired OpenClaw state represented as a directory", () => { From b220896d7fd3924089eb0e46d238187b0f2ed8da Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 16:08:18 -0400 Subject: [PATCH 07/24] fix(hermes): classify adapter integrity pins Signed-off-by: Julie Yaunches --- src/lib/onboard/managed-startup/profile.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/onboard/managed-startup/profile.ts b/src/lib/onboard/managed-startup/profile.ts index 66986900c14..05344ead460 100644 --- a/src/lib/onboard/managed-startup/profile.ts +++ b/src/lib/onboard/managed-startup/profile.ts @@ -751,6 +751,8 @@ export const MANAGED_STARTUP_PROFILE_EXCLUDED_DOCKER_INPUTS = { { input: "NEMOCLAW_HERMES_DISCORD_RECOVERY_PATCHER_SHA256", reason: "integrity-pin" }, { input: "NEMOCLAW_HERMES_LANGFUSE_PATCHER_SHA256", reason: "integrity-pin" }, { input: "NEMOCLAW_HERMES_WRAPPER_SHA256", reason: "integrity-pin" }, + { input: "NEMOCLAW_HERMES_CLI_ADAPTER_SHA256", reason: "integrity-pin" }, + { input: "NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256", reason: "integrity-pin" }, { input: "NEMOCLAW_HERMES_VALIDATOR_SHA256", reason: "integrity-pin" }, { input: "NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256", reason: "integrity-pin" }, { input: "NEMOCLAW_BUILD_ID", reason: "build-provenance" }, From d6be55192c9ca3c7e5a6ed1e6b88997bcff99a4e Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 16:26:17 -0400 Subject: [PATCH 08/24] test(hermes): require inference switch E2E Signed-off-by: Julie Yaunches --- test/pr-risk-plan.test.ts | 7 ++++++- tools/advisors/risk-plan.mts | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/test/pr-risk-plan.test.ts b/test/pr-risk-plan.test.ts index c8eb969e9df..7b6315897e7 100644 --- a/test/pr-risk-plan.test.ts +++ b/test/pr-risk-plan.test.ts @@ -500,7 +500,12 @@ describe("deterministic PR risk plan", () => { expect(result.families.map((family) => family.id)).toContain("sandbox-boundary"); expect(riskPlanRequiredJobIds(result)).toEqual( - expect.arrayContaining(["full-e2e", "hermes-e2e", "security-posture"]), + expect.arrayContaining([ + "full-e2e", + "hermes-e2e", + "hermes-inference-switch", + "security-posture", + ]), ); }); diff --git a/tools/advisors/risk-plan.mts b/tools/advisors/risk-plan.mts index 0f514f76ffe..d1997fd621e 100644 --- a/tools/advisors/risk-plan.mts +++ b/tools/advisors/risk-plan.mts @@ -342,9 +342,10 @@ export const RISK_RULES: readonly RiskRule[] = [ summary: "Sandbox blueprint and agent-runtime changes must preserve equivalent isolation and readiness across supported agents.", tier: 3, - requiredJobs: ["full-e2e", "hermes-e2e", "security-posture"], + requiredJobs: ["full-e2e", "hermes-e2e", "hermes-inference-switch", "security-posture"], invariants: [ "OpenClaw and Hermes both reach readiness through the changed sandbox boundary", + "the Hermes runtime preserves provider and model selection across managed inference updates", "the sandbox retains its required security posture and isolation controls", "blueprint state agrees with the runtime observed by both supported agents", ], From 35fa20351dd1815eaf1041a1163a60b5c2840e8f Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 16:28:43 -0400 Subject: [PATCH 09/24] docs(e2e): clarify Hermes route invariant Signed-off-by: Julie Yaunches --- tools/advisors/risk-plan.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/advisors/risk-plan.mts b/tools/advisors/risk-plan.mts index d1997fd621e..272c1073bd2 100644 --- a/tools/advisors/risk-plan.mts +++ b/tools/advisors/risk-plan.mts @@ -345,7 +345,7 @@ export const RISK_RULES: readonly RiskRule[] = [ requiredJobs: ["full-e2e", "hermes-e2e", "hermes-inference-switch", "security-posture"], invariants: [ "OpenClaw and Hermes both reach readiness through the changed sandbox boundary", - "the Hermes runtime preserves provider and model selection across managed inference updates", + "the Hermes runtime preserves provider and model selection across managed inference route changes", "the sandbox retains its required security posture and isolation controls", "blueprint state agrees with the runtime observed by both supported agents", ], From ce2811ab28435d0c2e8fdcd44fef7aa4362abf2b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sat, 1 Aug 2026 04:21:03 -0400 Subject: [PATCH 10/24] fix(hermes): validate preparse adapter arity Signed-off-by: Julie Yaunches --- agents/hermes/Dockerfile | 2 +- agents/hermes/validate-cli-adapter.py | 5 ++ test/hermes-cli-adapter-validator.test.ts | 83 +++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 test/hermes-cli-adapter-validator.test.ts diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 550547fbf09..a657196c93b 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -387,7 +387,7 @@ RUN node --experimental-strip-types \ # Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,hermes-cli-adapter-v1.json,validate-cli-adapter.py,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. ARG NEMOCLAW_HERMES_WRAPPER_SHA256=e7bafd03585d494a5036b2f59e3b3a7a7bee2a6f1ad1f3acd42347132e1d5ce4 ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=dcb9f03b0d8cc00afe0ed57cf2b79b94f94d451ee450aa27115bae3f70197f8b -ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=e6d6906daf990c03c44a842e1184d75d79a4cac657ef65b2518f889738d0c75b +ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=21a3e59712a609acb96a24bc080e338d1497a27c41219596844f6038d377b90c ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 # hadolint ignore=DL4006 diff --git a/agents/hermes/validate-cli-adapter.py b/agents/hermes/validate-cli-adapter.py index e4801b34d82..e5bc2180177 100755 --- a/agents/hermes/validate-cli-adapter.py +++ b/agents/hermes/validate-cli-adapter.py @@ -103,6 +103,11 @@ def validate(contract_path: Path, hermes_binary: str) -> None: option_names.add(name) for surface in option_surfaces: if surface == "preparse": + if arity != "required": + _fail( + f"adapter option {option_id} has arity {arity}, " + "but preparse parser metadata requires a value" + ) missing = sorted(name for name in names if preparse.get(name) is not True) if missing: _fail( diff --git a/test/hermes-cli-adapter-validator.test.ts b/test/hermes-cli-adapter-validator.test.ts new file mode 100644 index 00000000000..cf17473dcc4 --- /dev/null +++ b/test/hermes-cli-adapter-validator.test.ts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const CONTRACT = path.join(ROOT, "agents", "hermes", "hermes-cli-adapter-v1.json"); +const VALIDATOR = path.join(ROOT, "agents", "hermes", "validate-cli-adapter.py"); + +const PARSER_FIXTURE = ` +import argparse + +PRE_ARGPARSE_INHERITED_FLAGS = [("-p", True), ("--profile", True)] + +def _add_shared(parser): + parser.add_argument("-m", "--model") + parser.add_argument("--provider") + parser.add_argument("-t", "--toolsets") + parser.add_argument("-s", "--skills", action="append") + parser.add_argument("-r", "--resume") + parser.add_argument("-c", "--continue", nargs="?") + for name in ( + "--worktree", + "--accept-hooks", + "--yolo", + "--pass-session-id", + "--ignore-user-config", + "--ignore-rules", + "--no-restore-cwd", + "--safe-mode", + ): + parser.add_argument(name, action="store_true") + +def build_top_level_parser(): + top = argparse.ArgumentParser() + top.add_argument("-z", "--oneshot") + top.add_argument("--usage-file") + _add_shared(top) + chat = argparse.ArgumentParser() + _add_shared(chat) + return top, None, chat +`; + +function runValidator(contract: object) { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-adapter-")); + try { + const packageDir = path.join(fixture, "hermes_cli"); + fs.mkdirSync(packageDir); + fs.writeFileSync(path.join(packageDir, "__init__.py"), '__version__ = "0.19.0"\n'); + fs.writeFileSync(path.join(packageDir, "_parser.py"), PARSER_FIXTURE); + const contractPath = path.join(fixture, "adapter.json"); + fs.writeFileSync(contractPath, `${JSON.stringify(contract)}\n`); + const hermes = path.join(fixture, "hermes"); + fs.writeFileSync(hermes, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + + return spawnSync("python3", [VALIDATOR, "--contract", contractPath, "--hermes", hermes], { + encoding: "utf8", + env: { ...process.env, PYTHONPATH: fixture }, + }); + } finally { + fs.rmSync(fixture, { force: true, recursive: true }); + } +} + +describe("Hermes CLI adapter validator", () => { + it("rejects a preparse option whose contract arity does not require a value (#8011)", () => { + const contract = JSON.parse(fs.readFileSync(CONTRACT, "utf8")); + const profile = contract.options.find((option: { id: string }) => option.id === "profile"); + profile.arity = "boolean"; + + const result = runValidator(contract); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "adapter option profile has arity boolean, but preparse parser metadata requires a value", + ); + }); +}); From 2d801f72219e6b716663eff893e6ad912959562e Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sat, 1 Aug 2026 04:38:13 -0400 Subject: [PATCH 11/24] test(hermes): cover chat adapter drift Signed-off-by: Julie Yaunches --- test/hermes-cli-adapter-validator.test.ts | 24 +++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/test/hermes-cli-adapter-validator.test.ts b/test/hermes-cli-adapter-validator.test.ts index cf17473dcc4..2a174544d33 100644 --- a/test/hermes-cli-adapter-validator.test.ts +++ b/test/hermes-cli-adapter-validator.test.ts @@ -17,9 +17,10 @@ import argparse PRE_ARGPARSE_INHERITED_FLAGS = [("-p", True), ("--profile", True)] -def _add_shared(parser): +def _add_shared(parser, include_provider=True): parser.add_argument("-m", "--model") - parser.add_argument("--provider") + if include_provider: + parser.add_argument("--provider") parser.add_argument("-t", "--toolsets") parser.add_argument("-s", "--skills", action="append") parser.add_argument("-r", "--resume") @@ -46,13 +47,13 @@ def build_top_level_parser(): return top, None, chat `; -function runValidator(contract: object) { +function runValidator(contract: object, parserFixture = PARSER_FIXTURE) { const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-adapter-")); try { const packageDir = path.join(fixture, "hermes_cli"); fs.mkdirSync(packageDir); fs.writeFileSync(path.join(packageDir, "__init__.py"), '__version__ = "0.19.0"\n'); - fs.writeFileSync(path.join(packageDir, "_parser.py"), PARSER_FIXTURE); + fs.writeFileSync(path.join(packageDir, "_parser.py"), parserFixture); const contractPath = path.join(fixture, "adapter.json"); fs.writeFileSync(contractPath, `${JSON.stringify(contract)}\n`); const hermes = path.join(fixture, "hermes"); @@ -80,4 +81,19 @@ describe("Hermes CLI adapter validator", () => { "adapter option profile has arity boolean, but preparse parser metadata requires a value", ); }); + + it("rejects a translated option missing from the upstream chat parser (#8011)", () => { + const contract = JSON.parse(fs.readFileSync(CONTRACT, "utf8")); + const parserFixture = PARSER_FIXTURE.replace( + " _add_shared(chat)", + " _add_shared(chat, include_provider=False)", + ); + + const result = runValidator(contract, parserFixture); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "adapter option --provider is absent from the upstream chat parser", + ); + }); }); From b16d2b967cae1961b4d485b9721b992e5c5a1b88 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sat, 1 Aug 2026 04:57:13 -0400 Subject: [PATCH 12/24] fix(hermes): preserve managed command boundaries Signed-off-by: Julie Yaunches --- agents/hermes/Dockerfile | 2 +- agents/hermes/hermes-wrapper.py | 6 +++++- test/hermes-wrapper-oneshot-routing.test.ts | 12 ++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index a657196c93b..ae323b0b3a8 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -385,7 +385,7 @@ RUN node --experimental-strip-types \ # silent supply-chain tampering of the build context (an attacker rewriting a # file has to also rewrite the Dockerfile-committed hash, which reviewers gate). # Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,hermes-cli-adapter-v1.json,validate-cli-adapter.py,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=e7bafd03585d494a5036b2f59e3b3a7a7bee2a6f1ad1f3acd42347132e1d5ce4 +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=e2a8d97a056a1b9e7501bb6468829b15a0f16f26ff4108cff6aa1f7af2a0087b ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=dcb9f03b0d8cc00afe0ed57cf2b79b94f94d451ee450aa27115bae3f70197f8b ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=21a3e59712a609acb96a24bc080e338d1497a27c41219596844f6038d377b90c ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index afd2df968e7..c818240ca56 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -414,7 +414,11 @@ def _parse_managed_invocation(argv: list[str], adapter: dict) -> dict | None: parts.append(argv[cursor]) cursor += 1 if coalesce_session and command is None: - while cursor < len(argv) and not argv[cursor].startswith("-"): + while ( + cursor < len(argv) + and not argv[cursor].startswith("-") + and argv[cursor] not in adapter["managed_commands"] + ): parts.append(argv[cursor]) cursor += 1 if not parts and arity == "session": diff --git a/test/hermes-wrapper-oneshot-routing.test.ts b/test/hermes-wrapper-oneshot-routing.test.ts index ae73e3b3802..067ad2c90b0 100644 --- a/test/hermes-wrapper-oneshot-routing.test.ts +++ b/test/hermes-wrapper-oneshot-routing.test.ts @@ -132,6 +132,18 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py one-shot routing", () ]); }); + it.each([ + "--continue", + "--resume", + ])("passes an explicit managed command after %s through without translating across its boundary (#8011)", (flag) => { + const argv = [flag, "daily", "chat", "--oneshot", "Repeat the latest turn"]; + + const run = runWrapper(argv, {}); + + expect(run.status).toBe(0); + expect(run.realArgv).toEqual(argv); + }); + it.each([ [ ["-p", "work"], From 8102685a89fe2506072757e01e44d954b318243d Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sat, 1 Aug 2026 05:23:17 -0400 Subject: [PATCH 13/24] fix(hermes): preserve routing past atomic options --- agents/hermes/Dockerfile | 2 +- agents/hermes/hermes-wrapper.py | 8 ++++++++ test/hermes-wrapper-provider-merge.test.ts | 23 ++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index ae323b0b3a8..e78347c1b6f 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -385,7 +385,7 @@ RUN node --experimental-strip-types \ # silent supply-chain tampering of the build context (an attacker rewriting a # file has to also rewrite the Dockerfile-committed hash, which reviewers gate). # Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,hermes-cli-adapter-v1.json,validate-cli-adapter.py,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=e2a8d97a056a1b9e7501bb6468829b15a0f16f26ff4108cff6aa1f7af2a0087b +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=4ebaedb5cea2c06ab1fe386db3c73a5399f0eae9cb72150d033f7d411dbe7bd2 ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=dcb9f03b0d8cc00afe0ed57cf2b79b94f94d451ee450aa27115bae3f70197f8b ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=21a3e59712a609acb96a24bc080e338d1497a27c41219596844f6038d377b90c ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index c818240ca56..b28bee8bfdc 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -375,6 +375,7 @@ def _parse_managed_invocation(argv: list[str], adapter: dict) -> dict | None: occurrences: list[dict] = [] occurrence_ids: dict[str, int] = {} command: str | None = None + unknown_option = False terminated = False i = 0 while i < len(argv): @@ -450,6 +451,11 @@ def _parse_managed_invocation(argv: list[str], adapter: dict) -> dict | None: i += 1 continue if arg.startswith("-"): + # Skip only atomic unknown options; a following positional can be their value. + if "=" in arg or i + 1 >= len(argv) or argv[i + 1].startswith("-"): + unknown_option = True + i += 1 + continue return None if arg in adapter["managed_commands"]: command = arg @@ -468,6 +474,7 @@ def _parse_managed_invocation(argv: list[str], adapter: dict) -> dict | None: "command": command, "occurrences": occurrences, "terminated": terminated, + "unknown_option": unknown_option, } @@ -504,6 +511,7 @@ def _translate_resumed_oneshot( or len(resumes) + len(continues) != 1 or parsed["command"] is not None or parsed["terminated"] + or parsed["unknown_option"] ): return None if _occurrences(parsed, "usage_file"): diff --git a/test/hermes-wrapper-provider-merge.test.ts b/test/hermes-wrapper-provider-merge.test.ts index bea397542a4..383e395fb5b 100644 --- a/test/hermes-wrapper-provider-merge.test.ts +++ b/test/hermes-wrapper-provider-merge.test.ts @@ -27,6 +27,29 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py provider/model merge", expect(run.realArgv).toEqual(["-m", "opencode-zen/nemotron-3-ultra-free"]); }); + it("merges after an unknown atomic top-level option (#8011)", () => { + const run = runWrapper( + ["--future-flag", "--provider", "nvidia-prod", "--model", "nvidia/model"], + {}, + ); + + expect(run.status).toBe(0); + expect(run.realArgv).toEqual(["--future-flag", "--model", "nvidia-prod/nvidia/model"]); + }); + + it("passes through when an unknown option may own positional data (#8011)", () => { + const argv = [ + "--future-option", + "future-value", + "--provider", + "nvidia-prod", + "--model", + "nvidia/model", + ]; + + expect(runWrapper(argv, {}).realArgv).toEqual(argv); + }); + it("preserves a namespaced model while merging its separate provider (#7361)", () => { const run = runWrapper( ["--provider", "nvidia-prod", "--model", "nvidia/nemotron-3-super-120b-a12b"], From cb12114a4840d5febdb3b5da71ab443fbbebd2f4 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 04:12:57 -0400 Subject: [PATCH 14/24] docs(hermes): document versioned CLI adapter Signed-off-by: Julie Yaunches --- .../hermes-0.19.0-dependency-review.md | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/security/hermes-0.19.0-dependency-review.md b/docs/security/hermes-0.19.0-dependency-review.md index ea856acc8de..8ba89d86e3e 100644 --- a/docs/security/hermes-0.19.0-dependency-review.md +++ b/docs/security/hermes-0.19.0-dependency-review.md @@ -11,7 +11,8 @@ Pin the NemoClaw Hermes runtime to the published, non-draft, non-prerelease `v20 This replaces `v2026.7.1` and covers all three adjacent stable release ranges, including the four-component `v2026.7.7.2` tag. The upgrade is acceptable only with the downstream migrations recorded in this review. -NemoClaw preserves manual command approval instead of inheriting Hermes 0.19's new smart-approval default, emits configuration schema 33, recognizes the new one-shot flags and `console` subcommand, and backs up the new default-profile cron and Discord recovery SQLite ledgers online. +NemoClaw preserves manual command approval instead of inheriting Hermes 0.19's new smart-approval default and emits configuration schema 33. +It uses a versioned CLI adapter for the two required translations, passes unrelated commands through, and backs up the new default-profile cron and Discord recovery SQLite ledgers online. Named-profile copies remain inside the raw `profiles` directory capture under the existing generic snapshot limitation; this bounded residual is recorded rather than described as online backup. The gateway-runtime-metadata, session-preview, Langfuse-placeholder, managed-light-skin, provider-routing, and resumed-one-shot workarounds remain necessary against the target source and retain exact-shape guards. @@ -89,8 +90,19 @@ The image build creates a real fresh named profile, proves it remains config-les Hermes configuration schema moves from 32 to 33. The final image runs `hermes doctor --fix` before writing NemoClaw's generated configuration, so the generator and its hash contract now emit schema 33 directly. -The target CLI adds the `console` subcommand, the value flag `--usage-file`, and the boolean flag `--no-restore-cwd`; this migration also closes the wrapper's pre-existing `--safe-mode` allowlist gap. Hermes 0.19 writes `--usage-file` reports only on its native one-shot path, so the NemoClaw wrapper rejects the narrower resumed/continued one-shot combination that it must translate to `chat --query`; accepting that combination would silently omit the requested report. -The resumed-one-shot wrapper now recognizes those boundaries, global profile selectors, bare continue, and Hermes' exact preprocessing boundary set for unquoted multi-word session names across all four continue/resume spellings before translating session arguments, and still fails closed for an unknown future shape. +The versioned CLI adapter records two managed command forms: + +- top-level resumed or continued one-shot invocations that NemoClaw translates to `chat --query`; and +- invocations that combine separate provider and model flags. + +All other commands pass through without a duplicate upstream subcommand inventory. +The image build validates each managed option against Hermes' machine-readable preparse, top-level, and `chat` parser metadata. +Public help probes remain runtime evidence for the owned forms, not the compatibility authority. +The wrapper parses each managed invocation once and verifies that the installed CLI is Hermes 0.19.0 before translation. +Hermes 0.19 writes `--usage-file` reports only on its native one-shot path. +The wrapper rejects a resumed or continued one-shot invocation with `--usage-file` because translation would omit the report. +The wrapper also rejects separate provider and model flags after an unquoted multi-word session name because a later positional can be an upstream command. +An invalid adapter, an unknown adapter version, or a Hermes CLI version mismatch fails closed before a translated command runs. The target source still contains all six session-list queries whose preview must reflect the latest resumed or continued one-shot turn. The session-preview patch remains exact-count guarded. @@ -191,7 +203,7 @@ Artifact scanning must therefore inspect the assembled image and record the down | `HERMES-1` | High | Pin and test | The verified target tag, commit, source SHA-256, CalVer-to-semver mapping, registry cross-check, and producer runs are recorded, while final source-pin coherence still needs a test. | | `HERMES-2` | High | Migrate and test | `approvals.mode` is explicitly `manual`, and generated-config tests reject inheritance of smart authorization. | | `HERMES-3` | High | Migrate and test | Generated configuration and the doctor hash contract use schema 33 before runtime startup. | -| `HERMES-4` | High | Migrate and test | Wrapper routing covers `console`, `--no-restore-cwd`, and `--safe-mode`; it preserves profile selectors, bare continue, and unquoted multi-word names against Hermes' exact coalescing boundaries across all four continue/resume spellings, and recognizes and explicitly rejects `--usage-file` only when the resumed one-shot append workaround would otherwise discard the report. The final image compares the wrapper's private session-name boundary AST to the pinned upstream coalescer instead of deriving it from public help. Unknown future versions remain guarded. | +| `HERMES-4` | High | Migrate and test | The versioned adapter owns only top-level resumed or continued one-shot translation and separate provider and model composition. The image build validates its managed options against Hermes' machine-readable preparse, top-level, and `chat` parser metadata; public help remains runtime evidence. The wrapper parses a managed invocation once, rejects the unsupported `--usage-file` combination and ambiguous unquoted multi-word session names, and verifies Hermes 0.19.0 before translation. Unrelated commands pass through without a subcommand inventory. Each translation records its upstream source-fix constraint and removal condition. Invalid adapters, unknown adapter versions, and upstream version mismatches fail closed. | | `HERMES-5` | Medium | Guard and test | Every retained compatibility patch was compared with target source, retargeted, hash-bound, and exercised by a focused regression or image smoke probe. | | `HERMES-6` | High | Migrate, guard, test, and runtime-proof | Default-profile cron and Discord ledgers use online SQLite backup with nested-parent tests. The cron execution ledger is exact-source relocated to `runtime/cron-executions.db`, and Hermes quick snapshots follow the same path. The `cron` directory remains `root:sandbox` mode `0755` during Shields up, and its cron job definitions remain non-writable to the `sandbox` group. Descriptor-safe startup repair maintains only the writable runtime and gateway parents as `gateway:sandbox` `2770`. Gateway-to-sandbox-to-gateway image probes cover both ledgers; cron's live source is group-readable `0640` and its restored replacement is `0660`, while Discord additionally needs its guarded `0660` upstream chmod patch. Managed restart and rebuild persistence remain live E2E gates. | | `HERMES-7` | High | Test and runtime-proof | The target's `mcp__server__tool` names are compatible by source inspection, while managed-tool discovery and invocation remain a live E2E gate. | From f8e577978855e51b4aebc77c7b6b6e1384bf504f Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 13:57:06 -0400 Subject: [PATCH 15/24] test(hermes): complete CLI adapter live contract Signed-off-by: Julie Yaunches --- test/e2e/live/hermes-cli-adapter-live.ts | 227 +++++++++++++++++++++++ test/e2e/live/hermes-e2e.test.ts | 102 +--------- 2 files changed, 233 insertions(+), 96 deletions(-) create mode 100644 test/e2e/live/hermes-cli-adapter-live.ts diff --git a/test/e2e/live/hermes-cli-adapter-live.ts b/test/e2e/live/hermes-cli-adapter-live.ts new file mode 100644 index 00000000000..86e7f8fc2da --- /dev/null +++ b/test/e2e/live/hermes-cli-adapter-live.ts @@ -0,0 +1,227 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { resultText, shellQuote } from "../fixtures/clients/command.ts"; +import { type SandboxClient, trustedSandboxShellScript } from "../fixtures/clients/sandbox.ts"; +import { expect } from "../fixtures/e2e-test.ts"; +import { exportHermesSession, hermesLastActive } from "../fixtures/hermes-session.ts"; + +interface HermesCliAdapterLiveOptions { + env: NodeJS.ProcessEnv; + redactionValues: string[]; + sandbox: SandboxClient; + sandboxName: string; +} + +export function stripAnsi(value: string): string { + return value.replace(/\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g, ""); +} + +export function hermesSessionIds(output: string): Set { + return new Set(output.match(/\b[0-9]{8}_[0-9]{6}_[a-zA-Z0-9]+\b/g) ?? []); +} + +export function onlyNewHermesSessionId(before: Set, after: Set): string { + const created = [...after].filter((id) => !before.has(id)); + expect(created).toHaveLength(1); + return created[0]; +} + +export async function assertHermesCliAdapterLiveContract({ + env, + redactionValues, + sandbox, + sandboxName, +}: HermesCliAdapterLiveOptions): Promise { + const runHermesCli = async (args: string[], artifactName: string, timeoutMs = 6 * 60_000) => { + const result = await sandbox.exec(sandboxName, ["hermes", ...args], { + artifactName, + env, + redactionValues, + timeoutMs, + }); + expect(result.exitCode, resultText(result)).toBe(0); + return resultText(result); + }; + const listDefaultSessionsText = (artifactName: string) => + runHermesCli(["sessions", "list"], artifactName, 60_000); + const listDefaultSessions = async (artifactName: string) => + hermesSessionIds(await listDefaultSessionsText(artifactName)); + const expectNoNewDefaultSessions = async ( + before: Set, + beforeActivityArtifact: string, + expectedSessionId: string, + expectedRowToken: string, + args: string[], + runArtifact: string, + afterArtifact: string, + ) => { + const beforeActivity = await hermesLastActive( + sandbox, + sandboxName, + expectedSessionId, + beforeActivityArtifact, + ); + await runHermesCli(args, runArtifact); + const afterText = await listDefaultSessionsText(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 hermesLastActive(sandbox, sandboxName, expectedSessionId, `${afterArtifact}-metadata`), + ).toBeGreaterThan(beforeActivity); + }; + + const issue5254Marker = `NEMOCLAW_5254_${Date.now()}`; + const beforeSeedSessions = await listDefaultSessions("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 listDefaultSessions("phase-4-issue-5254-sessions-after-seed"), + ); + const resumePrompt = `N5254_${Date.now().toString(36)}_RESUME`; + await expectNoNewDefaultSessions( + await listDefaultSessions("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 expectNoNewDefaultSessions( + await listDefaultSessions("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, + sandboxName, + seedSessionId, + exportPath, + [seedPrompt, resumePrompt, continuePrompt], + { + artifactName: "phase-4-issue-5254-export-session", + env, + redactionValues, + timeoutMs: 60_000, + }, + ); + + const usageFilePath = `/tmp/nemoclaw-cli-adapter-usage-${Date.now()}.json`; + const sessionsBeforeGuardedUsage = await listDefaultSessions( + "phase-4-cli-adapter-sessions-before-guarded-usage", + ); + const guardedUsage = await sandbox.exec( + sandboxName, + [ + "hermes", + "--resume", + seedSessionId, + "-z", + `N8011_${Date.now().toString(36)}_USAGE_GUARD`, + "--usage-file", + usageFilePath, + ], + { + artifactName: "phase-4-cli-adapter-guarded-usage-file", + env, + redactionValues, + timeoutMs: 60_000, + }, + ); + expect(guardedUsage.exitCode, resultText(guardedUsage)).toBe(2); + expect(resultText(guardedUsage)).toContain( + "[COMPATIBILITY] Refusing resumed one-shot with --usage-file", + ); + expect( + [...(await listDefaultSessions("phase-4-cli-adapter-sessions-after-guarded-usage"))].sort(), + ).toEqual([...sessionsBeforeGuardedUsage].sort()); + const guardedUsageFile = await sandbox.execShell( + sandboxName, + trustedSandboxShellScript(`test ! -e ${shellQuote(usageFilePath)}`), + { + artifactName: "phase-4-cli-adapter-guarded-usage-file-absence", + env, + timeoutMs: 30_000, + }, + ); + expect(guardedUsageFile.exitCode, resultText(guardedUsageFile)).toBe(0); + + const profileName = "nemoclaw-cli-adapter-e2e"; + await runHermesCli( + ["profile", "create", profileName], + "phase-4-cli-adapter-create-named-profile", + 60_000, + ); + const profileHome = `/sandbox/.hermes/profiles/${profileName}`; + const prepareProfile = await sandbox.execShell( + sandboxName, + trustedSandboxShellScript( + [ + `test -d ${shellQuote(profileHome)}`, + `install -m 600 /sandbox/.hermes/config.yaml ${shellQuote(`${profileHome}/config.yaml`)}`, + `install -m 600 /sandbox/.hermes/.env ${shellQuote(`${profileHome}/.env`)}`, + ].join(" && "), + ), + { + artifactName: "phase-4-cli-adapter-prepare-named-profile", + env, + timeoutMs: 30_000, + }, + ); + expect(prepareProfile.exitCode, resultText(prepareProfile)).toBe(0); + + const listProfileSessionsText = (artifactName: string) => + runHermesCli(["--profile", profileName, "sessions", "list"], artifactName, 60_000); + const listProfileSessions = async (artifactName: string) => + hermesSessionIds(await listProfileSessionsText(artifactName)); + const profileSessionsBeforeSeed = await listProfileSessions( + "phase-4-cli-adapter-profile-sessions-before-seed", + ); + const profileSeedPrompt = `N8011_${Date.now().toString(36)}_PROFILE_SEED`; + await runHermesCli( + ["--profile", profileName, "-z", profileSeedPrompt], + "phase-4-cli-adapter-profile-seed-oneshot", + ); + const profileSessionId = onlyNewHermesSessionId( + profileSessionsBeforeSeed, + await listProfileSessions("phase-4-cli-adapter-profile-sessions-after-seed"), + ); + const profileSessionsBeforeContinue = await listProfileSessions( + "phase-4-cli-adapter-profile-sessions-before-continue", + ); + const profileContinuePrompt = `N8011_${Date.now().toString(36)}_PROFILE_CONTINUE`; + await runHermesCli( + ["--profile", profileName, "-c", "-z", profileContinuePrompt], + "phase-4-cli-adapter-profile-continue-oneshot", + ); + const profileSessionsAfterContinueText = await listProfileSessionsText( + "phase-4-cli-adapter-profile-sessions-after-continue", + ); + const profileSessionsAfterContinue = hermesSessionIds(profileSessionsAfterContinueText); + expect( + [...profileSessionsAfterContinue].filter((id) => !profileSessionsBeforeContinue.has(id)), + ).toEqual([]); + expect( + profileSessionsAfterContinue.has(profileSessionId), + stripAnsi(profileSessionsAfterContinueText), + ).toBe(true); + const profileSessionRow = stripAnsi(profileSessionsAfterContinueText) + .split("\n") + .find((line) => line.includes(profileSessionId)); + expect(profileSessionRow, stripAnsi(profileSessionsAfterContinueText)).toContain( + profileContinuePrompt, + ); +} diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index 2d69a1e76c2..efc256663ff 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -11,7 +11,6 @@ import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { resultText, shellQuote } from "../fixtures/clients/command.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 { REPO_ROOT } from "../fixtures/paths.ts"; import { assertSecurityPosture, @@ -19,6 +18,7 @@ import { securityPostureModeEnv, } from "../fixtures/security-posture.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; +import { assertHermesCliAdapterLiveContract, stripAnsi } from "./hermes-cli-adapter-live.ts"; import { HERMES_E2E_PHASES } from "./hermes-e2e-phases.ts"; const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-hermes"; @@ -163,20 +163,6 @@ function httpStatusOk(status: string): boolean { return /^[23][0-9][0-9]$/.test(status.trim()); } -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") @@ -484,88 +470,12 @@ test("hermes-e2e: install.sh onboards Hermes and proves health plus live inferen 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( + await assertHermesCliAdapterLiveContract({ + env: commandEnv(), + redactionValues, sandbox, - SANDBOX_NAME, - seedSessionId, - exportPath, - [seedPrompt, resumePrompt, continuePrompt], - { - artifactName: "phase-4-issue-5254-export-session", - env: commandEnv(), - redactionValues, - timeoutMs: 60_000, - }, - ); + sandboxName: SANDBOX_NAME, + }); if (hermesDashboardE2eEnabled()) { const entry = registryEntry(SANDBOX_NAME); From ccbf1934aa1ada81fb36d7423f04d8ad8b9198d1 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 14:47:12 -0400 Subject: [PATCH 16/24] test(hermes): broaden inference switch adapter coverage Signed-off-by: Julie Yaunches --- test/e2e/live/hermes-inference-switch.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/e2e/live/hermes-inference-switch.test.ts b/test/e2e/live/hermes-inference-switch.test.ts index 55725335096..e41b7f1f8df 100644 --- a/test/e2e/live/hermes-inference-switch.test.ts +++ b/test/e2e/live/hermes-inference-switch.test.ts @@ -73,7 +73,7 @@ test("Hermes inference set updates route/config and preserves live runtime", { "switch Hermes inference provider", "validate switched route and locked config", "exercise inference.local and Hermes API", - "run Hermes CLI against switched provider", + "run Hermes CLI adapter forms against switched provider", "prove split provider/model credential resolution", ], }, @@ -81,7 +81,7 @@ test("Hermes inference set updates route/config and preserves live runtime", { await artifacts.target.declare({ id: "hermes-inference-switch", boundary: - "install.sh + Hermes sandbox + inference set + in-sandbox health/chat + hermes -z probes", + "install.sh + Hermes sandbox + inference set + in-sandbox health/chat + managed Hermes CLI probes", sandboxName: SANDBOX_NAME, switchProvider: SWITCH_PROVIDER, switchModel: SWITCH_MODEL, @@ -382,7 +382,7 @@ test("Hermes inference set updates route/config and preserves live runtime", { expect(chatContent(chat.stdout)).toMatch(/PONG/i); expect(inferenceResponseModel(chat.stdout)).toBe(SWITCH_MODEL); - progress.phase("run Hermes CLI against switched provider"); + progress.phase("run Hermes CLI adapter forms against switched provider"); const hermesCli = await runHermesCliPongWithRetry({ run: (attempt) => sandbox.exec( @@ -433,15 +433,17 @@ test("Hermes inference set updates route/config and preserves live runtime", { SANDBOX_NAME, [ "hermes", - "-z", + "chat", + "--query", "Reply with exactly one word: PONG", + "--quiet", "--provider", PROXY_RESOLUTION_PROVIDER, "--model", proxyResolutionModel, ], { - artifactName: `hermes-cli-split-provider-namespaced-model-proxy-resolution-${attempt}`, + artifactName: `hermes-cli-chat-split-provider-namespaced-model-proxy-resolution-${attempt}`, env: env(), redactionValues, timeoutMs: 150_000, From 9413d4bfc479b53d2d5009c64ed126236c04a1a8 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 15:12:19 -0400 Subject: [PATCH 17/24] fix(hermes): preserve upstream session boundaries Signed-off-by: Julie Yaunches --- .../references/hermes-contract-map.md | 2 + agents/hermes/Dockerfile | 6 +- agents/hermes/hermes-cli-adapter-v1.json | 5 ++ agents/hermes/hermes-wrapper.py | 75 +++++++++++++++++-- agents/hermes/validate-cli-adapter.py | 49 ++++++++++++ .../hermes-0.19.0-dependency-review.md | 2 +- test/helpers/hermes-wrapper-harness.ts | 11 +++ test/hermes-cli-adapter-validator.test.ts | 33 +++++++- test/hermes-dependency-review.test.ts | 5 ++ test/hermes-wrapper-oneshot-routing.test.ts | 30 ++++++++ test/hermes-wrapper-provider-merge.test.ts | 27 +++---- 11 files changed, 216 insertions(+), 29 deletions(-) diff --git a/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md b/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md index 98da20fb60a..79ab9a62520 100644 --- a/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md +++ b/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md @@ -89,6 +89,8 @@ Review the top-level and `chat` parser metadata in the target source. Update `hermes-cli-adapter-v1.json` only for a managed translation form. Do not add an upstream subcommand to the adapter. `validate-cli-adapter.py` compares the contract with Hermes' machine-readable parser metadata. +The wrapper reads session-name command boundaries from the installed upstream coalescer source. +Do not copy that boundary set into the adapter or wrapper. The top-level and `chat` help probes are runtime evidence and are not the compatibility authority. Hermes 0.19 defines `-c/--continue` with an optional session value. diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 5f1ec0fcacb..8807086ec29 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -403,9 +403,9 @@ RUN node --experimental-strip-types \ # silent supply-chain tampering of the build context (an attacker rewriting a # file has to also rewrite the Dockerfile-committed hash, which reviewers gate). # Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,hermes-cli-adapter-v1.json,validate-cli-adapter.py,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=4ebaedb5cea2c06ab1fe386db3c73a5399f0eae9cb72150d033f7d411dbe7bd2 -ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=dcb9f03b0d8cc00afe0ed57cf2b79b94f94d451ee450aa27115bae3f70197f8b -ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=21a3e59712a609acb96a24bc080e338d1497a27c41219596844f6038d377b90c +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=263a802ac2b5b75c41b034a554eb7ab13ba2dfc796c2f822bd4fa056c07a2b85 +ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=9479c7efaadeb7733fdc82a2fb83e03f066688579728a041bb05b26487e27a6b +ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=31a6db76e840abb0095e8059de9dacd733d8f9363031edace3a50af8f285691a ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 # hadolint ignore=DL4006 diff --git a/agents/hermes/hermes-cli-adapter-v1.json b/agents/hermes/hermes-cli-adapter-v1.json index 032575e13fc..7c304c16c53 100644 --- a/agents/hermes/hermes-cli-adapter-v1.json +++ b/agents/hermes/hermes-cli-adapter-v1.json @@ -2,6 +2,11 @@ "adapter_version": 1, "upstream_cli_version": "0.19.0", "managed_commands": ["chat"], + "session_name_coalescer": { + "module": "hermes_cli.main", + "function": "_coalesce_session_name_args", + "boundary_set": "_SUBCOMMANDS" + }, "options": [ { "id": "profile", diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index b28bee8bfdc..54466d7c3d3 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -53,8 +53,9 @@ # `hermes-cli-adapter-v1.json` owns the exact translated command forms, their # upstream version, rationale, and removal conditions. The image build validates # that contract against Hermes' machine-readable top-level and chat parser -# metadata. The wrapper parses a managed invocation once from that contract and -# passes all unrelated commands to Hermes without a subcommand inventory. +# metadata. The wrapper reads session-name command boundaries from Hermes' +# installed coalescer source, parses a managed invocation once from the contract, +# and passes all unrelated commands through without a copied subcommand inventory. # # Scope of the masker: structured key-labelled secret fields (api_key, # api_secret, access_token, auth_token, client_secret, secret_key, secret, @@ -78,6 +79,7 @@ # Only a small set of top-level commands are intercepted; all other hermes # subcommands (dashboard, --version, ...) pass straight through unchanged. +import ast import json import os import re @@ -88,12 +90,14 @@ _INSTALLED_REAL = "/usr/local/bin/hermes.real" _INSTALLED_GUARD = "/usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py" _INSTALLED_CLI_ADAPTER = "/usr/local/share/nemoclaw/hermes-cli-adapter-v1.json" +_INSTALLED_HERMES_MAIN = "/opt/hermes/hermes_cli/main.py" # The Dockerfile installs the validator under the hermes-prefixed name even # though the repository source stays at `validate-env-secret-boundary.py`. # Mirror the same dev-fallback `start.sh` uses so an ad-hoc bash invocation # over a checkout still finds the guard. _GUARD_DEV_FILENAME = "validate-env-secret-boundary.py" _CLI_ADAPTER_DEV_FILENAME = "hermes-cli-adapter-v1.json" +_HERMES_MAIN_DEV_FILENAME = "hermes-main.py" # Trusted absolute paths for the python3 interpreter, ordered most-preferred # first. The resolver returns the first executable match (first-wins); the # same priority is mirrored by `agents/hermes/start.sh:resolve_trusted_python3` @@ -130,6 +134,12 @@ def _resolve_cli_adapter() -> str: return os.path.join(_self_dir(), _CLI_ADAPTER_DEV_FILENAME) +def _resolve_hermes_main() -> str: + if os.path.isfile(_INSTALLED_HERMES_MAIN): + return _INSTALLED_HERMES_MAIN + return os.path.join(_self_dir(), _HERMES_MAIN_DEV_FILENAME) + + def _resolve_trusted_python3() -> str | None: for candidate in _TRUSTED_PYTHON3: if os.access(candidate, os.X_OK): @@ -279,6 +289,11 @@ def _run_gateway_guard(guard_path: str) -> int: re.MULTILINE, ) _CLI_ADAPTER_ARITIES = {"boolean", "optional_session", "required", "session"} +_SESSION_NAME_COALESCER = { + "module": "hermes_cli.main", + "function": "_coalesce_session_name_args", + "boundary_set": "_SUBCOMMANDS", +} class _CliAdapterError(Exception): @@ -308,6 +323,8 @@ def _load_cli_adapter(path: str) -> dict: raise _CliAdapterError("Hermes CLI adapter has no upstream version") if adapter.get("managed_commands") != ["chat"]: raise _CliAdapterError("Hermes CLI adapter has unsupported managed commands") + if adapter.get("session_name_coalescer") != _SESSION_NAME_COALESCER: + raise _CliAdapterError("Hermes CLI adapter has an unsupported session-name coalescer") options = adapter.get("options") if not isinstance(options, list) or not options: @@ -349,6 +366,47 @@ def _load_cli_adapter(path: str) -> dict: return adapter +def _session_name_boundaries(adapter: dict) -> frozenset[str]: + coalescer = adapter["session_name_coalescer"] + source_path = _resolve_hermes_main() + try: + with open(source_path, encoding="utf-8") as source_file: + tree = ast.parse(source_file.read(), filename=source_path) + except (OSError, UnicodeError, SyntaxError) as exc: + raise _CliAdapterError( + f"could not read the Hermes session-name coalescer ({exc.__class__.__name__})" + ) from None + + functions = [ + node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == coalescer["function"] + ] + if len(functions) != 1: + raise _CliAdapterError("Hermes session-name coalescer function is incompatible") + assignments = [ + node + for node in functions[0].body + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and node.targets[0].id == coalescer["boundary_set"] + ] + if len(assignments) != 1: + raise _CliAdapterError("Hermes session-name coalescer boundary set is incompatible") + try: + boundaries = ast.literal_eval(assignments[0].value) + except (ValueError, TypeError, SyntaxError): + raise _CliAdapterError("Hermes session-name coalescer boundary set is not literal") from None + if ( + not isinstance(boundaries, set) + or not boundaries + or not all(isinstance(boundary, str) and boundary for boundary in boundaries) + ): + raise _CliAdapterError("Hermes session-name coalescer boundary set is invalid") + return frozenset(boundaries) + + def _option_index(adapter: dict) -> tuple[dict[str, dict], dict[str, dict]]: by_name: dict[str, dict] = {} by_id: dict[str, dict] = {} @@ -375,6 +433,7 @@ def _parse_managed_invocation(argv: list[str], adapter: dict) -> dict | None: occurrences: list[dict] = [] occurrence_ids: dict[str, int] = {} command: str | None = None + session_boundaries: frozenset[str] | None = None unknown_option = False terminated = False i = 0 @@ -412,13 +471,15 @@ def _parse_managed_invocation(argv: list[str], adapter: dict) -> dict | None: if cursor < len(argv) and not argv[cursor]: return None if cursor < len(argv) and not argv[cursor].startswith("-"): - parts.append(argv[cursor]) - cursor += 1 - if coalesce_session and command is None: + session_boundaries = session_boundaries or _session_name_boundaries(adapter) + if argv[cursor] not in session_boundaries: + parts.append(argv[cursor]) + cursor += 1 + if parts and coalesce_session and command is None: while ( cursor < len(argv) and not argv[cursor].startswith("-") - and argv[cursor] not in adapter["managed_commands"] + and argv[cursor] not in session_boundaries ): parts.append(argv[cursor]) cursor += 1 @@ -461,6 +522,8 @@ def _parse_managed_invocation(argv: list[str], adapter: dict) -> dict | None: command = arg i += 1 continue + if session_boundaries is not None and arg in session_boundaries: + return None if ( occurrence_ids.get("continue", 0) + occurrence_ids.get("resume", 0) == 1 and _has_option(argv, by_id["provider"]) diff --git a/agents/hermes/validate-cli-adapter.py b/agents/hermes/validate-cli-adapter.py index e5bc2180177..5773cc9fe48 100755 --- a/agents/hermes/validate-cli-adapter.py +++ b/agents/hermes/validate-cli-adapter.py @@ -5,6 +5,7 @@ """Validate the NemoClaw Hermes CLI adapter against upstream parser metadata.""" import argparse +import ast import json import subprocess import sys @@ -12,6 +13,11 @@ _ADAPTER_VERSION = 1 _ALLOWED_ARITIES = {"boolean", "optional_session", "required", "session"} +_SESSION_NAME_COALESCER = { + "module": "hermes_cli.main", + "function": "_coalesce_session_name_args", + "boundary_set": "_SUBCOMMANDS", +} def _fail(message: str) -> None: @@ -42,6 +48,44 @@ def _validate_action(option: dict, action: object, surface: str) -> None: ) +def _validate_session_name_coalescer(contract: dict, package_path: Path) -> None: + coalescer = contract.get("session_name_coalescer") + if coalescer != _SESSION_NAME_COALESCER: + _fail("Hermes CLI adapter has an unsupported session-name coalescer") + source_path = package_path.with_name("main.py") + try: + tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path)) + except (OSError, UnicodeError, SyntaxError) as exc: + _fail(f"could not read the Hermes session-name coalescer ({exc.__class__.__name__})") + functions = [ + node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == coalescer["function"] + ] + if len(functions) != 1: + _fail("Hermes session-name coalescer function is incompatible") + assignments = [ + node + for node in functions[0].body + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and node.targets[0].id == coalescer["boundary_set"] + ] + if len(assignments) != 1: + _fail("Hermes session-name coalescer boundary set is incompatible") + try: + boundaries = ast.literal_eval(assignments[0].value) + except (ValueError, TypeError, SyntaxError): + _fail("Hermes session-name coalescer boundary set is not literal") + if ( + not isinstance(boundaries, set) + or not boundaries + or not all(isinstance(boundary, str) and boundary for boundary in boundaries) + ): + _fail("Hermes session-name coalescer boundary set is invalid") + + def validate(contract_path: Path, hermes_binary: str) -> None: try: contract = json.loads(contract_path.read_text(encoding="utf-8")) @@ -53,6 +97,7 @@ def validate(contract_path: Path, hermes_binary: str) -> None: if contract.get("managed_commands") != ["chat"]: _fail("Hermes CLI adapter has unsupported managed commands") + from hermes_cli import __file__ as upstream_package_path from hermes_cli import __version__ as upstream_version from hermes_cli._parser import PRE_ARGPARSE_INHERITED_FLAGS, build_top_level_parser @@ -62,6 +107,10 @@ def validate(contract_path: Path, hermes_binary: str) -> None: f"{contract.get('upstream_cli_version')!r}, installed Hermes is {upstream_version!r}" ) + if not upstream_package_path: + _fail("could not locate the installed Hermes CLI package") + _validate_session_name_coalescer(contract, Path(upstream_package_path)) + parser, _subparsers, chat_parser = build_top_level_parser() surfaces = { "top": _parser_actions(parser), diff --git a/docs/security/hermes-0.19.0-dependency-review.md b/docs/security/hermes-0.19.0-dependency-review.md index 8ba89d86e3e..58c907c66c3 100644 --- a/docs/security/hermes-0.19.0-dependency-review.md +++ b/docs/security/hermes-0.19.0-dependency-review.md @@ -203,7 +203,7 @@ Artifact scanning must therefore inspect the assembled image and record the down | `HERMES-1` | High | Pin and test | The verified target tag, commit, source SHA-256, CalVer-to-semver mapping, registry cross-check, and producer runs are recorded, while final source-pin coherence still needs a test. | | `HERMES-2` | High | Migrate and test | `approvals.mode` is explicitly `manual`, and generated-config tests reject inheritance of smart authorization. | | `HERMES-3` | High | Migrate and test | Generated configuration and the doctor hash contract use schema 33 before runtime startup. | -| `HERMES-4` | High | Migrate and test | The versioned adapter owns only top-level resumed or continued one-shot translation and separate provider and model composition. The image build validates its managed options against Hermes' machine-readable preparse, top-level, and `chat` parser metadata; public help remains runtime evidence. The wrapper parses a managed invocation once, rejects the unsupported `--usage-file` combination and ambiguous unquoted multi-word session names, and verifies Hermes 0.19.0 before translation. Unrelated commands pass through without a subcommand inventory. Each translation records its upstream source-fix constraint and removal condition. Invalid adapters, unknown adapter versions, and upstream version mismatches fail closed. | +| `HERMES-4` | High | Migrate and test | The versioned adapter owns only top-level resumed or continued one-shot translation and separate provider and model composition. The image build validates its managed options against Hermes' machine-readable preparse, top-level, and `chat` parser metadata; public help remains runtime evidence. The wrapper reads session-name command boundaries from Hermes' installed coalescer source instead of copying its private set, parses a managed invocation once, rejects the unsupported `--usage-file` combination and ambiguous unquoted multi-word session names, and verifies Hermes 0.19.0 before translation. Unrelated commands pass through without a subcommand inventory. Each translation records its upstream source-fix constraint and removal condition. Invalid adapters, unknown adapter versions, incompatible upstream coalescer source shapes, and upstream version mismatches fail closed. | | `HERMES-5` | Medium | Guard and test | Every retained compatibility patch was compared with target source, retargeted, hash-bound, and exercised by a focused regression or image smoke probe. | | `HERMES-6` | High | Migrate, guard, test, and runtime-proof | Default-profile cron and Discord ledgers use online SQLite backup with nested-parent tests. The cron execution ledger is exact-source relocated to `runtime/cron-executions.db`, and Hermes quick snapshots follow the same path. The `cron` directory remains `root:sandbox` mode `0755` during Shields up, and its cron job definitions remain non-writable to the `sandbox` group. Descriptor-safe startup repair maintains only the writable runtime and gateway parents as `gateway:sandbox` `2770`. Gateway-to-sandbox-to-gateway image probes cover both ledgers; cron's live source is group-readable `0640` and its restored replacement is `0660`, while Discord additionally needs its guarded `0660` upstream chmod patch. Managed restart and rebuild persistence remain live E2E gates. | | `HERMES-7` | High | Test and runtime-proof | The target's `mcp__server__tool` names are compatible by source inspection, while managed-tool discovery and invocation remain a live E2E gate. | diff --git a/test/helpers/hermes-wrapper-harness.ts b/test/helpers/hermes-wrapper-harness.ts index 3de85a00b1c..fdcc81942e9 100644 --- a/test/helpers/hermes-wrapper-harness.ts +++ b/test/helpers/hermes-wrapper-harness.ts @@ -69,6 +69,7 @@ export function runWrapper( stub?: StubBehaviour; stubMode?: number; adapter?: object; + sessionBoundaries?: string[]; upstreamVersion?: string; validatorScript?: string; } = {}, @@ -80,6 +81,16 @@ export function runWrapper( ? `${JSON.stringify(opts.adapter, null, 2)}\n` : fs.readFileSync(ADAPTER, "utf-8"); fs.writeFileSync(path.join(dir, "hermes-cli-adapter-v1.json"), adapterContent); + const sessionBoundaries = opts.sessionBoundaries ?? ["chat", "gateway", "sessions"]; + fs.writeFileSync( + path.join(dir, "hermes-main.py"), + [ + "def _coalesce_session_name_args(argv):", + ` _SUBCOMMANDS = {${sessionBoundaries.map((value) => JSON.stringify(value)).join(", ")}}`, + " return argv", + "", + ].join("\n"), + ); const validatorContent = opts.validatorScript ?? fs.readFileSync(VALIDATOR, "utf-8"); // Source-layout filename lets the wrapper's dev fallback pick it up. fs.writeFileSync(path.join(dir, "validate-env-secret-boundary.py"), validatorContent, { diff --git a/test/hermes-cli-adapter-validator.test.ts b/test/hermes-cli-adapter-validator.test.ts index 2a174544d33..3cca39bb96c 100644 --- a/test/hermes-cli-adapter-validator.test.ts +++ b/test/hermes-cli-adapter-validator.test.ts @@ -25,8 +25,8 @@ def _add_shared(parser, include_provider=True): parser.add_argument("-s", "--skills", action="append") parser.add_argument("-r", "--resume") parser.add_argument("-c", "--continue", nargs="?") + parser.add_argument("-w", "--worktree", action="store_true") for name in ( - "--worktree", "--accept-hooks", "--yolo", "--pass-session-id", @@ -47,13 +47,24 @@ def build_top_level_parser(): return top, None, chat `; -function runValidator(contract: object, parserFixture = PARSER_FIXTURE) { +const MAIN_FIXTURE = ` +def _coalesce_session_name_args(argv): + _SUBCOMMANDS = {"chat", "gateway", "sessions"} + return argv +`; + +function runValidator( + contract: object, + parserFixture = PARSER_FIXTURE, + mainFixture = MAIN_FIXTURE, +) { const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-adapter-")); try { const packageDir = path.join(fixture, "hermes_cli"); fs.mkdirSync(packageDir); fs.writeFileSync(path.join(packageDir, "__init__.py"), '__version__ = "0.19.0"\n'); fs.writeFileSync(path.join(packageDir, "_parser.py"), parserFixture); + fs.writeFileSync(path.join(packageDir, "main.py"), mainFixture); const contractPath = path.join(fixture, "adapter.json"); fs.writeFileSync(contractPath, `${JSON.stringify(contract)}\n`); const hermes = path.join(fixture, "hermes"); @@ -69,6 +80,24 @@ function runValidator(contract: object, parserFixture = PARSER_FIXTURE) { } describe("Hermes CLI adapter validator", () => { + it("accepts the upstream session-name coalescer as its boundary authority (#8011)", () => { + const contract = JSON.parse(fs.readFileSync(CONTRACT, "utf8")); + + const result = runValidator(contract); + + expect(result.status, result.stderr).toBe(0); + }); + + it("rejects an upstream session-name coalescer without its literal boundary set (#8011)", () => { + const contract = JSON.parse(fs.readFileSync(CONTRACT, "utf8")); + const mainFixture = MAIN_FIXTURE.replace("_SUBCOMMANDS", "_OTHER_BOUNDARIES"); + + const result = runValidator(contract, PARSER_FIXTURE, mainFixture); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("session-name coalescer boundary set is incompatible"); + }); + it("rejects a preparse option whose contract arity does not require a value (#8011)", () => { const contract = JSON.parse(fs.readFileSync(CONTRACT, "utf8")); const profile = contract.options.find((option: { id: string }) => option.id === "profile"); diff --git a/test/hermes-dependency-review.test.ts b/test/hermes-dependency-review.test.ts index 95d0bca855c..0accce2a83a 100644 --- a/test/hermes-dependency-review.test.ts +++ b/test/hermes-dependency-review.test.ts @@ -102,6 +102,11 @@ describe("Hermes 0.19.0 dependency review", () => { expect(cliAdapter.adapter_version).toBe(1); expect(cliAdapter.upstream_cli_version).toBe("0.19.0"); expect(cliAdapter.managed_commands).toEqual(["chat"]); + expect(cliAdapter.session_name_coalescer).toEqual({ + module: "hermes_cli.main", + function: "_coalesce_session_name_args", + boundary_set: "_SUBCOMMANDS", + }); expect( ( Object.values(cliAdapter.translations) as Array<{ diff --git a/test/hermes-wrapper-oneshot-routing.test.ts b/test/hermes-wrapper-oneshot-routing.test.ts index 067ad2c90b0..70fefdbe520 100644 --- a/test/hermes-wrapper-oneshot-routing.test.ts +++ b/test/hermes-wrapper-oneshot-routing.test.ts @@ -132,6 +132,36 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py one-shot routing", () ]); }); + it("passes an upstream session boundary through without translating across it (#8011)", () => { + const argv = ["--continue", "daily", "gateway", "run", "-z", "Repeat the latest turn"]; + const run = runWrapper(argv, {}); + + expect(run.status).toBe(0); + expect(run.realArgv).toEqual(argv); + }); + + it("passes a new upstream session boundary through without an adapter update (#8011)", () => { + const argv = ["--continue", "daily", "future-command", "-z", "Repeat the latest turn"]; + const run = runWrapper(argv, {}, { sessionBoundaries: ["chat", "future-command"] }); + + expect(run.status).toBe(0); + expect(run.realArgv).toEqual(argv); + }); + + it("rejects an invalid upstream session boundary source before invoking Hermes (#8011)", () => { + const run = runWrapper( + ["--continue", "daily", "-z", "Repeat the latest turn"], + {}, + { + sessionBoundaries: [], + }, + ); + + expect(run.status).toBe(2); + expect(run.realInvoked).toBe(false); + expect(run.stderr).toContain("session-name coalescer boundary set is invalid"); + }); + it.each([ "--continue", "--resume", diff --git a/test/hermes-wrapper-provider-merge.test.ts b/test/hermes-wrapper-provider-merge.test.ts index 383e395fb5b..6971eeb7e02 100644 --- a/test/hermes-wrapper-provider-merge.test.ts +++ b/test/hermes-wrapper-provider-merge.test.ts @@ -136,26 +136,19 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py provider/model merge", ]); }); - it("treats a subcommand-looking first continue value as a session name (#7361)", () => { - const run = runWrapper( - [ - "-c", - "sessions", - "--provider", - "nvidia-prod", - "--model", - "nvidia/nemotron-3-super-120b-a12b", - ], - {}, - ); - - expect(run.status).toBe(0); - expect(run.realArgv).toEqual([ + it("passes an upstream session boundary through without merging provider/model (#8011)", () => { + const argv = [ "-c", "sessions", + "--provider", + "nvidia-prod", "--model", - "nvidia-prod/nvidia/nemotron-3-super-120b-a12b", - ]); + "nvidia/nemotron-3-super-120b-a12b", + ]; + const run = runWrapper(argv, {}); + + expect(run.status).toBe(0); + expect(run.realArgv).toEqual(argv); }); it("rejects an ambiguous unquoted multi-word resume form (#8011)", () => { From 1bf74ec3ddf85f86bdf9e87cd8b0c60e532ef8cc Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 15:33:22 -0400 Subject: [PATCH 18/24] refactor(hermes): clarify provider model composition Signed-off-by: Julie Yaunches --- .../references/hermes-contract-map.md | 2 ++ agents/hermes/Dockerfile | 6 ++--- agents/hermes/hermes-cli-adapter-v1.json | 2 +- agents/hermes/hermes-wrapper.py | 26 ++++++++++--------- agents/hermes/validate-cli-adapter.py | 2 +- test/hermes-dependency-review.test.ts | 4 +++ 6 files changed, 25 insertions(+), 17 deletions(-) diff --git a/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md b/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md index 79ab9a62520..2ef7cf39020 100644 --- a/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md +++ b/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md @@ -99,6 +99,8 @@ The adapter owns the resumed one-shot forms that require translation, including session names before the one-shot option. Test bare, quoted, and unquoted forms plus global profile selectors. Provider and model composition accepts a session name as one argument. +The `provider_model_composition` key names this managed translation, not the +`NEMOCLAW_PROVIDER_MODEL` environment value. The adapter rejects an unquoted multi-word session plus provider and model flags before Hermes runs because a later positional can be an upstream command. Quote the session name to make it one argument. diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 8807086ec29..2ced2c9585c 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -403,9 +403,9 @@ RUN node --experimental-strip-types \ # silent supply-chain tampering of the build context (an attacker rewriting a # file has to also rewrite the Dockerfile-committed hash, which reviewers gate). # Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,hermes-cli-adapter-v1.json,validate-cli-adapter.py,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=263a802ac2b5b75c41b034a554eb7ab13ba2dfc796c2f822bd4fa056c07a2b85 -ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=9479c7efaadeb7733fdc82a2fb83e03f066688579728a041bb05b26487e27a6b -ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=31a6db76e840abb0095e8059de9dacd733d8f9363031edace3a50af8f285691a +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=b9ec0c5eee7d91c8b041515530d3b4a9eb36765d6b01a779ebdfef39bf2e8e28 +ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=989edf54a8c09c6efb348600a8aa2f264c0b71408eb9d7bcd579b92cbeccf9b1 +ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=db4046e79e513eab67b069a8eda20167b8b65529cf26842531d2ad673c670330 ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 # hadolint ignore=DL4006 diff --git a/agents/hermes/hermes-cli-adapter-v1.json b/agents/hermes/hermes-cli-adapter-v1.json index 7c304c16c53..eb5eb0c7546 100644 --- a/agents/hermes/hermes-cli-adapter-v1.json +++ b/agents/hermes/hermes-cli-adapter-v1.json @@ -132,7 +132,7 @@ "source_fix_constraint": "The affected session persistence implementation belongs to Hermes. The durable source fix must land in Hermes before NemoClaw removes this translation.", "removal_condition": "Hermes appends top-level resumed and continued one-shot turns to the selected or most recent session." }, - "provider_model": { + "provider_model_composition": { "issue": 7361, "forms": [ "PROVIDER_OPTION := --provider PROVIDER | --provider=PROVIDER", diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index 54466d7c3d3..23d73487d28 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -359,7 +359,7 @@ def _load_cli_adapter(path: str) -> dict: raise _CliAdapterError("Hermes CLI adapter is missing a managed translation option") translations = adapter.get("translations") if not isinstance(translations, dict) or set(translations) != { - "provider_model", + "provider_model_composition", "resumed_oneshot", }: raise _CliAdapterError("Hermes CLI adapter has invalid translation metadata") @@ -550,7 +550,7 @@ def _merged_model(provider: str, model: str) -> str: return model if model.casefold().startswith(prefix.casefold()) else f"{provider}/{model}" -def _provider_model_values(parsed: dict) -> tuple[dict, dict, str] | None: +def _provider_model_composition(parsed: dict) -> tuple[dict, dict, str] | None: providers = _occurrences(parsed, "provider") models = _occurrences(parsed, "model") if len(providers) != 1 or len(models) != 1: @@ -564,7 +564,7 @@ def _provider_model_values(parsed: dict) -> tuple[dict, dict, str] | None: def _translate_resumed_oneshot( parsed: dict, - provider_model: tuple[dict, dict, str] | None, + composition: tuple[dict, dict, str] | None, ) -> list[str] | None: oneshots = _occurrences(parsed, "oneshot") resumes = _occurrences(parsed, "resume") @@ -591,9 +591,9 @@ def _translate_resumed_oneshot( if session["value"] is not None: translated.append(session["value"]) - provider_occurrence = provider_model[0] if provider_model else None - model_occurrence = provider_model[1] if provider_model else None - merged_model = provider_model[2] if provider_model else None + provider_occurrence = composition[0] if composition else None + model_occurrence = composition[1] if composition else None + merged_model = composition[2] if composition else None excluded = {"continue", "oneshot", "profile", "resume", "usage_file"} for occurrence in parsed["occurrences"]: if occurrence["id"] in excluded or occurrence is provider_occurrence: @@ -615,8 +615,10 @@ class _AmbiguousProviderModelSession(Exception): """Signal provider/model flags after an unquoted multi-word session name.""" -def _merge_provider_model(parsed: dict, provider_model: tuple[dict, dict, str]) -> list[str]: - provider, model, merged_model = provider_model +def _apply_provider_model_composition( + parsed: dict, composition: tuple[dict, dict, str] +) -> list[str]: + provider, model, merged_model = composition skip = set(range(provider["start"], provider["end"])) result: list[str] = [] for index, arg in enumerate(parsed["argv"]): @@ -635,12 +637,12 @@ def _adapt_cli_argv(argv: list[str], adapter: dict) -> tuple[str, list[str]]: parsed = _parse_managed_invocation(argv, adapter) if parsed is None: return "passthrough", argv - provider_model = _provider_model_values(parsed) - translated = _translate_resumed_oneshot(parsed, provider_model) + composition = _provider_model_composition(parsed) + translated = _translate_resumed_oneshot(parsed, composition) if translated is not None: return "translated", translated - if provider_model is not None: - return "translated", _merge_provider_model(parsed, provider_model) + if composition is not None: + return "translated", _apply_provider_model_composition(parsed, composition) return "passthrough", argv diff --git a/agents/hermes/validate-cli-adapter.py b/agents/hermes/validate-cli-adapter.py index 5773cc9fe48..defcaf56445 100755 --- a/agents/hermes/validate-cli-adapter.py +++ b/agents/hermes/validate-cli-adapter.py @@ -195,7 +195,7 @@ def validate(contract_path: Path, hermes_binary: str) -> None: translations = contract.get("translations") if not isinstance(translations, dict) or set(translations) != { - "provider_model", + "provider_model_composition", "resumed_oneshot", }: _fail("Hermes CLI adapter must declare the two managed translations") diff --git a/test/hermes-dependency-review.test.ts b/test/hermes-dependency-review.test.ts index 0accce2a83a..d2be5879494 100644 --- a/test/hermes-dependency-review.test.ts +++ b/test/hermes-dependency-review.test.ts @@ -107,6 +107,10 @@ describe("Hermes 0.19.0 dependency review", () => { function: "_coalesce_session_name_args", boundary_set: "_SUBCOMMANDS", }); + expect(Object.keys(cliAdapter.translations).sort()).toEqual([ + "provider_model_composition", + "resumed_oneshot", + ]); expect( ( Object.values(cliAdapter.translations) as Array<{ From 494ca8dffc069cf6951c261316b264c139a58e3a Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 15:48:14 -0400 Subject: [PATCH 19/24] test(hermes): provide coalescer source fixture Signed-off-by: Julie Yaunches --- test/helpers/hermes-wrapper-harness.ts | 26 +++++++++++++-------- test/hermes-wrapper-oneshot-routing.test.ts | 9 ++++++- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/test/helpers/hermes-wrapper-harness.ts b/test/helpers/hermes-wrapper-harness.ts index fdcc81942e9..ee90ce9836a 100644 --- a/test/helpers/hermes-wrapper-harness.ts +++ b/test/helpers/hermes-wrapper-harness.ts @@ -60,6 +60,21 @@ export type WrapperRun = { export type StubBehaviour = { stdout?: string; stderr?: string; exitCode?: number }; +export function writeSessionCoalescerFixture( + dir: string, + sessionBoundaries = ["chat", "gateway", "sessions"], +): void { + fs.writeFileSync( + path.join(dir, "hermes-main.py"), + [ + "def _coalesce_session_name_args(argv):", + ` _SUBCOMMANDS = {${sessionBoundaries.map((value) => JSON.stringify(value)).join(", ")}}`, + " return argv", + "", + ].join("\n"), + ); +} + export function runWrapper( args: string[], env: Record, @@ -81,16 +96,7 @@ export function runWrapper( ? `${JSON.stringify(opts.adapter, null, 2)}\n` : fs.readFileSync(ADAPTER, "utf-8"); fs.writeFileSync(path.join(dir, "hermes-cli-adapter-v1.json"), adapterContent); - const sessionBoundaries = opts.sessionBoundaries ?? ["chat", "gateway", "sessions"]; - fs.writeFileSync( - path.join(dir, "hermes-main.py"), - [ - "def _coalesce_session_name_args(argv):", - ` _SUBCOMMANDS = {${sessionBoundaries.map((value) => JSON.stringify(value)).join(", ")}}`, - " return argv", - "", - ].join("\n"), - ); + writeSessionCoalescerFixture(dir, opts.sessionBoundaries); const validatorContent = opts.validatorScript ?? fs.readFileSync(VALIDATOR, "utf-8"); // Source-layout filename lets the wrapper's dev fallback pick it up. fs.writeFileSync(path.join(dir, "validate-env-secret-boundary.py"), validatorContent, { diff --git a/test/hermes-wrapper-oneshot-routing.test.ts b/test/hermes-wrapper-oneshot-routing.test.ts index 70fefdbe520..28ffad669b4 100644 --- a/test/hermes-wrapper-oneshot-routing.test.ts +++ b/test/hermes-wrapper-oneshot-routing.test.ts @@ -22,7 +22,13 @@ import path from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; -import { ADAPTER, canRun, runWrapper, WRAPPER } from "./helpers/hermes-wrapper-harness.ts"; +import { + ADAPTER, + canRun, + runWrapper, + WRAPPER, + writeSessionCoalescerFixture, +} from "./helpers/hermes-wrapper-harness.ts"; describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py one-shot routing", () => { // Surface a hard error in CI when the prerequisites are missing instead of @@ -287,6 +293,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py one-shot routing", () try { fs.copyFileSync(WRAPPER, path.join(dir, "hermes")); fs.copyFileSync(ADAPTER, path.join(dir, "hermes-cli-adapter-v1.json")); + writeSessionCoalescerFixture(dir); fs.chmodSync(path.join(dir, "hermes"), 0o755); const statePath = path.join(dir, "sessions.json"); fs.writeFileSync( From 38baa861270541b83a852d5d58f54fbf9bd6d8e3 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 15:53:20 -0400 Subject: [PATCH 20/24] fix(hermes): honor argument delimiter in adapter Signed-off-by: Julie Yaunches --- agents/hermes/Dockerfile | 7 ++++--- agents/hermes/hermes-wrapper.py | 2 ++ test/hermes-wrapper-provider-merge.test.ts | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 2ced2c9585c..ff461d75f97 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -454,9 +454,10 @@ RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256" /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py \ | sha256sum -c - \ || { echo "ERROR: Hermes CLI adapter integrity mismatch" >&2; exit 1; } -# Validate the versioned adapter against Hermes' machine-readable top-level and -# chat parser metadata before the real binary moves behind the wrapper. Help -# probes remain runtime evidence for those two public surfaces. +# Validate the versioned adapter against Hermes' parser metadata and session-name +# coalescer source before the real binary moves behind the wrapper. The wrapper +# reads the installed coalescer source at runtime. Help probes remain runtime +# evidence for the top-level and chat surfaces. RUN /opt/hermes/.venv/bin/python -I \ /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py \ --contract /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json \ diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index 23d73487d28..b045c364966 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -419,6 +419,8 @@ def _option_index(adapter: dict) -> tuple[dict[str, dict], dict[str, dict]]: def _has_option(argv: list[str], option: dict) -> bool: for arg in argv: + if arg == "--": + break if arg in option["names"]: return True if arg.startswith("--") and "=" in arg and arg.split("=", 1)[0] in option["names"]: diff --git a/test/hermes-wrapper-provider-merge.test.ts b/test/hermes-wrapper-provider-merge.test.ts index 6971eeb7e02..c21295ef02d 100644 --- a/test/hermes-wrapper-provider-merge.test.ts +++ b/test/hermes-wrapper-provider-merge.test.ts @@ -302,6 +302,24 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py provider/model merge", expect(run.realArgv).toEqual(argv); }); + it("passes provider/model-looking arguments after -- through unchanged (#8011)", () => { + const argv = [ + "--resume", + "my", + "project", + "--", + "--provider", + "nvidia-prod", + "--model", + "nvidia/nemotron-3-super-120b-a12b", + ]; + + const run = runWrapper(argv, {}); + + expect(run.status).toBe(0); + expect(run.realArgv).toEqual(argv); + }); + it("rejects an unknown adapter version before invoking Hermes (#8011)", () => { const adapter = JSON.parse(fs.readFileSync(ADAPTER, "utf-8")); adapter.adapter_version = 2; From bc5925efd1fcf41b65cf5a53e1d5603a79aa5d56 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 15:57:02 -0400 Subject: [PATCH 21/24] fix(hermes): refresh wrapper integrity hash Signed-off-by: Julie Yaunches --- agents/hermes/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index ff461d75f97..490e1eab4d8 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -403,7 +403,7 @@ RUN node --experimental-strip-types \ # silent supply-chain tampering of the build context (an attacker rewriting a # file has to also rewrite the Dockerfile-committed hash, which reviewers gate). # Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,hermes-cli-adapter-v1.json,validate-cli-adapter.py,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=b9ec0c5eee7d91c8b041515530d3b4a9eb36765d6b01a779ebdfef39bf2e8e28 +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=a841a3673cd2543dd53559e513741dd66929361b8ca94b9196b72badf7827d3c ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=989edf54a8c09c6efb348600a8aa2f264c0b71408eb9d7bcd579b92cbeccf9b1 ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=db4046e79e513eab67b069a8eda20167b8b65529cf26842531d2ad673c670330 ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 From 7524035e5ad214efb41ead4d7a8f30e7e7e8fce8 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 16:25:37 -0400 Subject: [PATCH 22/24] test(e2e): require Hermes adapter lifecycle lanes Signed-off-by: Julie Yaunches --- test/e2e/README.md | 3 +++ test/pr-risk-plan.test.ts | 25 +++++++++++++++++++++++++ tools/advisors/risk-plan.mts | 23 ++++++++++++++++++++--- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/test/e2e/README.md b/test/e2e/README.md index 815192f13c0..08f34e7bb80 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -623,6 +623,9 @@ family remains the conservative boundary for shared E2E tools, workflow and security files, unknown live test paths, risk policy, dependency and test configuration, and preparation and upload actions. These cross-cutting changes keep the broad three-job floor. +Changes to the Hermes CLI wrapper, adapter manifest, or adapter validator also +select `channels-stop-start` and `mcp-bridge`. Both jobs run their Hermes shard +to verify passthrough through channel and managed MCP gateway lifecycles. Repository-root `Dockerfile` changes additionally select `full-e2e` alongside the platform-install `cloud-onboard` floor so OpenClaw final-image changes run through cold onboarding and a real first turn. diff --git a/test/pr-risk-plan.test.ts b/test/pr-risk-plan.test.ts index 7b6315897e7..cffe23f06f7 100644 --- a/test/pr-risk-plan.test.ts +++ b/test/pr-risk-plan.test.ts @@ -117,6 +117,31 @@ describe("deterministic PR risk plan", () => { ]); }); + it("selects Hermes MCP and channel lifecycle E2E for CLI adapter routing changes (#8011)", () => { + const changedFiles = [ + "agents/hermes/hermes-cli-adapter-v1.json", + "agents/hermes/hermes-wrapper.py", + "agents/hermes/validate-cli-adapter.py", + ]; + const result = plan(...changedFiles); + + expect(result.families).toContainEqual( + expect.objectContaining({ + id: "focused-e2e", + matchedFiles: changedFiles, + requiredJobs: ["channels-stop-start", "mcp-bridge"], + }), + ); + expect(riskPlanRequiredJobIds(result)).toEqual( + expect.arrayContaining([ + "channels-stop-start", + "hermes-e2e", + "hermes-inference-switch", + "mcp-bridge", + ]), + ); + }); + it("leaves E2E support-only changes in the fast e2e-support project (#7921)", () => { const changedFiles = ["test/e2e/support/workflow-plan.test.ts"]; const focusedE2eJobs = focusedE2eJobsForChangedFiles(changedFiles); diff --git a/tools/advisors/risk-plan.mts b/tools/advisors/risk-plan.mts index 272c1073bd2..d5f45e25fb2 100644 --- a/tools/advisors/risk-plan.mts +++ b/tools/advisors/risk-plan.mts @@ -25,6 +25,12 @@ const MANAGED_STARTUP_E2E_JOB_IDS = [ "issue-4462-scope-upgrade-approval", "openclaw-inference-switch", ] as const; +const HERMES_CLI_ADAPTER_E2E_JOB_IDS = ["channels-stop-start", "mcp-bridge"] as const; +const HERMES_CLI_ADAPTER_RUNTIME_FILES = new Set([ + "agents/hermes/hermes-cli-adapter-v1.json", + "agents/hermes/hermes-wrapper.py", + "agents/hermes/validate-cli-adapter.py", +]); export type RiskTier = 0 | 1 | 2 | 3; export type RiskFamilyId = @@ -165,7 +171,7 @@ export function focusedPrE2eTargetsForChangedFiles( export function focusedPrE2eJobsForChangedFiles( changedFiles: readonly string[], ): TrustedFocusedE2eJob[] { - const matchedFiles = stableUnique( + const managedStartupFiles = stableUnique( changedFiles.filter( (file) => (file.startsWith("src/lib/onboard/managed-startup/") || @@ -174,8 +180,19 @@ export function focusedPrE2eJobsForChangedFiles( isRuntimeRelevant(file), ), ); - if (matchedFiles.length === 0) return []; - return MANAGED_STARTUP_E2E_JOB_IDS.map((id) => ({ id, matchedFiles })); + const hermesCliAdapterFiles = stableUnique( + changedFiles.filter( + (file) => HERMES_CLI_ADAPTER_RUNTIME_FILES.has(file) && isRuntimeRelevant(file), + ), + ); + return [ + ...(managedStartupFiles.length > 0 + ? MANAGED_STARTUP_E2E_JOB_IDS.map((id) => ({ id, matchedFiles: managedStartupFiles })) + : []), + ...(hermesCliAdapterFiles.length > 0 + ? HERMES_CLI_ADAPTER_E2E_JOB_IDS.map((id) => ({ id, matchedFiles: hermesCliAdapterFiles })) + : []), + ]; } export const RISK_RULES: readonly RiskRule[] = [ From 506153fb451010cca465be105f9fec542f2aeb37 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 16:33:20 -0400 Subject: [PATCH 23/24] docs(e2e): document Hermes adapter lane selection Signed-off-by: Julie Yaunches --- test/e2e/README.md | 6 +++--- tools/pr-review-advisor/README.md | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/test/e2e/README.md b/test/e2e/README.md index 08f34e7bb80..eedfcb3d19b 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -616,9 +616,9 @@ controller completes it as cancelled with `Superseded by PR update` or `PR closed — gate no longer applies` and identifies the obsolete head and base. The closed-PR outcome also applies when a fork repository was deleted and GitHub consequently returns no head-repository object. -Shared sandbox-boundary changes have a floor of `full-e2e`, `hermes-e2e`, and -`security-posture`. E2E control-plane changes select `cloud-onboard`, -`cloud-inference`, and `security-posture`. The `e2e-control-plane` +Shared sandbox-boundary changes have a floor of `full-e2e`, `hermes-e2e`, +`hermes-inference-switch`, and `security-posture`. E2E control-plane changes +select `cloud-onboard`, `cloud-inference`, and `security-posture`. The `e2e-control-plane` family remains the conservative boundary for shared E2E tools, workflow and security files, unknown live test paths, risk policy, dependency and test configuration, and preparation and upload actions. These cross-cutting changes diff --git a/tools/pr-review-advisor/README.md b/tools/pr-review-advisor/README.md index d187bed00b4..0a91d63f486 100644 --- a/tools/pr-review-advisor/README.md +++ b/tools/pr-review-advisor/README.md @@ -124,6 +124,17 @@ Each match selects these focused E2E jobs: - `issue-4462-scope-upgrade-approval`. - `openclaw-inference-switch`. +The same risk plan maps these Hermes CLI adapter paths to `focused-e2e`: + +- `agents/hermes/hermes-cli-adapter-v1.json`. +- `agents/hermes/hermes-wrapper.py`. +- `agents/hermes/validate-cli-adapter.py`. + +Each Hermes CLI adapter match selects these focused E2E jobs: + +- `channels-stop-start`. +- `mcp-bridge`. + ## Required secret Configure this repository secret for review analysis: From 34846e450e58465b1a497a0deb695f6432af51d1 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 21:21:10 -0400 Subject: [PATCH 24/24] test(e2e): preserve merged Hermes risk coverage Signed-off-by: Julie Yaunches --- test/pr-risk-plan.test.ts | 57 +++++++++++++++++++++++-------- tools/pr-review-advisor/README.md | 2 +- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/test/pr-risk-plan.test.ts b/test/pr-risk-plan.test.ts index a2192f1ba0b..07091589e2d 100644 --- a/test/pr-risk-plan.test.ts +++ b/test/pr-risk-plan.test.ts @@ -136,21 +136,22 @@ describe("deterministic PR risk plan", () => { ]); }); - it("selects Hermes MCP and channel lifecycle E2E for CLI adapter routing changes (#8011)", () => { - const changedFiles = [ - "agents/hermes/hermes-cli-adapter-v1.json", - "agents/hermes/hermes-wrapper.py", - "agents/hermes/validate-cli-adapter.py", - ]; - const result = plan(...changedFiles); + it.each([ + "agents/hermes/hermes-cli-adapter-v1.json", + "agents/hermes/hermes-wrapper.py", + "agents/hermes/validate-cli-adapter.py", + ])("selects Hermes MCP and channel lifecycle E2E for %s (#8011)", (changedFile) => { + const result = plan(changedFile); - expect(result.families).toContainEqual( + const focusedFamily = result.families.find((family) => family.id === "focused-e2e"); + expect(focusedFamily).toEqual( expect.objectContaining({ - id: "focused-e2e", - matchedFiles: changedFiles, - requiredJobs: expect.arrayContaining(["channels-stop-start", "mcp-bridge"]), + matchedFiles: [changedFile], }), ); + expect(focusedFamily?.requiredJobs).toEqual( + expect.arrayContaining(["channels-stop-start", "mcp-bridge"]), + ); expect(riskPlanRequiredJobIds(result)).toEqual( expect.arrayContaining([ "channels-stop-start", @@ -166,13 +167,13 @@ describe("deterministic PR risk plan", () => { )("selects every Hermes managed-policy live E2E job for %s (#8008)", (changedFile) => { const result = plan(changedFile); - expect(result.families).toContainEqual( + const focusedFamily = result.families.find((family) => family.id === "focused-e2e"); + expect(focusedFamily).toEqual( expect.objectContaining({ - id: "focused-e2e", matchedFiles: [changedFile], - requiredJobs: expect.arrayContaining(HERMES_MANAGED_POLICY_JOBS), }), ); + expect(focusedFamily?.requiredJobs).toEqual(expect.arrayContaining(HERMES_MANAGED_POLICY_JOBS)); expect(riskPlanRequiredJobIds(result)).toEqual( expect.arrayContaining(HERMES_MANAGED_POLICY_JOBS), ); @@ -182,7 +183,33 @@ describe("deterministic PR risk plan", () => { const result = plan("agents/hermes/runtime-version.py"); expect(result.families).not.toContainEqual(expect.objectContaining({ id: "focused-e2e" })); - expect(riskPlanRequiredJobIds(result)).toEqual(["full-e2e", "hermes-e2e", "security-posture"]); + expect(riskPlanRequiredJobIds(result)).toEqual([ + "full-e2e", + "hermes-e2e", + "hermes-inference-switch", + "security-posture", + ]); + }); + + it("combines CLI adapter and managed-policy E2E for the Hermes wrapper (#8011)", () => { + const result = plan("agents/hermes/hermes-wrapper.py"); + + expect(result.families).toContainEqual( + expect.objectContaining({ + id: "focused-e2e", + matchedFiles: ["agents/hermes/hermes-wrapper.py"], + requiredJobs: [ + "bedrock-runtime-compatible-anthropic", + "channels-stop-start", + "dashboard-remote-bind", + "hermes-e2e", + "hermes-inference-switch", + "hermes-shields-config", + "mcp-bridge", + "security-posture", + ], + }), + ); }); it("leaves E2E support-only changes in the fast e2e-support project (#7921)", () => { const changedFiles = ["test/e2e/support/workflow-plan.test.ts"]; diff --git a/tools/pr-review-advisor/README.md b/tools/pr-review-advisor/README.md index 0a91d63f486..05a14057da9 100644 --- a/tools/pr-review-advisor/README.md +++ b/tools/pr-review-advisor/README.md @@ -112,7 +112,7 @@ Authors and coding agents should follow the shared [PR CI and Review Follow-Up]( job that the model omits or downgrades. The PR E2E controller separately dispatches every listed job without consuming the advisor's normalized result. -Risk plan version 10 maps runtime changes from these paths to the `focused-e2e` family: +Risk plan version 11 maps runtime changes from these paths to the `focused-e2e` family: - `src/lib/onboard/managed-startup/**`. - `src/lib/onboard/sandbox-create-launch.ts`.