Files
hermes-agent/hermes_cli/config.py

4888 lines
198 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."""
import copy
from decimal import Decimal, InvalidOperation
from hermes_cli.cli_output import line_input
import json
import logging
import os
import platform
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import threading
import time
import unicodedata
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple, Set
from hermes_cli.secret_prompt import masked_secret_prompt
logger = logging.getLogger(__name__)
# Track which (config_path, mtime_ns, size) tuples we've already warned about
# so concurrent CLI/gateway loads of a broken config.yaml don't spam stderr
# every time. Cleared automatically when the file changes (different mtime).
_CONFIG_PARSE_WARNED: set = set()
# Parallel record of active parse failures keyed by path -> (mtime_ns, size,
# error message). Written by _warn_config_parse_failure() (the single funnel
# every load-path parse failure goes through) and probed by
# get_active_config_parse_failure() so provider auto-resolution can refuse to
# adopt a paid provider from environment keys while the user's REAL config —
# which may name a completely different provider — is unreadable (#81952).
_CONFIG_PARSE_FAILURES: dict = {}
class InvalidUserConfigError(RuntimeError):
"""Raised when a run that cannot repair config finds invalid user YAML."""
def _backup_corrupt_config(config_path: Path) -> Optional[Path]:
"""Preserve a corrupted ``config.yaml`` by copying it to a timestamped ``.bak``.
When the YAML can't be parsed, ``load_config()`` silently falls back to ``DEFAULT_CONFIG`` and
the user's broken file stays on disk untouched.
Returns the backup path on success, else ``None``. Symlinks are not followed/copied (mirrors the
Gemini #21541 lstat guard) to avoid clobbering whatever a malicious/misconfigured symlink points
at.
"""
try:
if config_path.is_symlink():
return None
st = config_path.stat()
if st.st_size == 0:
# Empty file isn't worth preserving and yaml.safe_load returns {}
# for it anyway (so it wouldn't reach here), but guard regardless.
return None
ts = time.strftime("%Y%m%d-%H%M%S")
backup_path = config_path.with_name(f"{config_path.name}.corrupt.{ts}.bak")
# Don't clobber an existing backup from the same second; if there's
# already a corrupt backup for this exact mtime, assume we've snapshotted
# this corruption already and skip (the dedup cache normally prevents a
# second call, but a process restart can clear it).
sibling_baks = list(
config_path.parent.glob(f"{config_path.name}.corrupt.*.bak")
)
for existing in sibling_baks:
try:
if existing.stat().st_size == st.st_size:
# Same size as the current broken file — likely the same
# corruption already preserved. Avoid backup churn.
return None
except OSError:
continue
if backup_path.exists():
return None
shutil.copy2(config_path, backup_path)
return backup_path
except Exception:
return None
def _warn_config_parse_failure(
config_path: Path, exc: Exception, *, fallback: str = "defaults"
) -> None:
"""Surface a config.yaml parse failure to user, log, and stderr.
A YAML parse error in ``~/.hermes/config.yaml`` causes ``load_config()`` to silently fall back
to ``DEFAULT_CONFIG``, which means every user override (auxiliary providers, fallback chain,
model overrides, etc.) is dropped.
"""
try:
st = config_path.stat()
key = (str(config_path), st.st_mtime_ns, st.st_size)
_CONFIG_PARSE_FAILURES[str(config_path)] = (
st.st_mtime_ns,
st.st_size,
str(exc),
)
except OSError:
key = (str(config_path), 0, 0)
if key in _CONFIG_PARSE_WARNED:
return
_CONFIG_PARSE_WARNED.add(key)
backup_path = _backup_corrupt_config(config_path)
if fallback == "last-known-good":
msg = (
f"Failed to parse {config_path}: {exc}. "
f"Keeping the previously loaded config for this process — "
f"edits to config.yaml are being IGNORED until the YAML is fixed."
)
elif fallback == "refuse-write":
msg = (
f"Failed to parse {config_path}: {exc}. "
f"REFUSING to write config.yaml so the existing file is preserved. "
f"Fix the YAML (hermes config edit) and retry."
)
else:
msg = (
f"Failed to parse {config_path}: {exc}. "
f"Falling back to default config — every user override "
f"(auxiliary providers, fallback chain, model settings) is being IGNORED. "
f"Fix the YAML and restart."
)
if backup_path is not None:
msg += f" A copy of the corrupted file was saved to {backup_path}."
logger.warning(msg)
try:
sys.stderr.write(f"⚠️ hermes config: {msg}\n")
sys.stderr.flush()
except Exception:
pass
def get_active_config_parse_failure() -> Optional[str]:
"""Return the parse-error message if the ACTIVE config.yaml is corrupt.
Probes the failure record written by :func:`_warn_config_parse_failure` for the current
:func:`get_config_path` and re-stats the file NOW: the recorded error is returned only while the
file is byte-identical (mtime_ns + size) to the one that failed to parse.
"""
try:
path = get_config_path()
recorded = _CONFIG_PARSE_FAILURES.get(str(path))
if not recorded:
return None
mtime_ns, size, err = recorded
st = path.stat()
if st.st_mtime_ns == mtime_ns and st.st_size == size:
return err
except Exception:
return None
return None
_IS_WINDOWS = platform.system() == "Windows"
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Env var names that influence how the next subprocess executes — never writable
# through ``save_env_value``: dynamic loader (LD_*/DYLD_*: attacker code loads
# before main()), interpreter init (PYTHON*, NODE_*: Hermes restarts through
# them), PATH (too broad; fix tool lookup with absolute paths in integration
# config instead), git rewrites (fire on every plugin install / update),
# implicitly-invoked commands (BROWSER/EDITOR/VISUAL/PAGER = RCE on next $EDITOR),
# SHELL (shell=True defense in depth), and Hermes runtime-location flags
# (config.yaml is the supported surface; .env writes would relocate state).
#
# IMPORTANT: ``HERMES_*`` overall is NOT blocked — many integration credentials
# use that prefix (HERMES_LANGFUSE_PUBLIC_KEY, HERMES_SPOTIFY_CLIENT_ID, ...).
# The denylist is name-by-name so the gate stays narrow and cannot break provider
# setup wizards. Enforced on *write* only: pre-existing/out-of-band ``.env``
# values keep working; the point is the dashboard's writable surface cannot
# escalate by planting them.
_ENV_VAR_NAME_DENYLIST: frozenset[str] = frozenset({
# Loader / linker
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "LD_DEBUG",
"DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_LIBRARY_PATH", "DYLD_FALLBACK_FRAMEWORK_PATH",
# Python
"PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP", "PYTHONUSERBASE",
"PYTHONEXECUTABLE", "PYTHONNOUSERSITE",
# Node
"NODE_OPTIONS", "NODE_PATH",
# General
"PATH", "SHELL", "BROWSER", "EDITOR", "VISUAL", "PAGER",
# Git
"GIT_SSH_COMMAND", "GIT_EXEC_PATH", "GIT_SHELL",
# Hermes runtime location — never via dashboard env writer.
# NOT a HERMES_* blanket: integration credentials (HERMES_GEMINI_*,
# HERMES_LANGFUSE_*, HERMES_SPOTIFY_*, ...) ARE allowed.
"HERMES_HOME", "HERMES_PROFILE", "HERMES_CONFIG", "HERMES_ENV",
"HERMES_CONFIG_PATH", "HERMES_ENV_PATH",
# MCP catalog trust root. Package-manager wrappers may still provide this
# in the process environment; only generic persistence writes are blocked.
"HERMES_OPTIONAL_MCPS",
# Local ACP subprocess selection. Existing operator/package-manager values
# remain readable; generic writers cannot acquire executable/argv authority.
"HERMES_COPILOT_ACP_COMMAND", "HERMES_COPILOT_ACP_ARGS",
# Hermes security policy / approval-routing context. These remain available
# through their dedicated CLI/config/session controls, but a generic
# credential writer must not persist them for the next process startup.
"HERMES_YOLO_MODE", "HERMES_ACCEPT_HOOKS", "HERMES_REDACT_SECRETS",
"HERMES_INTERACTIVE", "HERMES_EXEC_ASK", "HERMES_GATEWAY_SESSION",
"HERMES_CRON_SESSION", "HERMES_SINGLE_QUERY_SESSION",
"HERMES_SESSION_KEY", "HERMES_SESSION_PLATFORM",
})
def _env_var_policy_name(key: str, *, is_windows: Optional[bool] = None) -> str:
"""Return the name used for environment policy comparisons.
Windows environment names are case-insensitive; POSIX names are not. The explicit override keeps
both semantics directly testable without pretending the test interpreter is running on another
host OS.
"""
windows = _IS_WINDOWS if is_windows is None else is_windows
return key.upper() if windows else key
def _reject_denylisted_env_var(key: str) -> None:
"""Raise if ``key`` is in :data:`_ENV_VAR_NAME_DENYLIST`."""
if _env_var_policy_name(key) in _ENV_VAR_NAME_DENYLIST:
raise ValueError(
f"Environment variable {key!r} is on the writer denylist. "
"Names that influence subprocess execution (LD_PRELOAD, "
"PYTHONPATH, PATH, EDITOR, ...) or Hermes runtime location "
"and security policy (HERMES_HOME, HERMES_YOLO_MODE, ...) "
"cannot be persisted via "
"the env writer. If you really need this, edit "
"~/.hermes/.env directly."
)
def validate_env_var_name_for_write(key: str) -> None:
"""Validate an environment name before a generic persistence write.
Exposed separately from :func:`save_env_value` so batch-style callers can validate their
complete request before writing the first value.
"""
if not _ENV_VAR_NAME_RE.match(key):
raise ValueError(f"Invalid environment variable name: {key!r}")
_reject_denylisted_env_var(key)
_LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {}
# path -> (user_mtime_ns, user_size, managed_mtime_ns, managed_size, merged_value,
# env_ref_snapshot). load_config() returns a deepcopy of the cached value while
# the signature matches, skipping safe_load + _deep_merge + _normalize_* +
# _expand_env_vars (~13 ms/call). save_config()/migrate_config() write via
# atomic_yaml_write (fresh inode → new mtime_ns), so no explicit invalidation
# hook is needed. The managed-file signature is folded in so editing the
# managed-scope config.yaml invalidates the cache, and the env snapshot
# invalidates it when a referenced ${VAR} changes value (late .env load,
# in-process rotation).
_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, int, int, Dict[str, Any], Dict[str, Optional[str]]]] = {}
# (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as
# _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want
# the user's on-disk values without defaults merged in.
_RAW_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}
# Serializes all config read/write paths. libyaml's C extension is not
# thread-safe for concurrent safe_load() on the same file, and multiple
# tool threads (approval.py, browser_tool.py, setup flows) hit
# load_config / read_raw_config / save_config from different threads
# during long agent runs. RLock (not Lock) because save_config internally
# calls read_raw_config. Also covers mutation of the module-level cache
# dicts above.
_CONFIG_LOCK = threading.RLock()
# Env var names written to .env that aren't in OPTIONAL_ENV_VARS
# (managed by setup/provider flows directly).
_EXTRA_ENV_KEYS = frozenset({
"OPENAI_API_KEY", "OPENAI_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
"DISCORD_HOME_CHANNEL", "DISCORD_HOME_CHANNEL_NAME",
"TELEGRAM_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL_NAME",
"SLACK_HOME_CHANNEL", "SLACK_HOME_CHANNEL_NAME",
"SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL", "SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
"SIGNAL_HOME_CHANNEL", "SIGNAL_HOME_CHANNEL_NAME", "SMS_HOME_CHANNEL", "SMS_HOME_CHANNEL_NAME",
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET", "DINGTALK_HOME_CHANNEL", "DINGTALK_HOME_CHANNEL_NAME",
"FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
"FEISHU_HOME_CHANNEL", "FEISHU_HOME_CHANNEL_NAME", "YUANBAO_HOME_CHANNEL", "YUANBAO_HOME_CHANNEL_NAME",
"WECOM_BOT_ID", "WECOM_SECRET", "WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET",
"WECOM_CALLBACK_AGENT_ID", "WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY",
"WECOM_CALLBACK_HOST", "WECOM_CALLBACK_PORT", "WECOM_HOME_CHANNEL", "WECOM_HOME_CHANNEL_NAME",
"WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL", "WEIXIN_CDN_BASE_URL",
"WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY",
"WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS",
"BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD", "BLUEBUBBLES_HOME_CHANNEL", "BLUEBUBBLES_HOME_CHANNEL_NAME",
"QQ_APP_ID", "QQ_CLIENT_SECRET", "QQBOT_HOME_CHANNEL", "QQBOT_HOME_CHANNEL_NAME",
"QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", # legacy aliases (pre-rename, still read for back-compat)
"QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT",
"QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL",
"IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL", "IRC_USE_TLS", "IRC_SERVER_PASSWORD",
"IRC_NICKSERV_PASSWORD", "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
# HERMES_TOOL_PROGRESS_MODE is deprecated (replaced by display.tool_progress) but STILL READ
# by the gateway as a back-compat fallback, so it must stay known to reload/compat paths. The
# boolean HERMES_TOOL_PROGRESS variant is unsupported since the v12 support floor retired its
# only consumer (the v3→4 migration): not listed here; doctor flags it as ignored.
"HERMES_TOOL_PROGRESS_MODE",
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE",
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", "MATRIX_DM_AUTO_THREAD",
"MATRIX_RECOVERY_KEY",
# Langfuse observability plugin — optional tuning keys + standard SDK vars. Activation is via
# plugins.enabled (`hermes plugins enable observability/langfuse` / `hermes tools → Langfuse`);
# credentials gate the plugin at runtime.
"HERMES_LANGFUSE_ENV", "HERMES_LANGFUSE_RELEASE", "HERMES_LANGFUSE_SAMPLE_RATE",
"HERMES_LANGFUSE_MAX_CHARS", "HERMES_LANGFUSE_CAPTURE", "HERMES_LANGFUSE_DEBUG",
"LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_BASE_URL",
# ACP (Agent Client Protocol) keys — profile-isolable so different profiles can use
# different ACP backends without cross-leak.
"HERMES_ACP_AUTH_METHOD", "HERMES_ACP_AUTO_APPROVE", "HERMES_COPILOT_ACP_COMMAND",
"HERMES_COPILOT_ACP_ARGS", "COPILOT_CLI_PATH", "COPILOT_ACP_BASE_URL",
})
import yaml
from hermes_cli.colors import Colors, color
from hermes_cli.default_soul import DEFAULT_SOUL_MD, is_legacy_template_soul
# =============================================================================
# Managed mode (NixOS declarative config)
# =============================================================================
_MANAGED_TRUE_VALUES = ("true", "1", "yes")
_NIX_MANAGED_SYSTEMS = {"nixos", "home-manager"}
# Only the NixOS module ever wrote a bare "true" or an empty marker, so both
# legacy signals name that system.
_LEGACY_MANAGED_SYSTEM = "nixos"
# The Nix store root. Used by detect_install_method to identify installs
# from `nix run` / `nix profile install` (which don't set HERMES_MANAGED).
# A module-level constant so tests can patch it without creating files
# under the real /nix/store.
_NIX_STORE = Path("/nix/store")
# Values that used to signal a Homebrew-managed install. Homebrew is no
# longer a supported distribution method, so these are explicitly ignored
# rather than treated as a managed system — they fall through to git/unknown
# detection instead of blocking config writes.
_IGNORED_MANAGED_VALUES = frozenset({"brew", "homebrew"})
def get_managed_system() -> Optional[str]:
"""Return the package manager owning this install, if any."""
marker = os.getenv("HERMES_MANAGED", "").strip().lower() or None
if marker is None:
managed_marker = get_hermes_home() / ".managed"
# An interactive shell reads the marker, because it does not see the
# HERMES_MANAGED variable of the service. A marker with content
# names the system that manages the install.
if managed_marker.exists():
try:
marker = managed_marker.read_text(encoding="utf-8", errors="replace").strip().lower()
except OSError:
marker = ""
if marker is None or marker in _IGNORED_MANAGED_VALUES:
return None
if marker == "" or marker in _MANAGED_TRUE_VALUES:
return _LEGACY_MANAGED_SYSTEM
return marker
def is_managed() -> bool:
"""Check if Hermes is running in package-manager-managed mode.
Two signals: the HERMES_MANAGED env var (set by the systemd service) or a .managed marker
file in HERMES_HOME (set by the NixOS activation script so interactive shells see it too).
"""
return get_managed_system() is not None
# Nix installs arrive by several routes (nix run, nix profile, a system flake,
# home-manager), and the running process cannot tell which one. Thus this text
# names the routes instead of one command.
_NIX_UPDATE_MSG = (
"Update Hermes through the Nix source that installed it "
"(e.g. nix profile upgrade, or update your flake input and rebuild with nixos-rebuild or home-manager switch)"
)
def get_managed_update_command() -> Optional[str]:
"""Return the preferred upgrade command for a managed install."""
managed_system = get_managed_system()
if managed_system in _NIX_MANAGED_SYSTEMS:
return _NIX_UPDATE_MSG
return None
def _install_method_project_root(project_root: Optional[Path] = None) -> Path:
"""Resolve the directory that holds the *running code* (the install tree).
This is the parent of ``hermes_cli/`` — i.e. the git checkout for source installs,
``/opt/hermes`` inside the published image. It is a property of the running interpreter, NOT of
``$HERMES_HOME``, which is why a code-scoped stamp here is immune to two installs sharing one
data directory.
"""
return project_root if project_root is not None else get_project_root()
def detect_install_method(project_root: Optional[Path] = None) -> str:
"""Detect how Hermes was installed: apt/docker/nix/nixos/home-manager/git/unknown.
Order: code-scoped ``<install 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'.
"""
root = _install_method_project_root(project_root)
# "apt" is intentionally the Termux APT distribution identifier, not a
# generic Debian/Ubuntu APT signal. If another APT-managed distribution is
# added, give it a distinct install method or make update-command selection
# platform-aware instead of silently reusing Termux's `pkg` command.
# "home-manager" is here because step 3 can return it. A stamp must name
# every method that this function returns. Without it, the stamp of a
# home-manager install gives "unknown".
supported_methods = {"apt", "docker", "nix", "nixos", "home-manager", "git", "unknown"}
def _stamp(path: Path) -> Optional[str]:
try:
method = path.read_text(encoding="utf-8").strip().lower()
except OSError:
return None
return method if method in supported_methods else None
# 1. Code-scoped stamp — authoritative, immune to shared $HERMES_HOME.
method = _stamp(root / ".install_method")
if method:
return method
# 2. Legacy home-scoped stamp — back-compat. Ignore a ``docker`` value
# when we are not actually containerised: that is the signature of a
# host install whose shared $HERMES_HOME was stamped by a co-located
# container, and honouring it wrongly blocks ``hermes update``.
method = _stamp(get_hermes_home() / ".install_method")
if method and not (method == "docker" and not _running_in_container()):
return method
managed = get_managed_system()
if managed:
return managed.lower().replace(" ", "-")
# detect Nix installs that don't set HERMES_MANAGED (e.g. ``nix run``,
# ``nix profile install``). The code lives under /nix/store/ which is the
# hallmark of a nix-built install — no other supported install path puts
# code there.
try:
resolved = root.resolve()
if resolved != _NIX_STORE and _NIX_STORE in resolved.parents:
return "nix"
except OSError:
pass
# detect git repo installs (normal installer, development env) — a .git
# directory, or a ``gitdir:`` pointer file for worktrees.
git_path = root / ".git"
if git_path.is_dir():
return "git"
if git_path.is_file():
try:
if git_path.read_text(encoding="utf-8").strip().startswith("gitdir:"):
return "git"
except OSError:
pass
return "unknown"
def _running_in_container() -> bool:
"""Thin wrapper around ``hermes_constants.is_container`` (import-safe)."""
try:
from hermes_constants import is_container
return is_container()
except Exception:
return False
def is_nix_install_method(method: str) -> bool:
"""Return True for every install method that Nix owns.
The callers that branch on the install method must treat "nix", "nixos" and "home-manager" the
same way. One helper keeps the three names in one place, so a new Nix shape cannot miss a call
site.
"""
return method == "nix" or method in _NIX_MANAGED_SYSTEMS
def recommended_update_command_for_method(method: str) -> str:
"""Return the update command or guidance for a given install method."""
if is_nix_install_method(method):
return _NIX_UPDATE_MSG
if method == "docker":
return "docker pull nousresearch/hermes-agent:latest"
if method == "apt":
# By contract, the current "apt" install method is the Termux APT
# distribution. It deliberately uses Termux's `pkg` frontend.
return "pkg upgrade hermes-agent"
return "hermes update"
def recommended_update_command() -> str:
"""Return the best update command for the current installation."""
# The managed state wins over the code-scoped stamp. A managed install
# can carry a stale stamp from an earlier install shape, and the stamp
# then names an update path that the managed guard refuses.
managed_cmd = get_managed_update_command()
if managed_cmd:
return managed_cmd
method = detect_install_method(get_project_root())
return recommended_update_command_for_method(method)
# Long-form text for ``hermes update`` / ``--check`` inside the Docker image,
# shared by ``cmd_update`` and ``_cmd_update_check`` (hermes_cli/main.py) so the
# wording never forks. The published image excludes ``.git`` (.dockerignore), so
# the git update path can never succeed there, and the generic "Not a git
# repository, reinstall via install.sh" fallback is misleading (that installs a
# NEW host-side Hermes). The right action is ``docker pull`` + restart.
_DOCKER_UPDATE_MESSAGE = """\
✗ ``hermes update`` doesn't apply inside the Docker container.
Hermes Agent runs as a published image (nousresearch/hermes-agent), not a
git checkout — the container has no working tree to pull into. Update by
pulling a fresh image and restarting your container instead:
docker pull nousresearch/hermes-agent:latest
# then restart whatever started the container, e.g.:
docker compose up -d --force-recreate hermes-agent
# or, for ad-hoc runs, exit the current container and `docker run` again
Verify the new version after restart:
docker run --rm nousresearch/hermes-agent:latest --version
Notes:
• If you pinned a specific tag (e.g. ``:v0.14.0``) the ``:latest`` tag
won't move your container — pull the newer tag you actually want, or
switch to ``:latest`` / ``:main`` for rolling updates. See available
tags at https://hub.docker.com/r/nousresearch/hermes-agent/tags
• Your config and session history live under ``$HERMES_HOME`` (``/opt/data``
in the container, typically bind-mounted from the host) and persist
across image upgrades — re-pulling doesn't lose any state.
• Running a fork? Build your own image with this repo's ``Dockerfile``
and replace the ``docker pull`` step with your build/push pipeline."""
def format_docker_update_message() -> str:
"""Return the user-facing message for ``hermes update`` inside Docker.
Centralised so ``cmd_update`` (the apply path) and ``_cmd_update_check`` (the dry-run path)
share the same wording. See ``_DOCKER_UPDATE_MESSAGE`` above for the full rationale.
"""
return _DOCKER_UPDATE_MESSAGE
def format_managed_message(action: str = "modify this Hermes installation") -> str:
"""Build a user-facing error for managed installs."""
managed_system = get_managed_system() or "a package manager"
return (
f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n"
"Use your package manager to upgrade or reinstall Hermes."
)
def managed_error(action: str = "modify configuration"):
"""Print user-friendly error for managed mode."""
print(format_managed_message(action), file=sys.stderr)
# =============================================================================
# Container-aware CLI (NixOS container mode)
# =============================================================================
def get_container_exec_info() -> Optional[dict]:
"""Read container mode metadata from HERMES_HOME/.container-mode.
Written by the NixOS activation script when container.enable = true; tells the host CLI to
exec into the container instead of running locally. Returns None when container mode is off,
when already inside the container, or when HERMES_DEV=1 is set.
"""
if os.environ.get("HERMES_DEV") == "1":
return None
from hermes_constants import is_container
if is_container():
return None
container_mode_file = get_hermes_home() / ".container-mode"
try:
info = {}
with open(container_mode_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if "=" in line and not line.startswith("#"):
key, _, value = line.partition("=")
info[key.strip()] = value.strip()
except FileNotFoundError:
return None
# All other exceptions (PermissionError, malformed data, etc.) propagate
return {
"backend": info.get("backend", "docker"),
"container_name": info.get("container_name", "hermes-agent"),
"exec_user": info.get("exec_user", "hermes"),
"hermes_bin": info.get("hermes_bin", "/data/current-package/bin/hermes"),
}
# =============================================================================
# Config paths
# =============================================================================
# Re-export from hermes_constants — canonical definition lives there.
from hermes_constants import get_hermes_home, get_process_hermes_home # noqa: E402,F401
from utils import atomic_replace, fast_safe_load
def get_config_path() -> Path:
"""Get the main config file path."""
return get_hermes_home() / "config.yaml"
def require_parseable_user_config(*, ignore_user_config: bool = False) -> None:
"""Reject an existing invalid config before a non-interactive agent run.
Interactive surfaces retain ``load_config()``'s recovery behavior so the operator can repair
their configuration. A one-shot or single-query run has no such repair opportunity: allowing
defaults there can silently pick a hosted provider/model and spend against credentials loaded
from ``.env``.
Missing and empty files remain valid first-run states. The explicit ``--ignore-user-
config``/safe-mode escape hatch also remains authoritative.
"""
if ignore_user_config or os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1":
return
config_path = get_config_path()
try:
with open(config_path, encoding="utf-8") as f:
data = fast_safe_load(f)
except FileNotFoundError:
return
except Exception as exc:
parse_error = exc
else:
if data is None or isinstance(data, dict):
return
parse_error = TypeError(
f"top-level YAML value must be a mapping, got {type(data).__name__}"
)
backup_path = _backup_corrupt_config(config_path)
message = (
f"Refusing non-interactive startup because {config_path} is invalid: "
f"{parse_error}. Repair the file or pass --ignore-user-config to "
"intentionally run with built-in defaults."
)
if backup_path is not None:
message += f" A copy was saved to {backup_path}."
logger.error(message)
raise InvalidUserConfigError(message) from parse_error
def get_env_path() -> Path:
"""Get the .env file path (for API keys)."""
return get_hermes_home() / ".env"
def get_project_root() -> Path:
"""Get the project installation directory."""
return Path(__file__).parent.parent.resolve()
def _resolve_hermes_uid_gid() -> tuple[Optional[int], Optional[int]]:
"""Read the HERMES_UID / HERMES_GID env vars set by Docker deployments.
The entrypoint chowns the top-level HERMES_HOME once, but subdirectories created at runtime
(notably ``profiles/<name>/``) need the same chown or they land as root:root and block later
uid-mapped workers with PermissionError. Returns (None, None) if unset/invalid or on Windows.
"""
if sys.platform == "win32":
return None, None
def _env_int(name: str) -> Optional[int]:
raw = os.environ.get(name, "").strip()
try:
return int(raw) if raw else None
except ValueError:
return None
return _env_int("HERMES_UID"), _env_int("HERMES_GID")
def _chown_to_hermes_uid(path) -> None:
"""Chown ``path`` to ``HERMES_UID:HERMES_GID`` if those env vars are set.
No-op when: - Either env var is unset/invalid - The current process isn't root (chown will EPERM
— silently ignored) - On Windows (chown semantics don't apply)
"""
uid, gid = _resolve_hermes_uid_gid()
if uid is None and gid is None:
return
try:
# os.chown with -1 means "don't change" for that field.
os.chown(
path,
uid if uid is not None else -1,
gid if gid is not None else -1,
)
except (OSError, AttributeError, NotImplementedError):
# OSError covers EPERM (not running as root) and ENOENT (race),
# both of which are non-fatal — the dir is still created and
# the entrypoint's startup chown -R will fix it on next restart.
pass
def _secure_dir(path):
"""Set directory to owner-only access (0700 by default). No-op on Windows.
The mode can be overridden via the HERMES_HOME_MODE environment variable (e.g.
HERMES_HOME_MODE=0701) for deployments where a web server (nginx, caddy, etc.) needs to traverse
HERMES_HOME to reach a served subdirectory. The execute-only bit on a directory permits cd-
through without exposing directory listings.
Also applies ``HERMES_UID``/``HERMES_GID``-based ownership when those env vars are set (#34107 —
Docker deployments need this so profile subdirs created at runtime by kanban workers don't land
as root:root and block subsequent uid-mapped workers).
"""
if is_managed():
return
mode = 0o700
try:
mode_str = os.environ.get("HERMES_HOME_MODE", "").strip()
if mode_str:
mode = int(mode_str, 8)
except ValueError:
pass
try:
os.chmod(path, mode)
except (OSError, NotImplementedError):
pass
_chown_to_hermes_uid(path)
def _is_container() -> bool:
"""Detect if we're running inside a Docker/Podman/LXC container.
Used to skip forcing 0o600 on volume-mounted config: in containers the gateway and dashboard
may run as different UIDs, or the mount itself needs broader permissions.
"""
# Explicit opt-out
if os.environ.get("HERMES_CONTAINER") or os.environ.get("HERMES_SKIP_CHMOD"):
return True
# Docker / Podman marker file
if os.path.exists("/.dockerenv"):
return True
# LXC / cgroup-based detection
try:
with open("/proc/1/cgroup", "r", encoding="utf-8") as f:
cgroup_content = f.read()
if "docker" in cgroup_content or "lxc" in cgroup_content or "kubepods" in cgroup_content:
return True
except (OSError, IOError):
pass
return False
def _secure_file(path):
"""Set file to owner-only read/write (0600). No-op on Windows.
Skipped in managed mode (the NixOS activation script sets 0640 group-readable config) and in
containers (volume mounts often need broader permissions). HERMES_SKIP_CHMOD=1 forces a skip.
"""
if is_managed() or _is_container():
return
try:
if os.path.exists(str(path)):
os.chmod(path, 0o600)
except (OSError, NotImplementedError):
pass
def _ensure_default_soul_md(home: Path) -> None:
"""Seed a default SOUL.md into HERMES_HOME, upgrading legacy empty templates.
First run: write DEFAULT_SOUL_MD. Installs whose SOUL.md is still the old comment-only
scaffold (seeded by older installers/images, shadowing the runtime default) are upgraded in
place. A SOUL.md the user actually customized is never touched.
"""
soul_path = home / "SOUL.md"
if soul_path.exists():
try:
existing = soul_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return
if not is_legacy_template_soul(existing):
return
# Legacy empty template -> upgrade to the real default in place.
soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8")
_secure_file(soul_path)
# Home paths whose directory skeleton has been created this process — see
# ensure_hermes_home(). Only successful passes are recorded, so a raised
# managed-mode/missing-profile error keeps re-checking on later loads.
_HERMES_HOME_ENSURED: set = set()
def ensure_hermes_home():
"""Ensure ~/.hermes directory structure exists with secure permissions.
Memoized per home path: this runs on EVERY ``load_config()`` (inside the config lock), and the
~14 mkdir/chmod syscalls per call made repeated config loads the dominant cost of hot read paths
like ``model.options``.
"""
home = get_hermes_home()
key = str(home)
# Named profiles must be created explicitly (e.g. ``hermes profile create``).
# Check tombstones before the memo so a stale empty shell cannot skip
# the deleted-profile guard.
from hermes_constants import assert_named_profile_home_live
assert_named_profile_home_live(home)
if key in _HERMES_HOME_ENSURED and home.is_dir():
return
if is_managed():
old_umask = os.umask(0o007)
try:
_ensure_hermes_home_managed(home)
finally:
os.umask(old_umask)
else:
home.mkdir(parents=True, exist_ok=True)
_secure_dir(home)
for subdir in (
"cron", "sessions", "logs", "logs/curator", "memories",
"pairing", "hooks", "image_cache", "audio_cache", "skills",
):
d = home / subdir
d.mkdir(parents=True, exist_ok=True)
_secure_dir(d)
_ensure_default_soul_md(home)
_HERMES_HOME_ENSURED.add(key)
def _ensure_hermes_home_managed(home: Path):
"""Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md."""
if not home.is_dir():
raise RuntimeError(
f"HERMES_HOME {home} does not exist."
)
for subdir in ("cron", "sessions", "logs", "memories"):
d = home / subdir
if not d.is_dir():
raise RuntimeError(f"{d} does not exist.")
# Curator reports dir is a sub-path of logs/; create it if missing.
# In managed mode the activation script may not know about this subdir,
# so we mkdir it ourselves (it's inside an already-secured logs/ dir).
(home / "logs" / "curator").mkdir(parents=True, exist_ok=True)
# Inside umask(0o007) scope — SOUL.md will be created as 0660
_ensure_default_soul_md(home)
# =============================================================================
# Config loading/saving
# =============================================================================
from hermes_cli.config_defaults import DEFAULT_CONFIG, OPTIONAL_ENV_VARS # noqa: F401
from hermes_cli.config_providers import ( # noqa: F401 (re-exported; callers/tests use hermes_cli.config.<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,
)
# =============================================================================
# Config Migration System
# =============================================================================
# Track which env vars were introduced in each config version.
# Migration only mentions vars new since the user's previous version.
ENV_VARS_BY_VERSION: Dict[int, List[str]] = {
3: ["FIRECRAWL_API_KEY", "BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "FAL_KEY"],
4: ["VOICE_TOOLS_OPENAI_KEY", "ELEVENLABS_API_KEY"],
5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS",
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"],
10: ["TAVILY_API_KEY"],
11: ["TERMINAL_MODAL_MODE"],
}
# Required environment variables with metadata for migration prompts.
# LLM provider is required but handled in the setup wizard's provider
# selection step (Nous Portal / OpenRouter / Custom endpoint), so this
# dict is intentionally empty — no single env var is universally required.
REQUIRED_ENV_VARS = {}
# Tool Gateway env vars are always visible — they're useful for
# self-hosted / custom gateway setups regardless of subscription state.
def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:
"""Check which environment variables are missing."""
groups = [(REQUIRED_ENV_VARS, True)]
if not required_only:
groups.append((OPTIONAL_ENV_VARS, False))
return [
{"name": var_name, **info, "is_required": is_required}
for table, is_required in groups
for var_name, info in table.items()
if not get_env_value(var_name)
]
def _split_key_path(key: str) -> list[str]:
"""Split a dotted config-key path, honoring backslash-escaped dots.
Backslashes before any other character are preserved verbatim. Keys without escapes behave
exactly as ``key.split(".")``.
"""
parts: list[str] = []
current: list[str] = []
i = 0
while i < len(key):
ch = key[i]
if ch == "\\" and i + 1 < len(key) and key[i + 1] == ".":
current.append(".")
i += 2
continue
if ch == ".":
parts.append("".join(current))
current = []
i += 1
continue
current.append(ch)
i += 1
parts.append("".join(current))
return parts
def _greedy_literal_match(container: dict, parts: list) -> Optional[Tuple[str, int]]:
"""Return ``(literal_key, n_consumed)`` for the longest dotted literal match.
Backward compatible: when no multi-segment literal key exists, the single segment ``parts[0]``
is the only candidate, which is exactly the historic plain-split behavior. Returns ``None`` when
nothing matches.
"""
if not isinstance(container, dict) or not parts:
return None
return next(
((".".join(parts[:n]), n) for n in range(len(parts), 0, -1) if ".".join(parts[:n]) in container),
None,
)
def _phantom_sibling(container: dict, part: str) -> Optional[str]:
"""Return an existing sibling key that ``part`` would shadow, if any.
Called before CREATING an intermediate mapping named ``part``. If the mapping already holds
a literal dotted key starting with ``part + '.'`` (creating ``grok-4`` beside ``grok-4.5``),
the split chopped a dotted leaf and the write would produce a phantom sibling the runtime
never reads -- fail loudly instead of silently corrupting.
"""
if not isinstance(container, dict):
return None
prefix = part + "."
return next((k for k in container if isinstance(k, str) and k.startswith(prefix)), None)
def _set_nested(config, dotted_key: str, value):
"""Set a value at an arbitrarily nested dotted key path.
Intermediate dicts are created on demand. List indices are parsed from numeric path segments;
the referenced index must already exist (we do not grow lists — the user is navigating into
structure they wrote themselves).
"""
parts = _split_key_path(dotted_key)
current = config
i = 0
while i < len(parts):
remaining = parts[i:]
at_leaf = len(remaining) == 1
if isinstance(current, list):
part = remaining[0]
if at_leaf:
current[int(part)] = value
return
try:
idx = int(part)
except (TypeError, ValueError):
raise TypeError(
f"Cannot navigate into list at key {dotted_key!r}: "
f"segment {part!r} is not a numeric index"
)
current = current[idx]
i += 1
elif isinstance(current, dict):
match = _greedy_literal_match(current, remaining)
if match is not None:
key, consumed = match
if i + consumed == len(parts):
current[key] = value
return
existing = current.get(key)
# Preserve dicts and lists; replace scalar with a fresh dict.
if not isinstance(existing, (dict, list)):
current[key] = {}
current = current[key]
i += consumed
continue
part = remaining[0]
if at_leaf:
current[part] = value
return
# About to CREATE an intermediate mapping. Refuse when that would
# write a phantom sibling of an existing dotted literal key.
shadowed = _phantom_sibling(current, part)
if shadowed is not None:
escaped = shadowed.replace(".", "\\.")
raise ValueError(
f"Refusing to create nested key {part!r} in {dotted_key!r}: "
f"the mapping already contains a literal key {shadowed!r} "
f"that contains a dot. If you meant that key, escape its "
f"dots with a backslash (e.g. {escaped})."
)
current[part] = {}
current = current[part]
i += 1
else:
raise TypeError(
f"Cannot navigate into {type(current).__name__} at key {dotted_key!r}"
)
def clear_model_endpoint_credentials(
model_cfg: Dict[str, Any],
*,
clear_api_key: bool = True,
clear_api_mode: bool = True,
clear_base_url: bool = False,
) -> Dict[str, Any]:
"""Remove stale inline endpoint credentials from a model config.
``model.api_key`` is valid only for explicit custom endpoint assignments. Built-in providers
resolve credentials from env vars, auth.json, or the credential pool. When switching away from a
custom endpoint, leaving these fields behind keeps secrets in config.yaml and can contaminate
later custom resolution paths.
"""
if not isinstance(model_cfg, dict):
return model_cfg
if clear_api_key:
model_cfg.pop("api_key", None)
model_cfg.pop("api", None)
if clear_api_mode:
model_cfg.pop("api_mode", None)
if clear_base_url:
model_cfg.pop("base_url", None)
return model_cfg
_MISSING = object()
def _locate_nested(config, parts: list):
"""Walk *parts* through nested dicts/lists (escape-aware, greedy-literal like ``_set_nested``).
Returns ``(parents, container, key)`` where ``container[key]`` is the addressed leaf and
``parents`` lists the ``(container, key)`` hops above it, or ``None`` when any hop is missing,
a list index is non-numeric/out of range, or a scalar is hit before the path is consumed.
"""
parents = []
current = config
i = 0
while True:
remaining = parts[i:]
if isinstance(current, list):
try:
key = int(remaining[0])
current[key]
except (TypeError, ValueError, IndexError):
return None
consumed = 1
elif isinstance(current, dict):
match = _greedy_literal_match(current, remaining)
if match is None:
return None
key, consumed = match
else:
return None
i += consumed
if i == len(parts):
return parents, current, key
parents.append((current, key))
current = current[key]
def _get_nested(config, dotted_key: str):
"""Return a dotted-path value from nested dict/list config data.
Mirrors ``_set_nested`` navigation: honors backslash-escaped dots and prefers an existing
literal dotted key over blind splitting, so ``models.grok-4.6.context_length`` reads the real
``grok-4.6`` entry instead of reporting the key unset.
"""
loc = _locate_nested(config, _split_key_path(dotted_key))
if loc is None:
return _MISSING
_, container, key = loc
return container[key]
def _unset_nested(config, dotted_key: str) -> bool:
"""Remove a dotted-path value from nested dict/list config data.
Same escape-aware, greedy-literal navigation as ``_set_nested`` / ``_get_nested``: unsetting
an unescaped dotted key removes the real literal entry rather than a phantom sibling.
"""
loc = _locate_nested(config, _split_key_path(dotted_key))
if loc is None:
return False
parents, current, key = loc
if isinstance(current, list):
current.pop(key)
else:
del current[key]
# Drop empty dict containers left behind by the deletion while preserving
# user-authored empty lists and non-empty sibling branches.
for parent, part in reversed(parents):
if current != {}:
break
if isinstance(parent, list):
if 0 <= part < len(parent) and parent[part] == {}:
parent.pop(part)
current = parent
continue
elif isinstance(parent, dict) and parent.get(part) == {}:
del parent[part]
current = parent
continue
break
return True
def _is_env_config_key(key: str) -> bool:
"""Return whether `hermes config set` routes this key to .env."""
if "." in key:
return False
key_upper = key.upper()
api_keys = [
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL',
'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME',
'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY', 'API_SERVER_KEY',
'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN',
'GITHUB_TOKEN', 'HONCHO_API_KEY',
]
return (
key_upper in api_keys
or key_upper.endswith(('_API_KEY', '_TOKEN', '_SECRET'))
or key_upper.startswith('TERMINAL_SSH')
)
def _format_config_get_value(value, *, as_json: bool) -> str:
"""Format a config value for command-line output."""
if as_json:
return json.dumps(value, ensure_ascii=False)
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return "null"
if isinstance(value, (dict, list)):
return yaml.safe_dump(value, sort_keys=False).rstrip()
return str(value)
def get_missing_config_fields() -> List[Dict[str, Any]]:
"""Check which config fields are missing or outdated (recursive)."""
config = load_config()
missing = []
def _check(defaults: dict, current: dict, prefix: str = ""):
for key, default_value in defaults.items():
if key.startswith('_'):
continue
full_key = key if not prefix else f"{prefix}.{key}"
if key not in current:
missing.append({
"key": full_key,
"default": default_value,
"description": f"New config option: {full_key}",
})
elif isinstance(default_value, dict) and isinstance(current.get(key), dict):
_check(default_value, current[key], full_key)
_check(DEFAULT_CONFIG, config)
return missing
def get_missing_skill_config_vars() -> List[Dict[str, Any]]:
"""Return skill-declared config vars that are missing or empty in config.yaml."""
try:
from agent.skill_utils import discover_all_skill_config_vars, SKILL_CONFIG_PREFIX
except Exception:
return []
try:
all_vars = discover_all_skill_config_vars()
except Exception as e:
# A malformed SKILL.md, unreadable external skill dir, or similar
# should never break `hermes update`. Skill-config prompting is a
# post-migration nicety, not a blocker.
logger.debug("discover_all_skill_config_vars failed: %s", e)
return []
if not all_vars:
return []
config = load_config()
missing: List[Dict[str, Any]] = []
for var in all_vars:
# Skill config is stored under skills.config.<logical_key>;
# missing = key doesn't exist or is an empty string.
value = cfg_get(config, *f"{SKILL_CONFIG_PREFIX}.{var['key']}".split("."))
if value is None or (isinstance(value, str) and not value.strip()):
missing.append(var)
return missing
def _coerce_config_version(value: Any) -> int:
"""Return a safe integer config version, treating invalid values as legacy."""
if isinstance(value, bool):
return 0
try:
version = int(value)
except (TypeError, ValueError):
return 0
return max(version, 0)
def _raw_config_has_explicit_version() -> bool:
"""True when config.yaml exists, parses, and carries a ``_config_version`` key.
Distinguishes an ANCIENT config (explicit old version → refused by the v12 support floor) from a
fresh minimal/hand-written/cloned config with no version key at all (→ migrated + stamped
normally). Missing or unparseable files return False so they never trip the floor gate.
"""
config_path = get_config_path()
if not config_path.exists():
return False
try:
raw = read_user_config_raw(config_path)
except Exception:
return False
return "_config_version" in raw
def check_config_version() -> Tuple[int, int]:
"""Check the raw on-disk config schema version; returns (current_version, latest_version).
Reads the raw file rather than ``load_config()``, which deep-merges over ``DEFAULT_CONFIG``
and would make a file lacking ``_config_version`` inherit the latest version in memory,
hiding that the persisted schema was never migrated.
"""
latest = _coerce_config_version(DEFAULT_CONFIG.get("_config_version", 1)) or 1
config_path = get_config_path()
if not config_path.exists():
return latest, latest
try:
with open(config_path, encoding="utf-8") as f:
config = fast_safe_load(f) or {}
except Exception as e:
# Invalid YAML needs a parse warning, not an automatic schema rewrite
# that could replace the user's broken file with defaults.
_warn_config_parse_failure(config_path, e)
return latest, latest
if not isinstance(config, dict):
config = {}
current = _coerce_config_version(config.get("_config_version"))
return current, latest
# =============================================================================
# Config structure validation
# =============================================================================
# Fields that are valid at root level of config.yaml.
# DEFAULT_CONFIG is the single source of truth for documented roots; keep this
# set derived so new defaults (skills, security, browser, …) are accepted
# automatically. A few optional/legacy roots are valid on disk but intentionally
# absent from DEFAULT_CONFIG (omitted when unused / alternate schema forms).
_EXTRA_KNOWN_ROOT_KEYS = {
"custom_providers", # legacy list form; modern equivalent is providers: {}
"fallback_model", # optional single dict or chain list; omitted when disabled
"mcp_servers", # MCP server definitions written by setup/tools flows
# Roots read from the raw user YAML (or written by our own flows) that are
# intentionally absent from DEFAULT_CONFIG:
"image_gen", # image-generation provider config (agent/image_gen_registry.py)
"video_gen", # video-generation provider config (agent/video_gen_registry.py)
"plugins", # plugin enable/disable lists (hermes_cli/plugins_cmd.py)
"smart_model_routing", # written by the setup wizard (hermes_cli/setup.py)
"platform_toolsets", # written by the setup wizard (hermes_cli/setup.py)
"known_plugin_toolsets", # written/read by hermes_cli/tools_config.py toolset-save flow
"known_builtin_toolsets", # ditto — which builtin toolsets a platform's checklist has offered
"tool_gateway_declined_tools", # per-tool Tool Gateway offer declines (hermes_cli/nous_subscription.py, #92647)
"session_reset", # top-level form read by gateway/config.py + setup
"group_sessions_per_user", # top-level form bridged by gateway/config.py
"thread_sessions_per_user", # top-level form bridged by gateway/config.py
"stt_echo_transcripts", # top-level form bridged by gateway/config.py
"reset_triggers", # top-level form bridged by gateway/config.py
"always_log_local", # top-level form bridged by gateway/config.py
"filter_silence_narration", # top-level form bridged by gateway/config.py
"multiplex_profiles", # top-level form accepted alongside gateway.multiplex_profiles
"profile_routes", # top-level form accepted alongside gateway.profile_routes
"platforms", # top-level per-platform map merged by gateway/config.py
"require_mention", # top-level convenience form honored by the gateway (#3979)
"unauthorized_dm_behavior", # top-level form read by gateway/config.py
"signal", # Signal settings bridged to env vars by gateway/config.py
"timeouts", # unified timeout resolution section (agent/deadline.py, #85125)
}
_KNOWN_ROOT_KEYS = frozenset(DEFAULT_CONFIG.keys()) | _EXTRA_KNOWN_ROOT_KEYS
# Valid fields inside a custom_providers list entry
_VALID_CUSTOM_PROVIDER_FIELDS = {
"name", "base_url", "api_key", "api_mode", "model", "models",
"context_length", "rate_limit_delay", "extra_body",
"ssl_ca_cert", "ssl_verify",
# key_env is read at runtime by runtime_provider.py and auxiliary_client.py
# — include it here so the set accurately describes the supported schema.
"key_env",
}
# Fields that look like they should be inside custom_providers, not at root
_CUSTOM_PROVIDER_LIKE_FIELDS = {"base_url", "api_key", "rate_limit_delay", "api_mode"}
@dataclass
class ConfigIssue:
"""A detected config structure problem."""
severity: str # "error", "warning"
message: str
hint: str
def _require_fields(
issues: List["ConfigIssue"],
entry: Dict[str, Any],
label: str,
fields: Tuple[Tuple[str, str], ...],
suffix: str = "",
) -> None:
"""Append a warning for every falsy ``field`` of *entry* (message: ``<label> is missing '<f>' field``)."""
for field, hint in fields:
if not entry.get(field):
issues.append(ConfigIssue(
"warning",
f"{label} is missing '{field}' field{suffix}",
hint,
))
def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["ConfigIssue"]:
"""Validate config.yaml structure and return a list of detected issues.
Catches common YAML formatting mistakes that otherwise surface as confusing runtime errors
(like "Unknown provider"). Accepts a pre-loaded config dict or loads from disk.
"""
if config is None:
try:
config = load_config()
except Exception:
return [ConfigIssue("error", "Could not load config.yaml", "Run 'hermes setup' to create a valid config")]
issues: List[ConfigIssue] = []
# ── voice.submit_mode: direct | draft ────────────────────────────────
voice_cfg = config.get("voice")
if isinstance(voice_cfg, dict) and "submit_mode" in voice_cfg:
submit_mode = voice_cfg.get("submit_mode")
normalized_submit_mode = (
submit_mode.strip().lower() if isinstance(submit_mode, str) else None
)
if normalized_submit_mode not in {"direct", "draft"}:
issues.append(ConfigIssue(
"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)",
))
# ── custom_providers must be a list, not a dict ──────────────────────
cp = config.get("custom_providers")
if cp is not None:
if isinstance(cp, dict):
issues.append(ConfigIssue(
"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: ...",
))
# Check if dict keys look like they should be list-entry fields
cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()
suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS
if suspicious:
issues.append(ConfigIssue(
"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 each entry in the list
for i, entry in enumerate(cp):
if not isinstance(entry, dict):
issues.append(ConfigIssue(
"warning",
f"custom_providers[{i}] is not a dict (got {type(entry).__name__})",
"Each entry should have at minimum: name, base_url",
))
continue
_require_fields(issues, entry, f"custom_providers[{i}]", (
("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"),
))
# ── fallback_model: single dict OR list of dicts (chain) ─────────────
fb = config.get("fallback_model")
if fb is not None:
if isinstance(fb, list):
# Chain fallback — validate each entry
for i, entry in enumerate(fb):
if not isinstance(entry, dict):
issues.append(ConfigIssue(
"error",
f"fallback_model[{i}] should be a dict, got {type(entry).__name__}",
"Each entry needs provider + model",
))
else:
_require_fields(issues, entry, f"fallback_model[{i}]", (
("provider", "Add: provider: openrouter (or another provider)"),
("model", "Add: model: <model-name>"),
))
elif not isinstance(fb, dict):
issues.append(ConfigIssue(
"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", (
("provider", "Add: provider: openrouter (or another provider)"),
("model", "Add: model: anthropic/claude-sonnet-4 (or another model)"),
), suffix=" — fallback will be disabled")
# ── Check for fallback_model accidentally nested inside custom_providers ──
if isinstance(cp, dict) and "fallback_model" not in config and "fallback_model" in (cp or {}):
issues.append(ConfigIssue(
"error",
"fallback_model appears inside custom_providers instead of at root level",
"Move fallback_model to the top level of config.yaml (no indentation)",
))
# ── model section: should exist when custom_providers is configured ──
model_cfg = config.get("model")
if cp and not model_cfg:
issues.append(ConfigIssue(
"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://...",
))
# ── Root-level keys that look misplaced ──────────────────────────────
# Only provider-like fields (base_url, api_key, …) are flagged. Arbitrary
# unknown top-level keys are deliberately NOT warned about: top-level
# scalars are bridged into os.environ (gateway/run.py, hermes send) so
# users can feed skills and external apps env-style keys from config.yaml
# — a closed-world allowlist can never enumerate those.
for key in config:
if key.startswith("_"):
continue
if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:
issues.append(ConfigIssue(
"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",
))
# ── web backends that no longer ship in-tree ─────────────────────────
# A stale selection (e.g. web.backend: tavily after the #99199 removal)
# otherwise fails only at the first web_search/web_extract call, with a
# generic "no registered provider" error. Warn at startup instead.
web_cfg = config.get("web")
if isinstance(web_cfg, dict):
try:
from tools.tool_backend_helpers import removed_backend_note
except Exception:
removed_backend_note = None
if removed_backend_note is not None:
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:
issues.append(ConfigIssue(
"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",
))
return issues
def print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:
"""Print config structure warnings to stderr at startup.
Called early in CLI and gateway init so users see problems before they hit cryptic "Unknown
provider" errors. Prints nothing if config is healthy.
"""
try:
issues = validate_config_structure(config)
except Exception:
return
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 or TERMINAL_CWD is set in .env instead of config.yaml.
These env vars are deprecated — the canonical setting is terminal.cwd in config.yaml. Read the
file rather than ``os.environ`` because runtime config bridges and session restoration
legitimately set ``TERMINAL_CWD``. Prints a migration hint to stderr.
"""
try:
env_map = load_env()
except Exception:
return
messaging_cwd = str(env_map.get("MESSAGING_CWD") or "").strip()
terminal_cwd_env = str(env_map.get("TERMINAL_CWD") or "").strip()
lines: list[str] = []
if messaging_cwd:
lines.append(
f" \033[33m⚠\033[0m MESSAGING_CWD={messaging_cwd} found in .env — "
f"this is deprecated."
)
if terminal_cwd_env:
lines.append(
f" \033[33m⚠\033[0m TERMINAL_CWD={terminal_cwd_env} found in .env — "
f"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.
THE INVARIANT (single source of truth for the whole migration pipeline): a migration may only
persist values that DIFFER from the current schema default, plus explicit removals/renames of
user data.
Every migration step MUST route its write through this helper instead of calling ``save_config``
directly. It is a thin wrapper over ``save_config(config)`` (default-stripping ON, no
``merge_existing``); centralising the call makes the invariant impossible to 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."""
if info.get("password"):
value = masked_secret_prompt(prompt)
else:
value = 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": []}
# ── Always: normalize safe .env line formatting ──
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; don't block migration on sanitize failure
# Check config version
current_ver, latest_ver = check_config_version()
# ── Auto-migration support floor (v12) ──
# An EXPLICIT on-disk ``_config_version`` below the floor is NOT migrated
# and NOT rewritten: surface an actionable message and leave the file
# byte-for-byte untouched (fail-safe like unparseable configs; deep-merge
# supplies defaults at read time). The gate lives here so run_migrations
# stays a pure mechanism tests can drive directly. A config with NO version
# key is a fresh minimal config (profile clones, hand-written two-liners),
# not an ancient install — it gets the normal ladder and a version stamp.
from hermes_cli.config_migrations import (
SUPPORT_FLOOR_VERSION,
run_migrations,
support_floor_message,
)
_explicit_version = _raw_config_has_explicit_version()
floor_refused = (
_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, matching the
# corrupt-config warning posture in _warn_config_parse_failure().
sys.stderr.write(f"⚠ hermes config: {msg}\n")
if not quiet:
print(f" ⚠ {msg}")
else:
# Versioned ladder: every registered step whose target exceeds
# current_ver, in strict ascending order (hermes_cli.config_migrations;
# imported lazily because the steps call back into this module).
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}")
# Check for missing required env vars
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()
# Check for missing optional env vars and offer to configure interactively
# Skip "advanced" vars (like OPENAI_BASE_URL) -- those are for power users
missing_optional = get_missing_env_vars(required_only=False)
required_names = {v["name"] for v in missing_env} if missing_env else set()
missing_optional = [
v for v in missing_optional
if v["name"] not in required_names and not v.get("advanced")
]
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 (see _persist_migration's invariant). The list
# only feeds the informational "N new config option(s)" display; only the
# version bump is persisted.
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)
# Skill-declared config.yaml settings (``metadata.hermes.config`` in SKILL.md).
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.
Users can hand-edit mcp_servers, and older installs may already contain a malicious entry.
Preserve the stanza for auditability but mark it disabled so the next startup will not spawn it.
"""
config = read_raw_config()
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():
if not isinstance(entry, dict):
continue
issues = validate_mcp_server_entry(server_name, entry)
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 loudly.
``resolve_toolset()`` returns [] for an unknown name, so a migration or hand-edit that leaves
one behind silently disables 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
ts_warnings = validate_platform_toolsets(
read_raw_config().get("platform_toolsets"),
validate_toolset,
toolset_allowed_for_platform,
)
for w in ts_warnings:
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_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, []))
if not new_var_names:
return
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:
return
print(f"\n {len(new_and_unset)} new optional key(s) in this update:")
for name, info in new_and_unset:
print(f" • {name} — {info.get('description', '')}")
print()
if not _ask_yes_no(" Configure new keys? [y/N]: "):
print(" Set later with: hermes config set <key> <value>")
return
print()
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."""
print(f"\n {len(missing_skill_config)} skill setting(s) not configured:")
for var in missing_skill_config:
print(f" • {var['key']} — {var['description']} (from skill: {var.get('skill', 'unknown')})")
print()
if not _ask_yes_no(" Configure skill settings? [y/N]: "):
print(" Set later with: hermes config set <key> <value>")
return
print()
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) if default else "")
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.
Top-level sections omitted from *override* are preserved; shared dict sections are deep-merged
so one nested key can be updated without dropping siblings on disk. Intentional key removals
are NOT supported here -- migrations must 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*, preserving nested defaults.
Keys in *override* take precedence. If both values are dicts the merge recurses, so a user who
overrides only ``tts.elevenlabs.voice_id`` will keep the default ``tts.elevenlabs.model_id``
intact.
"""
result = base.copy()
for key, value in override.items():
if (
key in result
and isinstance(result[key], dict)
and isinstance(value, dict)
):
result[key] = _deep_merge(result[key], value)
elif key in result and isinstance(result[key], dict) and value is None:
continue
else:
result[key] = value
return result
def _strip_dotted_keys(cfg: dict, dotted_keys: set) -> Tuple[dict, set]:
"""Remove the given dotted leaf keys from a nested config dict.
Returns ``(pruned_cfg, set_of_stripped_keys_that_were_present)``. Used by ``save_config`` to
drop managed-scope leaves before persisting, so a bulk write never writes a user value that
would lose to the managed layer on the next load. Only keys actually present in ``cfg`` are
reported as stripped.
"""
stripped: set = set()
for dotted in dotted_keys:
parts = dotted.split(".")
node = cfg
for p in parts[:-1]:
if not isinstance(node, dict) or p not in node:
node = None
break
node = node[p]
if isinstance(node, dict) and parts[-1] in node:
del node[parts[-1]]
stripped.add(dotted)
return cfg, stripped
def _env_ref_lookup(name: str) -> Optional[str]:
"""Resolve the env var behind a ``${VAR}`` / ``${env:VAR}`` config ref.
Outside a profile secret scope this is a plain ``os.environ`` read — the default profile and
every single-profile caller keep their legacy behavior.
"""
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 ``${...}`` config reference.
* ``${VAR}`` — legacy bare name, resolved via ``_env_ref_lookup`` (``os.environ``, or the active
profile secret scope). * ``${env:VAR}`` — Cursor-style SecretRef, same resolution after the
``env:`` prefix is stripped.
Other SecretRef sources (``file:``, ``bitwarden:``, ``vault:``, ...) are NOT resolved here —
external secret backends inject their values into the environment at startup (the ``secrets:``
block), so a config ref only ever needs the env shape. Unknown prefixes warn once and 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):
# Values from vault backends arrive via the secrets: block as env
# vars — point there instead of silently treating "bitwarden:FOO"
# as a var named "bitwarden:FOO".
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]:
"""Normalize a ``${...}`` body to the env-var name it reads, or None
when the ref uses a non-env source (or is an empty ``env:``) and never touches the environment."""
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}`` references in config values.
Only string values are processed; dict keys, numbers, booleans, and None are left untouched.
Unresolved references (variable not in ``os.environ``) are kept verbatim so callers can detect
them.
"""
if isinstance(obj, str):
return re.sub(r"\${([^}]+)}", _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 ``${VAR}`` / ``${env:VAR}`` referenced in config values to its current env value.
Stored with cached ``load_config()`` results so a cache hit can detect the expansion was made
against a different environment (e.g. a load before ``load_hermes_dotenv()`` ran, or a var
rotated in-process) -- file mtime/size alone cannot see either. Refs with a non-env source
prefix never read the environment and are excluded.
"""
if snapshot is None:
snapshot = {}
if isinstance(obj, str):
for raw in re.findall(r"\${([^}]+)}", 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 when a value is otherwise unchanged.
``load_config()`` expands env refs for runtime use. When a caller later persists that config
after modifying some unrelated setting, keep the original on-disk template instead of writing
the expanded plaintext secret back to ``config.yaml``.
"""
if isinstance(current, str) and isinstance(raw, str) and re.search(r"\${[^}]+}", raw):
if current == raw:
return raw
if isinstance(loaded_expanded, str) and current == loaded_expanded:
return raw
if _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):
# Prefer matching named config objects (e.g. custom_providers) by name
# so harmless reordering doesn't drop the original template. If names
# are duplicated, fall back to positional matching instead of silently
# shadowing one 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, ...]]:
"""Return leaf paths explicitly present in a raw config dict.
Computed on the **raw** (un-normalized, un-expanded) config so that values injected by
normalisation (e.g. ``agent.max_turns`` from ``DEFAULT_CONFIG``) are not mistakenly treated as
user-set.
Used by ``save_config`` to build the *preserve* set passed to ``_strip_default_values`` so only
user-authored keys survive the defaults-strip pass.
"""
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,))
return
if 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*.
Keys in *preserve_keys* (explicitly present in the user's raw config, before any normalisation)
are always kept even when they equal the default, so user-set values such as
``memory.user_char_limit: 2200`` survive a ``save_config`` round-trip.
Nested dicts whose every child is stripped are removed entirely so default-only subtrees (e.g.
``gateway``) never bloat ``config.yaml`` when the user has nothing to say about them.
"""
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: Dict[str, Any] = {}
for key, child in value.items():
child_default = default_dict.get(key)
stripped_child = _strip(child, child_default, path + (key,))
if stripped_child is not None:
stripped[key] = stripped_child
if stripped:
return stripped
# Entire subtree stripped — remove it
return None
if value == default:
return None
return copy.deepcopy(value)
result: Dict[str, Any] = {}
for key, value in config.items():
stripped = _strip(value, defaults.get(key), (key,))
if stripped is not None:
result[key] = stripped
return result
def split_model_config_default(raw_default: Any) -> tuple[str, str]:
"""Canonicalize a config ``model.default``/``model.model`` value.
A dict-valued default (``model.default: {provider: ..., model: ...}``) 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]:
"""Move stale root-level provider/base_url/context_length into model section.
Some users (or older code) placed ``provider:``, ``base_url:``, or ``context_length:`` at the
config root instead of inside ``model:``. These root-level keys are only used as a fallback when
the corresponding ``model.*`` key is empty — they never override an existing value.
``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.
"""
# Only act if there's something to migrate: root-level keys, an api_base
# alias, a dict-valued ``default``/``model``/``name`` (must be flattened at
# this single load/save chokepoint so no reader sees a nested dict), or an
# id under a non-canonical ``model``/``name`` key (promoted when ``default``
# is empty, dropped otherwise so config.yaml ends up canonical).
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")
if not isinstance(model, dict):
model = {"default": model} if model else {}
else:
model = dict(model)
config["model"] = model
# Flatten ``{provider: <p>, model: <m>}`` -> ``default: "<m>"`` plus
# ``provider: "<p>"`` unless an explicit outer provider is set. The nested
# provider must win 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)
# api_base is an alias for base_url, at the root OR inside model.
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)
# Canonicalize the id to ``default``; ``model``/``name`` are last-resort
# aliases (in that order), then dropped so the ambiguity can't return.
if not (model.get("default") or ""):
alias = model.get("model") or model.get("name")
if alias:
model["default"] = alias
if model.get("default"):
# Drop the now-redundant aliases so config.yaml ends up canonical.
model.pop("model", None)
model.pop("name", None)
return config
def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize legacy root-level max_turns into agent.max_turns.
Only injects the schema default when the user actually set max_turns somewhere (root level or
under ``agent``).
"""
config = dict(config)
agent_config = dict(config.get("agent") or {})
had_root = "max_turns" in config
had_agent = "max_turns" in agent_config
if had_root and not had_agent:
agent_config["max_turns"] = config["max_turns"]
# Only inject the default when the user explicitly set max_turns
# (either root-level or under agent). Otherwise leave it absent so
# save_config can omit it and the schema default fills in at runtime.
if (had_root or had_agent) and "max_turns" not in agent_config:
agent_config["max_turns"] = DEFAULT_CONFIG["agent"]["max_turns"]
config["agent"] = agent_config
config.pop("max_turns", None)
return config
# Sentinel for an unlimited turn budget. ``sys.maxsize`` survives the str→int
# round-trip through the HERMES_MAX_ITERATIONS env bridge (gateway/run.py), works
# in every ``<``/``>=``/``max - used`` comparison in agent/iteration_budget.py and
# agent/conversation_loop.py without a special "unlimited" case, and is
# unreachable in practice (9.2e18 turns ≈ 10^11 years).
TURN_LIMIT_UNLIMITED = sys.maxsize
# String spellings that mean "no limit". Lowercased, whitespace-stripped
# before comparison so ``"None"``, ``" unlimited "`` etc. all match.
_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.
The returned int is always ≥ 1, so loop conditions like ``while api_call_count <
agent.max_iterations`` behave correctly even when the default (unlimited) path is taken.
"""
if raw is None:
return default
if isinstance(raw, bool):
# bool is a subclass of int; reject it explicitly so True/False don't
# silently become 1/0.
return default
if isinstance(raw, (int, float)):
n = int(raw)
if n <= 0:
return TURN_LIMIT_UNLIMITED
return n
if 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
if n <= 0:
return TURN_LIMIT_UNLIMITED
return n
# 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
def cfg_get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> Any:
"""Traverse nested dict keys safely, returning ``default`` on any miss.
Named ``cfg_get`` rather than ``cfg_path`` to avoid shadowing the ubiquitous ``cfg_path =
_hermes_home / "config.yaml"`` local variable that appears in gateway/run.py, cron/scheduler.py,
main.py, etc.
Explicit ``None`` values are returned as-is (matches ``dict.get(key, default)`` semantics —
``default`` is only returned when the key is *absent*, not when it's present but set to
``None``).
"""
if not isinstance(cfg, dict):
return default
node: Any = cfg
for key in keys:
if not isinstance(node, dict):
return default
if key not in node:
return default
node = node[key]
return node
# Back-compat re-exports — :mod:`hermes_cli.personality` is the single owner of
# personality/overlay semantics; these names stay importable from here.
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,
)
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 = (st.st_mtime_ns, st.st_size)
except (FileNotFoundError, OSError):
return {}
path_key = str(config_path)
cached = _RAW_CONFIG_CACHE.get(path_key)
if cached is not None and cached[:2] == cache_key:
return copy.deepcopy(cached[2]) if want_deepcopy else cached[2]
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: the first caller must see the exact dict
# later cache hits return); the mutable path returns the fresh parse.
cached_copy = copy.deepcopy(data)
_RAW_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], cached_copy)
return data if want_deepcopy else cached_copy
def read_raw_config() -> Dict[str, Any]:
"""Read ~/.hermes/config.yaml as-is, without merging defaults or migrating.
Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't be parsed. Use this for
lightweight config reads where you just need a single value and don't want the overhead of
``load_config()``'s deep-merge + migration pipeline.
Cached on the config file's (mtime_ns, size) — same strategy as ``load_config()``. Returns a
deepcopy on every call since some callers mutate the result before passing to ``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 on disk (no defaults/overlay/expansion).
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]:
"""Fast-path variant of ``read_raw_config()`` for callers that ONLY READ.
Returns the cached raw-config dict directly, skipping the per-call ``copy.deepcopy`` that
``read_raw_config()`` performs (needed there because some callers mutate the result before
``save_config``).
**Mutating the returned dict corrupts the in-process cache for every subsequent caller.** Only
use on read-only paths — e.g. per-turn policy checks like the shared-metrics gate, which runs
2-3x per agent turn and was paying a full config deepcopy each time.
"""
return _read_raw_config_impl(want_deepcopy=False)
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.
Guards two collapse-to-empty failure modes that would otherwise let a read-then-write caller
silently wipe user overrides:
1. **Unreadable** (permissions / broken mount) - byte open fails. 2. **Unparseable or non-
mapping** - YAML load raises, or the root is a list/scalar. ``read_user_config_raw()`` / bare
``except`` loaders treat both as ``{}``, so a subsequent write would replace the recoverable
file with only the caller's partial dict. Fails closed (no bare-except to ``{}`` collapse) and
returns the parsed mapping so callers do not re-parse.
"""
if config_path is None:
config_path = get_config_path()
try:
config_path.stat()
except FileNotFoundError:
return {}
except OSError as exc:
raise RuntimeError(
f"Refusing to overwrite {config_path}: existing config.yaml cannot be accessed "
f"({exc}). Fix the file permissions or move it aside first."
) from exc
try:
with open(config_path, encoding="utf-8") as f:
loaded = fast_safe_load(f)
except OSError as exc:
raise RuntimeError(
f"Refusing to overwrite {config_path}: existing config.yaml cannot be read "
f"({exc}). Fix the file permissions or move it aside first."
) from exc
except Exception as exc:
_warn_config_parse_failure(config_path, exc, fallback="refuse-write")
raise RuntimeError(
f"Refusing to overwrite {config_path}: existing config.yaml is not valid YAML "
f"({exc}). Fix the file or restore from a .corrupt.*.bak backup first."
) 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 RuntimeError(
f"Refusing to overwrite {config_path}: top-level YAML must be a mapping, "
f"got {type(loaded).__name__}. Fix the file or restore from a "
f".corrupt.*.bak backup first."
) from exc
return loaded
def atomic_config_write(config_path: Path, data: Any, **kwargs: Any) -> None:
"""Fail-closed atomic write for ``config.yaml``.
Root cause this guards: ``read_user_config_raw()`` returns ``{}`` for an absent file /
unreadable path edge cases and collapses non-dict roots. Callers that read then overwrite can't
tell these apart, so a broken config would be replaced with only defaults or the single edited
section.
"""
from utils import atomic_yaml_write
require_readable_config_before_write(config_path)
atomic_yaml_write(config_path, data, **kwargs)
def load_config() -> Dict[str, Any]:
"""Load configuration from ~/.hermes/config.yaml.
Cached on the config file's (mtime_ns, size). Returns a deepcopy of the cached value when
unchanged, since most call sites mutate the result (e.g. ``cfg["model"]["default"] = ...``
before ``save_config``).
Read-only callers should use ``load_config_readonly()`` to skip the defensive deepcopy — that
path matters in agent-loop hot spots like ``get_provider_request_timeout`` which is called once
per API turn.
"""
return _load_config_impl(want_deepcopy=True)
def load_config_readonly() -> Dict[str, Any]:
"""Fast-path variant of ``load_config()`` for callers that ONLY READ.
Returns the cached config dict directly without the defensive deepcopy that ``load_config()``
applies. **Mutating the returned dict (or any nested structure) corrupts the in-process cache
for every subsequent caller** — only use this when you are absolutely sure your code path will
not write to the result.
Why this exists: ``load_config()`` cache-hit cost is ~265us per call, half of which (~135us) is
the defensive deepcopy.
"""
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`` preserves CLI setup flows that intentionally edit only the user's raw config file.
Dashboard routes use the default loaded-config path so they retain their existing 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_persist_across_processes", "docker_shared_container_key",
"docker_orphan_reaper", "sandbox_dir", "persistent_shell",
)
},
}
def _terminal_env_value(value: Any) -> str:
if isinstance(value, (list, dict)):
return json.dumps(value)
return 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."""
prefix = "terminal."
if not key.startswith(prefix):
return None
return TERMINAL_CONFIG_ENV_MAP.get(key[len(prefix):])
def _is_ssh_remote_tilde_cwd(backend: str, cwd: str) -> bool:
"""Return whether the remote SSH shell must expand *cwd* itself.
Expanding ``~`` on the Hermes host rewrites it to the host or container home before SSH sees it.
Preserve ``~`` and ``~/...`` so they follow the user selected by the SSH connection.
"""
if (backend or "").strip().lower() != "ssh":
return False
return 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 intentionally environment-driven because it also runs in child
processes (TUI, dashboard PTY, gateway workers). This helper gives those child-process launch
paths the same config bridge as classic CLI without importing ``cli.py`` and paying for its
startup side effects.
Explicit keys in the user config's ``terminal`` section are authoritative and override their
matching env values. Merged defaults only backfill missing env vars; they never replace
unrelated exported/.env values.
"""
target = os.environ if env is None else env
raw_config = read_raw_config()
raw_terminal_cfg = raw_config.get("terminal")
file_has_terminal_config = isinstance(raw_terminal_cfg, dict)
if not file_has_terminal_config:
raw_terminal_cfg = {}
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. For the
# normal merged-config path, only keys present in raw config.yaml may
# override existing env values; keys inherited from DEFAULT_CONFIG 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, managed_scope) -> Tuple[Optional[Tuple[int, int]], Optional[Tuple[int, int, int, int]]]:
"""Return ``(user_sig, cache_sig)`` for ``_LOAD_CONFIG_CACHE``.
The managed config file's (mtime, size) is folded in so editing /etc/hermes/config.yaml
invalidates the cached merged result ((0, 0) = no managed file). ``cache_sig`` is None only when
the user config is absent AND no managed file exists (nothing to cache on).
"""
try:
st = config_path.stat()
user_sig: Optional[Tuple[int, int]] = (st.st_mtime_ns, st.st_size)
except FileNotFoundError:
user_sig = None
managed_dir = managed_scope.get_managed_dir()
managed_cfg_path = (managed_dir / "config.yaml") if managed_dir else None
try:
mst = managed_cfg_path.stat() if managed_cfg_path else None
managed_sig = (mst.st_mtime_ns, mst.st_size) if mst else (0, 0)
except OSError:
managed_sig = (0, 0)
if user_sig is not None:
return user_sig, (*user_sig, *managed_sig)
if managed_sig != (0, 0):
return None, (0, 0, *managed_sig)
return None, None
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 (a gateway whose user
mid-edits config.yaml into broken YAML would lose them on the next load). Within a running
process keep serving the last successfully loaded config until the file is fixed.
"""
lkg = _LAST_EXPANDED_CONFIG_BY_PATH.get(path_key)
_warn_config_parse_failure(
config_path, exc, fallback="last-known-good" if lkg is not None else "defaults",
)
if lkg is None:
return None
# save_config() stores the pre-expansion normalized dict (env-ref 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 the broken file; fixing the
# file changes the signature and triggers a normal reload.
_LOAD_CONFIG_CACHE[path_key] = (*cache_sig, lkg_copy, {})
return lkg_copy
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)
from hermes_cli import managed_scope
user_sig, cache_sig = _load_config_cache_sig(config_path, managed_scope)
cached = _LOAD_CONFIG_CACHE.get(path_key)
if cached is not None and cache_sig is not None and cached[:4] == cache_sig:
# File 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 (e.g. auxiliary.<task>.api_key) for the process lifetime.
env_snapshot = cached[5] if len(cached) > 5 else {}
if all(_env_ref_lookup(k) == v for k, v in env_snapshot.items()):
return copy.deepcopy(cached[4]) if want_deepcopy else cached[4]
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)
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 = _normalize_root_model_keys(_normalize_max_turns_config(config))
expanded = _expand_env_vars(normalized)
# Managed scope wins at the leaf. Applied AFTER user expansion so a user
# ${VAR} cannot shadow a managed literal: managed values are expanded only
# against the process environment, never against user-config-defined refs.
# This deliberately inverts the usual env-over-config precedence for the
# keys the managed layer pins — see docs/design/managed-scope.md §4.1.
managed_config = managed_scope.load_managed_config()
if managed_config:
# Same canonicalization as the user config BEFORE merging (parity
# with managed_scope.apply_managed_overlay): a dict-valued
# ``model.default`` or bare ``model: <string>`` is flattened so the
# merged result never exposes a nested dict to runtime readers.
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"]}
expanded = _deep_merge(expanded, _expand_env_vars(managed_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 env 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)
# On the readonly path return the same cached object subsequent
# calls will see — keeps "two readonly calls return the same
# object" invariant that callers may rely on for identity checks.
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. For the deepcopy=True path this is the
# canonical "freshly-built mutable result" the function has always
# returned. For the deepcopy=False path with no cache (e.g. config
# file missing), it's also fine — callers get an isolated object.
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 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.
Default values from ``DEFAULT_CONFIG`` are not written to disk unless the user explicitly set
them (i.e. the path exists in the raw config before any normalisation). This prevents
config.yaml from being contaminated with schema defaults on every save, which makes future
default changes invisible to users.
When ``merge_existing`` is True, the on-disk raw config is deep-merged under *config* before
writing so partial callers (migration steps via ``_persist_migration``) cannot drop unrelated
sections the caller omitted.
"""
with _CONFIG_LOCK:
if is_managed():
managed_error("save configuration")
return
# Managed scope: strip any leaf the managed layer pins so a bulk write
# never persists a value that would lose to managed on the next load
# (single-key `config set` hard-rejects; this is the bulk safety net).
from hermes_cli import managed_scope
managed_keys = managed_scope.managed_config_keys()
if managed_keys:
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,
)
from utils import atomic_yaml_write
ensure_hermes_home()
config_path = get_config_path()
require_readable_config_before_write(config_path)
# Explicit user paths are computed from the RAW dict BEFORE any
# normalisation (which may inject agent.max_turns from DEFAULT_CONFIG)
# 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)
_canon = lambda c: _normalize_root_model_keys(_normalize_max_turns_config(c)) # noqa: E731
current_normalized = _canon(config)
normalized = current_normalized
if _raw_for_paths:
normalized = _preserve_env_ref_templates(
normalized,
_canon(_raw_for_paths),
_LAST_EXPANDED_CONFIG_BY_PATH.get(str(config_path)),
)
# Strip schema-default values so the user's custom settings are not
# silently reset on every save; explicitly-set paths always survive.
if strip_defaults:
effective_preserve_keys: Set[Tuple[str, ...]] = {("_config_version",)}
if _raw_for_paths:
effective_preserve_keys.update(_explicit_config_paths(_raw_for_paths))
effective_preserve_keys.update(preserve_keys or ())
normalized = _strip_default_values(
normalized,
DEFAULT_CONFIG,
preserve_keys=effective_preserve_keys,
)
# Build optional commented-out sections for features that are off by
# default or only relevant when explicitly configured.
parts = []
sec = normalized.get("security", {})
if not sec or sec.get("redact_secrets") is None:
parts.append(_SECURITY_COMMENT)
fb = normalized.get("fallback_model", {})
fb_entries = fb if isinstance(fb, list) else [fb]
fb_is_valid = any(isinstance(e, dict) and e.get("provider") and e.get("model") for e in fb_entries)
if not fb_is_valid:
parts.append(_FALLBACK_COMMENT)
atomic_yaml_write(
config_path,
normalized,
extra_content="".join(parts) if parts else None,
)
_secure_file(config_path)
_RAW_CONFIG_CACHE.pop(str(config_path), None)
_LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized)
def _parse_env_value(raw_value: str) -> str:
"""Parse the small .env value subset Hermes writes itself."""
value = raw_value.strip()
if len(value) >= 2 and value[0] == value[-1] == '"':
quoted = value[1:-1]
parsed: list[str] = []
i = 0
while i < len(quoted):
ch = quoted[i]
if ch == "\\" and i + 1 < len(quoted):
next_ch = quoted[i + 1]
if next_ch in {'"', "\\"}:
parsed.append(next_ch)
i += 2
continue
parsed.append(ch)
i += 1
return "".join(parsed)
if len(value) >= 2 and value[0] == value[-1] == "'":
return value[1:-1]
return value
def load_env() -> Dict[str, str]:
"""Load environment variables from ~/.hermes/.env.
Normalizes line endings before parsing while treating each assignment's value as opaque data for
boundary discovery.
The parsed dict is memoised keyed on the .env file mtime, because ``get_env_value()`` is called
dozens-to-hundreds of times per interactive menu render (`hermes tools`, `hermes setup`, status
panels).
"""
global _env_cache
env_path = get_env_path()
try:
mtime = env_path.stat().st_mtime
size = env_path.stat().st_size
cache_key = (str(env_path), mtime, size)
except FileNotFoundError:
cache_key = (str(env_path), None, None)
except Exception:
cache_key = None
if cache_key is not None and _env_cache is not None:
cached_key, cached_vars = _env_cache
if cached_key == cache_key:
return dict(cached_vars)
env_vars: Dict[str, str] = {}
if env_path.exists():
for line in _read_env_lines(env_path):
line = line.strip()
if line and not line.startswith('#') and '=' in line:
# Strip the bash-compatible ``export `` prefix so lines like
# ``export API_KEY=...`` parse as ``API_KEY`` rather than being
# stored under the wrong key ``"export API_KEY"`` (#6659).
if line.startswith('export '):
line = line[7:]
key, _, value = line.partition('=')
env_vars[key.strip()] = _parse_env_value(value)
if cache_key is not None:
_env_cache = (cache_key, dict(env_vars))
return env_vars
# Module-level memo for load_env(), keyed on (path, mtime, size).
# Editing .env bumps mtime → next load_env() rebuilds. invalidate_env_cache()
# is the explicit knob for writers that update .env via this module
# (set_env_value, save_env, etc.) without relying on filesystem mtime
# resolution.
_env_cache: Optional[Tuple[Tuple[str, Optional[float], Optional[int]], Dict[str, str]]] = None
def invalidate_env_cache() -> None:
"""Clear the load_env() process-level memo.
Writers that mutate .env (set_env_value, save_env, etc.) call this to guarantee the next
load_env() sees their change even on filesystems with coarse mtime resolution. Reads invalidate
naturally via the mtime/size check.
"""
global _env_cache
_env_cache = None
def _sanitize_env_lines(lines: list) -> list:
"""Normalize .env line endings without changing assignment semantics.
Content after the first ``=`` is opaque value data. A known variable name embedded in that value
must never be reinterpreted as another assignment; concatenated assignments are ambiguous and
therefore remain on one line.
"""
sanitized: list[str] = []
for line in lines:
raw = line.rstrip("\r\n")
stripped = raw.strip()
# Preserve blank lines and comments
if not stripped or stripped.startswith("#"):
sanitized.append(raw + "\n")
continue
sanitized.append(stripped + "\n")
return sanitized
def sanitize_env_file() -> int:
"""Read, sanitize, and rewrite ~/.hermes/.env in place.
Returns the number of lines whose safe formatting was normalized. Returns 0 when no changes are
needed.
"""
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
# Count lines whose normalized representation differs.
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 with the Windows-safe encoding and normalized line formatting."""
# On Windows, open() defaults to the system locale (cp1252) which can
# fail on UTF-8 .env files. Always use explicit UTF-8; tolerate BOM
# via utf-8-sig since users may edit .env in Notepad which adds one.
with open(env_path, encoding="utf-8-sig", errors="replace") as f:
lines = f.readlines()
return _sanitize_env_lines(lines)
def _write_env_lines(env_path: Path, lines: list, *, preserve_mode: bool) -> None:
"""Atomically replace ``.env`` with *lines* (tmp file + fsync + rename).
``preserve_mode`` keeps the original file mode (e.g. 0640 for Docker volume mounts) instead of
letting ``_secure_file`` unconditionally tighten to 0600; a new file is always secured.
"""
original_mode = None
if preserve_mode:
try:
original_mode = stat.S_IMODE(env_path.stat().st_mode)
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:
"""Warn and strip non-ASCII characters from credential values.
API keys and tokens must be pure ASCII — they are sent as HTTP header values which
httpx/httpcore encode as ASCII.
Returns the sanitized (ASCII-only) value. Prints a warning if any non-ASCII characters were
found and removed.
"""
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"
f"\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."""
if value == "":
return value
# Internal whitespace (space/tab/etc.) must be quoted so shell `set -a; . file`
# word-splits don't break paths like macOS "Application Support". Leading/
# trailing whitespace is already covered by the strip check; any() covers
# internal runs that strip() would leave alone.
needs_quoting = (
"#" in value
or '"' in value
or "'" in value
or value != value.strip()
or any(c.isspace() for c in value)
)
if not needs_quoting:
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 or ``export``-prefixed.
``load_env()`` accepts the bash-compatible ``export KEY=value`` form (#6659), so the writers
must recognise the same shape. Otherwise a hand-added ``export`` line is invisible to save
(duplicate appended) and remove (line survives → the value resurrects on the next load, #40041).
"""
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 (e.g. a ``/pair`` grant mirrored into
``DISCORD_ALLOWED_USERS``) 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.
"""
try:
from agent.secret_scope import current_secret_scope, is_multiplex_active
scope = current_secret_scope() if is_multiplex_active() else None
except Exception:
scope = None
if scope is not None:
if isinstance(scope, dict):
if value is None:
scope.pop(key, None)
else:
scope[key] = value
return
if value is None:
os.environ.pop(key, None)
else:
os.environ[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
from hermes_cli import managed_scope
if managed_scope.is_env_managed(key):
managed_dir = managed_scope.get_managed_dir()
src = (managed_dir / ".env") if managed_dir else "the managed scope"
print(
f"Cannot {action} {key}: it is managed by your administrator ({src}) "
f"and cannot be changed.",
file=sys.stderr,
)
return True
return False
def save_env_value(key: str, value: str):
"""Save or update a value in ~/.hermes/.env."""
if _env_write_blocked(key, "set"):
return
validate_env_var_name_for_write(key)
value = value.replace("\n", "").replace("\r", "")
# API keys / tokens must be ASCII — strip non-ASCII with a warning.
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)
# Find and update or append. Match both ``KEY=`` and the bash-compatible
# ``export KEY=`` form — load_env() parses export lines (#6659), so a
# user-added ``export GITHUB_TOKEN=...`` shows as set in every UI. If the
# writer didn't match it, a save would append a SECOND line and a later
# delete of that line would silently resurrect the old exported value
# (#40041: "token detected but cannot be replaced through the UI").
found = False
for i, line in enumerate(lines):
if _env_line_defines_key(line, key):
lines[i] = f"{key}={serialized_value}\n"
found = True
break
if not found:
# Ensure there's a newline at the end of the file before appending
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),
not just its hostname, 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 (every
IP-based endpoint does) -- ``save_env_value`` rejects digit-leading names.
"""
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.
Returns True if the key was found and removed, False otherwise.
"""
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]:
# 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).
from hermes_cli.credential_lifecycle import save_provider_env_credential
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 updated.
Adds/updates vars that changed and removes vars that were deleted from the .env file (but only
vars known to Hermes — OPTIONAL_ENV_VARS and _EXTRA_ENV_KEYS — to avoid clobbering unrelated
environment).
"""
env_vars = load_env()
known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS
count = 0
for key, value in env_vars.items():
if os.environ.get(key) != value:
os.environ[key] = value
count += 1
# Remove known Hermes vars that are no longer in .env
for key in known_keys:
if key not in env_vars and 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``.
Under an active profile scope (multiplexed gateway turn) the read is scope-checked rather than
leaking another profile's raw ``os.environ`` value. Falls back to a plain environ read when the
scope module is unavailable or fails; ``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`` or ``~/.hermes/.env``, scope-aware.
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.
"""
val = _scoped_environ_get(key)
if val is not None:
return val
return load_env().get(key)
def get_env_value_prefer_dotenv(key: str) -> Optional[str]:
"""Resolve a credential env value, preferring ``~/.hermes/.env`` over ``os.environ``.
Used for Hermes-managed credentials where a deliberate edit to ``.env`` must take precedence
over a stale value inherited from the parent shell (Codex CLI, test scripts, login profile
exports).
The ``os.environ`` fallback routes through ``secret_scope.get_secret`` so that, under an active
profile scope (multiplexed gateway turn), this read is scope-checked rather than leaking another
profile's raw ``os.environ`` value — matching the credential-pool seeding path's behaviour.
"""
val = load_env().get(key)
if val:
return val
return _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 to the terminal. Covers the
# fields a custom provider stuffs into the `model`/`custom_providers` blocks
# (`api_key`) plus the usual token/secret/password shapes. Exact-match only so
# benign keys like `token_count` or `secret_santa` don't get masked.
_SECRET_CONFIG_KEYS = frozenset({
"api_key", "apikey", "key", "token", "access_token", "refresh_token", "id_token",
"secret", "client_secret", "password", "passwd", "auth", "authorization",
"private_key", "bearer", "jwt",
})
def redact_config_value(value: Any, _depth: int = 0) -> Any:
"""Return a copy of ``value`` with credential-shaped keys masked for display.
Recursively masks values of keys in ``_SECRET_CONFIG_KEYS`` (case-insensitive). Use before
``print``-ing any config sub-tree that may carry an ``api_key``: ``print`` bypasses the logging
redactor, and opaque tokens (e.g. Cloudflare ``cfut_...``) miss the vendor-prefix regexes, so
structural key-name masking is required.
"""
from agent.redact import mask_secret
# Defensive bound on recursion depth for pathological/cyclic configs.
if _depth > 20:
return value
if isinstance(value, dict):
out = {}
for k, v in value.items():
if isinstance(k, str) and k.lower() in _SECRET_CONFIG_KEYS and isinstance(v, str) and v:
out[k] = mask_secret(v)
else:
out[k] = redact_config_value(v, _depth + 1)
return out
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:
# Managed scope: surface that some settings are administrator-pinned so the
# user understands why their config.yaml value may not be the effective one.
from hermes_cli import managed_scope
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"),
("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}")
# Warn on a stale HERMES_MAX_ITERATIONS ghost in .env that disagrees with
# config.yaml. Read the .env FILE directly so we catch the ghost even when
# the gateway bridge already overrode os.environ.
try:
env_ghost = load_env().get("HERMES_MAX_ITERATIONS")
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,
))
except Exception:
pass
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')
if tt is not None:
try:
tt = int(tt)
if tt > 0:
print(f" Token cap: {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:
auxiliary = config.get('auxiliary', {})
aux_tasks = {"Vision": 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 skill_vars:
resolved = resolve_skill_config_values(skill_vars)
_section("Skill Settings")
for var in skill_vars:
key = var["key"]
value = resolved.get(key, "")
skill_name = var.get("skill", "")
display_val = str(value) if value else color("(not set)", Colors.DIM)
print(f" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}")
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()
# Ensure config exists
if not config_path.exists():
save_config(DEFAULT_CONFIG, strip_defaults=False)
print(f"Created {config_path}")
# Find editor
editor = os.getenv('EDITOR') or os.getenv('VISUAL')
if not editor:
# Try common editors — order is platform-aware so Windows users
# land on a working editor (notepad) even without Git Bash or nano
# installed. On POSIX, prefer nano/vim over code/notepad because
# it's more likely to be present on headless / server systems.
if sys.platform == "win32":
candidates = ['notepad', 'code', 'vim', 'vi', 'nano']
else:
candidates = ['nano', 'vim', 'vi', 'code', 'notepad']
for cmd in candidates:
if shutil.which(cmd):
editor = cmd
break
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)])
def _cron_model_drift_axis_for_config_key(key: str) -> Optional[str]:
"""Return the cron drift guard axis affected by a config key, if any."""
normalized = str(key or "").strip().lower()
if normalized in {"model", "model.default", "model.model", "model.name"}:
return "model"
if normalized in {"model.provider", "provider"}:
return "provider"
return None
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
if not isinstance(config, dict):
return None
cron_config = config.get("cron")
return cron_config if isinstance(cron_config, dict) else None
def cron_model_drift_guard_enabled(
config: Optional[Dict[str, Any]] = None,
) -> bool:
"""Return whether cron must fail closed on unpinned inference drift.
Only the literal YAML boolean ``false`` disables this spend-safety guard. Missing, malformed, or
non-boolean values stay fail-closed. When *config* is omitted, load the active merged
configuration so CLI warnings honor the same user/managed setting as the scheduler.
"""
cron_config = _cron_section(config)
if cron_config is None:
return True
return cron_config.get("model_drift_guard", True) is not False
def _cron_fleet_default_covers_axis(
axis: str,
config: Optional[Dict[str, Any]] = None,
) -> bool:
"""True when cron.model / cron.model_provider covers *axis*.
An axis covered by the explicit cron-fleet default no longer follows the global model/provider
at fire time, so the drift guard never engages for it and switch-time warnings would be false
alarms.
"""
cron_config = _cron_section(config)
if cron_config is None:
return False
key = "model" if axis == "model" else "model_provider"
return bool(_model_assignment_text(cron_config.get(key)))
_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 values cron compares against snapshots.
Mirrors the scheduler's global-model precedence: a truthy configured model wins
``HERMES_MODEL``; the environment is only a fallback. Per-job and cron fleet defaults are
handled by the caller/classifier because they suppress a drift axis rather than changing the
global assignment.
"""
env = os.environ if environ is None else environ
provider = ""
model = _model_assignment_text(env.get("HERMES_MODEL", ""))
model_config = config.get("model") if isinstance(config, dict) else None
if isinstance(model_config, str):
configured_model = model_config.strip()
if configured_model:
model = configured_model
elif isinstance(model_config, dict):
provider = _model_assignment_text(model_config.get("provider"))
configured_model = _model_assignment_text(
model_config.get("default")
or model_config.get("model")
or model_config.get("name")
)
if configured_model:
model = configured_model
return provider, model
def cron_model_drift_axes(
job: Any,
*,
current_provider: Any = "",
current_model: Any = "",
config: Any = None,
) -> List[str]:
"""Return the unpinned axes that the fail-closed cron guard would block."""
if not isinstance(job, dict) or not cron_model_drift_guard_enabled(config):
return []
current = {
"provider": _model_assignment_text(current_provider).lower(),
"model": _model_assignment_text(current_model).lower(),
}
drifted: List[str] = []
for axis in ("provider", "model"):
if _cron_fleet_default_covers_axis(axis, config):
continue
if _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 _valid_cron_impact_job_id(value: Any) -> str:
if not isinstance(value, str):
return ""
job_id = value.strip()
if not job_id or len(job_id) > _CRON_MODEL_IMPACT_ID_LIMIT:
return ""
if any(unicodedata.category(char).startswith("C") for char in 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 unicodedata.category(char).startswith("C")
)
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 _unavailable_cron_model_impact(guard_enabled: bool) -> Dict[str, Any]:
return {
"available": False,
"guard_enabled": guard_enabled,
"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 jobs blocked by model drift.
Job-store inspection is best effort: the model assignment has already succeeded when Desktop
requests this summary, so an unreadable store is reported as unavailable rather than failing
or rolling back.
"""
guard_enabled = cron_model_drift_guard_enabled(config)
if jobs is None:
try:
from cron.jobs import load_jobs
jobs = load_jobs()
except Exception:
return _unavailable_cron_model_impact(guard_enabled)
if not isinstance(jobs, list):
return _unavailable_cron_model_impact(guard_enabled)
result: Dict[str, Any] = {
"available": True,
"guard_enabled": guard_enabled,
"affected_count": 0,
"truncated": False,
"jobs": [],
}
if not guard_enabled:
return result
from cron.jobs import is_job_runnable
seen_ids: Set[str] = set()
for job in jobs:
if not isinstance(job, dict):
continue
if 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:
"""Warn when a global model/provider change will trip cron's drift guard."""
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
snapshot_field = f"{axis}_snapshot"
noun = "job" if affected == 1 else "jobs"
verb = "has" if affected == 1 else "have"
print(
f"⚠️ {affected} enabled unpinned cron {noun} {verb} stored "
f"{snapshot_field} values that differ from the new global {axis}. "
"They will fail closed on their next run instead of silently using the "
"changed model/provider. Inspect with `hermes cron list`, then pin the "
"intended values with `hermes cron edit <job_id> --provider <provider> "
"--model <model>`."
)
def _default_value_for_key(dotted_key: str):
"""Return the leaf value declared for *dotted_key* in ``DEFAULT_CONFIG``."""
node = DEFAULT_CONFIG
for part in _split_key_path(dotted_key):
if not isinstance(node, dict) or part not in node:
return None
node = node[part]
return node if not isinstance(node, dict) else None
# Known top-level config keys that intentionally accept arbitrary user-supplied
# child keys ("dictionary-shaped" config: the schema declares the dict but the
# user populates its keys). Schema validation accepts ANY path below these
# without deep checking, so users can set e.g. ``mcp_servers.my-server.command``
# or ``providers.openrouter.api_key`` without us needing to know server names.
_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. on a
# PlatformConfig dataclass) but where users may legitimately add fields
# that DEFAULT_CONFIG doesn't enumerate (extras, per-channel overrides,
# etc.). For these we validate the FIRST segment but accept anything below.
_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 settings — enable/disable lists plus index_url override
# (hermes_cli/plugins_cmd.py, hermes_cli/plugin_index.py). Absent from
# DEFAULT_CONFIG (written only when used), so listed here for
# `hermes config set plugins.index_url ...` validation.
"plugins",
})
# Top-level keys that can be ANY user-supplied name (platform/provider dict
# shapes where the outer key IS user-defined).
_DYNAMIC_TOP_LEVEL_KEYS = frozenset({
"custom_providers", # list-shaped, but indexed by position
})
# Container keys whose immediate child IS a user-supplied platform name
# (``platforms.<name>.<field>``), both top-level and under ``gateway``.
# Anything below the name is accepted (``PlatformConfig`` has an open ``extra``).
_PLATFORM_CONTAINER_KEYS = frozenset({"platforms"})
def _known_top_level_keys() -> set[str]:
"""Return the union of known top-level config keys for validation."""
return set(DEFAULT_CONFIG) | _OPEN_DICT_TOP_LEVEL_KEYS | _DYNAMIC_TOP_LEVEL_KEYS | _SCHEMA_DEFINED_DICT_KEYS
def _suggest_closest_key(key: str, candidates: set[str], cutoff: float = 0.6) -> Optional[str]:
"""Return the closest valid key name from ``candidates`` if any are similar enough to ``key``, else
None. Used by ``hermes config set`` to point users at the right path when they've typo'd a
top-level key.
"""
import difflib
matches = difflib.get_close_matches(key, sorted(candidates), n=1, cutoff=cutoff)
return matches[0] if matches else 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: ``gateway.discord.gateway_restart_notification`` was silently written even
though ``gateway`` has only a handful of known sub-keys.
"""
if not key:
return False, None
segments = _split_key_path(key)
top = segments[0]
# A leading underscore on the FIRST segment (``_test.shim_marker``) marks an
# intentionally non-schema internal key (test harnesses/tooling); only the
# first segment is exempt so ``agent._max_turns`` is still caught.
# Top-level ``platforms.<name>.<field>`` is a valid shape (gateway/config.py
# resolves it alongside ``gateway.platforms``); accept anything below it.
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 not None:
rest = ".".join(segments[1:])
return False, f"{suggestion}.{rest}" if rest else suggestion
return False, None
# Open / dynamic / schema-defined-but-extensible containers: the user
# defines the inner shape (mcp_servers.<name>.command, providers.<name>.api_key).
if top in _OPEN_DICT_TOP_LEVEL_KEYS or top in _DYNAMIC_TOP_LEVEL_KEYS or top in _SCHEMA_DEFINED_DICT_KEYS:
return True, None
# Walk DEFAULT_CONFIG along the segments: a nested ``platforms`` container
# or a scalar leaf hit before the path is consumed both accept (the latter
# matches set_config_value's leaf→dict replacement); an unknown sub-key
# 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):
return True, None
if seg not in node:
sibling_suggestion = _suggest_closest_key(seg, set(node.keys()))
if sibling_suggestion is not None:
return False, ".".join(consumed + [sibling_suggestion])
return False, None
consumed.append(seg)
node = node[seg]
return True, None
def _looks_structured_value(value: str) -> bool:
"""Return True when *value* plausibly encodes a YAML/JSON list or mapping.
Used by :func:`set_config_value` to decide whether to attempt a ``yaml.safe_load`` structured
parse. Deliberately conservative so plain scalars are never mangled:
A bare leading ``-`` is NOT a trigger on its own: ``-5``, ``--flag`` and other dash-prefixed
single-line scalars must remain 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, colon followed by a space or end-of-line).
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):
"""Return int(value) for a clean integer literal, else None.
Uses ``int()`` so signs, whitespace, and underscores parse (``str.isdigit()`` rejects "-5").
Rejects float-looking strings (``_coerce_float``'s job) and bools masquerading as ints.
"""
try:
return int(value)
except (TypeError, ValueError):
return None
def _coerce_float(value: str):
"""Return ``float(value)`` when conversion preserves its decimal value.
Decimal-looking identifiers can be much more precise than a binary float. Silently rounding one
here corrupts it before it reaches ``config.yaml``, so values that do not round-trip through
``float`` remain strings.
"""
try:
f = float(value)
except (TypeError, ValueError):
return None
# Reject NaN/inf spellings — they are almost never intended config values
# and round-trip confusingly through YAML.
if f != f or f in (float("inf"), float("-inf")):
return None
try:
if Decimal(value) != Decimal(str(f)):
return None
except InvalidOperation:
return None
return f
_SCALAR_WORDS = {
'true': True, 'yes': True, 'on': True,
'false': False, 'no': False, 'off': False,
# YAML null / "off" state. Many DEFAULT_CONFIG leaves default to None and
# are documented as "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 YAML booleans; unknown keys keep best-effort coercion.
``int()`` handles signs/whitespace/underscores (``str.isdigit()`` rejected "-5"). List/mapping
literals (``'["file","web"]'`` or a multi-line YAML block) are parsed so isinstance-gated
readers see real structures; the trigger is conservative (see ``_looks_structured_value``) so
plain scalars like ``-5`` or ``--flag`` never reach the YAML parser.
"""
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>``.
Per-platform *display* settings (streaming, show_reasoning, tool_progress, …) are resolved by
the gateway from ``display.platforms.<name>.<setting>``
(``gateway/display_config.py::resolve_display_setting``), while the top-level
``platforms.<name>`` block holds only connection config (token, enabled, reply_to_mode, extra,
…).
Only known display settings (``OVERRIDEABLE_KEYS``) are redirected so real connection keys stay
put. Returns ``(canonical_key, note_or_None)``. The gateway import is guarded: the CLI must keep
working where the gateway package is not importable.
"""
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:
"""Managed scope guard (D2): a key pinned by the managed layer cannot be set/unset by the user —
the next load would override/reinstate it anyway. Hard-reject and name the source. Distinct from
``is_managed()`` (the package-manager write-lock). Env-shaped keys route to the .env writers,
which carry their own managed-env-key guard; this catches the config.yaml keys.
"""
from hermes_cli import managed_scope
if managed_scope.is_key_managed(key):
managed_dir = managed_scope.get_managed_dir()
src = (managed_dir / "config.yaml") if managed_dir else "the managed scope"
print(
f"Cannot {action} '{key}': it is managed by your administrator ({src}) "
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 section with a scalar.
Bare ``model`` is a documented shorthand — redirected to ``model.default`` so siblings
(provider/context_length/...) survive. Every other mapping section is rejected unless
``--force``. Returns the (possibly redirected) key.
"""
if "." in key:
return key
existing = user_config.get(key)
if 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 is an explicit "apply NOW": bump the skin file's mtime so the gateway
watcher's (name, mtime) signature moves even when the name is unchanged. Built-ins have no file;
a name switch already moves their signature. Best-effort — the config write already succeeded.
"""
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 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`` skips the unknown-key warning (scripted writes of keys this version doesn't know
yet) 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
# Reject malformed dotted keys with empty segments (leading/trailing/double
# dots): ``"agent."`` would write config["agent"][""] and pollute a live
# schema section with a garbage key that round-trips through get.
if key != key.strip() or not key.strip():
print(f"✗ Invalid config key: {key!r} (empty or surrounding whitespace).",
file=sys.stderr)
sys.exit(1)
if "" in _split_key_path(key):
print(
f"✗ Invalid config key: {key!r} — contains an empty path segment "
"(leading, trailing, or doubled '.').",
file=sys.stderr,
)
sys.exit(1)
_exit_if_key_managed(key, "set")
# Check if it's an API key (goes to .env)
if _is_env_config_key(key):
# Unified lifecycle: also rotates any config.yaml mirror of the old
# value so a stale higher-precedence copy can't win (#62269).
from hermes_cli.credential_lifecycle import save_provider_env_credential
save_provider_env_credential(key.upper(), value)
print(f"✓ Set {key} in {get_env_path()}")
return
# Unknown-key notice: arbitrary keys are still written (top-level scalars
# are bridged into os.environ for skills/external apps), but a
# plausible-but-wrong dotted path gets a post-write "did you mean" hint.
# Per-platform display settings live under display.platforms — canonicalize
# 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)
# Otherwise it goes to config.yaml: read the RAW user config (not merged
# with defaults) so defaults are never dumped back to disk; fail-closed on
# unparseable / non-mapping files.
config_path = get_config_path()
user_config = require_readable_config_before_write(config_path)
value = _coerce_config_set_value(key, value)
# Normalize a scalar ``model`` shorthand (``model: gpt-4o``) before writing
# sub-keys; otherwise _set_nested replaces the scalar with an empty dict and
# ``config set model.provider openai`` drops the model id permanently.
if key.strip().lower().startswith("model."):
_model_val = user_config.get("model")
if 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:
print(f"✗ {e}", file=sys.stderr)
sys.exit(1)
# Normalize the api_base → base_url alias at set-time too so the value lands
# on the key the runtime resolver reads (mirrors _normalize_root_model_keys).
if key.strip().lower() in ("model.api_base", "api_base"):
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 only user config back (not the full merged defaults)
ensure_hermes_home()
from utils import atomic_yaml_write
atomic_yaml_write(config_path, user_config, sort_keys=False)
# Keep .env in sync for keys that terminal_tool reads directly from env vars.
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
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. `hermes config set model.api_key cfut_...` routes to config.yaml
# (lowercase, so it misses the .env api_keys list above) and would otherwise
# print the raw secret to the terminal.
_display_value = value
if key.rsplit(".", 1)[-1].lower() in _SECRET_CONFIG_KEYS 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: 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(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 get_config_value(key: str, *, as_json: bool = False):
"""Print a resolved configuration value."""
if _is_env_config_key(key):
env_value = get_env_value(key.upper())
value = _MISSING if env_value is None else env_value
else:
# Mirror set_config_value: read the canonical display.platforms path
# so ``config get`` reports what the gateway resolves (#71047).
key, _ = _redirect_platform_display_key(key)
value = _get_nested(load_config(), key)
if value is _MISSING:
print(f"Config key not set: {key}", file=sys.stderr)
sys.exit(1)
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: prune env-seeded credential_pool entries and
# model-cache rows too, so `hermes config unset <KEY>` fully removes
# the provider instead of leaving it resurrectable (#51071 family).
from hermes_cli.credential_lifecycle import remove_provider_env_credential
if not remove_provider_env_credential(key.upper()).get("found"):
print(f"Config key not set: {key}", file=sys.stderr)
sys.exit(1)
print(f"✓ Unset {key} from {get_env_path()}")
return
config_path = get_config_path()
# Fail-closed parse via require_readable (unparseable / non-mapping
# refuse-write); returns the mapping so we do not re-parse / collapse.
user_config = require_readable_config_before_write(config_path)
# Mirror set_config_value's display.platforms canonicalization (#71047).
key, _redirect_note = _redirect_platform_display_key(key)
if _redirect_note:
print(_redirect_note.replace("saved as", "resolved as"))
removed = _unset_nested(user_config, key)
# Keep .env in sync for keys that terminal_tool reads directly from env vars.
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:
print(f"Config key not set: {key}", file=sys.stderr)
sys.exit(1)
ensure_hermes_home()
from utils import atomic_yaml_write
atomic_yaml_write(config_path, user_config, sort_keys=False)
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 _cmd_config_get(args):
key = getattr(args, 'key', None)
if not key:
_usage_exit("Usage: hermes config get <key> [--json]", [
"hermes config get model",
"hermes config get terminal.backend",
"hermes config get skills.config --json",
])
get_config_value(key, as_json=getattr(args, 'json', False))
def _cmd_config_set(args):
key = getattr(args, 'key', None)
value = getattr(args, 'value', None)
force = bool(getattr(args, 'force', False))
if not key or value is None:
_usage_exit("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",
])
try:
set_config_value(key, value, force=force)
except RuntimeError as exc:
# Fail-closed write guard (unparseable / non-mapping / unreadable
# config.yaml). Surface a clean CLI error instead of a traceback.
print(f"✗ {exc}", file=sys.stderr)
sys.exit(1)
def _cmd_config_unset(args):
key = getattr(args, 'key', None)
if not key:
_usage_exit("Usage: hermes config unset <key>", [
"hermes config unset model",
"hermes config unset terminal.backend",
"hermes config unset OPENROUTER_API_KEY",
])
try:
unset_config_value(key)
except RuntimeError as exc:
# Same fail-closed guard surface as `config set` above.
print(f"✗ {exc}", file=sys.stderr)
sys.exit(1)
def _cmd_config_migrate(args):
print()
print(color("🔄 Checking configuration for updates...", Colors.CYAN, Colors.BOLD))
print()
# Check what's missing
missing_env = get_missing_env_vars(required_only=False)
missing_config = get_missing_config_fields()
current_ver, latest_ver = check_config_version()
if not missing_env and not missing_config and current_ver >= latest_ver:
print(color("✓ Configuration is up to date!", Colors.GREEN))
print()
return
# Show what needs to be updated
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")
]
if required_missing:
print(f"\n ⚠️ {len(required_missing)} required API key(s) missing:")
for var in required_missing:
print(f" • {var['name']}")
if optional_missing:
print(f"\n ℹ️ {len(optional_missing)} optional API key(s) not configured:")
for var in optional_missing:
tools = var.get("tools", [])
tools_str = f" (enables: {', '.join(tools[:2])})" if tools else ""
print(f" • {var['name']}{tools_str}")
print()
# Run migration
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 check for what's missing
print()
print(color("📋 Configuration Status", Colors.CYAN, Colors.BOLD))
print()
current_ver, latest_ver = check_config_version()
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))
print()
print(color(" Required:", Colors.BOLD))
for var_name in REQUIRED_ENV_VARS:
if get_env_value(var_name):
print(f" ✓ {var_name}")
else:
print(color(f" ✗ {var_name} (missing)", Colors.RED))
print()
print(color(" Optional:", Colors.BOLD))
for var_name, info in OPTIONAL_ENV_VARS.items():
if get_env_value(var_name):
print(f" ✓ {var_name}")
else:
tools = info.get("tools", [])
tools_str = f" → {', '.join(tools[:2])}" if tools else ""
print(color(f" ○ {var_name}{tools_str}", Colors.DIM))
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,
}
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("Available commands:")
print(" hermes config Show current configuration")
print(" hermes config edit Open config in editor")
print(" hermes config get <key> Print a resolved config value")
print(" hermes config set <key> <value> Set a config value")
print(" hermes config unset <key> Remove a config value")
print(" hermes config check Check for missing/outdated config")
print(" hermes config migrate Update config with new options")
print(" hermes config path Show config file path")
print(" hermes config env-path Show .env file path")
sys.exit(1)
# ── Profile-driven env var injection ─────────────────────────────────────────
# Any provider registered in providers/ with auth_type="api_key" automatically
# gets its env_vars exposed in OPTIONAL_ENV_VARS without editing this file.
# Runs once at import time.
def _run_once(fn):
"""Idempotent module-load injector: the first call runs *fn*, later calls are no-ops."""
def wrapper() -> None:
if not wrapper.done:
wrapper.done = True
fn()
wrapper.done = False
wrapper.__name__ = fn.__name__
wrapper.__doc__ = fn.__doc__
return wrapper
@_run_once
def _inject_profile_env_vars() -> None:
"""Populate OPTIONAL_ENV_VARS from provider profiles not already listed."""
try:
from providers import list_providers
for _pp in list_providers():
if _pp.auth_type not in {"api_key",}:
continue
for _var in _pp.env_vars:
if _var in OPTIONAL_ENV_VARS:
continue
_is_key = not _var.endswith("_BASE_URL") and not _var.endswith("_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
# Eagerly inject so that OPTIONAL_ENV_VARS is fully populated at import time.
_inject_profile_env_vars()
# ── Platform-plugin env var injection ────────────────────────────────────────
# Bundled platform plugins under ``plugins/platforms/*/plugin.yaml`` declare
# their required env vars via ``requires_env``. This mirror of
# ``_inject_profile_env_vars`` surfaces them in ``hermes config`` UI so users
# can configure Teams / IRC / Google Chat without the core repo ever needing
# to know they exist.
#
# Each ``requires_env`` entry may be a bare string (name only) or a dict:
#
# requires_env:
# - TEAMS_CLIENT_ID # minimal
# - name: TEAMS_CLIENT_SECRET # rich
# description: "Teams bot client secret"
# url: "https://portal.azure.com/"
# password: true
# prompt: "Teams client secret"
#
# An optional ``optional_env`` block surfaces non-required vars the same way
# (e.g. allowlist, home channel).
@_run_once
def _inject_platform_plugin_env_vars() -> None:
"""Populate OPTIONAL_ENV_VARS from bundled platform plugin manifests.
Called once at module load time. Idempotent — repeated calls are no-ops. Failures are swallowed
so a malformed plugin.yaml can't break CLI import.
"""
try:
# Resolve the bundled plugins dir from this file's location so the
# injector works regardless of CWD.
platforms_dir = get_project_root() / "plugins" / "platforms"
if not platforms_dir.is_dir():
return
for child in platforms_dir.iterdir():
if not child.is_dir():
continue
manifest_path = child / "plugin.yaml"
if not manifest_path.exists():
manifest_path = child / "plugin.yml"
if not manifest_path.exists():
continue
try:
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = fast_safe_load(f) or {}
except Exception:
continue
label = manifest.get("label") or manifest.get("name") or child.name
# Merge required + optional env var declarations.
for entry in [*(manifest.get("requires_env") or []), *(manifest.get("optional_env") or [])]:
if isinstance(entry, str):
name = entry
meta: dict = {}
elif isinstance(entry, dict) and entry.get("name"):
name = entry["name"]
meta = entry
else:
continue
if name in OPTIONAL_ENV_VARS:
continue # hardcoded entry wins (back-compat)
# Heuristic: anything named *TOKEN, *SECRET, *KEY, *PASSWORD
# is a password field unless explicitly overridden.
name_upper = name.upper()
is_secret = bool(meta.get("password") or meta.get("secret"))
if not is_secret and not meta.get("password") is False:
is_secret = any(
name_upper.endswith(suf)
for suf in ("_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
# Eagerly inject so that platform plugin env vars show up in the setup wizard.
_inject_platform_plugin_env_vars()