From d0768a5c54b7bbe56ae8adfcca19887aa5cab2ef Mon Sep 17 00:00:00 2001 From: bearsyankees Date: Tue, 25 Aug 2026 17:34:30 -0400 Subject: [PATCH] fix(config): preserve file overrides on startup --- strix/config/loader.py | 46 +++++++++++++++++++++++-------------- tests/test_config_loader.py | 34 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/strix/config/loader.py b/strix/config/loader.py index fbcde8982..91593296d 100644 --- a/strix/config/loader.py +++ b/strix/config/loader.py @@ -54,21 +54,29 @@ def apply_config_override(path: Path) -> None: def persist_current() -> None: - """Write currently-set env vars to the active config file (0o600).""" + """Merge currently-set env vars into the active config file (0o600).""" s = load_settings() target = _override or _DEFAULT_PATH target.parent.mkdir(parents=True, exist_ok=True) - env_block: dict[str, str] = {} - for sub_name in s.model_fields: + env_block = { + key: value + for key, value in _read_env_block(target).items() + if isinstance(value, str) + } + process_env = {key.upper(): value for key, value in os.environ.items()} + for sub_name in type(s).model_fields: sub_model = getattr(s, sub_name) if not isinstance(sub_model, BaseModel): continue for finfo in type(sub_model).model_fields.values(): - for alias in _aliases_for(finfo): - value = os.environ.get(alias.upper()) + aliases = [alias.upper() for alias in _aliases_for(finfo)] + for alias in aliases: + value = process_env.get(alias) if value: - env_block[alias.upper()] = value + for sibling_alias in aliases: + env_block.pop(sibling_alias, None) + env_block[alias] = value break write_secret_text(target, json.dumps({"env": env_block}, indent=2)) @@ -93,17 +101,7 @@ def _read_json_overrides(path: Path) -> dict[str, dict[str, Any]]: Only includes keys whose env var is NOT already set, so env always wins over the persisted file. """ - if not path.exists(): - return {} - try: - data = json.loads(path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - return {} - env_block = data.get("env", {}) if isinstance(data, dict) else {} - if not isinstance(env_block, dict): - return {} - - env_block_upper = {str(k).upper(): v for k, v in env_block.items()} + env_block_upper = _read_env_block(path) env_present = {k.upper() for k in os.environ} nested: dict[str, dict[str, Any]] = {} @@ -123,3 +121,17 @@ def _read_json_overrides(path: Path) -> dict[str, dict[str, Any]]: if sub_data: nested[sub_name] = sub_data return nested + + +def _read_env_block(path: Path) -> dict[str, Any]: + """Return a config file's environment block with normalized keys.""" + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} + env_block = data.get("env", {}) if isinstance(data, dict) else {} + if not isinstance(env_block, dict): + return {} + return {str(key).upper(): value for key, value in env_block.items()} diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index e83ab1192..4a85d680a 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -208,6 +208,40 @@ def test_persist_current_writes_env_block(tmp_path: Path, monkeypatch: pytest.Mo } +def test_persist_current_preserves_file_only_values(tmp_path: Path) -> None: + target = tmp_path / "cli-config.json" + expected = { + "env": { + "STRIX_LLM": "openrouter/z-ai/glm-5.3", + "LLM_API_KEY": "sk-from-file", + } + } + target.write_text(json.dumps(expected), encoding="utf-8") + loader.apply_config_override(target) + + loader.persist_current() + + assert json.loads(target.read_text(encoding="utf-8")) == expected + + +def test_persist_current_replaces_stale_alias_from_environment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + target = tmp_path / "cli-config.json" + target.write_text( + json.dumps({"env": {"LLM_API_KEY": "sk-from-file"}}), + encoding="utf-8", + ) + monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env") + loader.apply_config_override(target) + + loader.persist_current() + + assert json.loads(target.read_text(encoding="utf-8")) == { + "env": {"OPENAI_API_KEY": "sk-from-env"} + } + + def test_persist_current_sets_0600_mode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("STRIX_LLM", "persisted-model") target = tmp_path / "cli-config.json"