Files
hermes-agent/hermes_cli/config_effective.py
teknium1 5aa1a50c57 fix(config): good-config backup only for the active home; fixture-based effective-config contract
- load_user_config_effective wrote backups/config/*.good.* into ANY home it
  read, so doctor and the TUI cwd lookup created backup dirs inside other
  profiles. The copy is now taken only when the path is the active home's
  config (the only home load_config ever backed up).
- The effective-config test re-composed the implementation's own primitives
  (a mirror); it now pins a literal expected dict for a fixture of user file +
  managed overlay + env, and fail_closed asserts yaml.YAMLError.
- Four repointed gateway tests carried duplicate _load_gateway_config setattr
  lines (one silently overriding the other); deduped to the intended dict.
2026-09-13 05:09:06 -07:00

109 lines
5.9 KiB
Python

"""The effective USER config: config.yaml + managed overlay + ``${VAR}`` expansion, no defaults.
``load_config()`` merges ``DEFAULT_CONFIG`` first, which is wrong for readers that treat a
missing key as "unset" (the gateway's presence-sensitive env bridge, ``cfg == {}`` sentinels,
cron model pinning) — so nine surfaces used to hand-roll raw-read → overlay → expand in
differing orders and none of them replayed the model-key canonicalization or the last-known-good
recovery ``load_config()`` gained. This module is that one primitive.
Order matches ``_load_config_impl``: the user layer is expanded BEFORE the managed overlay so a
managed ``${VAR}`` resolves against the process environment only (``apply_managed_overlay``
expands it) and can never be re-resolved through a profile's secret scope
(docs/design/managed-scope.md §4.1). ``read_user_config_raw`` stays the write-back primitive.
"""
from __future__ import annotations
import copy
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from hermes_cli import config as _config
from hermes_cli import managed_scope
from utils import fast_safe_load
# path -> raw user mapping from the last successful parse in this process; served (through the
# normal pipeline) when the file is later found mid-edit as broken YAML.
_LAST_GOOD_USER_RAW: Dict[str, Dict[str, Any]] = {}
# path -> (user_mtime_ns, user_size, managed_mtime_ns, managed_size, effective, env_snapshot).
_EFFECTIVE_CACHE: Dict[str, Tuple[int, int, int, int, Dict[str, Any], Dict[str, Optional[str]]]] = {}
def _effective(raw: Dict[str, Any]) -> Dict[str, Any]:
expanded = _config._expand_env_vars(raw)
merged = managed_scope.apply_managed_overlay(expanded if isinstance(expanded, dict) else {})
return _config._normalize_root_model_keys(merged if isinstance(merged, dict) else {})
def _recover_user_raw(config_path: Path, path_key: str, exc: Exception) -> Dict[str, Any]:
"""Last-known-good raw user mapping after a parse failure: this process's last good parse,
else the newest ``good`` copy in backups/config/, else ``{}`` (warned as defaults)."""
raw = _LAST_GOOD_USER_RAW.get(path_key)
fallback = "last-known-good"
if raw is None:
from hermes_cli.config_backups import load_newest_good_backup
raw = load_newest_good_backup(config_path)
fallback = "last-known-good-backup"
_config._warn_config_parse_failure(config_path, exc, fallback=fallback if raw is not None else "defaults")
return copy.deepcopy(raw) if raw is not None else {}
def load_user_config_effective(config_path: Optional[Path] = None, *, fail_closed: bool = False) -> Dict[str, Any]:
"""User ``config.yaml`` → ``${VAR}`` expansion → managed overlay → model-key canonicalization.
NO ``DEFAULT_CONFIG`` merge: a key absent from the file (and from the managed layer) is absent
here, so ``{}`` sentinels and presence-sensitive bridges keep working. An absent file is an
empty user layer (the managed layer still applies). Returns a fresh deepcopy.
Broken YAML: ``fail_closed=True`` raises the parse error (for callers that keep their own
last-good state); otherwise the last successfully parsed user file — in-process first, then
the newest ``backups/config/*.good.*`` copy — is served through the same pipeline, so a
mid-edit torn write never silently drops user overrides (same contract as ``load_config``).
Cached on the user + managed file signatures and the values of every referenced env var."""
if config_path is None:
config_path = _config.get_config_path()
path_key = str(config_path)
with _config._CONFIG_LOCK:
user_sig, cache_sig = _config._load_config_cache_sig(config_path)
cached = _EFFECTIVE_CACHE.get(path_key)
if cached is not None and cache_sig is not None and cached[:4] == cache_sig:
if all(_config._env_ref_lookup(k) == v for k, v in cached[5].items()):
return copy.deepcopy(cached[4])
raw: Dict[str, Any] = {}
recovered = False
raw_hit = _config._RAW_CONFIG_CACHE.get(path_key)
if user_sig is not None and raw_hit is not None and raw_hit[:2] == user_sig:
raw = copy.deepcopy(raw_hit[2]) # one parse per process, shared with read_raw_config()
_LAST_GOOD_USER_RAW.setdefault(path_key, copy.deepcopy(raw))
elif user_sig is not None:
try:
with open(config_path, encoding="utf-8") as f:
loaded = fast_safe_load(f)
except Exception as exc:
if fail_closed:
raise
raw, recovered = _recover_user_raw(config_path, path_key, exc), True
else:
raw = loaded if isinstance(loaded, dict) else {}
_config._RAW_CONFIG_CACHE[path_key] = (*user_sig, copy.deepcopy(raw))
_LAST_GOOD_USER_RAW[path_key] = copy.deepcopy(raw)
# Same copy load_config keeps: a fresh process recovers from it (see _recover_user_raw).
# Only for the ACTIVE home — a read of another profile's file (doctor, TUI cwd lookup)
# must not create backups/ inside that profile.
if config_path == _config.get_config_path():
from hermes_cli.config_backups import backup_config
backup_config(config_path, "good")
env_snapshot = _config._env_ref_snapshot(raw)
managed = managed_scope.load_managed_config()
if managed:
_config._env_ref_snapshot(managed, env_snapshot)
effective = _effective(raw)
# A recovered result is never cached under the corrupt file's signature: a later
# ``fail_closed`` caller must still see the parse error, not a cache hit.
if cache_sig is not None and not recovered:
_EFFECTIVE_CACHE[path_key] = (*cache_sig, copy.deepcopy(effective), env_snapshot)
else:
_EFFECTIVE_CACHE.pop(path_key, None)
return effective