"""Configuration management for Hermes Agent.""" import copy from decimal import Decimal, InvalidOperation from hermes_cli.cli_output import line_input 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 pathlib import Path from typing import Dict, Any, Optional, List, Tuple, Set from hermes_cli.route_identity import normalize_route_base_url from hermes_cli.secret_prompt import masked_secret_prompt logger = logging.getLogger(__name__) # Track which (config_path, mtime_ns, size) tuples we've already warned about # so concurrent CLI/gateway loads of a broken config.yaml don't spam stderr # every time. Cleared automatically when the file changes (different mtime). _CONFIG_PARSE_WARNED: set = set() # Parallel record of active parse failures keyed by path -> (mtime_ns, size, # error message). Written by _warn_config_parse_failure() (the single funnel # every load-path parse failure goes through) and probed by # get_active_config_parse_failure() so provider auto-resolution can refuse to # adopt a paid provider from environment keys while the user's REAL config — # which may name a completely different provider — is unreadable (#81952). _CONFIG_PARSE_FAILURES: dict = {} class InvalidUserConfigError(RuntimeError): """Raised when a run that cannot repair config finds invalid user YAML.""" def _backup_corrupt_config(config_path: Path) -> Optional[Path]: """Preserve a corrupted ``config.yaml`` by copying it to a timestamped ``.bak``. When the YAML can't be parsed, ``load_config()`` silently falls back to ``DEFAULT_CONFIG`` and the user's broken file stays on disk untouched. Returns the backup path on success, else ``None``. Symlinks are not followed/copied (mirrors the Gemini #21541 lstat guard) to avoid clobbering whatever a malicious/misconfigured symlink points at. """ try: if config_path.is_symlink(): return None st = config_path.stat() if st.st_size == 0: # Empty file isn't worth preserving and yaml.safe_load returns {} # for it anyway (so it wouldn't reach here), but guard regardless. return None ts = time.strftime("%Y%m%d-%H%M%S") backup_path = config_path.with_name(f"{config_path.name}.corrupt.{ts}.bak") # Don't clobber an existing backup from the same second; if there's # already a corrupt backup for this exact mtime, assume we've snapshotted # this corruption already and skip (the dedup cache normally prevents a # second call, but a process restart can clear it). sibling_baks = list( config_path.parent.glob(f"{config_path.name}.corrupt.*.bak") ) for existing in sibling_baks: try: if existing.stat().st_size == st.st_size: # Same size as the current broken file — likely the same # corruption already preserved. Avoid backup churn. return None except OSError: continue if backup_path.exists(): return None shutil.copy2(config_path, backup_path) return backup_path except Exception: return None def _warn_config_parse_failure( config_path: Path, exc: Exception, *, fallback: str = "defaults" ) -> None: """Surface a config.yaml parse failure to user, log, and stderr. A YAML parse error in ``~/.hermes/config.yaml`` causes ``load_config()`` to silently fall back to ``DEFAULT_CONFIG``, which means every user override (auxiliary providers, fallback chain, model overrides, etc.) is dropped. """ 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) backup_path = _backup_corrupt_config(config_path) if fallback == "last-known-good": msg = ( f"Failed to parse {config_path}: {exc}. " f"Keeping the previously loaded config for this process — " f"edits to config.yaml are being IGNORED until the YAML is fixed." ) elif fallback == "refuse-write": msg = ( f"Failed to parse {config_path}: {exc}. " f"REFUSING to write config.yaml so the existing file is preserved. " f"Fix the YAML (hermes config edit) and retry." ) else: msg = ( f"Failed to parse {config_path}: {exc}. " f"Falling back to default config — every user override " f"(auxiliary providers, fallback chain, model settings) is being IGNORED. " f"Fix the YAML and restart." ) 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 parse-error message if the ACTIVE config.yaml is corrupt. Probes the failure record written by :func:`_warn_config_parse_failure` for the current :func:`get_config_path` and re-stats the file NOW: the recorded error is returned only while the file is byte-identical (mtime_ns + size) to the one that failed to parse. """ try: path = get_config_path() recorded = _CONFIG_PARSE_FAILURES.get(str(path)) if not recorded: return None mtime_ns, size, err = recorded st = path.stat() if st.st_mtime_ns == mtime_ns and st.st_size == size: return err except Exception: return None 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 (too broad; fix tool lookup with absolute paths in integration # config instead), git rewrites (fire on every plugin install / update), # implicitly-invoked commands (BROWSER/EDITOR/VISUAL/PAGER = RCE on next $EDITOR), # SHELL (shell=True defense in depth), and Hermes runtime-location flags # (config.yaml is the supported surface; .env writes would relocate state). # # IMPORTANT: ``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 the gate stays narrow and cannot break provider # setup wizards. Enforced on *write* only: pre-existing/out-of-band ``.env`` # values keep working; the point is the dashboard's writable surface cannot # escalate by planting them. _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 "PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP", "PYTHONUSERBASE", "PYTHONEXECUTABLE", "PYTHONNOUSERSITE", # Node "NODE_OPTIONS", "NODE_PATH", # General "PATH", "SHELL", "BROWSER", "EDITOR", "VISUAL", "PAGER", # Git "GIT_SSH_COMMAND", "GIT_EXEC_PATH", "GIT_SHELL", # Hermes runtime location — never via dashboard env writer. # NOT a HERMES_* blanket: integration credentials (HERMES_GEMINI_*, # HERMES_LANGFUSE_*, HERMES_SPOTIFY_*, ...) ARE allowed. "HERMES_HOME", "HERMES_PROFILE", "HERMES_CONFIG", "HERMES_ENV", "HERMES_CONFIG_PATH", "HERMES_ENV_PATH", # MCP catalog trust root. Package-manager wrappers may still provide this # in the process environment; only generic persistence writes are blocked. "HERMES_OPTIONAL_MCPS", # Local ACP subprocess selection. Existing operator/package-manager values # remain readable; generic writers cannot acquire executable/argv authority. "HERMES_COPILOT_ACP_COMMAND", "HERMES_COPILOT_ACP_ARGS", # Hermes security policy / approval-routing context. These remain available # through their dedicated CLI/config/session controls, but a generic # credential writer must not persist them for the next process startup. "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: """Return the name used for environment policy comparisons. Windows environment names are case-insensitive; POSIX names are not. The explicit override keeps both semantics directly testable without pretending the test interpreter is running on another host OS. """ windows = _IS_WINDOWS if is_windows is None else is_windows return key.upper() if windows else key def _reject_denylisted_env_var(key: str) -> None: """Raise if ``key`` is in :data:`_ENV_VAR_NAME_DENYLIST`.""" 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." ) def validate_env_var_name_for_write(key: str) -> None: """Validate an environment name before a generic persistence write. Exposed separately from :func:`save_env_value` so batch-style callers can validate their complete request before writing the first value. """ if not _ENV_VAR_NAME_RE.match(key): raise ValueError(f"Invalid environment variable name: {key!r}") _reject_denylisted_env_var(key) _LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {} # path -> (user_mtime_ns, user_size, managed_mtime_ns, managed_size, merged_value, # env_ref_snapshot). load_config() returns a deepcopy of the cached value while # the signature matches, skipping safe_load + _deep_merge + _normalize_* + # _expand_env_vars (~13 ms/call). save_config()/migrate_config() write via # atomic_yaml_write (fresh inode → new mtime_ns), so no explicit invalidation # hook is needed. The managed-file signature is folded in so editing the # managed-scope config.yaml invalidates the cache, and the env snapshot # invalidates it when a referenced ${VAR} changes value (late .env load, # in-process rotation). _LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, int, int, Dict[str, Any], Dict[str, Optional[str]]]] = {} # (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as # _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want # the user's on-disk values without defaults merged in. _RAW_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {} # Serializes all config read/write paths. libyaml's C extension is not # thread-safe for concurrent safe_load() on the same file, and multiple # tool threads (approval.py, browser_tool.py, setup flows) hit # load_config / read_raw_config / save_config from different threads # during long agent runs. RLock (not Lock) because save_config internally # calls read_raw_config. Also covers mutation of the module-level cache # dicts above. _CONFIG_LOCK = threading.RLock() # Env var names written to .env that aren't in OPTIONAL_ENV_VARS # (managed by setup/provider flows directly). _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", # HERMES_TOOL_PROGRESS_MODE is deprecated (replaced by display.tool_progress) but STILL READ # by the gateway as a back-compat fallback, so it must stay known to reload/compat paths. The # boolean HERMES_TOOL_PROGRESS variant is unsupported since the v12 support floor retired its # only consumer (the v3→4 migration): not listed here; 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 — optional tuning keys + standard SDK vars. Activation is via # plugins.enabled (`hermes plugins enable observability/langfuse` / `hermes tools → Langfuse`); # 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 different profiles can use # different ACP backends without cross-leak. "HERMES_ACP_AUTH_METHOD", "HERMES_ACP_AUTO_APPROVE", "HERMES_COPILOT_ACP_COMMAND", "HERMES_COPILOT_ACP_ARGS", "COPILOT_CLI_PATH", "COPILOT_ACP_BASE_URL", }) import yaml from hermes_cli.colors import Colors, color from hermes_cli.default_soul import DEFAULT_SOUL_MD, is_legacy_template_soul # ============================================================================= # 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, so both # legacy signals name that system. _LEGACY_MANAGED_SYSTEM = "nixos" # The Nix store root. Used by detect_install_method to identify installs # from `nix run` / `nix profile install` (which don't set HERMES_MANAGED). # A module-level constant so tests can patch it without creating files # under the real /nix/store. _NIX_STORE = Path("/nix/store") # Values that used to signal a Homebrew-managed install. Homebrew is no # longer a supported distribution method, so these are explicitly ignored # rather than treated as a managed system — they fall through to git/unknown # detection instead of blocking config writes. _IGNORED_MANAGED_VALUES = frozenset({"brew", "homebrew"}) def get_managed_system() -> Optional[str]: """Return the package manager owning this install, if any.""" marker = os.getenv("HERMES_MANAGED", "").strip().lower() or None if marker is None: managed_marker = get_hermes_home() / ".managed" # An interactive shell reads the marker, because it does not see the # HERMES_MANAGED variable of the service. A marker with content # names the system that manages the install. if 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: 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. Two signals: the HERMES_MANAGED env var (set by the systemd service) or a .managed marker file in HERMES_HOME (set by the NixOS activation script so interactive shells see it too). """ return get_managed_system() is not None # Nix installs arrive by several routes (nix run, nix profile, a system flake, # home-manager), and the running process cannot tell which one. Thus this 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.""" managed_system = get_managed_system() if managed_system in _NIX_MANAGED_SYSTEMS: return _NIX_UPDATE_MSG return None def _install_method_project_root(project_root: Optional[Path] = None) -> Path: """Resolve the directory that holds the *running code* (the install tree). This is the parent of ``hermes_cli/`` — i.e. the git checkout for source installs, ``/opt/hermes`` inside the published image. It is a property of the running interpreter, NOT of ``$HERMES_HOME``, which is why a code-scoped stamp here is immune to two installs sharing one data directory. """ return project_root if project_root is not None else get_project_root() 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'. """ root = _install_method_project_root(project_root) # "apt" is intentionally the Termux APT distribution identifier, not a # generic Debian/Ubuntu APT signal. If another APT-managed distribution is # added, give it a distinct install method or make update-command selection # platform-aware instead of silently reusing Termux's `pkg` command. # "home-manager" is here because step 3 can return it. A stamp must name # every method that this function returns. Without it, the stamp of a # home-manager install gives "unknown". supported_methods = {"apt", "docker", "nix", "nixos", "home-manager", "git", "unknown"} def _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_methods else None # 1. Code-scoped stamp — authoritative, immune to shared $HERMES_HOME. method = _stamp(root / ".install_method") if method: return method # 2. Legacy home-scoped stamp — back-compat. Ignore a ``docker`` value # when we are not actually containerised: that is the signature of a # host install whose shared $HERMES_HOME was stamped by a co-located # container, and honouring it wrongly blocks ``hermes update``. 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(" ", "-") # detect Nix installs that don't set HERMES_MANAGED (e.g. ``nix run``, # ``nix profile install``). The code lives under /nix/store/ which is the # hallmark of a nix-built install — no other supported install path puts # code there. try: resolved = root.resolve() if resolved != _NIX_STORE and _NIX_STORE in resolved.parents: return "nix" except OSError: pass # detect git repo installs (normal installer, development env) — a .git # directory, or a ``gitdir:`` pointer file for worktrees. git_path = root / ".git" if git_path.is_dir(): return "git" if git_path.is_file(): try: if git_path.read_text(encoding="utf-8").strip().startswith("gitdir:"): return "git" except OSError: pass return "unknown" def _running_in_container() -> bool: """Thin wrapper around ``hermes_constants.is_container`` (import-safe).""" try: from hermes_constants import is_container return is_container() except Exception: return False def is_nix_install_method(method: str) -> bool: """Return True for every install method that Nix owns. The callers that branch on the install method must treat "nix", "nixos" and "home-manager" the same way. One helper keeps the three names in one place, so a new Nix shape cannot miss a call site. """ return method == "nix" or method in _NIX_MANAGED_SYSTEMS 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 if method == "docker": return "docker pull nousresearch/hermes-agent:latest" if method == "apt": # By contract, the current "apt" install method is the Termux APT # distribution. It deliberately uses Termux's `pkg` frontend. return "pkg upgrade hermes-agent" return "hermes update" def recommended_update_command() -> str: """Return the best update command for the current installation.""" # The managed state wins over the code-scoped stamp. A managed install # can carry a stale stamp from an earlier install shape, and the stamp # then names an update path that the managed guard refuses. managed_cmd = get_managed_update_command() if managed_cmd: return managed_cmd method = detect_install_method(get_project_root()) return recommended_update_command_for_method(method) # Long-form text for ``hermes update`` / ``--check`` inside the Docker image, # shared by ``cmd_update`` and ``_cmd_update_check`` (hermes_cli/main.py) so the # wording never forks. The published image excludes ``.git`` (.dockerignore), so # the git update path can never succeed there, and the generic "Not a git # repository, reinstall via install.sh" fallback is misleading (that installs a # NEW host-side Hermes). The right action is ``docker pull`` + restart. _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. Centralised so ``cmd_update`` (the apply path) and ``_cmd_update_check`` (the dry-run path) share the same wording. See ``_DOCKER_UPDATE_MESSAGE`` above for the full rationale. """ 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) # ============================================================================= # Container-aware CLI (NixOS container mode) # ============================================================================= 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. Returns None when container mode is off, when already inside the container, or when HERMES_DEV=1 is set. """ if os.environ.get("HERMES_DEV") == "1": return None from hermes_constants import is_container if is_container(): return None container_mode_file = get_hermes_home() / ".container-mode" try: info = {} with open(container_mode_file, "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 # All other exceptions (PermissionError, malformed data, etc.) propagate 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 # ============================================================================= # Re-export from hermes_constants — canonical definition lives there. from hermes_constants import get_hermes_home, get_process_hermes_home # noqa: E402,F401 from utils import atomic_replace, fast_safe_load 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 retain ``load_config()``'s recovery behavior so the operator can repair their configuration. A one-shot or single-query run has no such repair opportunity: allowing defaults there can silently pick a hosted provider/model and spend against credentials loaded from ``.env``. Missing and empty files remain valid first-run states. The explicit ``--ignore-user- config``/safe-mode escape hatch also remains 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__}" ) backup_path = _backup_corrupt_config(config_path) 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 the HERMES_UID / HERMES_GID env vars set by Docker deployments. The entrypoint chowns the top-level HERMES_HOME once, but subdirectories created at runtime (notably ``profiles//``) need the same chown or they land as root:root and block later uid-mapped workers with PermissionError. Returns (None, None) if unset/invalid or on Windows. """ if sys.platform == "win32": return None, None def _env_int(name: str) -> Optional[int]: raw = os.environ.get(name, "").strip() try: return int(raw) if raw else None except 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`` if those env vars are set. No-op when: - Either env var is unset/invalid - The current process isn't root (chown will EPERM — silently ignored) - On Windows (chown semantics don't apply) """ uid, gid = _resolve_hermes_uid_gid() if uid is None and gid is None: return try: # os.chown with -1 means "don't change" for that field. os.chown( path, uid if uid is not None else -1, gid if gid is not None else -1, ) except (OSError, AttributeError, NotImplementedError): # OSError covers EPERM (not running as root) and ENOENT (race), # both of which are non-fatal — the dir is still created and # the entrypoint's startup chown -R will fix it on next restart. pass def _secure_dir(path): """Set directory to owner-only access (0700 by default). No-op on Windows. The mode can be overridden via the HERMES_HOME_MODE environment variable (e.g. HERMES_HOME_MODE=0701) for deployments where a web server (nginx, caddy, etc.) needs to traverse HERMES_HOME to reach a served subdirectory. The execute-only bit on a directory permits cd- through without exposing 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 mode = 0o700 try: mode_str = os.environ.get("HERMES_HOME_MODE", "").strip() if mode_str: mode = int(mode_str, 8) except ValueError: pass try: os.chmod(path, mode) except (OSError, NotImplementedError): pass _chown_to_hermes_uid(path) def _is_container() -> bool: """Detect if we're running inside a Docker/Podman/LXC container. Used to skip forcing 0o600 on volume-mounted config: in containers the gateway and dashboard may run as different UIDs, or the mount itself needs broader permissions. """ # Explicit opt-out if os.environ.get("HERMES_CONTAINER") or os.environ.get("HERMES_SKIP_CHMOD"): return True # Docker / Podman marker file if os.path.exists("/.dockerenv"): return True # LXC / cgroup-based detection try: with open("/proc/1/cgroup", "r", encoding="utf-8") as f: cgroup_content = f.read() if "docker" in cgroup_content or "lxc" in cgroup_content or "kubepods" in cgroup_content: return True except (OSError, IOError): pass return False def _secure_file(path): """Set file to owner-only read/write (0600). No-op on Windows. Skipped in managed mode (the NixOS activation script sets 0640 group-readable config) and in containers (volume mounts often need broader permissions). HERMES_SKIP_CHMOD=1 forces a skip. """ 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 a default SOUL.md into HERMES_HOME, upgrading legacy empty templates. First run: write DEFAULT_SOUL_MD. Installs whose SOUL.md is still the old comment-only scaffold (seeded by older installers/images, shadowing the runtime default) are upgraded 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 # Legacy empty template -> upgrade to the real default in place. soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8") _secure_file(soul_path) # Home paths whose directory skeleton has been created this process — see # ensure_hermes_home(). Only successful passes are recorded, so a raised # managed-mode/missing-profile error keeps re-checking on later loads. _HERMES_HOME_ENSURED: set = set() def ensure_hermes_home(): """Ensure ~/.hermes directory structure exists with secure permissions. Memoized per home path: this runs on EVERY ``load_config()`` (inside the config lock), and the ~14 mkdir/chmod syscalls per call made repeated config loads the dominant cost of hot read paths like ``model.options``. """ home = get_hermes_home() key = str(home) # Named profiles must be created explicitly (e.g. ``hermes profile create``). # 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 if is_managed(): old_umask = os.umask(0o007) try: _ensure_hermes_home_managed(home) finally: os.umask(old_umask) else: home.mkdir(parents=True, exist_ok=True) _secure_dir(home) for subdir in ( "cron", "sessions", "logs", "logs/curator", "memories", "pairing", "hooks", "image_cache", "audio_cache", "skills", ): d = home / subdir d.mkdir(parents=True, exist_ok=True) _secure_dir(d) _ensure_default_soul_md(home) _HERMES_HOME_ENSURED.add(key) def _ensure_hermes_home_managed(home: Path): """Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.""" if not home.is_dir(): raise RuntimeError( f"HERMES_HOME {home} does not exist." ) for subdir in ("cron", "sessions", "logs", "memories"): d = home / subdir if not d.is_dir(): raise RuntimeError(f"{d} does not exist.") # Curator reports dir is a sub-path of logs/; create it if missing. # In managed mode the activation script may not know about this subdir, # so we mkdir it ourselves (it's inside an already-secured logs/ dir). (home / "logs" / "curator").mkdir(parents=True, exist_ok=True) # Inside umask(0o007) scope — SOUL.md will be created as 0660 _ensure_default_soul_md(home) # ============================================================================= # Config loading/saving # ============================================================================= from hermes_cli.config_defaults import DEFAULT_CONFIG, OPTIONAL_ENV_VARS # noqa: F401 # ============================================================================= # Config Migration System # ============================================================================= # Track which env vars were introduced in each 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"], } # Required environment variables with metadata for migration prompts. # LLM provider is required but handled in the setup wizard's provider # selection step (Nous Portal / OpenRouter / Custom endpoint), so this # dict is intentionally empty — no single env var is universally required. REQUIRED_ENV_VARS = {} # Tool Gateway env vars are always visible — they're useful for # self-hosted / custom gateway setups regardless of subscription state. 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. Backslashes before any other character are preserved verbatim. Keys without escapes behave exactly as ``key.split(".")``. """ parts: list[str] = [] current: list[str] = [] i = 0 while i < len(key): ch = key[i] if ch == "\\" and i + 1 < len(key) and key[i + 1] == ".": current.append(".") i += 2 continue if ch == ".": parts.append("".join(current)) current = [] i += 1 continue 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 match. Backward compatible: when no multi-segment literal key exists, the single segment ``parts[0]`` is the only candidate, which is exactly the historic plain-split behavior. Returns ``None`` when nothing matches. """ 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]: """Return an existing sibling key that ``part`` would shadow, if any. Called before CREATING an intermediate mapping named ``part``. If the mapping already holds a literal dotted key starting with ``part + '.'`` (creating ``grok-4`` beside ``grok-4.5``), the split chopped a dotted leaf and the write would produce a phantom sibling the runtime never reads -- fail loudly instead of silently corrupting. """ 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 an arbitrarily nested dotted key path. Intermediate dicts are created on demand. List indices are parsed from numeric path segments; the referenced index must already exist (we do not grow lists — the user is navigating into structure they wrote themselves). """ 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: idx = 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" ) current = current[idx] 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 existing = current.get(key) # Preserve dicts and lists; replace scalar with a fresh dict. if not isinstance(existing, (dict, list)): current[key] = {} current = current[key] i += consumed continue part = remaining[0] if at_leaf: current[part] = value return # About to CREATE an intermediate mapping. Refuse when that would # write a phantom sibling of an existing dotted literal key. 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}: " f"the mapping already contains a literal key {shadowed!r} " f"that contains a dot. If you meant that key, escape its " f"dots with a backslash (e.g. {escaped})." ) current[part] = {} current = current[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 endpoint assignments. Built-in providers resolve credentials from env vars, auth.json, or the credential pool. When switching away from a custom endpoint, leaving these fields behind keeps 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 from nested dict/list config data. Mirrors ``_set_nested`` navigation: honors backslash-escaped dots and prefers an existing literal dotted key over blind splitting, so ``models.grok-4.6.context_length`` reads the real ``grok-4.6`` entry instead of reporting the key unset. """ 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 from nested dict/list config data. Same escape-aware, greedy-literal navigation as ``_set_nested`` / ``_get_nested``: 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 if isinstance(current, list): current.pop(key) else: del current[key] # Drop empty dict containers left behind by the deletion while preserving # user-authored empty lists and non-empty sibling branches. for parent, part in reversed(parents): if current != {}: break if isinstance(parent, list): if 0 <= part < len(parent) and parent[part] == {}: parent.pop(part) current = parent continue elif isinstance(parent, dict) and parent.get(part) == {}: del parent[part] current = parent continue break return True 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() api_keys = [ '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_DOMAIN', 'TOOL_GATEWAY_SCHEME', 'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_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', ] return ( key_upper in api_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).""" config = load_config() 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, config) return missing def get_missing_skill_config_vars() -> List[Dict[str, Any]]: """Return skill-declared config vars that are missing or empty in config.yaml.""" 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, unreadable external skill dir, or similar # should never break `hermes update`. Skill-config prompting is a # post-migration nicety, not a blocker. logger.debug("discover_all_skill_config_vars failed: %s", e) return [] if not all_vars: return [] config = load_config() missing: List[Dict[str, Any]] = [] for var in all_vars: # Skill config is stored under skills.config.; # missing = key doesn't exist or is an empty string. value = cfg_get(config, *f"{SKILL_CONFIG_PREFIX}.{var['key']}".split(".")) if value is None or (isinstance(value, str) and not value.strip()): missing.append(var) return missing # ``_normalize_custom_provider_entry`` runs on every ``load_picker_context()`` # call (i.e. per interactive picker/inventory request), so any warning it emits # fires repeatedly for the same static config. Deduplicate per (provider, # signature): on Windows a repeated-warning storm contends on # ``concurrent-log-handler``'s cross-process rotation lock and can peg a core / # stall the gateway/serve event loop. The cache lives for the process lifetime. _PROVIDER_NORMALIZE_WARNED: set = set() def _warn_once_per_provider( provider_key: str, signature: str, msg: str, *args: Any ) -> None: """Emit ``logger.warning(msg, *args)`` at most once per (provider, signature).""" dedup_key = (provider_key or "?", signature) if dedup_key in _PROVIDER_NORMALIZE_WARNED: return _PROVIDER_NORMALIZE_WARNED.add(dedup_key) logger.warning(msg, *args) _API_MODE_ALIASES = { # Values accepted by earlier releases (and natural spellings) mapped to # the canonical transport names consumed by agent_init. Before this map # existed, an unrecognized api_mode was silently ignored and the # transport fell through to hostname-based guessing, so a config that # said ``api_mode: openai`` (valid on older releases) could flip to # ``codex_responses`` after an update and break the provider (#66543 # discussion; observed live against api.actual.inc). "openai": "chat_completions", "openai_chat": "chat_completions", "openai-chat": "chat_completions", "chat-completions": "chat_completions", "chatcompletions": "chat_completions", "responses": "codex_responses", "openai_responses": "codex_responses", "openai-responses": "codex_responses", "anthropic": "anthropic_messages", "anthropic-messages": "anthropic_messages", "messages": "anthropic_messages", "bedrock": "bedrock_converse", "bedrock-converse": "bedrock_converse", } def _canonical_api_mode(api_mode: str) -> str: """Map legacy/alias ``api_mode`` spellings to canonical transport names. Unknown values pass through unchanged (callers keep their existing fall-through behavior); known aliases are rewritten so downstream consumers (``agent_init``'s accepted-set check, runtime resolution) see a canonical name instead of silently discarding the user's intent. """ cleaned = api_mode.strip() return _API_MODE_ALIASES.get(cleaned.lower(), cleaned) def coerce_provider_id(value: Any) -> str: """Provider identity fields are strings.""" if value is None: return "" return str(value).strip() def stringify_provider_map(providers: Any) -> dict: """Copy a ``providers:`` mapping so keys are strings. Desktop Custom Endpoints store the name as the dict key; an unquoted YAML key ``2070:`` loads as int, so picker code calling ``ep_name.lower()`` crashes and CRUD lookups of ``"2070"`` miss. """ if not isinstance(providers, dict): return {} out: Dict[str, Any] = {} for stored, value in providers.items(): key = coerce_provider_id(stored) if key: out[key] = value return out def find_provider_entry(providers: Any, key: Any) -> Tuple[Any, Optional[Dict[str, Any]]]: """Return ``(stored_key, entry)`` matching *key* by string identity. Prefer an exact string hit, then scan. """ if not isinstance(providers, dict): return None, None want = coerce_provider_id(key) if not want: return None, None exact = providers.get(want) if isinstance(exact, dict): return want, exact for stored, entry in providers.items(): if coerce_provider_id(stored) == want and isinstance(entry, dict): return stored, entry return None, None # camelCase aliases commonly used in hand-written provider configs. _CAMEL_ALIASES: Dict[str, str] = { "apiKey": "api_key", "baseUrl": "base_url", "apiMode": "api_mode", "keyEnv": "key_env", "apiKeyEnv": "key_env", # alias — OpenClaw-compatible + docs variant "defaultModel": "default_model", "contextLength": "context_length", "rateLimitDelay": "rate_limit_delay", } _KNOWN_PROVIDER_KEYS = { # ``provider`` duplicates the ``providers.`` mapping key and is unused # here, but Hermes' own config writer has historically emitted it into # provider entries. Accept it silently so self-written configs don't warn. "provider", "name", "api", "url", "base_url", "api_key", "key_env", "api_key_env", "key_cmd", "api_mode", "transport", "model", "default_model", "models", "models_discovered", "context_length", "rate_limit_delay", "request_timeout_seconds", "stale_timeout_seconds", "discover_models", "extra_body", "extra_headers", "capabilities", "ssl_ca_cert", "ssl_verify", } def _pick_provider_base_url(entry: Dict[str, Any], provider_key: str) -> str: """First usable URL among ``base_url``/``url``/``api``, or "". URLs containing unresolved placeholder tokens — ``${ENV_VAR}`` env-refs and bare ``{region}`` templates — are accepted without validation: they are expanded at runtime, and a caller reaching this normalizer with raw config would otherwise see the provider silently dropped. """ from urllib.parse import urlparse for url_key in ("base_url", "url", "api"): raw_url = entry.get(url_key) if not (isinstance(raw_url, str) and raw_url.strip()): continue candidate = raw_url.strip() if re.search(r"\{[^}]+\}", candidate): return candidate parsed = urlparse(candidate) if parsed.scheme and parsed.netloc: return candidate logger.warning( "providers.%s: '%s' value '%s' is not a valid URL " "(no scheme or host) — skipped", provider_key or "?", url_key, candidate, ) return "" def _normalize_provider_models(models: Any) -> Tuple[Dict[str, Any], bool]: """Normalize an entry's ``models`` to the dict shape downstream expects. Returns ``(models_dict, discovered_flag)``. Older Hermes versions wrote an in-mapping ``__discovered_model_catalog__`` sentinel (accepted on read, stripped so sentinel keys never surface as model IDs). Hand-edited/older configs may write a plain list of ids or ``[{id: ...}]`` rows; both are converted so /model doesn't show the provider with (0) models. """ discovered = False if isinstance(models, dict) and models: # Shallow-copy: `models` may alias a cached config sub-dict, and the # normalized entry escapes into long-lived runtime state. models_copy = dict(models) if models_copy.pop("__discovered_model_catalog__", None) is True: discovered = True models_copy.pop("__explicit_model_allowlist__", None) return models_copy, discovered if isinstance(models, list) and models: normalized_models: Dict[str, Any] = {} for item in models: if isinstance(item, str) and item.strip(): normalized_models[item.strip()] = {} continue if not isinstance(item, dict): continue model_id = item.get("id") if not isinstance(model_id, str) or not model_id.strip(): model_id = item.get("name") if not isinstance(model_id, str) or not model_id.strip(): continue normalized_models[model_id.strip()] = { k: v for k, v in item.items() if k not in {"id", "name"} } return normalized_models, discovered return {}, discovered def _normalize_custom_provider_entry( entry: Any, *, provider_key: str = "", ) -> Optional[Dict[str, Any]]: """Return a runtime-compatible custom provider entry or ``None``.""" if not isinstance(entry, dict): return None # Shallow-copy before alias normalization writes into the entry: callers # pass live sub-dicts from load_config_readonly()'s shared cache, and # mutating those violates the cache's no-mutation contract and leaks alias # keys back into config.yaml on a later save_config(load_config()). entry = dict(entry) provider_key = coerce_provider_id(provider_key) # api_key_env is a documented snake_case alias for key_env (see # website/docs/guides/azure-foundry.md). Normalize it up front so the # rest of the normalizer treats it as the canonical field. if "api_key_env" in entry and "key_env" not in entry: entry["key_env"] = entry["api_key_env"] for camel, snake in _CAMEL_ALIASES.items(): if camel in entry and snake not in entry: _warn_once_per_provider( provider_key, f"camel:{camel}", "providers.%s: camelCase key '%s' auto-mapped to '%s' " "(use snake_case to avoid this warning)", provider_key or "?", camel, snake, ) entry[snake] = entry[camel] unknown = set(entry.keys()) - _KNOWN_PROVIDER_KEYS - set(_CAMEL_ALIASES.keys()) if unknown: _warn_once_per_provider( provider_key, "unknown:" + ",".join(sorted(unknown)), "providers.%s: unknown config keys ignored: %s", provider_key or "?", ", ".join(sorted(unknown)), ) base_url = _pick_provider_base_url(entry, provider_key) if not base_url: return None name = coerce_provider_id(entry.get("name")) or provider_key if not name: return None normalized: Dict[str, Any] = { "name": name, "base_url": base_url, } provider_key = provider_key.strip() if provider_key: normalized["provider_key"] = provider_key def _stripped(*keys: str) -> str: val = None for k in keys: val = entry.get(k) if val: break return val.strip() if isinstance(val, str) else "" if _stripped("api_key"): normalized["api_key"] = _stripped("api_key") key_env = _stripped("key_env", "api_key_env") if key_env: normalized["key_env"] = key_env if entry.get("api_key_env") and not entry.get("key_env"): normalized["api_key_env"] = key_env api_mode = _stripped("api_mode", "transport") if api_mode: normalized["api_mode"] = _canonical_api_mode(api_mode) model_name = _stripped("model", "default_model") if model_name: normalized["model"] = model_name # Entry-level marker: the ``models`` mapping was auto-discovered by Hermes # (``_save_discovered_models_to_config``), not hand-curated. models_dict, discovered = _normalize_provider_models(entry.get("models")) if models_dict: normalized["models"] = models_dict if entry.get("models_discovered") is True or discovered: normalized["models_discovered"] = True capabilities = entry.get("capabilities") if isinstance(capabilities, dict): normalized_capabilities = { key: value for key, value in capabilities.items() if isinstance(key, str) and isinstance(value, bool) } if normalized_capabilities: normalized["capabilities"] = normalized_capabilities context_length = entry.get("context_length") if isinstance(context_length, int) and context_length > 0: normalized["context_length"] = context_length rate_limit_delay = entry.get("rate_limit_delay") if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0: normalized["rate_limit_delay"] = rate_limit_delay if isinstance(entry.get("discover_models"), bool): normalized["discover_models"] = entry["discover_models"] if isinstance(entry.get("extra_body"), dict): normalized["extra_body"] = dict(entry["extra_body"]) # Per-provider extra HTTP headers (proxies, gateways, custom auth). # Values may carry credentials (e.g. CF-Access-Client-Secret) — never # log them anywhere downstream. normalized_headers = normalize_extra_headers(entry.get("extra_headers")) if normalized_headers: normalized["extra_headers"] = normalized_headers ssl_ca_cert = entry.get("ssl_ca_cert") if isinstance(ssl_ca_cert, str) and ssl_ca_cert.strip(): normalized["ssl_ca_cert"] = ssl_ca_cert.strip() ssl_verify = entry.get("ssl_verify") if isinstance(ssl_verify, bool): normalized["ssl_verify"] = ssl_verify elif isinstance(ssl_verify, str) and ssl_verify.strip(): normalized["ssl_verify"] = ssl_verify.strip() return normalized def _custom_provider_entry_to_provider_config( entry: Any, *, provider_key: str = "", ) -> Optional[Dict[str, Any]]: """Translate a legacy custom provider entry to the v12 providers shape.""" normalized = _normalize_custom_provider_entry( dict(entry) if isinstance(entry, dict) else entry, provider_key=provider_key, ) if normalized is None: return None provider_entry: Dict[str, Any] = {"api": normalized["base_url"]} for field in ( "name", "api_key", "key_env", "models", "models_discovered", "context_length", "rate_limit_delay", "discover_models", "extra_body", "extra_headers", "ssl_ca_cert", "ssl_verify", ): if field in normalized: provider_entry[field] = normalized[field] if "model" in normalized: provider_entry["default_model"] = normalized["model"] if "api_mode" in normalized: provider_entry["transport"] = normalized["api_mode"] return provider_entry def providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]: """Normalize ``providers`` config entries into the legacy custom-provider shape.""" if not isinstance(providers_dict, dict): return [] custom_providers: List[Dict[str, Any]] = [] for key, entry in providers_dict.items(): if isinstance(entry, dict) and not is_provider_enabled(entry): continue normalized = _normalize_custom_provider_entry( entry, provider_key=coerce_provider_id(key) ) if normalized is not None: custom_providers.append(normalized) return custom_providers def get_compatible_custom_providers( config: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """Return a deduplicated custom-provider view across legacy and v12+ config. ``custom_providers`` remains the on-disk legacy format, while ``providers`` is the newer keyed schema. Runtime and picker flows still need a single list-shaped view, but we should not materialise that compatibility layer back into config.yaml because it duplicates entries in UIs. """ if config is None: config = load_config() compatible: List[Dict[str, Any]] = [] seen_provider_keys: set = set() seen_name_url_pairs: set = set() def _append_if_new(entry: Optional[Dict[str, Any]]) -> None: if entry is None: return provider_key = str(entry.get("provider_key", "") or "").strip().lower() name = str(entry.get("name", "") or "").strip().lower() base_url = str(entry.get("base_url", "") or "").strip().rstrip("/").lower() model = str(entry.get("model", "") or "").strip().lower() pair = (name, base_url, model) if provider_key and provider_key in seen_provider_keys: return if name and base_url and pair in seen_name_url_pairs: return compatible.append(entry) if provider_key: seen_provider_keys.add(provider_key) if name and base_url: seen_name_url_pairs.add(pair) custom_providers = config.get("custom_providers") if custom_providers is not None: if not isinstance(custom_providers, list): return [] for entry in custom_providers: _append_if_new(_normalize_custom_provider_entry(entry)) for entry in providers_dict_to_custom_providers(config.get("providers")): _append_if_new(entry) return compatible def _entries_for_route( base_url: str, custom_providers: Optional[List[Dict[str, Any]]], config: Optional[Dict[str, Any]], ): """Yield custom-provider entries whose normalized route identity equals *base_url*. Loads ``get_compatible_custom_providers(config)`` when *custom_providers* is None (failures → no entries). Yields nothing for an empty *base_url* or non-list input. """ if custom_providers is None: try: custom_providers = get_compatible_custom_providers(config) except Exception: custom_providers = [] if not base_url or not isinstance(custom_providers, list): return target_url = normalize_route_base_url(base_url) if not target_url: return for entry in custom_providers: if not isinstance(entry, dict): continue entry_url = normalize_route_base_url(entry.get("base_url")) if entry_url and entry_url == target_url: yield entry def _route_model_cfg(entry: Dict[str, Any], model: str) -> Optional[Dict[str, Any]]: """Return ``entry.models[model]`` when both are mappings, else None.""" models = entry.get("models") if not isinstance(models, dict): return None model_cfg = models.get(model) return model_cfg if isinstance(model_cfg, dict) else None def _coerce_ssl_verify(value: Any) -> Optional[bool]: if value is None: return None if isinstance(value, bool): return value if isinstance(value, str): lowered = value.strip().lower() if lowered in {"false", "0", "no", "off"}: return False if lowered in {"true", "1", "yes", "on"}: return True return None def get_custom_provider_tls_settings( base_url: str, custom_providers: Optional[List[Dict[str, Any]]] = None, config: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Return TLS settings from a matching ``custom_providers`` / ``providers`` entry.""" for entry in _entries_for_route(base_url, custom_providers, config): out: Dict[str, Any] = {} ca = entry.get("ssl_ca_cert") if isinstance(ca, str) and ca.strip(): out["ssl_ca_cert"] = ca.strip() verify = _coerce_ssl_verify(entry.get("ssl_verify")) if verify is not None: out["ssl_verify"] = verify return out return {} def apply_custom_provider_tls_to_client_kwargs( client_kwargs: Dict[str, Any], base_url: str, custom_providers: Optional[List[Dict[str, Any]]] = None, config: Optional[Dict[str, Any]] = None, ) -> None: """Attach per-provider TLS knobs to OpenAI client kwargs when matched.""" tls = get_custom_provider_tls_settings(base_url, custom_providers, config) if tls.get("ssl_ca_cert"): client_kwargs["ssl_ca_cert"] = tls["ssl_ca_cert"] if "ssl_verify" in tls: client_kwargs["ssl_verify"] = tls["ssl_verify"] def normalize_extra_headers(extra_headers: Any) -> Dict[str, str]: """Normalize a raw ``extra_headers`` value into a ``dict[str, str]``. SECURITY: header values routinely carry credentials (Cloudflare Access service tokens, proxy auth, custom bearer schemes). Callers must never log the returned values. """ if not isinstance(extra_headers, dict) or not extra_headers: return {} return {str(k): str(v) for k, v in extra_headers.items() if v is not None} def get_custom_provider_extra_headers( base_url: str, custom_providers: Optional[List[Dict[str, Any]]] = None, config: Optional[Dict[str, Any]] = None, ) -> Dict[str, str]: """Return ``extra_headers`` from a matching ``providers`` / ``custom_providers`` entry. Matches the entry whose normalized route identity equals *base_url*, mirroring :func:`get_custom_provider_tls_settings`, and returns its ``extra_headers`` dict, or ``{}`` when no entry matches or declares none. SECURITY: header values routinely carry credentials (Cloudflare Access service tokens, proxy auth, custom bearer schemes). Callers must never log the returned values. """ for entry in _entries_for_route(base_url, custom_providers, config): headers = normalize_extra_headers(entry.get("extra_headers")) if headers: return headers return {} def apply_custom_provider_extra_headers_to_client_kwargs( client_kwargs: Dict[str, Any], base_url: str, custom_providers: Optional[List[Dict[str, Any]]] = None, config: Optional[Dict[str, Any]] = None, ) -> None: """Merge per-provider ``extra_headers`` onto OpenAI client ``default_headers``. Provider-specific headers win over SDK/provider defaults already in ``client_kwargs`` (they are the most specific level). No-op when base_url matches no provider entry or none declares headers. SECURITY: values may carry credentials -- never log them. """ extra_headers = get_custom_provider_extra_headers(base_url, custom_providers, config) if not extra_headers: return merged = dict(client_kwargs.get("default_headers") or {}) merged.update(extra_headers) client_kwargs["default_headers"] = merged def get_custom_provider_context_length( model: str, base_url: str, custom_providers: Optional[List[Dict[str, Any]]] = None, config: Optional[Dict[str, Any]] = None, ) -> Optional[int]: """Look up a per-model ``context_length`` override from ``custom_providers``. Matches any entry whose normalized route identity equals ``base_url`` and returns ``custom_providers[i].models..context_length`` if present and valid. Returns ``None`` when no override applies. """ if not model or not base_url: return None if custom_providers is None: try: custom_providers = get_compatible_custom_providers(config) except Exception: if config is None: return None raw = config.get("custom_providers") custom_providers = raw if isinstance(raw, list) else [] for entry in _entries_for_route(base_url, custom_providers, config): model_cfg = _route_model_cfg(entry, model) if model_cfg is None: continue raw_ctx = model_cfg.get("context_length") if raw_ctx is None: continue try: ctx = int(raw_ctx) except (TypeError, ValueError): continue if ctx > 0: return ctx return None def get_custom_provider_model_capability( model: str, base_url: str, capability: str, custom_providers: Optional[List[Dict[str, Any]]] = None, config: Optional[Dict[str, Any]] = None, ) -> Optional[bool]: """Return an explicit boolean capability for one custom-provider model. Matching is scoped to the normalized route and exact runtime model id so aliases can declare capabilities without changing the id sent upstream. Missing or non-boolean declarations return ``None``. """ if not model or not base_url or not capability: return None if custom_providers is None: try: if config is None: # Read-only path: this helper never mutates the entries it # scans, and get_compatible_custom_providers shallow-copies # each entry before normalizing, so the no-deepcopy cache is # safe here (~135us saved per call on the blank-stub paths). config = load_config_readonly() custom_providers = get_compatible_custom_providers(config) except Exception: return None for entry in _entries_for_route(base_url, custom_providers, config): model_cfg = _route_model_cfg(entry, model) if model_cfg is None: continue value = model_cfg.get(capability) if isinstance(value, bool): return value return None 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 _raw_config_has_explicit_version() -> bool: """True when config.yaml exists, parses, and carries a ``_config_version`` key. Distinguishes an ANCIENT config (explicit old version → refused by the v12 support floor) from a fresh minimal/hand-written/cloned config with no version key at all (→ migrated + stamped normally). Missing or unparseable files return False so they never trip the floor gate. """ config_path = get_config_path() if not config_path.exists(): return False try: raw = read_user_config_raw(config_path) except Exception: return False return "_config_version" in raw def check_config_version() -> Tuple[int, int]: """Check the raw on-disk config schema version; returns (current_version, latest_version). Reads the raw file rather than ``load_config()``, which deep-merges over ``DEFAULT_CONFIG`` and would make a file lacking ``_config_version`` inherit the latest version in memory, hiding that the persisted schema was never migrated. """ 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) or {} except Exception as e: # Invalid YAML needs a parse warning, not an automatic schema rewrite # that could replace the user's broken file with defaults. _warn_config_parse_failure(config_path, e) return latest, latest if not isinstance(config, dict): config = {} current = _coerce_config_version(config.get("_config_version")) return current, latest # ============================================================================= # Config structure validation # ============================================================================= # Fields that are valid at root level of config.yaml. # DEFAULT_CONFIG is the single source of truth for documented roots; keep this # set derived so new defaults (skills, security, browser, …) are accepted # automatically. A few 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 # Roots read from the raw user YAML (or written by our own flows) that are # intentionally absent from DEFAULT_CONFIG: "image_gen", # image-generation provider config (agent/image_gen_registry.py) "video_gen", # video-generation provider config (agent/video_gen_registry.py) "plugins", # plugin enable/disable lists (hermes_cli/plugins_cmd.py) "smart_model_routing", # written by the setup wizard (hermes_cli/setup.py) "platform_toolsets", # written by the setup wizard (hermes_cli/setup.py) "known_plugin_toolsets", # written/read by hermes_cli/tools_config.py toolset-save flow "known_builtin_toolsets", # ditto — which builtin toolsets a platform's checklist has offered "tool_gateway_declined_tools", # per-tool Tool Gateway offer declines (hermes_cli/nous_subscription.py, #92647) "session_reset", # top-level form read by gateway/config.py + setup "group_sessions_per_user", # top-level form bridged by gateway/config.py "thread_sessions_per_user", # top-level form bridged by gateway/config.py "stt_echo_transcripts", # top-level form bridged by gateway/config.py "reset_triggers", # top-level form bridged by gateway/config.py "always_log_local", # top-level form bridged by gateway/config.py "filter_silence_narration", # top-level form bridged by gateway/config.py "multiplex_profiles", # top-level form accepted alongside gateway.multiplex_profiles "profile_routes", # top-level form accepted alongside gateway.profile_routes "platforms", # top-level per-platform map merged by gateway/config.py "require_mention", # top-level convenience form honored by the gateway (#3979) "unauthorized_dm_behavior", # top-level form read by gateway/config.py "signal", # Signal settings bridged to env vars by gateway/config.py "timeouts", # unified timeout resolution section (agent/deadline.py, #85125) } _KNOWN_ROOT_KEYS = frozenset(DEFAULT_CONFIG.keys()) | _EXTRA_KNOWN_ROOT_KEYS # Valid fields inside a custom_providers list entry _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 is read at runtime by runtime_provider.py and auxiliary_client.py # — include it here so the set accurately describes the supported schema. "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 _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: ``