feat(config): route every user config.yaml read and write through a ConfigBackend seam

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.
This commit is contained in:
Ben Barclay authored and Teknium committed 2026-10-25 18:54:09 -07:00
1 parent 0ebc4a85cd
commit 871900df63
55 files changed
+808 -177

No files matched your search

+5
View File
@@ -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).
+2 -2
View File
@@ -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 {}
+9 -7
View File
@@ -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
+5 -4
View File
@@ -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
+2 -1
View File
@@ -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)
+2 -1
View File
@@ -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 {}
+5 -8
View File
@@ -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
+3 -3
View File
@@ -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")
+6 -3
View File
@@ -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)
+2 -1
View File
@@ -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
+7
View File
@@ -96,6 +96,13 @@ set/get/unset <NAME>` 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 — <why>`.
- **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
+2 -1
View File
@@ -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()
+2 -2
View File
@@ -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:
+6 -6
View File
@@ -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)
+5 -6
View File
@@ -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
+2 -2
View File
@@ -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
+2 -3
View File
@@ -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:
+39 -28
View File
@@ -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.<task>.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 <key> <value>` 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}")
+219
View File
@@ -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 ``<home>/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.
``<home>/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()
+8 -1
View File
@@ -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
+10 -10
View File
@@ -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)
+5 -5
View File
@@ -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
+4 -4
View File
@@ -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:
+2 -1
View File
@@ -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)
+4 -2
View File
@@ -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(""):
+4 -2
View File
@@ -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
+12 -3
View File
@@ -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 {}
+4 -2
View File
@@ -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:
+1 -1
View File
@@ -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)
+5 -6
View File
@@ -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", {})
+2 -1
View File
@@ -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:
+4 -3
View File
@@ -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.<name>.
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:
+2 -2
View File
@@ -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):
+8 -4
View File
@@ -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.<p>.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)
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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
+23 -8
View File
@@ -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")
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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:
+6 -2
View File
@@ -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)
+8 -3
View File
@@ -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)
+2 -5
View File
@@ -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", {})
+4 -3
View File
@@ -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():
+2 -3
View File
@@ -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 (
+2 -1
View File
@@ -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
+205
View File
@@ -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 ``<hermes home>/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 — <why>`` 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:]))
+6 -4
View File
@@ -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()]
+114
View File
@@ -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) == []
@@ -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):
+3 -1
View File
@@ -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, [])
+5 -3
View File
@@ -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
+3 -2
View File
@@ -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:
+3 -1
View File
@@ -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
+8 -5
View File
@@ -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