fix(secrets): allowlist API_SERVER listener settings as global deployment env

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.
This commit is contained in:
Teknium 2026-08-02 00:52:50 -07:00
parent f7efe3d766
commit 44640149f7
3 changed files with 103 additions and 0 deletions

View File

@ -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_",

View File

@ -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")

View File

@ -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):