Files
hermes-agent/hermes_cli/config_env_routing.py
teknium1 08f36192b5 fix(config): drop the "Hermes does not read this" note on config set
The code registry cannot tell a plugin-only name from one the gateway
reads straight off os.environ (TELEGRAM_GROUP_ALLOWED_USERS), so the
note was false for real settings. Every UPPER_SNAKE name simply lands
in .env; the docs say so.
2026-09-15 21:47:48 -07:00

86 lines
4.1 KiB
Python

"""Which ``hermes config`` keys live in ``.env`` instead of ``config.yaml``, and their lifecycle.
Platform setting keys such as ``FEISHU_HOME_CHANNEL`` had two writers: the platform setup flows and
``/sethome`` persist them to ``.env`` through ``save_env_value``, while ``hermes config set`` only
routed credential-shaped names there and wrote every other bare name to the top level of
``config.yaml``. The gateway bridges top-level scalars into the environment only when ``.env`` lacks
the name and one-shot CLI readers never bridge, so the two copies diverged silently (#111848).
The routing rule is the key's SHAPE, not a registry: a bare ``UPPER_SNAKE`` name is an environment
setting and goes to ``.env`` — the file every runtime reader (``os.getenv``, the gateway's
``platform_gate_env``) resolves against — whether or not Hermes enumerates it anywhere. Roughly 290
of the ~700 documented variables (``TELEGRAM_GROUP_ALLOWED_USERS``, ``HERMES_TIMEZONE``, ...) are
read straight from the environment without being registered in ``OPTIONAL_ENV_VARS``, so a registry
check alone kept landing them in ``config.yaml``. Provider credentials keep their own rotation
lifecycle in ``hermes_cli.credential_lifecycle``.
"""
import re
import sys
from pathlib import Path
from typing import Optional
# Environment-variable shape: what every shell and ``os.getenv`` caller treats as a variable name.
# Case-sensitive on purpose: a lowercase bare name (``my_flag``) stays a config.yaml top-level key.
_ENV_SHAPE_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
def is_registered_env_name(name: str) -> bool:
"""True when Hermes itself enumerates ``name``: ``OPTIONAL_ENV_VARS`` / ``_EXTRA_ENV_KEYS``, or a
self-configuring platform suffix so plugin adapters nobody listed (``IRC_HOME_CHANNEL``) count."""
from hermes_cli.config import _EXTRA_ENV_KEYS, OPTIONAL_ENV_VARS
from hermes_cli.setup_hidden_env import is_setup_hidden_env
return name in OPTIONAL_ENV_VARS or name in _EXTRA_ENV_KEYS or is_setup_hidden_env(name)
def is_env_setting_key(key: str) -> bool:
"""True for a bare (undotted) key ``hermes config`` stores in ``.env``: any ``UPPER_SNAKE`` name,
plus registered names typed in any case (``discord_home_channel``)."""
if "." in key:
return False
return bool(_ENV_SHAPE_RE.match(key)) or is_registered_env_name(key.upper())
def _drop_config_yaml_copies(key: str) -> bool:
"""Remove same-named top-level ``config.yaml`` copies (as typed and upper-cased) so the ``.env``
value is the only one the gateway bridge and CLI readers can disagree about."""
from hermes_cli.config import _write_user_config, get_config_path, require_readable_config_before_write
config_path = get_config_path()
user_config = require_readable_config_before_write(config_path)
stale = [name for name in {key, key.upper()} if name in user_config]
for name in stale:
del user_config[name]
if stale:
_write_user_config(config_path, user_config)
return bool(stale)
def save_env_setting(key: str, value: str) -> None:
from hermes_cli.config import save_env_value
save_env_value(key.upper(), value)
_drop_config_yaml_copies(key)
def remove_env_setting(key: str) -> bool:
"""Remove the ``.env`` entry and any stale ``config.yaml`` copy; False when neither existed."""
from hermes_cli.config import remove_env_value
removed = remove_env_value(key.upper())
return _drop_config_yaml_copies(key) or removed
def read_env_setting(key: str) -> Optional[str]:
"""Resolve like the gateway does: ``.env`` first, then a not-yet-converged top-level
``config.yaml`` copy under the name as typed, which is reported as stale on stderr."""
from hermes_cli.config import get_env_value, read_raw_config_readonly
value = get_env_value(key.upper())
if value is None:
value = read_raw_config_readonly().get(key)
if value is not None:
print(f" (note: {key} is a stale top-level config.yaml copy; `hermes config set {key} <value>` "
f"moves it to .env, `hermes config unset {key}` removes it)", file=sys.stderr)
return value