The .env write lock (a package-managed install, or a key the administrator pins in the managed-scope .env) refuses inside save_env_value / remove_env_value by printing to stderr and returning. The caller cannot tell that apart from a successful write, and the four multi-store writers built on them carried on: - save_provider_env_credential still rewrote config.yaml api_key mirrors of the old value, reloaded the pool and returned ok:true. - remove_provider_env_credential still pruned the env-seeded credential_pool entry, recorded env:<VAR> in suppressed_sources and dropped config.yaml mirrors. - save_env_setting / remove_env_setting still dropped the top-level config.yaml copy. So `hermes config set|unset <PINNED_KEY>` printed the refusal, then "✓ Set" / "✓ Unset", and exited 0. PUT and DELETE /api/env answered 200. With the user's .env holding an old key and model.api_key mirroring it, a refused set left .env on the old key and moved config.yaml to the new one. The lifecycle writers now check the lock before any side effect and raise ValueError with the refusal (require_env_writable). The CLI set/unset env routes turn that into exit 1, and _env_write_errors already maps ValueError to 400, so the dashboard and Desktop get the reason. save_env_value and remove_env_value keep their return shape for their other callers. model.disconnect (TUI/Desktop) loops over every env var of a provider, so it asks the lock about all of them (env_write_refusal) before removing any: a pinned key refuses with nothing touched, where it used to strip the user's own key first. A locked var that holds nothing is skipped, so Disconnect keeps clearing the auth store on a package-managed install, where every .env write is locked but api keys live in auth.json. The CLI secret prompt prints the refusal instead of only the skill setup's log line.
88 lines
4.2 KiB
Python
88 lines
4.2 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 require_env_writable, save_env_value
|
|
|
|
require_env_writable(key.upper(), "set")
|
|
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, require_env_writable
|
|
|
|
require_env_writable(key.upper(), "remove")
|
|
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
|