- env-change-with-unchanged-mtime must re-expand (proves the cache stores raw un-expanded YAML, not the expanded result -> no stale cross-profile expansion) - no-cross-serve between two profile config paths (proves the parse cache is path-keyed). Both pass on the current impl; locks down the highest-risk cross-profile credential-bleed invariant per Opus's non-blocking note.
187 lines
7.8 KiB
Python
187 lines
7.8 KiB
Python
"""Phase 2 (#4662): reload_config() must not re-run yaml.safe_load on the hot path
|
|
when config.yaml is unchanged. It now routes its parse through the mtime-keyed
|
|
_load_yaml_config_file cache (#4652). Behavior-preserving: the process-global
|
|
_cfg_cache is still pinned to the unscoped process-env expansion (#798 TLS), env
|
|
expansion still runs per call, and an mtime change still busts the cache.
|
|
"""
|
|
import os
|
|
import time
|
|
|
|
import yaml as _yaml
|
|
|
|
|
|
def test_reload_config_uses_mtime_cache(tmp_path, monkeypatch):
|
|
import api.config as cfg
|
|
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text("providers:\n openai:\n models: [gpt-5.5]\n", encoding="utf-8")
|
|
monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
|
|
|
|
parse_calls = {"n": 0}
|
|
real_safe_load = _yaml.safe_load
|
|
|
|
def _counting_safe_load(s):
|
|
parse_calls["n"] += 1
|
|
return real_safe_load(s)
|
|
|
|
monkeypatch.setattr(_yaml, "safe_load", _counting_safe_load)
|
|
# Clear the shared file cache so the first reload is a genuine miss.
|
|
with cfg._yaml_file_cache_lock:
|
|
cfg._yaml_file_cache.clear()
|
|
|
|
cfg.reload_config()
|
|
first = parse_calls["n"]
|
|
cfg.reload_config() # same file, unchanged mtime -> must hit cache
|
|
second = parse_calls["n"]
|
|
|
|
assert first >= 1, "first reload should parse the file at least once"
|
|
assert second == first, f"unchanged config.yaml was reparsed (parse went {first}->{second})"
|
|
|
|
|
|
def test_reload_config_busts_on_mtime_change(tmp_path, monkeypatch):
|
|
import api.config as cfg
|
|
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text("providers: {}\n", encoding="utf-8")
|
|
monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
|
|
with cfg._yaml_file_cache_lock:
|
|
cfg._yaml_file_cache.clear()
|
|
|
|
cfg.reload_config()
|
|
assert (cfg.get_config().get("providers") or {}) == {}
|
|
|
|
# Edit + bump mtime; the next reload must pick up the change, not the cache.
|
|
time.sleep(0.01)
|
|
config_path.write_text("providers:\n openai: {}\n", encoding="utf-8")
|
|
os.utime(config_path, None)
|
|
cfg.reload_config()
|
|
assert "openai" in (cfg.get_config().get("providers") or {}), "mtime change not picked up"
|
|
|
|
|
|
def test_reload_config_expands_env_vars(tmp_path, monkeypatch):
|
|
"""The pinned process-env expansion must still run: a ${VAR} in config.yaml
|
|
resolves against os.environ after reload_config (the #798 invariant)."""
|
|
import api.config as cfg
|
|
|
|
monkeypatch.setenv("HERMES_TEST_RELOAD_TOKEN", "expanded-value-xyz")
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text(
|
|
"providers:\n openai:\n api_key: ${HERMES_TEST_RELOAD_TOKEN}\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
|
|
with cfg._yaml_file_cache_lock:
|
|
cfg._yaml_file_cache.clear()
|
|
|
|
cfg.reload_config()
|
|
key = ((cfg.get_config().get("providers") or {}).get("openai") or {}).get("api_key")
|
|
assert key == "expanded-value-xyz", f"env var not expanded after reload: {key!r}"
|
|
|
|
|
|
def test_reload_config_reexpands_env_when_mtime_unchanged(tmp_path, monkeypatch):
|
|
"""#798 hardening (Opus gate): the mtime cache must store the RAW, un-expanded
|
|
YAML — not the expanded result — so that a ${VAR} re-expands against the CURRENT
|
|
os.environ on every reload even when config.yaml's mtime is unchanged. If the
|
|
cache stored the expanded value, a profile/env change with an unchanged file
|
|
would serve a stale expansion (cross-profile credential bleed)."""
|
|
import api.config as cfg
|
|
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text(
|
|
"providers:\n openai:\n api_key: ${HERMES_TEST_REEXPAND_TOKEN}\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
|
|
with cfg._yaml_file_cache_lock:
|
|
cfg._yaml_file_cache.clear()
|
|
|
|
# First reload under env value A.
|
|
monkeypatch.setenv("HERMES_TEST_REEXPAND_TOKEN", "value-A")
|
|
cfg.reload_config()
|
|
key_a = ((cfg.get_config().get("providers") or {}).get("openai") or {}).get("api_key")
|
|
assert key_a == "value-A", f"first expansion wrong: {key_a!r}"
|
|
|
|
# Change ONLY the env var — the file (and its mtime) is untouched, so the YAML
|
|
# parse cache will hit. The expansion must still pick up the new env value.
|
|
monkeypatch.setenv("HERMES_TEST_REEXPAND_TOKEN", "value-B")
|
|
cfg.reload_config()
|
|
key_b = ((cfg.get_config().get("providers") or {}).get("openai") or {}).get("api_key")
|
|
assert key_b == "value-B", (
|
|
f"env change with unchanged mtime did not re-expand (got {key_b!r}); "
|
|
"the mtime cache is storing the EXPANDED value instead of raw YAML "
|
|
"-> cross-profile credential-bleed risk (#798)"
|
|
)
|
|
|
|
|
|
def test_reload_config_does_not_cross_serve_between_profile_paths(tmp_path, monkeypatch):
|
|
"""#798 hardening (Opus gate): the YAML parse cache is keyed on the config PATH
|
|
(plus mtime+size), so two different profiles' config.yaml files must never serve
|
|
each other's parsed content — even within the same process. A path-blind cache
|
|
would leak one profile's providers/keys into another."""
|
|
import api.config as cfg
|
|
|
|
path_a = tmp_path / "profile_a" / "config.yaml"
|
|
path_b = tmp_path / "profile_b" / "config.yaml"
|
|
path_a.parent.mkdir(parents=True, exist_ok=True)
|
|
path_b.parent.mkdir(parents=True, exist_ok=True)
|
|
path_a.write_text("providers:\n openai:\n models: [model-a]\n", encoding="utf-8")
|
|
path_b.write_text("providers:\n anthropic:\n models: [model-b]\n", encoding="utf-8")
|
|
|
|
with cfg._yaml_file_cache_lock:
|
|
cfg._yaml_file_cache.clear()
|
|
|
|
monkeypatch.setattr(cfg, "_get_config_path", lambda: path_a)
|
|
cfg.reload_config()
|
|
providers_a = cfg.get_config().get("providers") or {}
|
|
assert "openai" in providers_a and "anthropic" not in providers_a, (
|
|
f"profile A served the wrong config: {sorted(providers_a)}"
|
|
)
|
|
|
|
monkeypatch.setattr(cfg, "_get_config_path", lambda: path_b)
|
|
cfg.reload_config()
|
|
providers_b = cfg.get_config().get("providers") or {}
|
|
assert "anthropic" in providers_b and "openai" not in providers_b, (
|
|
f"profile B was cross-served profile A's config (cache not path-keyed): "
|
|
f"{sorted(providers_b)}"
|
|
)
|
|
|
|
|
|
def test_reload_config_empty_dict_config_does_not_spin(tmp_path, monkeypatch):
|
|
"""An empty ``{}`` config must still stamp _cfg_mtime, or get_config()'s
|
|
`current_mtime != _cfg_mtime` stale check fires forever and re-enters
|
|
reload_config() under _cfg_lock on every call. The cache-update is correctly
|
|
skipped for {} (no-op), but the mtime stamp must not be. (Opus gate finding —
|
|
a {} config is reachable on the profile-switch hot path via a freshly
|
|
created/reset profile, and this also fixes the pre-existing empty/None-config
|
|
spin on master.)
|
|
"""
|
|
import api.config as cfg
|
|
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text("{}\n", encoding="utf-8")
|
|
monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
|
|
with cfg._yaml_file_cache_lock:
|
|
cfg._yaml_file_cache.clear()
|
|
|
|
cfg.reload_config()
|
|
# _cfg_mtime must equal the file's real mtime, not 0.0.
|
|
assert cfg._cfg_mtime == config_path.stat().st_mtime, (
|
|
f"_cfg_mtime not stamped for empty-dict config (got {cfg._cfg_mtime!r}); "
|
|
"get_config() would spin reload_config() forever"
|
|
)
|
|
|
|
# And get_config() must NOT re-enter reload_config() on subsequent calls.
|
|
reloads = {"n": 0}
|
|
real_reload = cfg.reload_config
|
|
|
|
def _counting_reload():
|
|
reloads["n"] += 1
|
|
return real_reload()
|
|
|
|
monkeypatch.setattr(cfg, "reload_config", _counting_reload)
|
|
cfg.get_config()
|
|
cfg.get_config()
|
|
cfg.get_config()
|
|
assert reloads["n"] == 0, f"get_config() re-entered reload_config() {reloads['n']}x on a stable {{}} config (spin)"
|
|
|