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:
@@ -479,9 +479,6 @@ def apply_onboarding_setup(body: dict) -> dict:
|
|||||||
|
|
||||||
if api_key:
|
if api_key:
|
||||||
_write_env_file(env_path, {provider_meta["env_var"]: 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
|
# Reload the hermes_cli provider/config cache so the next streaming call
|
||||||
# picks up the new key without requiring a server restart.
|
# picks up the new key without requiring a server restart.
|
||||||
@@ -491,6 +488,12 @@ def apply_onboarding_setup(body: dict) -> dict:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
try:
|
||||||
# hermes_cli may cache config at import time; ask it to reload if possible.
|
# hermes_cli may cache config at import time; ask it to reload if possible.
|
||||||
from hermes_cli.config import reload as _cli_reload
|
from hermes_cli.config import reload as _cli_reload
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ _CLONE_CONFIG_FILES = ['config.yaml', '.env', 'SOUL.md']
|
|||||||
# ── Module state ────────────────────────────────────────────────────────────
|
# ── Module state ────────────────────────────────────────────────────────────
|
||||||
_active_profile = 'default'
|
_active_profile = 'default'
|
||||||
_profile_lock = threading.Lock()
|
_profile_lock = threading.Lock()
|
||||||
|
_loaded_profile_env_keys: set[str] = set()
|
||||||
|
|
||||||
def _resolve_base_hermes_home() -> Path:
|
def _resolve_base_hermes_home() -> Path:
|
||||||
"""Return the BASE ~/.hermes directory — the root that contains profiles/.
|
"""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):
|
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'
|
env_path = home / '.env'
|
||||||
if not env_path.exists():
|
if not env_path.exists():
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
loaded_keys: set[str] = set()
|
||||||
for line in env_path.read_text().splitlines():
|
for line in env_path.read_text().splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line and not line.startswith('#') and '=' in line:
|
if line and not line.startswith('#') and '=' in line:
|
||||||
@@ -133,8 +147,10 @@ def _reload_dotenv(home: Path):
|
|||||||
v = v.strip().strip('"').strip("'")
|
v = v.strip().strip('"').strip("'")
|
||||||
if k and v:
|
if k and v:
|
||||||
os.environ[k] = v
|
os.environ[k] = v
|
||||||
|
loaded_keys.add(k)
|
||||||
|
_loaded_profile_env_keys = loaded_keys
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
_loaded_profile_env_keys = set()
|
||||||
|
|
||||||
|
|
||||||
def init_profile_state() -> None:
|
def init_profile_state() -> None:
|
||||||
|
|||||||
67
tests/test_profile_env_isolation.py
Normal file
67
tests/test_profile_env_isolation.py
Normal 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"
|
||||||
Reference in New Issue
Block a user