From f24c00a5bf8845fd07e059cce3ded7056af9fec2 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sun, 15 Mar 2026 06:46:28 -0700 Subject: [PATCH] fix(config): reload .env over stale shell overrides Hermes startup entrypoints now load ~/.hermes/.env and project fallback env files with user config taking precedence over stale shell-exported values. This makes model/provider/base URL changes in .env actually take effect after restarting Hermes. Adds a shared env loader plus regression coverage, and reproduces the original bug case where OPENAI_BASE_URL and HERMES_INFERENCE_PROVIDER remained stuck on old shell values before import. --- acp_adapter/entry.py | 15 +++---- cli.py | 17 ++----- gateway/run.py | 14 +++--- hermes_cli/env_loader.py | 46 +++++++++++++++++++ hermes_cli/main.py | 15 +++---- rl_cli.py | 23 +++------- run_agent.py | 22 +++------ tests/hermes_cli/test_env_loader.py | 70 +++++++++++++++++++++++++++++ 8 files changed, 150 insertions(+), 72 deletions(-) create mode 100644 hermes_cli/env_loader.py create mode 100644 tests/hermes_cli/test_env_loader.py diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index 27948612a..820e55f8c 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -42,19 +42,16 @@ def _setup_logging() -> None: def _load_env() -> None: """Load .env from HERMES_HOME (default ``~/.hermes``).""" - from dotenv import load_dotenv + from hermes_cli.env_loader import load_hermes_dotenv hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) - env_file = hermes_home / ".env" - if env_file.exists(): - try: - load_dotenv(dotenv_path=env_file, encoding="utf-8") - except UnicodeDecodeError: - load_dotenv(dotenv_path=env_file, encoding="latin-1") - logging.getLogger(__name__).info("Loaded env from %s", env_file) + loaded = load_hermes_dotenv(hermes_home=hermes_home) + if loaded: + for env_file in loaded: + logging.getLogger(__name__).info("Loaded env from %s", env_file) else: logging.getLogger(__name__).info( - "No .env found at %s, using system env", env_file + "No .env found at %s, using system env", hermes_home / ".env" ) diff --git a/cli.py b/cli.py index 46cf3a209..369e2071d 100755 --- a/cli.py +++ b/cli.py @@ -61,23 +61,14 @@ import queue _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") -# Load .env from ~/.hermes/.env first, then project root as dev fallback -from dotenv import load_dotenv +# Load .env from ~/.hermes/.env first, then project root as dev fallback. +# User-managed env files should override stale shell exports on restart. from hermes_constants import OPENROUTER_BASE_URL +from hermes_cli.env_loader import load_hermes_dotenv _hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) -_user_env = _hermes_home / ".env" _project_env = Path(__file__).parent / '.env' -if _user_env.exists(): - try: - load_dotenv(dotenv_path=_user_env, encoding="utf-8") - except UnicodeDecodeError: - load_dotenv(dotenv_path=_user_env, encoding="latin-1") -elif _project_env.exists(): - try: - load_dotenv(dotenv_path=_project_env, encoding="utf-8") - except UnicodeDecodeError: - load_dotenv(dotenv_path=_project_env, encoding="latin-1") +load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env) # Point mini-swe-agent at ~/.hermes/ so it shares our config os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(_hermes_home)) diff --git a/gateway/run.py b/gateway/run.py index 43ec89269..7bfb8059e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -35,16 +35,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) # Resolve Hermes home directory (respects HERMES_HOME override) _hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) -# Load environment variables from ~/.hermes/.env first -from dotenv import load_dotenv +# Load environment variables from ~/.hermes/.env first. +# User-managed env files should override stale shell exports on restart. +from dotenv import load_dotenv # backward-compat for tests that monkeypatch this symbol +from hermes_cli.env_loader import load_hermes_dotenv _env_path = _hermes_home / '.env' -if _env_path.exists(): - try: - load_dotenv(_env_path, encoding="utf-8") - except UnicodeDecodeError: - load_dotenv(_env_path, encoding="latin-1") -# Also try project .env as fallback -load_dotenv() +load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env') # Bridge config.yaml values into the environment so os.getenv() picks them up. # config.yaml is authoritative for terminal settings — overrides .env. diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py new file mode 100644 index 000000000..83379fc73 --- /dev/null +++ b/hermes_cli/env_loader.py @@ -0,0 +1,46 @@ +"""Helpers for loading Hermes .env files consistently across entrypoints.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Iterable + +from dotenv import load_dotenv + + +def _load_dotenv_with_fallback(path: Path, *, override: bool) -> None: + try: + load_dotenv(dotenv_path=path, override=override, encoding="utf-8") + except UnicodeDecodeError: + load_dotenv(dotenv_path=path, override=override, encoding="latin-1") + + +def load_hermes_dotenv( + *, + hermes_home: str | os.PathLike | None = None, + project_env: str | os.PathLike | None = None, +) -> list[Path]: + """Load Hermes environment files with user config taking precedence. + + Behavior: + - `~/.hermes/.env` overrides stale shell-exported values when present. + - project `.env` acts as a dev fallback and only fills missing values when + the user env exists. + - if no user env exists, the project `.env` also overrides stale shell vars. + """ + loaded: list[Path] = [] + + home_path = Path(hermes_home or os.getenv("HERMES_HOME", Path.home() / ".hermes")) + user_env = home_path / ".env" + project_env_path = Path(project_env) if project_env else None + + if user_env.exists(): + _load_dotenv_with_fallback(user_env, override=True) + loaded.append(user_env) + + if project_env_path and project_env_path.exists(): + _load_dotenv_with_fallback(project_env_path, override=not loaded) + loaded.append(project_env_path) + + return loaded diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 24458017c..e8aa10bf1 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -54,16 +54,11 @@ from typing import Optional PROJECT_ROOT = Path(__file__).parent.parent.resolve() sys.path.insert(0, str(PROJECT_ROOT)) -# Load .env from ~/.hermes/.env first, then project root as dev fallback -from dotenv import load_dotenv -from hermes_cli.config import get_env_path, get_hermes_home -_user_env = get_env_path() -if _user_env.exists(): - try: - load_dotenv(dotenv_path=_user_env, encoding="utf-8") - except UnicodeDecodeError: - load_dotenv(dotenv_path=_user_env, encoding="latin-1") -load_dotenv(dotenv_path=PROJECT_ROOT / '.env', override=False) +# Load .env from ~/.hermes/.env first, then project root as dev fallback. +# User-managed env files should override stale shell exports on restart. +from hermes_cli.config import get_hermes_home +from hermes_cli.env_loader import load_hermes_dotenv +load_hermes_dotenv(project_env=PROJECT_ROOT / '.env') # Point mini-swe-agent at ~/.hermes/ so it shares our config os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(get_hermes_home())) diff --git a/rl_cli.py b/rl_cli.py index 3aa0412d4..4ea28d948 100644 --- a/rl_cli.py +++ b/rl_cli.py @@ -27,25 +27,16 @@ from pathlib import Path import fire import yaml -# Load .env from ~/.hermes/.env first, then project root as dev fallback -from dotenv import load_dotenv - +# Load .env from ~/.hermes/.env first, then project root as dev fallback. +# User-managed env files should override stale shell exports on restart. _hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) -_user_env = _hermes_home / ".env" _project_env = Path(__file__).parent / '.env' -if _user_env.exists(): - try: - load_dotenv(dotenv_path=_user_env, encoding="utf-8") - except UnicodeDecodeError: - load_dotenv(dotenv_path=_user_env, encoding="latin-1") - print(f"✅ Loaded environment variables from {_user_env}") -elif _project_env.exists(): - try: - load_dotenv(dotenv_path=_project_env, encoding="utf-8") - except UnicodeDecodeError: - load_dotenv(dotenv_path=_project_env, encoding="latin-1") - print(f"✅ Loaded environment variables from {_project_env}") +from hermes_cli.env_loader import load_hermes_dotenv + +_loaded_env_paths = load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env) +for _env_path in _loaded_env_paths: + print(f"✅ Loaded environment variables from {_env_path}") # Set terminal working directory to tinker-atropos submodule # This ensures terminal commands run in the right context for RL work diff --git a/run_agent.py b/run_agent.py index 861debbe5..e696ded02 100644 --- a/run_agent.py +++ b/run_agent.py @@ -45,24 +45,16 @@ import fire from datetime import datetime from pathlib import Path -# Load .env from ~/.hermes/.env first, then project root as dev fallback -from dotenv import load_dotenv +# Load .env from ~/.hermes/.env first, then project root as dev fallback. +# User-managed env files should override stale shell exports on restart. +from hermes_cli.env_loader import load_hermes_dotenv _hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) -_user_env = _hermes_home / ".env" _project_env = Path(__file__).parent / '.env' -if _user_env.exists(): - try: - load_dotenv(dotenv_path=_user_env, encoding="utf-8") - except UnicodeDecodeError: - load_dotenv(dotenv_path=_user_env, encoding="latin-1") - logger.info("Loaded environment variables from %s", _user_env) -elif _project_env.exists(): - try: - load_dotenv(dotenv_path=_project_env, encoding="utf-8") - except UnicodeDecodeError: - load_dotenv(dotenv_path=_project_env, encoding="latin-1") - logger.info("Loaded environment variables from %s", _project_env) +_loaded_env_paths = load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env) +if _loaded_env_paths: + for _env_path in _loaded_env_paths: + logger.info("Loaded environment variables from %s", _env_path) else: logger.info("No .env file found. Using system environment variables.") diff --git a/tests/hermes_cli/test_env_loader.py b/tests/hermes_cli/test_env_loader.py new file mode 100644 index 000000000..b85ef4bec --- /dev/null +++ b/tests/hermes_cli/test_env_loader.py @@ -0,0 +1,70 @@ +import importlib +import os +import sys +from pathlib import Path + +from hermes_cli.env_loader import load_hermes_dotenv + + +def test_user_env_overrides_stale_shell_values(tmp_path, monkeypatch): + home = tmp_path / "hermes" + home.mkdir() + env_file = home / ".env" + env_file.write_text("OPENAI_BASE_URL=https://new.example/v1\n", encoding="utf-8") + + monkeypatch.setenv("OPENAI_BASE_URL", "https://old.example/v1") + + loaded = load_hermes_dotenv(hermes_home=home) + + assert loaded == [env_file] + assert os.getenv("OPENAI_BASE_URL") == "https://new.example/v1" + + +def test_project_env_overrides_stale_shell_values_when_user_env_missing(tmp_path, monkeypatch): + home = tmp_path / "hermes" + project_env = tmp_path / ".env" + project_env.write_text("OPENAI_BASE_URL=https://project.example/v1\n", encoding="utf-8") + + monkeypatch.setenv("OPENAI_BASE_URL", "https://old.example/v1") + + loaded = load_hermes_dotenv(hermes_home=home, project_env=project_env) + + assert loaded == [project_env] + assert os.getenv("OPENAI_BASE_URL") == "https://project.example/v1" + + +def test_user_env_takes_precedence_over_project_env(tmp_path, monkeypatch): + home = tmp_path / "hermes" + home.mkdir() + user_env = home / ".env" + project_env = tmp_path / ".env" + user_env.write_text("OPENAI_BASE_URL=https://user.example/v1\n", encoding="utf-8") + project_env.write_text("OPENAI_BASE_URL=https://project.example/v1\nOPENAI_API_KEY=project-key\n", encoding="utf-8") + + monkeypatch.setenv("OPENAI_BASE_URL", "https://old.example/v1") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + loaded = load_hermes_dotenv(hermes_home=home, project_env=project_env) + + assert loaded == [user_env, project_env] + assert os.getenv("OPENAI_BASE_URL") == "https://user.example/v1" + assert os.getenv("OPENAI_API_KEY") == "project-key" + + +def test_main_import_applies_user_env_over_shell_values(tmp_path, monkeypatch): + home = tmp_path / "hermes" + home.mkdir() + (home / ".env").write_text( + "OPENAI_BASE_URL=https://new.example/v1\nHERMES_INFERENCE_PROVIDER=custom\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("OPENAI_BASE_URL", "https://old.example/v1") + monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "openrouter") + + sys.modules.pop("hermes_cli.main", None) + importlib.import_module("hermes_cli.main") + + assert os.getenv("OPENAI_BASE_URL") == "https://new.example/v1" + assert os.getenv("HERMES_INFERENCE_PROVIDER") == "custom"