fix(managed-scope): apply managed layer in cli.py's standalone config loader

cli.py's load_cli_config() builds CLI_CONFIG independently of
hermes_cli.config._load_config_impl (it reads config.yaml directly and merges
into hardcoded defaults), so the Phase 2 managed merge never reached the
interactive CLI/TUI surface. Symptom: a managed display.skin (and any other
display/CLI pref read from CLI_CONFIG) was silently ignored by the TUI while
`hermes config`/`doctor`/write-guards — which go through load_config — correctly
honored it. Found via manual testing: the skin engine kept using 'default'.

Fix: overlay the managed config last in load_cli_config(), mirroring
_load_config_impl — expand against the process env only (so a user ${VAR} can't
shadow a managed literal), normalize the root model key so a managed
`model: x/y` string can't clobber the dict shape callers expect, then
leaf-merge. Fail-open so managed scope can never block CLI startup.

Adds tests/hermes_cli/test_managed_scope_cli_config.py locking that CLI_CONFIG
honors managed values, preserves user siblings, and is inert with no scope.
This commit is contained in:
Ben 2026-06-19 11:45:31 +10:00 committed by Teknium
parent 9a24e41d0f
commit 732293cf87
2 changed files with 103 additions and 0 deletions

21
cli.py
View file

@ -562,6 +562,27 @@ def load_cli_config() -> Dict[str, Any]:
from hermes_cli.config import _expand_env_vars
defaults = _expand_env_vars(defaults)
# Managed scope: overlay administrator-pinned values LAST so they win over
# the user's config here too. cli.py builds its config independently of
# hermes_cli.config._load_config_impl (which has its own managed merge), so
# without this the entire interactive CLI/TUI surface — skin, display prefs,
# etc. read from CLI_CONFIG — would silently ignore managed scope while
# `hermes config`/`doctor`/guards (which use load_config) honor it. Mirror
# _load_config_impl: expand managed against the process env only (so a user
# ${VAR} can't shadow a managed literal), normalize its root model key so a
# managed `model: x/y` string can't clobber the dict shape callers expect,
# then leaf-merge on top. Fail-open — managed scope must never block startup.
try:
from hermes_cli import managed_scope
from hermes_cli.config import _deep_merge, _normalize_root_model_keys
managed_config = managed_scope.load_managed_config()
if managed_config:
managed_expanded = _normalize_root_model_keys(_expand_env_vars(managed_config))
defaults = _deep_merge(defaults, managed_expanded)
except Exception as e: # noqa: BLE001 — never let managed scope break CLI startup
logger.warning("Failed to apply managed scope to CLI config: %s", e)
# Apply terminal config to environment variables (so terminal_tool picks them up)
terminal_config = defaults.get("terminal", {})

View file

@ -0,0 +1,82 @@
"""Managed scope must reach cli.py's independent config loader (CLI_CONFIG).
cli.py's load_cli_config() builds config separately from
hermes_cli.config._load_config_impl, so the managed-scope merge has to be
applied in BOTH places or the interactive CLI/TUI surface (skin, display prefs)
silently ignores administrator-pinned values while `hermes config`/`doctor`
honor them. This locks the cli.py path.
"""
import importlib
import pytest
@pytest.fixture
def homes(tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
managed = tmp_path / "managed"
managed.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
import hermes_cli.config as cfg
from hermes_cli import managed_scope
cfg._LOAD_CONFIG_CACHE.clear()
cfg._RAW_CONFIG_CACHE.clear()
managed_scope.invalidate_managed_cache()
return home, managed
def _load_cli_config(home):
"""Call cli.py's standalone loader fresh.
cli.py binds ``_hermes_home = get_hermes_home()`` at import time (module
singleton), so monkeypatching HERMES_HOME after import doesn't move it.
Point the module's cached home at the test's home for the duration of the
call. (In real use cli is imported once per process with the real home, so
this only matters for tests that swap HERMES_HOME.)
"""
import cli
cli._hermes_home = home
return cli.load_cli_config()
def test_cli_config_honors_managed_skin(homes):
"""A managed display.skin must reach CLI_CONFIG (the TUI's source)."""
home, managed = homes
(home / "config.yaml").write_text("display:\n skin: user_skin\n", encoding="utf-8")
(managed / "config.yaml").write_text("display:\n skin: charizard\n", encoding="utf-8")
from hermes_cli import managed_scope
managed_scope.invalidate_managed_cache()
cfg = _load_cli_config(home)
assert (cfg.get("display") or {}).get("skin") == "charizard"
def test_cli_config_managed_leaf_preserves_user_siblings(homes):
"""Managed display.skin must not wipe a user's other display.* prefs."""
home, managed = homes
(home / "config.yaml").write_text(
"display:\n skin: user_skin\n show_reasoning: true\n", encoding="utf-8"
)
(managed / "config.yaml").write_text("display:\n skin: charizard\n", encoding="utf-8")
from hermes_cli import managed_scope
managed_scope.invalidate_managed_cache()
cfg = _load_cli_config(home)
display = cfg.get("display") or {}
assert display.get("skin") == "charizard" # managed wins
assert display.get("show_reasoning") is True # user sibling preserved
def test_cli_config_no_managed_scope_uses_user_value(homes):
"""With no managed config, CLI_CONFIG reflects the user's value."""
home, managed = homes # managed dir exists but empty
(home / "config.yaml").write_text("display:\n skin: user_skin\n", encoding="utf-8")
from hermes_cli import managed_scope
managed_scope.invalidate_managed_cache()
cfg = _load_cli_config(home)
assert (cfg.get("display") or {}).get("skin") == "user_skin"