Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
d697774
refactor(hermes): add versioned CLI adapter
jyaunches Jul 31, 2026
06ea794
fix(hermes): preserve wrapper failure contracts
jyaunches Jul 31, 2026
da01699
fix(hermes): verify CLI adapter before execution
jyaunches Jul 31, 2026
621102a
test(hermes): clarify adapter security contracts
jyaunches Jul 31, 2026
7b4c1cd
docs(hermes): clarify adapter source ownership
jyaunches Jul 31, 2026
4249214
test(hermes): strengthen adapter integrity assertion
jyaunches Jul 31, 2026
b220896
fix(hermes): classify adapter integrity pins
jyaunches Jul 31, 2026
d6be551
test(hermes): require inference switch E2E
jyaunches Jul 31, 2026
35fa203
docs(e2e): clarify Hermes route invariant
jyaunches Jul 31, 2026
ce2811a
fix(hermes): validate preparse adapter arity
jyaunches Aug 1, 2026
2d801f7
test(hermes): cover chat adapter drift
jyaunches Aug 1, 2026
b16d2b9
fix(hermes): preserve managed command boundaries
jyaunches Aug 1, 2026
8102685
fix(hermes): preserve routing past atomic options
jyaunches Aug 1, 2026
e5527f3
merge(main): refresh PR #8025
jyaunches Aug 1, 2026
083ab16
merge: resolve conflicts with main
github-actions[bot] Aug 1, 2026
f7bf14b
merge(main): refresh PR #8025
jyaunches Aug 2, 2026
cb12114
docs(hermes): document versioned CLI adapter
jyaunches Aug 2, 2026
f8e5779
test(hermes): complete CLI adapter live contract
jyaunches Aug 2, 2026
ccbf193
test(hermes): broaden inference switch adapter coverage
jyaunches Aug 2, 2026
9413d4b
fix(hermes): preserve upstream session boundaries
jyaunches Aug 2, 2026
1bf74ec
refactor(hermes): clarify provider model composition
jyaunches Aug 2, 2026
494ca8d
test(hermes): provide coalescer source fixture
jyaunches Aug 2, 2026
38baa86
fix(hermes): honor argument delimiter in adapter
jyaunches Aug 2, 2026
bc5925e
fix(hermes): refresh wrapper integrity hash
jyaunches Aug 2, 2026
7524035
test(e2e): require Hermes adapter lifecycle lanes
jyaunches Aug 2, 2026
506153f
docs(e2e): document Hermes adapter lane selection
jyaunches Aug 2, 2026
0f474b1
merge: resolve conflicts with main
github-actions[bot] Aug 3, 2026
34846e4
test(e2e): preserve merged Hermes risk coverage
jyaunches Aug 3, 2026
f8a793b
merge(main): refresh PR #8025
jyaunches Aug 3, 2026
7d34e7e
merge(main): refresh PR #8025
jyaunches Aug 3, 2026
eab48cc
merge(main): refresh PR #8025 after #8107
jyaunches Aug 3, 2026
89cfd74
merge: resolve conflicts with main
github-actions[bot] Aug 3, 2026
a8d4783
merge(main): reconcile concurrent PR #8025 refresh
jyaunches Aug 3, 2026
e478046
merge(main): refresh PR #8025
jyaunches Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/skills/nemoclaw-contributor-update-hermes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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.
Expand Down
50 changes: 30 additions & 20 deletions agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"(?<![A-Za-z0-9_])--?[A-Za-z0-9][A-Za-z0-9-]*"; top_help = subprocess.check_output(["/usr/local/bin/hermes", "--help"], text=True, timeout=30); top_option_fields = [re.split(r"\s{2,}", line.strip(), maxsplit=1)[0] for line in top_help.splitlines() if line.lstrip().startswith("-")]; top_options = set(re.findall(option_pattern, "\n".join(top_option_fields))); top_expected = (required_value_flags - profile_flags) | session_flags | set(constants["_BOOLEAN_FLAGS"]); top_missing = sorted(top_expected - top_options); top_missing and sys.exit("ERROR: Hermes wrapper scanner flag allowlist drifted from pinned hermes --help: " + ", ".join(top_missing)); profile_probe_name = "nemoclaw-build-probe-missing"; profile_probe_expected = "Profile %r does not exist." % profile_probe_name; profile_probe_results = {flag: subprocess.run(["/usr/local/bin/hermes", flag, profile_probe_name, "--version"], text=True, capture_output=True, timeout=30) for flag in sorted(profile_flags)}; profile_probe_failures = sorted(flag for flag, result in profile_probe_results.items() if result.returncode == 0 or profile_probe_expected not in (result.stdout + result.stderr)); profile_probe_failures and sys.exit("ERROR: Hermes hidden profile flags drifted from the wrapper scanner: " + ", ".join(profile_probe_failures)); subcommand_match = re.search(r"^\s*\{([^}\n]+)\}\s*$", top_help, re.MULTILINE); not subcommand_match and sys.exit("ERROR: could not parse pinned hermes subcommands from --help"); pinned_subcommands = {name.strip() for name in subcommand_match.group(1).split(",")}; wrapper_subcommands = set(constants["_HERMES_SUBCOMMANDS"]); missing_subcommands = sorted(pinned_subcommands - wrapper_subcommands); stale_subcommands = sorted(wrapper_subcommands - pinned_subcommands); (missing_subcommands or stale_subcommands) and sys.exit("ERROR: Hermes wrapper subcommand allowlist drifted from pinned hermes --help: missing=" + ",".join(missing_subcommands) + " stale=" + ",".join(stale_subcommands)); chat_help = subprocess.check_output(["/usr/local/bin/hermes", "chat", "--help"], text=True, timeout=30); chat_option_fields = [re.split(r"\s{2,}", line.strip(), maxsplit=1)[0] for line in chat_help.splitlines() if line.lstrip().startswith("-")]; chat_options = set(re.findall(option_pattern, "\n".join(chat_option_fields))); chat_expected = set(constants["_VALUE_FLAGS"]) | set(constants["_VALUE_FLAGS"].values()) | set(constants["_BOOLEAN_FLAGS"]) | {"--query", "-q", "--quiet", "-Q", "--continue", "-c"}; chat_missing = sorted(chat_expected - chat_options); chat_missing and sys.exit("ERROR: Hermes wrapper forwarded flags drifted from pinned hermes chat --help: " + ", ".join(chat_missing))'
# Hermes' private unquoted-session coalescer intentionally differs from public
# help (for example, `console` is not a boundary). Bind that upstream AST set
# independently so a new command cannot silently change wrapper parsing.
# hadolint ignore=DL3059
RUN /usr/bin/python3 -I /opt/nemoclaw-hermes-config/image-build-probes.py \
wrapper-session-boundaries

# hadolint ignore=DL4006
RUN printf '%s %s\n' \
"$NEMOCLAW_HERMES_WRAPPER_SHA256" /usr/local/lib/nemoclaw/hermes-wrapper.py \
"$NEMOCLAW_HERMES_CLI_ADAPTER_SHA256" /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json \
"$NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256" /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py \
| sha256sum -c - \
|| { echo "ERROR: hermes-wrapper.py hash mismatch (update NEMOCLAW_HERMES_WRAPPER_SHA256)" >&2; exit 1; }
|| { echo "ERROR: Hermes CLI adapter integrity mismatch" >&2; exit 1; }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# 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

Expand Down Expand Up @@ -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"; \
Expand Down Expand Up @@ -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 \
Expand Down
Loading
Loading