From 732293cf879b11f7f3817aebbf7b4a0f88de4184 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 19 Jun 2026 11:45:31 +1000 Subject: [PATCH] fix(managed-scope): apply managed layer in cli.py's standalone config loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cli.py | 21 +++++ .../test_managed_scope_cli_config.py | 82 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 tests/hermes_cli/test_managed_scope_cli_config.py diff --git a/cli.py b/cli.py index 52bfe6cdb0a..5be829fc1cf 100644 --- a/cli.py +++ b/cli.py @@ -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", {}) diff --git a/tests/hermes_cli/test_managed_scope_cli_config.py b/tests/hermes_cli/test_managed_scope_cli_config.py new file mode 100644 index 00000000000..51d5fcae4ce --- /dev/null +++ b/tests/hermes_cli/test_managed_scope_cli_config.py @@ -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"