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

The 'Unknown top-level config key' warning (f5bacee27) assumed a
closed-world allowlist of valid roots, but top-level scalars in
config.yaml are deliberately bridged into os.environ (gateway/run.py,
hermes send) so skills and external apps Hermes drives can read
arbitrary env-style keys (DISCORD_HOME_CHANNEL, MY_APP_TOKEN, ...).
An allowlist can never enumerate those — two widening follow-ups
(7c2ece53c, 3c7217706) already proved the whack-a-mole. Drop the
generic warning entirely; keep the targeted provider-like-field
misplacement hint (base_url/api_key at root).
This commit is contained in:
Teknium 2026-07-20 02:25:33 -07:00 committed by GitHub
parent 1c3a48965b
commit 9bda6438d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 26 additions and 72 deletions

View file

@ -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

View file

@ -214,38 +214,27 @@ class TestConfigIssueDataclass:
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 @@ class TestUnknownTopLevelKeys:
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": "secret",
@ -285,19 +252,13 @@ class TestUnknownTopLevelKeys:
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 == []