diff --git a/.agents/skills/nemoclaw-contributor-update-hermes/SKILL.md b/.agents/skills/nemoclaw-contributor-update-hermes/SKILL.md index d6a929ec02e..0704bc01256 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 1658fd63e09..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 @@ -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,27 @@ 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 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. +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 `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. +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 30b824ab38d..490e1eab4d8 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -68,6 +68,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 @@ -195,7 +197,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=50f5cf638e2c11868fe5128dccc0f6289082ba11ea116efcd030b07286591c00 +ARG NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256=759e8e1466063692c5c89a0bbb60b77bedf16b0ad7e93d100463ba5ab2e49577 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256" /opt/nemoclaw-hermes-config/image-build-probes.py \ @@ -395,13 +397,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=a841a3673cd2543dd53559e513741dd66929361b8ca94b9196b72badf7827d3c +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 @@ -438,30 +442,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; } +# 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 \ + --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 \ + && chmod 444 /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json \ && chmod 755 /usr/local/bin/hermes.real \ && /usr/local/bin/hermes --version @@ -881,7 +885,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"; \ @@ -911,6 +919,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..eb5eb0c7546 --- /dev/null +++ b/agents/hermes/hermes-cli-adapter-v1.json @@ -0,0 +1,151 @@ +{ + "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", + "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.", + "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_composition": { + "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.", + "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." + } + } +} diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index bb844b7bd52..b045c364966 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -50,26 +50,12 @@ # 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 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, @@ -93,18 +79,25 @@ # 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 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" +_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` @@ -135,6 +128,18 @@ 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_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): @@ -277,534 +282,408 @@ 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", -) -# 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", - } +_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, ) -_PROFILE_VALUE_FLAGS = { - "-p": "--profile", - "--profile": "--profile", +_CLI_ADAPTER_ARITIES = {"boolean", "optional_session", "required", "session"} +_SESSION_NAME_COALESCER = { + "module": "hermes_cli.main", + "function": "_coalesce_session_name_args", + "boundary_set": "_SUBCOMMANDS", } -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.""" +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: + 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") + 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: + 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_composition", + "resumed_oneshot", + }: + raise _CliAdapterError("Hermes CLI adapter has invalid translation metadata") + 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] = {} + 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 _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 _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"]: + return True + return 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 + session_boundaries: frozenset[str] | None = None + unknown_option = False + 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("-"): - return None - if saw_oneshot: + if option is not None: + option_id = option["id"] + if occurrence_ids.get(option_id, 0) and not option.get("repeatable", False): 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]: return None - saw_resume = True - resume_args.extend([canonical, value]) - i = next_index + if cursor < len(argv) and not argv[cursor].startswith("-"): + 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 session_boundaries + ): + parts.append(argv[cursor]) + cursor += 1 + if not parts and arity == "session": + return None + 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 + 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 _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 + if command is not None: + i += 1 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") + 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 - 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) + return None + if arg in adapter["managed_commands"]: + command = 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): + 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"]) + and _has_option(argv, by_id["model"]) + ): + raise _AmbiguousProviderModelSession 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 + return { + "argv": argv, + "command": command, + "occurrences": occurrences, + "terminated": terminated, + "unknown_option": unknown_option, + } -# 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] +def _occurrences(parsed: dict, option_id: str) -> list[dict]: + return [occurrence for occurrence in parsed["occurrences"] if occurrence["id"] == option_id] - if arg == "--": - return True - if _split_flag_value(arg) is not None: - i += 1 - continue +def _merged_model(provider: str, model: str) -> str: + prefix = f"{provider}/" + return model if model.casefold().startswith(prefix.casefold()) else f"{provider}/{model}" - if arg in _PROVIDER_MODEL_COMMAND_SCAN_REQUIRED_VALUE_FLAGS: - i += 2 - continue - if arg in _PROVIDER_MODEL_COMMAND_SCAN_SESSION_FLAGS: - 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 +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: + 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, + composition: 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"] + or parsed["unknown_option"] + ): + return None + if _occurrences(parsed, "usage_file"): + raise _UnsupportedResumedOneshotUsageFile - if arg.startswith("-"): - 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 = 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: continue - - return arg == "chat" - - return True + 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 -def _merge_provider_into_model(argv: list[str]) -> list[str]: - """Merge separate --provider and -m/--model flags into the combined form. +class _UnsupportedResumedOneshotUsageFile(Exception): + """Signal a valid resumed one-shot form whose usage report would be lost.""" - 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 +class _AmbiguousProviderModelSession(Exception): + """Signal provider/model flags after an unquoted multi-word session name.""" - 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 - - 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 +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"]): + if index in skip: continue + 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(arg) + return result - 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 +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 + composition = _provider_model_composition(parsed) + translated = _translate_resumed_oneshot(parsed, composition) + if translated is not None: + return "translated", translated + if composition is not None: + return "translated", _apply_provider_model_composition(parsed, composition) + return "passthrough", argv - 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}" - ) +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 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 + 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'}" + ) - # 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) - result: list[str] = [] - for idx, val in enumerate(argv): - if idx 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: - result.append(merged_model) - else: - result.append(val) - return result +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: @@ -817,8 +696,15 @@ 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: + 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, " @@ -828,11 +714,20 @@ 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: + return _report_cli_adapter_error(exc) + 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: + return _report_cli_adapter_error(exc) 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 ccc4636292f..71f658c7f8e 100644 --- a/agents/hermes/image-build-probes.py +++ b/agents/hermes/image-build-probes.py @@ -201,65 +201,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 @@ -419,7 +360,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..defcaf56445 --- /dev/null +++ b/agents/hermes/validate-cli-adapter.py @@ -0,0 +1,233 @@ +#!/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 ast +import json +import subprocess +import sys +from pathlib import Path + +_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: + 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_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")) + 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 __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 + + 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}" + ) + + 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), + "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": + 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( + "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_composition", + "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", + "source_fix_constraint", + ): + 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/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 8e473b6d06a..d631f8afb89 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -271,6 +271,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/docs/security/hermes-0.19.0-dependency-review.md b/docs/security/hermes-0.19.0-dependency-review.md index ea856acc8de..58c907c66c3 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 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/src/lib/onboard/managed-startup/profile.ts b/src/lib/onboard/managed-startup/profile.ts index a655ab2b519..b1c96cf481a 100644 --- a/src/lib/onboard/managed-startup/profile.ts +++ b/src/lib/onboard/managed-startup/profile.ts @@ -748,6 +748,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" }, 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); 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, diff --git a/test/helpers/hermes-wrapper-harness.ts b/test/helpers/hermes-wrapper-harness.ts index 607d45a5826..ee90ce9836a 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 { @@ -52,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, @@ -60,12 +83,20 @@ export function runWrapper( shadowHelpers?: Record; stub?: StubBehaviour; stubMode?: number; + adapter?: object; + sessionBoundaries?: string[]; + 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); + 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, { @@ -79,6 +110,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-cli-adapter-validator.test.ts b/test/hermes-cli-adapter-validator.test.ts new file mode 100644 index 00000000000..3cca39bb96c --- /dev/null +++ b/test/hermes-cli-adapter-validator.test.ts @@ -0,0 +1,128 @@ +// 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, include_provider=True): + parser.add_argument("-m", "--model") + if include_provider: + 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="?") + parser.add_argument("-w", "--worktree", action="store_true") + for name in ( + "--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 +`; + +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"); + 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("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"); + 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", + ); + }); + + 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", + ); + }); +}); diff --git a/test/hermes-dependency-review.test.ts b/test/hermes-dependency-review.test.ts index 8273c2aa0c7..d2be5879494 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,30 @@ 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 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"]); + expect(cliAdapter.session_name_coalescer).toEqual({ + module: "hermes_cli.main", + 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<{ + 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-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 27d4e1016cf..5e33f8beab2 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", @@ -255,7 +265,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", @@ -313,10 +327,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", @@ -343,6 +354,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}`); @@ -377,6 +390,20 @@ 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.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).not.toBeNull(); + expect(adapterValidation).toBeGreaterThan(adapterIntegrityGate?.index ?? -1); + }); + it("rejects retired OpenClaw state represented as a directory", () => { const run = runFinalLayout({ openclaw: "directory" }); try { diff --git a/test/hermes-image-build-probes.test.ts b/test/hermes-image-build-probes.test.ts index 035f48acda3..ebc91161932 100644 --- a/test/hermes-image-build-probes.test.ts +++ b/test/hermes-image-build-probes.test.ts @@ -26,7 +26,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..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 { 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 @@ -132,6 +138,48 @@ 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", + ])("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"], @@ -244,12 +292,15 @@ 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")); + writeSessionCoalescerFixture(dir); 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..c21295ef02d 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)", () => { @@ -25,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"], @@ -71,37 +96,29 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py provider/model merge", } }); - it("merges after an unquoted multi-word continue session name (#7361)", () => { - const run = runWrapper( - [ - "-c", - "Pokemon", - "Agent", - "Dev", - "--provider", - "nvidia-prod", - "--model", - "nvidia/nemotron-3-super-120b-a12b", - ], - {}, - ); - - expect(run.status).toBe(0); - expect(run.realArgv).toEqual([ + it("rejects an ambiguous unquoted multi-word continue form (#8011)", () => { + const argv = [ "-c", "Pokemon", "Agent", "Dev", + "--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(2); + expect(run.realInvoked).toBe(false); + expect(run.stderr).toContain("ambiguous session name"); }); - it("treats a subcommand-looking first continue value as a session name (#7361)", () => { + it("merges after a quoted multi-word continue session name (#8011)", () => { const run = runWrapper( [ "-c", - "sessions", + "Pokemon Agent Dev", "--provider", "nvidia-prod", "--model", @@ -113,19 +130,50 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py provider/model merge", expect(run.status).toBe(0); expect(run.realArgv).toEqual([ "-c", - "sessions", + "Pokemon Agent Dev", "--model", "nvidia-prod/nvidia/nemotron-3-super-120b-a12b", ]); }); - it("merges after an unquoted multi-word resume session name (#7361)", () => { + it("passes an upstream session boundary through without merging provider/model (#8011)", () => { + const argv = [ + "-c", + "sessions", + "--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 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 +185,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 +267,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 +282,73 @@ 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("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; + + 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 +363,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)", () => {