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.
This commit is contained in:
Shannon Sands
2026-03-27 09:09:29 +10:00
parent d83ea4883b
commit fe325c1b40
3 changed files with 26 additions and 17 deletions
+8 -3
View File
@@ -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
+16 -8
View File
@@ -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
+2 -6
View File
@@ -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"