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