Files
hermes-agent/hermes_cli/config.py
teknium1 8609743389 fix(serve): launch-profile scope decided at entry; send keeps scope authority; per-reset release
Three edges of the fail-closed multi-profile host (#111620 review, andrexibiza P1 + P2,
kvnloo finding 1):

- `send` under a routed profile's scope `update()`d the installed scope from raw `.env`,
  reversing build_profile_secret_scope's precedence (user .env, then external secret
  sources) for the rest of the request; a stale user value beat the secret-manager one.
  The installed scope is authoritative as-is; only the config.yaml setdefault bridge runs.
- The launch profile's body was scoped only when `is_multiplex_active()` was already true
  at entry, while get_secret consults that global on every read. A launch RPC / dashboard
  request entering single-profile and resuming after a concurrent first `?profile=B`
  activation raised UnscopedSecretError mid-request. The launch profile's secret scope
  (its .env + external sources over the launch env: live while single-profile, the frozen
  snapshot once multiplexing is active) is now bound for every launch-profile body, so the
  credential source is fixed at entry. The terminal policy overlay stays multiplex-only
  (standalone terminal execution keeps its os.environ bridge). _publish_env_value mirrors a
  same-request .env write into that scope AND os.environ for the launch profile, only into
  the scope for a routed one (serves_routed_profile).
- _release_profile_runtime_scope_tokens reset terminal → secret → home in sequence under
  one outer suppress; a failing terminal reset left the previous profile's secrets and
  HERMES_HOME installed for the next body in that context. Each reset is now independent;
  the first failure is re-raised after every scope is released.

