From 28102382af898fb48618ae3c4c9e22d24738d57c Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:05:23 -0700 Subject: [PATCH] fix(config): write config.json atomically via temp+replace save_config truncated config.json in place on crash mid-write. Route through _write_text_atomic like the pairing store so a failed write leaves the prior file intact. --- nanobot/config/loader.py | 5 ++-- tests/config/test_config_atomic_save.py | 36 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 tests/config/test_config_atomic_save.py diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 2fb47abd6..5f526ed5e 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -11,6 +11,7 @@ from loguru import logger from pydantic import BaseModel from nanobot.config.schema import Config, _resolve_tool_config_refs +from nanobot.utils.helpers import _write_text_atomic # Global variable to store current config path (for multi-instance support) _current_config_path: Path | None = None @@ -85,8 +86,8 @@ def save_config(config: Config, config_path: Path | None = None) -> None: "proxy": config.providers.openai_codex.proxy, } - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) + # Temp + replace so a crash mid-write cannot leave a truncated config.json. + _write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False)) def merge_missing_defaults(existing: Any, defaults: Any) -> Any: diff --git a/tests/config/test_config_atomic_save.py b/tests/config/test_config_atomic_save.py new file mode 100644 index 000000000..f2ac23671 --- /dev/null +++ b/tests/config/test_config_atomic_save.py @@ -0,0 +1,36 @@ +"""Tests for atomic config.json writes.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from nanobot.config.loader import load_config, save_config +from nanobot.config.schema import Config + + +def test_save_config_round_trips(tmp_path: Path) -> None: + path = tmp_path / "config.json" + save_config(Config(), path) + loaded = load_config(path) + assert loaded.agents.defaults.model + + +def test_save_config_preserves_existing_file_when_write_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "config.json" + save_config(Config(), path) + before = path.read_text(encoding="utf-8") + + def boom(self: Path, target: Path) -> Path: # noqa: ARG001 + raise OSError("simulated crash before replace") + + monkeypatch.setattr(Path, "replace", boom) + with pytest.raises(OSError, match="simulated crash"): + save_config(Config(), path) + + assert path.read_text(encoding="utf-8") == before + assert json.loads(before) # still valid JSON