diff --git a/agent/secret_scope.py b/agent/secret_scope.py index 1fd87cf144822..919fe3e27bd37 100644 --- a/agent/secret_scope.py +++ b/agent/secret_scope.py @@ -106,6 +106,14 @@ _GLOBAL_ENV_EXACT = frozenset({ "VIRTUAL_ENV", "PYTHONPATH", "SSL_CERT_FILE", # Kanban paths (per-board, not per-profile-secret) "HERMES_KANBAN_DB", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_BOARD", + # API-server LISTENER settings — deployment config (Docker compose + # ``environment:`` block, systemd ``Environment=``), not profile secrets. + # The scoped runner reload (#64674) must keep seeing them or container + # deployments silently lose the api_server platform (#69379). NOTE: + # API_SERVER_KEY is deliberately NOT here — it IS a credential and stays + # profile-scoped. + "API_SERVER_ENABLED", "API_SERVER_HOST", "API_SERVER_PORT", + "API_SERVER_CORS_ORIGINS", }) _GLOBAL_ENV_PREFIXES = ( "HERMES_KANBAN_", diff --git a/tests/agent/test_secret_scope.py b/tests/agent/test_secret_scope.py index ff82eb8b8ac35..f0d76fe654353 100644 --- a/tests/agent/test_secret_scope.py +++ b/tests/agent/test_secret_scope.py @@ -250,3 +250,40 @@ class TestEnvFileParsing: ) assert ss.build_profile_secret_scope(profile) == {} + + +class TestApiServerListenerGlobals: + """API_SERVER listener settings are deployment config (#69379), not + profile secrets: the scoped runner reload must keep seeing container env + (Docker compose ``environment:`` block). API_SERVER_KEY IS a credential + and stays profile-scoped.""" + + LISTENER_VARS = ( + "API_SERVER_ENABLED", + "API_SERVER_HOST", + "API_SERVER_PORT", + "API_SERVER_CORS_ORIGINS", + ) + + def test_listener_vars_read_environ_even_when_scoped_multiplex(self, monkeypatch): + for name in self.LISTENER_VARS: + monkeypatch.setenv(name, f"container-{name.lower()}") + ss.set_multiplex_active(True) + token = ss.set_secret_scope({"TELEGRAM_BOT_TOKEN": "scoped"}) + try: + for name in self.LISTENER_VARS: + assert ss.get_secret(name) == f"container-{name.lower()}" + finally: + ss.reset_secret_scope(token) + + def test_api_server_key_stays_profile_scoped(self, monkeypatch): + monkeypatch.setenv("API_SERVER_KEY", "default-profile-key-0123456789abcdef") + ss.set_multiplex_active(True) + token = ss.set_secret_scope({"OTHER": "x"}) + try: + # A scoped miss must NOT borrow the (potentially cross-profile) + # environ value: API_SERVER_KEY is a credential. + assert ss.get_secret("API_SERVER_KEY") is None + finally: + ss.reset_secret_scope(token) + assert not ss._is_global_env("API_SERVER_KEY") diff --git a/tests/gateway/test_64674_multiplex_primary_token_scope.py b/tests/gateway/test_64674_multiplex_primary_token_scope.py index 1ce5f91abdf27..44398aec19f65 100644 --- a/tests/gateway/test_64674_multiplex_primary_token_scope.py +++ b/tests/gateway/test_64674_multiplex_primary_token_scope.py @@ -49,6 +49,64 @@ class TestLoadGatewayConfigForRunner: cfg = run_mod.load_gateway_config_for_runner() assert cfg.multiplex_profiles is False + def test_scoped_reload_still_sees_container_api_server_env(self, tmp_path, monkeypatch): + """#69379 — container-env API_SERVER_* visible during the scoped reload. + + Docker/systemd deployments enable the api_server platform via the + process environment (compose ``environment:`` block), not the profile + ``.env``. The multiplex runner reload happens inside the default + profile's secret scope; the listener settings are on the global + allowlist (deployment config, not profile secrets) so they must stay + visible there — while API_SERVER_KEY (a credential) still resolves + through the profile scope. + """ + from agent import secret_scope as ss + from gateway import run as run_mod + + home = tmp_path / "home" + home.mkdir() + # Credentials belong in the profile .env; listener settings do not. + (home / ".env").write_text( + "TELEGRAM_BOT_TOKEN=default-profile-token-123\n" + "API_SERVER_KEY=profile-scoped-key-0123456789abcdef\n", + encoding="utf-8", + ) + (home / "config.yaml").write_text( + "gateway:\n multiplex_profiles: true\n", encoding="utf-8" + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + # Listener settings live ONLY in os.environ — the Docker compose case. + monkeypatch.setenv("API_SERVER_ENABLED", "true") + monkeypatch.setenv("API_SERVER_HOST", "0.0.0.0") + monkeypatch.setenv("API_SERVER_PORT", "8642") + monkeypatch.delenv("API_SERVER_KEY", raising=False) + monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False) + monkeypatch.setattr(run_mod, "get_hermes_home", lambda: home) + monkeypatch.setattr(run_mod, "_hermes_home", home) + # Model the real multiplexed gateway: run.py flips the runtime flag + # before the runner reload, making any installed scope authoritative. + ss.set_multiplex_active(True) + + cfg = run_mod.load_gateway_config_for_runner() + + assert cfg.multiplex_profiles is True + # Telegram token from the profile scope (.env) + tg = cfg.platforms.get(Platform.TELEGRAM) + assert tg is not None + assert tg.token == "default-profile-token-123" + # api_server present: key from the profile scope, listener settings + # from the container environment via the global allowlist. + api = cfg.platforms.get(Platform.API_SERVER) + assert api is not None, ( + "api_server should be enabled from container env even inside " + "the scoped runner reload (#69379)" + ) + assert api.enabled is True + assert api.extra.get("key") == "profile-scoped-key-0123456789abcdef" + assert api.extra.get("host") == "0.0.0.0" + assert api.extra.get("port") == 8642 + + class TestPlatformHasBotCredential: def test_telegram_empty_token_false(self):