fix: normalize managed config overlay before merge in load_config

The shared load-boundary flatten added for dict-valued model.default only
ran on the user/default merge; _load_config_impl then deep-merged the raw
managed overlay without normalizing, so a managed model.default:
{provider, model} still reached status/fallback/runtime readers as a dict.

Normalize the managed overlay (same _normalize_root_model_keys pass, plus
the bare model-string -> model.default promotion used by
managed_scope.apply_managed_overlay) before expanding and merging, so
every overlay is canonical before load_config returns.

Adds load_config() regressions for a nested managed default and a bare
managed model string.
This commit is contained in:
mariobgsp
2026-08-12 05:14:48 +07:00
committed by kshitij
parent 998329a621
commit be708ff1b9
2 changed files with 49 additions and 1 deletions
+12 -1
View File
@@ -3586,7 +3586,18 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
# keys the managed layer pins — see docs/design/managed-scope.md §4.1.
managed_config = managed_scope.load_managed_config()
if managed_config:
managed_expanded = _expand_env_vars(managed_config)
# Normalize the managed overlay through the same canonicalization as
# the user config BEFORE merging (parity with
# managed_scope.apply_managed_overlay): a dict-valued
# ``model.default`` (``{provider: ..., model: ...}``) or a bare
# ``model: <string>`` must be flattened to a string ``default``
# paired with ``provider`` so the merged result never exposes a
# nested dict to status/fallback/runtime readers.
managed_normalized = _normalize_root_model_keys(managed_config)
if isinstance(managed_normalized.get("model"), str):
managed_normalized = dict(managed_normalized)
managed_normalized["model"] = {"default": managed_normalized["model"]}
managed_expanded = _expand_env_vars(managed_normalized)
expanded = _deep_merge(expanded, managed_expanded)
_LAST_EXPANDED_CONFIG_BY_PATH[path_key] = copy.deepcopy(expanded)
if cache_sig is not None:
@@ -64,3 +64,40 @@ def test_user_cannot_shadow_managed_literal_via_envref(homes, monkeypatch):
_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"
def test_managed_nested_dict_default_flattens_on_load(homes):
"""A dict-valued managed ``model.default`` must flatten on load.
``load_config()`` merges the managed overlay after its single
normalization pass, so a managed ``model.default: {provider: ...,
model: ...}`` used to reach runtime readers as a raw dict. The overlay
is now normalized before merging (parity with
``managed_scope.apply_managed_overlay``), so the merged config exposes a
string ``default`` paired with the nested ``provider``.
"""
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:\n provider: nous\n model: managed/nested\n")
cfg = load_config()
assert cfg_get(cfg, "model", "default") == "managed/nested"
assert cfg_get(cfg, "model", "provider") == "nous"
def test_managed_bare_string_model_flattens_to_default_on_load(homes):
"""A bare ``model: <string>`` in the managed file stays a dict shape.
Mirrors the existing managed-overlay contract: a bare string model must
merge as ``model.default`` so readers that do
``cfg["model"]["default"]`` keep working (never a bare string at
``cfg["model"]``).
"""
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: managed/bare\n")
cfg = load_config()
assert cfg_get(cfg, "model", "default") == "managed/bare"