"""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.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 from hermes_cli.config_providers import ( # noqa: 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, ) # ============================================================================= # 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 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: ``