fix(core): serialise record_installation, format the credential-store 500 (#1085) - #1142
Conversation
… 500 (#1085) Two gaps #954/#1084 left as out of scope. 1. record_installation did an unlocked read-modify-write #954 made the write atomic, so a crash cannot truncate the file. It does not stop two concurrent installs reading the same base and the second write dropping the first tool. The lock pattern lives in atomic_io now rather than being copied a third time from credentials.py — read_modify_write_lock(lock_path), thread lock plus an optional cross-process filelock, degrading to thread-only with one warning when filelock is absent. atomic_io is where "an atomic write is not a serialised write" belongs, and both callers already import from it. 2. An unreadable credential store gave a bare 500 CredentialManager's constructor runs the machine-wide migration, which can raise CredentialStoreUnreadableError since #954. Raised from a FastAPI *dependency* it bypasses each route's own try/except, so the client got an unformatted 500 instead of the api_error(...) shape every other path produces — and lost the recovery text the CLI already prints. All four dependency functions (both routers define their own get_credential_manager and _readonly) now catch it. The race tests use a threading.Barrier so both threads provably read the same base, rather than hoping for an unlucky interleaving; removing the lock fails both, including the ten-thread case. One of my own tests was passing vacuously: it wrote to "credentials.enc" while the loader reads "credentials.encrypted", so it exercised nothing. It uses the ENCRYPTED_FILE_NAME constant now. Full suite: 6439 passed, 49 skipped.
|
Claude finished @frankbria's task in 6m 43s —— View job Bug-hunt review — PR #1142Scoped to concrete defects only (logic errors, data loss/races, security, contract breaks). Style/coverage/architecture are covered by the general review. 1 minor finding (posted inline on
Verified correct (no defect)
Investigated and discarded (not reportable)
No critical/major defects. The locking work is sound; the only actionable item is the path-leak above. |
ReviewReviewed the diff ( Correctness
Test coverage Minor, non-blocking nits
Nothing else stood out. Scope is honest about what it's deliberately not doing (not migrating |
…1085) Security finding from review, and a disclosure this PR introduced. The bare 500 it replaced leaked nothing; api_error(str(e), ...) leaked the absolute store path — /home/<operator>/.codeframe/users/<id>/credentials.encrypted — to any authenticated tenant hitting GET /api/v2/settings/keys, /connect, /status or /issues. That is the operator's home directory and the per-tenant storage layout. All four sites now use #934's internal_error(): the full message goes to the operator's log under a correlation id, and the client gets the id plus the one actionable step (`cf auth setup`), which contains no path. Formatted, without being informative to the wrong audience. My first verification of this was wrong and worth recording: I reverted ONE of the two sites in settings_v2.py, saw the leak tests pass, and could have read that as confirmation. GET /keys uses the readonly dependency and I had reverted the write one — so the test never reached the reverted code. Reverting both fails both leak tests, which is the actual evidence. Four tests now: the standard shape, no path in the body, a correlation id that appears in the detail, and the recovery step still reaching the client. Full suite: 6441 passed, 49 skipped.
|
Claude finished @frankbria's task in 7m 40s —— View job GLM bug-hunt review — PR #1142 (synchronize)Re-reviewed the post-fix state ( 1 minor finding (posted inline on
Verified correct (no defect)
Investigated and discarded (not reportable)
No critical/major defects. The locking and the dependency-guard fix are sound; the only actionable item is extending the no-leak pattern to the two write route-body catch sites above. |
|
Reviewed the current state of the diff (both commits). I read the two prior reviews already on this PR (the automated bug-hunt and the Two things neither prior review flagged: 1. Test coverage gap: 2. Minor: the four try/except blocks are now near-verbatim duplicates — worth the same treatment as No other correctness, security, or performance issues found. Scope is honest and matches the CLAUDE.md guidance about not riding unrelated changes along with a P2 fix (leaving |
Closes #1085.
1.
record_installationdid an unlocked read-modify-write#954 made the write atomic, so a crash cannot truncate the file. That is a
different guarantee from serialised: two concurrent installs read the same
base, both write a whole new file, and the second one silently drops the first
tool.
Rather than copy the
_store_lockpattern out ofcredentials.py— which wouldbe its third instance and exactly the kind of duplication that drifts — I
extracted it into
codeframe/core/atomic_io.pyasread_modify_write_lock().That module is where "an atomic write is not a serialised write" belongs, it is
stdlib-only and headless, and both callers already import from it.
Same degradation as
credentials.py: thread lock always, cross-processfilelockwhen installed, one warning when it is not so the weaker guarantee isnever silent.
2. An unreadable credential store gave a bare 500
CredentialManager's constructor runs the machine-wide migration, which canraise
CredentialStoreUnreadableErrorsince #954. Raised from a FastAPIdependency, it never reaches the route's own
try/except, so the client gotan unformatted 500 — and lost the recovery text
cf auth setupalready prints.Both routers define their own
get_credential_managerandget_credential_manager_readonly, so all four needed the guard, not the two theissue names.
Evidence
The race tests use a
threading.Barrierso both threads provably read the samebase, rather than hoping for an unlucky interleaving. Removing the lock:
and with it, 8 passed. The lock itself is tested directly too (4 threads ×
200 increments = 800, not "some number less than 800").
A test of mine was passing vacuously
test_store_raises_rather_than_clobberingwrote its corrupt ciphertext tocredentials.enc. The real file iscredentials.encrypted, so the loader neverread it and the test exercised nothing — it surfaced only because the raise
assertion then failed. It uses the
ENCRYPTED_FILE_NAMEconstant now.Worth flagging: had I asserted something weaker, that test would have passed
while testing nothing, which is the failure mode the #954 guarantee can least
afford.
Acceptance criteria
record_installationserialises its read-modify-write; a two-thread test records both toolsapi_error(...)shape, asserted on status and body shapeFull suite: 6439 passed, 49 skipped.
ruffclean.Note
read_modify_write_lockis new shared API.credentials.pystill has its own_store_lock/_get_migration_locks— I did not migrate it, because that codeis load-bearing for credential integrity and rewiring its locking is a change
that deserves its own PR rather than riding along in a P2. The duplication is
now two instances rather than three, and the new one is the documented home.