From b91088d768e18697d522a28602dd8116b81310fb Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:28:17 -0700 Subject: [PATCH] =?UTF-8?q?refactor(config):=20one=20effective-user-config?= =?UTF-8?q?=20loader=20replaces=209=20hand-rolled=20raw=E2=86=92overlay?= =?UTF-8?q?=E2=86=92expand=20pipelines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cron/jobs.py | 8 +- cron/scheduler.py | 11 +- gateway/run.py | 77 ++---------- gateway/run_adapters.py | 4 +- gateway/run_config_loaders.py | 57 ++++----- gateway/slash_commands.py | 4 +- hermes_cli/AGENTS.md | 10 +- hermes_cli/config_effective.py | 105 +++++++++++++++++ hermes_cli/doctor_state.py | 8 +- hermes_cli/main.py | 16 +-- hermes_cli/send_cmd.py | 45 ++----- hermes_logging.py | 16 +-- hermes_time.py | 16 +-- tests/agent/test_fast_mode_auto.py | 2 +- tests/cron/test_scheduler.py | 9 +- tests/gateway/test_busy_command.py | 2 +- .../test_custom_provider_request_overrides.py | 4 +- tests/gateway/test_fast_command.py | 2 +- .../test_multiplex_adapter_registry.py | 4 +- .../gateway/test_multiplex_busy_input_mode.py | 2 +- .../test_reasoning_config_per_model.py | 8 +- .../test_streaming_tts_gateway_regression.py | 2 +- tests/hermes_cli/test_config_effective.py | 110 ++++++++++++++++++ tests/hermes_cli/test_send_cmd.py | 9 +- .../test_reasoning_config_per_model.py | 2 +- tui_gateway/server.py | 36 ++---- 26 files changed, 327 insertions(+), 242 deletions(-) create mode 100644 hermes_cli/config_effective.py create mode 100644 tests/hermes_cli/test_config_effective.py diff --git a/cron/jobs.py b/cron/jobs.py index 8e3c2ac09e..6002eb3a47 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -1497,16 +1497,12 @@ def _resolve_default_model_snapshot() -> Optional[str]: """Default model resolved as the ticker's ``run_job`` does, so unpinned jobs can snapshot it and keep running on it after a later swap. ``None`` on missing config or failure ("no snapshot").""" try: - from hermes_cli.config import _expand_env_vars, read_user_config_raw + from hermes_cli.config_effective import load_user_config_effective cfg_path = get_hermes_home() / "config.yaml" if not cfg_path.exists(): return None - cfg = read_user_config_raw(cfg_path) - with contextlib.suppress(Exception): - from hermes_cli import managed_scope - cfg = managed_scope.apply_managed_overlay(cfg) - cfg = _expand_env_vars(cfg) + cfg = load_user_config_effective(cfg_path) cron_cfg = cfg.get("cron") or {} if isinstance(cron_cfg, dict): cron_model = cron_cfg.get("model") diff --git a/cron/scheduler.py b/cron/scheduler.py index 96c8292d4b..498f2a40ab 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -39,7 +39,7 @@ from hermes_constants import get_hermes_home from cron.env_settings import cron_env_setting from hermes_cli._subprocess_compat import windows_hide_flags from hermes_cli.config import ( - _expand_env_vars, load_config, load_config_readonly, resolve_cron_model_drift_defaults) + load_config, load_config_readonly, resolve_cron_model_drift_defaults) from hermes_cli.fallback_config import get_fallback_chain from hermes_time import now as _hermes_now from agent.interrupt_compat import request_hard_interrupt @@ -1378,15 +1378,10 @@ def _load_cron_job_config(job: dict, job_id: str, job_name: str) -> _CronJobConf _cfg: dict = {} _model_cfg: Any = {} try: - from hermes_cli.config import read_user_config_raw + from hermes_cli.config_effective import load_user_config_effective _cfg_path = str(_get_hermes_home() / "config.yaml") if os.path.exists(_cfg_path): - _cfg = read_user_config_raw(Path(_cfg_path)) - # Honor administrator-pinned managed scope (fail-open; no-op without managed scope). - with contextlib.suppress(Exception): - from hermes_cli import managed_scope - _cfg = managed_scope.apply_managed_overlay(_cfg) - _cfg = _expand_env_vars(_cfg) + _cfg = load_user_config_effective(Path(_cfg_path)) # Coerce null to {} so a falsy default never clobbers a resolved env value. _model_cfg = _cfg.get("model") or {} _cron_cfg_for_model = _cfg.get("cron") or {} diff --git a/gateway/run.py b/gateway/run.py index 4e982e242a..6b02c6850d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2021,19 +2021,10 @@ def _bridge_config_to_env(_cfg: dict) -> None: def _load_bridge_config(config_path: Path) -> dict: - """Raw config read for the presence-sensitive env bridge, with the managed overlay applied. Raw (not - defaults-merged) so only keys the user wrote are bridged, else all of DEFAULT_CONFIG would be - exported; the overlay applies BEFORE bridging so pinned values win in env too.""" - from hermes_cli.config import _expand_env_vars, read_user_config_raw - cfg = _expand_env_vars(read_user_config_raw(config_path)) - if not isinstance(cfg, dict): - cfg = {} - try: - from hermes_cli import managed_scope - cfg = managed_scope.apply_managed_overlay(cfg) - except Exception: - pass - return cfg + """Effective USER config (no defaults) for the presence-sensitive env bridge: only keys the user + or the managed layer wrote get bridged, else all of DEFAULT_CONFIG would be exported.""" + from hermes_cli.config_effective import load_user_config_effective + return load_user_config_effective(config_path) _config_path = _hermes_home / 'config.yaml' @@ -2385,8 +2376,7 @@ def _try_resolve_fallback_provider() -> dict | None: """Attempt to resolve credentials from the fallback_model/fallback_providers config.""" from hermes_cli.runtime_provider import resolve_runtime_provider try: - # Canonical loader so managed overlay / ${VAR} expansion reach the fallback chain. - cfg = _load_gateway_runtime_config() + cfg = _load_gateway_config() fb_list = get_fallback_chain(cfg) if not fb_list: return None @@ -2791,51 +2781,18 @@ def _gateway_config_home() -> Path: def _load_gateway_config(config_path: "Path | None" = None) -> dict: - """Load and parse a gateway config.yaml, returning {} on any error (fail-open). - Defaults to the active gateway home (``_hermes_home`` monkeypatches apply); multiplexers pass a path. + """The effective user config.yaml (managed overlay, ``${VAR}`` expansion, model-key canon; no + DEFAULT_CONFIG merge) — ``{}`` on any error (fail-open). Defaults to the active gateway home + (``_hermes_home`` monkeypatches apply); multiplexers pass a path. """ if config_path is None: config_path = _gateway_config_home() / 'config.yaml' - raw: dict = {} - used_canonical = False try: - from hermes_cli.config import get_config_path, read_raw_config - # Fast path via shared cache when the path is canonical; else direct read (test monkeypatches). - if config_path == get_config_path(): - raw = read_raw_config() - used_canonical = True + from hermes_cli.config_effective import load_user_config_effective + return load_user_config_effective(config_path) except Exception: - pass - - if not used_canonical: - try: - if config_path.exists(): - import yaml - with open(config_path, 'r', encoding='utf-8') as f: - raw = yaml.safe_load(f) or {} - except Exception: - logger.debug("Could not load gateway config from %s", config_path) - raw = {} - - # Neither read_raw_config() nor yaml.safe_load carries the managed merge; overlay on both paths. - try: - from hermes_cli import managed_scope - raw = managed_scope.apply_managed_overlay(raw if isinstance(raw, dict) else {}) - except Exception: - pass - if not isinstance(raw, dict): + logger.debug("Could not load gateway config from %s", config_path, exc_info=True) return {} - # Canonicalize model-id aliases (model.name/model.model → model.default) and migrate stale root - # provider/base_url: the gateway bypasses load_config(), else ``model: {name: }`` is empty. - try: - # The gateway bypasses load_config() (it reads raw YAML for speed), so the normalization that - # load_config() applies must be replayed here or the gateway would resolve an empty model for - # ``model: {name: }`` configs while the CLI resolves it correctly. See issue #34500. Fail-open. - from hermes_cli.config import _normalize_root_model_keys - raw = _normalize_root_model_keys(raw) - except Exception: - pass - return raw def _checkpoint_agent_kwargs(config: dict | None) -> dict: @@ -2855,18 +2812,6 @@ def _checkpoint_agent_kwargs(config: dict | None) -> dict: "checkpoint_max_file_size_mb": cp_cfg.get("max_file_size_mb", defaults["max_file_size_mb"])} -def _load_gateway_runtime_config() -> dict: - """Load gateway config for runtime reads, expanding supported ``${VAR}`` refs. - Expansion failures are deliberately NOT swallowed: an unexpanded dict would mask the bug fixed here. - """ - cfg = _load_gateway_config() - if not isinstance(cfg, dict) or not cfg: - return {} - from hermes_cli.config import _expand_env_vars - expanded = _expand_env_vars(cfg) - return expanded if isinstance(expanded, dict) else {} - - def _resolve_gateway_model(config: dict | None = None) -> str: """Read model from config.yaml (single source of truth), else temporary AIAgents (e.g. /compress) use the hardcoded default, which fails under openai-codex.""" diff --git a/gateway/run_adapters.py b/gateway/run_adapters.py index 983428f30a..6c16c7d298 100644 --- a/gateway/run_adapters.py +++ b/gateway/run_adapters.py @@ -887,7 +887,7 @@ class GatewayAdapterLifecycleMixin: default profile owns the single shared listener and a secondary's port-binders are built in shared-listener mode (``/p//...``) by ``_start_one_profile_adapters``.""" from gateway.run import ( - MultiplexConfigError, _load_gateway_runtime_config, + MultiplexConfigError, _load_gateway_config, _own_policy_open_startup_violation, _profile_runtime_scope, ) from gateway.config import load_gateway_config @@ -895,7 +895,7 @@ class GatewayAdapterLifecycleMixin: # Hydrate external secret sources off-loop ONCE: sync hydration would stall every heartbeat. await asyncio.to_thread(hydrate_profile_secret_sources, profile_home) with _profile_runtime_scope(profile_home, hydrate_secrets=False): - profile_runtime_cfg = _load_gateway_runtime_config() + profile_runtime_cfg = _load_gateway_config() from hermes_cli.plugins import discover_plugins discover_plugins() # This profile's `hooks:` block: start() registered before any profile scope existed. diff --git a/gateway/run_config_loaders.py b/gateway/run_config_loaders.py index 7a02647966..7bbd4ed237 100644 --- a/gateway/run_config_loaders.py +++ b/gateway/run_config_loaders.py @@ -45,8 +45,8 @@ class GatewayConfigLoadersMixin: @staticmethod def _cfg_str(section: str, key: str) -> str: """``
.`` from the gateway runtime config as a stripped string ("" when unset).""" - from gateway.run import _load_gateway_runtime_config - return str(cfg_get(_load_gateway_runtime_config(), section, key, default="") or "").strip() + from gateway.run import _load_gateway_config + return str(cfg_get(_load_gateway_config(), section, key, default="") or "").strip() @classmethod def _env_or_cfg_str(cls, env_var: str, section: str, key: str) -> str: @@ -60,10 +60,10 @@ class GatewayConfigLoadersMixin: HERMES_PREFILL_MESSAGES_FILE env wins, then top-level prefill_messages_file in config.yaml, then legacy agent.prefill_messages_file. Relative paths resolve from ~/.hermes/. """ - from gateway.run import _gateway_config_home, _load_gateway_runtime_config + from gateway.run import _gateway_config_home, _load_gateway_config file_path = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") if not file_path: - cfg = _load_gateway_runtime_config() + cfg = _load_gateway_config() file_path = str( cfg.get("prefill_messages_file", "") or cfg_get(cfg, "agent", "prefill_messages_file", default="") or "" ) @@ -89,11 +89,11 @@ class GatewayConfigLoadersMixin: @staticmethod def _load_ephemeral_system_prompt() -> str: """HERMES_EPHEMERAL_SYSTEM_PROMPT env first, then ``display.personality`` / ``agent.system_prompt``.""" - from gateway.run import _load_gateway_runtime_config + from gateway.run import _load_gateway_config prompt = os.getenv("HERMES_EPHEMERAL_SYSTEM_PROMPT", "") if prompt: return prompt - return resolve_ephemeral_system_prompt_from_config(_load_gateway_runtime_config()) + return resolve_ephemeral_system_prompt_from_config(_load_gateway_config()) def _channel_override(self, platform: Platform, chat_id: str, thread_id, parent_id): """``channel_overrides`` entry for this channel/thread, or None (also when no config is bound).""" @@ -151,9 +151,9 @@ class GatewayConfigLoadersMixin: Closes #21256. """ - from gateway.run import _load_gateway_runtime_config + from gateway.run import _load_gateway_config from hermes_constants import resolve_reasoning_config - return resolve_reasoning_config(_load_gateway_runtime_config(), model) + return resolve_reasoning_config(_load_gateway_config(), model) @staticmethod def _parse_reasoning_command_args(raw_args: str) -> tuple[str, bool]: @@ -234,8 +234,8 @@ class GatewayConfigLoadersMixin: @staticmethod def _load_show_reasoning() -> bool: """``display.show_reasoning`` toggle.""" - from gateway.run import _load_gateway_runtime_config - return is_truthy_value(cfg_get(_load_gateway_runtime_config(), "display", "show_reasoning"), default=False) + from gateway.run import _load_gateway_config + return is_truthy_value(cfg_get(_load_gateway_config(), "display", "show_reasoning"), default=False) @classmethod def _load_busy_input_mode(cls) -> str: @@ -330,12 +330,12 @@ class GatewayConfigLoadersMixin: """Env var (non-empty) else ``agent.``; warn once when a supplied value fails to parse. ``0`` is a valid value; the parser falls back to ``default`` on garbage.""" - from gateway.run import _load_gateway_runtime_config + from gateway.run import _load_gateway_config env_raw = os.getenv(env_var) if env_raw is not None and str(env_raw).strip() != "": raw: object = env_raw else: - raw = cfg_get(_load_gateway_runtime_config(), "agent", cfg_key, default=None) + raw = cfg_get(_load_gateway_config(), "agent", cfg_key, default=None) value = parse(raw) if raw is not None and str(raw).strip() != "": cls._warn_unparsable_timeout(cfg_key, raw, default) @@ -363,8 +363,8 @@ class GatewayConfigLoadersMixin: @classmethod def _load_signal_interrupt_grace_timeout(cls) -> float: """``gateway.signal_interrupt_grace_timeout``: unexpected-signal post-interrupt grace in seconds.""" - from gateway.run import _load_gateway_runtime_config - raw = cfg_get(_load_gateway_runtime_config(), "gateway", "signal_interrupt_grace_timeout", default=None) + from gateway.run import _load_gateway_config + raw = cfg_get(_load_gateway_config(), "gateway", "signal_interrupt_grace_timeout", default=None) value = parse_signal_interrupt_grace_timeout(raw) if raw is not None and raw != "": cls._warn_unparsable_timeout( @@ -385,11 +385,11 @@ class GatewayConfigLoadersMixin: the AMBIENT profile — callers deciding for another profile's event enter its scope first (``_completion_event_scope``). The env override reads through the secret scope so a served secondary sees its own ``.env`` value, not the launch profile's ``os.environ``.""" - from gateway.run import _load_gateway_runtime_config + from gateway.run import _load_gateway_config from gateway.authz_mixin import _platform_gate_env mode = _platform_gate_env("HERMES_BACKGROUND_NOTIFICATIONS") if not mode: - raw = cfg_get(_load_gateway_runtime_config(), "display", "background_process_notifications") + raw = cfg_get(_load_gateway_config(), "display", "background_process_notifications") if raw is False: mode = "off" elif raw not in {None, ""}: @@ -403,19 +403,19 @@ class GatewayConfigLoadersMixin: @staticmethod def _load_provider_routing() -> dict: """OpenRouter provider routing preferences (canonical fail-open loader: managed overlay + ${VAR}).""" - from gateway.run import _load_gateway_runtime_config + from gateway.run import _load_gateway_config try: - return _load_gateway_runtime_config().get("provider_routing", {}) or {} + return _load_gateway_config().get("provider_routing", {}) or {} except Exception: return {} @staticmethod def _load_fallback_model() -> list | None: """Fallback chain: ``fallback_providers`` (kept first) merged with legacy ``fallback_model``.""" - from gateway.run import _load_gateway_runtime_config + from gateway.run import _load_gateway_config try: # Canonical gateway loader (fail-open): managed overlay + ${VAR} expansion apply here too. - return get_fallback_chain(_load_gateway_runtime_config()) or None + return get_fallback_chain(_load_gateway_config()) or None except Exception: return None @@ -443,23 +443,14 @@ class GatewayConfigLoadersMixin: by_home = self._fallback_model_by_home = {} home_key = hermes_home_key(home) try: - from hermes_cli.config import read_user_config_raw + from hermes_cli.config_effective import load_user_config_effective cfg_path = home / "config.yaml" if not cfg_path.exists(): by_home[home_key] = self._fallback_model = None return self._fallback_model - # Raw primitive (raises on parse failure) is required here: the canonical fail-open - # loader would return {} on a torn mid-edit write and WIPE the last known-good chain. - # The overlay/expansion below fixes the managed-scope/${VAR} drift without losing that. - cfg = read_user_config_raw(cfg_path) - with suppress(Exception): - from hermes_cli import managed_scope - cfg = managed_scope.apply_managed_overlay(cfg) - with suppress(Exception): - from hermes_cli.config import _expand_env_vars - expanded = _expand_env_vars(cfg) - if isinstance(expanded, dict): - cfg = expanded + # fail_closed: a torn mid-edit write must raise so the per-home last known-good chain + # below survives, instead of being WIPED by an empty fail-open result. + cfg = load_user_config_effective(cfg_path, fail_closed=True) except Exception: logger.debug("fallback_providers refresh: config.yaml read failed; keeping last known-good chain", exc_info=True) self._fallback_model = by_home.get(home_key, self._fallback_model) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 6d2b843272..23ea8ad6ee 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -950,8 +950,8 @@ class GatewaySlashCommandsMixin( return EphemeralReply("Busy input mode could not be saved to config. Mode unchanged.") profile_name = self._busy_profile_name_for_source(event.source) if profile_name: - from gateway.run import _load_gateway_runtime_config - self._snapshot_profile_busy_modes(profile_name, _load_gateway_runtime_config()) + from gateway.run import _load_gateway_config + self._snapshot_profile_busy_modes(profile_name, _load_gateway_config()) else: self._busy_input_mode = arg # busy_input_mode is also the source of truth for the text mode — re-derive it so the diff --git a/hermes_cli/AGENTS.md b/hermes_cli/AGENTS.md index b9896c080e..fba35ecf04 100644 --- a/hermes_cli/AGENTS.md +++ b/hermes_cli/AGENTS.md @@ -67,9 +67,13 @@ Do not add a surface-specific goal parser. ACP has no goal command or goal loop (`gateway_timeout`; `terminal.cwd` → `TERMINAL_CWD`). `MESSAGING_CWD` is removed and `TERMINAL_CWD` in `.env` is deprecated — the loader warns; canonical is `terminal.cwd`. - **Three loaders — know which you're in:** `load_cli_config()` (CLI, `cli.py`); `load_config()` - (`hermes tools/setup`, most subcommands, `hermes_cli/config.py`, merges `DEFAULT_CONFIG`); raw - YAML (gateway runtime, `gateway/run.py` + `gateway/config.py`). If the CLI sees a key and the - gateway doesn't (or vice versa), you're on the wrong loader — check `DEFAULT_CONFIG` coverage. + (`hermes tools/setup`, most subcommands, `hermes_cli/config.py`, merges `DEFAULT_CONFIG`); + `hermes_cli/config_effective.py::load_user_config_effective()` (gateway runtime via + `gateway/run.py::_load_gateway_config`, TUI gateway `_load_cfg`, cron, `hermes send`, doctor, + `hermes_time`/`hermes_logging`: user file + managed overlay + `${VAR}` expansion + model-key + canon, NO defaults — for presence-sensitive readers). If the CLI sees a key and the gateway + doesn't (or vice versa), you're on the wrong loader — check `DEFAULT_CONFIG` coverage. Never + hand-roll raw-read → overlay → expand; `read_user_config_raw` is for write-back round-trips only. - **Working directory:** CLI uses `os.getcwd()`; messaging uses `terminal.cwd`, bridged to `TERMINAL_CWD` for child tools. diff --git a/hermes_cli/config_effective.py b/hermes_cli/config_effective.py new file mode 100644 index 0000000000..518dc47fb7 --- /dev/null +++ b/hermes_cli/config_effective.py @@ -0,0 +1,105 @@ +"""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 diff --git a/hermes_cli/doctor_state.py b/hermes_cli/doctor_state.py index 8ec42f2e56..6702cd54ad 100644 --- a/hermes_cli/doctor_state.py +++ b/hermes_cli/doctor_state.py @@ -27,15 +27,11 @@ def _doctor_memory_config(hermes_home: Path | None = None) -> dict: """Return the effective memory section used by doctor diagnostics.""" from hermes_cli.doctor import HERMES_HOME try: - from hermes_cli.config import _expand_env_vars, read_user_config_raw + from hermes_cli.config_effective import load_user_config_effective config_path = (hermes_home if hermes_home is not None else HERMES_HOME) / "config.yaml" if not config_path.exists(): return {} - config = _expand_env_vars(read_user_config_raw(config_path)) - with warn_on_error(""): - from hermes_cli import managed_scope - config = managed_scope.apply_managed_overlay(config) - section = config.get("memory") if isinstance(config, dict) else None + section = load_user_config_effective(config_path).get("memory") return section if isinstance(section, dict) else {} except Exception: return {} diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3f0a981fd0..b11f7c1599 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -604,20 +604,14 @@ load_hermes_dotenv( # is read from the same parse to avoid a second full load_config() (~17ms). _FORCE_IPV4_EARLY = False try: - # read_raw_config()'s (mtime, size)-keyed cache means this SAME parse serves - # hermes_logging and later raw reads: 3-4 config.yaml parses become one. - from hermes_cli.config import read_raw_config as _read_raw_early + # The effective-config cache (shared raw parse with read_raw_config()) means this SAME parse + # serves hermes_logging, hermes_time and later raw reads: 3-4 config.yaml parses become one. + # Managed overlay included: administrator-pinned redact_secrets / force_ipv4 win here too. + from hermes_cli.config_effective import load_user_config_effective as _load_effective_early _cfg_path = get_hermes_home() / "config.yaml" if _cfg_path.exists(): - _early_cfg_raw = _read_raw_early() or {} - # Managed scope overlay: administrator-pinned redact_secrets / - # force_ipv4 must win here too (load_config isn't usable yet). Fail-open. - try: - from hermes_cli import managed_scope - _early_cfg_raw = managed_scope.apply_managed_overlay(_early_cfg_raw) - except Exception: - pass + _early_cfg_raw = _load_effective_early(_cfg_path) if "HERMES_REDACT_SECRETS" not in os.environ: _early_sec_cfg = _early_cfg_raw.get("security", {}) if isinstance(_early_sec_cfg, dict): diff --git a/hermes_cli/send_cmd.py b/hermes_cli/send_cmd.py index c785d55160..94f0885bcd 100644 --- a/hermes_cli/send_cmd.py +++ b/hermes_cli/send_cmd.py @@ -134,58 +134,31 @@ def _list_targets(platform_filter: Optional[str], *, json_mode: bool) -> int: def _load_hermes_env() -> None: """Populate ``os.environ`` from ``~/.hermes/.env`` AND bridge top-level ``config.yaml`` keys into the environment so the gateway config loader sees platform credentials and home channels.""" - try: - from dotenv import load_dotenv - except Exception: - load_dotenv = None # type: ignore[assignment] + import os try: from hermes_cli.config import get_hermes_home home = get_hermes_home() except Exception: return env_path = home / ".env" - if load_dotenv and env_path.exists(): + if env_path.exists(): try: - # utf-8-sig strips a leading BOM (PowerShell 5.1 / Notepad); plain "utf-8" would keep - # U+FEFF on the first key name and silently drop it from os.environ. - load_dotenv(str(env_path), override=True, encoding="utf-8-sig") - except UnicodeDecodeError: - try: # utf-8-sig can't strip a BOM once we fall back to latin-1. - import codecs - import io - raw = env_path.read_bytes().removeprefix(codecs.BOM_UTF8) - load_dotenv(stream=io.StringIO(raw.decode("latin-1")), override=True) - except Exception: - pass + from hermes_cli.env_loader import _load_dotenv_with_fallback + _load_dotenv_with_fallback(env_path, override=True) except Exception: pass - # Bridge top-level config.yaml scalars into the environment (never overriding existing values). - import os + # Bridge top-level scalars the user (or the managed layer) actually wrote — never DEFAULT_CONFIG — + # into the environment, without overriding existing values. config_path = home / "config.yaml" if not config_path.exists(): return try: - # Raw read is deliberate — only keys the user actually wrote get bridged. - from hermes_cli.config import read_user_config_raw - raw = read_user_config_raw(config_path) + from hermes_cli.config_effective import load_user_config_effective + cfg = load_user_config_effective(config_path) except Exception: return - try: - from hermes_cli.config import _expand_env_vars - raw = _expand_env_vars(raw) - except Exception: - pass - - # Managed scope: administrator-pinned values win here too (fail-open via the helper). - try: - from hermes_cli import managed_scope - raw = managed_scope.apply_managed_overlay(raw if isinstance(raw, dict) else {}) - except Exception: - pass - if not isinstance(raw, dict): - return - for key, val in raw.items(): + for key, val in cfg.items(): if isinstance(val, (str, int, float, bool)) and key not in os.environ: os.environ[key] = str(val) diff --git a/hermes_logging.py b/hermes_logging.py index 68cfbf62ad..4798f0742c 100644 --- a/hermes_logging.py +++ b/hermes_logging.py @@ -604,12 +604,12 @@ def _add_rotating_handler( def _read_logging_config(): """Best-effort read of ``logging.*`` from config.yaml.""" try: - # Prefer the shared (mtime, size)-keyed raw-config cache so this reuses - # hermes_cli.main's early parse (one config.yaml parse per process); - # fall back to a direct parse for bare hermes_logging consumers. + # Prefer the shared effective-config cache (managed overlay included, so an administrator + # can pin logging.*) so this reuses hermes_cli.main's early parse (one config.yaml parse + # per process); fall back to a direct parse for bare hermes_logging consumers. try: - from hermes_cli.config import read_raw_config as _rrc - cfg = _rrc() or {} + from hermes_cli.config_effective import load_user_config_effective + cfg = load_user_config_effective(get_config_path()) except Exception: from utils import fast_safe_load config_path = get_config_path() @@ -619,12 +619,6 @@ def _read_logging_config(): cfg = fast_safe_load(f) or {} if not cfg: return (None, None, None) - # Managed scope: an administrator can pin logging.* too (fail-open overlay). - try: - from hermes_cli import managed_scope - cfg = managed_scope.apply_managed_overlay(cfg) - except Exception: - pass log_cfg = cfg.get("logging", {}) if isinstance(log_cfg, dict): return (log_cfg.get("level"), log_cfg.get("max_size_mb"), log_cfg.get("backup_count")) diff --git a/hermes_time.py b/hermes_time.py index b36a179b72..a8cab27d47 100644 --- a/hermes_time.py +++ b/hermes_time.py @@ -48,22 +48,18 @@ def _resolve_timezone_name() -> str: if tz_env: return tz_env try: - # Prefer the shared cached raw-config reader (mtime-keyed + libyaml): a direct safe_load of - # a large config.yaml costs ~100 ms and this ran inside the FIRST system prompt build. + # Prefer the shared cached effective-config loader (mtime-keyed + libyaml, managed overlay + # included so an administrator can pin ``timezone``): a direct safe_load of a large + # config.yaml costs ~100 ms and this ran inside the FIRST system prompt build. The bare + # parse is the stdlib-safe fallback for bootstrap consumers without hermes_cli importable. try: - from hermes_cli.config import read_raw_config - cfg = read_raw_config() or {} + from hermes_cli.config_effective import load_user_config_effective + cfg = load_user_config_effective(get_config_path()) except Exception: import yaml config_path = get_config_path() cfg = (yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}) if config_path.exists() else {} if cfg: - # Managed scope: an administrator can pin ``timezone`` too (fail-open overlay). - try: - from hermes_cli import managed_scope - cfg = managed_scope.apply_managed_overlay(cfg) - except Exception: - pass tz_cfg = cfg.get("timezone", "") if isinstance(tz_cfg, str) and tz_cfg.strip(): return tz_cfg.strip() diff --git a/tests/agent/test_fast_mode_auto.py b/tests/agent/test_fast_mode_auto.py index a9af81a8df..053bd58704 100644 --- a/tests/agent/test_fast_mode_auto.py +++ b/tests/agent/test_fast_mode_auto.py @@ -104,7 +104,7 @@ def test_fast_auto_and_cold_parse_and_slash_command(monkeypatch): for raw, expected in (("auto", "auto"), ("COLD", "cold"), ("fast", "priority"), ("", None), ("bogus", None)): assert cli_mod._parse_service_tier_config(raw) == expected monkeypatch.setattr( - "gateway.run._load_gateway_runtime_config", lambda: {"agent": {"service_tier": raw}} + "gateway.run._load_gateway_config", lambda: {"agent": {"service_tier": raw}} ) assert GatewayRunner._load_service_tier() == expected assert DEFAULT_CONFIG["agent"]["service_tier"] == "" diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 0947edb459..029040b613 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -1087,7 +1087,8 @@ class TestRunJobConfigLogging: """Verify that config.yaml parse failures are logged, not silently swallowed.""" def test_bad_config_yaml_is_logged(self, caplog, tmp_path): - """When config.yaml is malformed, a warning should be logged.""" + """When config.yaml is malformed, the shared config loader warns loudly (and serves the + last known-good copy instead of silently dropping the user's overrides).""" bad_yaml = tmp_path / "config.yaml" bad_yaml.write_text("invalid: yaml: [[[bad") @@ -1116,11 +1117,11 @@ class TestRunJobConfigLogging: mock_agent.run_conversation.return_value = {"final_response": "ok"} mock_agent_cls.return_value = mock_agent - with caplog.at_level(logging.WARNING, logger="cron.scheduler"): + with caplog.at_level(logging.WARNING): run_job(job) - assert any("failed to load config.yaml" in r.message for r in caplog.records), \ - f"Expected 'failed to load config.yaml' warning in logs, got: {[r.message for r in caplog.records]}" + assert any("Failed to parse" in r.message and "config.yaml" in r.message for r in caplog.records), \ + f"Expected a config.yaml parse warning in logs, got: {[r.message for r in caplog.records]}" class TestRunJobConfigEnvVarExpansion: diff --git a/tests/gateway/test_busy_command.py b/tests/gateway/test_busy_command.py index e2aff70144..1c962cbdb7 100644 --- a/tests/gateway/test_busy_command.py +++ b/tests/gateway/test_busy_command.py @@ -75,7 +75,7 @@ class TestBusyCommandPersistence: # emulate the write that the mocked save_config_value skipped. monkeypatch.setattr( gateway_run, - "_load_gateway_runtime_config", + "_load_gateway_config", lambda: {"display": {"busy_input_mode": new_mode}}, ) monkeypatch.delenv("HERMES_GATEWAY_BUSY_TEXT_MODE", raising=False) diff --git a/tests/gateway/test_custom_provider_request_overrides.py b/tests/gateway/test_custom_provider_request_overrides.py index be7d7a27f9..4089095647 100644 --- a/tests/gateway/test_custom_provider_request_overrides.py +++ b/tests/gateway/test_custom_provider_request_overrides.py @@ -172,7 +172,7 @@ def test_turn_route_merges_fast_mode_with_provider_request_overrides(): @pytest.mark.asyncio async def test_run_agent_preserves_provider_request_overrides_on_gateway_path(monkeypatch): monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) - monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: {}) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4") monkeypatch.setattr( gateway_run, @@ -228,7 +228,7 @@ async def test_reused_agent_turn_merges_request_overrides_not_overwrite(monkeypa fast-mode key while the provider extra_body survives. """ monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) - monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: {}) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4") monkeypatch.setattr( gateway_run, diff --git a/tests/gateway/test_fast_command.py b/tests/gateway/test_fast_command.py index 1d9a63514f..e573df9d87 100644 --- a/tests/gateway/test_fast_command.py +++ b/tests/gateway/test_fast_command.py @@ -157,7 +157,7 @@ async def test_session_fast_override_beats_config_default(monkeypatch, tmp_path) monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) monkeypatch.setattr( gateway_run, - "_load_gateway_runtime_config", + "_load_gateway_config", lambda: {"agent": {"service_tier": "fast"}}, ) monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4") diff --git a/tests/gateway/test_multiplex_adapter_registry.py b/tests/gateway/test_multiplex_adapter_registry.py index 0a16bd2c29..ca0680edbc 100644 --- a/tests/gateway/test_multiplex_adapter_registry.py +++ b/tests/gateway/test_multiplex_adapter_registry.py @@ -301,7 +301,7 @@ class TestSecondaryProfileFatalRecovery: ) monkeypatch.setattr(runner, "_connect_adapter_with_timeout", connect) monkeypatch.setattr(runner, "_connect_initial_adapter_with_timeout", connect) - monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: {}) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) monkeypatch.setattr(runner, "_snapshot_profile_busy_modes", lambda *a, **k: None) monkeypatch.setattr("hermes_cli.plugins.discover_plugins", lambda: None) if entry == "startup": @@ -335,7 +335,7 @@ class TestSecondaryProfileFatalRecovery: synced = [] runner._sync_voice_mode_state_to_adapter = synced.append monkeypatch.setattr("hermes_cli.env_loader.hydrate_profile_secret_sources", lambda h: {}) - monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: {}) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) monkeypatch.setattr(runner, "_snapshot_profile_busy_modes", lambda *a, **k: None) monkeypatch.setattr("hermes_cli.plugins.discover_plugins", lambda: None) diff --git a/tests/gateway/test_multiplex_busy_input_mode.py b/tests/gateway/test_multiplex_busy_input_mode.py index e96b775070..da77663d84 100644 --- a/tests/gateway/test_multiplex_busy_input_mode.py +++ b/tests/gateway/test_multiplex_busy_input_mode.py @@ -444,7 +444,7 @@ async def test_effective_mode_uses_startup_snapshot_without_rereading_config( def fail_config_read(): raise AssertionError("busy-mode lookup reread config after startup") - monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", fail_config_read) + monkeypatch.setattr(gateway_run, "_load_gateway_config", fail_config_read) monkeypatch.setattr(gateway_run, "_load_gateway_config", fail_config_read) assert runner._effective_busy_input_mode(source) == "steer" diff --git a/tests/gateway/test_reasoning_config_per_model.py b/tests/gateway/test_reasoning_config_per_model.py index 27c467cc69..bec5c6b68a 100644 --- a/tests/gateway/test_reasoning_config_per_model.py +++ b/tests/gateway/test_reasoning_config_per_model.py @@ -21,7 +21,7 @@ class TestGatewayPerModelReasoningConfig: }, }, } - monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: fake_cfg) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: fake_cfg) result = gateway_run.GatewayRunner._load_reasoning_config() assert result is not None @@ -42,7 +42,7 @@ class TestGatewayPerModelReasoningConfig: "reasoning_effort": False, # YAML boolean, not string }, } - monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: fake_cfg) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: fake_cfg) result = gateway_run.GatewayRunner._load_reasoning_config() assert result is not None @@ -69,7 +69,7 @@ class TestGatewaySessionEffectiveModel: }, }, } - monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: fake_cfg) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: fake_cfg) # Session switched (session-only) to claude-opus-4.5 — its override # must win over the config default model's override. @@ -111,7 +111,7 @@ class TestApiServerPerModelReasoning: from gateway.platforms.api_server import APIServerAdapter monkeypatch.setattr( - gateway_run, "_load_gateway_runtime_config", lambda: self._cfg(), + gateway_run, "_load_gateway_config", lambda: self._cfg(), ) adapter = APIServerAdapter(PlatformConfig()) diff --git a/tests/gateway/test_streaming_tts_gateway_regression.py b/tests/gateway/test_streaming_tts_gateway_regression.py index 13729e4e20..8a1241dbff 100644 --- a/tests/gateway/test_streaming_tts_gateway_regression.py +++ b/tests/gateway/test_streaming_tts_gateway_regression.py @@ -100,7 +100,7 @@ def _setup_monkeypatches(monkeypatch, tmp_path): monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) monkeypatch.setattr( gateway_run, - "_load_gateway_runtime_config", + "_load_gateway_config", lambda: {"agent": {"model": "test-model"}}, ) monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "test-model") diff --git a/tests/hermes_cli/test_config_effective.py b/tests/hermes_cli/test_config_effective.py new file mode 100644 index 0000000000..92a6260da4 --- /dev/null +++ b/tests/hermes_cli/test_config_effective.py @@ -0,0 +1,110 @@ +"""Invariants for ``hermes_cli.config_effective.load_user_config_effective`` — the one loader every +defaults-free config reader (gateway runtime, TUI gateway, cron, ``hermes send`` bridge, doctor, +bootstrap modules) goes through.""" +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)) + monkeypatch.setenv("FIXTURE_USER_KEY", "user-secret") + monkeypatch.setenv("FIXTURE_MANAGED_URL", "https://managed.example") + _reset_caches() + return home, managed + + +def _reset_caches(): + import hermes_cli.config as cfg + from hermes_cli import config_effective, managed_scope + + cfg._LOAD_CONFIG_CACHE.clear() + cfg._RAW_CONFIG_CACHE.clear() + config_effective._EFFECTIVE_CACHE.clear() + config_effective._LAST_GOOD_USER_RAW.clear() + managed_scope.invalidate_managed_cache() + + +def _write(path, body): + path.write_text(textwrap.dedent(body), encoding="utf-8") + _reset_caches() + + +USER_YAML = """ + model: + name: user/model + api_key: ${FIXTURE_USER_KEY} + provider: custom + display: + skin: user-skin + """ +MANAGED_YAML = """ + model: + base_url: ${FIXTURE_MANAGED_URL} + display: + skin: managed-skin + """ + + +def _legacy_gateway_pipeline(config_path): + """The pre-unification gateway sequence (raw read → overlay → model-key canon → ${VAR} expansion).""" + from hermes_cli import managed_scope + from hermes_cli.config import _expand_env_vars, _normalize_root_model_keys, read_user_config_raw + + raw = managed_scope.apply_managed_overlay(read_user_config_raw(config_path)) + return _expand_env_vars(_normalize_root_model_keys(raw)) + + +def test_effective_equals_legacy_gateway_pipeline_and_carries_no_defaults(homes): + """Contract: the shared loader returns byte-for-byte what the gateway's hand-rolled pipeline did + for a user file with a managed overlay and ``${VAR}`` refs on both layers — so per-message + gateway config reads (and the system prompt built from them) do not change — while never + merging DEFAULT_CONFIG (a missing key stays missing).""" + from hermes_cli.config import DEFAULT_CONFIG + from hermes_cli.config_effective import load_user_config_effective + + home, managed = homes + _write(home / "config.yaml", USER_YAML) + _write(managed / "config.yaml", MANAGED_YAML) + + effective = load_user_config_effective(home / "config.yaml") + + assert effective == _legacy_gateway_pipeline(home / "config.yaml") + assert effective["model"] == { + "default": "user/model", "provider": "custom", "api_key": "user-secret", + "base_url": "https://managed.example"} + assert effective["display"]["skin"] == "managed-skin" + assert "provider" not in effective # root key migrated under ``model`` (canonicalization applied) + assert "agent" not in effective and "agent" in DEFAULT_CONFIG # no DEFAULT_CONFIG merge + + +def test_broken_yaml_serves_last_good_and_fail_closed_raises(homes): + """A torn mid-edit write must not silently drop user overrides: the fail-open path serves the last + successfully parsed user file through the same pipeline; ``fail_closed`` surfaces the error to + callers that keep their own last-good state.""" + from hermes_cli.config_effective import load_user_config_effective + + home, _ = homes + _write(home / "config.yaml", USER_YAML) + good = load_user_config_effective(home / "config.yaml") + + (home / "config.yaml").write_text("model: [unterminated", encoding="utf-8") + _reset_caches_keep_last_good() + + assert load_user_config_effective(home / "config.yaml") == good + with pytest.raises(Exception): + load_user_config_effective(home / "config.yaml", fail_closed=True) + + +def _reset_caches_keep_last_good(): + import hermes_cli.config as cfg + from hermes_cli import config_effective + + cfg._RAW_CONFIG_CACHE.clear() + config_effective._EFFECTIVE_CACHE.clear() diff --git a/tests/hermes_cli/test_send_cmd.py b/tests/hermes_cli/test_send_cmd.py index e77bfbbd7f..57d0b56fe8 100644 --- a/tests/hermes_cli/test_send_cmd.py +++ b/tests/hermes_cli/test_send_cmd.py @@ -313,16 +313,17 @@ def test_load_hermes_env_latin1_fallback_still_loads(tmp_path, monkeypatch): def test_load_hermes_env_latin1_fallback_overrides_shell(tmp_path, monkeypatch): """The stream-based latin-1 fallback must keep override=True semantics: - the .env value wins over a stale shell export, same as the primary path.""" + the .env value wins over a stale shell export, same as the primary path. (A non-credential + key name: ``*_TOKEN`` values are ASCII-sanitized by the shared loader, by design.)""" import os hermes_home = tmp_path / ".hermes" hermes_home.mkdir() # 0xE9 forces the UnicodeDecodeError \u2192 latin-1 stream fallback. - (hermes_home / ".env").write_bytes(b"SEND_OVR_TOKEN=caf\xe9-file\n") + (hermes_home / ".env").write_bytes(b"SEND_OVR_LABEL=caf\xe9-file\n") monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("SEND_OVR_TOKEN", "stale-shell-value") + monkeypatch.setenv("SEND_OVR_LABEL", "stale-shell-value") from importlib import reload import hermes_cli.config as _hc_config @@ -330,7 +331,7 @@ def test_load_hermes_env_latin1_fallback_overrides_shell(tmp_path, monkeypatch): send_cmd._load_hermes_env() - assert os.environ.get("SEND_OVR_TOKEN") == "caf\xe9-file" + assert os.environ.get("SEND_OVR_LABEL") == "caf\xe9-file" def test_load_hermes_env_fallback_read_error_is_swallowed(tmp_path, monkeypatch): """An I/O error inside the latin-1 fallback must not escape \u2014 the send diff --git a/tests/tui_gateway/test_reasoning_config_per_model.py b/tests/tui_gateway/test_reasoning_config_per_model.py index 285c382f79..7e5edb3da3 100644 --- a/tests/tui_gateway/test_reasoning_config_per_model.py +++ b/tests/tui_gateway/test_reasoning_config_per_model.py @@ -74,7 +74,7 @@ class TestTUIPerModelReasoningConfig: }, } monkeypatch.setattr(tui_server, "_load_cfg", lambda: fake_cfg) - monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: fake_cfg) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: fake_cfg) tui_result = tui_server._load_reasoning_config() gw_result = gateway_run.GatewayRunner._load_reasoning_config() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index cbfe41572d..e1837564ed 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -541,7 +541,7 @@ def _configured_cwd_from_cfg(cfg: dict | None) -> str | None: def _profile_configured_cwd(profile_home: Path | None) -> str | None: """A non-launch profile's ``terminal.cwd`` from ITS config.yaml (fail-open → None): the process-global ``TERMINAL_CWD`` belongs to the *launch* profile, and load_config() resolves the ACTIVE profile, so - read the file directly through the _load_cfg pipeline. + read that file through the same effective-config pipeline as ``_load_cfg``. A new session bound to another profile must take its workspace from THAT profile's config, not the stale env var (issue #40334). Returns an absolute, existing directory, or None for placeholders / missing / @@ -550,9 +550,9 @@ def _profile_configured_cwd(profile_home: Path | None) -> str | None: if profile_home is None: return None with contextlib.suppress(Exception): - from hermes_cli.config import read_user_config_raw + from hermes_cli.config_effective import load_user_config_effective p = Path(profile_home) / "config.yaml" - return _configured_cwd_from_cfg(_expand_cfg(_apply_managed(read_user_config_raw(p)))) if p.exists() else None + return _configured_cwd_from_cfg(load_user_config_effective(p)) if p.exists() else None return None @@ -1159,31 +1159,15 @@ def _load_cfg_raw() -> dict: return {} -def _expand_cfg(cfg: dict) -> dict: - """``${ENV_VAR}`` expansion (same as ``load_config_readonly``); non-dict results keep the input.""" - from hermes_cli.config import _expand_env_vars - expanded = _expand_env_vars(cfg) - return expanded if isinstance(expanded, dict) else cfg - - def _load_cfg() -> dict: - """Behavioral config read: raw user file + managed overlay + ${VAR} expansion — ``load_config_readonly`` - minus the DEFAULT_CONFIG merge (callers treat a missing key as "unset"; merging would break - ``_load_cfg() == {}`` sentinels). Never pass the result to ``_save_cfg`` (use ``_load_cfg_raw()``).""" - cfg = _apply_managed(_load_cfg_raw()) + """Behavioral config read: the effective USER config (managed overlay, ``${VAR}`` expansion, model-key + canon) minus the DEFAULT_CONFIG merge — callers treat a missing key as "unset", so merging would break + ``_load_cfg() == {}`` sentinels. Fail-open to ``{}``. Never pass the result to ``_save_cfg`` (use + ``_load_cfg_raw()``).""" with contextlib.suppress(Exception): - cfg = _expand_cfg(cfg) - return cfg - - -def _apply_managed(cfg: dict) -> dict: - """Overlay administrator-pinned managed-scope values (read-side only, fail-open): this backend builds - config independently of load_config, so managed skin/reasoning_effort/service_tier/provider_routing - would otherwise be silently ignored.""" - with contextlib.suppress(Exception): - from hermes_cli import managed_scope - return managed_scope.apply_managed_overlay(cfg if isinstance(cfg, dict) else {}) - return cfg + from hermes_cli.config_effective import load_user_config_effective + return load_user_config_effective(_active_config_path()) + return {} def _save_cfg(cfg: dict):