Skip to content

fix(cli): accept the emptied hooks: {} mapping in the Hermes hook editor - #2896

Merged
DeusData merged 2 commits into
mainfrom
fix/hermes-remover-empty-hooks
Nov 3, 2026
Merged

DeusData merged 2 commits into
mainfrom
fix/hermes-remover-empty-hooks

Conversation

@DeusData

@DeusData DeusData commented Nov 2, 2026

Copy link
Copy Markdown
Owner

What does this PR do?

Fixes a Hermes reinstall failure: on a config where our pre_llm_call hook
was the only entry under hooks:, install -> uninstall -> install exited 1,
and a repeated uninstall exited 1 as well.

Why it happened:

  • cbm_remove_hermes_context_hook (src/cli/cli.c) removes our sequence item
    and then, when pre_llm_call is left empty, drops that key with
    cbm_yaml_remove_mapping_entry. When it was the last key under hooks:,
    the mapping editor writes the emptied section as hooks: {}
    (yaml_build_last_mapping_entry_removal), the same convention it uses for
    mcp_servers: {}.
  • The hook sequence editor (yaml_sequence_analyze in
    src/cli/config_yaml_edit.c) required every parent on the sequence path to be
    a block mapping header with an empty tail, so it refused hooks: {} for both
    upsert and removal. The next install failed with
    op=pre_llm_hook_install, a repeated uninstall with
    op=pre_llm_hook_uninstall.

The fix mirrors what the mapping-entry editor already does for
section: {}: an exact empty flow mapping on a PARENT of the sequence path
is read as that empty mapping. Removal is a byte-identical no-op; upsert
expands the header in place (hooks: {} -> hooks: plus the nested keys),
keeping a trailing comment, the line endings and every other byte. A
reinstall therefore lands back on the first install byte for byte. Still
refused, byte-identically: the sequence key itself holding a mapping
(pre_llm_call: {}), any non-empty or sequence flow value
(hooks: {pre_llm_call: []}, hooks: []), and content indented under an
inline {}. Hooks written by the user and every foreign key are untouched.

Found while finishing #1862: its persisted hook opt-out runs this remover on
every later install, so every Hermes user whose only hook was ours would have
hit the refusal on each install after the first.

Tests (reproduce-first, RED on main a5b248f, GREEN with the fix):

  • cli_hermes_hook_reinstalls_after_uninstall_empties_hooks (cli): fake
    HOME, install -> uninstall -> uninstall -> install. RED on main: the
    repeated uninstall and the reinstall both fail on hooks: {}. GREEN: both
    exit 0, the repeated uninstall leaves the file unchanged, the reinstall is
    byte-identical to the first install with exactly one owned hook.
  • cli_hermes_hook_cycle_keeps_foreign_pre_llm_hook (cli): a user hook in
    the same pre_llm_call sequence survives the cycle and uninstall restores
    the original file byte for byte (guard; green before and after).
  • config_yaml_edit_nested_sequence_reinserts_under_emptied_parent and
    config_yaml_edit_nested_sequence_expands_explicit_empty_parent_mapping
    (config_yaml_edit): the editor primitives round trip, plus comment, CRLF,
    no-final-newline and trailing-comment cases with exact expected bytes.
    RED on main (the remover returns -1 on hooks: {}), GREEN with the fix.
  • config_yaml_edit_nested_sequence_still_refuses_non_empty_parent_flow
    (config_yaml_edit): the refusals above stay refused byte-identically for
    upsert and removal (guard; green before and after).

Proof on the real binary (macOS arm64, fake HOME with only
~/.hermes/config.yaml = theme: solarized):

  • main: install 0, uninstall 0 (leaves hooks: {}), repeated uninstall 1,
    reinstall 1 (file left with hooks: {}, no hook).
  • this branch: install 0, uninstall 0, repeated uninstall 0 (file
    unchanged), reinstall 0 (byte-identical to the first install, one hook).

Refs #1862

Checklist

  • Every commit is signed off (git commit -s)
  • Tests pass locally (make -f Makefile.cbm test) - ran the affected
    suites on macOS ASan/UBSan: cli 400 passed + 1 Windows-only skip,
    config_toml_edit 49 passed, config_yaml_edit 69 passed; full suite left
    to PR CI
  • Lint passes (make -f Makefile.cbm lint-ci) - clang-format clean on
    the touched hunks, CI cppcheck 2.20.0 clean on
    src/cli/config_yaml_edit.c; full lint left to PR CI
  • New behavior is covered by a test (reproduce-first for bug fixes)

Removing the only Hermes pre_llm_call hook empties the hooks mapping, and
the mapping editor writes an emptied section as hooks: {} - the same
convention it uses for mcp_servers: {}. The hook sequence editor then
refused that file for both upsert and removal, because it required every
parent on the sequence path to be a block mapping header. On a config
where our hook was the only entry under hooks, install -> uninstall ->
install exited 1, and a repeated uninstall did too.

An exact empty flow mapping on a parent of the sequence path is now read
as that empty mapping, mirroring how the mapping-entry editor already
treats section: {}. Removal is a byte-identical no-op, and upsert expands
the header in place, keeping its comment, the line endings and every
other byte, so a reinstall lands back on the first install byte for
byte. The sequence key itself holding a mapping, any other flow value,
and content nested under the inline mapping stay refused. Hooks written
by the user and every foreign key are left untouched.

Found while finishing #1862: its persisted hook opt-out runs this remover
on every later install, so every Hermes user whose only hook was ours
would have hit the refusal on each install after the first.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
@DeusData
DeusData merged commit 5830d28 into main Nov 3, 2026
35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant