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:
+16
-6
@@ -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))
|
||||
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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`.
|
||||
:::
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user