NousResearch/hermes-agent

config set key validation and path mapping fixes

Cluster 34067 · hermes cli. Data as of

2 open items. Fix merged in #67988 on 20 Jul 2026. Check it covers your case.

The short answer

1open issue
1open PR
9items grouped
Known?
Yes. 9 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #67988. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #67988. That is separate from the fix event.
Still open
1 issue and 1 PR.

These issues and PRs address 'hermes config set' accepting unknown or shortcut keys (like 'provider') that diverge from canonical dotted paths (like 'model.provider'), and debate how to handle unrecognized keys — via warnings, errors, or a --force flag.

Open issues1

  • Issue#41943config: "hermes config set provider" writes to dead key, diverges from model.provider

Open PRs1

  • PR#41956fix(cli): resolve config set provider/base_url shortcuts to canonical dotted paths

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #67924 fix(config): remove unknown-top-level-key warning — top-level keys bridge to env

    merged to main

    files: hermes_cli/config.py, tests/hermes_cli/test_config_validation.py

    Show the diff
    • hermes_cli/config.py
      @@ -5699,28 +5699,21 @@ def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["
                   "    base_url: https://...",
               ))
       
      -    # ── Unknown / misplaced root-level keys ──────────────────────────────
      -    # Typos like skillz:/secrity: were previously silent (only provider-like
      -    # fields were flagged). Warn on any unknown top-level key so config
      -    # hygiene surfaces without breaking startup.
      +    # ── Root-level keys that look misplaced ──────────────────────────────
      +    # Only provider-like fields (base_url, api_key, …) are flagged. Arbitrary
      +    # unknown top-level keys are deliberately NOT warned about: top-level
      +    # scalars are bridged into os.environ (gateway/run.py, hermes send) so
      +    # users can feed skills and external apps env-style keys from config.yaml
      +    # — a closed-world allowlist can never enumerate those.
           for key in config:
               if key.startswith("_"):
                   continue
      -        if key in _KNOWN_ROOT_KEYS:
      -            continue
      -        if key in _CUSTOM_PROVIDER_LIKE_FIELDS:
      +        if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:
                   issues.append(ConfigIssue(
                       "warning",
                       f"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?",
                       f"Move '{key}' under the appropriate section",
                   ))
      -        else:
      -            issues.append(ConfigIssue(
      -                "warning",
      -                f"Unknown top-level config key '{key}' — it will be ignored",
      -                "Check for typos, or remove the key if it is not a supported config root. "
      -                "Run 'hermes doctor' for more detail.",
      -            ))
       
           return issues
       
    • tests/hermes_cli/test_config_validation.py
      @@ -214,38 +214,27 @@ def test_equality(self):
       
       
       class TestUnknownTopLevelKeys:
      -    """Unknown top-level keys should warn (not error); known roots stay silent."""
      +    """Arbitrary top-level keys must NOT warn — they are bridged to os.environ.
       
      -    def test_typo_top_level_key_warns_with_key_name(self):
      -        """A typo like skillz: must surface as a warning naming the key."""
      +    Top-level scalars in config.yaml are forwarded into the environment
      +    (gateway/run.py, hermes send) so users can feed skills and external apps
      +    env-style keys like DISCORD_HOME_CHANNEL or MY_APP_TOKEN. A closed-world
      +    allowlist can never enumerate those, so no "Unknown top-level config key"
      +    warning may exist.
      +    """
      +
      +    def test_arbitrary_top_level_keys_stay_silent(self):
      +        """Env-style and custom keys must produce no unknown-key warnings."""
               issues = validate_config_structure({
                   "model": {"provider": "openrouter"},
      +            "DISCORD_HOME_CHANNEL": "12345",
      +            "TELEGRAM_HOME_CHANNEL": "-100987",
      +            "DISCORD_ALLOW_ALL_USERS": True,
      +            "MY_CUSTOM_SKILL_VAR": "hello",
                   "skillz": {"enabled": True},
      -            "secrity": {"redact": True},
               })
      -        warnings = [i for i in issues if i.severity == "warning"]
      -        unknown = [i for i in warnings if "Unknown top-level config key" in i.message]
      -        messages = " ".join(i.message for i in unknown)
      -        assert "skillz" in messages
      -        assert "secrity" in messages
      -        assert all(i.severity == "warning" for i in unknown)
      -        assert not any(i.severity == "error" for i in unknown)
      -
      -    def test_all_default_config_roots_accepted_without_unknown_warning(self):
      -        """Every DEFAULT_CONFIG root (and legacy extras) must not warn as unknown."""
      -        config = {key: {} if isinstance(DEFAULT_CONFIG.get(key), dict) else DEFAULT_CONFIG.get(key)
      -                  for key in DEFAULT_CONFIG}
      -        for key in _EXTRA_KNOWN_ROOT_KEYS:
      -            if key == "custom_providers":
      -                config[key] = [{"name": "x", "base_url": "https://example.com"}]
      -                config.setdefault("model", {"provider": "custom", "default": "m"})
      -            elif key == "fallback_model":
      -                config[key] = {"provider": "openrouter", "model": "test"}
      -            else:
      -                config[key] = {}
      -        issues = validate_config_structure(config)
      -        unknown = [i for i in issues if "Unknown top-level config key" in i.message]
      -        assert unknown == [], f"Unexpected unknown-key warnings: {[i.message for i in unknown]}"
      +        assert not any("Unknown top-level config key" in i.message for i in issues)
      +        assert issues == []
       
           def test_known_root_keys_derived_from_default_config(self):
               """_KNOWN_ROOT_KEYS must be DEFAULT_CONFIG.keys() plus extras — single source of truth."""
      @@ -253,30 +242,8 @@ def test_known_root_keys_derived_from_default_config(self):
               assert _EXTRA_KNOWN_ROOT_KEYS.issubset(_KNOWN_ROOT_KEYS)
               assert _KNOWN_ROOT_KEYS == frozenset(DEFAULT_CONFIG.keys()) | _EXTRA_KNOWN_ROOT_KEYS
       
      -    def test_hermes_written_roots_not_flagged_as_unknown(self):
      -        """Roots Hermes itself writes/reads must not warn as unknown (#67397)."""
      -        issues = validate_config_structure({
      -            "model": {"provider": "openrouter"},
      -            "known_plugin_toolsets": {"cli": ["spotify"]},
      -            "group_sessions_per_user": True,
      -            "thread_sessions_per_user": False,
      -            "stt_echo_transcripts": True,
      -            "reset_triggers": ["/new"],
      -            "always_log_local": True,
      -            "filter_silence_narration": True,
      -        })
      -        unknown = [i for i in issues if "Unknown top-level config key" in i.message]
      -        messages = " ".join(i.message for i in unknown)
      -        assert "known_plugin_toolsets" not in messages
      -        assert "group_sessions_per_user" not in messages
      -        assert "thread_sessions_per_user" not in messages
      -        assert "stt_echo_transcripts" not in messages
      -        assert "reset_triggers" not in messages
      -        assert "always_log_local" not in messages
      -        assert "filter_silence_narration" not in messages
      -
           def test_provider_like_unknown_root_keeps_misplaced_message(self):
      -        """Preserve existing base_url/api_key root-level guidance (not generic unknown)."""
      +        """Preserve existing base_url/api_key root-level guidance."""
               issues = validate_config_structure({
                   "base_url": "https://example.com/v1",
                   "api_key": "[redacted:secret-assignment]",
      @@ -285,19 +252,13 @@ def test_provider_like_unknown_root_keeps_misplaced_message(self):
                   i for i in issues
                   if i.severity == "warning" and "looks misplaced" in i.message
               ]
      -        generic_unknown = [
      -            i for i in issues
      -            if "Unknown top-level config key" in i.message
      -        ]
               assert any("base_url" in i.message for i in misplaced)
               assert any("api_key" in i.message for i in misplaced)
      -        assert generic_unknown == []
       
           def test_private_underscore_keys_not_flagged(self):
      -        """Internal keys starting with _ remain ignored (except known defaults)."""
      +        """Internal keys starting with _ remain ignored."""
               issues = validate_config_structure({
                   "_internal_scratch": True,
                   "model": {"provider": "openrouter"},
               })
      -        assert not any("Unknown top-level" in i.message for i in issues)
      -        assert not any("_internal_scratch" in i.message for i in issues)
      +        assert issues == []
  • #67988 fix(config): warn on unrecognized keys in 'hermes config set' — write + notice, never block (#34067)

    merged to main

    files: hermes_cli/config.py, hermes_cli/subcommands/config.py, tests/hermes_cli/test_console_engine.py, tests/hermes_cli/test_placeholder_usage.py, tests/hermes_cli/test_set_config_value.py

    Show the diff
    • hermes_cli/config.py
      @@ -8534,8 +8534,190 @@ def _default_value_for_key(dotted_key: str):
           return node if not isinstance(node, dict) else None
       
       
      -def set_config_value(key: str, value: str):
      -    """Set a configuration value."""
      +# Known top-level config keys that intentionally accept arbitrary user-supplied
      +# child keys ("dictionary-shaped" config: the schema declares the dict but the
      +# user populates its keys). Schema validation accepts ANY path below these
      +# without deep checking, so users can set e.g. ``mcp_servers.my-server.command``
      +# or ``providers.openrouter.api_key`` without us needing to know server names.
      +_OPEN_DICT_TOP_LEVEL_KEYS = frozenset({
      +    "providers",
      +    "credential_pool_strategies",
      +    "mcp_servers",
      +    "hooks",
      +    "quick_commands",
      +    "personalities",
      +    "command_allowlist",
      +    "model_catalog",
      +    "channel_prompts",
      +    "server_actions",
      +    "secrets",
      +    "goals",
      +})
      +
      +# Top-level keys whose sub-keys are partially schema-defined (e.g. on a
      +# PlatformConfig dataclass) but where users may legitimately add fields
      +# that DEFAULT_CONFIG doesn't enumerate (extras, per-channel overrides,
      +# etc.). For these we validate the FIRST segment but accept anything below.
      +_SCHEMA_DEFINED_DICT_KEYS = frozenset({
      +    # Platform configs — PlatformConfig dataclass + dynamic extras
      +    "discord", "telegram", "slack", "whatsapp", "signal", "mattermost",
      +    "matrix", "feishu", "wecom", "weixin", "bluebubbles", "qqbot", "yuanbao",
      +    "email", "sms", "dingtalk",
      +    # MCP server template / dynamic auth dicts
      +    "sessions", "checkpoints",
      +})
      +
      +# Top-level keys that can be ANY user-supplied name (platform/provider dict
      +# shapes where the outer key IS user-defined).
      +_DYNAMIC_TOP_LEVEL_KEYS = frozenset({
      +    "custom_providers",  # list-shaped, but indexed by position
      +})
      +
      +# Container keys whose immediate child IS a user-supplied platform name
      +# (``platforms.<name>.<field>``).  These appear both at the top level and
      +# nested under ``gateway`` — current docs configure platforms under
      +# ``gateway.platforms.<name>`` (website/docs/developer-guide/
      +# adding-platform-adapters.md) and ``gateway/config.py`` also resolves a
      +# top-level ``platforms`` map.  Anything below the platform-name segment is
      +# accepted because ``PlatformConfig`` carries an open ``extra`` mapping.
      +_PLATFORM_CONTAINER_KEYS = frozenset({"platforms"})
      +
      +
      +def _known_top_level_keys() -> set[str]:
      +    """Return the union of known top-level config keys for validation.
      +
      +    Combines :data:`DEFAULT_CONFIG` with the dynamic categories that
      +    accept user-supplied child keys.  Used by :func:`_validate_config_key`
      +    to decide whether a ``hermes config set`` invocation is targeting a
      +    known shape.
      +    """
      +    keys = set(DEFAULT_CONFIG.keys())
      +    keys.update(_OPEN_DICT_TOP_LEVEL_KEYS)
      +    keys.update(_DYNAMIC_TOP_LEVEL_KEYS)
      +    keys.update(_SCHEMA_DEFINED_DICT_KEYS)
      +    return keys
      +
      +
      +def _suggest_closest_key(key: str, candidates: set[str], cutoff: float = 0.6) -> Optional[str]:
      +    """Return the closest valid key name from ``candidates`` if any are
      +    similar enough to ``key``, else None.  Used by ``hermes config set``
      +    to point users at the right path when they've typo'd a top-level key.
      +
      +    Uses :func:`difflib.get_close_matches` with a conservative cutoff so
      +    we only suggest when there's a strong match — we'd rather say nothing
      +    than mislead a user toward a wrong-but-similar key.
      +    """
      +    import difflib
      +    matches = difflib.get_close_matches(key, sorted(candidates), n=1, cutoff=cutoff)
      +    return matches[0] if matches else None
      +
      +
      +def _validate_config_key(key: str) -> tuple[bool, Optional[str]]:
      +    """Validate a dotted config-key path against the known schema.
      +
      +    Returns ``(is_known, suggested_alternative_or_None)``.  Known keys
      +    return ``(True, None)``.  Unknown keys return ``(False, <suggestion>)``
      +    where ``<suggestion>`` may be ``None`` if no close match was found.
      +
      +    Validates as deep as DEFAULT_CONFIG can be safely walked, then stops
      +    at any segment that hits an open-dict container (mcp_servers,
      +    providers, hooks, etc.) where users define the inner keys themselves.
      +
      +    Headline case from #34067: ``gateway.discord.gateway_restart_notification``
      +    was silently written, even though ``gateway`` only has 4 known sub-keys
      +    (``strict``, ``media_delivery_allow_dirs``, ``trust_recent_files``,
      +    ``trust_recent_files_seconds``). The correct path is
      +    ``discord.gateway_restart_notification`` (platform configs live at the
      +    top level, not under a ``platforms`` namespace).
      +    """
      +    if not key:
      +        return False, None
      +
      +    segments = key.split(".")
      +    top = segments[0]
      +
      +    # ── Underscore-prefixed keys are internal/test markers ───────────
      +    # A leading underscore on the top-level segment (e.g. ``_test.shim_marker``)
      +    # signals an intentionally non-schema, internal key. Test harnesses and
      +    # tooling use these to write a deterministic marker into config.yaml
      +    # without polluting the user-facing schema (see the Docker privilege-drop
      +    # shim test, which writes ``_test.shim_marker`` to probe file ownership).
      +    # Python's own convention treats a leading underscore as "private"; we
      +    # honour that here so schema validation never blocks deliberately-internal
      +    # keys. This is narrow: only the FIRST segment is checked, so a real typo
      +    # like ``agent._max_turns`` still gets caught at the sub-key level.
      +    if top.startswith("_"):
      +        return True, None
      +
      +    known = _known_top_level_keys()
      +
      +    # ── First-segment validation ─────────────────────────────────────
      +    # Top-level ``platforms.<name>.<field>`` is a valid current shape:
      +    # ``gateway/config.py`` resolves a top-level ``platforms`` map in
      +    # addition to ``gateway.platforms``.  Accept anything below it.
      +    if top in _PLATFORM_CONTAINER_KEYS:
      +        return True, None
      +
      +    if top not in known:
      +        suggestion = _suggest_closest_key(top, known)
      +        if suggestion is not None:
      +            rest = ".".join(segments[1:])
      +            suggested_full = f"{suggestion}.{rest}" if rest else suggestion
      +            return False, suggested_full
      +
      +        return False, None
      +
      +    # ── Deeper validation ────────────────────────────────────────────
      +    # Walk DEFAULT_CONFIG along the user's segments. Stop at:
      +    #   - An open-dict container (user-defined inner keys are OK below it)
      +    #   - A schema-defined-but-extensible dict (accept anything below)
      +    #   - A leaf scalar (the user's key is fully consumed and valid)
      +    #   - An unknown sub-key (return False with a same-level suggestion)
      +    if top in _OPEN_DICT_TOP_LEVEL_KEYS or top in _DYNAMIC_TOP_LEVEL_KEYS or top in _SCHEMA_DEFINED_DICT_KEYS:
      +        # Any path below these is accepted — the user defines the inner
      +        # shape themselves (mcp_servers.<name>.command, discord.<extras>,
      +        # providers.<name>.api_key, etc.).
      +        return True, None
      +
      +    node: Any = DEFAULT_CONFIG.get(top)
      +    consumed = [top]
      +    for seg in segments[1:]:
      +        # ``gateway.platforms.<name>.<field>`` (and any other nested
      +        # ``platforms`` container) — the segment after ``platforms`` is a
      +        # user-supplied platform name, so accept everything below it.
      +        if seg in _PLATFORM_CONTAINER_KEYS:
      +            return True, None
      +        if not isinstance(node, dict):
      +            # We hit a scalar leaf before consuming the user's full path —
      +            # they're trying to set ``foo.bar`` where ``foo`` is a string.
      +            # Accept it (set_config_value's coercion will replace the
      +            # leaf with a dict, matching pre-existing behavior).
      +            return True, None
      +        if seg not in node:
      +            # Suggest the closest sibling at this depth.
      +            sibling_suggestion = _suggest_closest_key(seg, set(node.keys()))
      +            if sibling_suggestion is not None:
      +                fixed_path = ".".join(consumed + [sibling_suggestion])
      +                return False, fixed_path
      +            return False, None
      +        consumed.append(seg)
      +        node = node[seg]
      +
      +    # Walked the entire user-supplied path without hitting an unknown
      +    # segment — it's known.
      +    return True, None
      +
      +
      +def set_config_value(key: str, value: str, force: bool = False):
      +    """Set a configuration value.
      +
      +    Args:
      +        key: Dotted config path (e.g. ``terminal.backend``).
      +        value: String value (auto-coerced to bool/int/float when matching).
      +        force: When True, skip the unknown-key warning — useful for scripted
      +            writes of keys the running version doesn't recognize yet. The CLI
      +            exposes this via ``hermes config set --force``.
      +    """
           if is_managed():
               managed_error("set configuration values")
               return
      @@ -8564,7 +8746,16 @@ def set_config_value(key: str, value: str):
               save_provider_env_credential(key.upper(), value)
               print(f"✓ Set {key} in {get_env_path()}")
               return
      -    
      +
      +    # Unknown-key notice (#34067): the key is still written (arbitrary keys
      +    # are supported — top-level scalars are bridged into os.environ for
      +    # skills and external apps), but a plausible-but-wrong dotted path like
      +    # ``gateway.discord.gateway_restart_notification`` previously reported
      +    # bare success and left the user debugging behavior that never changed.
      +    # Warn after the write so the user gets immediate feedback plus a
      +    # "did you mean" hint, without blocking legitimate unknown keys.
      +    is_known, suggestion = _validate_config_key(key)
      +
           # Otherwise it goes to config.yaml
           # Read the raw user config (not merged with defaults) to avoid
           # dumping all default values back to the file
      @@ -8631,6 +8822,23 @@ def set_config_value(key: str, value: str):
               _display_value = value
           print(f"✓ Set {key} = {_display_value} in {config_path}")
       
      +    # Post-write unknown-key notice (#34067): value IS saved, but tell the
      +    # user the runtime may never read it and suggest the likely-intended path.
      +    if not is_known and not force:
      +        print(color(
      +            f"⚠ '{key}' is not a recognized config key — it was saved anyway, "
      +            "but Hermes may not read it.",
      +            Colors.YELLOW,
      +        ))
      +        if suggestion:
      +            print(color(f"  Did you mean: {suggestion}", Colors.YELLOW))
      +        print(color(
      +            "  (Custom top-level keys are supported and bridged to the "
      +            "environment for skills/external tools. Use --force to skip "
      +            "this notice.)",
      +            Colors.DIM,
      +        ))
      +
       
       def get_config_value(key: str, *, as_json: bool = False):
           """Print a resolved configuration value."""
      @@ -8734,15 +8942,18 @@ def config_command(args):
           elif subcmd == "set":
               key = getattr(args, 'key', None)
               value = getattr(args, 'value', None)
      +        force = bool(getattr(args, 'force', False))
               if not key or value is None:
      -            print("Usage: hermes config set <key> <value>")
      +            print("Usage: hermes config set [--force] <key> <value>")
                   print()
                   print("Examples:")
                   print("  hermes config set model anthropic/claude-sonnet-4")
                   print("  hermes config set terminal.backend docker")
                   print("  hermes config set OPENROUTER_API_KEY sk-or-...")
      +            print()
      +            print("  --force: skip the unknown-key notice for unrecognized keys")
                   sys.exit(1)
      -        set_config_value(key, value)
      +        set_config_value(key, value, force=force)
       
           elif subcmd == "unset":
               key = getattr(args, 'key', None)
    • hermes_cli/subcommands/config.py
      @@ -40,6 +40,12 @@ def build_config_parser(subparsers, *, cmd_config: Callable) -> None:
               "key", nargs="?", help="Configuration key (e.g., model, terminal.backend)"
           )
           config_set.add_argument("value", nargs="?", help="Value to set")
      +    config_set.add_argument(
      +        "--force",
      +        action="store_true",
      +        help="Skip the unknown-key notice printed after writing a key the "
      +        "running version doesn't recognize (the value is saved either way).",
      +    )
       
           # config unset
           config_unset = config_subparsers.add_parser(
    • tests/hermes_cli/test_console_engine.py
      @@ -404,18 +404,21 @@ def test_help_lists_supported_commands_and_not_full_cli():
       def test_config_set_requires_confirmation_then_writes(_isolate_hermes_home):
           engine = HermesConsoleEngine()
       
      -    pending = engine.execute("config set console.test true")
      +    # Use a schema-known key path. Since #34067, `config set` refuses unknown
      +    # top-level keys, so this flow test must target a valid path (telegram is a
      +    # PlatformConfig-shaped dict that accepts arbitrary child keys).
      +    pending = engine.execute("config set telegram.test true")
           assert pending.status == "confirm_required"
       
           from hermes_cli.config import read_raw_config
       
           assert read_raw_config() == {}
       
      -    result = engine.execute("config set console.test true", confirmed=True)
      +    result = engine.execute("config set telegram.test true", confirmed=True)
       
           assert result.status == "ok"
      -    assert "console.test" in result.output
      -    assert read_raw_config()["console"]["test"] is True
      +    assert "telegram.test" in result.output
      +    assert read_raw_config()["telegram"]["test"] is True
       
       
       def test_sessions_list_and_stats_use_isolated_session_store(_isolate_hermes_home):
    • tests/hermes_cli/test_placeholder_usage.py
      @@ -18,7 +18,15 @@ def test_config_set_usage_marks_placeholders(capsys):
       
           assert exc.value.code == 1
           out = capsys.readouterr().out
      -    assert "Usage: hermes config set <key> <value>" in out
      +    # The usage line documents the optional --force flag added in #34067
      +    # (schema validation for unknown keys). Placeholder convention is preserved:
      +    # the literal ``<key>`` and ``<value>`` markers must still be present so
      +    # downstream tooling can detect placeholder syntax.
      +    assert "Usage: hermes config set" in out
      +    assert "<key>" in out
      +    assert "<value>" in out
      +    # --force escape hatch must be documented in the usage line.
      +    assert "--force" in out
       
       
       def test_config_unknown_command_help_marks_placeholders(capsys):
    • tests/hermes_cli/test_set_config_value.py
      @@ -146,9 +146,11 @@ def test_empty_string_routes_to_config(self, _isolated_hermes_home):
       
           def test_zero_routes_to_config(self, _isolated_hermes_home):
               """Setting a config key to '0' should write 0 to config.yaml."""
      -        set_config_value("verbose", "0")
      +        # Use a real DEFAULT_CONFIG sub-key so schema validation passes — the
      +        # original test used ``verbose`` which is not in the known schema.
      +        set_config_value("agent.gateway_timeout", "0")
               config = _read_config(_isolated_hermes_home)
      -        assert "verbose: 0" in config
      +        assert "gateway_timeout: 0" in config
       
           def test_config_command_rejects_missing_value(self):
               """config set with no value arg (None) should still exit."""
      @@ -346,20 +348,23 @@ def test_indexed_set_preserves_non_targeted_fields(self, _isolated_hermes_home):
           def test_deeper_nesting_through_list(self, _isolated_hermes_home):
               """Navigation path mixing dict → list → dict → scalar."""
               self._write_config(_isolated_hermes_home, (
      -            "platforms:\n"
      -            "  telegram:\n"
      -            "    allowlist:\n"
      +            "telegram:\n"
      +            "  allowlist:\n"
                   "    - name: alice\n"
                   "      role: admin\n"
                   "    - name: bob\n"
                   "      role: user\n"
               ))
       
      -        set_config_value("platforms.telegram.allowlist.1.role", "admin")
      +        # NOTE: original test path was ``platforms.telegram.allowlist.1.role``,
      +        # which #34067 schema validation correctly rejects (platform configs
      +        # live at the top level, not under a ``platforms`` namespace). Use
      +        # the canonical path.
      +        set_config_value("telegram.allowlist.1.role", "admin")
       
               import yaml
               reloaded = yaml.safe_load(_read_config(_isolated_hermes_home))
      -        allowlist = reloaded["platforms"]["telegram"]["allowlist"]
      +        allowlist = reloaded["telegram"]["allowlist"]
               assert isinstance(allowlist, list)
               assert allowlist[0] == {"name": "alice", "role": "admin"}
               assert allowlist[1] == {"name": "bob", "role": "admin"}
      @@ -398,7 +403,9 @@ def test_non_string_defaults_keep_existing_coercion(
               assert type(node) is type(expected)
       
           def test_unknown_keys_keep_existing_coercion(self, _isolated_hermes_home):
      -        set_config_value("custom.enabled", "off")
      +        # ``custom`` is not a known top-level key, so it now requires --force
      +        # (schema validation, #34067); coercion behavior is unchanged.
      +        set_config_value("custom.enabled", "off", force=True)
       
               import yaml
               saved = yaml.safe_load(_read_config(_isolated_hermes_home))
      @@ -457,3 +464,169 @@ def test_set_echo_keeps_nonsecret_value(self, _isolated_hermes_home, capsys):
       
               captured = capsys.readouterr()
               assert "Set model.reasoning_effort = high" in captured.out
      +
      +# #34067: Schema validation for unknown keys
      +# ---------------------------------------------------------------------------
      +
      +class TestSchemaValidation:
      +    """#34067: ``hermes config set`` must not report bare success for
      +    unrecognized keys. The key IS written (arbitrary keys are supported —
      +    top-level scalars bridge into os.environ for skills/external apps), but
      +    a post-write notice warns that Hermes may never read it and suggests the
      +    likely-intended path. Headline case: the plausible-but-wrong
      +    ``gateway.discord.gateway_restart_notification`` (correct path:
      +    ``discord.gateway_restart_notification``).
      +    """
      +
      +    def test_unknown_top_level_key_written_with_notice(self, _isolated_hermes_home, capsys):
      +        """An unknown top-level key is saved AND a notice is printed."""
      +        set_config_value("totally_made_up_key", "value")
      +        out = capsys.readouterr().out
      +        assert "not a recognized config key" in out
      +        assert "totally_made_up_key" in out
      +        assert "saved anyway" in out
      +        # The value WAS written.
      +        assert "totally_made_up_key" in _read_config(_isolated_hermes_home)
      +
      +    def test_unknown_subkey_written_with_notice(self, _isolated_hermes_home, capsys):
      +        """The headline #34067 path: written, but warned about — no more
      +        bare success for gateway.discord.gateway_restart_notification."""
      +        set_config_value("gateway.discord.gateway_restart_notification", "false")
      +        out = capsys.readouterr().out
      +        assert "✓ Set" in out
      +        assert "not a recognized config key" in out
      +
      +    def test_platforms_container_is_accepted(self, _isolated_hermes_home, capsys):
      +        """``platforms.<name>.<field>`` is a valid current shape: gateway/
      +        config.py resolves a top-level ``platforms`` map in addition to the
      +        top-level platform blocks, so it must NOT trigger the notice."""
      +        set_config_value("platforms.discord.enabled", "true")
      +        content = _read_config(_isolated_hermes_home)
      +        assert "enabled: true" in content
      +        assert "not a recognized config key" not in capsys.readouterr().out
      +
      +    def test_gateway_platforms_nested_is_accepted(self, _isolated_hermes_home, capsys):
      +        """Docs configure platforms under ``gateway.platforms.<name>`` — the
      +        canonical layout must validate as known (no notice)."""
      +        set_config_value("gateway.platforms.my_platform.extra.token", "abc")
      +        content = _read_config(_isolated_hermes_home)
      +        assert "token: abc" in content
      +        assert "not a recognized config key" not in capsys.readouterr().out
      +
      +    def test_unknown_approvals_subkey_warns_but_writes(self, _isolated_hermes_home, capsys):
      +        """``approvals`` is a defined schema, so a typo'd sub-key gets the
      +        notice — but is still written."""
      +        set_config_value("approvals.notarealkey", "true")
      +        out = capsys.readouterr().out
      +        assert "not a recognized config key" in out
      +        assert "notarealkey" in _read_config(_isolated_hermes_home)
      +
      +    def test_known_approvals_subkey_is_accepted(self, _isolated_hermes_home, capsys):
      +        """Real ``approvals.*`` keys still validate silently."""
      +        set_config_value("approvals.mode", "off")
      +        import yaml
      +        saved = yaml.safe_load(_read_config(_isolated_hermes_home))
      +        assert saved["approvals"]["mode"] == "off"
      +        assert "not a recognized config key" not in capsys.readouterr().out
      +
      +    def test_close_typo_suggests_correct_key(self, _isolated_hermes_home, capsys):
      +        """Typo'd top-level keys should get a fuzzy-match suggestion."""
      +        set_config_value("disco", "false")
      +        out = capsys.readouterr().out
      +        assert "Did you mean" in out
      +        assert "discord" in out
      +
      +    def test_typoed_subkey_suggests_sibling(self, _isolated_hermes_home, capsys):
      +        """``agent.max_turn`` should suggest ``agent.max_turns``."""
      +        set_config_value("agent.max_turn", "100")
      +        out = capsys.readouterr().out
      +        assert "agent.max_turns" in out
      +
      +    def test_force_suppresses_notice(self, _isolated_hermes_home, capsys):
      +        """``--force`` writes unknown keys without the notice (scripted
      +        forward-compat writes)."""
      +        set_config_value("brand_new_future_key", "value", force=True)
      +        out = capsys.readouterr().out
      +        assert "not a recognized config key" not in out
      +        # And the value WAS written.
      +        content = _read_config(_isolated_hermes_home)
      +        assert "brand_new_future_key" in content
      +
      +    def test_known_top_level_key_accepted(self, _isolated_hermes_home):
      +        """Sanity check: real config keys still work."""
      +        set_config_value("terminal.backend", "docker")
      +        content = _read_config(_isolated_hermes_home)
      +        assert "backend: docker" in content
      +
      +    def test_known_platform_config_accepted(self, _isolated_hermes_home):
      +        """Schema-defined-extensible top-level keys (platform configs) accept
      +        any sub-key path because PlatformConfig has dynamic ``extra`` fields."""
      +        # discord is a platform config — sub-keys accept anything.
      +        set_config_value("discord.gateway_restart_notification", "false")
      +        content = _read_config(_isolated_hermes_home)
      +        assert "gateway_restart_notification: false" in content
      +
      +    def test_open_dict_mcp_servers_accepts_any_subkey(self, _isolated_hermes_home):
      +        """``mcp_servers.<user-named-server>.<field>`` must work for any
      +        user-supplied server name."""
      +        set_config_value("mcp_servers.my-server.command", "npx")
      +        content = _read_config(_isolated_hermes_home)
      +        assert "my-server" in content
      +        assert "command: npx" in content
      +
      +
      +class TestValidateConfigKey:
      +    """Unit tests for the validator itself."""
      +
      +    @pytest.mark.parametrize("key", [
      +        "model",
      +        "terminal.backend",
      +        "agent.max_turns",
      +        "discord.gateway_restart_notification",
      +        "telegram.bot_token",
      +        "mcp_servers.foo.command",
      +        "providers.openrouter.api_key",
      +        "gateway.strict",
      +        "platforms.discord.enabled",
      +        "gateway.platforms.my_platform.extra.token",
      +        "approvals.mode",
      +    ])
      +    def test_known_keys_pass(self, key):
      +        from hermes_cli.config import _validate_config_key
      +        is_known, _ = _validate_config_key(key)
      +        assert is_known, f"Expected {key!r} to validate as known"
      +
      +    @pytest.mark.parametrize("key,expected_in_suggestion", [
      +        ("gateway.discord.gateway_restart_notification", None),  # no close suggestion
      +        ("disco", "discord"),
      +        ("agent.max_turn", "agent.max_turns"),
      +    ])
      +    def test_unknown_keys_with_suggestion(self, key, expected_in_suggestion):
      +        from hermes_cli.config import _validate_config_key
      +        is_known, suggestion = _validate_config_key(key)
      +        assert not is_known, f"Expected {key!r} to validate as unknown"
      +        if expected_in_suggestion is not None:
      +            assert suggestion is not None and expected_in_suggestion in suggestion, \
      +                f"Expected suggestion to contain {expected_in_suggestion!r}, got {suggestion!r}"
      +
      +    @pytest.mark.parametrize("key", [
      +        "_test.shim_marker",
      +        "_internal",
      +        "_test.nested.deep.marker",
      +        "_x",
      +    ])
      +    def test_underscore_prefixed_keys_are_accepted(self, key):
      +        """Underscore-prefixed top-level keys are internal/test markers and
      +        bypass schema validation. The Docker privilege-drop shim test writes
      +        ``_test.shim_marker`` to probe config.yaml ownership; that must not
      +        be rejected. (Regression: #34250 schema validation broke this.)"""
      +        from hermes_cli.config import _validate_config_key
      +        is_known, _ = _validate_config_key(key)
      +        assert is_known, f"Expected underscore-prefixed {key!r} to be accepted"
      +
      +    def test_underscore_only_first_segment_escapes(self):
      +        """The underscore escape only applies to the FIRST segment. A real
      +        typo in a sub-key (e.g. agent._max_turns) is still caught."""
      +        from hermes_cli.config import _validate_config_key
      +        is_known, suggestion = _validate_config_key("agent._max_turns")
      +        assert not is_known, "Sub-key typo under a known top-level key must still be flagged"

4 PRs closed without merging

From the close comments: merged via another change 1 · superseded by another PR 1 · other 2

  • #34187 fix(cli): validate keys in hermes config set and add --force flag (#34067) · other → #34067, #67988, #34250
  • #34250 fix(config): validate config-key schema, refuse unknown keys (#34067) · merged via another change → #67988, #67924
  • #41970 fix(config): map `provider` shortcut to `model.provider` for hermes config set · superseded by another PR → #41956
  • #42062 fix(cli): redirect 'hermes config set provider' to model.provider (#41943) · other
Closed issues · 1
  • Closed#34067`hermes config set` silently accepts unknown keys, writing them where the runtime never reads
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.20: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#67924, #67988
Closed issues
1
Attempts
4 PRs closed without merging
Fix link checked
20 Jul 2026

For your agent

The same facts, typed.