fix(config): route every UPPER_SNAKE key from hermes config set to .env by shape

`hermes config set TELEGRAM_GROUP_ALLOWED_USERS ...` (and ~290 other documented
variables Hermes reads straight from os.getenv without registering them in
OPTIONAL_ENV_VARS) still landed as a config.yaml top-level scalar with a notice,
while the setup flows write .env and one-shot CLI readers never bridge YAML
scalars — two writers, two readers. #112250 routed the registered names; this
closes the class with a shape rule: any bare ^[A-Z][A-Z0-9_]*$ key is an
environment setting.

- set: writes .env, drops a stale config.yaml copy, never writes UPPER_SNAKE
  into config.yaml (--force included); the env writer's denylist
  (HERMES_YOLO_MODE, PATH, ...) now refuses cleanly instead of the YAML detour
  bridging the value into os.environ; a name neither registered nor in the
  environment-variables reference gets a one-line note but is still saved.
- get: .env first; a leftover top-level config.yaml copy is reported as stale.
- unset: removes the .env entry and the stale copy.
- Registered names, credentials (credential lifecycle + masking), dotted paths
  and lowercase bare keys are unchanged.

Fixes #111848 (first half landed in #112250).
This commit is contained in:
teknium1
2026-09-15 20:17:14 -07:00
committed by Teknium
parent db25a7852e
commit f8e8cacf35
6 changed files with 130 additions and 21 deletions
+16 -6
View File
@@ -3544,12 +3544,21 @@ def set_config_value(key: str, value: str, force: bool = False):
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
from hermes_cli.config_env_routing import is_env_setting_key, save_env_setting, unknown_env_name_note
if is_env_setting_key(key):
# Same file the platform setup flows and /sethome write (#111848).
save_env_setting(key, value)
print(f"✓ Set {key} in {get_env_path()}")
# 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()}")
note = unknown_env_name_note(key)
if note:
print(note)
return
# Canonicalize per-platform display keys BEFORE validation/coercion so both see the path the
@@ -3560,8 +3569,9 @@ def set_config_value(key: str, value: str, force: bool = False):
is_known, suggestion = _validate_config_key(key)
# Unknown-key handling (#34067, #112003): an unknown path UNDER a known section can only be a
# typo (``gateway.discord.gateway_restart_notification``), so it is refused before anything is
# written. Unknown TOP-LEVEL keys stay writable with a post-write notice — their scalars are
# bridged into os.environ for skills/external apps, so that namespace is open by design.
# written. Unknown lowercase TOP-LEVEL keys stay writable with a post-write notice — their
# scalars are bridged into os.environ for skills/external apps, so that namespace is open by
# design (UPPER_SNAKE names were already routed to .env above).
if not is_known and not force and _split_key_path(key)[0] in _known_top_level_keys():
_exit_invalid(_unknown_subkey_refusal(key, suggestion))
+57 -11
View File
@@ -5,27 +5,70 @@ Platform setting keys such as ``FEISHU_HOME_CHANNEL`` had two writers: the platf
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).
Every name Hermes itself registers as an environment variable now routes to ``.env`` from
``set``/``get``/``unset``; provider credentials keep their own rotation lifecycle in
``hermes_cli.credential_lifecycle``.
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 functools import lru_cache
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_env_setting_key(key: str) -> bool:
"""True for a bare (undotted) name Hermes documents as a ``.env`` variable: registered in
``OPTIONAL_ENV_VARS`` or ``_EXTRA_ENV_KEYS``, or carrying a self-configuring platform suffix so
plugin adapters nobody enumerated (``IRC_HOME_CHANNEL``) get the same routing."""
if "." in key:
return False
_DOCS_REGISTRY = Path(__file__).resolve().parents[1] / "website" / "docs" / "reference" / "environment-variables.md"
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
name = key.upper()
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())
@lru_cache(maxsize=1)
def _documented_env_names() -> Optional[frozenset]:
"""Names in the environment-variable reference of a source checkout; None when the docs tree is
not installed alongside the package (then nothing can be said about an unregistered name)."""
try:
text = _DOCS_REGISTRY.read_text(encoding="utf-8")
except OSError:
return None
return frozenset(re.findall(r"`([A-Z][A-Z0-9_]*)`", text))
def unknown_env_name_note(key: str) -> Optional[str]:
"""One-line heads-up when neither the code nor the documentation knows ``key``. The value is
still exported to the process environment from ``.env``, so a plugin or skill may read it."""
name = key.upper()
if is_registered_env_name(name):
return None
documented = _documented_env_names()
if documented is None or name in documented:
return None
return (f" (note: Hermes does not read {name} itself; it is exported to the process environment "
"from .env for plugins, skills and external tools)")
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."""
@@ -58,10 +101,13 @@ def remove_env_setting(key: str) -> bool:
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."""
``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
@@ -58,3 +58,56 @@ def test_registered_env_setting_converges_stale_config_yaml_copy(tmp_path, monke
capsys.readouterr()
with pytest.raises(SystemExit):
cfg.get_config_value("WHATSAPP_MODE")
def test_unregistered_upper_snake_name_routes_to_env_by_shape(tmp_path, monkeypatch, capsys):
"""Any ``UPPER_SNAKE`` key is an environment setting even when Hermes never registered it
(``TELEGRAM_GROUP_ALLOWED_USERS`` is read straight from ``os.getenv``): it lands in ``.env``,
the stale ``config.yaml`` copy converges, and ``get`` reads the ``.env`` value. A lowercase bare
key keeps the open top-level namespace (control)."""
from hermes_cli import config as cfg
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "managed"))
config_path = tmp_path / "config.yaml"
config_path.write_text(
"model:\n default: test/model\nTELEGRAM_GROUP_ALLOWED_USERS: '111'\n", encoding="utf-8")
cfg.set_config_value("TELEGRAM_GROUP_ALLOWED_USERS", "222,333")
cfg.set_config_value("HERMES_TIMEZONE", "Europe/Berlin")
cfg.set_config_value("my_custom_flag", "hello")
out = capsys.readouterr().out
assert "does not read" not in out # both names are documented: no unknown-variable note
env_text = (tmp_path / ".env").read_text(encoding="utf-8")
assert "TELEGRAM_GROUP_ALLOWED_USERS=222,333" in env_text and "HERMES_TIMEZONE=Europe/Berlin" in env_text
assert yaml.safe_load(config_path.read_text(encoding="utf-8")) == {
"model": {"default": "test/model"}, "my_custom_flag": "hello"}
cfg.get_config_value("TELEGRAM_GROUP_ALLOWED_USERS")
assert capsys.readouterr().out.strip() == "222,333"
def test_env_writer_denylist_guards_upper_snake_names_and_unknown_names_get_a_note(
tmp_path, monkeypatch, capsys):
"""Routing by shape closes the config.yaml detour around the env writer's denylist: a
denylisted name is refused and written nowhere. A name neither registered nor documented is
still stored in ``.env`` with a one-line note."""
from hermes_cli import config as cfg
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "managed"))
config_path = tmp_path / "config.yaml"
config_path.write_text("model:\n default: test/model\n", encoding="utf-8")
with pytest.raises(SystemExit):
cfg.set_config_value("HERMES_YOLO_MODE", "true", force=True)
assert "denylist" in capsys.readouterr().err
assert not (tmp_path / ".env").exists()
assert yaml.safe_load(config_path.read_text(encoding="utf-8")) == {"model": {"default": "test/model"}}
cfg.set_config_value("SOME_PLUGIN_ONLY_KNOB", "xyz")
out = capsys.readouterr().out
assert "✓ Set SOME_PLUGIN_ONLY_KNOB in" in out and "does not read SOME_PLUGIN_ONLY_KNOB" in out
assert "SOME_PLUGIN_ONLY_KNOB=xyz" in (tmp_path / ".env").read_text(encoding="utf-8")
assert yaml.safe_load(config_path.read_text(encoding="utf-8")) == {"model": {"default": "test/model"}}
+2 -2
View File
@@ -1241,8 +1241,8 @@ Subcommands:
| `show` | Show current config values. |
| `edit` | Open `config.yaml` in your editor. |
| `get <key> [--json] [--raw]` | Print a single config value by dotted key (e.g. `hermes config get model.default`). `--json` emits machine-readable output. Credential-shaped values (`api_key`, `*_TOKEN`, `*_SECRET`, `password`, …) are masked (`sk-o...7890`) because the agent runs this from sessions whose transcripts persist; `--raw` prints the real value (or set `security.redact_secrets: false`). |
| `set <key> <value> [--force]` | Set a config value. Dotted paths go to `config.yaml`; API keys and the environment settings Hermes registers (`OPENROUTER_API_KEY`, `DISCORD_HOME_CHANNEL`, `*_ALLOWED_USERS` and the other platform `*_HOME_CHANNEL` / `*_ALLOWED_USERS`-style names) go to `.env` — the same file the platform setup flows and `/sethome` write. An unknown path under a known section (`gateway.discord.foo`) is refused with a did-you-mean and nothing is written; an unknown *top-level* key is written with a notice (top-level scalars are bridged into the environment for skills). `--force` writes either. |
| `unset <key>` | Remove a config key, reverting it to the built-in default. For `.env`-routed names this also drops a stale top-level `config.yaml` copy. |
| `set <key> <value> [--force]` | Set a config value. Dotted paths go to `config.yaml`; every `UPPER_SNAKE` name (`OPENROUTER_API_KEY`, `DISCORD_HOME_CHANNEL`, `TELEGRAM_GROUP_ALLOWED_USERS`, `HERMES_TIMEZONE`, …) is an environment variable and goes to `.env` — the same file the platform setup flows and `/sethome` write, and the one every runtime reader resolves against. `config set` never writes an `UPPER_SNAKE` key into `config.yaml`, `--force` included; names on the env writer's denylist (`HERMES_YOLO_MODE`, `PATH`, …) are refused outright, and a name Hermes neither reads nor documents is still saved with a one-line note. An unknown path under a known section (`gateway.discord.foo`) is refused with a did-you-mean and nothing is written; an unknown lowercase *top-level* key is written with a notice (top-level scalars are bridged into the environment for skills). `--force` writes either of those. |
| `unset <key>` | Remove a config key, reverting it to the built-in default. For `UPPER_SNAKE` names this removes the `.env` entry and also drops a stale top-level `config.yaml` copy left by older `config set` runs (`get` reports such a copy as stale). |
| `path` | Print the config file path. |
| `env-path` | Print the `.env` file path. |
| `check` | Check for missing or stale config. |
@@ -948,5 +948,5 @@ These go in `~/.hermes/config.yaml` under the `provider_routing` section:
| `data_collection` | `"allow"` (default) or `"deny"` to exclude data-storing providers |
:::tip
Use `hermes config set` to set environment variables — API keys, the optional variables Hermes registers, and any platform `*_HOME_CHANNEL` / `*_ALLOWED_USERS`-style setting are saved to `.env`, the same file the setup flows write; dotted `config.yaml` settings go to `config.yaml`.
Use `hermes config set` to set environment variables — every `UPPER_SNAKE` name on this page (and any other environment-shaped name) is saved to `.env`, the same file the setup flows write and the one the runtime reads; it is never written into `config.yaml`. Names on the env writer's denylist (`HERMES_HOME`, `HERMES_YOLO_MODE`, `PATH`, …) are refused. Dotted `config.yaml` settings go to `config.yaml`.
:::
+1 -1
View File
@@ -47,7 +47,7 @@ hermes config set OPENROUTER_API_KEY sk-or-... # Saves to .env
```
:::tip
The `hermes config set` command automatically routes values to the right file — API keys and the environment settings Hermes registers (`DISCORD_HOME_CHANNEL`, `TELEGRAM_ALLOWED_USERS`, …) are saved to `.env`; other bare ALL-CAPS names are written to `config.yaml` as top-level scalars with a notice, dotted settings to `config.yaml`. A misspelled path under a known section (`gateway.discord.foo`) is refused with a did-you-mean before anything is written; pass `--force` to write it anyway.
The `hermes config set` command automatically routes values to the right file — every `UPPER_SNAKE` name (`OPENROUTER_API_KEY`, `DISCORD_HOME_CHANNEL`, `TELEGRAM_GROUP_ALLOWED_USERS`, `HERMES_TIMEZONE`, …) is an environment variable and is saved to `.env`, never to `config.yaml`; dotted settings go to `config.yaml`. A name Hermes neither reads nor documents is still saved to `.env` with a one-line note (it is exported to the process environment for plugins and skills); names on the env writer's denylist (`HERMES_YOLO_MODE`, `PATH`, …) are refused. A misspelled path under a known section (`gateway.discord.foo`) is refused with a did-you-mean before anything is written; pass `--force` to write it anyway.
:::
## Configuration Precedence