Skip to content

Commit 2802e48

Browse files
authored
fix(core): make config, credential, installer and workspace-init writes crash-safe (#954) (#1084)
* fix(core): make config, credential, installer and workspace-init writes crash-safe (#954) Five in-place writes that truncate their target before writing a byte, so a crash or a full disk mid-save destroys the previous contents. New `codeframe/core/atomic_io.py` — headless, stdlib-only — does the durable replace properly: unique temp file in the same directory, fsync the file, os.replace, then fsync the directory (the rename itself is not durable without that). `ui/routers/_helpers.atomic_write_json` re-exports it so the two existing router callers are unchanged. Routed through it: `save_environment_config`, `ConfigManager.save`, `record_installation`, `clear_installation_history`, and `CredentialStore._save_encrypted_store` — which had its own temp/replace copy that fsynced nothing (`mode=0o600` applies the permission before the rename, so the ciphertext is never briefly world-readable at its final name). The worst bug here was on the READ side, not the write side. `_load_encrypted_store` returned `{}` on a failed decrypt, which reads as "no credentials stored". The write paths do load -> mutate -> save, so the very next `store()` re-encrypted that empty dict plus one new entry over the top of the real file — permanently destroying every other provider key, in exactly the CODEFRAME_CREDENTIAL_SECRET / machine-id-change scenario CLAUDE.md documents. It now raises `CredentialStoreUnreadableError`; read-only callers (`retrieve`, `list_providers`) degrade through an explicit `_load_encrypted_store_for_read`, write callers let it propagate so the ciphertext survives and stays recoverable. `record_installation` also crashed a *successful* install when environment.json held anything but the expected dict-of-dicts (a list or a string raised TypeError on the index). Any unexpected shape is now treated as "no usable history". Workspace init is transactional: the DB is built at a temp path, the workspace row committed, the WAL checkpointed, and only then os.replace'd into state.db. Before, a crash between `_init_database` and the INSERT left a schema-complete but rowless DB, and every later call hit the `db_path.exists()` fast path and raised "contains no workspace record" — forever, with no way out but deleting .codeframe by hand. Closes #954 * fix(workspace): keep state.db's umask permissions when building via mkstemp (#954) mkstemp forces 0600 and os.replace preserves it, so the transactional-init change silently tightened state.db from the umask-derived mode sqlite used to create it (0644 on a stock 0022 umask). That is an unrequested behavior change that could break a shared-group deployment. Reproduce a normal file creation instead — permissions are not this change's business. * fix(core): correct permission handling and CLI error surfacing (#954 review) Four defects raised by the GLM and claude reviewers on PR #1084. 1. state.db was group-WRITABLE under a 002/007 umask. My previous commit reproduced permissions with `0o666 & ~umask`, but sqlite creates with an 0644 base, not open()'s 0666. Verified: umask 002 -> sqlite 0644, my formula 0664. Fixed by not doing permission math at all — drop mkstemp and let sqlite create the temp DB itself (a uuid4 name gives the per-writer uniqueness mkstemp was providing). My test for this was tautological, using the same formula on both sides, so it passed while being wrong; it now compares against a reference database sqlite creates, across three umasks. 2. `os.umask(0)` + restore per write toggles a process global, so in a threaded server another thread could create a world-writable file in that window. The umask is now read once at import into DEFAULT_FILE_MODE. 3. atomic_write_text/atomic_write_json silently tightened config.yaml, config.json and environment.json to 0600, because mkstemp forces that mode and os.replace carries it onto the target. Only the credential store passed an explicit mode. They now default to DEFAULT_FILE_MODE — what `open(path, "w")` produced before this change. 4. `cf auth setup` / `cf auth rotate` printed a raw traceback when the store was unreadable, since CredentialStoreUnreadableError now propagates and cli/app.py has no top-level handler. That is precisely the user this exception's recovery text was written for. Both now catch and print it, the way `cf auth remove` already did. Every fix has a regression test, and both permission tests are mutation-checked (reinstating either bug fails 6 tests). Not fixed, flagged as a follow-up by the reviewer: record_installation still does an unlocked read-modify-write, so two concurrent installs can lose an entry. Pre-existing and outside this issue's scope. * fix(atomic_io): preserve an existing file's permissions on save (#954 review) Third instance of the same bug class on this branch. os.replace points the target *name* at the temp inode, carrying the temp's mode with it, whereas open(path, "w") truncated the existing inode and left its mode alone. So a fixed DEFAULT_FILE_MODE silently undid an operator's `chmod 600 .codeframe/config.yaml` on the next save — through the web UI's engine toggle, for instance. When no explicit mode is given, the target's current permissions are now preserved and only a not-yet-existing file gets the umask default. An explicit mode (the credential store's 0600) still wins. Raised by the GLM reviewer with the exact failure scenario. Mutation-checked: reverting to the fixed default fails 4 tests. * fix(installer): give get_installation_history the same shape guard as the writer (#954 review) record_installation was hardened against a malformed environment.json; get_installation_history one function below was not. `data.get("installations")` raises AttributeError for any valid-JSON-but-not-an-object file (`["a"]`, `"str"`, `42`, `null`), and `except (json.JSONDecodeError, IOError)` does not catch AttributeError — so it propagated raw. The PR description claimed this method already degraded to {} for a malformed file. That was only true of a parse failure, not the wrong-shape case this change had just added explicit write-side coverage for. The claim is corrected in the PR body. Also pins UTF-8 on the read to match the writer (#1029's convention). Caught by the claude-review bot, which verified the claim instead of taking it. Mutation-checked: removing the guard fails 5 tests.
1 parent 01d52fe commit 2802e48

8 files changed

Lines changed: 1043 additions & 114 deletions

File tree

codeframe/cli/auth_commands.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
CredentialManager,
5656
CredentialProvider,
5757
CredentialSource,
58+
CredentialStoreUnreadableError,
5859
)
5960
from codeframe.core.api_key_service import ApiKeyService
6061
from codeframe.platform_store.database import Database
@@ -668,8 +669,16 @@ def setup_credential(
668669
console.print("Please check the value and try again.")
669670
raise typer.Exit(1)
670671

671-
# Store credential
672-
manager.set_credential(provider_enum, value)
672+
# Store credential. An unreadable store now raises rather than silently
673+
# overwriting itself (#954), and that lands on precisely the users this
674+
# command exists for — someone re-running `cf auth setup` after adding
675+
# CODEFRAME_CREDENTIAL_SECRET or moving machines. Show the exception's
676+
# recovery text instead of a raw traceback (raised by the claude reviewer).
677+
try:
678+
manager.set_credential(provider_enum, value)
679+
except CredentialStoreUnreadableError as e:
680+
console.print(f"[red]Error:[/red] {e}")
681+
raise typer.Exit(1)
673682
console.print(f"[green]Successfully stored credential for {provider_enum.display_name}[/green]")
674683

675684

@@ -845,8 +854,12 @@ def rotate_credential(
845854
console.print("Use --force to skip validation.")
846855
raise typer.Exit(1)
847856

848-
# Rotate credential
849-
manager.rotate_credential(provider_enum, value)
857+
# Rotate credential (same unreadable-store handling as `setup` above).
858+
try:
859+
manager.rotate_credential(provider_enum, value)
860+
except CredentialStoreUnreadableError as e:
861+
console.print(f"[red]Error:[/red] {e}")
862+
raise typer.Exit(1)
850863
console.print(f"[green]Successfully rotated credential for {provider_enum.display_name}[/green]")
851864

852865

codeframe/core/atomic_io.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Atomic, crash-safe file writes.
2+
3+
Headless by construction — stdlib only, no FastAPI/UI imports, so every layer
4+
(core, CLI, routers) can share one implementation.
5+
6+
Why this exists: `open(path, "w")` truncates the target *first*. A crash, a
7+
full disk or a killed process between that truncation and the last byte leaves
8+
the file empty or half-written, and the previous contents are gone. For
9+
`config.yaml`, `environment.json` and the encrypted credential store, that is
10+
silent data loss on the user's machine (#954).
11+
12+
The sequence here is the standard durable-replace dance:
13+
14+
1. write into a temp file **in the same directory** (``os.replace`` is only
15+
atomic within a filesystem),
16+
2. ``fsync`` it so the bytes are on disk before anything points at them,
17+
3. ``os.replace`` onto the target — atomic, so a reader sees either the whole
18+
old file or the whole new one, never a mixture,
19+
4. best-effort ``fsync`` of the directory so the rename itself survives a crash.
20+
21+
The temp name is unique per call: a shared ``.tmp`` suffix let two concurrent
22+
writers collide, with the loser's cleanup deleting the winner's in-flight file
23+
(#920).
24+
"""
25+
26+
import json
27+
import os
28+
import tempfile
29+
from pathlib import Path
30+
from typing import Any, Union
31+
32+
__all__ = [
33+
"atomic_write_bytes",
34+
"atomic_write_text",
35+
"atomic_write_json",
36+
"fsync_directory",
37+
"DEFAULT_FILE_MODE",
38+
]
39+
40+
# ``tempfile.mkstemp`` forces 0600 and ``os.replace`` carries that onto the
41+
# target, so writing through this module would silently tighten config.yaml /
42+
# environment.json from the umask-derived mode ``open(path, "w")`` gave them.
43+
# Default to what a plain create would produce instead; callers that want
44+
# something stricter (the credential store) pass ``mode`` explicitly.
45+
#
46+
# Read once at import, not per call: ``os.umask`` is a read-modify-write of a
47+
# process-global, so doing it on every write opens a window where another
48+
# thread creates a world-writable file (raised by the GLM reviewer).
49+
_UMASK = os.umask(0)
50+
os.umask(_UMASK)
51+
DEFAULT_FILE_MODE = 0o666 & ~_UMASK
52+
53+
54+
def fsync_directory(path: Union[str, Path]) -> None:
55+
"""Best-effort ``fsync`` of a directory so a rename inside it is durable.
56+
57+
``os.replace`` is atomic but not automatically durable: on POSIX
58+
filesystems a power loss right after the rename can lose the new directory
59+
entry even though the file's own contents were synced. Anything that
60+
renames a finished file into place — including the workspace ``state.db``
61+
swap, which does not go through ``atomic_write_bytes`` — needs this.
62+
63+
Silently does nothing where directories cannot be opened (Windows).
64+
"""
65+
try:
66+
dir_fd = os.open(path, os.O_RDONLY)
67+
except OSError: # pragma: no cover - platform dependent
68+
return
69+
try:
70+
os.fsync(dir_fd)
71+
except OSError: # pragma: no cover - platform dependent
72+
pass
73+
finally:
74+
os.close(dir_fd)
75+
76+
77+
def atomic_write_bytes(
78+
path: Union[str, Path], data: bytes, mode: int | None = None
79+
) -> None:
80+
"""Durably replace ``path`` with ``data``.
81+
82+
Args:
83+
path: Target file. Parent directories are created if missing.
84+
data: Bytes to write.
85+
mode: Permission bits applied to the file before it is moved into
86+
place, so it never briefly carries the wrong mode at the target
87+
name (the credential store passes 0600). When omitted, the target's
88+
existing permissions are preserved, and a file that does not exist
89+
yet gets ``DEFAULT_FILE_MODE`` — matching ``open(path, "w")`` in
90+
both cases, rather than mkstemp's 0600.
91+
92+
Raises:
93+
OSError: If the write, fsync or rename fails. The existing file at
94+
``path`` is left untouched in that case.
95+
"""
96+
path = Path(path)
97+
path.parent.mkdir(parents=True, exist_ok=True)
98+
99+
fd, tmp_name = tempfile.mkstemp(
100+
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
101+
)
102+
try:
103+
with os.fdopen(fd, "wb") as f:
104+
f.write(data)
105+
f.flush()
106+
os.fsync(f.fileno())
107+
if mode is not None:
108+
file_mode = mode
109+
else:
110+
# os.replace points the target name at the temp *inode*, carrying
111+
# its mode along, whereas `open(path, "w")` truncated the existing
112+
# inode and left its mode alone. So a fixed default would silently
113+
# undo an operator's `chmod 600 .codeframe/config.yaml` on the next
114+
# save. Preserve what is already there; only a file that does not
115+
# exist yet gets the umask default.
116+
try:
117+
file_mode = path.stat().st_mode & 0o777
118+
except OSError:
119+
file_mode = DEFAULT_FILE_MODE
120+
os.chmod(tmp_name, file_mode)
121+
os.replace(tmp_name, path)
122+
except BaseException:
123+
# Never leak the temp file — a failed save must not litter the
124+
# workspace with .config.yaml.*.tmp droppings.
125+
try:
126+
os.unlink(tmp_name)
127+
except OSError:
128+
pass
129+
raise
130+
131+
# Persist the directory entry too, or the rename itself can be lost on a
132+
# hard crash even though the file contents were synced.
133+
fsync_directory(path.parent)
134+
135+
136+
def atomic_write_text(
137+
path: Union[str, Path], text: str, encoding: str = "utf-8", mode: int | None = None
138+
) -> None:
139+
"""Durably replace ``path`` with ``text``.
140+
141+
The encoding is explicit and defaults to UTF-8 rather than the locale
142+
default, which would otherwise write cp1252 on stock Windows and then be
143+
rejected by our own UTF-8 readers (#931/#1029).
144+
"""
145+
atomic_write_bytes(path, text.encode(encoding), mode=mode)
146+
147+
148+
def atomic_write_json(path: Union[str, Path], payload: Any, mode: int | None = None) -> None:
149+
"""Durably replace ``path`` with ``payload`` serialized as indented JSON."""
150+
atomic_write_text(path, json.dumps(payload, indent=2), mode=mode)

codeframe/core/config.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
from pydantic import BaseModel, Field, field_validator
2828
from pydantic_settings import BaseSettings, SettingsConfigDict
2929

30+
from codeframe.core.atomic_io import atomic_write_json, atomic_write_text
31+
3032
logger = logging.getLogger(__name__)
3133

3234

@@ -579,14 +581,16 @@ def save_environment_config(workspace_path: Path, config: EnvironmentConfig) ->
579581
# Must match the reader's encoding (#931). allow_unicode=True emits non-ASCII
580582
# verbatim, so with the locale default here a value like "café" would be
581583
# written as cp1252 on stock Windows and then rejected by our own UTF-8 read.
582-
with open(config_file, "w", encoding="utf-8") as f:
583-
yaml.dump(
584-
config.to_dict(),
585-
f,
586-
default_flow_style=False,
587-
sort_keys=False,
588-
allow_unicode=True,
589-
)
584+
rendered = yaml.dump(
585+
config.to_dict(),
586+
default_flow_style=False,
587+
sort_keys=False,
588+
allow_unicode=True,
589+
)
590+
# Atomic: `open(..., "w")` truncated config.yaml before writing a byte, so a
591+
# crash or a full disk mid-save left the workspace with an empty or
592+
# half-written config and no way back (#954).
593+
atomic_write_text(config_file, rendered)
590594

591595

592596
def get_default_environment_config() -> EnvironmentConfig:
@@ -854,8 +858,8 @@ def load(self) -> ProjectConfig:
854858
def save(self, config: ProjectConfig) -> None:
855859
"""Save project configuration."""
856860
self.config_dir.mkdir(parents=True, exist_ok=True)
857-
with open(self.config_file, "w") as f:
858-
json.dump(config.model_dump(), f, indent=2)
861+
# Atomic (#954) — see save_environment_config.
862+
atomic_write_json(self.config_file, config.model_dump())
859863
self._project_config = config
860864

861865
def get_global(self) -> GlobalConfig:

0 commit comments

Comments
 (0)