From 44640149f75075a58baaa9e6fbf58e18c6f148b3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:52:50 -0700 Subject: [PATCH] fix(secrets): allowlist API_SERVER listener settings as global deployment env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #69379 (v2026.7.20 Docker multiplex regression: the scoped runner reload dropped API_SERVER_* set via the container environment, silently losing the api_server platform) by the canonical mechanism — corrects the direction of #69524, which patched gateway/config._getenv to fall through to os.environ on EVERY scoped miss, re-opening the cross-profile borrow for all credentials. API_SERVER_ENABLED / API_SERVER_HOST / API_SERVER_PORT / API_SERVER_CORS_ORIGINS are deployment listener settings (Docker compose environment: block, systemd Environment=), not profile secrets: they join _GLOBAL_ENV_EXACT so get_secret reads them from os.environ regardless of scope. API_SERVER_KEY is deliberately NOT allowlisted — it IS a credential and stays profile-scoped, which keeps tests/gateway/test_config.py's secondary-profile isolation semantics intact (a secondary profile without the key still doesn't bind a listener). Ports #69524's regression test in the corrected form: container-env API_SERVER_* stays visible during the scoped runner reload while the key resolves through the profile scope; plus unit tests locking the allowlist membership and the deliberate exclusion of API_SERVER_KEY. --- agent/secret_scope.py | 8 +++ tests/agent/test_secret_scope.py | 37 ++++++++++++ ...est_64674_multiplex_primary_token_scope.py | 58 +++++++++++++++++++ 3 files changed, 103 insertions(+) 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):