Commit 2802e48
authored
* 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
- core
- ui/routers
- tests/core
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
55 | 55 | | |
56 | 56 | | |
57 | 57 | | |
| 58 | + | |
58 | 59 | | |
59 | 60 | | |
60 | 61 | | |
| |||
668 | 669 | | |
669 | 670 | | |
670 | 671 | | |
671 | | - | |
672 | | - | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
| 680 | + | |
| 681 | + | |
673 | 682 | | |
674 | 683 | | |
675 | 684 | | |
| |||
845 | 854 | | |
846 | 855 | | |
847 | 856 | | |
848 | | - | |
849 | | - | |
| 857 | + | |
| 858 | + | |
| 859 | + | |
| 860 | + | |
| 861 | + | |
| 862 | + | |
850 | 863 | | |
851 | 864 | | |
852 | 865 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| 30 | + | |
| 31 | + | |
30 | 32 | | |
31 | 33 | | |
32 | 34 | | |
| |||
579 | 581 | | |
580 | 582 | | |
581 | 583 | | |
582 | | - | |
583 | | - | |
584 | | - | |
585 | | - | |
586 | | - | |
587 | | - | |
588 | | - | |
589 | | - | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
590 | 594 | | |
591 | 595 | | |
592 | 596 | | |
| |||
854 | 858 | | |
855 | 859 | | |
856 | 860 | | |
857 | | - | |
858 | | - | |
| 861 | + | |
| 862 | + | |
859 | 863 | | |
860 | 864 | | |
861 | 865 | | |
| |||
0 commit comments