tests/tui_gateway/test_multi_profile_hosting_transitions.py: manager-vs-dotenv precedence
through _load_hermes_env, TUI-RPC and dashboard barrier tests (launch enters single-profile,
B activates on another thread, launch resumes and still resolves its injected credential,
never B's), forced terminal-reset failure still releases secret + home. 4/4 red on base.
2026-09-16 00:35:00 -07:00

4011 lines
181 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Configuration management for Hermes Agent: config.yaml / .env loading, saving,
validation, migration, and the ``hermes config`` command."""
import copy
import difflib
import json
import logging
import os
import platform
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import threading
import time
import unicodedata
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple, Set
import yaml
from hermes_cli.cli_output import line_input
from hermes_cli.colors import Colors, color
from hermes_cli import managed_scope
from hermes_cli.default_soul import DEFAULT_SOUL_MD, is_legacy_template_soul
from hermes_cli.secret_prompt import masked_secret_prompt
# Re-export from hermes_constants — canonical definition lives there.
from hermes_constants import get_hermes_home, get_process_hermes_home # noqa: F401
from utils import atomic_replace, atomic_yaml_write, fast_safe_load, file_signature
logger = logging.getLogger(__name__)
# (config_path, mtime_ns, size) tuples already warned about, so concurrent CLI/gateway
# loads of a broken config.yaml don't spam stderr. A changed file (new mtime) warns again.
_CONFIG_PARSE_WARNED: set = set()
# path -> (mtime_ns, size, error message) of active parse failures. Written by
# _warn_config_parse_failure() (the single funnel for every load-path parse failure) and
# probed by get_active_config_parse_failure() so provider auto-resolution can refuse to
# adopt a paid provider from env keys while the user's REAL config is unreadable.
_CONFIG_PARSE_FAILURES: dict = {}
class InvalidUserConfigError(RuntimeError):
"""Raised when a run that cannot repair config finds invalid user YAML."""
_PARSE_FAILURE_FALLBACK_MSG = {
"last-known-good": "Hermes is running on the settings it loaded before the edit until it is fixed, so recent changes are not applied.",
"last-known-good-backup": "Hermes is running on your last good settings until it is fixed, so recent changes are not applied.",
"refuse-write": "Nothing was written, so the existing file is preserved."}
_PARSE_FAILURE_DEFAULTS_MSG = (
"Hermes is running on default settings until it is fixed, so none of your saved settings are applied.")
_PARSE_FAILURE_REPAIR_MSG = "Open it with `hermes config edit`, fix {where}, then run `hermes config check`."
def _yaml_error_location(exc: Exception) -> str:
"""``"line 12"`` from a PyYAML problem mark (1-based), else ``""``."""
mark = getattr(exc, "problem_mark", None) or getattr(exc, "context_mark", None)
line = getattr(mark, "line", None)
return f"line {line + 1}" if isinstance(line, int) else ""
def _yaml_error_details(exc: Exception) -> str:
"""Single-line ``Details:`` text: the PyYAML problem, or the exception's first line."""
problem = getattr(exc, "problem", None)
text = f"{problem}" if problem else str(exc).strip()
return " ".join(text.split())
def format_config_parse_failure(config_path: Path, exc: Exception, *, fallback: str = "defaults") -> str:
"""User copy for an unparseable config.yaml: what happened, what Hermes is doing, how to fix.
Only the problem line/column is printed; the raw PyYAML text goes to a ``Details:`` line."""
where = _yaml_error_location(exc)
at = f" at {where}" if where else ""
fallback_msg = _PARSE_FAILURE_FALLBACK_MSG.get(fallback, _PARSE_FAILURE_DEFAULTS_MSG)
repair = _PARSE_FAILURE_REPAIR_MSG.format(where=where or "the problem")
return f"Your settings file ({config_path}) has a formatting error{at}. {fallback_msg} {repair}"
def _warn_config_parse_failure(
config_path: Path, exc: Exception, *, fallback: str = "defaults") -> None:
"""Surface a config.yaml parse failure to log and stderr (once per file signature).
Silent fallback to ``DEFAULT_CONFIG`` drops every user override, so this must be loud.
``fallback`` selects the message wording: ``"defaults"`` (fresh process, nothing else to serve) or
``"last-known-good"`` (in-process retention of the previously loaded config — see the codex#31188 port
in ``_load_config_impl``).
"""
try:
st = config_path.stat()
sig = file_signature(st)
key = (str(config_path), *sig)
_CONFIG_PARSE_FAILURES[str(config_path)] = (*sig, str(exc))
except OSError:
key = (str(config_path), 0, 0, 0, 0)
if key in _CONFIG_PARSE_WARNED:
return
_CONFIG_PARSE_WARNED.add(key)
from hermes_cli.config_backups import backup_config
backup_path = backup_config(config_path, "corrupt")
msg = format_config_parse_failure(config_path, exc, fallback=fallback)
if backup_path is not None:
msg += f" A copy of the broken file was saved to {backup_path}."
logger.warning("%s Details: %s", msg, _yaml_error_details(exc))
try:
sys.stderr.write(f"⚠️ hermes config: {msg}\n Details: {_yaml_error_details(exc)}\n")
sys.stderr.flush()
except Exception:
pass
def get_active_config_parse_failure() -> Optional[str]:
"""Return the recorded parse error while the ACTIVE config.yaml is still byte-identical
(mtime_ns + size + ino + ctime_ns) to the file that failed to parse; else None."""
try:
record = _CONFIG_PARSE_FAILURES[str(path := get_config_path())]
st = path.stat()
return record[4] if file_signature(st) == record[:4] else None
except Exception:
return None
_IS_WINDOWS = platform.system() == "Windows"
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Env var names that influence how the next subprocess executes — never writable through
# ``save_env_value``: dynamic loader (LD_*/DYLD_*: attacker code loads before main()),
# interpreter init (PYTHON*, NODE_*: Hermes restarts through them), PATH (fix tool lookup
# with absolute paths instead), git rewrites (fire on every plugin install/update),
# implicitly-invoked commands (BROWSER/EDITOR/VISUAL/PAGER = RCE on next $EDITOR), SHELL,
# and Hermes runtime-location / security-policy flags (config.yaml is the supported surface).
#
# ``HERMES_*`` overall is NOT blocked — many integration credentials use that prefix
# (HERMES_LANGFUSE_PUBLIC_KEY, HERMES_SPOTIFY_CLIENT_ID, ...). The denylist is name-by-name so
# it cannot break provider setup wizards. Enforced on *write* only: pre-existing/out-of-band
# ``.env`` values keep working; the dashboard's writable surface just cannot escalate.
_ENV_VAR_NAME_DENYLIST: frozenset[str] = frozenset({
# Loader / linker
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "LD_DEBUG",
"DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_LIBRARY_PATH", "DYLD_FALLBACK_FRAMEWORK_PATH",
# Python / Node
"PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP", "PYTHONUSERBASE",
"PYTHONEXECUTABLE", "PYTHONNOUSERSITE", "NODE_OPTIONS", "NODE_PATH",
# General / git
"PATH", "SHELL", "BROWSER", "EDITOR", "VISUAL", "PAGER",
"GIT_SSH_COMMAND", "GIT_EXEC_PATH", "GIT_SHELL",
# Hermes runtime location
"HERMES_HOME", "HERMES_PROFILE", "HERMES_CONFIG", "HERMES_ENV",
"HERMES_CONFIG_PATH", "HERMES_ENV_PATH",
# MCP catalog trust root; package-manager wrappers may still set it in the process env.
"HERMES_OPTIONAL_MCPS",
# Local ACP subprocess selection (executable/argv authority).
"HERMES_COPILOT_ACP_COMMAND", "HERMES_COPILOT_ACP_ARGS",
# Security policy / approval-routing context — set via their dedicated controls only.
"HERMES_YOLO_MODE", "HERMES_ACCEPT_HOOKS", "HERMES_REDACT_SECRETS",
"HERMES_INTERACTIVE", "HERMES_EXEC_ASK", "HERMES_GATEWAY_SESSION",
"HERMES_CRON_SESSION", "HERMES_SINGLE_QUERY_SESSION",
"HERMES_SESSION_KEY", "HERMES_SESSION_PLATFORM"})
def _env_var_policy_name(key: str, *, is_windows: Optional[bool] = None) -> str:
"""Name used for env policy comparisons: Windows env names are case-insensitive, POSIX not.
The override keeps both semantics testable on any host."""
windows = _IS_WINDOWS if is_windows is None else is_windows
return key.upper() if windows else key
def validate_env_var_name_for_write(key: str) -> None:
"""Validate an env name before a generic persistence write (exposed for batch callers)."""
if not _ENV_VAR_NAME_RE.match(key):
raise ValueError(f"Invalid environment variable name: {key!r}")
if _env_var_policy_name(key) in _ENV_VAR_NAME_DENYLIST:
raise ValueError(
f"Environment variable {key!r} is on the writer denylist. "
"Names that influence subprocess execution (LD_PRELOAD, PYTHONPATH, PATH, EDITOR, ...) "
"or Hermes runtime location and security policy (HERMES_HOME, HERMES_YOLO_MODE, ...) "
"cannot be persisted via the env writer. If you really need this, edit ~/.hermes/.env "
"directly.")
# Serializes all config read/write paths and guards the module-level caches below. libyaml's
# C extension is not thread-safe for concurrent safe_load() on one file, and tool threads
# (approval, browser, setup flows) load/save config concurrently during long agent runs.
# RLock because save_config internally calls read_raw_config.
_CONFIG_LOCK = threading.RLock()
# path -> last successfully loaded (expanded) config; served after a parse failure so a
# mid-edit broken YAML never silently drops user overrides (e.g. approvals.deny rules).
_LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {}
# path -> (user_mtime_ns, user_size, managed_mtime_ns, managed_size, merged, env_ref_snapshot).
# load_config() returns a deepcopy of the cached value while the signature matches (skips
# safe_load + merge + normalize + expand, ~13 ms). Writers use atomic_yaml_write (fresh inode
# -> new mtime_ns) so no explicit invalidation is needed. The managed-file signature is folded
# in so editing the managed-scope config.yaml invalidates, and the env snapshot invalidates
# when a referenced ${VAR} changes value (late .env load, in-process rotation).
# (path, mtime_ns, size) -> cached expanded config dict. load_config() returns a deepcopy of the cached
# value when the file hasn't changed since the last load, skipping yaml.safe_load + _deep_merge +
# _normalize_* + _expand_env_vars (~13 ms/call). save_config() + migrate_config() write via
# atomic_yaml_write which produces a fresh inode, so stat() sees a new signature and the next load
# repopulates automatically — no explicit invalidation hook. See #58514.
_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, ...]] = {}
# path -> (mtime_ns, size, ino, ctime_ns, raw yaml dict) for read_raw_config() (no defaults merged in).
_RAW_CONFIG_CACHE: Dict[str, Tuple[int, ...]] = {}
# Env var names written to .env that aren't in OPTIONAL_ENV_VARS (managed by setup/provider
# flows directly). Also the set reload_env() may remove from os.environ.
_EXTRA_ENV_KEYS = frozenset({
"OPENAI_API_KEY", "OPENAI_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
"DISCORD_HOME_CHANNEL", "DISCORD_HOME_CHANNEL_NAME",
"TELEGRAM_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL_NAME",
"SLACK_HOME_CHANNEL", "SLACK_HOME_CHANNEL_NAME",
"SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL", "SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
"SIGNAL_HOME_CHANNEL", "SIGNAL_HOME_CHANNEL_NAME", "SMS_HOME_CHANNEL", "SMS_HOME_CHANNEL_NAME",
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET", "DINGTALK_HOME_CHANNEL", "DINGTALK_HOME_CHANNEL_NAME",
"FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
"FEISHU_HOME_CHANNEL", "FEISHU_HOME_CHANNEL_NAME", "YUANBAO_HOME_CHANNEL", "YUANBAO_HOME_CHANNEL_NAME",
"WECOM_BOT_ID", "WECOM_SECRET", "WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET",
"WECOM_CALLBACK_AGENT_ID", "WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY",
"WECOM_CALLBACK_HOST", "WECOM_CALLBACK_PORT", "WECOM_HOME_CHANNEL", "WECOM_HOME_CHANNEL_NAME",
"WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL", "WEIXIN_CDN_BASE_URL",
"WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY",
"WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS",
"BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD", "BLUEBUBBLES_HOME_CHANNEL", "BLUEBUBBLES_HOME_CHANNEL_NAME",
"QQ_APP_ID", "QQ_CLIENT_SECRET", "QQBOT_HOME_CHANNEL", "QQBOT_HOME_CHANNEL_NAME",
"QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", # legacy aliases (pre-rename, still read for back-compat)
"QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT",
"QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL",
"IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL", "IRC_USE_TLS", "IRC_SERVER_PASSWORD",
"IRC_NICKSERV_PASSWORD", "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
# Deprecated (replaced by display.tool_progress) but STILL READ by the gateway as a
# back-compat fallback. The boolean HERMES_TOOL_PROGRESS variant is unsupported (its only
# consumer, the v3->4 migration, is below the v12 support floor); doctor flags it as ignored.
"HERMES_TOOL_PROGRESS_MODE",
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE",
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", "MATRIX_DM_AUTO_THREAD",
"MATRIX_RECOVERY_KEY",
# Langfuse observability plugin tuning keys + standard SDK vars (activation is via
# plugins.enabled; credentials gate the plugin at runtime).
"HERMES_LANGFUSE_ENV", "HERMES_LANGFUSE_RELEASE", "HERMES_LANGFUSE_SAMPLE_RATE",
"HERMES_LANGFUSE_MAX_CHARS", "HERMES_LANGFUSE_CAPTURE", "HERMES_LANGFUSE_DEBUG",
"LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_BASE_URL",
# ACP (Agent Client Protocol) keys — profile-isolable so profiles can use different backends.
"HERMES_ACP_AUTH_METHOD", "HERMES_ACP_AUTO_APPROVE", "HERMES_COPILOT_ACP_COMMAND",
"HERMES_COPILOT_ACP_ARGS", "COPILOT_CLI_PATH", "COPILOT_ACP_BASE_URL"})
# ---- Managed mode (NixOS declarative config) ----
_MANAGED_TRUE_VALUES = ("true", "1", "yes")
_NIX_MANAGED_SYSTEMS = {"nixos", "home-manager"}
# Only the NixOS module ever wrote a bare "true" or an empty marker.
_LEGACY_MANAGED_SYSTEM = "nixos"
# Nix store root; identifies `nix run` / `nix profile install` installs (which don't set
# HERMES_MANAGED). Module-level so tests can patch it without touching /nix/store.
_NIX_STORE = Path("/nix/store")
# Homebrew is no longer a supported distribution: these markers fall through to git/unknown
# detection instead of blocking config writes.
_IGNORED_MANAGED_VALUES = frozenset({"brew", "homebrew"})
# Explicit opt-out (``HERMES_MANAGED=false``): without this a bool-shaped value became a package
# manager literally named "false" and is_managed() blocked `hermes update` (#12864).
_MANAGED_FALSE_VALUES = frozenset({"false", "0", "no", "off"})
def get_managed_system() -> Optional[str]:
"""Return the package manager owning this install, if any.
Signals: HERMES_MANAGED env var (systemd service) or a ``.managed`` marker file in
HERMES_HOME (NixOS activation script — interactive shells don't see the service env)."""
marker = os.getenv("HERMES_MANAGED", "").strip().lower() or None
managed_marker = get_hermes_home() / ".managed"
if marker is None and managed_marker.exists():
try:
marker = managed_marker.read_text(encoding="utf-8", errors="replace").strip().lower()
except OSError:
marker = ""
if marker is None or marker in _IGNORED_MANAGED_VALUES or marker in _MANAGED_FALSE_VALUES:
return None
if marker == "" or marker in _MANAGED_TRUE_VALUES:
return _LEGACY_MANAGED_SYSTEM
return marker
def is_managed() -> bool:
"""Check if Hermes is running in package-manager-managed mode."""
return get_managed_system() is not None
# Nix installs arrive by several routes (nix run, nix profile, system flake, home-manager) and
# the running process cannot tell which, so the text names the routes instead of one command.
_NIX_UPDATE_MSG = (
"Update Hermes through the Nix source that installed it "
"(e.g. nix profile upgrade, or update your flake input and rebuild with nixos-rebuild or home-manager switch)"
)
def get_managed_update_command() -> Optional[str]:
"""Return the preferred upgrade command for a managed install."""
return _NIX_UPDATE_MSG if get_managed_system() in _NIX_MANAGED_SYSTEMS else None
# "apt" is the Termux APT distribution identifier, not a generic Debian/Ubuntu signal; another
# APT distribution needs its own method. "home-manager" is listed because the managed marker can
# return it and a stamp must name every method this function returns.
_SUPPORTED_INSTALL_METHODS = frozenset({"apt", "docker", "nix", "nixos", "home-manager", "git", "unknown"})
def _install_method_stamp(path: Path) -> Optional[str]:
try:
method = path.read_text(encoding="utf-8").strip().lower()
except OSError:
return None
return method if method in _SUPPORTED_INSTALL_METHODS else None
def detect_install_method(project_root: Optional[Path] = None) -> str:
"""Detect how Hermes was installed: apt/docker/nix/nixos/home-manager/git/unknown.
Order: code-scoped ``<install tree>/.install_method`` stamp (authoritative) -> legacy
``$HERMES_HOME/.install_method`` -> managed marker -> /nix/store path -> .git dir -> unknown.
The stamp lives next to the code because HERMES_HOME is shared data: a container and a host
install can bind-mount the same home, so a home-scoped ``docker`` stamp would make the host
``hermes update`` refuse to run. A legacy ``docker`` value is therefore ignored unless we are
really inside a container, and being in a container alone never implies 'docker'.
The supported installs self-identify via the code-scoped stamp: - the curl installer
(scripts/install.sh, the README/website install command) git-clones the repo and stamps ``git`` next to
the code; - the published ``nousresearch/hermes-agent`` image bakes a ``docker`` stamp into
``/opt/hermes`` at build time. An unsupported manual install dropped into a container (no stamp) falls
through to the ``.git`` checks and behaves like any off-path install. See issue #34397.
"""
# The stamp is a property of the running code tree (parent of hermes_cli/), NOT of $HERMES_HOME,
# so it survives two installs sharing a home.
root = project_root if project_root is not None else get_project_root()
method = _install_method_stamp(root / ".install_method")
if method:
return method
method = _install_method_stamp(get_hermes_home() / ".install_method")
if method and not (method == "docker" and not _running_in_container()):
return method
managed = get_managed_system()
if managed:
return managed.lower().replace(" ", "-")
# Code under /nix/store/ is the hallmark of a nix-built install.
try:
resolved = root.resolve()
if resolved != _NIX_STORE and _NIX_STORE in resolved.parents:
return "nix"
except OSError:
pass
# A .git directory, or a ``gitdir:`` pointer file for worktrees.
git_path = root / ".git"
try:
if git_path.is_dir() or git_path.read_text(encoding="utf-8").strip().startswith("gitdir:"):
return "git"
except OSError:
pass
return "unknown"
def _running_in_container() -> bool:
"""Import-safe wrapper around ``hermes_constants.is_container``."""
try:
from hermes_constants import is_container
return is_container()
except Exception:
return False
def is_nix_install_method(method: str) -> bool:
"""True for every install method Nix owns ("nix", "nixos", "home-manager")."""
return method == "nix" or method in _NIX_MANAGED_SYSTEMS
_UPDATE_COMMAND_BY_METHOD = {
"docker": "docker pull nousresearch/hermes-agent:latest",
"apt": "pkg upgrade hermes-agent", # "apt" == Termux APT by contract; uses Termux's `pkg`.
}
def recommended_update_command_for_method(method: str) -> str:
"""Return the update command or guidance for a given install method."""
if is_nix_install_method(method):
return _NIX_UPDATE_MSG
return _UPDATE_COMMAND_BY_METHOD.get(method, "hermes update")
def recommended_update_command() -> str:
"""Return the best update command for the current installation.
Managed state wins over the code-scoped stamp: a managed install can carry a stale stamp
naming an update path the managed guard refuses."""
return get_managed_update_command() or recommended_update_command_for_method(
detect_install_method(get_project_root()))
# Shared by ``cmd_update`` and ``_cmd_update_check`` (hermes_cli/main.py) so the wording never
# forks. The published image excludes ``.git``, so the git update path can never succeed there
# and the generic "reinstall via install.sh" fallback would install a NEW host-side Hermes.
_DOCKER_UPDATE_MESSAGE = """\
✗ ``hermes update`` doesn't apply inside the Docker container.
Hermes Agent runs as a published image (nousresearch/hermes-agent), not a
git checkout — the container has no working tree to pull into. Update by
pulling a fresh image and restarting your container instead:
docker pull nousresearch/hermes-agent:latest
# then restart whatever started the container, e.g.:
docker compose up -d --force-recreate hermes-agent
# or, for ad-hoc runs, exit the current container and `docker run` again
Verify the new version after restart:
docker run --rm nousresearch/hermes-agent:latest --version
Notes:
• If you pinned a specific tag (e.g. ``:v0.14.0``) the ``:latest`` tag
won't move your container — pull the newer tag you actually want, or
switch to ``:latest`` / ``:main`` for rolling updates. See available
tags at https://hub.docker.com/r/nousresearch/hermes-agent/tags
• Your config and session history live under ``$HERMES_HOME`` (``/opt/data``
in the container, typically bind-mounted from the host) and persist
across image upgrades — re-pulling doesn't lose any state.
• Running a fork? Build your own image with this repo's ``Dockerfile``
and replace the ``docker pull`` step with your build/push pipeline."""
def format_docker_update_message() -> str:
"""Return the user-facing message for ``hermes update`` inside Docker."""
return _DOCKER_UPDATE_MESSAGE
def format_managed_message(action: str = "modify this Hermes installation") -> str:
"""Build a user-facing error for managed installs."""
managed_system = get_managed_system() or "a package manager"
return (
f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n"
"Use your package manager to upgrade or reinstall Hermes.")
def managed_error(action: str = "modify configuration"):
"""Print user-friendly error for managed mode."""
print(format_managed_message(action), file=sys.stderr)
def get_container_exec_info() -> Optional[dict]:
"""Read container mode metadata from HERMES_HOME/.container-mode.
Written by the NixOS activation script when container.enable = true; tells the host CLI to
exec into the container instead of running locally. None when container mode is off, when
already inside the container, or when HERMES_DEV=1 is set. Only FileNotFoundError is
swallowed; other errors (permissions, malformed data) propagate."""
if os.environ.get("HERMES_DEV") == "1":
return None
from hermes_constants import is_container
if is_container():
return None
try:
info = {}
with open(get_hermes_home() / ".container-mode", "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if "=" in line and not line.startswith("#"):
key, _, value = line.partition("=")
info[key.strip()] = value.strip()
except FileNotFoundError:
return None
return {
"backend": info.get("backend", "docker"),
"container_name": info.get("container_name", "hermes-agent"),
"exec_user": info.get("exec_user", "hermes"),
"hermes_bin": info.get("hermes_bin", "/data/current-package/bin/hermes")}
# ---- Config paths / HERMES_HOME skeleton ----
def get_config_path() -> Path:
"""Get the main config file path."""
return get_hermes_home() / "config.yaml"
def require_parseable_user_config(*, ignore_user_config: bool = False) -> None:
"""Reject an existing invalid config before a non-interactive agent run.
Interactive surfaces keep ``load_config()``'s recovery behavior so the operator can repair
the file; a one-shot run has no such chance, and defaults there could silently pick a hosted
provider and spend against ``.env`` credentials. Missing/empty files stay valid first-run
states; ``--ignore-user-config`` / HERMES_IGNORE_USER_CONFIG=1 remain authoritative."""
if ignore_user_config or os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1":
return
config_path = get_config_path()
try:
with open(config_path, encoding="utf-8") as f:
data = fast_safe_load(f)
except FileNotFoundError:
return
except Exception as exc:
parse_error = exc
else:
if data is None or isinstance(data, dict):
return
parse_error = TypeError(f"top-level YAML value must be a mapping, got {type(data).__name__}")
from hermes_cli.config_backups import backup_config
backup_path = backup_config(config_path, "corrupt")
where = _yaml_error_location(parse_error)
message = (
f"Hermes stopped because your settings file ({config_path}) has a formatting error"
f"{f' at {where}' if where else ''}. Fix it with `hermes config edit` and check with "
"`hermes config check`, or add --ignore-user-config to run once with default settings.")
if backup_path is not None:
message += f" A copy of the broken file is at {backup_path}."
message += f" Details: {_yaml_error_details(parse_error)}"
logger.error(message)
raise InvalidUserConfigError(message) from parse_error
def get_env_path() -> Path:
"""Get the .env file path (for API keys)."""
return get_hermes_home() / ".env"
def get_project_root() -> Path:
"""Get the project installation directory."""
return Path(__file__).parent.parent.resolve()
def _resolve_hermes_uid_gid() -> tuple[Optional[int], Optional[int]]:
"""Read HERMES_UID / HERMES_GID (set by Docker deployments); (None, None) if unset/invalid/Windows.
The entrypoint chowns HERMES_HOME once, but subdirs created at runtime (``profiles/<name>/``)
need the same chown or they land root:root and block later uid-mapped workers.
Docker containers running Hermes commonly set these to map the in-container user to a host user so
volume-mounted state files end up with the right ownership. See #34107.
"""
if sys.platform == "win32":
return None, None
def _env_int(name: str) -> Optional[int]:
try:
return int(os.environ.get(name, "").strip() or None)
except (TypeError, ValueError):
return None
return _env_int("HERMES_UID"), _env_int("HERMES_GID")
def _chown_to_hermes_uid(path) -> None:
"""Chown ``path`` to ``HERMES_UID:HERMES_GID`` when set; EPERM/ENOENT are non-fatal (the
entrypoint's startup chown -R fixes ownership on the next restart).
Used by :func:`_secure_dir` to keep ownership consistent across all directories created by
:func:`ensure_hermes_home` on Docker deployments. See #34107.
"""
uid, gid = _resolve_hermes_uid_gid()
if uid is None and gid is None:
return
try:
os.chown(path, uid if uid is not None else -1, gid if gid is not None else -1)
except (OSError, AttributeError, NotImplementedError):
pass
def _secure_dir(path):
"""chmod a directory owner-only (0700) and apply HERMES_UID/GID ownership. No-op when managed;
in a container only an explicit HERMES_HOME_MODE is applied. HERMES_HOME_MODE (e.g. 0701)
overrides the mode so a web server can traverse HERMES_HOME to a served subdirectory without
directory listings.
Also applies ``HERMES_UID``/``HERMES_GID``-based ownership when those env vars are set (#34107 — Docker
deployments need this so profile subdirs created at runtime by kanban workers don't land as root:root
and block subsequent uid-mapped workers).
"""
if is_managed():
return
explicit_mode = os.environ.get("HERMES_HOME_MODE", "").strip()
# Same skip as _secure_file: a bind-mounted data dir is often shared with sibling containers
# running as other UIDs (web UI, permissions fixers); forcing 0700 on it locks them out on every
# start (#10757). An explicit HERMES_HOME_MODE is the operator's choice and is still applied.
if _is_container() and not explicit_mode:
_chown_to_hermes_uid(path)
return
try:
mode = int(explicit_mode or "700", 8)
except ValueError:
mode = 0o700
try:
os.chmod(path, mode)
except (OSError, NotImplementedError):
pass
_chown_to_hermes_uid(path)
def _is_container() -> bool:
"""Detect Docker/Podman/LXC (or HERMES_CONTAINER / HERMES_SKIP_CHMOD opt-out).
Volume-mounted config is not forced to 0o600 in containers: gateway and dashboard may run
as different UIDs, or the mount itself needs broader permissions."""
if (os.environ.get("HERMES_CONTAINER") or os.environ.get("HERMES_SKIP_CHMOD")
or os.path.exists("/.dockerenv")):
return True
try:
with open("/proc/1/cgroup", "r", encoding="utf-8") as f:
cgroup_content = f.read()
return any(marker in cgroup_content for marker in ("docker", "lxc", "kubepods"))
except (OSError, IOError):
return False
def _secure_file(path):
"""chmod a file 0600. Skipped when managed (activation sets 0640 group-readable) or in a
container (mounts often need broader permissions)."""
if is_managed() or _is_container():
return
try:
if os.path.exists(str(path)):
os.chmod(path, 0o600)
except (OSError, NotImplementedError):
pass
def _ensure_default_soul_md(home: Path) -> None:
"""Seed DEFAULT_SOUL_MD on first run; upgrade a legacy comment-only scaffold in place.
A SOUL.md the user actually customized is never touched."""
soul_path = home / "SOUL.md"
if soul_path.exists():
try:
existing = soul_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return
if not is_legacy_template_soul(existing):
return
soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8")
_secure_file(soul_path)
# Home paths whose directory skeleton was created this process. Only successful passes are
# recorded, so a raised managed-mode/missing-profile error keeps re-checking on later loads.
_HERMES_HOME_ENSURED: set = set()
_HERMES_HOME_SUBDIRS = (
"cron", "sessions", "logs", "logs/curator", "memories",
"pairing", "hooks", "image_cache", "audio_cache", "skills")
def ensure_hermes_home():
"""Ensure the ~/.hermes directory skeleton exists with secure permissions.
Memoized per home path: this runs on EVERY ``load_config()`` and the ~14 mkdir/chmod syscalls
made repeated loads the dominant cost of hot read paths."""
home = get_hermes_home()
key = str(home)
# Named profiles must be created explicitly. Check tombstones BEFORE the memo so a stale
# empty shell cannot skip the deleted-profile guard.
from hermes_constants import assert_named_profile_home_live
assert_named_profile_home_live(home)
if key in _HERMES_HOME_ENSURED and home.is_dir():
return
from hermes_cli.config_home import initialize_home
initialize_home(home, _HERMES_HOME_SUBDIRS, _HERMES_HOME_ENSURED)
# ---- Config loading/saving ----
from hermes_cli.config_defaults import DEFAULT_CONFIG, OPTIONAL_ENV_VARS # noqa: E402,F401
from hermes_cli.config_providers import ( # noqa: E402,F401 (re-exported; callers/tests use hermes_cli.config.<name>)
_API_MODE_ALIASES, _CAMEL_ALIASES, _KNOWN_PROVIDER_KEYS, _PROVIDER_NORMALIZE_WARNED,
_canonical_api_mode, _coerce_ssl_verify, _custom_provider_entry_to_provider_config,
_entries_for_route, _normalize_custom_provider_entry, _normalize_provider_models,
_pick_provider_base_url, _route_model_cfg, _warn_once_per_provider,
apply_custom_provider_extra_headers_to_client_kwargs,
apply_custom_provider_tls_to_client_kwargs, coerce_provider_id, find_provider_entry,
get_compatible_custom_providers, get_custom_provider_context_length,
get_custom_provider_extra_headers, get_custom_provider_model_capability,
get_custom_provider_tls_settings, is_provider_enabled, normalize_extra_headers,
providers_dict_to_custom_providers, stringify_provider_map)
# Back-compat re-exports — :mod:`hermes_cli.personality` owns personality/overlay semantics.
from hermes_cli.personality import ( # noqa: E402,F401
NEUTRAL_PERSONALITY_NAMES as _NEUTRAL_PERSONALITY_NAMES,
prompt_text as _prompt_text,
render_personality_prompt,
resolve_ephemeral_system_prompt as resolve_ephemeral_system_prompt_from_config)
# ---- Config Migration System ----
# Env vars introduced per config version; migration only mentions vars new since the user's
# previous version.
ENV_VARS_BY_VERSION: Dict[int, List[str]] = {
3: ["FIRECRAWL_API_KEY", "BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "FAL_KEY"],
4: ["VOICE_TOOLS_OPENAI_KEY", "ELEVENLABS_API_KEY"],
5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS",
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"],
10: ["TAVILY_API_KEY"],
11: ["TERMINAL_MODAL_MODE"]}
# Intentionally empty: the LLM provider is required but handled by the setup wizard's provider
# selection step, so no single env var is universally required.
REQUIRED_ENV_VARS = {}
def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:
"""Check which environment variables are missing."""
groups = [(REQUIRED_ENV_VARS, True)]
if not required_only:
groups.append((OPTIONAL_ENV_VARS, False))
return [
{"name": var_name, **info, "is_required": is_required}
for table, is_required in groups
for var_name, info in table.items()
if not get_env_value(var_name)]
def _split_key_path(key: str) -> list[str]:
"""Split a dotted config-key path, honoring backslash-escaped dots (``a\\.b`` -> ``a.b``).
Backslashes before any other character are preserved verbatim.
``hermes config set`` uses ``.`` as the nesting separator, so a key that itself contains a literal dot
(e.g. provider names like ``qwen3.5-397b-wafer``) was silently split into bogus nested segments
(#84064).
"""
parts: list[str] = []
current: list[str] = []
i = 0
while i < len(key):
ch = key[i]
if ch == "\\" and key[i + 1:i + 2] == ".":
current.append(".")
i += 2
continue
if ch == ".":
parts.append("".join(current))
current = []
else:
current.append(ch)
i += 1
parts.append("".join(current))
return parts
def _greedy_literal_match(container: dict, parts: list) -> Optional[Tuple[str, int]]:
"""Return ``(literal_key, n_consumed)`` for the longest dotted literal key present in
*container*, or None. With no multi-segment literal this is the historic plain-split walk.
Dots in config key names are the norm, not the exception — model IDs (``grok-4.6``, ``glm-5.3``), Matrix
room IDs (``!room:chat.example.cc``), and versioned provider names all embed dots. Users typing
``providers.myprov.models.grok-4.6.context_length`` do not know the escape syntax exists, so when
navigating an EXISTING mapping we prefer an existing literal key equal to the dot-join of the next N
path segments (longest match wins) over blindly splitting. See #84064 / #80006 / 91095 / #91607 /
#99124.
"""
if not isinstance(container, dict) or not parts:
return None
return next(
((".".join(parts[:n]), n) for n in range(len(parts), 0, -1) if ".".join(parts[:n]) in container),
None)
def _phantom_sibling(container: dict, part: str) -> Optional[str]:
"""Existing literal dotted key that creating an intermediate mapping ``part`` would shadow
(``grok-4`` beside ``grok-4.5``) — the write would produce a phantom sibling the runtime never
reads, so callers fail loudly instead.
Called when a write is about to CREATE a new intermediate mapping named ``part``. See #84064.
"""
if not isinstance(container, dict):
return None
prefix = part + "."
return next((k for k in container if isinstance(k, str) and k.startswith(prefix)), None)
def _set_nested(config, dotted_key: str, value):
"""Set a value at a dotted key path, creating intermediate dicts on demand.
Numeric segments index lists; the index must already exist (lists are never grown).
Guards against #17876: before this fix the code unconditionally replaced any non-dict value (including
lists) with ``{}``, silently destroying list-typed config like ``custom_providers`` whenever a caller
used an indexed path.
Dotted key names (#84064 family): when navigating an existing mapping, an existing literal key equal to
the dot-join of the next N segments is preferred over blind splitting (see ``_greedy_literal_match``),
so ``models.grok-4.6.supports_vision`` lands on the real ``grok-4.6`` entry. And when a write WOULD
create a new intermediate mapping that shadows an existing dotted sibling (``grok-4`` beside
``grok-4.5``), it raises ``ValueError`` instead of silently writing a phantom the runtime never reads.
"""
parts = _split_key_path(dotted_key)
current = config
i = 0
while i < len(parts):
remaining = parts[i:]
at_leaf = len(remaining) == 1
if isinstance(current, list):
part = remaining[0]
if at_leaf:
current[int(part)] = value
return
try:
current = current[int(part)]
except (TypeError, ValueError):
raise TypeError(
f"Cannot navigate into list at key {dotted_key!r}: "
f"segment {part!r} is not a numeric index")
i += 1
elif isinstance(current, dict):
match = _greedy_literal_match(current, remaining)
if match is not None:
key, consumed = match
if i + consumed == len(parts):
current[key] = value
return
# Preserve dicts and lists; replace scalar with a fresh dict.
if not isinstance(current.get(key), (dict, list)):
current[key] = {}
current = current[key]
i += consumed
continue
part = remaining[0]
if at_leaf:
current[part] = value
return
shadowed = _phantom_sibling(current, part)
if shadowed is not None:
escaped = shadowed.replace(".", "\\.")
raise ValueError(
f"Refusing to create nested key {part!r} in {dotted_key!r}: the mapping "
f"already contains a literal key {shadowed!r} that contains a dot. If you "
f"meant that key, escape its dots with a backslash (e.g. {escaped}).")
current = current.setdefault(part, {})
i += 1
else:
raise TypeError(f"Cannot navigate into {type(current).__name__} at key {dotted_key!r}")
def clear_model_endpoint_credentials(
model_cfg: Dict[str, Any], *, clear_api_key: bool = True, clear_api_mode: bool = True,
clear_base_url: bool = False) -> Dict[str, Any]:
"""Remove stale inline endpoint credentials from a model config.
``model.api_key`` is valid only for explicit custom endpoints; built-in providers resolve
credentials from env/auth.json/the pool. Leftovers keep secrets in config.yaml and can
contaminate later custom resolution paths."""
if not isinstance(model_cfg, dict):
return model_cfg
if clear_api_key:
model_cfg.pop("api_key", None)
model_cfg.pop("api", None)
# key_env is a first-class credential POINTER (runtime_provider and
# auxiliary_client resolve it), written by custom-endpoint activation.
# Leaving it behind on a provider switch routes the NEW provider's
# requests to the OLD endpoint's env var — same staleness class as an
# inline api_key, so it clears under the same flag.
model_cfg.pop("key_env", None)
model_cfg.pop("api_key_env", None)
if clear_api_mode:
model_cfg.pop("api_mode", None)
if clear_base_url:
model_cfg.pop("base_url", None)
return model_cfg
_MISSING = object()
def _locate_nested(config, parts: list):
"""Walk *parts* through nested dicts/lists (escape-aware, greedy-literal like ``_set_nested``).
Returns ``(parents, container, key)`` where ``container[key]`` is the addressed leaf and
``parents`` lists the ``(container, key)`` hops above it, or ``None`` when any hop is missing,
a list index is non-numeric/out of range, or a scalar is hit before the path is consumed."""
parents = []
current = config
i = 0
while True:
remaining = parts[i:]
if isinstance(current, list):
try:
key = int(remaining[0])
current[key]
except (TypeError, ValueError, IndexError):
return None
consumed = 1
elif isinstance(current, dict):
match = _greedy_literal_match(current, remaining)
if match is None:
return None
key, consumed = match
else:
return None
i += consumed
if i == len(parts):
return parents, current, key
parents.append((current, key))
current = current[key]
def _get_nested(config, dotted_key: str):
"""Return a dotted-path value (``_MISSING`` when absent); same navigation as ``_set_nested``
so ``models.grok-4.6.context_length`` reads the real ``grok-4.6`` entry.
Mirrors ``_set_nested``'s navigation: honors backslash-escaped dots and prefers an existing literal
dotted key over blind splitting, so ``config get providers.p.models.grok-4.6.context_length`` reads the
real ``grok-4.6`` entry instead of reporting the key unset (#84064).
"""
loc = _locate_nested(config, _split_key_path(dotted_key))
if loc is None:
return _MISSING
_, container, key = loc
return container[key]
def _unset_nested(config, dotted_key: str) -> bool:
"""Remove a dotted-path value; True if it existed. Empty dict containers left behind are
dropped, while user-authored empty lists and non-empty sibling branches are preserved.
Same escape-aware, greedy-literal navigation as ``_set_nested`` / ``_get_nested`` (#84064): unsetting an
unescaped dotted key removes the real literal entry rather than a phantom sibling.
"""
loc = _locate_nested(config, _split_key_path(dotted_key))
if loc is None:
return False
parents, current, key = loc
del current[key]
# ``parent[part] is current`` for every hop, so each now-empty dict container is dropped.
for parent, part in reversed(parents):
if current != {}:
break
del parent[part]
current = parent
return True
_ENV_CONFIG_KEYS = frozenset({
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL',
'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_URL', 'CONNECTOR_GATEWAY_URL',
'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME',
'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY', 'PERPLEXITY_API_KEY', 'API_SERVER_KEY',
'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN',
'GITHUB_TOKEN', 'HONCHO_API_KEY'})
def _is_env_config_key(key: str) -> bool:
"""Return whether `hermes config set` routes this credential-shaped key to .env through the
provider credential lifecycle. Non-secret env settings (``*_HOME_CHANNEL``, ``*_ALLOWED_USERS``)
are ``config_env_routing.is_env_setting_key`` and take the plain ``.env`` path."""
if "." in key:
return False
key_upper = key.upper()
return (
key_upper in _ENV_CONFIG_KEYS
or key_upper.endswith(('_API_KEY', '_TOKEN', '_SECRET'))
or key_upper.startswith('TERMINAL_SSH'))
def _format_config_get_value(value, *, as_json: bool) -> str:
"""Format a config value for command-line output."""
if as_json:
return json.dumps(value, ensure_ascii=False)
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return "null"
if isinstance(value, (dict, list)):
return yaml.safe_dump(value, sort_keys=False).rstrip()
return str(value)
def get_missing_config_fields() -> List[Dict[str, Any]]:
"""Check which config fields are missing or outdated (recursive)."""
missing = []
def _check(defaults: dict, current: dict, prefix: str = ""):
for key, default_value in defaults.items():
if key.startswith('_'):
continue
full_key = key if not prefix else f"{prefix}.{key}"
if key not in current:
missing.append({"key": full_key, "default": default_value,
"description": f"New config option: {full_key}"})
elif isinstance(default_value, dict) and isinstance(current.get(key), dict):
_check(default_value, current[key], full_key)
_check(DEFAULT_CONFIG, load_config())
return missing
def get_missing_skill_config_vars() -> List[Dict[str, Any]]:
"""Return skill-declared config vars (``skills.config.<key>``) that are missing or empty."""
try:
from agent.skill_utils import discover_all_skill_config_vars, SKILL_CONFIG_PREFIX
except Exception:
return []
try:
all_vars = discover_all_skill_config_vars()
except Exception as e:
# A malformed SKILL.md must never break `hermes update`; this prompting is a nicety.
logger.debug("discover_all_skill_config_vars failed: %s", e)
return []
if not all_vars:
return []
config = load_config()
values = ((var, cfg_get(config, *f"{SKILL_CONFIG_PREFIX}.{var['key']}".split("."))) for var in all_vars)
return [var for var, v in values if v is None or (isinstance(v, str) and not v.strip())]
def _coerce_config_version(value: Any) -> int:
"""Return a safe integer config version, treating invalid values as legacy."""
if isinstance(value, bool):
return 0
try:
version = int(value)
except (TypeError, ValueError):
return 0
return max(version, 0)
def check_config_version(*, raise_on_parse_error: bool = False) -> Tuple[int, int]:
"""Return ``(current_version, latest_version)`` from the raw on-disk config.
Reads the raw file rather than ``load_config()``: the deep-merge would make a file lacking
``_config_version`` inherit the latest version, hiding that the schema was never migrated.
Invalid YAML gets a parse warning, not an automatic schema rewrite. Tolerant runtime status
callers keep the historical latest/latest fallback for malformed YAML; mutation and explicit
validation paths set ``raise_on_parse_error`` so a parse failure or a non-mapping root cannot
be mistaken for an up-to-date config."""
latest = _coerce_config_version(DEFAULT_CONFIG.get("_config_version", 1)) or 1
config_path = get_config_path()
if not config_path.exists():
return latest, latest
try:
with open(config_path, encoding="utf-8") as f:
config = fast_safe_load(f)
except Exception as e:
_warn_config_parse_failure(config_path, e)
if raise_on_parse_error:
raise InvalidUserConfigError(
f"Cannot inspect {config_path}: config.yaml is not valid YAML ({e})"
) from e
return latest, latest
if config is None:
config = {} # empty file / bare document: valid first-run state
if not isinstance(config, dict):
# A list/scalar root parses fine but is just as unusable as broken YAML: save_config()
# would refuse it later, after .env was already rewritten. Strict callers see it up front.
if raise_on_parse_error:
raise InvalidUserConfigError(
f"Cannot inspect {config_path}: config.yaml top-level value must be "
f"a mapping, got {type(config).__name__}"
)
config = {}
return _coerce_config_version(config.get("_config_version")), latest
# ---- Config structure validation ----
# DEFAULT_CONFIG is the single source of truth for documented roots; the set is derived so new
# defaults are accepted automatically. These optional/legacy roots are valid on disk but
# intentionally absent from DEFAULT_CONFIG (omitted when unused / alternate schema forms).
_EXTRA_KNOWN_ROOT_KEYS = {
"custom_providers", # legacy list form; modern equivalent is providers: {}
"fallback_model", # optional single dict or chain list; omitted when disabled
"mcp_servers", # MCP server definitions written by setup/tools flows
"image_gen", # agent/image_gen_registry.py
"video_gen", # agent/video_gen_registry.py
"plugins", # plugin enable/disable lists (hermes_cli/plugins_cmd.py)
"smart_model_routing", # written by the setup wizard
"platform_toolsets", # written by the setup wizard
"known_plugin_toolsets", # hermes_cli/tools_config.py toolset-save flow
"known_builtin_toolsets", # ditto — builtin toolsets a platform's checklist has offered
"tool_gateway_declined_tools", # per-tool Tool Gateway offer declines
# Top-level forms read/bridged by gateway/config.py:
"group_sessions_per_user", "thread_sessions_per_user",
"stt_echo_transcripts", "reset_triggers", "always_log_local", "filter_silence_narration",
"multiplex_profiles", "profile_routes", "platforms", "require_mention",
"unauthorized_dm_behavior", "signal", "allow_all_users",
"timeouts", # unified timeout resolution section (agent/deadline.py)
}
_KNOWN_ROOT_KEYS = frozenset(DEFAULT_CONFIG.keys()) | _EXTRA_KNOWN_ROOT_KEYS
# Valid fields inside a custom_providers list entry (key_env is read at runtime by
# runtime_provider.py and auxiliary_client.py).
_VALID_CUSTOM_PROVIDER_FIELDS = {
"name", "base_url", "api_key", "api_mode", "model", "models",
"context_length", "rate_limit_delay", "extra_body",
"ssl_ca_cert", "ssl_verify", "key_env"}
# Fields that look like they should be inside custom_providers, not at root
_CUSTOM_PROVIDER_LIKE_FIELDS = {"base_url", "api_key", "rate_limit_delay", "api_mode"}
@dataclass
class ConfigIssue:
"""A detected config structure problem."""
severity: str # "error", "warning"
message: str
hint: str
def _issue(issues: List["ConfigIssue"], severity: str, message: str, hint: str) -> None:
issues.append(ConfigIssue(severity, message, hint))
def _require_fields(
issues: List["ConfigIssue"], entry: Dict[str, Any], label: str,
fields: Tuple[Tuple[str, str], ...], suffix: str = "") -> None:
"""Append a warning for every falsy ``field`` of *entry* (message: ``<label> is missing '<f>' field``)."""
for field, hint in fields:
if not entry.get(field):
_issue(issues, "warning", f"{label} is missing '{field}' field{suffix}", hint)
_CP_REQUIRED_FIELDS = (
("name", "Add a name, e.g.: name: my-provider"),
("base_url", "Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1"))
_FB_REQUIRED_FIELDS = (
("provider", "Add: provider: openrouter (or another provider)"),
("model", "Add: model: <model-name>"))
_FB_SINGLE_REQUIRED_FIELDS = (
("provider", "Add: provider: openrouter (or another provider)"),
("model", "Add: model: anthropic/claude-sonnet-4 (or another model)"))
def _validate_voice(config: Dict[str, Any], issues: List[ConfigIssue]) -> None:
voice_cfg = config.get("voice")
if not (isinstance(voice_cfg, dict) and "submit_mode" in voice_cfg):
return
submit_mode = voice_cfg.get("submit_mode")
normalized = submit_mode.strip().lower() if isinstance(submit_mode, str) else None
if normalized not in {"direct", "draft"}:
_issue(issues, "error", f"voice.submit_mode must be 'direct' or 'draft', got {submit_mode!r}",
"Set voice.submit_mode to direct (submit immediately) or draft (edit before sending)")
def _validate_timezone(config: Dict[str, Any], issues: List[ConfigIssue]) -> None:
"""``timezone`` must be an IANA name the runtime can load.
``hermes_time._get_zoneinfo()`` swallows an invalid name behind a single WARNING in the
gateway log, then runs the agent clock AND every cron schedule on server-local time.
Surface it here, where doctor and the startup check both look. Silent when the
interpreter has no tz database at all (bare Windows without ``tzdata``) — nothing can be
judged there.
"""
if "timezone" not in config:
return
tz = config.get("timezone")
hint = ("Use an IANA zone name such as America/New_York or Asia/Tokyo (see "
"`timedatectl list-timezones`). With an invalid value the agent clock and cron "
"schedules silently fall back to server-local time. HERMES_TIMEZONE overrides "
"this key when set.")
if tz is not None and not isinstance(tz, str):
_issue(issues, "error", f"timezone must be an IANA zone name string, got {tz!r}", hint)
return
if not (isinstance(tz, str) and tz.strip()):
return
name = tz.strip()
try:
import zoneinfo
zoneinfo.ZoneInfo("UTC") # is a tz database available at all?
except Exception:
return
try:
zoneinfo.ZoneInfo(name)
except Exception:
_issue(issues, "error", f"timezone {name!r} is not a valid IANA zone name", hint)
def _validate_entry_list(
entries: list, label: str, issues: List[ConfigIssue], fields, *, non_dict: Tuple[str, str, str],
) -> None:
"""Validate each list entry: ``non_dict`` = (severity, message-with-{i}-and-{type}, hint) for
non-dict items; dict items get ``_require_fields`` with *fields*."""
severity, message, hint = non_dict
for i, entry in enumerate(entries):
if not isinstance(entry, dict):
_issue(issues, severity, message.format(i=i, type=type(entry).__name__), hint)
else:
_require_fields(issues, entry, f"{label}[{i}]", fields)
def _validate_custom_providers(cp: Any, issues: List[ConfigIssue]) -> None:
"""custom_providers must be a list of dicts, not a dict."""
if isinstance(cp, dict):
_issue(issues, "error",
"custom_providers is a dict — it must be a YAML list (items prefixed with '-')",
"Change to:\n custom_providers:\n - name: my-provider\n base_url: https://...\n"
" api_key: ...")
suspicious = set(cp.keys()) & _CUSTOM_PROVIDER_LIKE_FIELDS
if suspicious:
_issue(issues, "warning",
f"Root-level keys {sorted(suspicious)} look like custom_providers entry fields",
"These should be indented under a '- name: ...' list entry, not at root level")
elif isinstance(cp, list):
_validate_entry_list(cp, "custom_providers", issues, _CP_REQUIRED_FIELDS, non_dict=(
"warning", "custom_providers[{i}] is not a dict (got {type})",
"Each entry should have at minimum: name, base_url"))
def _validate_fallback_model(fb: Any, issues: List[ConfigIssue]) -> None:
"""fallback_model: single dict OR list of dicts (chain)."""
if isinstance(fb, list):
_validate_entry_list(fb, "fallback_model", issues, _FB_REQUIRED_FIELDS, non_dict=(
"error", "fallback_model[{i}] should be a dict, got {type}", "Each entry needs provider + model"))
elif not isinstance(fb, dict):
_issue(issues, "error",
f"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}",
"Change to:\n fallback_model:\n provider: openrouter\n model: anthropic/claude-sonnet-4")
elif fb:
_require_fields(issues, fb, "fallback_model", _FB_SINGLE_REQUIRED_FIELDS,
suffix=" — fallback will be disabled")
def _validate_web_backends(config: Dict[str, Any], issues: List[ConfigIssue]) -> None:
"""A stale web backend selection otherwise fails only at the first web_search/web_extract
call with a generic "no registered provider" error; warn at startup instead."""
# See #99199.
web_cfg = config.get("web")
if not isinstance(web_cfg, dict):
return
try:
from tools.tool_backend_helpers import removed_backend_note
except Exception:
return
seen: set = set()
for _key in ("backend", "search_backend", "extract_backend"):
_val = str(web_cfg.get(_key) or "").strip().lower()
if not _val or _val in seen:
continue
seen.add(_val)
note = removed_backend_note("web", _val)
if note:
_issue(issues, "warning",
f"web.{_key} is set to '{_val}', but {note} — "
"web_search/web_extract will fail until it is changed",
"Run 'hermes tools' and pick a different Web Search & Extract provider")
def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["ConfigIssue"]:
"""Validate config.yaml structure and return detected issues (accepts a pre-loaded dict).
Catches common YAML mistakes that otherwise surface as confusing runtime errors."""
if config is None:
try:
config = load_config()
except Exception as exc:
from hermes_cli.config_home import config_load_issue
return [config_load_issue(exc)]
issues: List[ConfigIssue] = []
_validate_voice(config, issues)
_validate_timezone(config, issues)
cp = config.get("custom_providers")
fb = config.get("fallback_model")
for value, validator in ((cp, _validate_custom_providers), (fb, _validate_fallback_model)):
if value is not None:
validator(value, issues)
if isinstance(cp, dict) and "fallback_model" not in config and "fallback_model" in (cp or {}):
_issue(issues, "error", "fallback_model appears inside custom_providers instead of at root level",
"Move fallback_model to the top level of config.yaml (no indentation)")
if cp and not config.get("model"):
_issue(issues, "warning",
"custom_providers defined but no 'model' section — Hermes won't know which provider to use",
"Add a model section:\n model:\n provider: custom\n default: your-model-name\n"
" base_url: https://...")
# Only provider-like fields are flagged as misplaced roots. Arbitrary unknown top-level keys
# are deliberately NOT warned about: top-level scalars are bridged into os.environ so users
# can feed skills/external apps env-style keys — a closed-world allowlist cannot enumerate those.
for key in config:
if not key.startswith("_") and key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:
_issue(issues, "warning",
f"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?",
f"Move '{key}' under the appropriate section")
_validate_web_backends(config, issues)
return issues
def print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:
"""Print config structure warnings to stderr at startup; nothing if config is healthy."""
try:
issues = validate_config_structure(config)
except Exception:
issues = []
if not issues:
return
lines = ["\033[33m⚠ Config issues detected in config.yaml:\033[0m"]
for ci in issues:
marker = "\033[31m✗\033[0m" if ci.severity == "error" else "\033[33m⚠\033[0m"
lines.append(f" {marker} {ci.message}")
lines.append(" \033[2mRun 'hermes doctor' for fix suggestions.\033[0m")
sys.stderr.write("\n".join(lines) + "\n\n")
def warn_deprecated_cwd_env_vars() -> None:
"""Warn if MESSAGING_CWD / TERMINAL_CWD is set in .env (canonical: terminal.cwd in config.yaml).
Reads the file rather than ``os.environ`` because runtime bridges and session restoration
legitimately set ``TERMINAL_CWD``."""
try:
env_map = load_env()
except Exception:
return
lines: list[str] = []
for name in ("MESSAGING_CWD", "TERMINAL_CWD"):
val = str(env_map.get(name) or "").strip()
if val:
lines.append(f" \033[33m⚠\033[0m {name}={val} found in .env — this is deprecated.")
if lines:
from hermes_constants import display_hermes_home
hint_path = display_hermes_home()
lines.insert(0, "\033[33m⚠ Deprecated .env settings detected:\033[0m")
lines.append(
" \033[2mMove to config.yaml instead: "
"terminal:\\n cwd: /your/project/path\033[0m")
lines.append(f" \033[2mThen remove the old entries from {hint_path}/.env\033[0m")
sys.stderr.write("\n".join(lines) + "\n\n")
def _persist_migration(config: Dict[str, Any]) -> None:
"""Persist a migrated config under THE migration write invariant: a migration may only
persist values that DIFFER from the schema default, plus explicit removals/renames of user
data. Every migration step MUST write through here (``save_config`` with default-stripping
ON, no ``merge_existing``) so the invariant cannot regress one migration at a time."""
save_config(config)
def _prompt_and_save_env(name: str, info: Dict[str, Any], prompt: str, results: Dict[str, Any]) -> bool:
"""Prompt for one env var (masked when ``info['password']``), save it, record it; False if skipped."""
value = masked_secret_prompt(prompt) if info.get("password") else line_input(prompt).strip()
if not value:
return False
save_env_value(name, value)
results["env_added"].append(name)
print(f" ✓ Saved {name}")
return True
def _ask_yes_no(prompt: str) -> bool:
try:
answer = input(prompt).strip().lower()
except (EOFError, KeyboardInterrupt):
answer = "n"
return answer in {"y", "yes"}
def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:
"""Migrate config to latest version, prompting for new required fields."""
results = {"env_added": [], "config_added": [], "warnings": []}
# Validate config.yaml before any migration side effect: sanitize_env_file() rewrites .env,
# which must not happen when the migration will be refused for malformed YAML.
current_ver, latest_ver = check_config_version(raise_on_parse_error=True)
try:
fixes = sanitize_env_file()
if fixes and not quiet:
print(f" ✓ Normalized .env line formatting ({fixes} line(s) changed)")
except Exception:
pass # best-effort; never block migration on sanitize failure
# Auto-migration support floor (v12): an EXPLICIT on-disk ``_config_version`` below the
# floor is NOT migrated and NOT rewritten — surface a message and leave the file untouched
# (deep-merge supplies defaults at read time). A config with NO version key is a fresh
# minimal config, not an ancient install: it gets the normal ladder and a version stamp.
# Missing/unparseable files never trip the floor gate.
# Imported lazily because the steps call back into this module.
from hermes_cli.config_migrations import (
SUPPORT_FLOOR_VERSION, run_migrations, support_floor_message)
try:
has_explicit_version = "_config_version" in read_user_config_raw()
except Exception:
has_explicit_version = False
floor_refused = (
has_explicit_version and current_ver < SUPPORT_FLOOR_VERSION and current_ver < latest_ver)
if floor_refused:
msg = support_floor_message()
results["warnings"].append(msg)
# stderr so it is visible even on quiet startup paths.
sys.stderr.write(f"⚠ hermes config: {msg}\n")
if not quiet:
print(f" ⚠ {msg}")
else:
run_migrations(current_ver, results, quiet)
_disable_suspicious_mcp_servers(results, quiet)
_warn_invalid_platform_toolsets(results, quiet)
if current_ver < latest_ver and not quiet and not floor_refused:
print(f"Config version: {current_ver} → {latest_ver}")
missing_env = get_missing_env_vars(required_only=True)
if missing_env and not quiet:
print("\n⚠️ Missing required environment variables:")
for var in missing_env:
print(f" • {var['name']}: {var['description']}")
if interactive and missing_env:
print("\nLet's configure them now:\n")
for var in missing_env:
if var.get("url"):
print(f" Get your key at: {var['url']}")
if not _prompt_and_save_env(var["name"], var, f" {var['prompt']}: ", results):
results["warnings"].append(f"Skipped {var['name']} - some features may not work")
print()
if interactive and not quiet:
_offer_new_optional_env_vars(current_ver, latest_ver, results)
# New default keys are NOT materialised to disk (load_config() deep-merges DEFAULT_CONFIG at
# read time); this list only feeds the "N new config option(s)" display.
results["config_added"].extend(field["key"] for field in get_missing_config_fields())
if current_ver < latest_ver and not floor_refused:
config = read_raw_config()
config["_config_version"] = latest_ver
_persist_migration(config)
missing_skill_config = get_missing_skill_config_vars()
if missing_skill_config and interactive and not quiet:
_offer_skill_config_vars(missing_skill_config, results)
return results
def _disable_suspicious_mcp_servers(results: Dict[str, Any], quiet: bool) -> None:
"""Post-migration: disable exfiltration-shaped MCP stdio entries (hand-edited or from older
installs). The stanza is preserved for auditability but marked disabled."""
config = read_raw_config()
# Preserve the stanza for auditability but mark it disabled so the next startup will not spawn it.
# (#45620)
raw_mcp_servers = config.get("mcp_servers")
if not isinstance(raw_mcp_servers, dict):
return
try:
from hermes_cli.mcp_security import validate_mcp_server_entry
except Exception:
return
mcp_touched = False
for server_name, entry in raw_mcp_servers.items():
issues = validate_mcp_server_entry(server_name, entry) if isinstance(entry, dict) else None
if not issues:
continue
entry["enabled"] = False
mcp_touched = True
results["warnings"].append(f"Disabled suspicious MCP server '{server_name}'")
if not quiet:
for issue in issues:
print(f" ⚠ {issue}")
print(f" ⚠ Disabled MCP server '{server_name}' pending review")
if mcp_touched:
config["mcp_servers"] = raw_mcp_servers
_persist_migration(config)
def _warn_invalid_platform_toolsets(results: Dict[str, Any], quiet: bool) -> None:
"""Surface invalid toolset names in platform_toolsets: ``resolve_toolset()`` returns [] for an
unknown name, silently disabling the affected tools. Best-effort; never blocks migration."""
try:
from toolsets import validate_toolset
from hermes_cli.toolset_validation import validate_platform_toolsets
from hermes_cli.toolset_scope import toolset_allowed_for_platform
for w in validate_platform_toolsets(
read_raw_config().get("platform_toolsets"), validate_toolset, toolset_allowed_for_platform):
results["warnings"].append(w)
if not quiet:
print(f" ⚠ {w}")
except Exception as _ts_val_err:
logger.debug("platform_toolsets validation skipped: %s", _ts_val_err)
def _offer_list(heading: str, items: List[str], question: str) -> bool:
"""Print a bulleted offer list and ask; False (with the "set later" hint) when declined."""
print(heading)
for item in items:
print(f" • {item}")
print()
if not _ask_yes_no(question):
print(" Set later with: hermes config set <key> <value>")
return False
print()
return True
def _offer_new_optional_env_vars(current_ver: int, latest_ver: int, results: Dict[str, Any]) -> None:
"""Interactively offer env vars that are NEW since the user's previous config version."""
new_var_names: set = set()
for ver in range(current_ver + 1, latest_ver + 1):
new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))
new_and_unset = [
(name, OPTIONAL_ENV_VARS[name])
for name in sorted(new_var_names)
if not get_env_value(name) and name in OPTIONAL_ENV_VARS]
if not new_and_unset or not _offer_list(
f"\n {len(new_and_unset)} new optional key(s) in this update:",
[f"{name} — {info.get('description', '')}" for name, info in new_and_unset],
" Configure new keys? [y/N]: "):
return
for name, info in new_and_unset:
print(f" {info.get('description', name)}")
if info.get("url"):
print(f" Get your key at: {info['url']}")
_prompt_and_save_env(name, info, f" {info.get('prompt', name)} (Enter to skip): ", results)
print()
def _offer_skill_config_vars(missing_skill_config: List[Dict[str, Any]], results: Dict[str, Any]) -> None:
"""Prompt for skill-declared settings that are missing/empty and persist the answers."""
if not _offer_list(
f"\n {len(missing_skill_config)} skill setting(s) not configured:",
[f"{v['key']} — {v['description']} (from skill: {v.get('skill', 'unknown')})" for v in missing_skill_config],
" Configure skill settings? [y/N]: "):
return
config = read_raw_config()
try:
from agent.skill_utils import SKILL_CONFIG_PREFIX
except Exception:
SKILL_CONFIG_PREFIX = "skills.config"
for var in missing_skill_config:
default = var.get("default", "")
default_hint = f" (default: {default})" if default else ""
value = line_input(f" {var['prompt']}{default_hint}: ").strip() or str(default or "")
if value:
_set_nested(config, f"{SKILL_CONFIG_PREFIX}.{var['key']}", value)
results["config_added"].append(var["key"])
print(f" ✓ Saved {var['key']} = {value}")
else:
results["warnings"].append(
f"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later")
print()
_persist_migration(config)
def _merge_partial_save(raw: dict, override: dict) -> dict:
"""Merge *override* over *raw* for partial ``save_config`` writes.
Omitted top-level sections are preserved; shared dict sections deep-merge so one nested key
can change without dropping siblings on disk. Key REMOVALS are not supported here —
migrations go through ``_persist_migration`` with a full ``read_raw_config()`` dict."""
result = copy.deepcopy(override)
for key, value in raw.items():
if key not in result:
result[key] = copy.deepcopy(value)
elif isinstance(result.get(key), dict) and isinstance(value, dict):
result[key] = _deep_merge(value, result[key])
return result
def _deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge *override* into *base*: dict-over-dict recurses (so overriding one leaf
keeps sibling defaults), and ``None`` over a dict section is ignored.
An empty section key in config.yaml (``terminal:`` with no value) parses as YAML ``None``; treating that
as an override would replace the entire default dict with ``None`` and crash every downstream consumer
that expects a mapping (#58277).
"""
result = base.copy()
for key, value in override.items():
over_dict = isinstance(result.get(key), dict)
if over_dict and isinstance(value, dict):
result[key] = _deep_merge(result[key], value)
elif not (over_dict and value is None):
result[key] = value
return result
def _strip_dotted_keys(cfg: dict, dotted_keys: set) -> Tuple[dict, set]:
"""Remove dotted leaf keys from *cfg* in place -> ``(cfg, keys_actually_present)``.
``save_config`` drops managed-scope leaves this way so a bulk write never persists a user
value that would lose to the managed layer on the next load."""
stripped: set = set()
for dotted in dotted_keys:
*parents, leaf = dotted.split(".")
node = cfg_get(cfg, *parents)
if isinstance(node, dict) and leaf in node:
del node[leaf]
stripped.add(dotted)
return cfg, stripped
_ENV_REF_RE = re.compile(r"\${([^}]+)}")
def _env_ref_lookup(name: str) -> Optional[str]:
"""Resolve the env var behind a ``${VAR}`` / ``${env:VAR}`` ref — plain ``os.environ`` outside
a profile secret scope (legacy behavior for the default profile).
Inside a scope (a multiplexed gateway turn, a secondary profile's config load, a cron job) the read goes
through ``agent.secret_scope.get_secret`` so the ref resolves against *that* profile's ``.env``: under
multiplexing a miss is a miss, never another profile's ``os.environ`` value (#84079 — every profile
"had" the default profile's ``${MATRIX_ACCESS_TOKEN}`` and fanned out). Same policy as
``gateway.config._getenv`` and ``get_env_value``.
"""
try:
from agent.secret_scope import current_secret_scope, get_secret as _get_secret
except Exception:
return os.environ.get(name)
if current_secret_scope() is None:
return os.environ.get(name)
return _get_secret(name)
def _env_expand_match(m: re.Match) -> str:
"""Expand one ``${VAR}`` (legacy bare name) or ``${env:VAR}`` (Cursor-style SecretRef).
Other SecretRef sources (``file:``, ``bitwarden:``, ``vault:``...) are NOT resolved here:
external backends inject their values into the environment at startup (the ``secrets:``
block), so a config ref only ever needs the env shape. Unresolved refs stay verbatim so
callers can detect them."""
raw = m.group(0)
inner = m.group(1).strip()
name = _env_ref_var_name(inner)
if name is None:
if not inner.startswith("env:") and _is_non_env_secret_ref(inner):
logger.warning(
"Config ref %r uses source %r which is not resolvable in "
"config.yaml — external secret sources inject env vars at "
"startup, so reference the variable as ${env:NAME} instead",
raw, inner.split(":", 1)[0])
return raw # non-env source, or empty ``${env:}``
val = _env_ref_lookup(name)
if val is not None:
return val
if inner.startswith("env:"):
logger.warning(
"Config ref %r: %s is not set (check ~/.hermes/.env); "
"keeping the literal placeholder", raw, name)
return raw
def _is_non_env_secret_ref(ref: str) -> bool:
"""True for a SecretRef body with a non-``env`` source (``bitwarden:FOO``, ``vault:...``)."""
return ":" in ref and re.match(r"^[a-z][a-z0-9_-]*:", ref) is not None
def _env_ref_var_name(ref: str) -> Optional[str]:
"""Env-var name a ``${...}`` body reads, or None for a non-env source / empty ``env:``."""
ref = ref.strip()
if ref.startswith("env:"):
return ref[len("env:"):].strip() or None
if _is_non_env_secret_ref(ref):
return None
return ref
def _expand_env_vars(obj):
"""Recursively expand ``${VAR}`` / ``${env:VAR}`` in string values (keys/non-strings untouched)."""
if isinstance(obj, str):
return _ENV_REF_RE.sub(_env_expand_match, obj)
if isinstance(obj, dict):
return {k: _expand_env_vars(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_expand_env_vars(item) for item in obj]
return obj
def _env_ref_snapshot(obj, snapshot=None):
"""Map each env-sourced ``${...}`` ref in *obj* to its current value.
Stored with cached ``load_config()`` results so a cache hit can detect that the expansion was
made against a different environment (load before ``load_hermes_dotenv()``, in-process
rotation) — file mtime/size alone cannot see either.
See #58514.
"""
if snapshot is None:
snapshot = {}
if isinstance(obj, str):
for raw in _ENV_REF_RE.findall(obj):
name = _env_ref_var_name(raw)
if name is not None:
snapshot[name] = _env_ref_lookup(name)
elif isinstance(obj, dict):
for value in obj.values():
_env_ref_snapshot(value, snapshot)
elif isinstance(obj, list):
for item in obj:
_env_ref_snapshot(item, snapshot)
return snapshot
def _items_by_unique_name(items):
"""Return a name-indexed dict only when all items have unique string names."""
if not isinstance(items, list):
return None
indexed = {}
for item in items:
if not isinstance(item, dict) or not isinstance(item.get("name"), str):
return None
name = item["name"]
if name in indexed:
return None
indexed[name] = item
return indexed
def _preserve_env_ref_templates(current, raw, loaded_expanded=None):
"""Restore raw ``${VAR}`` templates where the value is otherwise unchanged, so persisting a
loaded (expanded) config never writes the plaintext secret back to ``config.yaml``."""
if isinstance(current, str) and isinstance(raw, str) and _ENV_REF_RE.search(raw):
if current in (raw, loaded_expanded) or _expand_env_vars(raw) == current:
return raw
return current
if isinstance(current, dict) and isinstance(raw, dict):
return {
key: _preserve_env_ref_templates(
value, raw.get(key),
loaded_expanded.get(key) if isinstance(loaded_expanded, dict) else None)
for key, value in current.items()}
if isinstance(current, list) and isinstance(raw, list):
# Match named objects (e.g. custom_providers) by name so reordering keeps templates;
# with duplicate names fall back to positional matching rather than shadowing an entry.
current_by_name = _items_by_unique_name(current)
raw_by_name = _items_by_unique_name(raw)
loaded_by_name = _items_by_unique_name(loaded_expanded)
if current_by_name is not None and raw_by_name is not None:
return [
_preserve_env_ref_templates(
item, raw_by_name.get(item.get("name")),
loaded_by_name.get(item.get("name")) if loaded_by_name is not None else None)
for item in current]
return [
_preserve_env_ref_templates(
item,
raw[index] if index < len(raw) else None,
loaded_expanded[index]
if isinstance(loaded_expanded, list) and index < len(loaded_expanded)
else None)
for index, item in enumerate(current)]
return current
def _explicit_config_paths(config: Dict[str, Any]) -> Set[Tuple[str, ...]]:
"""Leaf paths explicitly present in a RAW (un-normalized) config, so values injected by
normalisation are never mistaken for user-set ones. Feeds ``_strip_default_values``."""
paths: Set[Tuple[str, ...]] = set()
def _walk(value: Any, path: Tuple[str, ...]) -> None:
if isinstance(value, dict):
for key, child in value.items():
_walk(child, path + (key,))
elif path:
paths.add(path)
_walk(config, ())
return paths
def _strip_default_values(
config: Dict[str, Any], defaults: Dict[str, Any] = DEFAULT_CONFIG,
preserve_keys: Optional[Set[Tuple[str, ...]]] = None) -> Dict[str, Any]:
"""Return *config* without keys whose values match *defaults*.
Paths in *preserve_keys* (explicitly present in the user's raw config) are always kept even
when equal to the default. Dicts whose every child is stripped are removed entirely so
default-only subtrees never bloat ``config.yaml``."""
preserve_keys = {("_config_version",)} | set(preserve_keys or ())
def _strip(value: Any, default: Any, path: Tuple[str, ...]) -> Any:
if path in preserve_keys:
return copy.deepcopy(value)
if isinstance(value, dict) and value:
default_dict = default if isinstance(default, dict) else {}
stripped = {k: _strip(v, default_dict.get(k), path + (k,)) for k, v in value.items()}
return {k: v for k, v in stripped.items() if v is not None} or None
return None if value == default else copy.deepcopy(value)
return _strip(config, defaults, ()) or {}
def split_model_config_default(raw_default: Any) -> tuple[str, str]:
"""Canonicalize ``model.default``/``model.model`` -> ``(model, provider)``; a dict value pairs
the model string with the provider it must be routed through."""
if isinstance(raw_default, dict):
provider = str(raw_default.get("provider") or "").strip()
model = raw_default.get("model") or raw_default.get("default")
return (str(model or "").strip(), provider)
return (str(raw_default or "").strip(), "")
def _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:
"""Canonicalize the ``model`` section at the single load/save chokepoint.
Root-level ``provider``/``base_url``/``context_length`` (older layouts) are moved under
``model`` only when the corresponding ``model.*`` key is empty — never overriding. ``api_base``
(the OpenAI-SDK/LiteLLM name users reach for) is an alias for ``base_url``; the runtime reads
only ``model.base_url``. A dict-valued ``default``/``model``/``name`` is flattened so no reader
sees a nested dict, and the id is canonicalized to ``default``.
Also aliases ``api_base`` → ``base_url`` (issue #8919). ``api_base`` is the intuitive name OpenAI-SDK /
LiteLLM users reach for, and ``hermes config set`` blindly accepts any dotted key — so
``model.api_base`` got written, confirmed, and then silently ignored by the runtime resolver (which
reads only ``model.base_url``), causing requests to fall back to OpenRouter. We migrate the alias to the
canonical key (fallback-only — never override an explicit ``base_url``) and drop the alias so it can't
confuse later loads.
Finally, canonicalizes the model-id key to ``model.default`` (issue #34500). The runtime resolver and
~14 other readers select the chat model via ``model.default``; ``model.model`` was already aliased
inline at some sites but ``model.name`` was not, so a custom-provider config like ``model: {name: <id>,
provider: <custom>}`` resolved to an empty model and the API request went out with ``model=`` (HTTP 400
from OpenAI-compatible backends) — while display paths (``hermes status``/``dump``) read ``name`` and
*showed* the model, making the failure silent. Normalizing here (the single load/save chokepoint) means
every reader, present and future, sees a populated ``default`` and the stale alias is migrated out of
config.yaml on the next save. Precedence: ``default`` > ``model`` > ``name`` (never overrides an
explicit ``default``, so existing configs are unaffected).
"""
model_in = config.get("model")
needs_model_work = isinstance(model_in, dict) and (
model_in.get("api_base")
or model_in.get("model") or model_in.get("name")
or any(isinstance(model_in.get(k), dict) for k in ("default", "model", "name")))
has_root = any(config.get(k) for k in ("provider", "base_url", "context_length", "api_base"))
if not has_root and not needs_model_work:
return config
config = dict(config)
model = config.get("model")
model = dict(model) if isinstance(model, dict) else {"default": model} if model else {}
config["model"] = model
# Flatten ``{provider: <p>, model: <m>}``. The nested provider wins over the merged default
# ``"auto"`` (which runtime resolution treats as authoritative) but never over a configured one.
for _key in ("default", "model", "name"):
_val = model.get(_key)
if isinstance(_val, dict):
_nested_model = _val.get("model") or _val.get("default")
_nested_provider = str(_val.get("provider") or "").strip()
model[_key] = str(_nested_model or "").strip()
if _nested_provider:
_outer_provider = str(model.get("provider") or "").strip()
if not _outer_provider or _outer_provider == "auto":
model["provider"] = _nested_provider
for key in ("provider", "base_url", "context_length"):
root_val = config.get(key)
if root_val and not model.get(key):
model[key] = root_val
config.pop(key, None)
for alias_val in (config.get("api_base"), model.get("api_base")):
if alias_val and not model.get("base_url"):
model["base_url"] = alias_val
config.pop("api_base", None)
model.pop("api_base", None)
# ``model``/``name`` are last-resort aliases (in that order), then dropped.
alias = model.get("model") or model.get("name")
if not model.get("default") and alias:
model["default"] = alias
if model.get("default"):
model.pop("model", None)
model.pop("name", None)
return config
def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:
"""Move legacy root-level ``max_turns`` under ``agent``; the schema default is injected only
when the user set max_turns somewhere (so save_config can otherwise omit it)."""
config = dict(config)
agent_config = dict(config.get("agent") or {})
if "max_turns" in config and "max_turns" not in agent_config:
agent_config["max_turns"] = config["max_turns"]
config["agent"] = agent_config
config.pop("max_turns", None)
return config
def _canonicalize_config(config: Dict[str, Any]) -> Dict[str, Any]:
"""The load/save normalization pipeline: max_turns relocation, then model-section canon."""
return _normalize_root_model_keys(_normalize_max_turns_config(config))
# Sentinel for an unlimited turn budget. ``sys.maxsize`` survives the str->int round-trip through
# the HERMES_MAX_ITERATIONS env bridge, works in every ``<``/``>=``/``max - used`` comparison in
# the iteration budget without an "unlimited" special case, and is unreachable in practice.
TURN_LIMIT_UNLIMITED = sys.maxsize
# Spellings that mean "no limit" (compared lowercased, whitespace-stripped).
_UNLIMITED_SPELLINGS = frozenset({
"none", "null", "unlimited", "infinite", "infinity", "inf", "∞", "-1", "0"})
def resolve_turn_limit(raw: Any, default: int = TURN_LIMIT_UNLIMITED) -> int:
"""Normalize a raw ``agent.max_turns`` value into an int iteration cap (always >= 1)."""
# bool is a subclass of int; reject explicitly so True/False don't become 1/0.
if raw is None or isinstance(raw, bool):
return default
if isinstance(raw, (int, float)):
n = int(raw)
elif isinstance(raw, str):
s = raw.strip().lower()
if not s:
return default
if s in _UNLIMITED_SPELLINGS:
return TURN_LIMIT_UNLIMITED
try:
n = int(s)
except ValueError:
try:
n = int(float(s))
except ValueError:
logger.debug("resolve_turn_limit: unparseable value %r → default %d", raw, default)
return default
else:
# Unknown type (list, dict, …) — don't crash the agent over a bad config.
logger.debug("resolve_turn_limit: unsupported type %s (%r) → default %d", type(raw).__name__, raw, default)
return default
return TURN_LIMIT_UNLIMITED if n <= 0 else n
def cfg_get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> Any:
"""Traverse nested dict keys safely, returning ``default`` on any miss.
Explicit ``None`` values are returned as-is (``dict.get`` semantics: ``default`` only when the
key is absent). Named ``cfg_get`` to avoid shadowing the ubiquitous ``cfg_path`` local."""
if not isinstance(cfg, dict):
return default
node: Any = cfg
for key in keys:
if not isinstance(node, dict) or key not in node:
return default
node = node[key]
return node
def _read_raw_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
with _CONFIG_LOCK:
try:
config_path = get_config_path()
st = config_path.stat()
cache_key = file_signature(st)
except (FileNotFoundError, OSError):
return {}
path_key = str(config_path)
cached = _RAW_CONFIG_CACHE.get(path_key)
if cached is not None and cached[:len(cache_key)] == cache_key:
return copy.deepcopy(cached[len(cache_key)]) if want_deepcopy else cached[len(cache_key)]
try:
with open(config_path, encoding="utf-8") as f:
data = fast_safe_load(f) or {}
except Exception as e:
_warn_config_parse_failure(config_path, e)
return {}
if not isinstance(data, dict):
data = {}
# The cache stores its own deepcopy. The readonly path returns THAT object (identity
# invariant: later cache hits return the same dict); the mutable path returns the parse.
cached_copy = copy.deepcopy(data)
_RAW_CONFIG_CACHE[path_key] = (*cache_key, cached_copy)
return data if want_deepcopy else cached_copy
def read_raw_config() -> Dict[str, Any]:
"""Read config.yaml as-is (no defaults merged, no migration); ``{}`` if missing/unparseable.
Cached on the file signature (mtime_ns, size, ino, ctime_ns); returns a deepcopy since callers mutate before ``save_config()``."""
return _read_raw_config_impl(want_deepcopy=True)
def read_user_config_raw(config_path: Optional[Path] = None) -> Dict[str, Any]:
"""Read a user ``config.yaml`` EXACTLY as written (no defaults/overlay/expansion, no cache).
ONLY legal for write-back round-trips and raw-file diagnostics — behavioral reads must use
load_config()/load_config_readonly()."""
if config_path is None:
config_path = get_config_path()
try:
with open(config_path, encoding="utf-8") as f:
data = fast_safe_load(f) or {}
except FileNotFoundError:
return {}
return data if isinstance(data, dict) else {}
def read_raw_config_readonly() -> Dict[str, Any]:
"""``read_raw_config()`` without the per-call deepcopy, for callers that ONLY READ.
**Mutating the result corrupts the in-process cache for every subsequent caller.** Meant for
per-turn policy checks that were paying a full config deepcopy 2-3x per agent turn."""
return _read_raw_config_impl(want_deepcopy=False)
def _refuse_overwrite(config_path: Path, reason: str, exc: Exception, fix: str) -> RuntimeError:
"""Error for a write that must not replace an existing config.yaml. Plain lead + ``Details:``."""
where = _yaml_error_location(exc)
at = f" ({where})" if where else ""
return RuntimeError(
f"Your settings file ({config_path}) {reason}{at}, so this change was not saved. {fix} "
f"Details: {_yaml_error_details(exc)}")
def _backups_dir_display() -> str:
from hermes_constants import display_hermes_home
return f"{display_hermes_home()}/backups/config/"
_FIX_PERMS = "Fix the file permissions or move it aside first."
_FIX_YAML = (
"Fix it with `hermes config edit` and check with `hermes config check`, or copy the newest good "
"file from {backups} over config.yaml.")
def require_readable_config_before_write(config_path: Optional[Path] = None) -> Dict[str, Any]:
"""Refuse to replace an existing config.yaml that cannot be read or parsed; return the mapping.
Guards two collapse-to-empty failure modes that would let a read-then-write caller silently
wipe user overrides: an unreadable file (permissions / broken mount) and an unparseable or
non-mapping root — bare-``except`` loaders treat both as ``{}``, so a subsequent write would
replace the recoverable file with only the caller's partial dict. Fails closed."""
if config_path is None:
config_path = get_config_path()
try:
config_path.stat()
except FileNotFoundError:
return {}
except OSError as exc:
raise _refuse_overwrite(config_path, "cannot be accessed", exc, _FIX_PERMS) from exc
try:
with open(config_path, encoding="utf-8") as f:
loaded = fast_safe_load(f)
except OSError as exc:
raise _refuse_overwrite(config_path, "cannot be read", exc, _FIX_PERMS) from exc
except Exception as exc:
_warn_config_parse_failure(config_path, exc, fallback="refuse-write")
raise _refuse_overwrite(
config_path, "has a formatting error", exc, _FIX_YAML.format(backups=_backups_dir_display())) from exc
if loaded is None:
return {}
if not isinstance(loaded, dict):
exc = TypeError(f"top-level YAML must be a mapping, got {type(loaded).__name__}")
_warn_config_parse_failure(config_path, exc, fallback="refuse-write")
raise _refuse_overwrite(
config_path, f"must start with settings names, but its top level is a {type(loaded).__name__}",
exc, _FIX_YAML.format(backups=_backups_dir_display())) from exc
return loaded
def atomic_config_write(config_path: Path, data: Any, **kwargs: Any) -> None:
"""Fail-closed atomic write for ``config.yaml`` (``require_readable_config_before_write`` first)."""
require_readable_config_before_write(config_path)
atomic_yaml_write(config_path, data, **kwargs)
def load_config() -> Dict[str, Any]:
"""Load the merged configuration (DEFAULT_CONFIG + config.yaml + managed scope, env-expanded).
Cached on the file signature; returns a deepcopy since most call sites mutate the result.
Read-only hot paths should use ``load_config_readonly()`` to skip the deepcopy."""
return _load_config_impl(want_deepcopy=True)
def load_config_readonly() -> Dict[str, Any]:
"""``load_config()`` without the defensive deepcopy (~half of the 265us cache-hit cost).
**Mutating the returned dict (or any nested structure) corrupts the in-process cache for
every subsequent caller** — only for code paths that never write to the result."""
return _load_config_impl(want_deepcopy=False)
def _ensure_dict(parent: Dict[str, Any], key: str) -> Dict[str, Any]:
"""Return ``parent[key]`` as a dict, replacing a missing or non-dict value with ``{}``."""
child = parent.get(key)
if not isinstance(child, dict):
child = {}
parent[key] = child
return child
def write_platform_config_field(
platform_key: str, field_key: str, value: Any, *, raw: bool = False) -> None:
"""Persist one scalar field under ``platforms.<platform_key>``.
``raw=True`` (CLI setup flows) edits only the user's raw file; dashboard routes use the
default loaded-config path to keep their profile-scoped ``load_config`` behavior."""
config = read_raw_config() if raw else load_config()
platforms = _ensure_dict(config, "platforms")
_ensure_dict(platforms, platform_key)[field_key] = value
save_config(config)
# ``terminal.<key>`` -> env var read by tools.terminal_tool. Every key maps to ``TERMINAL_<KEY>``
# except ``backend`` (historically ``TERMINAL_ENV``).
TERMINAL_CONFIG_ENV_MAP = {
"backend": "TERMINAL_ENV",
**{
key: f"TERMINAL_{key.upper()}"
for key in (
"modal_mode", "degraded_mode", "cwd", "temp_dir", "timeout", "lifetime_seconds",
"docker_image", "docker_forward_env", "singularity_image", "modal_image",
"daytona_image", "vercel_runtime", "ssh_host", "ssh_user", "ssh_port", "ssh_key",
"container_cpu", "container_memory", "container_disk", "container_persistent",
"docker_volumes", "docker_env", "docker_mount_cwd_to_workspace", "docker_network",
"docker_extra_args", "docker_shm_size", "docker_run_as_host_user", "docker_snap_compat",
"docker_persist_across_processes", "docker_shared_container_key",
"docker_orphan_reaper", "sandbox_dir", "persistent_shell")}}
def _terminal_env_value(value: Any) -> str:
return json.dumps(value) if isinstance(value, (list, dict)) else str(value)
def _terminal_config_value_is_bridgeable(key: str, value: Any) -> bool:
"""Return whether a terminal config value owns its mirrored env var."""
return not (key == "cwd" and str(value or "").strip() in {".", "auto", "cwd"})
def terminal_config_owned_env_vars(terminal_config: Any) -> Set[str]:
"""Return env vars explicitly owned by a raw ``terminal`` config section."""
if not isinstance(terminal_config, dict):
return set()
return {
env_var
for key, env_var in TERMINAL_CONFIG_ENV_MAP.items()
if key in terminal_config
and _terminal_config_value_is_bridgeable(key, terminal_config[key])}
def terminal_config_env_var_for_key(key: str) -> Optional[str]:
"""Return the env var mirrored by a ``terminal.*`` config key."""
return TERMINAL_CONFIG_ENV_MAP.get(key[len("terminal."):]) if key.startswith("terminal.") else None
def _is_ssh_remote_tilde_cwd(backend: str, cwd: str) -> bool:
"""Whether the remote SSH shell must expand *cwd* itself: ``~`` expanded on the Hermes host
would name the host/container home instead of the SSH user's."""
return (backend or "").strip().lower() == "ssh" and (cwd == "~" or cwd.startswith("~/"))
def apply_terminal_config_to_env(
*, env: Optional[Dict[str, str]] = None, config: Optional[Dict[str, Any]] = None,
override: Optional[bool] = None) -> Dict[str, str]:
"""Bridge ``terminal.*`` config into the env vars terminal tools read.
``tools.terminal_tool`` is environment-driven because it also runs in child processes (TUI,
dashboard PTY, gateway workers); this gives those launch paths the same bridge as the CLI
without importing ``cli.py``. Explicit keys in the user's raw ``terminal`` section override
matching env values; merged defaults only backfill missing env vars."""
target = os.environ if env is None else env
raw_terminal_cfg = read_raw_config().get("terminal")
file_has_terminal_config = isinstance(raw_terminal_cfg, dict)
raw_terminal_cfg = raw_terminal_cfg if file_has_terminal_config else {}
should_override = file_has_terminal_config if override is None else override
cfg = config if config is not None else load_config_readonly()
terminal_cfg = cfg.get("terminal", {}) if isinstance(cfg, dict) else {}
if not isinstance(terminal_cfg, dict):
return target
# A caller-supplied config is its own source of explicit keys; otherwise only keys present
# in raw config.yaml may override existing env values (DEFAULT_CONFIG keys are backfill-only).
explicit_keys = terminal_cfg.keys() if config is not None else raw_terminal_cfg.keys()
backend_sources = (terminal_cfg.get("backend"), target.get("TERMINAL_ENV"))
if not (config is not None or "backend" in raw_terminal_cfg):
backend_sources = backend_sources[::-1] # env wins when the file did not set backend
terminal_backend = str(backend_sources[0] or backend_sources[1] or "")
for cfg_key, env_var in TERMINAL_CONFIG_ENV_MAP.items():
if cfg_key not in terminal_cfg:
continue
value = terminal_cfg[cfg_key]
if not _terminal_config_value_is_bridgeable(cfg_key, value):
continue
if cfg_key == "cwd":
raw_cwd = str(value or "").strip()
if isinstance(value, str) and not _is_ssh_remote_tilde_cwd(terminal_backend, raw_cwd):
value = os.path.expanduser(value)
if (should_override and cfg_key in explicit_keys) or env_var not in target:
target[env_var] = _terminal_env_value(value)
return target
def _load_config_cache_sig(config_path: Path) -> Tuple[Optional[Tuple[int, int, int, int]], Optional[Tuple[int, ...]]]:
"""Return ``(user_sig, cache_sig)`` for ``_LOAD_CONFIG_CACHE``.
The managed config file's signature is folded in ((0, 0, 0, 0) = none) so editing it invalidates
the merged result. ``cache_sig`` is None only when neither file exists (nothing to cache on)."""
try:
st = config_path.stat()
user_sig: Optional[Tuple[int, int, int, int]] = file_signature(st)
except FileNotFoundError:
user_sig = None
managed_dir = managed_scope.get_managed_dir()
try:
mst = (managed_dir / "config.yaml").stat() if managed_dir else None
managed_sig = file_signature(mst) if mst else (0, 0, 0, 0)
except OSError:
managed_sig = (0, 0, 0, 0)
if user_sig is None and managed_sig == (0, 0, 0, 0):
return None, None
return user_sig, (*(user_sig or (0, 0, 0, 0)), *managed_sig)
def _last_known_good_fallback(config_path: Path, path_key: str, cache_sig, exc: Exception) -> Optional[Dict[str, Any]]:
"""Warn about a parse failure and return the last-known-good config, or None (-> defaults).
A parse failure must not silently replace the effective config with defaults — that drops
EVERY user override, including security-critical ``approvals.deny`` rules, when a gateway
user mid-edits config.yaml into broken YAML. Keep serving the last good config until fixed."""
# Falling through to DEFAULT_CONFIG here drops EVERY user override — including security-critical
# ``approvals.deny`` rules, which are supposed to block commands even under yolo. Within a running
# process we still have the last successfully loaded config — keep serving it until the file is fixed.
# See #31188.
lkg = _LAST_EXPANDED_CONFIG_BY_PATH.get(path_key)
fallback = "last-known-good"
if lkg is None:
# Fresh process (CLI restart, `hermes config get`): nothing loaded yet in this process, so
# fall back to the newest byte-exact copy the last successful parse left in backups/config/.
# It holds the raw file (``${VAR}`` templates intact), so it goes through the same
# canonicalize -> expand -> managed-overlay pipeline as a normal load.
from hermes_cli.config_backups import load_newest_good_backup
raw_good = load_newest_good_backup(config_path)
if raw_good is not None:
normalized = _canonicalize_config(_deep_merge(copy.deepcopy(DEFAULT_CONFIG), raw_good))
expanded_good: Dict[str, Any] = _expand_env_vars(normalized) # type: ignore[assignment]
lkg, _ = _merge_managed_overlay(expanded_good)
fallback = "last-known-good-backup"
_warn_config_parse_failure(
config_path, exc, fallback=fallback if lkg is not None else "defaults")
if lkg is None:
return None
# save_config() stores the pre-expansion dict (templates preserved); the load path stores the
# expanded one. Expand defensively — idempotent when already expanded.
lkg_copy: Dict[str, Any] = _expand_env_vars(copy.deepcopy(lkg))
if cache_sig is not None:
# Cache under the corrupt file's signature (empty env snapshot: always valid) so repeated
# loads don't re-parse; fixing the file changes the signature and reloads normally.
_LOAD_CONFIG_CACHE[path_key] = (*cache_sig, lkg_copy, {})
return lkg_copy
def _merge_managed_overlay(expanded: Dict[str, Any]) -> Tuple[Dict[str, Any], Any]:
"""Apply the managed-scope overlay; returns ``(merged, managed_config_or_falsy)``.
Managed wins at the leaf and is applied AFTER user expansion so a user ``${VAR}`` cannot shadow
a managed literal: managed values expand only against the process environment. This
deliberately inverts the usual env-over-config precedence for the keys the managed layer pins
(docs/design/managed-scope.md §4.1)."""
managed_config = managed_scope.load_managed_config()
if not managed_config:
return expanded, managed_config
# Same canonicalization as the user config BEFORE merging (parity with
# managed_scope.apply_managed_overlay) so the merged result never exposes a nested dict.
managed_normalized = _normalize_root_model_keys(managed_config)
if isinstance(managed_normalized.get("model"), str):
managed_normalized = dict(managed_normalized)
managed_normalized["model"] = {"default": managed_normalized["model"]}
return _deep_merge(expanded, _expand_env_vars(managed_normalized)), managed_config
def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
with _CONFIG_LOCK:
ensure_hermes_home()
config_path = get_config_path()
path_key = str(config_path)
user_sig, cache_sig = _load_config_cache_sig(config_path)
cached = _LOAD_CONFIG_CACHE.get(path_key)
if cached is not None and cache_sig is not None and cached[:8] == cache_sig:
# Signatures match, but the cached expansion is only valid if every ${VAR} it was
# expanded against still has the same value — otherwise a load before
# load_hermes_dotenv() pins unexpanded literals for the process lifetime.
# Without this, a load_config() that ran before load_hermes_dotenv() pins unexpanded literals
# (e.g. auxiliary.<task>.api_key) for the life of the process (#58514).
env_snapshot = cached[9] if len(cached) > 9 else {}
if all(_env_ref_lookup(k) == v for k, v in env_snapshot.items()):
return copy.deepcopy(cached[8]) if want_deepcopy else cached[8]
config = copy.deepcopy(DEFAULT_CONFIG)
if user_sig is not None:
try:
with open(config_path, encoding="utf-8") as f:
user_config = fast_safe_load(f) or {}
if "max_turns" in user_config:
agent_user_config = dict(user_config.get("agent") or {})
if agent_user_config.get("max_turns") is None:
agent_user_config["max_turns"] = user_config["max_turns"]
user_config["agent"] = agent_user_config
user_config.pop("max_turns", None)
config = _deep_merge(config, user_config)
# A copy of the file that just parsed is what a FRESH process falls back to when the
# next edit breaks the YAML (see _last_known_good_fallback). backup_config() skips
# byte-identical repeats and keeps a bounded count, so steady-state loads cost one stat.
from hermes_cli.config_backups import backup_config
backup_config(config_path, "good")
except Exception as e:
lkg_copy = _last_known_good_fallback(config_path, path_key, cache_sig, e)
if lkg_copy is not None:
return copy.deepcopy(lkg_copy) if want_deepcopy else lkg_copy
normalized = _canonicalize_config(config)
expanded, managed_config = _merge_managed_overlay(_expand_env_vars(normalized))
_LAST_EXPANDED_CONFIG_BY_PATH[path_key] = copy.deepcopy(expanded)
if cache_sig is not None:
# The cache stores its own deepcopy so load_config() callers can mutate freely while
# load_config_readonly() callers all see the same stable object. The env snapshot
# records the values this expansion was made against so later loads detect drift.
cached_copy = copy.deepcopy(expanded)
env_snapshot = _env_ref_snapshot(normalized)
if managed_config:
_env_ref_snapshot(managed_config, env_snapshot)
_LOAD_CONFIG_CACHE[path_key] = (*cache_sig, cached_copy, env_snapshot)
# Readonly path returns the same object later calls will see (identity invariant).
if not want_deepcopy:
return cached_copy
else:
_LOAD_CONFIG_CACHE.pop(path_key, None)
# First-load result is a fresh dict (not aliased to the cache); safe to return directly.
return expanded
_SECURITY_COMMENT = """
# ── Security ──────────────────────────────────────────────────────────
# Secret redaction is ON by default — strings that look like API keys,
# tokens, and passwords are masked in tool output, logs, and chat
# responses before the model or user ever sees them. Set redact_secrets
# to false to disable (e.g. when developing the redactor itself).
# tirith pre-exec scanning is enabled by default when the tirith binary
# is available. Configure via security.tirith_* keys or env vars
# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).
#
# security:
# redact_secrets: true
# tirith_enabled: true
# tirith_path: "tirith"
# tirith_timeout: 5
# tirith_fail_open: true
"""
_FALLBACK_COMMENT = """
# ── Fallback Model ────────────────────────────────────────────────────
# Automatic provider failover when primary is unavailable.
# Uncomment and configure to enable. Triggers on rate limits (429),
# overload (529), service errors (503), or connection failures.
#
# Supported providers:
# openrouter (OPENROUTER_API_KEY) — routes to any model
# openai-codex (OAuth — hermes auth) — OpenAI Codex
# nous (OAuth — hermes auth) — Nous Portal
# zai (ZAI_API_KEY) — Z.AI / GLM
# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot
# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)
# minimax (MINIMAX_API_KEY) — MiniMax
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
# bedrock (AWS IAM / boto3) — AWS Bedrock (Converse API)
#
# For custom OpenAI-compatible endpoints, add base_url and key_env.
#
# fallback_model:
# provider: openrouter
# model: anthropic/claude-sonnet-4
"""
def _strip_managed_keys_for_save(config: Dict[str, Any]) -> Dict[str, Any]:
"""Drop every leaf the managed layer pins (bulk safety net; single-key ``config set``
hard-rejects) and tell the user what was not saved."""
managed_keys = managed_scope.managed_config_keys()
if not managed_keys:
return config
config, _stripped = _strip_dotted_keys(copy.deepcopy(config), managed_keys)
if _stripped:
print(
f"Note: {len(_stripped)} managed setting(s) were not saved "
f"(managed by your administrator): {', '.join(sorted(_stripped))}", file=sys.stderr)
return config
def _commented_sections_for_save(normalized: Dict[str, Any]) -> Optional[str]:
"""Commented-out example blocks for features that are off/unconfigured."""
parts = []
if (normalized.get("security") or {}).get("redact_secrets") is None:
parts.append(_SECURITY_COMMENT)
fb = normalized.get("fallback_model", {})
fb_entries = fb if isinstance(fb, list) else [fb]
if not any(isinstance(e, dict) and e.get("provider") and e.get("model") for e in fb_entries):
parts.append(_FALLBACK_COMMENT)
return "".join(parts) or None
def save_config(
config: Dict[str, Any], *, strip_defaults: bool = True,
preserve_keys: Optional[Set[Tuple[str, ...]]] = None, merge_existing: bool = False):
"""Save configuration to ~/.hermes/config.yaml.
Schema defaults are not written unless the user explicitly set them (the path exists in the
raw config before normalisation), so config.yaml is never contaminated with defaults that
would hide future default changes. ``merge_existing`` deep-merges the on-disk raw config
under *config* so partial callers cannot drop sections they omitted."""
with _CONFIG_LOCK:
if is_managed():
managed_error("save configuration")
return
config = _strip_managed_keys_for_save(config)
ensure_hermes_home()
config_path = get_config_path()
require_readable_config_before_write(config_path)
# Explicit user paths come from the RAW dict BEFORE normalisation (which may inject
# agent.max_turns) so _strip_default_values keeps exactly what the user set.
_raw_for_paths = read_raw_config()
if merge_existing and _raw_for_paths:
config = _merge_partial_save(_raw_for_paths, config)
current_normalized = _canonicalize_config(config)
normalized = current_normalized
if _raw_for_paths:
normalized = _preserve_env_ref_templates(
normalized, _canonicalize_config(_raw_for_paths),
_LAST_EXPANDED_CONFIG_BY_PATH.get(str(config_path)))
if strip_defaults:
# ``_strip_default_values`` always preserves ``_config_version`` itself.
effective_preserve_keys = _explicit_config_paths(_raw_for_paths) | set(preserve_keys or ())
normalized = _strip_default_values(normalized, DEFAULT_CONFIG, preserve_keys=effective_preserve_keys)
atomic_yaml_write(config_path, normalized, extra_content=_commented_sections_for_save(normalized))
_secure_file(config_path)
_RAW_CONFIG_CACHE.pop(str(config_path), None)
_LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized)
# load_env() memo keyed on (path, *file_signature). Editing .env bumps mtime/inode -> rebuild;
# invalidate_env_cache() is the explicit knob for writers on coarse-mtime filesystems.
_env_cache: Optional[Tuple[Tuple[str, Optional[Tuple[int, int, int, int]]], Dict[str, str]]] = None
def load_env() -> Dict[str, str]:
"""Load ~/.hermes/.env as a dict (memoised; ``get_env_value()`` runs hundreds of times per
interactive menu render). Each assignment's value is opaque data for boundary discovery."""
global _env_cache
env_path = get_env_path()
try:
st = env_path.stat()
cache_key = (str(env_path), file_signature(st))
except FileNotFoundError:
cache_key = (str(env_path), None)
except Exception:
cache_key = None
if cache_key is not None and _env_cache is not None and _env_cache[0] == cache_key:
return dict(_env_cache[1])
from agent.secret_scope import load_env_file # the one .env tokenizer; also installs profile scopes
env_vars = load_env_file(env_path)
if cache_key is not None:
_env_cache = (cache_key, dict(env_vars))
return env_vars
def invalidate_env_cache() -> None:
"""Clear the load_env() memo so the next call sees a write even on coarse-mtime filesystems."""
global _env_cache
_env_cache = None
def _sanitize_env_lines(lines: list) -> list:
"""Normalize .env line endings/whitespace without changing assignment semantics.
Content after the first ``=`` is opaque value data: a known variable name embedded in a value
must never be reinterpreted as another assignment, so concatenated lines stay on one line."""
sanitized: list[str] = []
for line in lines:
raw = line.rstrip("\r\n")
stripped = raw.strip()
# Blank lines and comments are preserved verbatim.
sanitized.append((raw if not stripped or stripped.startswith("#") else stripped) + "\n")
return sanitized
def sanitize_env_file() -> int:
"""Rewrite ~/.hermes/.env with normalized line formatting; returns the number of changed lines."""
env_path = get_env_path()
if not env_path.exists():
return 0
with open(env_path, encoding="utf-8-sig", errors="replace") as f:
original_lines = f.readlines()
sanitized = _sanitize_env_lines(original_lines)
if sanitized == original_lines:
return 0
fixes = abs(len(sanitized) - len(original_lines)) or sum(
1 for a, b in zip(original_lines, sanitized) if a != b)
_write_env_lines(env_path, sanitized, preserve_mode=False)
invalidate_env_cache()
return fixes
def _read_env_lines(env_path: Path) -> list:
"""Read ``.env`` lines, normalized. Explicit UTF-8 (Windows defaults to cp1252) with BOM
tolerance (Notepad adds one)."""
with open(env_path, encoding="utf-8-sig", errors="replace") as f:
return _sanitize_env_lines(f.readlines())
def _write_env_lines(env_path: Path, lines: list, *, preserve_mode: bool) -> None:
"""Atomically replace ``.env`` (tmp file + fsync + rename).
``preserve_mode`` keeps the original file mode (e.g. 0640 for Docker volume mounts) instead of
letting ``_secure_file`` tighten to 0600; a new file is always secured."""
original_mode = None
try:
original_mode = stat.S_IMODE(env_path.stat().st_mode) if preserve_mode else None
except OSError:
pass
fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix=".tmp", prefix=".env_")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.writelines(lines)
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, env_path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
if original_mode is not None:
try:
os.chmod(env_path, original_mode)
except OSError:
pass
else:
_secure_file(env_path)
def _check_non_ascii_credential(key: str, value: str) -> str:
"""Strip non-ASCII characters from a credential (HTTP header values must be ASCII) and warn.
Lookalike glyphs typically come from copy-pasting out of a PDF or rich-text editor."""
if value.isascii():
return value
bad_chars = [f" position {i}: {ch!r} (U+{ord(ch):04X})" for i, ch in enumerate(value) if ord(ch) > 127]
sanitized = value.encode("ascii", errors="ignore").decode("ascii")
print(
f"\n Warning: {key} contains non-ASCII characters that will break API requests.\n"
f" This usually happens when copy-pasting from a PDF, rich-text editor,\n"
f" or web page that substitutes lookalike Unicode glyphs for ASCII letters.\n\n"
+ "\n".join(f" {line}" for line in bad_chars[:5])
+ ("\n ... and more" if len(bad_chars) > 5 else "")
+ "\n\n The non-ASCII characters have been stripped automatically.\n"
" If authentication fails, re-copy the key from the provider's dashboard.\n",
file=sys.stderr)
return sanitized
def _quote_env_value(value: str) -> str:
"""Quote .env values containing characters with special dotenv meaning. Any whitespace
(including internal runs) is quoted so ``set -a; . file`` word-splitting keeps paths intact."""
if value == "":
return value
if not ("#" in value or '"' in value or "'" in value or any(c.isspace() for c in value)):
return value
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def _env_line_defines_key(line: str, key: str, *, is_windows: Optional[bool] = None) -> bool:
"""True when a .env line assigns ``key`` — plain, ``export``-prefixed, or ``KEY = value``.
Must match exactly the shapes ``load_env()`` parses; otherwise a hand-added line is invisible
to save (duplicate appended) and remove (line survives -> the value resurrects on next load).
``load_env()`` accepts the bash-compatible ``export KEY=value`` form (#6659), so the writers must
recognise the same shape.
"""
stripped = line.strip()
if stripped.startswith("export "):
stripped = stripped[7:].lstrip()
assigned_key, separator, _value = stripped.partition("=")
if not separator:
return False
# load_env() strips whitespace around the parsed name, so `KEY = value` IS a live assignment. The
# writers must match the same shape, or a hand-edited spaced line is invisible to save (duplicate
# appended) and remove (line survives -> value resurrects on next load). #67488.
return _env_var_policy_name(
assigned_key.strip(), is_windows=is_windows
) == _env_var_policy_name(key, is_windows=is_windows)
def _publish_env_value(key: str, value: Optional[str]) -> None:
"""Publish a just-persisted ``.env`` change to the live process.
Under a multiplexed gateway a routed profile's write must not land in the SHARED
``os.environ`` where every profile sees it; the installed scope mapping is updated instead so
same-turn reads see the change. All other callers keep the legacy ``os.environ`` publish.
``save_env_value`` / ``remove_env_value`` already target the right file (``get_env_path()`` honors the
profile-home override), but the in-process mirror historically went straight to ``os.environ``. See
#77490, #88441.
"""
try:
from agent.secret_scope import current_secret_scope, serves_routed_profile
scope, routed = current_secret_scope(), serves_routed_profile()
except Exception:
scope, routed = None, False
# The launch profile's own body runs under a scope snapshot even single-profile (the TUI /
# dashboard launch scope), so a same-request read after the write must see it there too; a
# routed profile's value never reaches the shared process env.
targets = [scope] if isinstance(scope, dict) else []
if not routed and (scope is None or isinstance(scope, dict)):
targets.append(os.environ)
for target in targets:
if value is None:
target.pop(key, None)
else:
target[key] = value
def _env_write_blocked(key: str, action: str) -> bool:
"""Shared write-lock check for ``.env`` writers; prints the refusal and returns True when blocked.
Two distinct locks: ``is_managed()`` (package-manager install) and the managed *scope*
(administrator-pinned env key — the managed .env wins at load anyway)."""
if is_managed():
managed_error(f"{action} {key}")
return True
if managed_scope.is_env_managed(key):
print(
f"Cannot {action} {key}: it is managed by your administrator ({_managed_source('.env')}) "
f"and cannot be changed.", file=sys.stderr)
return True
return False
def _managed_source(filename: str):
"""``<managed dir>/<filename>`` for refusal messages, or a generic label without a managed dir."""
managed_dir = managed_scope.get_managed_dir()
return (managed_dir / filename) if managed_dir else "the managed scope"
def save_env_value(key: str, value: str):
"""Save or update a value in ~/.hermes/.env (also matching ``export KEY=`` lines, so a save
never appends a second line that a later delete would resurrect)."""
if _env_write_blocked(key, "set"):
return
validate_env_var_name_for_write(key)
value = value.replace("\n", "").replace("\r", "")
value = _check_non_ascii_credential(key, value)
ensure_hermes_home()
env_path = get_env_path()
lines = _read_env_lines(env_path) if env_path.exists() else []
serialized_value = _quote_env_value(value)
idx = next((i for i, line in enumerate(lines) if _env_line_defines_key(line, key)), None)
if idx is not None:
lines[idx] = f"{key}={serialized_value}\n"
else:
if lines and not lines[-1].endswith("\n"):
lines[-1] += "\n"
lines.append(f"{key}={serialized_value}\n")
_write_env_lines(env_path, lines, preserve_mode=env_path.exists())
_publish_env_value(key, value)
invalidate_env_cache()
def custom_endpoint_key_env(identity: str) -> str:
"""Env var name holding a custom endpoint's API key.
``identity`` is the endpoint's own id (Desktop endpoint id, or ``host:port`` for CLI setup),
so two endpoints on one host get separate slots. The fixed ``HERMES_CUSTOM_`` prefix keeps the
name POSIX-valid when the slug starts with a digit (``save_env_value`` rejects those)."""
slug = re.sub(r"[^A-Z0-9]+", "_", str(identity or "").upper()).strip("_")
return f"HERMES_CUSTOM_{slug}_API_KEY" if slug else "HERMES_CUSTOM_API_KEY"
def remove_env_value(key: str) -> bool:
"""Remove a key from ~/.hermes/.env and os.environ; True if it was found and removed."""
if _env_write_blocked(key, "remove"):
return False
if not _ENV_VAR_NAME_RE.match(key):
raise ValueError(f"Invalid environment variable name: {key!r}")
env_path = get_env_path()
if not env_path.exists():
_publish_env_value(key, None)
return False
lines = _read_env_lines(env_path)
new_lines = [line for line in lines if not _env_line_defines_key(line, key)]
found = len(new_lines) < len(lines)
if found:
_write_env_lines(env_path, new_lines, preserve_mode=True)
_publish_env_value(key, None)
invalidate_env_cache()
return found
def _write_anthropic_slots(token: str, api_key: str, save_fn=None, *, token_first: bool = True):
"""Write both Anthropic credential slots (one holds the value, the other is cleared)."""
writer = save_fn or save_env_value
order = (("ANTHROPIC_TOKEN", token), ("ANTHROPIC_API_KEY", api_key))
for name, value in order if token_first else reversed(order):
writer(name, value)
def save_anthropic_oauth_token(value: str, save_fn=None):
"""Persist an Anthropic OAuth/setup token and clear the API-key slot."""
_write_anthropic_slots(value, "", save_fn)
def use_anthropic_claude_code_credentials(save_fn=None):
"""Use Claude Code's own credential files instead of persisting env tokens."""
_write_anthropic_slots("", "", save_fn)
def save_anthropic_api_key(value: str, save_fn=None):
"""Persist an Anthropic API key and clear the OAuth/setup-token slot."""
_write_anthropic_slots("", value, save_fn, token_first=False)
def save_env_value_secure(key: str, value: str) -> Dict[str, Any]:
"""Save via the unified credential lifecycle (also refreshes any config.yaml mirror of the old
value and lifts a prior env-source suppression)."""
from hermes_cli.credential_lifecycle import save_provider_env_credential
# Route through the unified credential lifecycle so a rotation via the secret-capture path also
# refreshes any config.yaml mirror of the old value and lifts a prior env-source suppression (#62269 fix
# family).
save_provider_env_credential(key, value)
return {"success": True, "stored_as": key, "validated": False}
def reload_env() -> int:
"""Re-read ~/.hermes/.env into os.environ; returns count of vars changed.
Removes deleted vars only when known to Hermes (OPTIONAL_ENV_VARS and _EXTRA_ENV_KEYS) so
unrelated environment is never clobbered."""
env_vars = load_env()
count = 0
for key, value in env_vars.items():
if os.environ.get(key) != value:
os.environ[key] = value
count += 1
for key in (set(OPTIONAL_ENV_VARS) | _EXTRA_ENV_KEYS) - set(env_vars):
if key in os.environ:
del os.environ[key]
count += 1
return count
def _scoped_environ_get(key: str) -> Optional[str]:
"""Read ``key`` from ``os.environ`` through ``agent.secret_scope.get_secret`` so an active
profile scope (multiplexed gateway turn) never leaks another profile's raw value. Falls back to
a plain environ read when the scope module is unavailable; ``UnscopedSecretError`` propagates."""
try:
from agent.secret_scope import UnscopedSecretError, get_secret as _get_secret
except Exception:
return os.environ.get(key)
try:
return _get_secret(key)
except UnscopedSecretError:
raise
except Exception:
return os.environ.get(key)
def get_env_value(key: str) -> Optional[str]:
"""Get a value from ``os.environ`` (scope-aware) or ``~/.hermes/.env``.
The ``os.environ`` read routes through ``agent.secret_scope.get_secret`` so that, under an active
profile scope (multiplexed gateway turn), this is scope-checked rather than leaking another profile's
raw ``os.environ`` value. ``get_secret`` encodes the whole policy: global vars pass through; scope is
authoritative under multiplexing (miss -> None, no environ fallthrough); when multiplexing is off it
behaves exactly like the legacy ``os.environ`` read. Its siblings ``get_env_value_prefer_dotenv`` and
``gateway.config._getenv`` already work this way — this was the last scope-blind reader of the trio
(#67027).
"""
val = _scoped_environ_get(key)
return load_env().get(key) if val is None else val
def get_env_value_prefer_dotenv(key: str) -> Optional[str]:
"""Resolve a Hermes-managed credential preferring ``~/.hermes/.env`` over ``os.environ``, so a
deliberate .env edit beats a stale value inherited from the parent shell."""
return load_env().get(key) or _scoped_environ_get(key)
# ---- Config display ----
def redact_key(key: str) -> str:
"""Redact an API key for display."""
from agent.redact import mask_secret
return mask_secret(key, empty=color("(not set)", Colors.DIM))
# Key names (case-insensitive, exact match) whose VALUE is a credential and must be masked
# before printing any config dict. Exact-match so ``token_count`` / ``secret_santa`` stay visible.
# Bare ``auth`` is deliberately absent: ``mcp_servers.<s>.auth: oauth`` is a documented mode enum.
_SECRET_CONFIG_KEYS = frozenset({
"api_key", "apikey", "key", "token", "access_token", "refresh_token", "id_token",
"secret", "client_secret", "password", "passwd", "authorization",
"private_key", "bearer", "jwt"})
# Env-map shapes (``mcp_servers.<s>.env.FOO_API_KEY``, ``FAL_KEY``, ``AWS_SECRET_ACCESS_KEY``) and
# the suffixes ``_is_env_config_key`` routes to .env. Suffix-only so ``token_count`` stays visible.
_SECRET_CONFIG_KEY_SUFFIXES = ("_api_key", "_token", "_secret", "_password", "_key", "_access_key")
# .env-routed keys are credentials by default; these suffixes name the non-secret exceptions
# (``TERMINAL_SSH_HOST``, ``TOOL_GATEWAY_URL``, ``BROWSERBASE_PROJECT_ID``).
_NON_SECRET_KEY_SUFFIXES = ("_url", "_host", "_user", "_id", "_domain", "_scheme")
_ENV_PLACEHOLDER_RE = re.compile(r"^\$\{[A-Za-z_][A-Za-z0-9_]*\}$")
def _is_secret_config_key(key: str) -> bool:
"""Whether the LAST segment of a config key names a credential value. Header names
(``mcp_servers.<s>.headers.X-API-Key``) are folded to snake_case before matching."""
leaf = key.rsplit(".", 1)[-1].lower().replace("-", "_")
if _is_env_config_key(key):
return not leaf.endswith(_NON_SECRET_KEY_SUFFIXES)
return leaf in _SECRET_CONFIG_KEYS or leaf.endswith(_SECRET_CONFIG_KEY_SUFFIXES)
def redact_config_value(value: Any, _depth: int = 0) -> Any:
"""Copy of ``value`` with credential-shaped keys masked. ``print`` bypasses the logging
redactor and opaque tokens miss the vendor-prefix regexes, so structural masking is required."""
from agent.redact import mask_secret
if _depth > 20: # bound recursion for pathological/cyclic configs
return value
if isinstance(value, dict):
return {
k: mask_secret(v)
if isinstance(k, str) and _is_secret_config_key(k) and isinstance(v, str) and v
and not _ENV_PLACEHOLDER_RE.match(v)
else redact_config_value(v, _depth + 1)
for k, v in value.items()}
if isinstance(value, list):
return [redact_config_value(v, _depth + 1) for v in value]
return value
def _section(title: str) -> None:
print()
print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD))
def _show_managed_banner() -> None:
"""Surface administrator-pinned settings so the user knows why a config.yaml value may not
be the effective one."""
managed_keys = managed_scope.managed_config_keys()
managed_env = managed_scope.load_managed_env()
if not managed_keys and not managed_env:
return
print()
print(color(
f" ⚷ Some settings are managed by your administrator ({managed_scope.get_managed_dir()}) "
f"and cannot be changed", Colors.YELLOW, Colors.BOLD))
for label, keys in (("config", managed_keys), ("env", managed_env)):
if keys:
print(color(f" Managed {label} keys: {', '.join(sorted(keys))}", Colors.YELLOW))
_SHOW_CONFIG_API_KEYS = (
("OPENROUTER_API_KEY", "OpenRouter"),
("VOICE_TOOLS_OPENAI_KEY", "OpenAI (STT/TTS)"),
("EXA_API_KEY", "Exa"),
("PARALLEL_API_KEY", "Parallel"),
("FIRECRAWL_API_KEY", "Firecrawl"),
("TAVILY_API_KEY", "Tavily"),
("PERPLEXITY_API_KEY", "Perplexity"),
("BROWSERBASE_API_KEY", "Browserbase"),
("BROWSER_USE_API_KEY", "Browser Use"),
("FAL_KEY", "FAL"))
def _show_model_section(config: Dict[str, Any]) -> None:
_section("Model")
print(f" Model: {redact_config_value(config.get('model', 'not set'))}")
cfg_max_turns = config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])
print(f" Max turns: {cfg_max_turns}")
# Read the .env FILE directly so a stale HERMES_MAX_ITERATIONS ghost is caught even when the
# gateway bridge already overrode os.environ.
try:
env_ghost = load_env().get("HERMES_MAX_ITERATIONS")
except Exception:
env_ghost = None
if env_ghost is not None and str(env_ghost).strip() != str(cfg_max_turns).strip():
print(color(f" ⚠ .env has stale HERMES_MAX_ITERATIONS={env_ghost} "
f"(run 'hermes doctor --fix' to remove)", Colors.YELLOW))
def _show_display_section(config: Dict[str, Any]) -> None:
_section("Display")
display = config.get('display', {})
try:
from hermes_cli.personality import active_personality_name
active_personality = active_personality_name(config) or 'none'
except Exception:
active_personality = display.get('personality') or 'none'
on_off = lambda flag: 'on' if flag else 'off' # noqa: E731
print(f" Personality: {active_personality}")
print(f" Reasoning: {on_off(display.get('show_reasoning', True))}")
print(
f" Bell: complete={on_off(display.get('bell_on_complete', False))}, "
f"prompt={on_off(display.get('bell_on_prompt', False))}")
ump = display.get('user_message_preview', {})
ump = ump if isinstance(ump, dict) else {}
print(f" User preview: first {ump.get('first_lines', 2)} line(s), last {ump.get('last_lines', 2)} line(s)")
def _show_terminal_section(config: Dict[str, Any]) -> None:
_section("Terminal")
terminal = config.get('terminal', {})
print(f" Backend: {terminal.get('backend', 'local')}")
print(f" Working dir: {terminal.get('cwd', '.')}")
print(f" Timeout: {terminal.get('timeout', 60)}s")
configured = lambda *names: 'configured' if all(get_env_value(n) for n in names) else '(not set)' # noqa: E731
default_img = 'nikolaik/python-nodejs:python3.11-nodejs20'
backend_lines = {
'docker': lambda: [f" Docker image: {terminal.get('docker_image', default_img)}"],
'singularity': lambda: [f" Image: {terminal.get('singularity_image', 'docker://' + default_img)}"],
'modal': lambda: [
f" Modal image: {terminal.get('modal_image', default_img)}",
f" Modal token: {configured('MODAL_TOKEN_ID')}"],
'daytona': lambda: [
f" Daytona image: {terminal.get('daytona_image', default_img)}",
f" API key: {configured('DAYTONA_API_KEY')}"],
'vercel_sandbox': lambda: [
f" Vercel runtime: {terminal.get('vercel_runtime', 'node24')}",
f" Vercel auth: {'configured' if get_env_value('VERCEL_OIDC_TOKEN') or (get_env_value('VERCEL_TOKEN') and get_env_value('VERCEL_PROJECT_ID') and get_env_value('VERCEL_TEAM_ID')) else '(not set)'}",
],
'ssh': lambda: [
f" SSH host: {get_env_value('TERMINAL_SSH_HOST') or '(not set)'}",
f" SSH user: {get_env_value('TERMINAL_SSH_USER') or '(not set)'}"]}
for line in backend_lines.get(terminal.get('backend'), list)():
print(line)
def _show_compression_section(config: Dict[str, Any]) -> None:
_section("Context Compression")
compression = config.get('compression', {})
enabled = compression.get('enabled', True)
print(f" Enabled: {'yes' if enabled else 'no'}")
if not enabled:
return
print(f" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%")
tt = compression.get('threshold_tokens')
try:
if tt is not None and int(tt) > 0:
print(f" Token cap: {int(tt):,} tokens (takes lower of ratio vs absolute)")
except (TypeError, ValueError):
pass
print(f" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved")
print(f" Protect last: {compression.get('protect_last_n', 20)} messages")
print(f" Protect first: {compression.get('protect_first_n', 3)} non-system head messages")
aux_comp = config.get('auxiliary', {}).get('compression', {})
print(f" Model: {aux_comp.get('model', '') or '(auto)'}")
comp_provider = aux_comp.get('provider', 'auto')
if comp_provider and comp_provider != 'auto':
print(f" Provider: {comp_provider}")
def _show_aux_overrides(config: Dict[str, Any]) -> None:
aux_tasks = {"Vision": config.get('auxiliary', {}).get('vision', {})}
overrides = {
label: (t.get('provider', 'auto'), t.get('model', ''))
for label, t in aux_tasks.items()
if t.get('provider', 'auto') != 'auto' or t.get('model', '')}
if not overrides:
return
_section("Auxiliary Models (overrides)")
for label, (prov, mdl) in overrides.items():
parts = [f"provider={prov}"] + ([f"model={mdl}"] if mdl else [])
print(f" {label:12s} {', '.join(parts)}")
def _show_skill_settings() -> None:
try:
from agent.skill_utils import discover_all_skill_config_vars, resolve_skill_config_values
skill_vars = discover_all_skill_config_vars()
if not skill_vars:
return
resolved = resolve_skill_config_values(skill_vars)
_section("Skill Settings")
for var in skill_vars:
value = resolved.get(var["key"], "")
display_val = str(value) if value else color("(not set)", Colors.DIM)
skill_tag = color(f"[{var.get('skill', '')}]", Colors.DIM)
print(f" {var['key']:<20s} {display_val} {skill_tag}")
except Exception:
pass
def show_config():
"""Display current configuration."""
config = load_config()
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
print(color("│ ☤ Hermes Configuration │", Colors.CYAN))
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
_show_managed_banner()
_section("Paths")
print(f" Config: {get_config_path()}")
print(f" Secrets: {get_env_path()}")
print(f" Install: {get_project_root()}")
_section("API Keys")
for env_key, name in _SHOW_CONFIG_API_KEYS:
print(f" {name:<14} {redact_key(get_env_value(env_key))}")
from hermes_cli.auth import get_anthropic_key
print(f" {'Anthropic':<14} {redact_key(get_anthropic_key())}")
_show_model_section(config)
_show_display_section(config)
_show_terminal_section(config)
_section("Timezone")
tz = config.get('timezone', '')
print(f" Timezone: {tz or color('(server-local)', Colors.DIM)}")
_show_compression_section(config)
_show_aux_overrides(config)
_section("Messaging Platforms")
for label, env_key in (("Telegram", "TELEGRAM_BOT_TOKEN"), ("Discord", "DISCORD_BOT_TOKEN")):
state = 'configured' if get_env_value(env_key) else color('not configured', Colors.DIM)
print(f" {label + ':':<13} {state}")
_show_skill_settings()
print()
print(color("─" * 60, Colors.DIM))
print(color(" hermes config edit # Edit config file", Colors.DIM))
print(color(" hermes config set <key> <value>", Colors.DIM))
print(color(" hermes setup # Run setup wizard", Colors.DIM))
print()
def edit_config():
"""Open config file in user's editor."""
if is_managed():
managed_error("edit configuration")
return
config_path = get_config_path()
if not config_path.exists():
save_config(DEFAULT_CONFIG, strip_defaults=False)
print(f"Created {config_path}")
# Windows lands on notepad even without Git Bash/nano; POSIX prefers nano/vim, which headless
# servers are more likely to have.
candidates = (['notepad', 'code', 'vim', 'vi', 'nano'] if sys.platform == "win32"
else ['nano', 'vim', 'vi', 'code', 'notepad'])
editor = os.getenv('EDITOR') or os.getenv('VISUAL') or next(
(cmd for cmd in candidates if shutil.which(cmd)), None)
if not editor:
print("No editor found. Config file is at:")
print(f" {config_path}")
return
print(f"Opening {config_path} in {editor}...")
subprocess.run([editor, str(config_path)])
# ---- Cron model-drift helpers: which unpinned jobs stay on their creation snapshot ----
_CRON_DRIFT_AXIS_BY_KEY = {
"model": "model", "model.default": "model", "model.model": "model", "model.name": "model",
"model.provider": "provider", "provider": "provider"}
def _cron_model_drift_axis_for_config_key(key: str) -> Optional[str]:
"""Return the cron inference axis affected by a config key, if any."""
return _CRON_DRIFT_AXIS_BY_KEY.get(str(key or "").strip().lower())
def _cron_section(config: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Return the ``cron`` mapping of *config* (loading the merged config when None), else None."""
if config is None:
try:
config = load_config()
except Exception:
return None
cron_config = config.get("cron") if isinstance(config, dict) else None
return cron_config if isinstance(cron_config, dict) else None
_CRON_MODEL_IMPACT_JOB_LIMIT = 50
_CRON_MODEL_IMPACT_ID_LIMIT = 256
_CRON_MODEL_IMPACT_NAME_LIMIT = 120
def _model_assignment_text(value: Any) -> str:
"""Return a trimmed scalar model/provider value, or empty for malformed data."""
return value.strip() if isinstance(value, str) else ""
def resolve_cron_model_drift_defaults(
config: Any, *, environ: Optional[Dict[str, str]] = None) -> Tuple[str, str]:
"""Resolve the global ``(provider, model)`` cron compares against snapshots.
Mirrors the scheduler's precedence: a truthy configured model wins over ``HERMES_MODEL``; the
environment is only a fallback. Per-job and cron fleet defaults are handled by the caller
because they cover an axis rather than changing the global assignment."""
env = os.environ if environ is None else environ
provider = ""
model_config = config.get("model") if isinstance(config, dict) else None
if isinstance(model_config, dict):
provider = _model_assignment_text(model_config.get("provider"))
model_config = model_config.get("default") or model_config.get("model") or model_config.get("name")
configured_model = _model_assignment_text(model_config)
return provider, configured_model or _model_assignment_text(env.get("HERMES_MODEL", ""))
def cron_model_drift_axes(
job: Any, *, current_provider: Any = "", current_model: Any = "", config: Any = None
) -> List[str]:
"""Return the unpinned axes on which *job* will keep running on its creation snapshot rather
than the new global assignment (the scheduler treats the snapshot as the effective pin)."""
if not isinstance(job, dict):
return []
current = {
"provider": _model_assignment_text(current_provider).lower(),
"model": _model_assignment_text(current_model).lower()}
# A cron.model / cron.model_provider fleet default covers its axis: that axis never reads the
# snapshot at fire time, so reporting it would be false.
fleet = _cron_section(config) or {}
drifted: List[str] = []
for axis, fleet_key in (("provider", "model_provider"), ("model", "model")):
if _model_assignment_text(fleet.get(fleet_key)) or _model_assignment_text(job.get(axis)):
continue
snapshot = _model_assignment_text(job.get(f"{axis}_snapshot")).lower()
if snapshot and current[axis] and snapshot != current[axis]:
drifted.append(axis)
return drifted
def _is_control_char(char: str) -> bool:
return unicodedata.category(char).startswith("C")
def _valid_cron_impact_job_id(value: Any) -> str:
job_id = value.strip() if isinstance(value, str) else ""
if len(job_id) > _CRON_MODEL_IMPACT_ID_LIMIT or any(map(_is_control_char, job_id)):
return ""
return job_id
def _cron_impact_job_name(value: Any, job_id: str) -> str:
if isinstance(value, str):
printable = "".join(char for char in value if not _is_control_char(char))
name = " ".join(printable.split())[:_CRON_MODEL_IMPACT_NAME_LIMIT].rstrip()
if name:
return name
return f"Job {job_id}"[:_CRON_MODEL_IMPACT_NAME_LIMIT].rstrip()
def _cron_model_impact_result(available: bool) -> Dict[str, Any]:
return {"available": available, "affected_count": 0, "truncated": False, "jobs": []}
def build_cron_model_impact(
*, current_provider: Any = "", current_model: Any = "", config: Any = None, jobs: Any = None
) -> Dict[str, Any]:
"""Build a bounded, profile-local summary of unpinned jobs that stay on their creation snapshot
after a global model/provider change. Job-store inspection is best effort: the model assignment
has already succeeded when Desktop requests this, so an unreadable store is reported as
unavailable rather than failing."""
if jobs is None:
try:
from cron.jobs import load_jobs
jobs = load_jobs()
except Exception:
return _cron_model_impact_result(False)
if not isinstance(jobs, list):
return _cron_model_impact_result(False)
result = _cron_model_impact_result(True)
from cron.jobs import is_job_runnable
seen_ids: Set[str] = set()
for job in jobs:
if not isinstance(job, dict) or not is_job_runnable(job) or job.get("no_agent"):
continue
job_id = _valid_cron_impact_job_id(job.get("id"))
if not job_id or job_id in seen_ids:
continue
seen_ids.add(job_id)
axes = cron_model_drift_axes(
job, current_provider=current_provider, current_model=current_model, config=config)
if not axes:
continue
result["affected_count"] += 1
if len(result["jobs"]) < _CRON_MODEL_IMPACT_JOB_LIMIT:
result["jobs"].append({
"id": job_id,
"name": _cron_impact_job_name(job.get("name"), job_id),
"drifted_axes": axes})
result["truncated"] = result["affected_count"] > len(result["jobs"])
return result
def warn_unpinned_cron_jobs_after_model_config_change(
key: str, value: Any, config: Optional[Dict[str, Any]] = None) -> None:
"""Tell the operator which unpinned cron jobs a global model/provider change does NOT move."""
axis = _cron_model_drift_axis_for_config_key(key)
if axis is None:
return
new_value = _model_assignment_text(value)
if not new_value:
return
impact = build_cron_model_impact(
current_provider=new_value if axis == "provider" else "",
current_model=new_value if axis == "model" else "", config=config, jobs=None)
affected = impact["affected_count"]
if affected <= 0:
return
noun, verb = ("job", "keeps") if affected == 1 else ("jobs", "keep")
print(
f"ℹ️ {affected} unpinned cron {noun} {verb} running on the {axis} it was created under "
f"(its {axis}_snapshot), not the new global {axis}. To move it, pin it with "
"`hermes cron edit <job_id> --provider <provider> --model <model>` or set a fleet default "
"with `hermes config set cron.model <model>`.")
def _default_value_for_key(dotted_key: str):
"""Return the leaf value declared for *dotted_key* in ``DEFAULT_CONFIG`` (None for dicts/misses)."""
node = cfg_get(DEFAULT_CONFIG, *_split_key_path(dotted_key))
return None if isinstance(node, dict) else node
# Top-level keys that accept arbitrary user-supplied child keys (schema declares the dict, the
# user populates it): any path below is accepted without deep checking.
_OPEN_DICT_TOP_LEVEL_KEYS = frozenset({
"providers", "credential_pool_strategies", "mcp_servers", "hooks", "quick_commands",
"personalities", "command_allowlist", "model_catalog", "channel_prompts", "server_actions",
"secrets", "goals", "loops"})
# Top-level keys whose sub-keys are partially schema-defined (e.g. a PlatformConfig dataclass) but
# where users may add fields DEFAULT_CONFIG doesn't enumerate: validate the FIRST segment only.
_SCHEMA_DEFINED_DICT_KEYS = frozenset({
# Platform configs — PlatformConfig dataclass + dynamic extras
"discord", "telegram", "slack", "whatsapp", "signal", "mattermost",
"matrix", "feishu", "wecom", "weixin", "bluebubbles", "qqbot", "yuanbao",
"email", "sms", "dingtalk",
# MCP server template / dynamic auth dicts
"sessions", "checkpoints",
# Plugin enable/disable lists + per-plugin entries; absent from DEFAULT_CONFIG.
"plugins"})
# Top-level keys that can be ANY user-supplied name.
_DYNAMIC_TOP_LEVEL_KEYS = frozenset({
"custom_providers", # list-shaped, but indexed by position
})
# Containers whose immediate child IS a user-supplied platform name (``platforms.<name>.<field>``),
# both top-level and under ``gateway``; anything below the name is accepted (open ``extra``).
_PLATFORM_CONTAINER_KEYS = frozenset({"platforms"})
# Top-level keys whose sub-keys are accepted without deep checking.
_OPEN_SUBKEY_TOP_LEVEL_KEYS = _OPEN_DICT_TOP_LEVEL_KEYS | _DYNAMIC_TOP_LEVEL_KEYS | _SCHEMA_DEFINED_DICT_KEYS
def _known_top_level_keys() -> set[str]:
"""Return the union of known top-level config keys for validation."""
return set(DEFAULT_CONFIG) | _OPEN_SUBKEY_TOP_LEVEL_KEYS
def _suggest_closest_key(key: str, candidates: set[str], cutoff: float = 0.6) -> Optional[str]:
"""Closest candidate key name for a typo'd ``key``, or None."""
return next(iter(difflib.get_close_matches(key, sorted(candidates), n=1, cutoff=cutoff)), None)
def _validate_config_key(key: str) -> tuple[bool, Optional[str]]:
"""Validate a dotted config-key path against the known schema -> ``(is_known, suggestion)``.
Headline case from #34067: ``gateway.discord.gateway_restart_notification`` was silently written, even
though ``gateway`` only has 4 known sub-keys (``strict``, ``media_delivery_allow_dirs``,
``trust_recent_files``, ``trust_recent_files_seconds``). The correct path is
``discord.gateway_restart_notification`` (platform configs live at the top level, not under a
``platforms`` namespace).
"""
if not key:
return False, None
segments = _split_key_path(key)
top = segments[0]
# A leading underscore on the FIRST segment marks an intentionally non-schema internal key
# (test harnesses/tooling); only the first segment is exempt so ``agent._max_turns`` is caught.
if top.startswith("_") or top in _PLATFORM_CONTAINER_KEYS:
return True, None
known = _known_top_level_keys()
if top not in known:
suggestion = _suggest_closest_key(top, known)
if suggestion is None:
return False, None
rest = ".".join(segments[1:])
return False, f"{suggestion}.{rest}" if rest else suggestion
if top in _OPEN_SUBKEY_TOP_LEVEL_KEYS:
return True, None
# Walk DEFAULT_CONFIG: a nested ``platforms`` container, a scalar leaf, or an EMPTY dict hit
# before the path is consumed all accept. An empty dict is a free-form mapping section
# (``compression.model_thresholds.<model>``, ``terminal.docker_env.<VAR>``,
# ``lsp.servers.<lang>``): its keys are user-chosen, so nothing under it can be a typo. An
# unknown sub-key of a populated section fails with a same-level "did you mean" suggestion.
node: Any = DEFAULT_CONFIG.get(top)
consumed = [top]
for seg in segments[1:]:
if seg in _PLATFORM_CONTAINER_KEYS or not isinstance(node, dict) or not node:
return True, None
if seg not in node:
sibling = _suggest_closest_key(seg, set(node.keys()))
if sibling is not None:
return False, ".".join(consumed + [sibling])
# ``gateway.discord.<field>``: the path minus its wrong prefix is itself a known key.
rest = ".".join(segments[len(consumed):])
if _split_key_path(rest)[0] in _known_top_level_keys() and _validate_config_key(rest)[0]:
return False, rest
return False, None
consumed.append(seg)
node = node[seg]
return True, None
def _looks_structured_value(value: str) -> bool:
"""True when *value* plausibly encodes a YAML/JSON list or mapping. Deliberately conservative:
a bare leading ``-`` is not a trigger (``-5``, ``--flag`` must stay strings)."""
stripped = value.lstrip()
if stripped[:1] in ('[', '{'):
return True
if '\n' not in value:
return False
for line in value.splitlines():
item = line.strip()
if item == '-' or item.startswith('- '):
return True
# ``key: value`` / ``key:`` mapping-entry shape (no whitespace in the key).
head, sep, _rest = item.partition(': ')
if sep and head and ' ' not in head and not head.startswith('#'):
return True
if item.endswith(':') and ' ' not in item[:-1] and item[:-1]:
return True
return False
def _coerce_int(value: str):
"""int(value) for a clean integer literal (signs/whitespace/underscores OK), else None."""
try:
return int(value)
except (TypeError, ValueError):
return None
def _coerce_float(value: str):
"""``float(value)`` only when the conversion preserves its decimal value; NaN/inf rejected.
Decimal-looking identifiers more precise than a binary float must stay strings."""
try:
f = float(value)
if f != f or f in (float("inf"), float("-inf")) or Decimal(value) != Decimal(str(f)):
return None
except (TypeError, ValueError, InvalidOperation):
return None
return f
_SCALAR_WORDS = {
'true': True, 'yes': True, 'on': True,
'false': False, 'no': False, 'off': False,
# YAML null. Many DEFAULT_CONFIG leaves are "null/absent = off"; without this,
# ``config set X null`` stored the truthy string "null" and the feature could never be cleared.
'null': None, 'none': None, '~': None}
def _coerce_config_set_value(key: str, value: str) -> Any:
"""Auto-coerce a ``hermes config set`` string to bool/None/int/float/list/dict.
String-typed settings (per ``DEFAULT_CONFIG``) are preserved verbatim so enum members such as
``approvals.mode="off"`` never become booleans. List/mapping literals are parsed so
isinstance-gated readers see real structures; the trigger is conservative."""
if isinstance(_default_value_for_key(key), str):
return value
stripped = value.strip()
lower = stripped.lower()
if lower in _SCALAR_WORDS:
return _SCALAR_WORDS[lower]
for coerce in (_coerce_int, _coerce_float):
coerced = coerce(stripped)
if coerced is not None:
return coerced
if not _looks_structured_value(value):
return value
try:
parsed = yaml.safe_load(value)
except yaml.YAMLError:
print(
f"Warning: value for '{key}' looks like a list/mapping but is "
f"not valid YAML/JSON; storing as string. Most isinstance-gated "
f"readers will ignore a string here.", file=sys.stderr)
return value
if isinstance(parsed, (list, dict)):
return parsed
print(
f"Warning: value for '{key}' looks like a list/mapping but "
f"parsed as {type(parsed).__name__}; storing as string.", file=sys.stderr)
return value
def _redirect_platform_display_key(key: str) -> tuple[str, Optional[str]]:
"""Canonicalize ``platforms.<name>.<display_setting>`` -> ``display.platforms.<name>.<setting>``.
The gateway resolves per-platform display settings (streaming, show_reasoning, ...) from
``display.platforms``; the top-level ``platforms.<name>`` block holds only connection config.
Only known display settings (``OVERRIDEABLE_KEYS``) are redirected. Returns ``(key, note)``;
the gateway import is guarded so the CLI works where the gateway package is unavailable.
Before #71047 a write such as ``hermes config set platforms.telegram.streaming false`` landed on a key
the gateway never reads: ``config get`` echoed the new value back while the runtime kept the old
``display.platforms`` one — a silent no-op that looks like a duplicated key to the user.
"""
segs = _split_key_path(key)
if len(segs) != 3 or segs[0] != "platforms":
return key, None
try:
from gateway.display_config import OVERRIDEABLE_KEYS as _display_keys
except Exception:
return key, None
if segs[2] not in _display_keys:
return key, None
canonical = f"display.platforms.{segs[1]}.{segs[2]}"
return canonical, f" (note: per-platform display setting — saved as {canonical})"
def _exit_if_key_managed(key: str, action: str) -> None:
"""A key pinned by the managed layer cannot be set/unset (the next load would reinstate it):
hard-reject and name the source. Distinct from ``is_managed()``; env-shaped keys route to the
.env writers, which carry their own guard."""
if managed_scope.is_key_managed(key):
print(
f"Cannot {action} '{key}': it is managed by your administrator ({_managed_source('config.yaml')}) "
f"and cannot be changed. Contact your administrator to modify it.", file=sys.stderr)
sys.exit(1)
def _guard_section_overwrite(key: str, value: Any, user_config: Dict[str, Any], force: bool) -> str:
"""Refuse (or with ``force`` allow) a single-segment key overwriting a mapping with a scalar.
Bare ``model`` is a documented shorthand — redirected to ``model.default`` so siblings survive.
Returns the (possibly redirected) key."""
existing = user_config.get(key)
if "." in key or not isinstance(existing, dict):
return key
if key == "model":
if force:
print(
f"⚠ Replacing entire 'model' section with a scalar "
f"(discarding {len(existing)} existing sub-key(s))")
return key
print(
f"✓ Redirecting bare 'model' to 'model.default' "
f"(preserving {len(existing)} existing model sub-key(s))")
return "model.default"
if force:
return key
sub = [k for k in existing if isinstance(k, str)]
err = [
f"✗ Cannot set '{key}' to a scalar — '{key}' is a "
f"configuration section with {len(sub)} sub-key(s)."]
if sub:
err.append(f" Sub-keys: {', '.join(sub[:8])}")
if len(sub) > 8:
err.append(f" ... and {len(sub) - 8} more")
err += [
" Use a dotted path to set a specific leaf key:",
f" hermes config set {key}.<sub-key> <value>",
" Or use --force to replace the entire section:",
f" hermes config set --force {key} {value!r}"]
print("\n".join(err), file=sys.stderr)
sys.exit(1)
def _touch_skin_file(key: str, value: Any) -> None:
"""``display.skin`` set means "apply NOW": bump the skin file's mtime so the gateway watcher's
(name, mtime) signature moves even when the name is unchanged. Best-effort."""
if key == "display.skin" and isinstance(value, str) and value:
try:
skin_file = get_hermes_home() / "skins" / f"{value}.yaml"
if skin_file.exists():
skin_file.touch()
except Exception:
pass
def _exit_invalid(msg: str) -> None:
print(msg, file=sys.stderr)
sys.exit(1)
def _write_user_config(config_path: Path, user_config: Dict[str, Any]) -> None:
"""Write only the user's raw config back (never the merged defaults)."""
ensure_hermes_home()
atomic_yaml_write(config_path, user_config, sort_keys=False)
def _print_unknown_key_notice(key: str, suggestion: Optional[str]) -> None:
print(color(
f"⚠ '{key}' is not a recognized config key — it was saved anyway, "
"but Hermes may not read it.", Colors.YELLOW))
if suggestion:
print(color(f" Did you mean: {suggestion}", Colors.YELLOW))
print(color(
" (Custom top-level keys are supported and bridged to the "
"environment for skills/external tools. Use --force to skip "
"this notice.)", Colors.DIM))
def _unknown_subkey_refusal(key: str, suggestion: Optional[str]) -> str:
lines = [color(f"✗ '{key}' is not a recognized config key — nothing was written.", Colors.RED)]
if suggestion:
lines.append(color(f" Did you mean: {suggestion}", Colors.YELLOW))
lines.append(color(
" (Custom top-level keys are supported; use --force to write this path anyway.)", Colors.DIM))
return "\n".join(lines)
def set_config_value(key: str, value: str, force: bool = False):
"""Set a configuration value at a dotted ``key``; ``value`` is auto-coerced to bool/int/float.
``force`` writes an unknown path under a known section (otherwise refused), skips the
unknown-top-level-key notice AND authorizes replacing a mapping section with a
scalar. Without it, scalar writes over mappings are refused and bare ``model`` is redirected
to ``model.default``."""
if is_managed():
managed_error("set configuration values")
return
# Empty segments (``"agent."``) would write config["agent"][""] into a live schema section.
if key != key.strip() or not key.strip():
_exit_invalid(f"✗ Invalid config key: {key!r} (empty or surrounding whitespace).")
if "" in _split_key_path(key):
_exit_invalid(
f"✗ Invalid config key: {key!r} — contains an empty path segment "
"(leading, trailing, or doubled '.').")
_exit_if_key_managed(key, "set")
if _is_env_config_key(key):
from hermes_cli.credential_lifecycle import save_provider_env_credential
# Unified lifecycle: also rotates any config.yaml mirror of the old value so a stale
# higher-precedence copy can't win (#62269).
save_provider_env_credential(key.upper(), value)
print(f"✓ Set {key} in {get_env_path()}")
return
from hermes_cli.config_env_routing import is_env_setting_key, save_env_setting
if is_env_setting_key(key):
# Every UPPER_SNAKE name is an environment setting: same file the platform setup flows and
# /sethome write, and the only one os.getenv readers see. config.yaml never gets one from
# here, --force included (#111848). The env writer's denylist (HERMES_YOLO_MODE, PATH, ...)
# therefore also refuses the config.yaml detour that used to bridge those into os.environ.
try:
save_env_setting(key, value)
except ValueError as exc:
_exit_invalid(f"✗ {exc}")
print(f"✓ Set {key.upper()} in {get_env_path()}")
return
# Canonicalize per-platform display keys BEFORE validation/coercion so both see the path the
# runtime reads.
key, _redirect_note = _redirect_platform_display_key(key)
if _redirect_note:
print(_redirect_note)
is_known, suggestion = _validate_config_key(key)
# Unknown-key handling (#34067, #112003): an unknown path UNDER a known section can only be a
# typo (``gateway.discord.gateway_restart_notification``), so it is refused before anything is
# written. Unknown lowercase TOP-LEVEL keys stay writable with a post-write notice — their
# scalars are bridged into os.environ for skills/external apps, so that namespace is open by
# design (UPPER_SNAKE names were already routed to .env above).
if not is_known and not force and _split_key_path(key)[0] in _known_top_level_keys():
_exit_invalid(_unknown_subkey_refusal(key, suggestion))
# Read the RAW user config (not merged) so defaults are never dumped back; fail-closed.
config_path = get_config_path()
user_config = require_readable_config_before_write(config_path)
value = _coerce_config_set_value(key, value)
# A scalar ``model`` shorthand must become a dict before writing sub-keys, or _set_nested
# replaces it with an empty dict and the model id is lost.
_model_val = user_config.get("model")
if key.strip().lower().startswith("model.") and isinstance(_model_val, str) and _model_val:
user_config["model"] = {"default": _model_val}
key = _guard_section_overwrite(key, value, user_config, force)
try:
_set_nested(user_config, key, value)
except ValueError as e:
_exit_invalid(f"✗ {e}")
# api_base -> base_url alias at set-time too (mirrors _normalize_root_model_keys).
if key.strip().lower() in ("model.api_base", "api_base"):
# Normalize the api_base → base_url alias at set-time too (issue #8919), so a fresh `hermes config
# set model.api_base ...` lands on the canonical key the runtime resolver actually reads, instead of
# being silently ignored.
user_config = _normalize_root_model_keys(user_config)
key = "model.base_url"
print(" (note: 'api_base' is an alias — saved as model.base_url)")
_write_user_config(config_path, user_config)
# Keep .env in sync: terminal_tool reads TERMINAL_ENV etc. directly from env vars.
env_var = terminal_config_env_var_for_key(key)
if env_var and key != "terminal.cwd":
save_env_value(env_var, _terminal_env_value(value))
_touch_skin_file(key, value)
# Mask the echoed value when the (possibly nested) key is credential-shaped, e.g.
# ``model.api_key`` (lowercase, so it misses the .env routing above).
_display_value = value
if _is_secret_config_key(key) and isinstance(value, str) and value:
from agent.redact import mask_secret
_display_value = mask_secret(value)
print(f"✓ Set {key} = {_display_value} in {config_path}")
warn_unpinned_cron_jobs_after_model_config_change(key, value, user_config)
# Post-write unknown-key notice (#34067): value IS saved, but tell the user the runtime may never read
# it and suggest the likely-intended path.
if not is_known and not force:
_print_unknown_key_notice(key, suggestion)
def get_config_value(key: str, *, as_json: bool = False, raw: bool = False):
"""Print a resolved configuration value. Credentials are masked unless ``--raw`` or
``security.redact_secrets: false``: ``print`` bypasses the log redactor, and the agent runs
this command from sessions whose transcripts persist (#84106, #110758)."""
from hermes_cli.config_env_routing import is_env_setting_key, read_env_setting
if _is_env_config_key(key):
env_value = get_env_value(key.upper())
value = _MISSING if env_value is None else env_value
elif is_env_setting_key(key):
env_value = read_env_setting(key)
value = _MISSING if env_value is None else env_value
else:
# Mirror set_config_value: read the canonical display.platforms path.
# See #71047.
key, _ = _redirect_platform_display_key(key)
value = _get_nested(load_config(), key)
if value is _MISSING:
_exit_invalid(f"Config key not set: {key}")
from agent.redact import _redact_enabled, mask_secret
if not raw and _redact_enabled():
if isinstance(value, str):
if _is_secret_config_key(key) and not _ENV_PLACEHOLDER_RE.match(value):
value = mask_secret(value)
else:
value = redact_config_value(value)
print(_format_config_get_value(value, as_json=as_json))
def unset_config_value(key: str):
"""Remove a user-set configuration or .env value."""
if is_managed():
managed_error("unset configuration values")
return
_exit_if_key_managed(key, "unset")
if _is_env_config_key(key):
# Unified lifecycle: also prunes env-seeded credential_pool entries and model-cache rows so
# the provider is fully removed instead of left resurrectable.
# See #51071.
from hermes_cli.credential_lifecycle import remove_provider_env_credential
if not remove_provider_env_credential(key.upper()).get("found"):
_exit_invalid(f"Config key not set: {key}")
print(f"✓ Unset {key} from {get_env_path()}")
return
from hermes_cli.config_env_routing import is_env_setting_key, remove_env_setting
if is_env_setting_key(key):
# Also drops a stale top-level config.yaml copy left by older `config set` runs (#111848).
if not remove_env_setting(key):
_exit_invalid(f"Config key not set: {key}")
print(f"✓ Unset {key} from {get_env_path()}")
return
config_path = get_config_path()
user_config = require_readable_config_before_write(config_path)
key, _redirect_note = _redirect_platform_display_key(key)
if _redirect_note:
# Mirror set_config_value's display.platforms canonicalization (#71047).
print(_redirect_note.replace("saved as", "resolved as"))
removed = _unset_nested(user_config, key)
env_var = terminal_config_env_var_for_key(key)
if env_var and key != "terminal.cwd":
removed = remove_env_value(env_var) or removed
if not removed:
_exit_invalid(f"Config key not set: {key}")
_write_user_config(config_path, user_config)
print(f"✓ Unset {key} from {config_path}")
# ---- Command handler ----
def _usage_exit(usage: str, examples: List[str], extra: Optional[List[str]] = None) -> None:
print(usage)
print()
print("Examples:")
for line in examples:
print(f" {line}")
for line in extra or ():
print(line)
sys.exit(1)
def _run_write_command(fn, *args) -> None:
"""Run a config writer, surfacing the fail-closed write guard's RuntimeError as a clean CLI
error instead of a traceback."""
try:
fn(*args)
except RuntimeError as exc:
_exit_invalid(f"✗ {exc}")
_USAGE_GET = ("Usage: hermes config get <key> [--json] [--raw]", [
"hermes config get model", "hermes config get terminal.backend",
"hermes config get skills.config --json"], None)
_USAGE_SET = ("Usage: hermes config set [--force] <key> <value>", [
"hermes config set model anthropic/claude-sonnet-4", "hermes config set terminal.backend docker",
"hermes config set OPENROUTER_API_KEY sk-or-..."], [
"", " --force: skip the unknown-key notice for unrecognized keys,",
" and allow a scalar to replace a whole mapping section"])
_USAGE_UNSET = ("Usage: hermes config unset <key>", [
"hermes config unset model", "hermes config unset terminal.backend",
"hermes config unset OPENROUTER_API_KEY"], None)
def _cmd_config_get(args):
key = getattr(args, 'key', None)
if not key:
_usage_exit(*_USAGE_GET)
get_config_value(key, as_json=getattr(args, 'json', False), raw=bool(getattr(args, 'raw', False)))
def _cmd_config_set(args):
key = getattr(args, 'key', None)
value = getattr(args, 'value', None)
if not key or value is None:
_usage_exit(*_USAGE_SET)
_run_write_command(set_config_value, key, value, bool(getattr(args, 'force', False)))
def _cmd_config_unset(args):
key = getattr(args, 'key', None)
if not key:
_usage_exit(*_USAGE_UNSET)
_run_write_command(unset_config_value, key)
def _tools_suffix(info: Dict[str, Any], fmt: str) -> str:
tools = info.get("tools", [])
return fmt.format(", ".join(tools[:2])) if tools else ""
def _print_banner(text: str) -> None:
print()
print(color(text, Colors.CYAN, Colors.BOLD))
print()
def _cmd_config_migrate(args):
_print_banner("🔄 Checking configuration for updates...")
missing_env = get_missing_env_vars(required_only=False)
missing_config = get_missing_config_fields()
current_ver, latest_ver = check_config_version(raise_on_parse_error=True)
if not missing_env and not missing_config and current_ver >= latest_ver:
print(color("✓ Configuration is up to date!", Colors.GREEN))
print()
return
if current_ver < latest_ver:
print(f" Config version: {current_ver} → {latest_ver}")
if missing_config:
print(f"\n {len(missing_config)} new config option(s) will be added with defaults")
required_missing = [v for v in missing_env if v.get("is_required")]
optional_missing = [v for v in missing_env if not v.get("is_required") and not v.get("advanced")]
for heading, group, suffix in (
("⚠️ {} required API key(s) missing:", required_missing, ""),
("ℹ️ {} optional API key(s) not configured:", optional_missing, " (enables: {})")):
if group:
print(f"\n {heading.format(len(group))}")
for var in group:
print(f" • {var['name']}{_tools_suffix(var, suffix) if suffix else ''}")
print()
results = migrate_config(interactive=True, quiet=False)
print()
if results["env_added"] or results["config_added"]:
print(color("✓ Configuration updated!", Colors.GREEN))
if results["warnings"]:
print()
for warning in results["warnings"]:
print(color(f" ⚠️ {warning}", Colors.YELLOW))
print()
def _cmd_config_check(args):
"""Non-interactive report of what's missing."""
_print_banner("📋 Configuration Status")
current_ver, latest_ver = check_config_version(raise_on_parse_error=True)
if current_ver >= latest_ver:
print(f" Config version: {current_ver} ✓")
else:
print(color(f" Config version: {current_ver} → {latest_ver} (update available)", Colors.YELLOW))
groups = (
("Required", REQUIRED_ENV_VARS, lambda n, i: color(f" ✗ {n} (missing)", Colors.RED)),
("Optional", OPTIONAL_ENV_VARS,
lambda n, i: color(f" ○ {n}{_tools_suffix(i, ' → {}')}", Colors.DIM)))
for title, table, missing_line in groups:
print()
print(color(f" {title}:", Colors.BOLD))
for var_name, info in table.items():
print(f" ✓ {var_name}" if get_env_value(var_name) else missing_line(var_name, info))
missing_config = get_missing_config_fields()
if missing_config:
print()
print(color(f" {len(missing_config)} new config option(s) available", Colors.YELLOW))
print(" Run 'hermes config migrate' to add them")
print()
_CONFIG_SUBCOMMANDS = {
None: lambda args: show_config(),
"show": lambda args: show_config(),
"edit": lambda args: edit_config(),
"get": _cmd_config_get,
"set": _cmd_config_set,
"unset": _cmd_config_unset,
"path": lambda args: print(get_config_path()),
"env-path": lambda args: print(get_env_path()),
"migrate": _cmd_config_migrate,
"check": _cmd_config_check}
_CONFIG_USAGE = """Available commands:
hermes config Show current configuration
hermes config edit Open config in editor
hermes config get <key> Print a resolved config value
hermes config set <key> <value> Set a config value
hermes config unset <key> Remove a config value
hermes config check Check for missing/outdated config
hermes config migrate Update config with new options
hermes config path Show config file path
hermes config env-path Show .env file path"""
def config_command(args):
"""Handle config subcommands."""
subcmd = getattr(args, 'config_command', None)
handler = _CONFIG_SUBCOMMANDS.get(subcmd)
if handler is not None:
handler(args)
return
print(f"Unknown config command: {subcmd}")
print()
print(_CONFIG_USAGE)
sys.exit(1)
# ---- OPTIONAL_ENV_VARS injection from provider profiles and platform plugins (once, at import) ----
def _inject_profile_env_vars() -> None:
"""Expose env_vars of every ``auth_type="api_key"`` provider in providers/ via OPTIONAL_ENV_VARS
without editing this file."""
try:
from providers import list_providers
for _pp in list_providers():
if _pp.auth_type != "api_key":
continue
for _var in _pp.env_vars:
if _var in OPTIONAL_ENV_VARS:
continue
_is_key = not _var.endswith(("_BASE_URL", "_URL"))
_label = _pp.display_name or _pp.name
OPTIONAL_ENV_VARS[_var] = {
"description": f"{_label} {'API key' if _is_key else 'base URL override'}",
"prompt": f"{_label} {'API key' if _is_key else 'base URL (leave empty for default)'}",
"url": _pp.signup_url or None,
"password": _is_key,
"category": "provider",
"advanced": True}
except Exception:
pass
_inject_profile_env_vars()
def _platform_plugin_manifests():
"""Yield ``(dir_name, manifest_dict)`` for every bundled ``plugins/platforms/*/plugin.y(a)ml``."""
platforms_dir = get_project_root() / "plugins" / "platforms"
if not platforms_dir.is_dir():
return
for child in platforms_dir.iterdir():
manifest_path = next(
(p for p in (child / "plugin.yaml", child / "plugin.yml") if child.is_dir() and p.exists()), None)
if manifest_path is None:
continue
try:
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = fast_safe_load(f) or {}
except Exception:
continue
yield child.name, manifest
def _inject_platform_plugin_env_vars() -> None:
"""Populate OPTIONAL_ENV_VARS from bundled platform plugin manifests so Teams / IRC / Google
Chat etc. are configurable in ``hermes config`` UI without the core knowing they exist.
``requires_env`` / ``optional_env`` entries are a bare name or a dict with ``name`` plus
optional ``description``/``url``/``password``/``prompt``/``category``. Failures are swallowed
so a malformed plugin.yaml can't break CLI import.
"""
try:
for dir_name, manifest in _platform_plugin_manifests():
label = manifest.get("label") or manifest.get("name") or dir_name
for entry in [*(manifest.get("requires_env") or []), *(manifest.get("optional_env") or [])]:
meta = {"name": entry} if isinstance(entry, str) else entry if isinstance(entry, dict) else {}
name = meta.get("name")
if not name or name in OPTIONAL_ENV_VARS:
continue # hardcoded entry wins (back-compat)
# *TOKEN / *SECRET / *KEY / *PASSWORD / *JSON are password fields unless overridden.
is_secret = bool(meta.get("password") or meta.get("secret"))
if not is_secret and not meta.get("password") is False:
is_secret = name.upper().endswith(("_TOKEN", "_SECRET", "_KEY", "_PASSWORD", "_JSON"))
OPTIONAL_ENV_VARS[name] = {
"description": meta.get("description") or f"{label} configuration",
"prompt": meta.get("prompt") or name,
"url": meta.get("url") or None,
"password": is_secret,
"category": meta.get("category") or "messaging"}
except Exception:
pass
_inject_platform_plugin_env_vars()
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
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.
"""
if project_root is not None:
return project_root
return Path(__file__).parent.parent.resolve()
def stamp_install_method(method: str, project_root: Optional[Path] = None) -> None:
"""Write the install method next to the running code (code-scoped stamp).
The stamp lives in the install tree (``<install tree>/.install_method``),
not in ``$HERMES_HOME``, so that two installs sharing one data directory
do not overwrite each other's marker. See ``detect_install_method`` for
the full rationale.
Best-effort: if the install tree is read-only (e.g. the immutable
``/opt/hermes`` in the published image, which instead bakes the stamp at
build time) the write silently no-ops and detection falls back to its
other signals.
"""
root = _install_method_project_root(project_root)
try:
root.mkdir(parents=True, exist_ok=True)
(root / ".install_method").write_text(method + "\n", encoding="utf-8")
except OSError:
pass
_PLUGIN_COMPAT_LAZY = {
'normalize_route_base_url': ('hermes_cli.route_identity', 'normalize_route_base_url'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----