"""Configuration management for Hermes Agent: config.yaml / .env loading, saving, validation, migration, and the ``hermes config`` command.""" import copy import difflib import json import logging import os import platform import re import shutil import stat import subprocess import sys import tempfile import threading import time import unicodedata from dataclasses import dataclass from decimal import Decimal, InvalidOperation from pathlib import Path from typing import Dict, Any, Optional, List, Tuple, Set import yaml from hermes_cli.cli_output import line_input from hermes_cli.colors import Colors, color from hermes_cli import managed_scope from hermes_cli.default_soul import DEFAULT_SOUL_MD, is_legacy_template_soul from hermes_cli.secret_prompt import masked_secret_prompt # Re-export from hermes_constants — canonical definition lives there. from hermes_constants import get_hermes_home, get_process_hermes_home # noqa: F401 from utils import atomic_replace, atomic_yaml_write, fast_safe_load logger = logging.getLogger(__name__) # (config_path, mtime_ns, size) tuples already warned about, so concurrent CLI/gateway # loads of a broken config.yaml don't spam stderr. A changed file (new mtime) warns again. _CONFIG_PARSE_WARNED: set = set() # path -> (mtime_ns, size, error message) of active parse failures. Written by # _warn_config_parse_failure() (the single funnel for every load-path parse failure) and # probed by get_active_config_parse_failure() so provider auto-resolution can refuse to # adopt a paid provider from env keys while the user's REAL config is unreadable. _CONFIG_PARSE_FAILURES: dict = {} class InvalidUserConfigError(RuntimeError): """Raised when a run that cannot repair config finds invalid user YAML.""" _PARSE_FAILURE_FALLBACK_MSG = { "last-known-good": ( "Keeping the previously loaded config for this process — " "edits to config.yaml are being IGNORED until the YAML is fixed."), "last-known-good-backup": ( "Loading the LAST KNOWN GOOD copy from backups/config/ instead — edits to config.yaml " "since that copy are being IGNORED until the YAML is fixed."), "refuse-write": ( "REFUSING to write config.yaml so the existing file is preserved. " "Fix the YAML (hermes config edit) and retry.")} _PARSE_FAILURE_DEFAULTS_MSG = ( "Falling back to default config — every user override (auxiliary providers, fallback chain, " "model settings) is being IGNORED. Fix the YAML and restart.") def _warn_config_parse_failure( config_path: Path, exc: Exception, *, fallback: str = "defaults") -> None: """Surface a config.yaml parse failure to log and stderr (once per file signature). Silent fallback to ``DEFAULT_CONFIG`` drops every user override, so this must be loud. ``fallback`` selects the message wording: ``"defaults"`` (fresh process, nothing else to serve) or ``"last-known-good"`` (in-process retention of the previously loaded config — see the codex#31188 port in ``_load_config_impl``). """ try: st = config_path.stat() key = (str(config_path), st.st_mtime_ns, st.st_size) _CONFIG_PARSE_FAILURES[str(config_path)] = (st.st_mtime_ns, st.st_size, str(exc)) except OSError: key = (str(config_path), 0, 0) if key in _CONFIG_PARSE_WARNED: return _CONFIG_PARSE_WARNED.add(key) from hermes_cli.config_backups import backup_config backup_path = backup_config(config_path, "corrupt") msg = f"Failed to parse {config_path}: {exc}. " + _PARSE_FAILURE_FALLBACK_MSG.get( fallback, _PARSE_FAILURE_DEFAULTS_MSG) if backup_path is not None: msg += f" A copy of the corrupted file was saved to {backup_path}." logger.warning(msg) try: sys.stderr.write(f"⚠️ hermes config: {msg}\n") sys.stderr.flush() except Exception: pass def get_active_config_parse_failure() -> Optional[str]: """Return the recorded parse error while the ACTIVE config.yaml is still byte-identical (mtime_ns + size) to the file that failed to parse; else None.""" try: path = get_config_path() mtime_ns, size, err = _CONFIG_PARSE_FAILURES[str(path)] st = path.stat() return err if (st.st_mtime_ns, st.st_size) == (mtime_ns, size) else None except Exception: return None _IS_WINDOWS = platform.system() == "Windows" _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") # Env var names that influence how the next subprocess executes — never writable through # ``save_env_value``: dynamic loader (LD_*/DYLD_*: attacker code loads before main()), # interpreter init (PYTHON*, NODE_*: Hermes restarts through them), PATH (fix tool lookup # with absolute paths instead), git rewrites (fire on every plugin install/update), # implicitly-invoked commands (BROWSER/EDITOR/VISUAL/PAGER = RCE on next $EDITOR), SHELL, # and Hermes runtime-location / security-policy flags (config.yaml is the supported surface). # # ``HERMES_*`` overall is NOT blocked — many integration credentials use that prefix # (HERMES_LANGFUSE_PUBLIC_KEY, HERMES_SPOTIFY_CLIENT_ID, ...). The denylist is name-by-name so # it cannot break provider setup wizards. Enforced on *write* only: pre-existing/out-of-band # ``.env`` values keep working; the dashboard's writable surface just cannot escalate. _ENV_VAR_NAME_DENYLIST: frozenset[str] = frozenset({ # Loader / linker "LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "LD_DEBUG", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "DYLD_FALLBACK_LIBRARY_PATH", "DYLD_FALLBACK_FRAMEWORK_PATH", # Python / Node "PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP", "PYTHONUSERBASE", "PYTHONEXECUTABLE", "PYTHONNOUSERSITE", "NODE_OPTIONS", "NODE_PATH", # General / git "PATH", "SHELL", "BROWSER", "EDITOR", "VISUAL", "PAGER", "GIT_SSH_COMMAND", "GIT_EXEC_PATH", "GIT_SHELL", # Hermes runtime location "HERMES_HOME", "HERMES_PROFILE", "HERMES_CONFIG", "HERMES_ENV", "HERMES_CONFIG_PATH", "HERMES_ENV_PATH", # MCP catalog trust root; package-manager wrappers may still set it in the process env. "HERMES_OPTIONAL_MCPS", # Local ACP subprocess selection (executable/argv authority). "HERMES_COPILOT_ACP_COMMAND", "HERMES_COPILOT_ACP_ARGS", # Security policy / approval-routing context — set via their dedicated controls only. "HERMES_YOLO_MODE", "HERMES_ACCEPT_HOOKS", "HERMES_REDACT_SECRETS", "HERMES_INTERACTIVE", "HERMES_EXEC_ASK", "HERMES_GATEWAY_SESSION", "HERMES_CRON_SESSION", "HERMES_SINGLE_QUERY_SESSION", "HERMES_SESSION_KEY", "HERMES_SESSION_PLATFORM"}) def _env_var_policy_name(key: str, *, is_windows: Optional[bool] = None) -> str: """Name used for env policy comparisons: Windows env names are case-insensitive, POSIX not. The override keeps both semantics testable on any host.""" windows = _IS_WINDOWS if is_windows is None else is_windows return key.upper() if windows else key def validate_env_var_name_for_write(key: str) -> None: """Validate an env name before a generic persistence write (exposed for batch callers).""" if not _ENV_VAR_NAME_RE.match(key): raise ValueError(f"Invalid environment variable name: {key!r}") if _env_var_policy_name(key) in _ENV_VAR_NAME_DENYLIST: raise ValueError( f"Environment variable {key!r} is on the writer denylist. " "Names that influence subprocess execution (LD_PRELOAD, PYTHONPATH, PATH, EDITOR, ...) " "or Hermes runtime location and security policy (HERMES_HOME, HERMES_YOLO_MODE, ...) " "cannot be persisted via the env writer. If you really need this, edit ~/.hermes/.env " "directly.") # Serializes all config read/write paths and guards the module-level caches below. libyaml's # C extension is not thread-safe for concurrent safe_load() on one file, and tool threads # (approval, browser, setup flows) load/save config concurrently during long agent runs. # RLock because save_config internally calls read_raw_config. _CONFIG_LOCK = threading.RLock() # path -> last successfully loaded (expanded) config; served after a parse failure so a # mid-edit broken YAML never silently drops user overrides (e.g. approvals.deny rules). _LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {} # path -> (user_mtime_ns, user_size, managed_mtime_ns, managed_size, merged, env_ref_snapshot). # load_config() returns a deepcopy of the cached value while the signature matches (skips # safe_load + merge + normalize + expand, ~13 ms). Writers use atomic_yaml_write (fresh inode # -> new mtime_ns) so no explicit invalidation is needed. The managed-file signature is folded # in so editing the managed-scope config.yaml invalidates, and the env snapshot invalidates # when a referenced ${VAR} changes value (late .env load, in-process rotation). # (path, mtime_ns, size) -> cached expanded config dict. load_config() returns a deepcopy of the cached # value when the file hasn't changed since the last load, skipping yaml.safe_load + _deep_merge + # _normalize_* + _expand_env_vars (~13 ms/call). save_config() + migrate_config() write via # atomic_yaml_write which produces a fresh inode, so stat() sees a new mtime_ns and the next load # repopulates automatically — no explicit invalidation hook. See #58514. _LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, int, int, Dict[str, Any], Dict[str, Optional[str]]]] = {} # path -> (mtime_ns, size, raw yaml dict) for read_raw_config() (no defaults merged in). _RAW_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {} # Env var names written to .env that aren't in OPTIONAL_ENV_VARS (managed by setup/provider # flows directly). Also the set reload_env() may remove from os.environ. _EXTRA_ENV_KEYS = frozenset({ "OPENAI_API_KEY", "OPENAI_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "DISCORD_HOME_CHANNEL", "DISCORD_HOME_CHANNEL_NAME", "TELEGRAM_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL_NAME", "SLACK_HOME_CHANNEL", "SLACK_HOME_CHANNEL_NAME", "SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL", "SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS", "SIGNAL_HOME_CHANNEL", "SIGNAL_HOME_CHANNEL_NAME", "SMS_HOME_CHANNEL", "SMS_HOME_CHANNEL_NAME", "DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET", "DINGTALK_HOME_CHANNEL", "DINGTALK_HOME_CHANNEL_NAME", "FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN", "FEISHU_HOME_CHANNEL", "FEISHU_HOME_CHANNEL_NAME", "YUANBAO_HOME_CHANNEL", "YUANBAO_HOME_CHANNEL_NAME", "WECOM_BOT_ID", "WECOM_SECRET", "WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID", "WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY", "WECOM_CALLBACK_HOST", "WECOM_CALLBACK_PORT", "WECOM_HOME_CHANNEL", "WECOM_HOME_CHANNEL_NAME", "WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL", "WEIXIN_CDN_BASE_URL", "WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY", "WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS", "BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD", "BLUEBUBBLES_HOME_CHANNEL", "BLUEBUBBLES_HOME_CHANNEL_NAME", "QQ_APP_ID", "QQ_CLIENT_SECRET", "QQBOT_HOME_CHANNEL", "QQBOT_HOME_CHANNEL_NAME", "QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", # legacy aliases (pre-rename, still read for back-compat) "QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT", "QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL", "IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL", "IRC_USE_TLS", "IRC_SERVER_PASSWORD", "IRC_NICKSERV_PASSWORD", "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT", # Deprecated (replaced by display.tool_progress) but STILL READ by the gateway as a # back-compat fallback. The boolean HERMES_TOOL_PROGRESS variant is unsupported (its only # consumer, the v3->4 migration, is below the v12 support floor); doctor flags it as ignored. "HERMES_TOOL_PROGRESS_MODE", "WHATSAPP_MODE", "WHATSAPP_ENABLED", "MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE", "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM", "MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", "MATRIX_DM_AUTO_THREAD", "MATRIX_RECOVERY_KEY", # Langfuse observability plugin tuning keys + standard SDK vars (activation is via # plugins.enabled; credentials gate the plugin at runtime). "HERMES_LANGFUSE_ENV", "HERMES_LANGFUSE_RELEASE", "HERMES_LANGFUSE_SAMPLE_RATE", "HERMES_LANGFUSE_MAX_CHARS", "HERMES_LANGFUSE_CAPTURE", "HERMES_LANGFUSE_DEBUG", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_BASE_URL", # ACP (Agent Client Protocol) keys — profile-isolable so profiles can use different backends. "HERMES_ACP_AUTH_METHOD", "HERMES_ACP_AUTO_APPROVE", "HERMES_COPILOT_ACP_COMMAND", "HERMES_COPILOT_ACP_ARGS", "COPILOT_CLI_PATH", "COPILOT_ACP_BASE_URL"}) # ---- Managed mode (NixOS declarative config) ---- _MANAGED_TRUE_VALUES = ("true", "1", "yes") _NIX_MANAGED_SYSTEMS = {"nixos", "home-manager"} # Only the NixOS module ever wrote a bare "true" or an empty marker. _LEGACY_MANAGED_SYSTEM = "nixos" # Nix store root; identifies `nix run` / `nix profile install` installs (which don't set # HERMES_MANAGED). Module-level so tests can patch it without touching /nix/store. _NIX_STORE = Path("/nix/store") # Homebrew is no longer a supported distribution: these markers fall through to git/unknown # detection instead of blocking config writes. _IGNORED_MANAGED_VALUES = frozenset({"brew", "homebrew"}) # Explicit opt-out (``HERMES_MANAGED=false``): without this a bool-shaped value became a package # manager literally named "false" and is_managed() blocked `hermes update` (#12864). _MANAGED_FALSE_VALUES = frozenset({"false", "0", "no", "off"}) def get_managed_system() -> Optional[str]: """Return the package manager owning this install, if any. Signals: HERMES_MANAGED env var (systemd service) or a ``.managed`` marker file in HERMES_HOME (NixOS activation script — interactive shells don't see the service env).""" marker = os.getenv("HERMES_MANAGED", "").strip().lower() or None managed_marker = get_hermes_home() / ".managed" if marker is None and managed_marker.exists(): try: marker = managed_marker.read_text(encoding="utf-8", errors="replace").strip().lower() except OSError: marker = "" if marker is None or marker in _IGNORED_MANAGED_VALUES or marker in _MANAGED_FALSE_VALUES: return None if marker == "" or marker in _MANAGED_TRUE_VALUES: return _LEGACY_MANAGED_SYSTEM return marker def is_managed() -> bool: """Check if Hermes is running in package-manager-managed mode.""" return get_managed_system() is not None # Nix installs arrive by several routes (nix run, nix profile, system flake, home-manager) and # the running process cannot tell which, so the text names the routes instead of one command. _NIX_UPDATE_MSG = ( "Update Hermes through the Nix source that installed it " "(e.g. nix profile upgrade, or update your flake input and rebuild with nixos-rebuild or home-manager switch)" ) def get_managed_update_command() -> Optional[str]: """Return the preferred upgrade command for a managed install.""" return _NIX_UPDATE_MSG if get_managed_system() in _NIX_MANAGED_SYSTEMS else None # "apt" is the Termux APT distribution identifier, not a generic Debian/Ubuntu signal; another # APT distribution needs its own method. "home-manager" is listed because the managed marker can # return it and a stamp must name every method this function returns. _SUPPORTED_INSTALL_METHODS = frozenset({"apt", "docker", "nix", "nixos", "home-manager", "git", "unknown"}) def _install_method_stamp(path: Path) -> Optional[str]: try: method = path.read_text(encoding="utf-8").strip().lower() except OSError: return None return method if method in _SUPPORTED_INSTALL_METHODS else None def detect_install_method(project_root: Optional[Path] = None) -> str: """Detect how Hermes was installed: apt/docker/nix/nixos/home-manager/git/unknown. Order: code-scoped ``/.install_method`` stamp (authoritative) -> legacy ``$HERMES_HOME/.install_method`` -> managed marker -> /nix/store path -> .git dir -> unknown. The stamp lives next to the code because HERMES_HOME is shared data: a container and a host install can bind-mount the same home, so a home-scoped ``docker`` stamp would make the host ``hermes update`` refuse to run. A legacy ``docker`` value is therefore ignored unless we are really inside a container, and being in a container alone never implies 'docker'. The supported installs self-identify via the code-scoped stamp: - the curl installer (scripts/install.sh, the README/website install command) git-clones the repo and stamps ``git`` next to the code; - the published ``nousresearch/hermes-agent`` image bakes a ``docker`` stamp into ``/opt/hermes`` at build time. An unsupported manual install dropped into a container (no stamp) falls through to the ``.git`` checks and behaves like any off-path install. See issue #34397. """ # The stamp is a property of the running code tree (parent of hermes_cli/), NOT of $HERMES_HOME, # so it survives two installs sharing a home. root = project_root if project_root is not None else get_project_root() method = _install_method_stamp(root / ".install_method") if method: return method method = _install_method_stamp(get_hermes_home() / ".install_method") if method and not (method == "docker" and not _running_in_container()): return method managed = get_managed_system() if managed: return managed.lower().replace(" ", "-") # Code under /nix/store/ is the hallmark of a nix-built install. try: resolved = root.resolve() if resolved != _NIX_STORE and _NIX_STORE in resolved.parents: return "nix" except OSError: pass # A .git directory, or a ``gitdir:`` pointer file for worktrees. git_path = root / ".git" try: if git_path.is_dir() or git_path.read_text(encoding="utf-8").strip().startswith("gitdir:"): return "git" except OSError: pass return "unknown" def _running_in_container() -> bool: """Import-safe wrapper around ``hermes_constants.is_container``.""" try: from hermes_constants import is_container return is_container() except Exception: return False def is_nix_install_method(method: str) -> bool: """True for every install method Nix owns ("nix", "nixos", "home-manager").""" return method == "nix" or method in _NIX_MANAGED_SYSTEMS _UPDATE_COMMAND_BY_METHOD = { "docker": "docker pull nousresearch/hermes-agent:latest", "apt": "pkg upgrade hermes-agent", # "apt" == Termux APT by contract; uses Termux's `pkg`. } def recommended_update_command_for_method(method: str) -> str: """Return the update command or guidance for a given install method.""" if is_nix_install_method(method): return _NIX_UPDATE_MSG return _UPDATE_COMMAND_BY_METHOD.get(method, "hermes update") def recommended_update_command() -> str: """Return the best update command for the current installation. Managed state wins over the code-scoped stamp: a managed install can carry a stale stamp naming an update path the managed guard refuses.""" return get_managed_update_command() or recommended_update_command_for_method( detect_install_method(get_project_root())) # Shared by ``cmd_update`` and ``_cmd_update_check`` (hermes_cli/main.py) so the wording never # forks. The published image excludes ``.git``, so the git update path can never succeed there # and the generic "reinstall via install.sh" fallback would install a NEW host-side Hermes. _DOCKER_UPDATE_MESSAGE = """\ ✗ ``hermes update`` doesn't apply inside the Docker container. Hermes Agent runs as a published image (nousresearch/hermes-agent), not a git checkout — the container has no working tree to pull into. Update by pulling a fresh image and restarting your container instead: docker pull nousresearch/hermes-agent:latest # then restart whatever started the container, e.g.: docker compose up -d --force-recreate hermes-agent # or, for ad-hoc runs, exit the current container and `docker run` again Verify the new version after restart: docker run --rm nousresearch/hermes-agent:latest --version Notes: • If you pinned a specific tag (e.g. ``:v0.14.0``) the ``:latest`` tag won't move your container — pull the newer tag you actually want, or switch to ``:latest`` / ``:main`` for rolling updates. See available tags at https://hub.docker.com/r/nousresearch/hermes-agent/tags • Your config and session history live under ``$HERMES_HOME`` (``/opt/data`` in the container, typically bind-mounted from the host) and persist across image upgrades — re-pulling doesn't lose any state. • Running a fork? Build your own image with this repo's ``Dockerfile`` and replace the ``docker pull`` step with your build/push pipeline.""" def format_docker_update_message() -> str: """Return the user-facing message for ``hermes update`` inside Docker.""" return _DOCKER_UPDATE_MESSAGE def format_managed_message(action: str = "modify this Hermes installation") -> str: """Build a user-facing error for managed installs.""" managed_system = get_managed_system() or "a package manager" return ( f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n" "Use your package manager to upgrade or reinstall Hermes.") def managed_error(action: str = "modify configuration"): """Print user-friendly error for managed mode.""" print(format_managed_message(action), file=sys.stderr) def get_container_exec_info() -> Optional[dict]: """Read container mode metadata from HERMES_HOME/.container-mode. Written by the NixOS activation script when container.enable = true; tells the host CLI to exec into the container instead of running locally. None when container mode is off, when already inside the container, or when HERMES_DEV=1 is set. Only FileNotFoundError is swallowed; other errors (permissions, malformed data) propagate.""" if os.environ.get("HERMES_DEV") == "1": return None from hermes_constants import is_container if is_container(): return None try: info = {} with open(get_hermes_home() / ".container-mode", "r", encoding="utf-8") as f: for line in f: line = line.strip() if "=" in line and not line.startswith("#"): key, _, value = line.partition("=") info[key.strip()] = value.strip() except FileNotFoundError: return None return { "backend": info.get("backend", "docker"), "container_name": info.get("container_name", "hermes-agent"), "exec_user": info.get("exec_user", "hermes"), "hermes_bin": info.get("hermes_bin", "/data/current-package/bin/hermes")} # ---- Config paths / HERMES_HOME skeleton ---- def get_config_path() -> Path: """Get the main config file path.""" return get_hermes_home() / "config.yaml" def require_parseable_user_config(*, ignore_user_config: bool = False) -> None: """Reject an existing invalid config before a non-interactive agent run. Interactive surfaces keep ``load_config()``'s recovery behavior so the operator can repair the file; a one-shot run has no such chance, and defaults there could silently pick a hosted provider and spend against ``.env`` credentials. Missing/empty files stay valid first-run states; ``--ignore-user-config`` / HERMES_IGNORE_USER_CONFIG=1 remain authoritative.""" if ignore_user_config or os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1": return config_path = get_config_path() try: with open(config_path, encoding="utf-8") as f: data = fast_safe_load(f) except FileNotFoundError: return except Exception as exc: parse_error = exc else: if data is None or isinstance(data, dict): return parse_error = TypeError(f"top-level YAML value must be a mapping, got {type(data).__name__}") from hermes_cli.config_backups import backup_config backup_path = backup_config(config_path, "corrupt") message = ( f"Refusing non-interactive startup because {config_path} is invalid: " f"{parse_error}. Repair the file or pass --ignore-user-config to " "intentionally run with built-in defaults.") if backup_path is not None: message += f" A copy was saved to {backup_path}." logger.error(message) raise InvalidUserConfigError(message) from parse_error def get_env_path() -> Path: """Get the .env file path (for API keys).""" return get_hermes_home() / ".env" def get_project_root() -> Path: """Get the project installation directory.""" return Path(__file__).parent.parent.resolve() def _resolve_hermes_uid_gid() -> tuple[Optional[int], Optional[int]]: """Read HERMES_UID / HERMES_GID (set by Docker deployments); (None, None) if unset/invalid/Windows. The entrypoint chowns HERMES_HOME once, but subdirs created at runtime (``profiles//``) need the same chown or they land root:root and block later uid-mapped workers. Docker containers running Hermes commonly set these to map the in-container user to a host user so volume-mounted state files end up with the right ownership. See #34107. """ if sys.platform == "win32": return None, None def _env_int(name: str) -> Optional[int]: try: return int(os.environ.get(name, "").strip() or None) except (TypeError, ValueError): return None return _env_int("HERMES_UID"), _env_int("HERMES_GID") def _chown_to_hermes_uid(path) -> None: """Chown ``path`` to ``HERMES_UID:HERMES_GID`` when set; EPERM/ENOENT are non-fatal (the entrypoint's startup chown -R fixes ownership on the next restart). Used by :func:`_secure_dir` to keep ownership consistent across all directories created by :func:`ensure_hermes_home` on Docker deployments. See #34107. """ uid, gid = _resolve_hermes_uid_gid() if uid is None and gid is None: return try: os.chown(path, uid if uid is not None else -1, gid if gid is not None else -1) except (OSError, AttributeError, NotImplementedError): pass def _secure_dir(path): """chmod a directory owner-only (0700) and apply HERMES_UID/GID ownership. No-op when managed; in a container only an explicit HERMES_HOME_MODE is applied. HERMES_HOME_MODE (e.g. 0701) overrides the mode so a web server can traverse HERMES_HOME to a served subdirectory without directory listings. Also applies ``HERMES_UID``/``HERMES_GID``-based ownership when those env vars are set (#34107 — Docker deployments need this so profile subdirs created at runtime by kanban workers don't land as root:root and block subsequent uid-mapped workers). """ if is_managed(): return explicit_mode = os.environ.get("HERMES_HOME_MODE", "").strip() # Same skip as _secure_file: a bind-mounted data dir is often shared with sibling containers # running as other UIDs (web UI, permissions fixers); forcing 0700 on it locks them out on every # start (#10757). An explicit HERMES_HOME_MODE is the operator's choice and is still applied. if _is_container() and not explicit_mode: _chown_to_hermes_uid(path) return try: mode = int(explicit_mode or "700", 8) except ValueError: mode = 0o700 try: os.chmod(path, mode) except (OSError, NotImplementedError): pass _chown_to_hermes_uid(path) def _is_container() -> bool: """Detect Docker/Podman/LXC (or HERMES_CONTAINER / HERMES_SKIP_CHMOD opt-out). Volume-mounted config is not forced to 0o600 in containers: gateway and dashboard may run as different UIDs, or the mount itself needs broader permissions.""" if (os.environ.get("HERMES_CONTAINER") or os.environ.get("HERMES_SKIP_CHMOD") or os.path.exists("/.dockerenv")): return True try: with open("/proc/1/cgroup", "r", encoding="utf-8") as f: cgroup_content = f.read() return any(marker in cgroup_content for marker in ("docker", "lxc", "kubepods")) except (OSError, IOError): return False def _secure_file(path): """chmod a file 0600. Skipped when managed (activation sets 0640 group-readable) or in a container (mounts often need broader permissions).""" if is_managed() or _is_container(): return try: if os.path.exists(str(path)): os.chmod(path, 0o600) except (OSError, NotImplementedError): pass def _ensure_default_soul_md(home: Path) -> None: """Seed DEFAULT_SOUL_MD on first run; upgrade a legacy comment-only scaffold in place. A SOUL.md the user actually customized is never touched.""" soul_path = home / "SOUL.md" if soul_path.exists(): try: existing = soul_path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): return if not is_legacy_template_soul(existing): return soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8") _secure_file(soul_path) # Home paths whose directory skeleton was created this process. Only successful passes are # recorded, so a raised managed-mode/missing-profile error keeps re-checking on later loads. _HERMES_HOME_ENSURED: set = set() _HERMES_HOME_SUBDIRS = ( "cron", "sessions", "logs", "logs/curator", "memories", "pairing", "hooks", "image_cache", "audio_cache", "skills") def ensure_hermes_home(): """Ensure the ~/.hermes directory skeleton exists with secure permissions. Memoized per home path: this runs on EVERY ``load_config()`` and the ~14 mkdir/chmod syscalls made repeated loads the dominant cost of hot read paths.""" home = get_hermes_home() key = str(home) # Named profiles must be created explicitly. Check tombstones BEFORE the memo so a stale # empty shell cannot skip the deleted-profile guard. from hermes_constants import assert_named_profile_home_live assert_named_profile_home_live(home) if key in _HERMES_HOME_ENSURED and home.is_dir(): return from hermes_cli.config_home import initialize_home initialize_home(home, _HERMES_HOME_SUBDIRS, _HERMES_HOME_ENSURED) # ---- Config loading/saving ---- from hermes_cli.config_defaults import DEFAULT_CONFIG, OPTIONAL_ENV_VARS # noqa: E402,F401 from hermes_cli.config_providers import ( # noqa: E402,F401 (re-exported; callers/tests use hermes_cli.config.) _API_MODE_ALIASES, _CAMEL_ALIASES, _KNOWN_PROVIDER_KEYS, _PROVIDER_NORMALIZE_WARNED, _canonical_api_mode, _coerce_ssl_verify, _custom_provider_entry_to_provider_config, _entries_for_route, _normalize_custom_provider_entry, _normalize_provider_models, _pick_provider_base_url, _route_model_cfg, _warn_once_per_provider, apply_custom_provider_extra_headers_to_client_kwargs, apply_custom_provider_tls_to_client_kwargs, coerce_provider_id, find_provider_entry, get_compatible_custom_providers, get_custom_provider_context_length, get_custom_provider_extra_headers, get_custom_provider_model_capability, get_custom_provider_tls_settings, is_provider_enabled, normalize_extra_headers, providers_dict_to_custom_providers, stringify_provider_map) # Back-compat re-exports — :mod:`hermes_cli.personality` owns personality/overlay semantics. from hermes_cli.personality import ( # noqa: E402,F401 NEUTRAL_PERSONALITY_NAMES as _NEUTRAL_PERSONALITY_NAMES, prompt_text as _prompt_text, render_personality_prompt, resolve_ephemeral_system_prompt as resolve_ephemeral_system_prompt_from_config) # ---- Config Migration System ---- # Env vars introduced per config version; migration only mentions vars new since the user's # previous version. ENV_VARS_BY_VERSION: Dict[int, List[str]] = { 3: ["FIRECRAWL_API_KEY", "BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "FAL_KEY"], 4: ["VOICE_TOOLS_OPENAI_KEY", "ELEVENLABS_API_KEY"], 5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"], 10: ["TAVILY_API_KEY"], 11: ["TERMINAL_MODAL_MODE"]} # Intentionally empty: the LLM provider is required but handled by the setup wizard's provider # selection step, so no single env var is universally required. REQUIRED_ENV_VARS = {} def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]: """Check which environment variables are missing.""" groups = [(REQUIRED_ENV_VARS, True)] if not required_only: groups.append((OPTIONAL_ENV_VARS, False)) return [ {"name": var_name, **info, "is_required": is_required} for table, is_required in groups for var_name, info in table.items() if not get_env_value(var_name)] def _split_key_path(key: str) -> list[str]: """Split a dotted config-key path, honoring backslash-escaped dots (``a\\.b`` -> ``a.b``). Backslashes before any other character are preserved verbatim. ``hermes config set`` uses ``.`` as the nesting separator, so a key that itself contains a literal dot (e.g. provider names like ``qwen3.5-397b-wafer``) was silently split into bogus nested segments (#84064). """ parts: list[str] = [] current: list[str] = [] i = 0 while i < len(key): ch = key[i] if ch == "\\" and key[i + 1:i + 2] == ".": current.append(".") i += 2 continue if ch == ".": parts.append("".join(current)) current = [] else: current.append(ch) i += 1 parts.append("".join(current)) return parts def _greedy_literal_match(container: dict, parts: list) -> Optional[Tuple[str, int]]: """Return ``(literal_key, n_consumed)`` for the longest dotted literal key present in *container*, or None. With no multi-segment literal this is the historic plain-split walk. Dots in config key names are the norm, not the exception — model IDs (``grok-4.6``, ``glm-5.3``), Matrix room IDs (``!room:chat.example.cc``), and versioned provider names all embed dots. Users typing ``providers.myprov.models.grok-4.6.context_length`` do not know the escape syntax exists, so when navigating an EXISTING mapping we prefer an existing literal key equal to the dot-join of the next N path segments (longest match wins) over blindly splitting. See #84064 / #80006 / 91095 / #91607 / #99124. """ if not isinstance(container, dict) or not parts: return None return next( ((".".join(parts[:n]), n) for n in range(len(parts), 0, -1) if ".".join(parts[:n]) in container), None) def _phantom_sibling(container: dict, part: str) -> Optional[str]: """Existing literal dotted key that creating an intermediate mapping ``part`` would shadow (``grok-4`` beside ``grok-4.5``) — the write would produce a phantom sibling the runtime never reads, so callers fail loudly instead. Called when a write is about to CREATE a new intermediate mapping named ``part``. See #84064. """ if not isinstance(container, dict): return None prefix = part + "." return next((k for k in container if isinstance(k, str) and k.startswith(prefix)), None) def _set_nested(config, dotted_key: str, value): """Set a value at a dotted key path, creating intermediate dicts on demand. Numeric segments index lists; the index must already exist (lists are never grown). Guards against #17876: before this fix the code unconditionally replaced any non-dict value (including lists) with ``{}``, silently destroying list-typed config like ``custom_providers`` whenever a caller used an indexed path. Dotted key names (#84064 family): when navigating an existing mapping, an existing literal key equal to the dot-join of the next N segments is preferred over blind splitting (see ``_greedy_literal_match``), so ``models.grok-4.6.supports_vision`` lands on the real ``grok-4.6`` entry. And when a write WOULD create a new intermediate mapping that shadows an existing dotted sibling (``grok-4`` beside ``grok-4.5``), it raises ``ValueError`` instead of silently writing a phantom the runtime never reads. """ parts = _split_key_path(dotted_key) current = config i = 0 while i < len(parts): remaining = parts[i:] at_leaf = len(remaining) == 1 if isinstance(current, list): part = remaining[0] if at_leaf: current[int(part)] = value return try: current = current[int(part)] except (TypeError, ValueError): raise TypeError( f"Cannot navigate into list at key {dotted_key!r}: " f"segment {part!r} is not a numeric index") i += 1 elif isinstance(current, dict): match = _greedy_literal_match(current, remaining) if match is not None: key, consumed = match if i + consumed == len(parts): current[key] = value return # Preserve dicts and lists; replace scalar with a fresh dict. if not isinstance(current.get(key), (dict, list)): current[key] = {} current = current[key] i += consumed continue part = remaining[0] if at_leaf: current[part] = value return shadowed = _phantom_sibling(current, part) if shadowed is not None: escaped = shadowed.replace(".", "\\.") raise ValueError( f"Refusing to create nested key {part!r} in {dotted_key!r}: the mapping " f"already contains a literal key {shadowed!r} that contains a dot. If you " f"meant that key, escape its dots with a backslash (e.g. {escaped}).") current = current.setdefault(part, {}) i += 1 else: raise TypeError(f"Cannot navigate into {type(current).__name__} at key {dotted_key!r}") def clear_model_endpoint_credentials( model_cfg: Dict[str, Any], *, clear_api_key: bool = True, clear_api_mode: bool = True, clear_base_url: bool = False) -> Dict[str, Any]: """Remove stale inline endpoint credentials from a model config. ``model.api_key`` is valid only for explicit custom endpoints; built-in providers resolve credentials from env/auth.json/the pool. Leftovers keep secrets in config.yaml and can contaminate later custom resolution paths.""" if not isinstance(model_cfg, dict): return model_cfg if clear_api_key: model_cfg.pop("api_key", None) model_cfg.pop("api", None) if clear_api_mode: model_cfg.pop("api_mode", None) if clear_base_url: model_cfg.pop("base_url", None) return model_cfg _MISSING = object() def _locate_nested(config, parts: list): """Walk *parts* through nested dicts/lists (escape-aware, greedy-literal like ``_set_nested``). Returns ``(parents, container, key)`` where ``container[key]`` is the addressed leaf and ``parents`` lists the ``(container, key)`` hops above it, or ``None`` when any hop is missing, a list index is non-numeric/out of range, or a scalar is hit before the path is consumed.""" parents = [] current = config i = 0 while True: remaining = parts[i:] if isinstance(current, list): try: key = int(remaining[0]) current[key] except (TypeError, ValueError, IndexError): return None consumed = 1 elif isinstance(current, dict): match = _greedy_literal_match(current, remaining) if match is None: return None key, consumed = match else: return None i += consumed if i == len(parts): return parents, current, key parents.append((current, key)) current = current[key] def _get_nested(config, dotted_key: str): """Return a dotted-path value (``_MISSING`` when absent); same navigation as ``_set_nested`` so ``models.grok-4.6.context_length`` reads the real ``grok-4.6`` entry. Mirrors ``_set_nested``'s navigation: honors backslash-escaped dots and prefers an existing literal dotted key over blind splitting, so ``config get providers.p.models.grok-4.6.context_length`` reads the real ``grok-4.6`` entry instead of reporting the key unset (#84064). """ loc = _locate_nested(config, _split_key_path(dotted_key)) if loc is None: return _MISSING _, container, key = loc return container[key] def _unset_nested(config, dotted_key: str) -> bool: """Remove a dotted-path value; True if it existed. Empty dict containers left behind are dropped, while user-authored empty lists and non-empty sibling branches are preserved. Same escape-aware, greedy-literal navigation as ``_set_nested`` / ``_get_nested`` (#84064): unsetting an unescaped dotted key removes the real literal entry rather than a phantom sibling. """ loc = _locate_nested(config, _split_key_path(dotted_key)) if loc is None: return False parents, current, key = loc del current[key] # ``parent[part] is current`` for every hop, so each now-empty dict container is dropped. for parent, part in reversed(parents): if current != {}: break del parent[part] current = parent return True _ENV_CONFIG_KEYS = frozenset({ 'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY', 'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_URL', 'CONNECTOR_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME', 'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY', 'PERPLEXITY_API_KEY', 'API_SERVER_KEY', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY', 'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN', 'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY', 'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'GITHUB_TOKEN', 'HONCHO_API_KEY'}) def _is_env_config_key(key: str) -> bool: """Return whether `hermes config set` routes this key to .env.""" if "." in key: return False key_upper = key.upper() return ( key_upper in _ENV_CONFIG_KEYS or key_upper.endswith(('_API_KEY', '_TOKEN', '_SECRET')) or key_upper.startswith('TERMINAL_SSH')) def _format_config_get_value(value, *, as_json: bool) -> str: """Format a config value for command-line output.""" if as_json: return json.dumps(value, ensure_ascii=False) if isinstance(value, bool): return "true" if value else "false" if value is None: return "null" if isinstance(value, (dict, list)): return yaml.safe_dump(value, sort_keys=False).rstrip() return str(value) def get_missing_config_fields() -> List[Dict[str, Any]]: """Check which config fields are missing or outdated (recursive).""" missing = [] def _check(defaults: dict, current: dict, prefix: str = ""): for key, default_value in defaults.items(): if key.startswith('_'): continue full_key = key if not prefix else f"{prefix}.{key}" if key not in current: missing.append({"key": full_key, "default": default_value, "description": f"New config option: {full_key}"}) elif isinstance(default_value, dict) and isinstance(current.get(key), dict): _check(default_value, current[key], full_key) _check(DEFAULT_CONFIG, load_config()) return missing def get_missing_skill_config_vars() -> List[Dict[str, Any]]: """Return skill-declared config vars (``skills.config.``) that are missing or empty.""" try: from agent.skill_utils import discover_all_skill_config_vars, SKILL_CONFIG_PREFIX except Exception: return [] try: all_vars = discover_all_skill_config_vars() except Exception as e: # A malformed SKILL.md must never break `hermes update`; this prompting is a nicety. logger.debug("discover_all_skill_config_vars failed: %s", e) return [] if not all_vars: return [] config = load_config() values = ((var, cfg_get(config, *f"{SKILL_CONFIG_PREFIX}.{var['key']}".split("."))) for var in all_vars) return [var for var, v in values if v is None or (isinstance(v, str) and not v.strip())] def _coerce_config_version(value: Any) -> int: """Return a safe integer config version, treating invalid values as legacy.""" if isinstance(value, bool): return 0 try: version = int(value) except (TypeError, ValueError): return 0 return max(version, 0) def check_config_version(*, raise_on_parse_error: bool = False) -> Tuple[int, int]: """Return ``(current_version, latest_version)`` from the raw on-disk config. Reads the raw file rather than ``load_config()``: the deep-merge would make a file lacking ``_config_version`` inherit the latest version, hiding that the schema was never migrated. Invalid YAML gets a parse warning, not an automatic schema rewrite. Tolerant runtime status callers keep the historical latest/latest fallback for malformed YAML; mutation and explicit validation paths set ``raise_on_parse_error`` so a parse failure or a non-mapping root cannot be mistaken for an up-to-date config.""" latest = _coerce_config_version(DEFAULT_CONFIG.get("_config_version", 1)) or 1 config_path = get_config_path() if not config_path.exists(): return latest, latest try: with open(config_path, encoding="utf-8") as f: config = fast_safe_load(f) except Exception as e: _warn_config_parse_failure(config_path, e) if raise_on_parse_error: raise InvalidUserConfigError( f"Cannot inspect {config_path}: config.yaml is not valid YAML ({e})" ) from e return latest, latest if config is None: config = {} # empty file / bare document: valid first-run state if not isinstance(config, dict): # A list/scalar root parses fine but is just as unusable as broken YAML: save_config() # would refuse it later, after .env was already rewritten. Strict callers see it up front. if raise_on_parse_error: raise InvalidUserConfigError( f"Cannot inspect {config_path}: config.yaml top-level value must be " f"a mapping, got {type(config).__name__}" ) config = {} return _coerce_config_version(config.get("_config_version")), latest # ---- Config structure validation ---- # DEFAULT_CONFIG is the single source of truth for documented roots; the set is derived so new # defaults are accepted automatically. These optional/legacy roots are valid on disk but # intentionally absent from DEFAULT_CONFIG (omitted when unused / alternate schema forms). _EXTRA_KNOWN_ROOT_KEYS = { "custom_providers", # legacy list form; modern equivalent is providers: {} "fallback_model", # optional single dict or chain list; omitted when disabled "mcp_servers", # MCP server definitions written by setup/tools flows "image_gen", # agent/image_gen_registry.py "video_gen", # agent/video_gen_registry.py "plugins", # plugin enable/disable lists (hermes_cli/plugins_cmd.py) "smart_model_routing", # written by the setup wizard "platform_toolsets", # written by the setup wizard "known_plugin_toolsets", # hermes_cli/tools_config.py toolset-save flow "known_builtin_toolsets", # ditto — builtin toolsets a platform's checklist has offered "tool_gateway_declined_tools", # per-tool Tool Gateway offer declines # Top-level forms read/bridged by gateway/config.py: "group_sessions_per_user", "thread_sessions_per_user", "stt_echo_transcripts", "reset_triggers", "always_log_local", "filter_silence_narration", "multiplex_profiles", "profile_routes", "platforms", "require_mention", "unauthorized_dm_behavior", "signal", "timeouts", # unified timeout resolution section (agent/deadline.py) } _KNOWN_ROOT_KEYS = frozenset(DEFAULT_CONFIG.keys()) | _EXTRA_KNOWN_ROOT_KEYS # Valid fields inside a custom_providers list entry (key_env is read at runtime by # runtime_provider.py and auxiliary_client.py). _VALID_CUSTOM_PROVIDER_FIELDS = { "name", "base_url", "api_key", "api_mode", "model", "models", "context_length", "rate_limit_delay", "extra_body", "ssl_ca_cert", "ssl_verify", "key_env"} # Fields that look like they should be inside custom_providers, not at root _CUSTOM_PROVIDER_LIKE_FIELDS = {"base_url", "api_key", "rate_limit_delay", "api_mode"} @dataclass class ConfigIssue: """A detected config structure problem.""" severity: str # "error", "warning" message: str hint: str def _issue(issues: List["ConfigIssue"], severity: str, message: str, hint: str) -> None: issues.append(ConfigIssue(severity, message, hint)) def _require_fields( issues: List["ConfigIssue"], entry: Dict[str, Any], label: str, fields: Tuple[Tuple[str, str], ...], suffix: str = "") -> None: """Append a warning for every falsy ``field`` of *entry* (message: ``