From fe325c1b40cd23e80c3e023ff661ec35461dce4d Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Fri, 27 Mar 2026 09:09:29 +1000 Subject: [PATCH] fix(gateway): overwrite stale env vars on keystore-backed refresh The gateway refresh path now calls keystore injection with force=True so rotated secrets replace stale in-process env vars without requiring a restart. Startup paths still keep the default non-overwriting behavior so shell exports and explicitly supplied env vars win on boot. Also tighten the regression test to require the rotated keystore secret to replace a stale env value during refresh, instead of accepting either old or new values. --- gateway/run.py | 11 ++++++++--- keystore/client.py | 24 ++++++++++++++++-------- tests/gateway/test_keystore_injection.py | 8 ++------ 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 45533287bf..8b1345545a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -87,9 +87,14 @@ _env_path = _hermes_home / '.env' load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env') -def _inject_keystore_env() -> None: +def _inject_keystore_env(force: bool = False) -> None: """Inject keystore-backed secrets into os.environ when available. + Args: + force: If True, overwrite already-present env vars with the latest + keystore values. This is used by the gateway refresh path so + secret rotation takes effect without restart. + Runs independently of config.yaml so gateway/headless deployments using only a keystore (with stubbed .env) still receive credentials. """ @@ -97,7 +102,7 @@ def _inject_keystore_env() -> None: from keystore.client import get_keystore _ks = get_keystore() if _ks.is_initialized and _ks.ensure_unlocked(interactive=False): - _ks.inject_env() + _ks.inject_env(force=force) except ImportError: pass except Exception as _e: @@ -5252,7 +5257,7 @@ class GatewayRunner: # Re-inject keystore secrets too so rotated values take effect # without requiring a gateway restart. try: - _inject_keystore_env() + _inject_keystore_env(force=True) except Exception: pass diff --git a/keystore/client.py b/keystore/client.py index 6b1f0f5c50..1c91d3b2e5 100644 --- a/keystore/client.py +++ b/keystore/client.py @@ -153,27 +153,35 @@ class KeystoreClient: raise PassphraseMismatch("Too many incorrect passphrase attempts") - def inject_env(self) -> Dict[str, bool]: + def inject_env(self, force: bool = False) -> Dict[str, bool]: """Inject all ``injectable`` secrets into ``os.environ``. - Returns a dict of {secret_name: True} for each injected secret. - Existing env vars are NOT overwritten (shell exports take priority). + Args: + force: If True, overwrite existing env vars with keystore values. + Use this only for explicit refresh flows in long-lived processes + (e.g. gateway credential reload). Startup paths should usually + keep the default ``False`` so shell exports/Docker env vars win. + + Returns: + Dict of ``{secret_name: injected_or_overwritten}``. """ secrets = self._store.get_injectable_secrets() injected = {} for name, value in secrets.items(): - if name not in os.environ: + if force or name not in os.environ: os.environ[name] = value injected[name] = True else: # Already set in environment (shell export or Docker env) injected[name] = False self._injected = injected - count_new = sum(1 for v in injected.values() if v) - count_existing = sum(1 for v in injected.values() if not v) + count_written = sum(1 for v in injected.values() if v) + count_skipped = sum(1 for v in injected.values() if not v) logger.info( - "Keystore: injected %d secrets (%d already in env)", - count_new, count_existing, + "Keystore: %s %d secrets (%d skipped)", + "refreshed" if force else "injected", + count_written, + count_skipped, ) return injected diff --git a/tests/gateway/test_keystore_injection.py b/tests/gateway/test_keystore_injection.py index 28567f529b..1f0850c5eb 100644 --- a/tests/gateway/test_keystore_injection.py +++ b/tests/gateway/test_keystore_injection.py @@ -72,12 +72,8 @@ def test_gateway_refresh_reinjects_keystore_secret(monkeypatch, tmp_path): gateway_run = _reload_gateway_run(monkeypatch, home) assert os.environ.get("OPENAI_API_KEY") == "sk-old" - # Rotate secret in keystore; .env stays empty. + # Rotate secret in keystore; refresh must overwrite the stale in-process env var. ks.set_secret("OPENAI_API_KEY", "sk-new") os.environ["OPENAI_API_KEY"] = "stale" - gateway_run._inject_keystore_env() - assert os.environ.get("OPENAI_API_KEY") == "stale" or os.environ.get("OPENAI_API_KEY") == "sk-new" - # More important guarantee: if env is cleared, reinjection restores the keystore value. - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - gateway_run._inject_keystore_env() + gateway_run._inject_keystore_env(force=True) assert os.environ.get("OPENAI_API_KEY") == "sk-new"