fix: isolate profile .env secrets on switch (#351)

* fix: isolate profile .env secrets on switch

* fix: move direct os.environ set after _reload_dotenv to survive profile isolation

The profile env isolation in _reload_dotenv now clears previously tracked
env keys before re-reading .env. When apply_onboarding_setup set
os.environ BEFORE _reload_dotenv, the key was immediately cleared.
Move the belt-and-braces os.environ set to AFTER _reload_dotenv so
the API key survives regardless of profile tracking state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hinotobi
2026-04-13 15:51:55 +08:00
committed by GitHub
parent 1fee123ac8
commit 88dc8bbe26
3 changed files with 91 additions and 5 deletions

View File

@@ -479,9 +479,6 @@ def apply_onboarding_setup(body: dict) -> dict:
if api_key:
_write_env_file(env_path, {provider_meta["env_var"]: api_key})
# Belt-and-braces: set directly on os.environ so the value is visible to
# any code in the same process that reads it before the next request cycle.
os.environ[provider_meta["env_var"]] = api_key
# Reload the hermes_cli provider/config cache so the next streaming call
# picks up the new key without requiring a server restart.
@@ -491,6 +488,12 @@ def apply_onboarding_setup(body: dict) -> dict:
except Exception:
pass
# Belt-and-braces: set directly on os.environ AFTER _reload_dotenv so the
# value survives even if _reload_dotenv cleared it (e.g. when _write_env_file
# wrote to disk but the profile isolation tracking hasn't seen it yet).
if api_key:
os.environ[provider_meta["env_var"]] = api_key
try:
# hermes_cli may cache config at import time; ask it to reload if possible.
from hermes_cli.config import reload as _cli_reload

View File

@@ -26,6 +26,7 @@ _CLONE_CONFIG_FILES = ['config.yaml', '.env', 'SOUL.md']
# ── Module state ────────────────────────────────────────────────────────────
_active_profile = 'default'
_profile_lock = threading.Lock()
_loaded_profile_env_keys: set[str] = set()
def _resolve_base_hermes_home() -> Path:
"""Return the BASE ~/.hermes directory — the root that contains profiles/.
@@ -120,11 +121,24 @@ def _set_hermes_home(home: Path):
def _reload_dotenv(home: Path):
"""Load .env from the profile dir into os.environ (additive)."""
"""Load .env from the profile dir into os.environ with profile isolation.
Clears env vars that were loaded from the previously active profile before
applying the current profile's .env. This prevents API keys and other
profile-scoped secrets from leaking across profile switches.
"""
global _loaded_profile_env_keys
# Remove keys loaded from the previous profile first.
for key in list(_loaded_profile_env_keys):
os.environ.pop(key, None)
_loaded_profile_env_keys = set()
env_path = home / '.env'
if not env_path.exists():
return
try:
loaded_keys: set[str] = set()
for line in env_path.read_text().splitlines():
line = line.strip()
if line and not line.startswith('#') and '=' in line:
@@ -133,8 +147,10 @@ def _reload_dotenv(home: Path):
v = v.strip().strip('"').strip("'")
if k and v:
os.environ[k] = v
loaded_keys.add(k)
_loaded_profile_env_keys = loaded_keys
except Exception:
pass
_loaded_profile_env_keys = set()
def init_profile_state() -> None:

View File

@@ -0,0 +1,67 @@
import importlib
import os
import sys
from pathlib import Path
def test_profile_switch_clears_previous_profile_env_vars(monkeypatch, tmp_path):
base = tmp_path / ".hermes"
(base / "profiles" / "p1").mkdir(parents=True)
(base / "profiles" / "p2").mkdir(parents=True)
(base / "profiles" / "p1" / ".env").write_text(
"OPENAI_API_KEY=secret-from-p1\nCUSTOM_TOKEN=token-from-p1\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_BASE_HOME", str(base))
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("CUSTOM_TOKEN", raising=False)
sys.modules.pop("api.profiles", None)
profiles = importlib.import_module("api.profiles")
profiles = importlib.reload(profiles)
profiles.init_profile_state()
profiles.switch_profile("p1")
assert os.environ.get("OPENAI_API_KEY") == "secret-from-p1"
assert os.environ.get("CUSTOM_TOKEN") == "token-from-p1"
profiles.switch_profile("p2")
assert os.environ.get("OPENAI_API_KEY") is None
assert os.environ.get("CUSTOM_TOKEN") is None
assert profiles.get_active_profile_name() == "p2"
def test_profile_switch_replaces_overlapping_keys(monkeypatch, tmp_path):
base = tmp_path / ".hermes"
(base / "profiles" / "p1").mkdir(parents=True)
(base / "profiles" / "p2").mkdir(parents=True)
(base / "profiles" / "p1" / ".env").write_text(
"OPENAI_API_KEY=secret-from-p1\nONLY_P1=one\n",
encoding="utf-8",
)
(base / "profiles" / "p2" / ".env").write_text(
"OPENAI_API_KEY=secret-from-p2\nONLY_P2=two\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_BASE_HOME", str(base))
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ONLY_P1", raising=False)
monkeypatch.delenv("ONLY_P2", raising=False)
sys.modules.pop("api.profiles", None)
profiles = importlib.import_module("api.profiles")
profiles = importlib.reload(profiles)
profiles.init_profile_state()
profiles.switch_profile("p1")
assert os.environ.get("OPENAI_API_KEY") == "secret-from-p1"
assert os.environ.get("ONLY_P1") == "one"
profiles.switch_profile("p2")
assert os.environ.get("OPENAI_API_KEY") == "secret-from-p2"
assert os.environ.get("ONLY_P1") is None
assert os.environ.get("ONLY_P2") == "two"