hermes-agent/tests/hermes_cli/test_managed_scope_config.py
Ben b5ddd6e719 feat(managed-scope): managed config layer wins over user config
_load_config_impl now deep-merges the managed config.yaml on top of the
expanded user config so managed leaves win while sibling keys stay
user-controlled (leaf-level merge, D3). Managed values are expanded against
the process env only, never user-defined ${VAR}, so a user can't shadow a
managed literal. The managed file's (mtime,size) is folded into the load
cache key so editing it invalidates the cache. This inverts the usual
env-over-config precedence for pinned keys by design (see design doc §4.1).
2026-06-19 07:46:33 -07:00

97 lines
3.6 KiB
Python

"""Config integration tests — managed scope wins over user config at the leaf."""
import textwrap
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 _write(path, body):
path.write_text(textwrap.dedent(body), encoding="utf-8")
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()
def test_managed_beats_user(homes):
from hermes_cli.config import load_config, cfg_get
home, managed = homes
_write(home / "config.yaml", "model:\n default: user/model\n")
_write(managed / "config.yaml", "model:\n default: managed/model\n")
assert cfg_get(load_config(), "model", "default") == "managed/model"
def test_managed_leaf_does_not_freeze_siblings(homes):
"""D3/Q4: pinning model.default leaves model.fallback user-controlled."""
from hermes_cli.config import load_config, cfg_get
home, managed = homes
_write(home / "config.yaml", "model:\n default: user/model\n fallback: user/fb\n")
_write(managed / "config.yaml", "model:\n default: managed/model\n")
cfg = load_config()
assert cfg_get(cfg, "model", "default") == "managed/model"
assert cfg_get(cfg, "model", "fallback") == "user/fb" # sibling preserved
def test_no_managed_config_is_unchanged(homes):
from hermes_cli.config import load_config, cfg_get
home, _ = homes
_write(home / "config.yaml", "model:\n default: user/model\n")
assert cfg_get(load_config(), "model", "default") == "user/model"
def test_managed_list_wins_wholesale(homes):
"""D3: a managed list value replaces the user's wholesale."""
from hermes_cli.config import load_config, cfg_get
home, managed = homes
_write(home / "config.yaml", "toolsets:\n enabled: [a, b, c]\n")
_write(managed / "config.yaml", "toolsets:\n enabled: [x]\n")
assert cfg_get(load_config(), "toolsets", "enabled") == ["x"]
def test_editing_managed_file_invalidates_cache(homes):
from hermes_cli.config import load_config, cfg_get
home, managed = homes
_write(home / "config.yaml", "model:\n default: user/model\n")
_write(managed / "config.yaml", "model:\n default: managed/v1\n")
assert cfg_get(load_config(), "model", "default") == "managed/v1"
_write(managed / "config.yaml", "model:\n default: managed/v2\n")
assert cfg_get(load_config(), "model", "default") == "managed/v2"
def test_user_cannot_shadow_managed_literal_via_envref(homes, monkeypatch):
"""A managed literal must NOT be expandable via a ${VAR} the user controls.
The managed value is a plain literal 'managed/locked' with no ${...}, so a
user-defined env var has nothing to substitute. This asserts the managed
literal survives verbatim regardless of user env, and that managed wins.
"""
from hermes_cli.config import load_config, cfg_get
home, managed = homes
monkeypatch.setenv("EVIL", "user/override")
_write(home / "config.yaml", "model:\n default: ${EVIL}\n")
_write(managed / "config.yaml", "model:\n default: managed/locked\n")
assert cfg_get(load_config(), "model", "default") == "managed/locked"