diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index f926c6ed4e..7afca4229b 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -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 diff --git a/tests/gateway/test_multiplex_credential_isolation.py b/tests/gateway/test_multiplex_credential_isolation.py index f5d2d5d8f6..a43ee8f440 100644 --- a/tests/gateway/test_multiplex_credential_isolation.py +++ b/tests/gateway/test_multiplex_credential_isolation.py @@ -88,6 +88,54 @@ class TestProfilePathResolutionUnderMultiplexScope: 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 ): @@ -164,5 +212,3 @@ def test_cold_profile_hydrates_external_source_without_global_env( assert calls["count"] == 1 assert "TEST_PROVIDER_API_KEY" not in os.environ assert "EXPLICIT_API_KEY" not in os.environ - - diff --git a/tests/test_env_loader_secret_sources.py b/tests/test_env_loader_secret_sources.py index 303ed92268..065270458b 100644 --- a/tests/test_env_loader_secret_sources.py +++ b/tests/test_env_loader_secret_sources.py @@ -174,6 +174,55 @@ def test_cold_profile_bitwarden_uses_profile_bootstrap_without_global_env( assert os.environ.get("ANTHROPIC_API_KEY") is None +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_cold_profile_hydration_seeds_op_env_bootstrap(tmp_path, monkeypatch): """The .op.env bootstrap file must feed cold-profile hydration.