From 7a736da4651dc64f219b35f85ea996d44678f228 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Thu, 20 Aug 2026 16:14:11 +0000 Subject: [PATCH 1/2] fix: preserve persisted config environment --- strix/config/loader.py | 26 ++++++++++++++++++++++++-- tests/test_config_loader.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/strix/config/loader.py b/strix/config/loader.py index fbcde8982..5c8f96702 100644 --- a/strix/config/loader.py +++ b/strix/config/loader.py @@ -54,12 +54,16 @@ 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). + + Keys already on disk are preserved (including keys unknown to this + version's schema); env vars win on conflicts. + """ s = load_settings() target = _override or _DEFAULT_PATH target.parent.mkdir(parents=True, exist_ok=True) - env_block: dict[str, str] = {} + env_block: dict[str, str] = _read_persisted_env(target) for sub_name in s.model_fields: sub_model = getattr(s, sub_name) if not isinstance(sub_model, BaseModel): @@ -74,6 +78,24 @@ def persist_current() -> None: write_secret_text(target, json.dumps({"env": env_block}, indent=2)) +def _read_persisted_env(path: Path) -> dict[str, str]: + """Read the ``{"env": {...}}`` block already stored at ``path``.""" + 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(): str(value) + for key, value in env_block.items() + if isinstance(value, str | int | float) and str(value) + } + + def _aliases_for(finfo: FieldInfo) -> list[str]: """Collect every env-var name that should populate ``finfo``.""" aliases: list[str] = [] diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index e83ab1192..38a650403 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -208,6 +208,43 @@ def test_persist_current_writes_env_block(tmp_path: Path, monkeypatch: pytest.Mo } +def test_persist_current_preserves_keys_missing_from_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("STRIX_LLM", raising=False) + monkeypatch.delenv("LLM_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "env-key") + target = tmp_path / "cli-config.json" + target.write_text( + json.dumps({"env": {"STRIX_LLM": "file-model", "STRIX_FUTURE_KEY": "keep-me"}}), + encoding="utf-8", + ) + loader.apply_config_override(target) + + loader.persist_current() + + assert json.loads(target.read_text(encoding="utf-8")) == { + "env": { + "STRIX_LLM": "file-model", + "STRIX_FUTURE_KEY": "keep-me", + "OPENAI_API_KEY": "env-key", + } + } + + +def test_persist_current_env_wins_over_persisted_value( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("STRIX_LLM", "env-model") + target = tmp_path / "cli-config.json" + target.write_text(json.dumps({"env": {"STRIX_LLM": "file-model"}}), encoding="utf-8") + loader.apply_config_override(target) + + loader.persist_current() + + assert json.loads(target.read_text(encoding="utf-8"))["env"]["STRIX_LLM"] == "env-model" + + 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" From 857b24454dd72d12cb706b09a6d7c260cd76bb66 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Thu, 20 Aug 2026 16:27:33 +0000 Subject: [PATCH 2/2] fix: preserve structured persisted config values --- strix/config/loader.py | 8 ++++---- tests/test_config_loader.py | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/strix/config/loader.py b/strix/config/loader.py index 5c8f96702..03c59ea01 100644 --- a/strix/config/loader.py +++ b/strix/config/loader.py @@ -63,7 +63,7 @@ def persist_current() -> None: target = _override or _DEFAULT_PATH target.parent.mkdir(parents=True, exist_ok=True) - env_block: dict[str, str] = _read_persisted_env(target) + env_block: dict[str, Any] = _read_persisted_env(target) for sub_name in s.model_fields: sub_model = getattr(s, sub_name) if not isinstance(sub_model, BaseModel): @@ -78,7 +78,7 @@ def persist_current() -> None: write_secret_text(target, json.dumps({"env": env_block}, indent=2)) -def _read_persisted_env(path: Path) -> dict[str, str]: +def _read_persisted_env(path: Path) -> dict[str, Any]: """Read the ``{"env": {...}}`` block already stored at ``path``.""" if not path.exists(): return {} @@ -90,9 +90,9 @@ def _read_persisted_env(path: Path) -> dict[str, str]: if not isinstance(env_block, dict): return {} return { - str(key).upper(): str(value) + str(key).upper(): value for key, value in env_block.items() - if isinstance(value, str | int | float) and str(value) + if value is not None and value != "" } diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index 38a650403..5b70fc5d2 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -245,6 +245,32 @@ def test_persist_current_env_wins_over_persisted_value( assert json.loads(target.read_text(encoding="utf-8"))["env"]["STRIX_LLM"] == "env-model" +def test_persist_current_preserves_dict_valued_key( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("STRIX_LLM", raising=False) + monkeypatch.delenv("LLM_EXTRA_HEADERS", raising=False) + target = tmp_path / "cli-config.json" + target.write_text( + json.dumps( + { + "env": { + "STRIX_LLM": "file-model", + "LLM_EXTRA_HEADERS": {"X-Foo": "bar"}, + } + } + ), + encoding="utf-8", + ) + loader.apply_config_override(target) + + loader.persist_current() + + persisted_env = json.loads(target.read_text(encoding="utf-8"))["env"] + assert persisted_env["STRIX_LLM"] == "file-model" + assert persisted_env["LLM_EXTRA_HEADERS"] == {"X-Foo": "bar"} + + 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"