fix(env): make config.yaml authoritative for terminal.backend (#29186)
A leftover TERMINAL_ENV in ~/.hermes/.env (written by `hermes setup` or shell exports) was silently overriding terminal.backend in config.yaml, so users switching from docker to local saw `hermes config show` agree with their change while the gateway / cron / batch_runner still ran against the old backend. load_hermes_dotenv now re-applies config.yaml's terminal.* values on top of whatever the .env files set, so the documented source of truth wins for every entrypoint that goes through the loader. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a6defd4f15
commit
e471c7165e
|
|
@ -512,9 +512,50 @@ def load_hermes_dotenv(
|
|||
_apply_external_secret_sources(home_path)
|
||||
_apply_managed_env()
|
||||
|
||||
# config.yaml is the documented source of truth for terminal.* settings,
|
||||
# but the dotenv loads above run with override=True — so a stale
|
||||
# TERMINAL_ENV=docker left in ~/.hermes/.env (e.g. written by an older
|
||||
# `hermes setup` before the user switched terminal.backend in config.yaml)
|
||||
# silently wins again on every reload. Startup launchers bridge
|
||||
# config→env once, but long-lived processes (gateway per-turn reload,
|
||||
# cron standalone runs) call load_hermes_dotenv() repeatedly and used to
|
||||
# flip the effective backend back to the stale .env value mid-session
|
||||
# (#29186, #67323). Re-apply config.yaml's explicit terminal keys last so
|
||||
# the documented config path always wins. Runs after _apply_managed_env()
|
||||
# so the merged config (which already carries the managed overlay) is
|
||||
# what lands in the env.
|
||||
_reapply_terminal_config_bridge(home_path)
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
def _reapply_terminal_config_bridge(home_path: Path) -> None:
|
||||
"""Re-assert config.yaml's explicit ``terminal.*`` keys over reloaded .env.
|
||||
|
||||
Delegates to ``hermes_cli.config.apply_terminal_config_to_env`` — the
|
||||
single shared bridge (same one terminal_tool's fallback and the TUI/
|
||||
dashboard launchers use) — so key coverage, explicit-keys-only override
|
||||
semantics, cwd placeholder handling, and the managed-scope overlay can't
|
||||
drift from the other bridge sites. Only keys the user actually wrote in
|
||||
config.yaml's ``terminal`` section override env values; a config.yaml
|
||||
without a terminal section leaves .env/shell selections untouched.
|
||||
|
||||
Scoped to the process HERMES_HOME: the shared bridge reads the
|
||||
process-global config, so re-applying it for a *different* profile's
|
||||
``load_hermes_dotenv(hermes_home=...)`` call would bridge the wrong
|
||||
profile's config. Fail-open — a config problem must never break dotenv
|
||||
loading (the historical env-driven behavior still applies).
|
||||
"""
|
||||
try:
|
||||
if Path(home_path).resolve() != _process_hermes_home().resolve():
|
||||
return
|
||||
from hermes_cli.config import apply_terminal_config_to_env
|
||||
|
||||
apply_terminal_config_to_env(env=None)
|
||||
except Exception: # noqa: BLE001 — early bootstrap / malformed config
|
||||
pass
|
||||
|
||||
|
||||
def _apply_managed_env() -> None:
|
||||
"""Apply the managed-scope .env last, with override, so it beats user/shell.
|
||||
|
||||
|
|
|
|||
|
|
@ -325,3 +325,118 @@ def test_cleanup_scope_is_the_profile_managed_set():
|
|||
f"{key} looks credential-shaped; startup scrub must not "
|
||||
"cover credentials — read-time secret scoping owns those"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# config.yaml terminal.* re-apply after dotenv loads (#29186 / #67323)
|
||||
#
|
||||
# load_hermes_dotenv loads .env with override=True, so a stale
|
||||
# TERMINAL_ENV=docker in .env used to silently beat config.yaml's
|
||||
# terminal.backend on every reload (gateway per-turn reload, cron standalone
|
||||
# runs). The bridge re-applies config.yaml's EXPLICIT terminal keys last via
|
||||
# the shared hermes_cli.config.apply_terminal_config_to_env helper.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_terminal_home(tmp_path, monkeypatch, *, config_yaml=None, env_text=None):
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
if config_yaml is not None:
|
||||
(home / "config.yaml").write_text(config_yaml, encoding="utf-8")
|
||||
if env_text is not None:
|
||||
(home / ".env").write_text(env_text, encoding="utf-8")
|
||||
# The bridge is scoped to the process HERMES_HOME (a different profile's
|
||||
# load must not bridge this process's config), so point the process at
|
||||
# the seeded home like a real gateway/cron process would be.
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
return home
|
||||
|
||||
|
||||
def test_config_yaml_terminal_backend_overrides_stale_env(tmp_path, monkeypatch):
|
||||
"""Regression for #29186: a leftover TERMINAL_ENV=docker in ~/.hermes/.env
|
||||
must not silently override the user's choice in config.yaml. config.yaml
|
||||
is the documented source of truth, so its value must win after load."""
|
||||
home = _seed_terminal_home(
|
||||
tmp_path, monkeypatch,
|
||||
config_yaml="terminal:\n backend: local\n",
|
||||
env_text="TERMINAL_ENV=docker\n",
|
||||
)
|
||||
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
|
||||
load_hermes_dotenv(hermes_home=home)
|
||||
|
||||
assert os.getenv("TERMINAL_ENV") == "local"
|
||||
|
||||
|
||||
def test_config_yaml_terminal_backend_overrides_stale_shell(tmp_path, monkeypatch):
|
||||
"""config.yaml must also beat a stale TERMINAL_ENV exported in the shell
|
||||
(e.g. set in ~/.zshrc when the user was experimenting with docker)."""
|
||||
home = _seed_terminal_home(
|
||||
tmp_path, monkeypatch,
|
||||
config_yaml="terminal:\n backend: local\n",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
||||
|
||||
load_hermes_dotenv(hermes_home=home)
|
||||
|
||||
assert os.getenv("TERMINAL_ENV") == "local"
|
||||
|
||||
|
||||
def test_no_terminal_section_leaves_env_value_alone(tmp_path, monkeypatch):
|
||||
"""When config.yaml has no terminal section, the .env value is still the
|
||||
user's active setting — the bridge must NOT clobber it with merged
|
||||
defaults."""
|
||||
home = _seed_terminal_home(
|
||||
tmp_path, monkeypatch,
|
||||
config_yaml="display:\n streaming: true\n",
|
||||
env_text="TERMINAL_ENV=docker\n",
|
||||
)
|
||||
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
|
||||
load_hermes_dotenv(hermes_home=home)
|
||||
|
||||
assert os.getenv("TERMINAL_ENV") == "docker"
|
||||
|
||||
|
||||
def test_config_yaml_terminal_omitted_key_does_not_clear_env(tmp_path, monkeypatch):
|
||||
"""If config.yaml has a terminal block but no `backend`, the .env value
|
||||
must survive (only explicit config keys override env)."""
|
||||
home = _seed_terminal_home(
|
||||
tmp_path, monkeypatch,
|
||||
config_yaml="terminal:\n timeout: 600\n",
|
||||
env_text="TERMINAL_ENV=docker\n",
|
||||
)
|
||||
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
|
||||
load_hermes_dotenv(hermes_home=home)
|
||||
|
||||
assert os.getenv("TERMINAL_ENV") == "docker"
|
||||
assert os.getenv("TERMINAL_TIMEOUT") == "600"
|
||||
|
||||
|
||||
def test_other_profile_home_does_not_bridge_process_config(tmp_path, monkeypatch):
|
||||
"""Loading a DIFFERENT profile's .env must not re-bridge this process's
|
||||
config.yaml — the shared bridge reads the process-global config, so
|
||||
applying it for another home would stamp the wrong profile's terminal
|
||||
settings into the env."""
|
||||
process_home = tmp_path / "process-home"
|
||||
process_home.mkdir()
|
||||
(process_home / "config.yaml").write_text(
|
||||
"terminal:\n backend: local\n", encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(process_home))
|
||||
|
||||
other_home = tmp_path / "other-profile"
|
||||
other_home.mkdir()
|
||||
(other_home / ".env").write_text("TERMINAL_ENV=docker\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
|
||||
load_hermes_dotenv(hermes_home=other_home)
|
||||
|
||||
# The other profile's .env value stands; the process config was not applied.
|
||||
assert os.getenv("TERMINAL_ENV") == "docker"
|
||||
|
|
|
|||
Loading…
Reference in New Issue