mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
0ebc4a85cd
commit
871900df63
55 files changed
+808
-177
No files matched your search
@@ -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
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(""):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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", {})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:]))
|
||||
@@ -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()]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user