Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions hermes_cli/env_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,11 @@ def load_hermes_dotenv(
"""Load Hermes environment files with user config taking precedence.

Behavior:
- `~/.hermes/.env` overrides stale shell-exported values when present.
- `~/.hermes/.env` fills missing values by default (override=False),
respecting 12-factor env precedence so runtime-injected secrets
(systemd, Docker, k8s) are not overridden by stale .env values.
- Set ``HERMES_DOTENV_OVERRIDE=1`` to restore the legacy behavior
where .env overrides all pre-existing environment variables.
- 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.
Expand All @@ -235,7 +239,13 @@ def load_hermes_dotenv(
_sanitize_env_file_if_needed(project_env_path)

if user_env.exists():
_load_dotenv_with_fallback(user_env, override=True)
# Respect 12-factor env precedence by default: runtime environment
# variables (systemd EnvironmentFile=, Docker secrets, k8s injection)
# take precedence over the .env file. Users who rely on .env
# overriding stale shell exports can opt back in via
# HERMES_DOTENV_OVERRIDE=1 (see #18705).
user_override = os.getenv("HERMES_DOTENV_OVERRIDE", "0") == "1"
_load_dotenv_with_fallback(user_env, override=user_override)
loaded.append(user_env)

if project_env_path and project_env_path.exists():
Expand Down
34 changes: 34 additions & 0 deletions tests/hermes_cli/test_env_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,45 @@
from hermes_cli.env_loader import load_hermes_dotenv


def test_user_env_fills_missing_shell_values(tmp_path, monkeypatch):
"""Default (12-factor): .env fills missing vars, does NOT override existing."""
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.delenv("OPENAI_BASE_URL", raising=False)

loaded = load_hermes_dotenv(hermes_home=home)

assert loaded == [env_file]
assert os.getenv("OPENAI_BASE_URL") == "https://new.example/v1"


def test_default_does_not_override_shell_values(tmp_path, monkeypatch):
"""Default (12-factor): shell env wins over .env file (override=False)."""
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://old.example/v1"


def test_user_env_overrides_stale_shell_values(tmp_path, monkeypatch):
"""Opt-in override: HERMES_DOTENV_OVERRIDE=1 restores legacy .env-over-shell behavior."""
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")
monkeypatch.setenv("HERMES_DOTENV_OVERRIDE", "1")

loaded = load_hermes_dotenv(hermes_home=home)

Expand Down Expand Up @@ -59,6 +91,7 @@ def test_user_env_takes_precedence_over_project_env(tmp_path, monkeypatch):
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("HERMES_DOTENV_OVERRIDE", "1")
monkeypatch.setenv("OPENAI_BASE_URL", "https://old.example/v1")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)

Expand Down Expand Up @@ -95,6 +128,7 @@ def test_main_import_applies_user_env_over_shell_values(tmp_path, monkeypatch):
)

monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_DOTENV_OVERRIDE", "1")
monkeypatch.setenv("OPENAI_BASE_URL", "https://old.example/v1")
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "openrouter")

Expand Down
58 changes: 58 additions & 0 deletions tests/hermes_cli/test_env_loader_precedence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Tests for load_hermes_dotenv 12-factor env precedence (#18705)."""

from __future__ import annotations

import os
from pathlib import Path

import pytest


class TestLoadHermesDotenvOverride:
"""Verify HERMES_DOTENV_OVERRIDE toggle behavior."""

def test_default_no_override(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""By default, .env should NOT override existing env vars (12-factor)."""
env_file = tmp_path / ".env"
env_file.write_text("TEST_PRECEDENCE_KEY=from_dotenv\n")

# Simulate a runtime-injected secret.
monkeypatch.setenv("TEST_PRECEDENCE_KEY", "from_runtime")
# Ensure the toggle is not set.
monkeypatch.delenv("HERMES_DOTENV_OVERRIDE", raising=False)

from hermes_cli.env_loader import load_hermes_dotenv

load_hermes_dotenv(hermes_home=tmp_path)

assert os.environ["TEST_PRECEDENCE_KEY"] == "from_runtime"

def test_override_opt_in(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""HERMES_DOTENV_OVERRIDE=1 restores legacy override behavior."""
env_file = tmp_path / ".env"
env_file.write_text("TEST_PRECEDENCE_KEY=from_dotenv\n")

monkeypatch.setenv("TEST_PRECEDENCE_KEY", "from_runtime")
monkeypatch.setenv("HERMES_DOTENV_OVERRIDE", "1")

from hermes_cli.env_loader import load_hermes_dotenv

load_hermes_dotenv(hermes_home=tmp_path)

assert os.environ["TEST_PRECEDENCE_KEY"] == "from_dotenv"

def test_no_runtime_var_fills_from_env(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When no runtime var exists, .env should fill the value."""
env_file = tmp_path / ".env"
env_file.write_text("TEST_FILL_KEY=from_dotenv\n")

monkeypatch.delenv("TEST_FILL_KEY", raising=False)
monkeypatch.delenv("HERMES_DOTENV_OVERRIDE", raising=False)

from hermes_cli.env_loader import load_hermes_dotenv

load_hermes_dotenv(hermes_home=tmp_path)

assert os.environ["TEST_FILL_KEY"] == "from_dotenv"
1 change: 1 addition & 0 deletions tests/hermes_cli/test_managed_scope_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def test_no_managed_env_is_noop(env_homes, monkeypatch):

home, managed = env_homes # managed dir exists but has no .env
monkeypatch.setenv("SOME_VALUE", "from_shell")
monkeypatch.setenv("HERMES_DOTENV_OVERRIDE", "1")
(home / ".env").write_text("SOME_VALUE=from_user\n", encoding="utf-8")
load_hermes_dotenv(hermes_home=str(home))
assert os.environ["SOME_VALUE"] == "from_user"
17 changes: 16 additions & 1 deletion tests/hermes_cli/test_managed_scope_regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,28 @@ def test_no_managed_dir_means_user_value_wins(hermes_home):
assert cfg_get(load_config(), "model", "default") == "user/model-y"


def test_user_env_overrides_shell(tmp_path, monkeypatch):
def test_shell_env_wins_by_default(tmp_path, monkeypatch):
"""12-factor: .env fills missing values but does NOT override shell env."""
from hermes_cli.env_loader import load_hermes_dotenv

home = tmp_path / "home"
home.mkdir()
(home / ".env").write_text("FOO_TOKEN=from_user_env\n", encoding="utf-8")
monkeypatch.setenv("FOO_TOKEN", "from_shell")
monkeypatch.delenv("HERMES_DOTENV_OVERRIDE", raising=False)
load_hermes_dotenv(hermes_home=str(home))
assert os.environ["FOO_TOKEN"] == "from_shell"


def test_dotenv_override_opt_in_restores_legacy(tmp_path, monkeypatch):
"""HERMES_DOTENV_OVERRIDE=1 restores legacy .env-overrides-shell behavior."""
from hermes_cli.env_loader import load_hermes_dotenv

home = tmp_path / "home"
home.mkdir()
(home / ".env").write_text("FOO_TOKEN=from_user_env\n", encoding="utf-8")
monkeypatch.setenv("FOO_TOKEN", "from_shell")
monkeypatch.setenv("HERMES_DOTENV_OVERRIDE", "1")
load_hermes_dotenv(hermes_home=str(home))
assert os.environ["FOO_TOKEN"] == "from_user_env"

Expand Down
Loading