From 871900df633d2edebf6afffb55ffca36f3f7048e Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Fri, 25 Sep 2026 13:32:09 +1000 Subject: [PATCH] feat(config): route every user config.yaml read and write through a ConfigBackend seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add hermes_cli/config_backend.py (config-config design §4.1, D10/D11): a ConfigBackend protocol, the FileBackend (today's behaviour), selection by HERMES_CONFIG_BACKEND only (default file; 'remote' and unknown values fail closed with ConfigBackendUnavailable), and path-level helpers (read_config_doc, read_config_doc_readonly, config_version, config_exists, write_config_key, write_config_document, supports_file_tooling). Migrate every direct reader/stat/exists/bypass writer of a user config.yaml to the helpers (config.py loaders + caches, config_effective, env_loader, gateway, TUI, CLI watchers, cron, tools, profiles, logging, time). File tooling (config edit, backups, xAI migration rewrite, raw dashboard editor) checks supports_file_tooling() first; migrations skip write-back without it. Add scripts/check_config_yaml_readers.py (CI lint next to the writers gate) that rejects a direct read of a config path outside the backend. --- .github/workflows/lint.yml | 5 + agent/moa_loop.py | 4 +- agent/skill_utils.py | 16 +- cli.py | 9 +- cron/jobs.py | 3 +- cron/scheduler.py | 3 +- gateway/config_loader.py | 13 +- gateway/readiness.py | 6 +- gateway/run.py | 9 +- gateway/run_config_loaders.py | 3 +- hermes_cli/AGENTS.md | 7 + hermes_cli/auth.py | 3 +- hermes_cli/backup.py | 4 +- hermes_cli/cli_config_load.py | 12 +- hermes_cli/cli_info_mixin.py | 11 +- hermes_cli/cli_tui_mixin.py | 4 +- hermes_cli/commands_completion.py | 5 +- hermes_cli/config.py | 67 +++--- hermes_cli/config_backend.py | 219 ++++++++++++++++++ hermes_cli/config_backups.py | 9 +- hermes_cli/config_effective.py | 20 +- hermes_cli/config_read_errors.py | 10 +- hermes_cli/config_version_stamp.py | 8 +- hermes_cli/credential_lifecycle.py | 3 +- hermes_cli/doctor_config.py | 6 +- hermes_cli/doctor_state.py | 6 +- hermes_cli/env_loader.py | 15 +- hermes_cli/gateway_multiplex_mode.py | 6 +- hermes_cli/kanban_db.py | 2 +- hermes_cli/main.py | 11 +- hermes_cli/migrate.py | 3 +- hermes_cli/model_switch.py | 7 +- hermes_cli/personality.py | 4 +- hermes_cli/profile_channels.py | 12 +- hermes_cli/profile_distribution.py | 2 +- hermes_cli/profile_memory_config.py | 4 +- hermes_cli/profiles.py | 31 ++- hermes_cli/send_cmd.py | 3 +- hermes_cli/update_channel.py | 4 +- hermes_cli/update_cmd_config.py | 2 +- hermes_cli/web_routers/analytics.py | 8 +- hermes_cli/xai_retirement.py | 11 +- hermes_logging.py | 7 +- hermes_time.py | 7 +- model_tools.py | 5 +- plugins/platforms/telegram/adapter.py | 3 +- scripts/check_config_yaml_readers.py | 205 ++++++++++++++++ tests/agent/test_skill_utils.py | 10 +- tests/hermes_cli/test_config_backend.py | 114 +++++++++ .../hermes_cli/test_config_save_integrity.py | 10 +- tools/bot_mode_probe.py | 4 +- tools/terminal_scope.py | 8 +- tools/website_policy.py | 5 +- tui_gateway/methods_config.py | 4 +- tui_gateway/server.py | 13 +- 55 files changed, 808 insertions(+), 177 deletions(-) create mode 100644 hermes_cli/config_backend.py create mode 100644 scripts/check_config_yaml_readers.py create mode 100644 tests/hermes_cli/test_config_backend.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2d41b15410..603bb105e6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -252,6 +252,11 @@ jobs: - name: Forbid config.yaml writers that bypass the comment-preserving writer run: python scripts/check_config_yaml_writers.py + # Every read of a user config.yaml goes through hermes_cli.config_backend, so a non-file + # config backend (config-config design §4.1) sees every reader. + - name: Forbid config.yaml readers that bypass the config backend + run: python scripts/check_config_yaml_readers.py + # The OS lanes import only files carrying the matching marker, so a test that fakes # macOS (is_macos -> True, sys.platform -> "darwin") without `platforms("macos")` is green on # Linux over a faked branch and never runs on macOS (#111866, tests/AGENTS.md § Don't fake the host OS). diff --git a/agent/moa_loop.py b/agent/moa_loop.py index 40e62319ea..ff0d140037 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -126,9 +126,9 @@ def _resolve_preset_cached(preset_name: str) -> tuple[dict[str, Any], Any]: (skips resolve_moa_preset's full validation of the moa block on every create()).""" from hermes_cli.config import get_config_path, load_config from hermes_cli.moa_config import resolve_moa_preset - from utils import file_signature + from hermes_cli.config_backend import config_version try: - cfg_stamp = file_signature(get_config_path().stat()) + cfg_stamp = config_version(get_config_path()) except OSError: cfg_stamp = None moa_raw = load_config().get("moa") or {} diff --git a/agent/skill_utils.py b/agent/skill_utils.py index f24faec3d4..a829436603 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -209,23 +209,24 @@ def _raw_config_cache_clear() -> None: def _config_cache_key(config_path: Path) -> Optional[tuple[str, int, int, int, int]]: - """``(path, *file_signature)`` identity of config.yaml, or None when unreadable/absent.""" + """``(path, *backend version)`` identity of config.yaml, or None when unreadable/absent.""" try: - from utils import file_signature - return (str(config_path), *file_signature(config_path.stat())) + from hermes_cli.config_backend import config_version + return (str(config_path), *config_version(config_path)) except OSError: return None def _load_raw_config() -> dict[str, Any]: - """Read config.yaml with an mtime+size keyed cache (no hermes_cli.config import).""" + """Read config.yaml with a version-keyed cache (no hermes_cli.config import).""" from agent.safe_worker_policy import worker_config_snapshot + from hermes_cli.config_backend import config_exists, read_config_doc snapshot = worker_config_snapshot() if snapshot is not None: return snapshot config_path = get_config_path() - if not config_path.exists(): + if not config_exists(config_path): return {} cache_key = _config_cache_key(config_path) with _RAW_CONFIG_CACHE_LOCK: @@ -233,7 +234,7 @@ def _load_raw_config() -> dict[str, Any]: if cached is not None: return cached try: - parsed = yaml_load(config_path.read_text(encoding="utf-8-sig")) + parsed = read_config_doc(config_path) except Exception as e: logger.debug("Could not read skill config %s: %s", config_path, e) return {} @@ -337,8 +338,9 @@ def _config_str_list(raw) -> list[str]: def get_external_skills_dirs() -> list[Path]: """Validated, deduplicated ``skills.external_dirs`` (existing dirs only). Entries are ``~``/``${VAR}`` expanded, relative to HERMES_HOME; the local skills dir is skipped.""" + from hermes_cli.config_backend import config_exists config_path = get_config_path() - if not config_path.exists(): + if not config_exists(config_path): return [] full_key = _config_cache_key(config_path) cache_key = full_key diff --git a/cli.py b/cli.py index f422411172..450160cc88 100644 --- a/cli.py +++ b/cli.py @@ -778,8 +778,8 @@ def save_config_value(key_path: str, value: any) -> bool: try: from hermes_constants import mkdir_under_hermes_home mkdir_under_hermes_home(config_path.parent) - from utils import atomic_roundtrip_yaml_update - atomic_roundtrip_yaml_update(config_path, key_path, value) + from hermes_cli.config_backend import write_config_key + write_config_key(config_path, key_path, value) try: # owner-only: config files contain API keys os.chmod(config_path, 0o600) except (OSError, NotImplementedError): @@ -1040,14 +1040,15 @@ class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin, def show_config(self): """Display current configuration with kawaii ASCII art.""" + from hermes_cli.config_backend import config_exists terminal_env = os.getenv("TERMINAL_ENV", "local") terminal_cwd = os.getenv("TERMINAL_CWD", os.getcwd()) terminal_timeout = os.getenv("TERMINAL_TIMEOUT", "60") config_path = _hermes_home / 'config.yaml' - if not config_path.exists(): + if not config_exists(config_path): config_path = Path(__file__).parent / 'cli-config.yaml' - config_status = _t("cli.config.loaded") if config_path.exists() else _t("cli.config.not_found") + config_status = _t("cli.config.loaded") if config_exists(config_path) else _t("cli.config.not_found") # ``api_key`` may be a callable bearer provider (Entra ID, Vertex OAuth, key_cmd, ...): # label it by its tagged source and never invoke it (that would mint a token just to mask diff --git a/cron/jobs.py b/cron/jobs.py index 7fed57ffba..5558dae3ad 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -1656,10 +1656,11 @@ def _main_model_pin() -> tuple[Optional[str], Optional[str]]: """``(provider, model)`` the main agent runs on right now (``model.default`` + the provider it resolves to), for ``pinned=True`` jobs: the lock is a plain per-job pin, so the scheduler needs no second precedence axis. ``(None, None)`` when nothing is configured (the job stays unpinned).""" + from hermes_cli.config_backend import config_exists from hermes_cli.config_effective import load_user_config_effective cfg_path = get_hermes_home() / "config.yaml" - cfg = load_user_config_effective(cfg_path) if cfg_path.exists() else {} + cfg = load_user_config_effective(cfg_path) if config_exists(cfg_path) else {} model_cfg = cfg.get("model") or {} model = model_cfg.get("default") or model_cfg.get("model") if isinstance(model_cfg, dict) else model_cfg model = _normalize_job_optional_text(model) diff --git a/cron/scheduler.py b/cron/scheduler.py index 2092d1179e..7df27bf29e 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -1635,6 +1635,7 @@ def _load_cron_job_config(job: dict, job_id: str, job_name: str) -> _CronJobConf the main agent model (config ``model:``, then HERMES_MODEL) > the claimed welcome trial's. Re-read every tick (no cache) so ``hermes cron edit --model`` and ``hermes model`` both apply next tick.""" from hermes_cli.anon_auth import trial_model + from hermes_cli.config_backend import config_exists model = job.get("model") or cron_env_setting("HERMES_MODEL") or "" _cron_default_provider = "" _cfg: dict = {} @@ -1642,7 +1643,7 @@ def _load_cron_job_config(job: dict, job_id: str, job_name: str) -> _CronJobConf try: from hermes_cli.config_effective import load_user_config_effective _cfg_path = str(_get_hermes_home() / "config.yaml") - if os.path.exists(_cfg_path): + if config_exists(_cfg_path): _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 {} diff --git a/gateway/config_loader.py b/gateway/config_loader.py index 3328704ceb..4081ff1c64 100644 --- a/gateway/config_loader.py +++ b/gateway/config_loader.py @@ -493,15 +493,12 @@ def read_yaml_layers(home: Path) -> dict: (``gateway.relay.relay_explicitly_disabled``) reads through here so it cannot disagree with ``load_gateway_config()`` on which files count. """ - config_yaml_path = home / "config.yaml" - yaml_cfg: dict = {} - if config_yaml_path.exists(): - # The installer seeds config.yaml by copying cli-config.yaml.example (~120 KB, almost all - # comments), and nothing caches this loader — so the pure-Python parser dominates the load. - from utils import fast_safe_load + from hermes_cli.config_backend import get_config_backend - with open(config_yaml_path, encoding="utf-8-sig") as f: - yaml_cfg = fast_safe_load(f) or {} + backend = get_config_backend() + yaml_cfg: dict = {} + if backend.exists(home): + yaml_cfg = backend.read_user_layer(home).doc or {} from hermes_cli.config import _expand_env_vars diff --git a/gateway/readiness.py b/gateway/readiness.py index 3de2f60eb4..651aab2102 100644 --- a/gateway/readiness.py +++ b/gateway/readiness.py @@ -9,7 +9,6 @@ from pathlib import Path from typing import Any from hermes_constants import get_hermes_home -from utils import load_yaml_file_readonly _DISK_DEGRADED_PERCENT = 90.0 @@ -50,11 +49,12 @@ def _probe_state_db(home: Path) -> dict[str, Any]: def _probe_config(home: Path) -> dict[str, Any]: + from hermes_cli.config_backend import config_exists, read_config_doc_readonly path = home / "config.yaml" - if not path.exists(): + if not config_exists(path): return _check("ok", "using defaults") try: - raw = load_yaml_file_readonly(path) + raw = read_config_doc_readonly(path) except Exception as exc: return _check("degraded", f"invalid config ({type(exc).__name__})") return _check("ok") if raw is None or isinstance(raw, dict) else _check("degraded", "top level is not a mapping") diff --git a/gateway/run.py b/gateway/run.py index 5ae6dc85e0..b1f214c65c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1601,11 +1601,12 @@ def _bridge_max_turns_from_config(home: Path) -> None: """Re-bridge agent.max_turns (+ sessions.*) per turn; managed overlay applies or it reverts. Skipped inside a served secondary's scope: the env slots are the launch profile's and hermes_state reads the routed profile's ``sessions.*`` from its own config under scope.""" + from hermes_cli.config_backend import config_exists from gateway.platforms._shared import profile_scoped if profile_scoped(): return config_path = home / 'config.yaml' - if not config_path.exists(): + if not config_exists(config_path): return try: cfg = _load_bridge_config(config_path) @@ -1621,13 +1622,14 @@ def _current_max_iterations() -> int: ``int()`` crash. A routed profile (HERMES_HOME override, multiplexed turns) reads ITS ``agent.max_turns`` straight from config: the ``HERMES_MAX_ITERATIONS`` bridge is one process-wide slot holding the launch profile's value, so every secondary would inherit the default's budget.""" + from hermes_cli.config_backend import config_exists _reload_runtime_env_preserving_config_authority() from hermes_cli.config import resolve_turn_limit as _resolve_turn_limit override = get_hermes_home_override() if override: config_path = Path(override) / 'config.yaml' try: - cfg = _load_bridge_config(config_path) if config_path.exists() else {} + cfg = _load_bridge_config(config_path) if config_exists(config_path) else {} except Exception: cfg = {} agent_cfg = cfg.get("agent") @@ -2128,7 +2130,8 @@ def _load_bridge_config(config_path: Path) -> dict: _config_path = _hermes_home / 'config.yaml' _cfg: dict = {} -if _config_path.exists(): +from hermes_cli.config_backend import config_exists +if config_exists(_config_path): try: _cfg = _load_bridge_config(_config_path) _bridge_config_to_env(_cfg) diff --git a/gateway/run_config_loaders.py b/gateway/run_config_loaders.py index 5230924b18..512216d8fe 100644 --- a/gateway/run_config_loaders.py +++ b/gateway/run_config_loaders.py @@ -532,6 +532,7 @@ class GatewayConfigLoadersMixin: and keeps one last-known-good chain per home: a single runner-wide slot filled from the launch home handed every secondary profile the default profile's fallback chain. """ + from hermes_cli.config_backend import config_exists from gateway.run import _gateway_config_home from hermes_constants import hermes_home_key home = _gateway_config_home() @@ -542,7 +543,7 @@ class GatewayConfigLoadersMixin: try: from hermes_cli.config_effective import load_user_config_effective cfg_path = home / "config.yaml" - if not cfg_path.exists(): + if not config_exists(cfg_path): by_home[home_key] = self._fallback_model = None return self._fallback_model # fail_closed: a torn mid-edit write must raise so the per-home last known-good chain diff --git a/hermes_cli/AGENTS.md b/hermes_cli/AGENTS.md index b7aa264b16..fcfcee48b9 100644 --- a/hermes_cli/AGENTS.md +++ b/hermes_cli/AGENTS.md @@ -96,6 +96,13 @@ set/get/unset ` route any bare name registered in `OPTIONAL_ENV_VARS` / `_ `atomic_yaml_write` / `yaml.dump` / `yaml.safe_dump` on a config path — `scripts/check_config_yaml_writers.py` (CI lint) rejects it, and `tests/hermes_cli/test_config_yaml_comment_preservation.py` guards each path (#92554). The commented example blocks are appended only when the file is created. +- **Config backend seam:** every read, stat, existence check and write of a user `config.yaml` + goes through `hermes_cli/config_backend.py` (`read_config_doc`, `read_config_doc_readonly`, + `config_version`, `config_exists`, `write_config_key`, `write_config_document`). The backend + is chosen by `HERMES_CONFIG_BACKEND` only (default `file`; never by a config value). Tools that + copy, edit or back up the file itself check `supports_file_tooling()` first. + `scripts/check_config_yaml_readers.py` (CI lint) rejects a direct `open`/`read_text`/`stat`/ + `exists`/YAML load of a config path; mark a true false positive `# config-reader: ok — `. - **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`); `hermes_cli/config_effective.py::load_user_config_effective()` (gateway runtime via diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index c02974bd6d..0aedc13174 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2417,8 +2417,9 @@ def _logout_default_provider_from_config() -> Optional[str]: def _reset_config_provider() -> Path: """Reset config.yaml provider back to auto after logout.""" + from hermes_cli.config_backend import config_exists config_path = get_config_path() - if not config_path.exists(): + if not config_exists(config_path): return config_path require_readable_config_before_write(config_path) config = read_raw_config() diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index d50dbee9da..3790935578 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -766,7 +766,7 @@ def run_import(args) -> Optional[int]: print(f"Detected archive prefix: {prefix!r} (will be stripped)") # Check for existing installation - has_config = (hermes_root / "config.yaml").exists() + has_config = (hermes_root / "config.yaml").exists() # config-reader: ok — profile marker for the backup scope (file tooling) has_env = (hermes_root / ".env").exists() if (has_config or has_env) and not args.force: @@ -863,7 +863,7 @@ def run_import(args) -> Optional[int]: continue profile_name = entry.name # Only create wrappers for directories with config - if not (entry / "config.yaml").exists() and not (entry / ".env").exists(): + if not (entry / "config.yaml").exists() and not (entry / ".env").exists(): # config-reader: ok — profile marker for the backup scope (file tooling) continue collision = check_alias_collision(profile_name) if collision: diff --git a/hermes_cli/cli_config_load.py b/hermes_cli/cli_config_load.py index 9fa18198e4..281dd848b8 100644 --- a/hermes_cli/cli_config_load.py +++ b/hermes_cli/cli_config_load.py @@ -12,7 +12,6 @@ import os import sys from pathlib import Path from typing import List, Dict, Any -from utils import fast_safe_load # Log-record parity with the origin module. logger = logging.getLogger("cli") @@ -262,8 +261,10 @@ def load_cli_config() -> dict[str, Any]: ``HERMES_IGNORE_USER_CONFIG=1`` skips the user config entirely (``.env`` still loads). """ from cli import _cli_config_defaults, _hermes_home, _merge_file_config, _mirror_config_to_env + from hermes_cli.config_backend import config_exists, read_config_doc config_path = _hermes_home / 'config.yaml' - if not config_path.exists() or os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1": + # A backend whose user layer always exists never takes the repo-file fallback. + if not config_exists(config_path) or os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1": config_path = Path(__file__).parent / 'cli-config.yaml' defaults = _cli_config_defaults() @@ -271,12 +272,11 @@ def load_cli_config() -> dict[str, Any]: # Only a file's terminal section may overwrite terminal env vars already set by .env. _file_has_terminal_config = False - if config_path.exists(): + if config_exists(config_path): try: - with open(config_path, "r", encoding="utf-8-sig") as f: - from hermes_cli.config import _normalize_root_model_keys + from hermes_cli.config import _normalize_root_model_keys - file_config = _normalize_root_model_keys(fast_safe_load(f) or {}) + file_config = _normalize_root_model_keys(read_config_doc(config_path) or {}) _file_has_terminal_config = "terminal" in file_config _merge_file_config(defaults, file_config) diff --git a/hermes_cli/cli_info_mixin.py b/hermes_cli/cli_info_mixin.py index 1d9b4b87fe..772946da56 100644 --- a/hermes_cli/cli_info_mixin.py +++ b/hermes_cli/cli_info_mixin.py @@ -18,7 +18,7 @@ from typing import Any, NamedTuple from agent.i18n import t from hermes_constants import is_termux as _is_termux_environment from rich.markup import escape as _escape -from utils import base_url_hostname, file_signature +from utils import base_url_hostname from hermes_cli.cli_modal_mixin import _gated_confirm from hermes_cli.colors import Colors as _Colors @@ -864,7 +864,7 @@ class CLIInfoMixin: cache** (the next message re-sends the full input prefix, expensive on long-context / high-reasoning models). See #1474. """ - import hermes_yaml as _yaml + from hermes_cli.config_backend import config_exists, config_version, read_config_doc now = time.monotonic() if now - self._last_config_check < CONFIG_WATCH_INTERVAL: @@ -873,10 +873,10 @@ class CLIInfoMixin: from hermes_cli.config import get_config_path as _get_config_path cfg_path = _get_config_path() - if not cfg_path.exists(): + if not config_exists(cfg_path): return try: - sig = file_signature(cfg_path.stat()) + sig = config_version(cfg_path) except OSError: return if sig == self._config_sig: @@ -884,8 +884,7 @@ class CLIInfoMixin: self._config_sig = sig try: - with open(cfg_path, encoding="utf-8-sig") as f: - new_cfg = _yaml.safe_load(f) or {} + new_cfg = read_config_doc(cfg_path) or {} except Exception: return diff --git a/hermes_cli/cli_tui_mixin.py b/hermes_cli/cli_tui_mixin.py index 2fd80416f4..66cc4c4497 100644 --- a/hermes_cli/cli_tui_mixin.py +++ b/hermes_cli/cli_tui_mixin.py @@ -1817,6 +1817,7 @@ class CLITuiMixin: def _tui_init_run_state(self): """Reset the per-run REPL state (queues, modal states, voice state, config watcher).""" + from hermes_cli.config_backend import config_exists, config_version self._agent_running = False self._pending_input = queue.Queue() # normal input (commands + new queries) self._interrupt_queue = queue.Queue() # messages typed while the agent is running @@ -1835,9 +1836,8 @@ class CLITuiMixin: # Config file watcher — detect mcp_servers changes and auto-reload. from hermes_cli.config import get_config_path as _get_config_path - from utils import file_signature _cfg_path = _get_config_path() - self._config_sig: tuple | None = file_signature(_cfg_path.stat()) if _cfg_path.exists() else None + self._config_sig: tuple | None = config_version(_cfg_path) if config_exists(_cfg_path) else None self._config_mcp_servers: dict = self.config.get("mcp_servers") or {} self._last_config_check: float = 0.0 # monotonic time of last check diff --git a/hermes_cli/commands_completion.py b/hermes_cli/commands_completion.py index 56b2cb34b6..3a0990d43e 100644 --- a/hermes_cli/commands_completion.py +++ b/hermes_cli/commands_completion.py @@ -31,13 +31,12 @@ def _personalities_from_cli_config() -> dict[str, Any]: Falls back to a fresh load when the file cannot be stat'ed.""" global _personalities_memo from cli import load_cli_config - from utils import file_signature from hermes_cli.personality import available_personalities try: from hermes_cli.config import get_config_path + from hermes_cli.config_backend import config_version cfg_path = get_config_path() - st = cfg_path.stat() - sig = (str(cfg_path), *file_signature(st)) + sig = (str(cfg_path), *config_version(cfg_path)) except Exception: sig = (None, None, None, None, None) if _personalities_memo is None or _personalities_memo[0] != sig: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 18d348a668..dec6b7da01 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -47,6 +47,8 @@ from hermes_constants import ( # Re-export from hermes_constants — canonical definition lives there. from hermes_constants import get_hermes_home, get_process_hermes_home from utils import atomic_replace, fast_safe_load, file_signature, mkstemp_beside +from hermes_cli.config_backend import ( + config_exists, config_version, read_config_doc, supports_file_tooling, write_config_document) from hermes_cli.config_read_errors import ( _CONFIG_PARSE_FAILURES, _FIX_PERMS, _FIX_YAML, FailedConfigRead, _backups_dir_display, _refuse_failed_read, _refuse_overwrite, _warn_config_parse_failure, _yaml_error_details, @@ -444,8 +446,7 @@ def require_parseable_user_config(*, ignore_user_config: bool = False) -> None: config_path = get_config_path() try: - with open(config_path, encoding="utf-8-sig") as f: - data = fast_safe_load(f) + data = read_config_doc(config_path) except FileNotFoundError: return except Exception as exc: @@ -1260,9 +1261,12 @@ def _persist_migration(config: dict[str, Any]) -> None: persist values that DIFFER from the schema default, plus explicit removals/renames of user data. Every migration step MUST write through here (``save_config`` with default-stripping ON, no ``merge_existing``) so the invariant cannot regress one migration at a time. A migration - is Hermes' own write, never a user turning a feature off.""" + is Hermes' own write, never a user turning a feature off. A backend without file tooling + migrates in memory on read and never writes back (D12).""" from hermes_cli.observability.shared_metrics_disabled import hermes_applied_write + if not supports_file_tooling(): + return with hermes_applied_write(): save_config(config) @@ -1884,6 +1888,12 @@ def _raw_config_cache_hit(path_key: str, cache_key: tuple[Any, ...]) -> Optional return None +def _raw_config_cache_store(path_key: str, cache_key: tuple[Any, ...], data: dict[str, Any]) -> None: + """Publish ``data`` (caller-owned copy) as the raw config for ``path_key`` at ``cache_key``. + One whole-tuple replace, so the lock-free readers see either the old or the new entry.""" + _RAW_CONFIG_CACHE[path_key] = (*cache_key, data) + + def _worker_config_snapshot() -> Optional[dict[str, Any]]: """A safe worker's frozen config, or None. Install-time loads run from ``hermes_cli`` alone (the installer's completion step ships no ``agent`` package), and without ``agent`` there @@ -1909,7 +1919,7 @@ def _read_raw_config_impl(*, want_deepcopy: bool) -> dict[str, Any]: # every cached read for the duration). A lost race just falls through to the locked re-check. try: config_path = get_config_path() - cache_key = file_signature(config_path.stat()) + cache_key = config_version(config_path) hit = _raw_config_cache_hit(str(config_path), cache_key) if hit is not None: return copy.deepcopy(hit) if want_deepcopy else hit @@ -1919,7 +1929,7 @@ def _read_raw_config_impl(*, want_deepcopy: bool) -> dict[str, Any]: with _CONFIG_LOCK: config_path = get_config_path() try: - cache_key = file_signature(config_path.stat()) + cache_key = config_version(config_path) except FileNotFoundError: return {} except OSError as e: @@ -1931,8 +1941,7 @@ def _read_raw_config_impl(*, want_deepcopy: bool) -> dict[str, Any]: return copy.deepcopy(hit) if want_deepcopy else hit try: - with open(config_path, encoding="utf-8-sig") as f: - data = fast_safe_load(f) or {} + data = read_config_doc(config_path) or {} except Exception as e: _warn_config_parse_failure(config_path, e) return FailedConfigRead(error=e) @@ -1943,7 +1952,7 @@ def _read_raw_config_impl(*, want_deepcopy: bool) -> dict[str, Any]: # The cache stores its own deepcopy. The readonly path returns THAT object (identity # invariant: later cache hits return the same dict); the mutable path returns the parse. cached_copy = copy.deepcopy(data) - _RAW_CONFIG_CACHE[path_key] = (*cache_key, cached_copy) + _raw_config_cache_store(path_key, cache_key, cached_copy) return data if want_deepcopy else cached_copy @@ -1960,8 +1969,7 @@ def read_user_config_raw(config_path: Optional[Path] = None) -> dict[str, Any]: if config_path is None: config_path = get_config_path() try: - with open(config_path, encoding="utf-8-sig") as f: - data = fast_safe_load(f) or {} + data = read_config_doc(config_path) or {} except FileNotFoundError: return {} return data if isinstance(data, dict) else {} @@ -1987,15 +1995,14 @@ def require_readable_config_before_write(config_path: Optional[Path] = None) -> return copy.deepcopy(snapshot) config_path = get_config_path() try: - config_path.stat() + config_version(config_path) except FileNotFoundError: return {} except OSError as exc: raise _refuse_overwrite(config_path, "cannot be accessed", exc, _FIX_PERMS) from exc try: - with open(config_path, encoding="utf-8-sig") as f: - loaded = fast_safe_load(f) + loaded = read_config_doc(config_path) except OSError as exc: raise _refuse_overwrite(config_path, "cannot be read", exc, _FIX_PERMS) from exc except Exception as exc: @@ -2043,8 +2050,6 @@ def _write_config_state( extra_content_on_create: Optional[str] = None, ) -> None: """Shared comment-preserving config writer; omission policy is selected by the public wrapper.""" - from utils import atomic_roundtrip_yaml_save - _refuse_failed_read(config_path, data) if not allow_omissions: existing = require_readable_config_before_write(config_path) @@ -2061,7 +2066,7 @@ def _write_config_state( "Pass the complete current config, or use atomic_config_replace() only when " "deletion by omission is deliberate.", ) from exc - atomic_roundtrip_yaml_save(config_path, data, extra_content_on_create=extra_content_on_create) + write_config_document(config_path, data, extra_content_on_create=extra_content_on_create) def atomic_config_write( @@ -2223,13 +2228,12 @@ def _load_config_cache_sig(config_path: Path) -> tuple[Optional[tuple[int, int, The managed config file's signature is folded in ((0, 0, 0, 0) = none) so editing it invalidates the merged result. ``cache_sig`` is None only when neither file exists (nothing to cache on).""" try: - st = config_path.stat() - user_sig: Optional[tuple[int, int, int, int]] = file_signature(st) + user_sig: Optional[tuple[Any, ...]] = config_version(config_path) except FileNotFoundError: user_sig = None managed_dir = managed_scope.get_managed_dir() try: - mst = (managed_dir / "config.yaml").stat() if managed_dir else None + mst = (managed_dir / "config.yaml").stat() if managed_dir else None # config-reader: ok — managed overlay, not a user layer managed_sig = file_signature(mst) if mst else (0, 0, 0, 0) except OSError: managed_sig = (0, 0, 0, 0) @@ -2304,19 +2308,23 @@ def _load_config_cache_hit(path_key: str, cache_sig: Any) -> Optional[dict[str, pin unexpanded literals (e.g. auxiliary..api_key) for the process lifetime (#58514). Shared by the lock-free fast path and the locked re-check of ``_load_config_impl``.""" cached = _LOAD_CONFIG_CACHE.get(path_key) - if cached is None or cache_sig is None or cached[:8] != cache_sig: + if cached is None or cache_sig is None: return None - hit = cached[8] + n = len(cache_sig) # the backend's version tuple + the managed file's signature + if cached[:n] != cache_sig: + return None + hit = cached[n] if isinstance(hit, FailedConfigRead) and isinstance(hit.read_error, OSError): # A read error (EMFILE/EIO/sharing violation) can clear without touching the file's # signature: serve the fallback only while the file still cannot be read. try: - with open(path_key, "rb") as f: - f.read() - return None + read_config_doc(path_key) except OSError: return hit - env_snapshot = cached[9] if len(cached) > 9 else {} + except Exception: + pass # readable again; the reload reports the parse error + return None + env_snapshot = cached[n + 1] if len(cached) > n + 1 else {} if all(_env_ref_lookup(k) == v for k, v in env_snapshot.items()): return hit return None @@ -2359,8 +2367,7 @@ def _load_config_impl(*, want_deepcopy: bool) -> dict[str, Any]: if user_sig is not None: try: - with open(config_path, encoding="utf-8-sig") as f: - user_config = fast_safe_load(f) or {} + user_config = read_config_doc(config_path) or {} _CONFIG_PARSE_FAILURES.pop(path_key, None) # the file reads now (a transient error left the record) if "max_turns" in user_config: @@ -2995,8 +3002,12 @@ def edit_config(): if is_managed(): managed_error("edit configuration") return + if not supports_file_tooling(): + print("`hermes config edit` opens the local config file, and this config backend has none. " + "Use `hermes config set ` instead.", file=sys.stderr) + return config_path = get_config_path() - if not config_path.exists(): + if not config_path.exists(): # config-reader: ok — file tooling, gated on supports_file_tooling() seed_config_file(config_path) print(f"Created {config_path}") diff --git a/hermes_cli/config_backend.py b/hermes_cli/config_backend.py new file mode 100644 index 0000000000..15c4a8264c --- /dev/null +++ b/hermes_cli/config_backend.py @@ -0,0 +1,219 @@ +"""Where the user config layer lives: the ``ConfigBackend`` seam (config-config design §4.1, D10/D11). + +Every read and write of a profile's user ``config.yaml`` goes through the backend returned by +:func:`get_config_backend` (``scripts/check_config_yaml_readers.py`` and +``scripts/check_config_yaml_writers.py`` reject direct file access elsewhere). The key per call is +the hermes home (profile): one multiplexed process serves N profiles, and every config cache +already keys on the config path, so a backend keeps per-home state. + +:class:`FileBackend` is the only backend in this build and is today's behaviour byte for byte: +the version is the ``file_signature`` stat tuple, reads are ``open`` + ``fast_safe_load`` and raise +exactly what those raise (``FileNotFoundError`` for an absent file, ``OSError``, YAML errors), and +writes are the ruamel round-trip writers in ``utils``. Callers keep their own error handling, so a +backend whose layer always exists simply never takes the "no file" branches. + +The managed scope (``/etc/hermes``) is NOT a backend concern: it stays an overlay applied on top of +whatever user layer the backend returns. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Mapping, Optional, Protocol, Tuple, Union, runtime_checkable + +CONFIG_FILENAME = "config.yaml" +BACKEND_ENV = "HERMES_CONFIG_BACKEND" + +PathLike = Union[str, "os.PathLike[str]"] + + +@dataclass(frozen=True) +class UserLayer: + """One read of a profile's user layer.""" + doc: Any # the parsed root as written (None for an empty document) + version: Tuple[Any, ...] # the cache signature this doc was read at (etag for a remote backend) + locks: Mapping[str, str] = field(default_factory=dict) # dotted prefix -> level that locks it + provenance: str = "" # where the doc came from, for diagnostics + + +@dataclass(frozen=True) +class Changes: + """A write to one profile's user layer. + + ``document`` replaces the whole user-layer document (``atomic_config_write``: the file backend + merges it onto the on-disk document, comment-preserving); ``set`` / ``unset`` are single dotted + keys (``atomic_roundtrip_yaml_update`` semantics). A backend applies ``document`` first. + """ + document: Optional[dict] = None + set: Mapping[str, Any] = field(default_factory=dict) + unset: Tuple[str, ...] = () + extra_content_on_create: Optional[str] = None + + +@runtime_checkable +class ConfigBackend(Protocol): + name: str + + def read_user_layer(self, home: Path) -> UserLayer: ... + + def read_user_doc_readonly(self, home: Path) -> Any: + """``read_user_layer(home).doc`` re-read only when ``version`` changes; never mutate it.""" + ... + + def version(self, home: Path) -> Tuple[Any, ...]: + """Cheap cache signature; changes whenever the user layer does.""" + ... + + def exists(self, home: Path) -> bool: ... + + def write_changes(self, home: Path, changes: Changes) -> None: ... + + def locked(self, home: Path, dotted: str) -> Optional[str]: + """The level that locks ``dotted`` (prefix-aware), or None.""" + ... + + def supports_file_tooling(self) -> bool: + """Whether tools that copy/edit/back up the config FILE (profile clone, backup/restore, + ``config edit``, last-known-good backups, ``migrate_config`` write-back) may run.""" + ... + + +class ConfigBackendUnavailable(SystemExit): + """The selected backend cannot serve config. A ``SystemExit`` on purpose: many config readers + fail open with ``except Exception`` → defaults, and serving defaults instead of the selected + backend's config is exactly the silent failure this must not become.""" + + +class FileBackend: + """The user layer is ``/config.yaml`` on local disk.""" + + name = "file" + + # Path-level primitives: the file backend's whole behaviour, also used for explicit files + # that are not a home's config.yaml (see ``_route``). + @staticmethod + def version_path(path: Path) -> Tuple[int, int, int, int]: + from utils import file_signature + return file_signature(path.stat()) + + @staticmethod + def read_path(path: Path) -> Any: + from utils import fast_safe_load + with open(path, encoding="utf-8-sig") as f: + return fast_safe_load(f) + + @staticmethod + def read_path_readonly(path: Path) -> Any: + from utils import load_yaml_file_readonly + return load_yaml_file_readonly(path) + + @staticmethod + def write_path(path: Path, changes: Changes) -> None: + from utils import atomic_roundtrip_yaml_save, atomic_roundtrip_yaml_update + if changes.document is not None: + atomic_roundtrip_yaml_save(path, changes.document, extra_content_on_create=changes.extra_content_on_create) + for key, value in changes.set.items(): + atomic_roundtrip_yaml_update(path, key, value) + for key in changes.unset: + atomic_roundtrip_yaml_update(path, key, None) + + def config_path(self, home: Path) -> Path: + return Path(home) / CONFIG_FILENAME + + def read_user_layer(self, home: Path) -> UserLayer: + path = self.config_path(home) + version = self.version_path(path) + return UserLayer(doc=self.read_path(path), version=version, provenance=str(path)) + + def read_user_doc_readonly(self, home: Path) -> Any: + return self.read_path_readonly(self.config_path(home)) + + def version(self, home: Path) -> Tuple[int, int, int, int]: + return self.version_path(self.config_path(home)) + + def exists(self, home: Path) -> bool: + return self.config_path(home).exists() + + def write_changes(self, home: Path, changes: Changes) -> None: + self.write_path(self.config_path(home), changes) + + def locked(self, home: Path, dotted: str) -> Optional[str]: + return None # a local file has no levels; the managed scope is a separate overlay + + def supports_file_tooling(self) -> bool: + return True + + +_FILE_BACKEND = FileBackend() + + +def get_config_backend() -> ConfigBackend: + """The backend selected by ``HERMES_CONFIG_BACKEND`` (default ``file``). + + Read from the environment on every call, before any config read, so no config value can + select it (D11). Raises :class:`ConfigBackendUnavailable` for a backend this build lacks. + """ + kind = os.environ.get(BACKEND_ENV, "").strip().lower() or "file" + if kind == "file": + return _FILE_BACKEND + if kind == "remote": + raise ConfigBackendUnavailable( + f"{BACKEND_ENV}=remote: the remote config backend is not available in this build of " + "Hermes. Unset it to use the local config.yaml.") + raise ConfigBackendUnavailable(f"{BACKEND_ENV}={kind!r} is not a config backend (expected 'file').") + + +def _route(config_path: PathLike) -> Tuple[Any, Path, bool]: + """``(backend, home_or_path, is_user_layer)`` for a config path. + + ``/config.yaml`` is that home's user layer and goes to the selected backend. Any other + file name is an explicit file (a test fixture, an import source), not a user layer: it is read + and written with the file backend's primitives regardless of the selected backend. + """ + path = Path(config_path) + if path.name == CONFIG_FILENAME: + return get_config_backend(), path.parent, True + return _FILE_BACKEND, path, False + + +def read_config_doc(config_path: PathLike) -> Any: + """Parsed root of a config file (None when empty); raises like ``open`` + ``fast_safe_load``.""" + backend, target, is_layer = _route(config_path) + return backend.read_user_layer(target).doc if is_layer else backend.read_path(target) + + +def read_config_doc_readonly(config_path: PathLike) -> Any: + """Signature-cached :func:`read_config_doc`; the result is shared — never mutate it.""" + backend, target, is_layer = _route(config_path) + return backend.read_user_doc_readonly(target) if is_layer else backend.read_path_readonly(target) + + +def config_version(config_path: PathLike) -> Tuple[Any, ...]: + """Cache signature of a config file; raises ``FileNotFoundError`` / ``OSError`` like ``stat``.""" + backend, target, is_layer = _route(config_path) + return backend.version(target) if is_layer else backend.version_path(target) + + +def config_exists(config_path: PathLike) -> bool: + backend, target, is_layer = _route(config_path) + return backend.exists(target) if is_layer else target.exists() + + +def write_config_document(config_path: PathLike, document: dict, *, extra_content_on_create: Optional[str] = None) -> None: + """Replace a config file's document (comment-preserving merge for the file backend).""" + backend, target, is_layer = _route(config_path) + changes = Changes(document=document, extra_content_on_create=extra_content_on_create) + backend.write_changes(target, changes) if is_layer else backend.write_path(target, changes) + + +def write_config_key(config_path: PathLike, key_path: str, value: Any) -> None: + """Set one dotted key (``None`` removes it) — ``atomic_roundtrip_yaml_update`` semantics.""" + backend, target, is_layer = _route(config_path) + changes = Changes(unset=(key_path,)) if value is None else Changes(set={key_path: value}) + backend.write_changes(target, changes) if is_layer else backend.write_path(target, changes) + + +def supports_file_tooling() -> bool: + """Gate for tools that copy, edit or back up the config FILE itself (§4.3, §4.7).""" + return get_config_backend().supports_file_tooling() diff --git a/hermes_cli/config_backups.py b/hermes_cli/config_backups.py index cc90cbb213..e722fa1cf5 100644 --- a/hermes_cli/config_backups.py +++ b/hermes_cli/config_backups.py @@ -46,9 +46,13 @@ def backup_config(config_path: Path, reason: str, *, keep: int = DEFAULT_KEEP) - Skips when the file is missing/empty, or when the newest backup for *reason* already holds identical bytes. Never raises: a failed backup must not block the write it precedes. + Skipped entirely for a config backend without file tooling (no local copies, D2). """ + from hermes_cli.config_backend import supports_file_tooling + if not supports_file_tooling(): + return None try: - if not config_path.is_file() or config_path.stat().st_size == 0: + if not config_path.is_file() or config_path.stat().st_size == 0: # config-reader: ok — file tooling, gated above return None root = backups_dir(config_path) root.mkdir(parents=True, exist_ok=True) @@ -77,6 +81,9 @@ def load_newest_good_backup(config_path: Path) -> Optional[dict]: tried: a backup that fails to parse means the on-disk copy was damaged after the fact, and guessing further back would serve a config the user never saw as current. """ + from hermes_cli.config_backend import supports_file_tooling + if not supports_file_tooling(): + return None newest = list_config_backups(config_path, "good")[:1] if not newest: return None diff --git a/hermes_cli/config_effective.py b/hermes_cli/config_effective.py index ac32724b39..55ff6fcce6 100644 --- a/hermes_cli/config_effective.py +++ b/hermes_cli/config_effective.py @@ -20,8 +20,8 @@ from typing import Any, Dict, Optional, Tuple from hermes_cli import config as _config from hermes_cli import managed_scope +from hermes_cli.config_backend import read_config_doc from hermes_cli.config_read_errors import _warn_config_parse_failure -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. @@ -73,27 +73,27 @@ def load_user_config_effective(config_path: Optional[Path] = None, *, fail_close 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]) + n = len(cache_sig) if cache_sig is not None else 0 + if cached is not None and cache_sig is not None and cached[:n] == cache_sig: + if all(_config._env_ref_lookup(k) == v for k, v in cached[n + 1].items()): + return copy.deepcopy(cached[n]) 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() + raw_hit = _config._raw_config_cache_hit(path_key, user_sig) if user_sig is not None else None + if raw_hit is not None: + raw = copy.deepcopy(raw_hit) # 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-sig") as f: - loaded = fast_safe_load(f) + loaded = read_config_doc(config_path) 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)) + _config._raw_config_cache_store(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) diff --git a/hermes_cli/config_read_errors.py b/hermes_cli/config_read_errors.py index d5c78a531e..afad4defaf 100644 --- a/hermes_cli/config_read_errors.py +++ b/hermes_cli/config_read_errors.py @@ -13,7 +13,6 @@ import sys from pathlib import Path from typing import Any, Optional -from utils import file_signature logger = logging.getLogger(__name__) @@ -76,8 +75,8 @@ def _warn_config_parse_failure( in ``hermes_cli.config._load_config_impl``). """ try: - st = config_path.stat() - sig = file_signature(st) + from hermes_cli.config_backend import config_version + sig = config_version(config_path) key = (str(config_path), *sig) _CONFIG_PARSE_FAILURES[str(config_path)] = (*sig, str(exc)) except OSError: @@ -104,9 +103,10 @@ def get_active_config_parse_failure() -> Optional[str]: (mtime_ns + size + ino + ctime_ns) to the file that failed to parse; else None.""" from hermes_cli.config import get_config_path try: + from hermes_cli.config_backend import config_version record = _CONFIG_PARSE_FAILURES[str(path := get_config_path())] - st = path.stat() - return record[4] if file_signature(st) == record[:4] else None + sig = config_version(path) + return record[len(sig)] if sig == record[:len(sig)] else None except Exception: return None diff --git a/hermes_cli/config_version_stamp.py b/hermes_cli/config_version_stamp.py index ab125e6d0a..df82d83412 100644 --- a/hermes_cli/config_version_stamp.py +++ b/hermes_cli/config_version_stamp.py @@ -29,16 +29,16 @@ def read_config_version_stamp(*, raise_on_parse_error: bool = False) -> tuple[Op never-stamped current-schema file, not an ancient install — ``migrate_config()`` gives it only the legacy-key steps). A missing file, or malformed YAML under a tolerant caller, reads as ``latest`` exactly as ``check_config_version()`` always reported it.""" - from hermes_cli.config import InvalidUserConfigError, _warn_config_parse_failure, fast_safe_load, get_config_path + from hermes_cli.config import InvalidUserConfigError, _warn_config_parse_failure, get_config_path + from hermes_cli.config_backend import config_exists, read_config_doc latest = _coerce_config_version(DEFAULT_CONFIG.get("_config_version", 1)) or 1 config_path = get_config_path() - if not config_path.exists(): + if not config_exists(config_path): return latest, latest try: - with open(config_path, encoding="utf-8-sig") as f: - config = fast_safe_load(f) + config = read_config_doc(config_path) except Exception as e: _warn_config_parse_failure(config_path, e) if raise_on_parse_error: diff --git a/hermes_cli/credential_lifecycle.py b/hermes_cli/credential_lifecycle.py index 0e88844b99..c87aa3f078 100644 --- a/hermes_cli/credential_lifecycle.py +++ b/hermes_cli/credential_lifecycle.py @@ -86,12 +86,13 @@ def _scrub_config_yaml_mirrors(old_value: str, new_value: str | None) -> list[st ``.env`` is touched. ``new_value=None`` removes the field. Operates on the RAW user config so defaults are never baked into the user's file. """ + from hermes_cli.config_backend import config_exists if not old_value: return [] from hermes_cli.config import atomic_config_replace, get_config_path, read_user_config_raw config_path = get_config_path() - if not config_path.exists(): + if not config_exists(config_path): return [] try: user_config = read_user_config_raw(config_path) diff --git a/hermes_cli/doctor_config.py b/hermes_cli/doctor_config.py index cedcba5014..0f7bd13f39 100644 --- a/hermes_cli/doctor_config.py +++ b/hermes_cli/doctor_config.py @@ -335,9 +335,10 @@ def _validate_auxiliary_config(config_path, issues: list) -> None: @doctor_check() def _check_config_file(should_fix: bool, f: Finding) -> None: """config.yaml presence (project cli-config.yaml as fallback); model/provider validation.""" + from hermes_cli.config_backend import config_exists from hermes_cli.doctor import HERMES_HOME, PROJECT_ROOT, _DHH config_path = HERMES_HOME / 'config.yaml' - if config_path.exists(): + if config_exists(config_path): check_ok(f"{_DHH}/config.yaml exists") with warn_on_error("Could not validate model/provider config"): _validate_model_config(config_path, f.issues) @@ -529,9 +530,10 @@ def _check_config_drift(should_fix: bool, f: Finding) -> None: Each step is independent and best-effort: a failure in one never hides the next. """ + from hermes_cli.config_backend import config_exists from hermes_cli.doctor import HERMES_HOME config_path = HERMES_HOME / 'config.yaml' - if not config_path.exists(): + if not config_exists(config_path): config_path = None for step in _CONFIG_DRIFT_STEPS if config_path else (_drift_deprecations,): with warn_on_error(""): diff --git a/hermes_cli/doctor_state.py b/hermes_cli/doctor_state.py index 1fb68122d4..5812a8c93b 100644 --- a/hermes_cli/doctor_state.py +++ b/hermes_cli/doctor_state.py @@ -16,11 +16,12 @@ from hermes_state_holders import read_only_db_uri def _doctor_memory_config(hermes_home: Path | None = None) -> dict: """Return the effective memory section used by doctor diagnostics.""" + from hermes_cli.config_backend import config_exists from hermes_cli.doctor import HERMES_HOME try: 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(): + if not config_exists(config_path): return {} section = load_user_config_effective(config_path).get("memory") return section if isinstance(section, dict) else {} @@ -680,6 +681,7 @@ def _check_plugins_in_both_lists(should_fix: bool, f: Finding) -> None: @doctor_check("") # best-effort: profile enumeration must never break doctor def _check_profiles(should_fix: bool, f: Finding) -> None: from hermes_constants import PROFILE_ID_RE + from hermes_cli.config_backend import config_exists from hermes_cli.profiles import _wrapper_path, list_profiles, _get_wrapper_dir, profile_exists import re as _re named_profiles = [p for p in list_profiles() if not p.is_default] @@ -691,7 +693,7 @@ def _check_profiles(should_fix: bool, f: Finding) -> None: for p in named_profiles: parts = [text for cond, text in ( (p.gateway_running, "gateway running"), (p.model, (p.model or "")[:30]), - (not (p.path / "config.yaml").exists(), "⚠ missing config"), (not (p.path / ".env").exists(), "no .env"), + (not config_exists(p.path / "config.yaml"), "⚠ missing config"), (not (p.path / ".env").exists(), "no .env"), (p.alias_path is None, "no alias")) if cond] check_ok(f" {p.name}: {', '.join(parts) if parts else 'configured'}") # Orphan wrappers diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index d2055780a4..e3727e8d95 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -15,7 +15,7 @@ from pathlib import Path # wiped (#57828) so early recovery provably runs before third-party imports (test_early_recovery). # The parser internals are imported lazily below because gateway tests stub ``sys.modules["dotenv"]``. import dotenv -from utils import atomic_replace, load_yaml_file_readonly, mkstemp_beside +from utils import atomic_replace, mkstemp_beside logger = logging.getLogger(__name__) @@ -581,6 +581,13 @@ def load_hermes_dotenv( if os.environ.get(name) != value: os.environ[name] = value + # The config backend is selected here — after the .env files, before the first config.yaml read + # (``_apply_external_secret_sources`` reads ``secrets:``) — so an unavailable backend stops the + # process before any reader could fall back to defaults (config-config design §4.4, D11/D32). + from hermes_cli.config_backend import get_config_backend + + get_config_backend() + # External sources are skipped for the updater (dotenv + managed env still load): ``update`` must not # import optional secret-manager libs (Bitwarden → cryptography → _rust.pyd) into the process replacing # that env on Windows, and a fresh retry after a deferred dependency install would otherwise make the @@ -822,8 +829,10 @@ def _remediation_hint(source_name: str, error_kind, secrets_cfg: dict, *, scope: def _load_secrets_config(home_path: Path) -> dict: """Read just the ``secrets:`` section of config.yaml, isolated so a malformed config can't break dotenv.""" + from hermes_cli.config_backend import config_exists, read_config_doc_readonly + config_path = home_path / "config.yaml" - if not config_path.exists(): + if not config_exists(config_path): return {} # Prefer the shared raw-config cache: this is the first config.yaml read of a normal startup, so # populating it lets main.py's early bridge and hermes_logging reuse one parse instead of 3-4. @@ -837,7 +846,7 @@ def _load_secrets_config(home_path: Path) -> dict: pass # Routed profiles re-enter their scope on every poll/turn; only re-parse after the file changed. try: - data = load_yaml_file_readonly(config_path) or {} + data = read_config_doc_readonly(config_path) or {} except Exception: return {} return data.get("secrets") or {} diff --git a/hermes_cli/gateway_multiplex_mode.py b/hermes_cli/gateway_multiplex_mode.py index efdf1e3167..24bfa2a60f 100644 --- a/hermes_cli/gateway_multiplex_mode.py +++ b/hermes_cli/gateway_multiplex_mode.py @@ -60,12 +60,13 @@ def explicit_multiplex_flag(default_home: Path) -> Optional[bool]: ``GATEWAY_MULTIPLEX_PROFILES``, else ``gateway.multiplex_profiles`` (or the top-level alias) as written in its config.yaml; ``None`` when neither is set. Raw read on purpose: the callers are other processes (``hermes -p X ...`` has X's config loaded) asking about the default's file.""" + from hermes_cli.config_backend import config_exists from gateway.config import _bool_token, _env_multiplex_profiles_override env = _env_multiplex_profiles_override() if env is not None: return env cfg_path = Path(default_home) / "config.yaml" - if not cfg_path.exists(): + if not config_exists(cfg_path): return None from hermes_cli.config import read_user_config_raw cfg = read_user_config_raw(cfg_path) or {} @@ -196,13 +197,14 @@ def persist_resolved_default(decision: MultiplexDecision, default_home: Optional is made explicit ("left unset" was read as "off"), a retired ``false`` is rewritten in place and leaves a one-time marker for the boxed notice. Comment-preserving writer, once, and NEVER on a guard refusal (the file must not say true while the runtime is standalone). Returns True on a write.""" + from hermes_cli.config_backend import config_exists if not decision.enabled or decision.source == "guard": return False default_home = Path(default_home) if default_home is not None else _default_profile_home() cfg_path = default_home / "config.yaml" try: from hermes_cli.config import read_user_config_raw - cfg = read_user_config_raw(cfg_path) or {} if cfg_path.exists() else {} + cfg = read_user_config_raw(cfg_path) or {} if config_exists(cfg_path) else {} section = cfg.get("gateway") if isinstance(cfg.get("gateway"), dict) else {} in_file = cfg.get("multiplex_profiles", section.get("multiplex_profiles")) if in_file is True: diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 4279503ff5..557fd2aad8 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -4097,7 +4097,7 @@ def list_profiles_on_disk() -> list[str]: names.add("default") if profiles_dir.is_dir(): try: - names.update(e.name for e in profiles_dir.iterdir() if e.is_dir() and (e / "config.yaml").is_file()) + names.update(e.name for e in profiles_dir.iterdir() if e.is_dir() and (e / "config.yaml").is_file()) # config-reader: ok — profile directory discovery marker except OSError: pass return sorted(names) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 28717e732e..a38f8588e3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -259,16 +259,14 @@ def _config_default_interface_early() -> str: """Return the configured default interface ("cli"/"tui") via a minimal YAML read. Best-effort: any error falls back to "cli" (legacy behavior).""" global _EARLY_INTERFACE_CACHE + from hermes_cli.config_backend import config_exists, read_config_doc cfg_path = _early_interface_config_path() if _EARLY_INTERFACE_CACHE is not None and _EARLY_INTERFACE_CACHE[0] == cfg_path: return _EARLY_INTERFACE_CACHE[1] value = "cli" try: - if os.path.exists(cfg_path): - import hermes_yaml as _yaml_iface - - with open(cfg_path, encoding="utf-8-sig") as _f: - raw = _yaml_iface.safe_load(_f) or {} + if config_exists(cfg_path): + raw = read_config_doc(cfg_path) or {} disp = raw.get("display", {}) if isinstance(disp, dict): iface = disp.get("interface") @@ -721,6 +719,7 @@ load_hermes_dotenv(project_env=PROJECT_ROOT / ".env") # .env value still wins — this is config.yaml fallback only. network.force_ipv4 # is read from the same parse to avoid a second full load_config() (~17ms). _FORCE_IPV4_EARLY = False +from hermes_cli.config_backend import config_exists try: # 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. @@ -728,7 +727,7 @@ try: 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(): + if config_exists(_cfg_path): _early_cfg_raw = _load_effective_early(_cfg_path) if "HERMES_REDACT_SECRETS" not in os.environ: _early_sec_cfg = _early_cfg_raw.get("security", {}) diff --git a/hermes_cli/migrate.py b/hermes_cli/migrate.py index 11f96a7b8c..e322dfc7e7 100644 --- a/hermes_cli/migrate.py +++ b/hermes_cli/migrate.py @@ -29,6 +29,7 @@ def _fail(message: str) -> int: def cmd_migrate_xai(args: Any) -> int: """Run xAI May-15 model migration in dry-run or apply mode.""" + from hermes_cli.config_backend import config_exists from hermes_cli.xai_retirement import ( MIGRATION_GUIDE_URL, RETIREMENT_DATE, apply_migration, find_retired_xai_refs, format_issue) @@ -62,7 +63,7 @@ def cmd_migrate_xai(args: Any) -> int: Colors.DIM)) return 0 - if not config_path or not config_path.exists(): + if not config_path or not config_exists(config_path): return _fail(f"Could not locate config.yaml (looked at: {config_path})") try: diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 7e1c683b31..0eb7297849 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -19,7 +19,7 @@ from hermes_cli.providers import ( from hermes_cli.model_normalize import normalize_model_for_provider from agent.models_dev import ( ModelCapabilities, ModelInfo, get_model_capabilities, get_model_info, list_provider_models) -from utils import base_url_host_matches, base_url_hostname, base_url_origin, file_signature +from utils import base_url_host_matches, base_url_hostname, base_url_origin # Re-exported: callers/tests patch hermes_cli.model_switch.. from hermes_cli.model_switch_providers import list_authenticated_providers @@ -270,14 +270,15 @@ def _direct_alias_source_identity() -> Optional[tuple]: path = get_config_path() except Exception: return None + from hermes_cli.config_backend import config_version try: - stat = path.stat() + version = config_version(path) except OSError: # A missing config is still a definite identity for this profile. return (str(path), None) except Exception: return None - return (str(path), file_signature(stat)) + return (str(path), version) def _ensure_direct_aliases() -> None: diff --git a/hermes_cli/personality.py b/hermes_cli/personality.py index a438a8920a..8d72c07c6f 100644 --- a/hermes_cli/personality.py +++ b/hermes_cli/personality.py @@ -131,12 +131,12 @@ def persist_personality(value: Any) -> bool: name = normalize_personality_name(value) try: from hermes_constants import get_hermes_home - from utils import atomic_roundtrip_yaml_update + from hermes_cli.config_backend import write_config_key config_path = get_hermes_home() / "config.yaml" from hermes_constants import mkdir_under_hermes_home mkdir_under_hermes_home(config_path.parent) - atomic_roundtrip_yaml_update(config_path, "display.personality", name) + write_config_key(config_path, "display.personality", name) try: os.chmod(config_path, 0o600) except (OSError, NotImplementedError): diff --git a/hermes_cli/profile_channels.py b/hermes_cli/profile_channels.py index ac04c4a71a..cf5d7e7ce2 100644 --- a/hermes_cli/profile_channels.py +++ b/hermes_cli/profile_channels.py @@ -269,10 +269,11 @@ def _shared_adapters_active(source_dir: Optional[Path], shared: dict[str, tuple[ """Shared-prefix platforms the SOURCE runs as a channel: explicitly enabled in its config.yaml, or auto-enabled by a complete credential set in its ``.env`` and not explicitly disabled — the same gate ``gateway.platform_env.EnvCred`` applies at gateway start.""" + from hermes_cli.config_backend import config_exists if source_dir is None: return set(shared) # no source to consult: the historical (strip) behaviour raw: dict = {} - if (source_dir / "config.yaml").is_file(): + if config_exists(source_dir / "config.yaml"): from hermes_cli.config import read_user_config_raw with contextlib.suppress(Exception): raw = read_user_config_raw(source_dir / "config.yaml") or {} @@ -386,7 +387,8 @@ def _channel_config_paths(raw: dict, platforms: Iterable[str]) -> list[tuple[str def strip_channel_config(config_path: Path, index: Optional[ChannelKeyIndex] = None) -> list[str]: """Remove platform sections from a raw ``config.yaml`` in place. Returns the dotted paths removed.""" - if not config_path.is_file(): + from hermes_cli.config_backend import config_exists + if not config_exists(config_path): return [] from hermes_cli.config import atomic_config_replace, read_user_config_raw index = index or ChannelKeyIndex() @@ -450,6 +452,7 @@ def strip_channel_settings(profile_dir: Path, *, include_state: bool, source_dir def channel_platforms_configured(profile_dir: Path) -> list[str]: """Platform ids with any channel setting in ``profile_dir`` (.env keys or config.yaml sections) — what a channel-less clone of it leaves behind. Pure read, in ``profile_dir``'s plugin scope.""" + from hermes_cli.config_backend import config_exists index = ChannelKeyIndex(profile_dir) found: set[str] = set() env_path = profile_dir / ".env" @@ -460,7 +463,7 @@ def channel_platforms_configured(profile_dir: Path) -> list[str]: if platform and platform != GATEWAY_POLICY_ID: found.add(platform) config_path = profile_dir / "config.yaml" - if config_path.is_file(): + if config_exists(config_path): from hermes_cli.config import read_user_config_raw raw = read_user_config_raw(config_path) for path in _channel_config_paths(raw, index.platforms): @@ -499,8 +502,9 @@ def clone_channels_refusal(source_dir: Path, source_label: str) -> Optional[str] def _config_platform_tokens(config_path: Path) -> dict[str, str]: """``{platform: token}`` from ``platforms.

.token|api_key`` (both nesting spellings).""" + from hermes_cli.config_backend import config_exists tokens: dict[str, str] = {} - if not config_path.is_file(): + if not config_exists(config_path): return tokens from hermes_cli.config import read_user_config_raw raw = read_user_config_raw(config_path) diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py index e2b63be025..007b2f4196 100644 --- a/hermes_cli/profile_distribution.py +++ b/hermes_cli/profile_distribution.py @@ -559,7 +559,7 @@ def _copy_dist_payload(staged: Path, target: Path, manifest: DistributionManifes # _replace_entry unlinks first so copy2 cannot write through a symlinked .env.EXAMPLE. _replace_entry(src, target / ENV_EXAMPLE_FILENAME) continue - if name == "config.yaml" and preserve_config and (target / "config.yaml").exists(): + if name == "config.yaml" and preserve_config and (target / "config.yaml").exists(): # config-reader: ok — file tooling continue if _merges_per_root(src, rel_parts): _merge_dir(src, _real_dir(target, rel_parts), rel_parts) diff --git a/hermes_cli/profile_memory_config.py b/hermes_cli/profile_memory_config.py index 36bb099b09..c3e0da2354 100644 --- a/hermes_cli/profile_memory_config.py +++ b/hermes_cli/profile_memory_config.py @@ -63,9 +63,9 @@ def clone_memory_provider_config(source_dir: Path, profile_dir: Path, provider: def cloned_memory_provider(profile_dir: Path) -> Optional[str]: """Name of the external provider whose config *profile_dir* now carries, for the CLI notice.""" - from hermes_cli.profiles import _load_yaml_dict + from hermes_cli.profiles import _load_config_dict - provider = active_memory_provider(_load_yaml_dict(profile_dir / "config.yaml")) + provider = active_memory_provider(_load_config_dict(profile_dir)) if provider and ((profile_dir / provider).is_dir() or (profile_dir / f"{provider}.json").is_file()): return provider return None diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 822b9a3de5..a8eead5a0b 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -585,7 +585,8 @@ def remove_wrapper_script(name: str) -> bool: def _migrate_profile_config_if_outdated(profile_dir: Path) -> None: """Migrate a copied config.yaml to the current schema (non-interactive, scoped to the new profile); otherwise the first desktop/doctor view shows a scary ``v0 -> latest`` warning.""" - if not (profile_dir / "config.yaml").exists(): + from hermes_cli.config_backend import config_exists + if not config_exists(profile_dir / "config.yaml"): return # Creation must not fail over an unmigratable old config; `hermes doctor --fix` surfaces # the detailed error in the target profile. @@ -705,6 +706,19 @@ def _load_yaml_dict(path: Path) -> Optional[dict]: return data if isinstance(data, dict) else None +def _load_config_dict(profile_dir: Path) -> Optional[dict]: + """:func:`_load_yaml_dict` for a profile's config.yaml, read through the config backend.""" + from hermes_cli.config_backend import config_exists, read_config_doc + path = profile_dir / "config.yaml" + if not config_exists(path): + return None + try: + data = read_config_doc(path) or {} + except Exception: + return None + return data if isinstance(data, dict) else None + + # (path, kind) -> (file signature, the small derived value). `list_profiles` re-reads three YAML # files PER PROFILE, and it is the shared body of `GET /api/profiles` and `profiles.list`, which the # Bots roster polls every 5s per connection — so an installer-seeded config.yaml (the annotated @@ -755,8 +769,9 @@ def _read_distribution_meta(profile_dir: Path) -> tuple: def _read_config_model(profile_dir: Path) -> tuple: """Read model/provider from a profile's config.yaml. Returns (model, provider).""" + from hermes_cli.config_backend import config_exists config_path = profile_dir / "config.yaml" - if not config_path.exists(): + if not config_exists(config_path): return None, None def _read() -> tuple: @@ -778,15 +793,16 @@ def _read_config_model(profile_dir: Path) -> tuple: def _seed_model_config(profile_dir: Path) -> None: """Copy (not link) the active profile's model block into a fresh profile so it is usable; profiles stay independent islands afterwards.""" + from hermes_cli.config_backend import config_exists config_path = profile_dir / "config.yaml" - if config_path.exists(): + if config_exists(config_path): return with contextlib.suppress(Exception): # creation must not fail over this; `hermes model` sets it later from hermes_constants import get_hermes_home from hermes_cli.config import atomic_config_write, read_user_config_raw from hermes_cli.profile_model_seed import launch_model_seed source = get_hermes_home() / "config.yaml" - seed = launch_model_seed(read_user_config_raw(source)) if source.is_file() else {} + seed = launch_model_seed(read_user_config_raw(source)) if config_exists(source) else {} if seed: atomic_config_write(config_path, seed) @@ -1068,13 +1084,13 @@ def profile_is_standalone(home: Path) -> bool: never standalone — it IS the host — and warns once per process if the key is set there.""" global _STANDALONE_WARNED from hermes_yaml import YAMLError - from utils import file_signature + from hermes_cli.config_backend import config_version home = Path(home) cfg_path = home / "config.yaml" key = str(home) try: - signature = file_signature(cfg_path.stat()) + signature = config_version(cfg_path) except FileNotFoundError: signature = None except OSError as exc: @@ -1379,8 +1395,7 @@ def _bootstrap_profile_dir(profile_dir: Path, source_dir: Optional[Path], _clone_file(source_dir, profile_dir, relpath) _clone_plugins(source_dir, profile_dir) from hermes_cli.profile_memory_config import active_memory_provider, clone_memory_provider_config - clone_memory_provider_config(source_dir, profile_dir, - active_memory_provider(_load_yaml_dict(source_dir / "config.yaml"))) + clone_memory_provider_config(source_dir, profile_dir, active_memory_provider(_load_config_dict(source_dir))) if sync_imports: # a plain file at the profile root, written by the agent-importers plugin _clone_file(source_dir, profile_dir, "import-sync.json") diff --git a/hermes_cli/send_cmd.py b/hermes_cli/send_cmd.py index 80739e78d6..8af4e37903 100644 --- a/hermes_cli/send_cmd.py +++ b/hermes_cli/send_cmd.py @@ -156,6 +156,7 @@ def _load_hermes_env() -> None: the profile's external secret sources over it — so it is authoritative as-is; replaying raw ``.env`` over it would let a stale user value beat the secret-manager one for this request. """ + from hermes_cli.config_backend import config_exists import os try: from hermes_cli.config import get_hermes_home @@ -179,7 +180,7 @@ def _load_hermes_env() -> None: # 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(): + if not config_exists(config_path): return try: from hermes_cli.config_effective import load_user_config_effective diff --git a/hermes_cli/update_channel.py b/hermes_cli/update_channel.py index a85dddf273..2aa8e6a00f 100644 --- a/hermes_cli/update_channel.py +++ b/hermes_cli/update_channel.py @@ -322,7 +322,7 @@ def _write_channel_record_locked(sha16: str, path: str, channel: str, the dotted writer would otherwise turn a scalar into a mapping and destroy whatever the user had there. """ - from utils import atomic_roundtrip_yaml_update + from hermes_cli.config_backend import write_config_key from hermes_cli.config import require_readable_config_before_write existing = require_readable_config_before_write(config_path) @@ -338,7 +338,7 @@ def _write_channel_record_locked(sha16: str, path: str, channel: str, new_record = dict(record) if isinstance(record, dict) else {} new_record["path"] = path # DATA, for humans + doctor GC new_record["channel"] = channel - atomic_roundtrip_yaml_update(config_path, f"update.installs.{sha16}", new_record) + write_config_key(config_path, f"update.installs.{sha16}", new_record) return True diff --git a/hermes_cli/update_cmd_config.py b/hermes_cli/update_cmd_config.py index bc0993ce41..ff14ce898d 100644 --- a/hermes_cli/update_cmd_config.py +++ b/hermes_cli/update_cmd_config.py @@ -52,7 +52,7 @@ def _migrate_sibling_profile_configs(notes: dict[str, list[str]] | None = None) from hermes_cli.backup import _sibling_profile_homes active_home = Path(get_process_hermes_home()) for name, profile_home in _sibling_profile_homes(active_home): - if not (profile_home / "config.yaml").is_file(): + if not (profile_home / "config.yaml").is_file(): # config-reader: ok — profile directory discovery marker continue # profile never configured — nothing to migrate token = set_hermes_home_override(profile_home) try: diff --git a/hermes_cli/web_routers/analytics.py b/hermes_cli/web_routers/analytics.py index 4c6d5e388e..253e5bb85c 100644 --- a/hermes_cli/web_routers/analytics.py +++ b/hermes_cli/web_routers/analytics.py @@ -41,11 +41,15 @@ async def get_config_raw(profile: Optional[str] = None): process's own profile, which is wrong under the global profile switcher. """ def _run(): + from hermes_cli.config_backend import supports_file_tooling + if not supports_file_tooling(): + # The raw editor shows the file's bytes; a non-file backend has no file. + raise HTTPException(status_code=409, detail="This config backend has no raw config file") with _profile_scope(profile): path = get_config_path() - if not path.exists(): + if not path.exists(): # config-reader: ok — file tooling (gated above) return {"yaml": "", "path": str(path)} - return {"yaml": path.read_text(encoding="utf-8-sig"), "path": str(path)} + return {"yaml": path.read_text(encoding="utf-8-sig"), "path": str(path)} # config-reader: ok — file tooling (gated above) return await asyncio.to_thread(_run) diff --git a/hermes_cli/xai_retirement.py b/hermes_cli/xai_retirement.py index 91c861a6f5..8b62aeccbf 100644 --- a/hermes_cli/xai_retirement.py +++ b/hermes_cli/xai_retirement.py @@ -131,8 +131,13 @@ def apply_migration( Unless ``backup=False`` a copy goes to ``backups/config/`` (reason ``pre-migrate-xai``). """ from ruamel.yaml import YAML # local import — avoid hard dep at module load + from hermes_cli.config_backend import ConfigBackendUnavailable, supports_file_tooling + if not supports_file_tooling(): + # A type-literal-preserving rewrite of the file itself; a non-file backend has no file. + raise ConfigBackendUnavailable("the xAI model migration rewrites the local config.yaml, and " + "this config backend has none") config_path = Path(config_path) - if not config_path.exists(): + if not config_path.exists(): # config-reader: ok — file tooling, gated on supports_file_tooling() raise FileNotFoundError(config_path) unchanged = ApplyResult(file_path=config_path, backup_path=None, issues_resolved=[], config_changed=False) if not issues: @@ -142,7 +147,7 @@ def apply_migration( yaml = YAML(typ="rt") yaml.width = ROUNDTRIP_YAML_WIDTH yaml.preserve_quotes = True - with config_path.open("r", encoding="utf-8-sig") as fh: + with config_path.open("r", encoding="utf-8-sig") as fh: # config-reader: ok — file tooling (gated above) doc = yaml.load(fh) if doc is None: return unchanged @@ -175,5 +180,5 @@ def apply_migration( # 0640 / container installs; a root-run migration must not flip ownership). buf = io.StringIO() yaml.dump(doc, buf) - atomic_write_text(config_path, buf.getvalue(), preserve_mode=True) + atomic_write_text(config_path, buf.getvalue(), preserve_mode=True) # config-reader: ok — file tooling (gated above) return ApplyResult(file_path=config_path, backup_path=backup_path, issues_resolved=resolved, config_changed=True) diff --git a/hermes_logging.py b/hermes_logging.py index fa86e3aa94..53f118630c 100644 --- a/hermes_logging.py +++ b/hermes_logging.py @@ -1030,12 +1030,9 @@ def _read_logging_config(): 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 + from hermes_cli.config_backend import config_exists, read_config_doc config_path = get_config_path() - cfg = {} - if config_path.exists(): - with open(config_path, "r", encoding="utf-8-sig") as f: - cfg = fast_safe_load(f) or {} + cfg = (read_config_doc(config_path) or {}) if config_exists(config_path) else {} if not cfg: return (None, None, None) log_cfg = cfg.get("logging", {}) diff --git a/hermes_time.py b/hermes_time.py index ad6bf842ae..b8bf20e3f5 100644 --- a/hermes_time.py +++ b/hermes_time.py @@ -89,14 +89,15 @@ def _resolve_timezone_name() -> str: # 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. + # parse (through the dependency-free config backend module) is the fallback when the + # effective loader cannot be imported. try: from hermes_cli.config_effective import load_user_config_effective cfg = load_user_config_effective(get_config_path()) except Exception: - import hermes_yaml as yaml + from hermes_cli.config_backend import config_exists, read_config_doc config_path = get_config_path() - cfg = (yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}) if config_path.exists() else {} + cfg = (read_config_doc(config_path) or {}) if config_exists(config_path) else {} if cfg: tz_cfg = cfg.get("timezone", "") if isinstance(tz_cfg, str) and tz_cfg.strip(): diff --git a/model_tools.py b/model_tools.py index 97f2987a7b..5010146333 100644 --- a/model_tools.py +++ b/model_tools.py @@ -23,7 +23,6 @@ from tools.registry import _MAX_TOOL_ERROR_CHARS as _TOOL_ERROR_MAX_LEN from toolsets import resolve_toolset, validate_toolset from tools.arg_coercion import coerce_tool_args from tools.todo_tool import TODO_LEGACY_ALIASES, TODO_SCHEMA -from utils import file_signature logger = logging.getLogger(__name__) @@ -266,8 +265,8 @@ def _tool_defs_cache_key( return None try: from hermes_cli.config import get_config_path - cfg_stat = get_config_path().stat() - cfg_fp = file_signature(cfg_stat) + from hermes_cli.config_backend import config_version + cfg_fp = config_version(get_config_path()) except (FileNotFoundError, OSError, ImportError): cfg_fp = None return ( diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 7e152298a1..635a12722b 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -2589,10 +2589,11 @@ class TelegramAdapter(TelegramHeldInboundMixin, TelegramTopicRoutingMixin, BaseP def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int, replace_existing: bool = False) -> None: """Save a newly created thread_id back into config.yaml so it survives restarts.""" + from hermes_cli.config_backend import config_exists try: from hermes_constants import get_hermes_home config_path = get_hermes_home() / "config.yaml" - if not config_path.exists(): + if not config_exists(config_path): logger.warning("[%s] Config file not found at %s, cannot persist thread_id", self.name, config_path) return from hermes_cli.config import atomic_config_write, read_user_config_raw diff --git a/scripts/check_config_yaml_readers.py b/scripts/check_config_yaml_readers.py new file mode 100644 index 0000000000..3f9e36eb6b --- /dev/null +++ b/scripts/check_config_yaml_readers.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Fail when a user ``config.yaml`` is read by anything but the config backend. + +Every read of ``/config.yaml`` must go through ``hermes_cli.config_backend`` +(``get_config_backend()`` / the path helpers there) so a non-file backend (design §4.1, D10) sees +every reader. A direct ``open`` / ``read_text`` / ``yaml.safe_load`` / ``fast_safe_load`` / +``load_yaml_file_readonly`` / ``stat`` of a config path silently keeps reading the local file — +the reader twin of the #92554 writer class, which regrew each time a new caller picked the raw +primitive again. + +Flags, in the scanned trees: + +* ``open(p)``, ``p.open()``, ``p.read_text()``, ``p.read_bytes()``, ``p.stat()``, + ``p.exists()``, ``p.is_file()``, ``os.stat(p)``, ``os.path.exists(p)``, ``os.path.isfile(p)``, + ``os.path.getmtime(p)``, ``load_yaml_file_readonly(p)``, ``safe_load(p)``, + ``fast_safe_load(p)``, ``yaml.load(p)`` and any ``*_load_yaml*`` / ``*_read_yaml*`` helper + call, where ``p`` is a config path: an expression naming ``config.yaml``, + any ``*config_path()`` call (``get_config_path()``, ``_active_config_path()``), or a local name / parameter bound to one + (``cfg_path = home / "config.yaml"`` then ``open(cfg_path)``); +* ``atomic_roundtrip_yaml_update`` / ``atomic_roundtrip_yaml_save`` / ``atomic_write_text`` of a + config path (the bypass writers; route them through ``write_config_document`` / + ``write_config_key``). + +Existence checks count: for a backend whose user layer is not a local file, "no local +config.yaml" must never mean "use defaults" (design §4.2) — ``config_exists()`` asks the backend. + +Suppress a true false positive with ``# config-reader: ok — `` on the call's line. + +Usage: python3 scripts/check_config_yaml_readers.py [paths...] +""" +from __future__ import annotations + +import ast +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_TREES = ( + "hermes_cli", "agent", "gateway", "tui_gateway", "cron", "plugins", "tools", "acp_adapter", + "cli.py", "utils.py", "hermes_constants.py", "hermes_logging.py", "hermes_time.py", "run_agent.py", + "model_tools.py", "batch_runner.py") +# The backend module itself, and the on-disk primitives it wraps. +ALLOWED_FILES = {ROOT / "hermes_cli" / "config_backend.py", ROOT / "utils.py"} +# Modules whose ``config_path`` / ``path`` names are their OWN config file, never a hermes +# config.yaml (the name heuristic would flag every line of them). +FOREIGN_CONFIG_FILES = { + "plugins/memory/honcho": "Honcho's own honcho.json", + "plugins/memory/openviking": "OpenViking's own ovcli config", + "agent/proxy_sources/iron_proxy.py": "iron-proxy's proxy.yaml", +} +SUPPRESS = "# config-reader: ok" + +# An expression that evaluates to a user config.yaml path. +CONFIG_PATH_EXPR_RE = re.compile(r"""["']config\.yaml["']|\w*config_path\(\)""") +# Parameters that conventionally carry the user config path. +CONFIG_PARAM_NAMES = {"config_path", "cfg_path", "config_yaml_path"} +PATH_METHOD_READS = {"open", "read_text", "read_bytes", "stat", "exists", "is_file"} +FUNC_READS = {"exists", "isfile", "open", "load_yaml_file_readonly", "safe_load", "fast_safe_load", "getmtime", "stat"} +BYPASS_WRITERS = {"atomic_roundtrip_yaml_update", "atomic_roundtrip_yaml_save", "atomic_write_text"} +YAML_HELPER_RE = re.compile(r"(load|read)_yaml|yaml_(load|read)") + + +def _func_name(node: ast.AST) -> str | None: + if isinstance(node, ast.Attribute): + return node.attr + if isinstance(node, ast.Name): + return node.id + return None + + +def _scope_nodes(scope: ast.AST): + """Nodes of *scope* without descending into nested function/class bodies.""" + stack = list(ast.iter_child_nodes(scope)) + while stack: + node = stack.pop() + yield node + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)): + stack.extend(ast.iter_child_nodes(node)) + + +class _Scanner: + def __init__(self, path: Path): + self.path = path + self.src = path.read_text(encoding="utf-8-sig") + self.lines = re.split(r"\r\n|\r|\n", self.src) # the tokenizer's line breaks, not splitlines()'s + self.blines = [line.encode("utf-8") for line in self.lines] + self.rel = path.relative_to(ROOT).as_posix() + self.problems: list[str] = [] + + def seg(self, node: ast.AST) -> str: + # ast.get_source_segment re-splits the whole file per call (quadratic on config.py). + start, end = getattr(node, "lineno", None), getattr(node, "end_lineno", None) + if start is None or end is None: + return "" + # col offsets are UTF-8 byte offsets. + c0, c1 = getattr(node, "col_offset"), getattr(node, "end_col_offset") + b = self.blines + if start == end: + return b[start - 1][c0:c1].decode("utf-8", "replace") + return b"\n".join([b[start - 1][c0:], *b[start:end - 1], b[end - 1][:c1]]).decode("utf-8", "replace") + + def is_config_path(self, node: ast.AST, names: set[str]) -> bool: + if isinstance(node, ast.Name) and node.id in names: + return True + if isinstance(node, ast.Call) and _func_name(node.func) in {"Path", "str", "expanduser", "resolve"}: + inner = node.args[0] if node.args else (node.func.value if isinstance(node.func, ast.Attribute) else None) + if inner is not None and self.is_config_path(inner, names): + return True + if isinstance(node, ast.BoolOp): + return any(self.is_config_path(v, names) for v in node.values) + return bool(CONFIG_PATH_EXPR_RE.search(self.seg(node))) + + def config_names(self, scope: ast.AST, inherited: set[str]) -> set[str]: + names = set(inherited) + if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)): + for a in (*scope.args.posonlyargs, *scope.args.args, *scope.args.kwonlyargs): + if a.arg in CONFIG_PARAM_NAMES: + names.add(a.arg) + # Two passes so `a = home / "config.yaml"; b = a` binds b too. + for _ in range(2): + for node in _scope_nodes(scope): + targets: list = [] + if isinstance(node, ast.Assign): + targets, value = node.targets, node.value + elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)) and node.value is not None: + targets, value = [node.target], node.value + else: + continue + if self.is_config_path(value, names): + names.update(t.id for t in targets if isinstance(t, ast.Name)) + return names + + def flag(self, node: ast.Call, why: str) -> None: + if SUPPRESS in self.lines[node.lineno - 1]: + return + self.problems.append(f"{self.rel}:{node.lineno}: {why} — go through hermes_cli.config_backend") + + def check_call(self, node: ast.Call, names: set[str]) -> None: + name = _func_name(node.func) + if name is None: + return + receiver = node.func.value if isinstance(node.func, ast.Attribute) else None + first = node.args[0] if node.args else None + if name in BYPASS_WRITERS and first is not None and self.is_config_path(first, names): + self.flag(node, f"bypass write of a config path ({self.seg(first)})") + return + if name in PATH_METHOD_READS and receiver is not None and self.is_config_path(receiver, names): + self.flag(node, f"direct {name}() of a config path ({self.seg(receiver)})") + return + is_reader = name in FUNC_READS or YAML_HELPER_RE.search(name) is not None + if name == "load" and receiver is not None and "yaml" in self.seg(receiver).lower(): + is_reader = True + if is_reader and first is not None and (receiver is None or name != "open") and self.is_config_path(first, names): + self.flag(node, f"direct {name}() of a config path ({self.seg(first)[:60]})") + + def scan(self) -> list[str]: + try: + tree = ast.parse(self.src) + except SyntaxError: + return [] + + def walk(scope: ast.AST, inherited: set[str]) -> None: + names = self.config_names(scope, inherited) + for node in _scope_nodes(scope): + if isinstance(node, ast.Call): + self.check_call(node, names) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)): + walk(node, names) + + walk(tree, set()) + return self.problems + + +def scan_file(path: Path) -> list[str]: + return _Scanner(path).scan() + + +def main(argv: list[str]) -> int: + targets = [ROOT / a for a in argv] or [ROOT / t for t in DEFAULT_TREES] + files: list[Path] = [] + for t in targets: + if t.is_dir(): + files.extend(p for p in t.rglob("*.py") if "node_modules" not in p.parts and "tests" not in p.parts) + elif t.suffix == ".py" and t.exists(): + files.append(t) + problems: list[str] = [] + for f in sorted(set(files)): + if f.resolve() in ALLOWED_FILES: + continue + rel = f.resolve().relative_to(ROOT).as_posix() if f.resolve().is_relative_to(ROOT) else "" + if any(rel == p or rel.startswith(p + "/") for p in FOREIGN_CONFIG_FILES): + continue + problems.extend(scan_file(f)) + if problems: + print("config.yaml must only be read through hermes_cli.config_backend:", file=sys.stderr) + for p in problems: + print(f" {p}", file=sys.stderr) + return 1 + print(f"check_config_yaml_readers: OK ({len(files)} files)") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/agent/test_skill_utils.py b/tests/agent/test_skill_utils.py index e8809503d0..6cffa328d0 100644 --- a/tests/agent/test_skill_utils.py +++ b/tests/agent/test_skill_utils.py @@ -48,18 +48,20 @@ skills: """.strip(), encoding="utf-8", ) + import utils parse_count = 0 - real_yaml_load = skill_utils.yaml_load + real_load = utils.fast_safe_load - def counting_yaml_load(text): + def counting_load(stream): nonlocal parse_count parse_count += 1 - return real_yaml_load(text) + return real_load(stream) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) skill_utils._external_dirs_cache_clear() getattr(skill_utils, "_raw_config_cache_clear", lambda: None)() - monkeypatch.setattr(skill_utils, "yaml_load", counting_yaml_load) + # The file config backend parses through utils.fast_safe_load. + monkeypatch.setattr(utils, "fast_safe_load", counting_load) assert get_disabled_skill_names() == {"hidden-skill"} assert get_external_skills_dirs() == [external.resolve()] diff --git a/tests/hermes_cli/test_config_backend.py b/tests/hermes_cli/test_config_backend.py new file mode 100644 index 0000000000..4e74c99d2c --- /dev/null +++ b/tests/hermes_cli/test_config_backend.py @@ -0,0 +1,114 @@ +"""The ConfigBackend seam (config-config design §4.1, D10/D11) and its reader gate.""" + +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from hermes_cli import config_backend as cb + +REPO = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def home(tmp_path, monkeypatch): + monkeypatch.delenv(cb.BACKEND_ENV, raising=False) + h = tmp_path / "home" + h.mkdir() + return h + + +class TestSelection: + + def test_default_is_file(self, home): + assert cb.get_config_backend().name == "file" + assert cb.supports_file_tooling() is True + + @pytest.mark.parametrize("value", ["remote", "bogus"]) + def test_unavailable_backend_fails_closed(self, home, monkeypatch, value): + # No config value can select the backend, and an unknown one never falls back to the file. + monkeypatch.setenv(cb.BACKEND_ENV, value) + with pytest.raises(cb.ConfigBackendUnavailable): + cb.get_config_backend() + with pytest.raises(cb.ConfigBackendUnavailable): + cb.read_config_doc(home / "config.yaml") + + def test_explicit_non_config_file_ignores_backend(self, home, monkeypatch): + other = home / "import-source.yaml" + other.write_text("a: 1\n", encoding="utf-8") + monkeypatch.setenv(cb.BACKEND_ENV, "remote") + assert cb.read_config_doc(other) == {"a": 1} + + +class TestFileBackend: + + def test_missing(self, home): + path = home / "config.yaml" + assert cb.config_exists(path) is False + with pytest.raises(FileNotFoundError): + cb.config_version(path) + with pytest.raises(FileNotFoundError): + cb.read_config_doc(path) + + def test_read_version_and_bom(self, home): + path = home / "config.yaml" + path.write_bytes("\ufeffmodel:\n default: x\n".encode("utf-8")) + assert cb.config_exists(path) + assert cb.read_config_doc(path) == {"model": {"default": "x"}} + assert cb.read_config_doc_readonly(path) == {"model": {"default": "x"}} + v1 = cb.config_version(path) + path.write_text("model:\n default: longer-value\n", encoding="utf-8") + assert cb.config_version(path) != v1 + + def test_writes_preserve_comments(self, home): + path = home / "config.yaml" + path.write_text("# keep me\nmodel:\n default: a # inline\n", encoding="utf-8") + cb.write_config_key(path, "model.default", "b") + cb.write_config_key(path, "display.personality", "kawaii") + text = path.read_text(encoding="utf-8") + assert "# keep me" in text and "# inline" in text + assert cb.read_config_doc(path) == {"model": {"default": "b"}, "display": {"personality": "kawaii"}} + cb.write_config_document(path, {"model": {"default": "c"}}) + assert "# keep me" in path.read_text(encoding="utf-8") + assert cb.read_config_doc(path) == {"model": {"default": "c"}} + + +class TestReaderGate: + + def _guard(self): + sys.path.insert(0, str(REPO / "scripts")) + try: + import check_config_yaml_readers as guard + finally: + sys.path.pop(0) + return guard + + def test_flags_direct_readers(self, tmp_path): + guard = self._guard() + bad = tmp_path / "hermes_cli" / "bad_reader.py" + bad.parent.mkdir() + bad.write_text( + "import yaml\n" + "def a(home):\n p = home / 'config.yaml'\n return yaml.safe_load(p.read_text())\n" + "def b(config_path):\n return config_path.exists()\n" + "def c(other):\n return other.read_text()\n" + "def d(cfg_path):\n return cfg_path.stat() # config-reader: ok — test\n" + "def e():\n from utils import atomic_roundtrip_yaml_update\n" + " atomic_roundtrip_yaml_update(get_config_path(), 'a', 1)\n", + encoding="utf-8") + with patch.object(guard, "ROOT", tmp_path): + problems = guard.scan_file(bad) + assert sorted(int(p.split(":")[1]) for p in problems) == [4, 6, 13], problems + + def test_backend_calls_are_clean(self, tmp_path): + guard = self._guard() + good = tmp_path / "hermes_cli" / "good_reader.py" + good.parent.mkdir() + good.write_text( + "from hermes_cli.config_backend import config_exists, read_config_doc\n" + "def a(home):\n p = home / 'config.yaml'\n" + " return read_config_doc(p) if config_exists(p) else {}\n", + encoding="utf-8") + with patch.object(guard, "ROOT", tmp_path): + assert guard.scan_file(good) == [] diff --git a/tests/hermes_cli/test_config_save_integrity.py b/tests/hermes_cli/test_config_save_integrity.py index 71968f15ec..de2d57c6cd 100644 --- a/tests/hermes_cli/test_config_save_integrity.py +++ b/tests/hermes_cli/test_config_save_integrity.py @@ -38,7 +38,8 @@ class _ReadFaults: """Counts parses of config.yaml and fails the chosen one with a transient EMFILE (intact file).""" def __init__(self, monkeypatch, path): - self.path, self.count, self.fail_at, real = str(path), 0, 0, config_mod.fast_safe_load + import utils + self.path, self.count, self.fail_at, real = str(path), 0, 0, utils.fast_safe_load def flaky(stream): if getattr(stream, "name", None) == self.path: @@ -46,7 +47,8 @@ class _ReadFaults: if self.count == self.fail_at: raise OSError(errno.EMFILE, "Too many open files") return real(stream) - monkeypatch.setattr(config_mod, "fast_safe_load", flaky) + # Every config read parses through the file config backend, which calls utils.fast_safe_load. + monkeypatch.setattr(utils, "fast_safe_load", flaky) def arm(self, fail_at=0): self.count, self.fail_at = 0, fail_at @@ -157,7 +159,7 @@ def test_unreadable_config_serves_one_cached_fallback_until_it_reads(home, monke once, not per call: ~250x slower loads before), and the first load once the file opens again reads the real file even though its signature never changed.""" import builtins - from hermes_cli import config_backups + from hermes_cli import config_backend, config_backups from hermes_cli.config_read_errors import FailedConfigRead path = home / "config.yaml" _fresh_process(home, _CONFIG) @@ -170,7 +172,7 @@ def test_unreadable_config_serves_one_cached_fallback_until_it_reads(home, monke if blocked[0] and str(file) == str(path): raise OSError(errno.EMFILE, "Too many open files") return builtins.open(file, *args, **kwargs) - monkeypatch.setattr(config_mod, "open", guarded_open, raising=False) + monkeypatch.setattr(config_backend, "open", guarded_open, raising=False) monkeypatch.setattr(config_backups, "load_newest_good_backup", lambda p: rebuilds.append(p) or real_backup(p)) for _ in range(5): diff --git a/tools/bot_mode_probe.py b/tools/bot_mode_probe.py index a67db2addb..6392ce3b89 100644 --- a/tools/bot_mode_probe.py +++ b/tools/bot_mode_probe.py @@ -209,7 +209,9 @@ def _peers(root: Path) -> list[str]: """Registered peer gateway names (``hermes peer``) from config.yaml, read directly (no config-loader import; the section is absent on most installs). Never raises.""" def _names() -> list[str]: - peers = (_read_yaml_dict(root / "config.yaml", "bot_peers") or {}).get("bot_peers") + from hermes_cli.config_backend import read_config_doc_readonly + doc = read_config_doc_readonly(root / "config.yaml") + peers = doc.get("bot_peers") if isinstance(doc, dict) else None return sorted(str(n) for n in peers if str(n).strip()) if isinstance(peers, dict) else [] return _swallow(_names, []) diff --git a/tools/terminal_scope.py b/tools/terminal_scope.py index 270a47a06b..a9110eb6d2 100644 --- a/tools/terminal_scope.py +++ b/tools/terminal_scope.py @@ -127,6 +127,8 @@ def build_profile_terminal_scope( closes. It sits where the process env sits in the standalone bridge — explicit YAML keys still win (``apply_terminal_config_to_env``). """ + from hermes_cli.config_backend import config_exists + home = Path(hermes_home) scope = default_terminal_scope() default_image = scope.get("TERMINAL_DOCKER_IMAGE") @@ -159,15 +161,15 @@ def build_profile_terminal_scope( # "unparseable" into {}): present-but-unparseable must fail closed. config_path = home / "config.yaml" try: - config_exists = config_path.exists() + config_exists = config_exists(config_path) except Exception as exc: raise TerminalPolicyUnavailable(f"cannot resolve terminal config in {home}: {exc}") from exc if config_exists: - from utils import load_yaml_file_readonly + from hermes_cli.config_backend import read_config_doc_readonly try: # Signature-cached: a scope is rebuilt per routed turn/poll, the file rarely changes. - raw = load_yaml_file_readonly(config_path) + raw = read_config_doc_readonly(config_path) except Exception as exc: raise TerminalPolicyUnavailable(f"cannot parse {config_path}: {exc}") from exc raw_terminal = raw.get("terminal") if isinstance(raw, dict) else None diff --git a/tools/website_policy.py b/tools/website_policy.py index d2c24b2066..2f5ebf8e12 100644 --- a/tools/website_policy.py +++ b/tools/website_policy.py @@ -78,7 +78,8 @@ def _require_mapping(value: Any, label: str) -> dict[str, Any]: def _load_policy_config(config_path: Path) -> dict[str, Any]: - if not config_path.exists(): + from hermes_cli.config_backend import config_exists, read_config_doc + if not config_exists(config_path): return dict(_DEFAULT_WEBSITE_BLOCKLIST) try: import hermes_yaml as yaml @@ -86,7 +87,7 @@ def _load_policy_config(config_path: Path) -> dict[str, Any]: logger.debug("ruamel.yaml not installed — website blocklist disabled") return dict(_DEFAULT_WEBSITE_BLOCKLIST) try: - config = yaml.safe_load(config_path.read_text(encoding="utf-8-sig")) or {} + config = read_config_doc(config_path) or {} except yaml.YAMLError as exc: raise WebsitePolicyError(f"Invalid config YAML at {config_path}: {exc}") from exc except OSError as exc: diff --git a/tui_gateway/methods_config.py b/tui_gateway/methods_config.py index 2ee5564f08..ac34f7c613 100644 --- a/tui_gateway/methods_config.py +++ b/tui_gateway/methods_config.py @@ -244,9 +244,11 @@ def _cfg_get_thinking_mode(params): def _cfg_get_mtime(params): + from hermes_cli.config_backend import config_exists, config_version cfg_path = _hermes_home / "config.yaml" try: - mtime = cfg_path.stat().st_mtime if cfg_path.exists() else 0 + # The client only compares for change; the file backend's version leads with st_mtime_ns. + mtime = config_version(cfg_path)[0] / 1_000_000_000 if config_exists(cfg_path) else 0 except Exception: return {"mtime": 0} # mcp_rev: hash of the MCP-relevant sections so the poller reloads MCP servers only when diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e5a7a6193e..7469d779e2 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -28,7 +28,7 @@ from hermes_constants import ( get_hermes_home, get_hermes_home_override, get_process_hermes_home, profile_name_for_home, reset_hermes_home_override, set_hermes_home_override) from hermes_cli.env_loader import load_hermes_dotenv -from utils import file_signature, is_truthy_value +from utils import is_truthy_value from hermes_state_ids import new_session_id from tools.environments.local import hermes_subprocess_env from agent.fast_mode import STATIC_TIERS @@ -671,12 +671,13 @@ def _profile_configured_cwd(profile_home: Path | None) -> str | None: env var (issue #40334). Returns an absolute, existing directory, or None for placeholders / missing / invalid paths. """ + from hermes_cli.config_backend import config_exists if profile_home is None: return None with contextlib.suppress(Exception): from hermes_cli.config_effective import load_user_config_effective p = Path(profile_home) / "config.yaml" - return _configured_cwd_from_cfg(load_user_config_effective(p)) if p.exists() else None + return _configured_cwd_from_cfg(load_user_config_effective(p)) if config_exists(p) else None return None @@ -997,15 +998,16 @@ def _load_cfg_raw() -> dict: expansion applied here would be persisted on the next save). Behavioral reads use :func:`_load_cfg`. Cache keyed on the resolved path so profiles don't clobber.""" global _cfg_cache, _cfg_sig, _cfg_path + from hermes_cli.config_backend import config_exists, config_version from hermes_cli.config import read_user_config_raw from hermes_cli.config_read_errors import FailedConfigRead try: p = _active_config_path() - sig = file_signature(p.stat()) if p.exists() else None + sig = config_version(p) if config_exists(p) else None with _cfg_lock: if _cfg_cache is not None and _cfg_sig == sig and _cfg_path == p: return copy.deepcopy(_cfg_cache) - data = read_user_config_raw(p) if p.exists() else {} + data = read_user_config_raw(p) if config_exists(p) else {} except Exception as exc: return FailedConfigRead(error=exc) # readable as {}, refused by _save_cfg with _cfg_lock: # cache the RAW config: _save_cfg writes _cfg_cache back to disk @@ -1027,12 +1029,13 @@ def _load_cfg() -> dict: def _save_cfg(cfg: dict): global _cfg_cache, _cfg_sig, _cfg_path from hermes_cli.config import atomic_config_replace + from hermes_cli.config_backend import config_version path = _active_config_path() atomic_config_replace(path, cfg) with _cfg_lock: _cfg_cache, _cfg_path = copy.deepcopy(cfg), path try: - _cfg_sig = file_signature(path.stat()) + _cfg_sig = config_version(path) except Exception: _cfg_sig = None