Files
hermes-agent/hermes_cli/config_effective.py
teknium1 a1e7f74e64 fix: stat signatures cover inode + ctime everywhere config identity is cached
The cherry-picked commit extends the config/profile/MCP/managed-scope/completer/
OAuth/skills-manifest signatures. This commit finishes the class and trims it:

- `file_signature()` lives in `utils.py` next to the other stat/metadata helpers
  instead of `hermes_cli.managed_scope` (gateway/ and agent/ callers no longer
  reach into the managed-scope module for a generic stat helper).
- `hermes_cli/config_effective.py` was left comparing 2-/4-wide prefixes against
  the widened `_RAW_CONFIG_CACHE` / `_load_config_cache_sig` records, so
  `load_user_config_effective()` re-parsed on every call (3 parses for 3 calls on
  an unchanged file, 1 before); index by the new widths.
- Sibling caches keyed on the same (mtime, size) shape and reading the SAME files
  now use the helper: `load_env()` memo, `agent/skill_utils` raw-config and
  external-dirs caches, `hermes_cli/model_switch` alias identity, `agent/moa_loop`
  preset stamp, `hermes_cli/auth` global auth-store memo.
- Tests trimmed to one invariant each (pinned-mtime replacement invalidates; an
  unchanged file still hits), both red on origin/main.

Left alone on purpose: `tools/registry.py`, `tools/skills_tool_dedup.py`,
`gateway/status.py`, `hermes_cli/banner.py`, `hermes_cli/main.py`,
`hermes_cli/session_recovery.py` — those fingerprint source files, PID/lock files
or write to persisted on-disk caches shared across processes, where an inode/ctime
key would churn on every checkout/copy rather than catch a replaced config.
2026-09-15 06:29:50 -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_signature, *managed_signature, effective, env_snapshot); see utils.file_signature.
_EFFECTIVE_CACHE: Dict[str, Tuple[Any, ...]] = {}
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[:8] == cache_sig:
if all(_config._env_ref_lookup(k) == v for k, v in cached[9].items()):
return copy.deepcopy(cached[8])
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[:4] == user_sig:
raw = copy.deepcopy(raw_hit[4]) # 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