From e471c7165e945ac09f8f899ddbb259e8d5ad6dba Mon Sep 17 00:00:00 2001 From: Jiahui-Gu Date: Wed, 20 May 2026 17:39:56 +0800 Subject: [PATCH] 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) --- hermes_cli/env_loader.py | 41 ++++++++++ tests/hermes_cli/test_env_loader.py | 115 ++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index abeea95dfc00c..5f309827cc9d7 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -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. diff --git a/tests/hermes_cli/test_env_loader.py b/tests/hermes_cli/test_env_loader.py index 9effed26e58aa..0546c9957eb54 100644 --- a/tests/hermes_cli/test_env_loader.py +++ b/tests/hermes_cli/test_env_loader.py @@ -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"