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
26 changes: 24 additions & 2 deletions hermes_cli/env_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,10 +483,32 @@ def load_hermes_dotenv(
- callers that only maintain the installation can set
``load_external_secrets=False`` to avoid loading optional secret-manager
dependencies into the process that replaces that same environment.
- routed multiplex profile loads hydrate external sources into the
profile's private secret snapshot without mutating the shared process
environment; unscoped startup loads retain the normal behavior above.
"""
loaded: list[Path] = []

home_path = Path(hermes_home or os.getenv("HERMES_HOME", Path.home() / ".hermes"))

# A multiplex gateway hosts every profile in one process. While a routed
# profile-home override is active, copying that profile's .env into
# os.environ would expose its credentials to sibling turns and every
# subsequently spawned child. An unscoped startup load remains process
# configuration and must retain the normal loading path.
# External secret sources still need their normal refresh path, so resolve
# them against the existing profile-local mapping instead of simply
# returning before all hydration work.
from agent.secret_scope import is_multiplex_active
from hermes_constants import get_hermes_home_override

if is_multiplex_active() and get_hermes_home_override() is not None:
if load_external_secrets:
from hermes_cli import _early_recovery

if not _early_recovery._should_skip_external_secret_sources():
hydrate_profile_secret_sources(home_path)
return []

loaded: list[Path] = []
user_env = home_path / ".env"
project_env_path = Path(project_env) if project_env else None

Expand Down
51 changes: 49 additions & 2 deletions tests/gateway/test_multiplex_credential_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from agent import secret_scope as ss



@pytest.fixture(autouse=True)
def _reset(monkeypatch):
ss.set_multiplex_active(False)
Expand Down Expand Up @@ -88,6 +89,54 @@ def test_skills_dir_follows_multiplex_scope(self, tmp_path):
assert b_seen == prof_b / "skills"


def test_turn_scoped_dotenv_reload_does_not_pollute_process_env(tmp_path, monkeypatch):
"""A routed profile reload must stay inside its context-local scope.

``load_hermes_dotenv`` has several lazy-import and cron call sites beyond
the gateway's guarded reload helper. Any one of them can run during a
multiplexed turn, so the loader itself must not copy the active profile's
``.env`` into the shared process environment.
"""
import os

from agent.secret_scope import get_secret
from gateway.run import _profile_runtime_scope
from hermes_cli.env_loader import load_hermes_dotenv
from hermes_constants import get_hermes_home

profile_a = tmp_path / "profiles" / "a"
profile_b = tmp_path / "profiles" / "b"
profile_a.mkdir(parents=True)
profile_b.mkdir(parents=True)
(profile_a / ".env").write_text(
"PROFILE_SCOPED_API_KEY=secret-a\n"
"DISCORD_ALLOWED_CHANNELS=profile-a-only\n",
encoding="utf-8",
)
(profile_b / ".env").write_text(
"PROFILE_SCOPED_API_KEY=secret-b\n"
"DISCORD_ALLOWED_CHANNELS=profile-b-only\n",
encoding="utf-8",
)
monkeypatch.delenv("PROFILE_SCOPED_API_KEY", raising=False)
monkeypatch.setenv("DISCORD_ALLOWED_CHANNELS", "all-channels")

ss.set_multiplex_active(True)
with _profile_runtime_scope(profile_a):
assert get_secret("PROFILE_SCOPED_API_KEY") == "secret-a"
assert get_secret("DISCORD_ALLOWED_CHANNELS") == "profile-a-only"
assert load_hermes_dotenv(hermes_home=get_hermes_home()) == []
assert "PROFILE_SCOPED_API_KEY" not in os.environ
assert os.environ["DISCORD_ALLOWED_CHANNELS"] == "all-channels"

with _profile_runtime_scope(profile_b):
assert get_secret("PROFILE_SCOPED_API_KEY") == "secret-b"
assert get_secret("DISCORD_ALLOWED_CHANNELS") == "profile-b-only"
assert load_hermes_dotenv(hermes_home=get_hermes_home()) == []
assert "PROFILE_SCOPED_API_KEY" not in os.environ
assert os.environ["DISCORD_ALLOWED_CHANNELS"] == "all-channels"


def test_cold_profile_hydrates_external_source_without_global_env(
tmp_path, monkeypatch
):
Expand Down Expand Up @@ -164,5 +213,3 @@ def _fake_apply_all(_cfg, _home, *, environ=None):
assert calls["count"] == 1
assert "TEST_PROVIDER_API_KEY" not in os.environ
assert "EXPLICIT_API_KEY" not in os.environ


106 changes: 106 additions & 0 deletions tests/test_env_loader_secret_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,112 @@ def _fake_fetch(**kwargs):
assert os.environ.get("ANTHROPIC_API_KEY") is None


def test_multiplex_without_profile_scope_still_loads(tmp_path, monkeypatch):
"""Multiplex startup without a routed profile scope still loads .env."""
from agent import secret_scope
from hermes_constants import get_hermes_home_override

env_file = tmp_path / ".env"
env_file.write_text("DISCORD_ALLOWED_CHANNELS=123,456\n", encoding="utf-8")
monkeypatch.delenv("DISCORD_ALLOWED_CHANNELS", raising=False)

assert get_hermes_home_override() is None

was_active = secret_scope.is_multiplex_active()
secret_scope.set_multiplex_active(True)
try:
loaded = env_loader.load_hermes_dotenv(hermes_home=tmp_path)
finally:
secret_scope.set_multiplex_active(was_active)

assert os.environ.get("DISCORD_ALLOWED_CHANNELS") == "123,456"
assert env_file in loaded


def test_multiplex_dotenv_load_hydrates_sources_without_global_env(
tmp_path, monkeypatch
):
"""The safe multiplex path must still refresh profile secret sources."""
from agent import secret_scope
import agent.secret_sources.bitwarden as bw_module
from agent.secret_sources import registry as reg_module
from hermes_constants import (
reset_hermes_home_override,
set_hermes_home_override,
)

monkeypatch.delenv("BWS_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
(tmp_path / ".env").write_text(
"BWS_ACCESS_TOKEN=profile-bootstrap\n", encoding="utf-8"
)
(tmp_path / "config.yaml").write_text(
"secrets:\n"
" bitwarden:\n"
" enabled: true\n"
" project_id: test-project\n"
" access_token_env: BWS_ACCESS_TOKEN\n",
encoding="utf-8",
)
monkeypatch.setattr(bw_module, "find_bws", lambda **_kw: Path("/fake/bws"))
monkeypatch.setattr(
bw_module,
"fetch_bitwarden_secrets",
lambda **_kw: ({"ANTHROPIC_API_KEY": "profile-provider-key"}, []),
)
reg_module._reset_registry_for_tests()

was_active = secret_scope.is_multiplex_active()
home_token = set_hermes_home_override(tmp_path)
secret_scope.set_multiplex_active(True)
try:
assert env_loader.load_hermes_dotenv(hermes_home=tmp_path) == []
finally:
secret_scope.set_multiplex_active(was_active)
reset_hermes_home_override(home_token)

assert env_loader.get_secret_source_values(tmp_path) == {
"ANTHROPIC_API_KEY": "profile-provider-key"
}
assert os.environ.get("BWS_ACCESS_TOKEN") is None
assert os.environ.get("ANTHROPIC_API_KEY") is None


def test_multiplex_scoped_load_respects_external_secret_opt_out(
tmp_path, monkeypatch
):
"""Updater opt-out must skip hydration without exporting profile dotenv."""
from agent import secret_scope
from hermes_constants import (
reset_hermes_home_override,
set_hermes_home_override,
)

(tmp_path / ".env").write_text("PROFILE_ONLY=secret\n", encoding="utf-8")
monkeypatch.delenv("PROFILE_ONLY", raising=False)
hydration_calls = []
monkeypatch.setattr(
env_loader,
"hydrate_profile_secret_sources",
lambda home: hydration_calls.append(home),
)

was_active = secret_scope.is_multiplex_active()
home_token = set_hermes_home_override(tmp_path)
secret_scope.set_multiplex_active(True)
try:
assert env_loader.load_hermes_dotenv(
hermes_home=tmp_path,
load_external_secrets=False,
) == []
finally:
secret_scope.set_multiplex_active(was_active)
reset_hermes_home_override(home_token)

assert hydration_calls == []
assert os.environ.get("PROFILE_ONLY") is None


def test_cold_profile_hydration_seeds_op_env_bootstrap(tmp_path, monkeypatch):
"""The .op.env bootstrap file must feed cold-profile hydration.

Expand Down
Loading