b91088d768
Every defaults-free config reader (gateway runtime, TUI gateway, cron
scheduler + job snapshot, `hermes send` env bridge, doctor memory section,
hermes_cli/main early parse, hermes_time, hermes_logging, the gateway
fallback-chain refresh) re-implemented "read config.yaml + managed overlay +
${VAR} expansion" by hand, in three different orders, and none of them
replayed the model-key canonicalization or the last-known-good recovery that
load_config() gained. An admin-pinned `${VAR}` expanded on one surface and was
bridged literally on another; `model: {name: x}` resolved to an empty model
everywhere except the gateway.
hermes_cli/config_effective.py::load_user_config_effective is the one
primitive: user file → ${VAR} → managed overlay → _normalize_root_model_keys,
no DEFAULT_CONFIG merge, sharing read_raw_config's parse cache and serving the
last good parse (in-process, then backups/config/*.good.*) on torn YAML;
`fail_closed=True` raises for the one caller that keeps its own last-good
state (the fallback-chain refresh). gateway/run.py::_load_gateway_runtime_config
is deleted — it was _load_gateway_config plus expansion, and _load_gateway_config
now expands.
Behavior change: _load_bridge_config, send_cmd._load_hermes_env and
doctor_state._doctor_memory_config expanded BEFORE the overlay; they now match
load_config (managed `${VAR}` expands against the process env only). All nine
sites gain model-key canonicalization and last-good recovery.
send_cmd._load_hermes_env now routes its .env read through
env_loader._load_dotenv_with_fallback so the credential sanitizer runs.
106 lines
5.7 KiB
Python
106 lines
5.7 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).
|
|
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
|