fix(auth): atomic locked credential writes, safe usage-queue flush, MCP refresh recovery#83
Merged
Conversation
…CP refresh recovery
auth.json and mcp-auth.json writes were unlocked read-modify-write cycles
over a truncate-then-write Bun.write, so concurrent writers could drop
providers and a corrupt file silently read as {} and got rewritten
containing only the entry being saved. Route both stores through a shared
JsonStore util: temp-file-plus-rename writes, read-modify-write serialized
behind the in-process Lock, and a corrupt store is now fatal on the write
path (backed up alongside as *.corrupt-<pid>) while the read path still
degrades to {} so the CLI boots.
usage-queue.jsonl flush now holds the same Lock across its read, send, and
rewrite cycle and rewrites the file excluding only the lines it actually
read, so an append landing mid-flush is preserved instead of deleted.
Flush stays best-effort and never throws at startup.
MCP OAuth token refresh gets the codex recovery pattern: refreshes are
single-flight per server, and a failed refresh re-reads the persisted
tokens and retries once with a rotated refresh token another process
persisted before surfacing re-auth.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Credential stores: atomic writes, locking, corrupt-file safety
Auth.set/Auth.remove(and theMcpAuthequivalents) were unlocked read-modify-write cycles over a truncate-then-writeBun.write, and a file that existed but did not parse silently read as{}. The combination meant a concurrent writer could drop a provider, and a torn read or disk-full truncation caused the next routine token refresh to rewriteauth.jsoncontaining only itself — permanently destroying every other credential.Both stores now route through a shared
util/jsonstore.ts:Lock(the same utilBunProc.installuses), keyed by file path. TheMcpAuth.update*helpers each became a single atomic cycle instead of a racyget→setpair.<file>.corrupt-<pid>and the write throws with a clear message instead of proceeding with{}. The read path (all()) still degrades to{}so the CLI boots.Fixes #54
Usage queue: locked flush, mid-flush appends survive
flushPendingUsagenow holds the queue lock across its whole read → send → rewrite cycle (andpersistToQueuetakes the same lock), so concurrent flushes in one process can't double-send. The final rewrite re-reads the file and removes only the lines actually read — counted, so a report queued twice is removed exactly twice — meaning an append that lands mid-flush from another process is preserved instead of deleted. Flush remains best-effort and never throws at startup.Fixes #58
MCP OAuth: refresh-token rotation recovery (the #20 pattern)
McpOAuthProvidernow applies the codex recovery pattern to token refresh: when the persisted access token is expired,tokens()refreshes it through the SDK's discovery +refreshAuthorizationhelpers with a single-flight guard per server, so concurrent callers share one round-trip instead of racing a rotating refresh token. On refresh failure it re-reads the persisted tokens — if another process won the race, its still-valid access token is used directly, and a rotated refresh token is retried once — before falling back to the SDK's normal re-auth path. Successful refreshes persist through the now-locked, atomicMcpAuthstore, closing the cross-process persistence race underneath.Fixes #59
Tests
test/auth/auth.test.ts: concurrentAuth.setcalls lose no provider; a corruptauth.jsonmakesset/removethrow, leaves the original untouched, and creates the backup; unknown entries survive writes; no temp files left behind.test/mcp/auth.test.ts: concurrentMcpAuthupdates keep every server entry; corrupt-store write refusal.test/mcp/oauth-refresh.test.ts: against a real loopback OAuth server — concurrent expired-token callers trigger exactly one refresh; a rejected refresh recovers via the rotated token another process persisted; a still-valid access token persisted by the winner is reused without a second refresh.test/openscience-usage.test.ts: flush leaves the queue intact when unauthenticated; malformed-only queues are dropped and unlinked.bun test(846 pass) andbun run typecheckare green.