Skip to content

fix(daemon): refresh a live session's copied bearer when its rolling member rotates - #146

Open
koushikraj-s wants to merge 6 commits into
uwuclxdy:mommyfrom
koushikraj-s:fix/refresh-stale-session-item-bearers
Open

koushikraj-s wants to merge 6 commits into
uwuclxdy:mommyfrom
koushikraj-s:fix/refresh-stale-session-item-bearers

Conversation

@koushikraj-s

Copy link
Copy Markdown

Summary

Stacked on #143. This branch carries #143's commits plus 3 of its own. Please review it after #143; I'll rebase once #143 merges.

On macOS, the first time Claude Code saves credentials inside a clauth start session (an MCP sign-in or refresh, for example), it writes the whole blob into the session's per-session Keychain item. That blob includes a copy of the session's claudeAiOauth bearer, read from the symlinked .credentials.json, and the runtime's .credentials.json is gone afterwards. From then on the session reads only that copy. For a member on a rolling token, the copy is a refreshless bearer. When the member's chain rotates, clauth re-stamps session-token.json correctly, but the session never sees it, and the rotation has revoked the bearer it holds. The session fails with OAuth token revoked.

What I saw on 2026-10-27 (fingerprints only):

  • developers rotated at 12:48:31Z, and its sidecar and credentials.json moved to the new bearer straight away.
  • Two sessions on that member had items still holding the old bearer, with no refresh token. One of them failed with OAuth token revoked at 12:49:29Z.
  • Four of five live sessions were in this state: the runtime .credentials.json gone, and the item holding claudeAiOauth plus mcpOAuth.

I think this also explains the misses described in #144: there the sidecar was re-stamped, but sessions that had detached like this never read it.

Fix. A daemon pass, macOS only, runs on the existing MCP-sync thread at most every 15 s. It covers live clauth sessions whose member has rolling_token set. When a session's item holds a refreshless bearer that differs from the member's live rolling sidecar (copied_bearer_behind_sidecar), it writes the sidecar's claudeAiOauth into the item and keeps every other key, mcpOAuth included.

Verified live (macOS, Claude Code 2.1.310, built from this branch): koushikraj rotated at about 13:30:06Z with two detached sessions on it. The daemon logged re-pointed session 18075-0's per-session Keychain item at 'koushikraj@procteye.com''s current rolling bearer … at 13:30:07Z, and the same for 18891-0 at 13:30:08Z. Neither session hit OAuth token revoked afterwards. A second member, getintouch, rotated at 13:35:17Z, also cleanly.

Checklist

  • Secrets redacted from any pasted output (no sk-ant-*, no ~/.claude/~/.clauth contents)
  • cargo fmt --all -- --check, cargo clippy --locked --all-targets --all-features -- -D warnings and cargo test --locked --all-features pass locally

On this branch:

  • cargo fmt --all -- --check and cargo clippy --locked --all-targets --all-features -- -D warnings are clean.
  • Targeted tests (copied_bearer, mcp_, sync_mcp_logins, hand_off, rolling, restamp, no_item_leg_folds) pass: 158 tests.
  • The full suite wasn't run on this branch; I'm leaving that to CI.
  • New tests:
    • a_copied_bearer_behind_a_live_rolling_sidecar_is_refreshed
    • a_copied_bearer_is_left_when_nothing_proves_it_stale (covers equal bearers, a real login, a dead sidecar, a mint, a mis-fill and garbage shapes)
  • no_item_leg_folds_in_a_carry_or_a_flock now counts 8 owned legs, with the new site named. The new call has no carry, flock or row retouch inside it.
  • The write path itself only runs against a real Keychain, so it's covered by the live run above, not a unit test.

AI

  • no AI
  • AI-assisted, I reviewed everything
  • AI did most of it under my direction

@uwuclxdy

Copy link
Copy Markdown
Owner

Could you split the copied-bearer fix off #143 and open it against mommy on its own? It should ship in 0.18.1, while #143 lands after that release.

It currently runs on #143's MCP-sync thread and writes through its owned-write path, so on mommy it needs its own trigger and its own item write.

@uwuclxdy uwuclxdy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes. Findings are against your three commits (12a20c68..02965ce2), so they carry over to the split.

Design

  • major: copied_bearer_behind_sidecar calls an item stale when it holds a refreshless bearer that differs from the sidecar, which never shows it's the member's own revoked bearer. A static mint on a member later flipped to rolling_token gets replaced. So does a session whose swap left current_member on A after its row update failed: every 15 s its item moves back to the account the chain moved it off. Match the item's bearer against one clauth wrote for that member, as the Keychain mirror's recognition does, or test it against the chain with #144's rolling_bearer_behind_chain.
  • major: the pass polls every 15 s, but the sidecar changes on one event, stamp_rolling_token in land_pair_with. Between a rotation and the next pass the session holds a revoked bearer. Drive the re-point off the stamp, or say in the PR why the poll is enough.
  • minor: a sidecar that is behind its chain still counts as the truth, so the pass writes a bearer the rotation already revoked and logs re-pointed. Skip while rolling_sidecar_behind_chain holds.
  • minor: the item side reads refreshToken: "" as refreshless, where sidecar_kind_of calls the same shape mis-filled, and the pass re-derives sidecar_summary from the line above it. Reuse both.

Coverage

  • minor: a background session's item is Claude Code-created and unowned, and mcp_sync_may_adopt refuses background, so --bg sessions keep the revoked copy while the wiki says every running session.
  • minor: an unreadable profile turns into a silent skip on every pass. Log it, as #144 did for the unreadable rolling_token flag.
  • minor: only the pure predicate is tested. The rolling gate, the adopt check and the background exclusion have no seam, so deleting any of them stays green.
  • minor: each live rolling session can cost an item read, a flock wait and a 20 s leg budget in series, about 55 s worst case, with no bound across sessions. That delays the MCP sync behind it.
  • minor: copied_bearer_behind_sidecar and refresh_copied_bearer are only called from macOS code, so -D warnings fails linux and windows. Gate them like session_item_carry_gate.

Docs and copy

  • minor: wiki/Auto-Switch.md says Claude Code never stores a refreshless bearer in the item, which this PR's premise contradicts. Fix that sentence alongside the new paragraph.
  • nit: '{}''s renders as 'cld0''s, and "its copy was one the member's rotation had revoked" claims more than the predicate checks.
  • nit: the mechanism is explained three times in doc comments and once more in the wiki. Once is enough.

The split onto mommy

What the pass uses from #143, none of it on mommy: the clauth-mcp-sync thread with its 15 s throttle, live_session_runtime_dirs and LiveRuntime, mcp_sync_may_adopt, authorize_owned_write, authorize_live_write and failed_on_locked_keychain. Plus the wiki's reference to the sync MCP logins setting. owned_keychain_leg, the Keychain primitives and sidecar_kind_of are already there.

  • the item write (refresh_config_dir_item_bearer) and the two pure functions move over as they are.
  • the cleanest split carries the two write witnesses over; #143 then rebases onto them. Using mommy's authorize_write instead takes the row over, which #143 deliberately keeps apart from adopting.
  • on mommy the no_item_leg_folds_in_a_carry_or_a_flock count becomes 7, and its message drops the MCP sync clause.
  • if the pass moves onto the rotation stamp (second point above), the throttle and the thread aren't needed at all.

@koushikraj-s
koushikraj-s force-pushed the fix/refresh-stale-session-item-bearers branch from 02965ce to 1dab4c0 Compare October 29, 2026 02:28
@koushikraj-s

Copy link
Copy Markdown
Author

Done. This now sits on mommy on its own, with its own trigger (the stamp) and its own item write. I moved the two write witnesses (authorize_owned_write, authorize_live_write) and the live-session helpers here, so #143 rebases onto them instead of carrying copies. On mommy the owned-leg count is 7, and its message has no MCP sync clause.

Verified live on macOS with this build: personal rotated at 02:17:58Z with three sessions holding copied bearers. In the same second, clauth logged session N held a copy of 'personal' rolling bearer that its last re-stamp replaced; … for each of 77082-0, 70230-0 and 92428-0, and none of them hit OAuth token revoked.

Replies to the review:

Design

  • major, stale test: Agreed, "differs" was too loose. In 5e3c130 the item is re-pointed only when its bearer is exactly the one the member's last stamp replaced: stamp_rolling_token reads that bearer inside the write's own lock hold and passes it along. A static mint flipped to rolling, another account's bearer, or a row whose member lags the item never match, and tests cover each case (eba9aab).
  • major, 15 s poll: The re-point is now driven by the stamp (5e3c130). The stamp queues the bearer it revoked, and the queue drains right after the caller releases its locks: at the end of land_pair_with, after the rolling gate, and after the re-stamp leg. The daemon run loop drains on a detached thread. There's no poll, thread or throttle any more.
  • sidecar behind its chain: The rule skips while rolling_sidecar_behind_chain holds, and a test pins it.
  • refreshToken: "" and the re-derived summary: Both sides now go through sidecar_kind_of, so refreshToken: "" reads as mis-filled and is left alone, and the extra sidecar read is gone.

Coverage

  • --bg sessions: Adoption now covers a background row's own runtime-bg tree. That tree only ever serves the one account, every job on it shares the item, and the orphan GC retires the row with the tree. A test pins both the accepted and the refused cases.
  • unreadable profile: The pass no longer reads a profile per session. Only a rolling profile gets stamped, and the rolling-flag read in land_pair_with already logs the profile by name when it fails (from fix(oauth): re-stamp a rolling sidecar the moment its chain rotates past it #144), so no silent skip is left.
  • only the predicate tested: eba9aab adds seams for the stamp trigger (rolling vs not, arming stamp), the member filter, the adoption check including background trees, and both write witnesses. Each runs without the Keychain.
  • 55 s worst case: One drain now spends at most 20 s on Keychain legs across all sessions, with each leg's budget clamped to what is left. Sessions it doesn't reach are queued for the next drain. It never runs inside a flock hold or an armed budget, and in the daemon it runs off the main loop.
  • Linux/Windows -D warnings: Everything only macOS code calls is now gated the same way as session_item_carry_gate. I can't build for Linux or Windows locally, so CI will confirm.

Docs and copy

  • Auto-Switch.md sentence: Rewritten in 1dab4c0: it now says how Claude Code can put the bearer back into the item, and what clauth does about it.
  • log copy: The line now reads "session N held a copy of 'M' rolling bearer that its last re-stamp replaced; its per-session Keychain item now holds the current one", which states only what the rule checked.
  • explained three times: The mechanism is explained once, in the wiki. The doc comments now only state each function's rule.

@koushikraj-s

Copy link
Copy Markdown
Author

Rebased onto current mommy (10b7b77) to clear the conflict. The only overlap was test blocks appended at the end of tests/inline/claude.rs, and both sides are kept. No code changes. Targeted tests, fmt and clippy pass locally.

@uwuclxdy

Copy link
Copy Markdown
Owner

Pushed def90718 on top. A re-point that fails used to drop its pair, and the item then never matched a later stamp's bearer again, so that session stayed on the revoked one. Now a locked keychain, a failed write or a sidecar that isn't ready yet (behind its chain, unreadable) keeps the pair queued on the swap item retry's backoff (1 s doubling to 30 s), and each pair logs its failure once. A read that fails with its full timeout gives that session up, as the swap does, since a Claude Code-created item can raise an access dialog on every read. Also moved state_lock_held out from between armed_budget_remaining's doc comment and its fn.

I can't test on macos, could you run your rotation check once more on this head?

@harrisrobin

harrisrobin commented Nov 1, 2026 •

Copy link
Copy Markdown

Ran the rotation check on def90718 on macOS. The main path works. There's one gap below, plus a few data points.

Setup: macOS 27.2 (arm64), Claude Code 2.1.316, def90718 built with cargo build --release --locked and running as the LaunchAgent daemon. One rolling-token profile (personal) with six live clauth start sessions. No token values were printed. Bearers below are the first 10 hex chars of their SHA-256.

Result: the copied bearer is re-pointed within a second, and the session keeps working

  • A fresh clauth start personal session (not --resume) started at 16:21:49Z. Claude Code created its per-session item at 16:21:51Z and deleted the runtime .credentials.json. The item held the sidecar's bearer b2e3e844bb with no refresh token.
  • To avoid waiting about 5 hours for the natural re-stamp, I moved expiresAt in both credentials.json and session-token.json to now + 90 minutes. The rolling scan then did a real guarded refresh at 16:26:18Z, and the chain and sidecar moved to 4bd5d357b2.
  • At 16:26:19Z the daemon logged session 63866-0 held a copy of 'personal' rolling bearer that its last re-stamp replaced; its per-session Keychain item now holds the current one. The item now holds 4bd5d357b2.
  • The session answered a prompt right afterwards, with no OAuth token revoked.
  • GET /api/oauth/profile with the replaced bearer b2e3e844bb returns 401 OAuth access token has been revoked., and the new one returns 200. So the rotation does revoke the old bearer server-side.
  • Sessions still reading the symlinked file were unaffected, as expected. One of them answered 94 times after the previous rotation with no error.

Gap: an item holding an older revoked bearer is never re-pointed

The queue only knows the bearer that the last stamp in the same process replaced. A session whose item copied an earlier bearer stays on a dead bearer for the rest of its life.

Live example from the same run: session 11207-0's item holds 7310cc49cc. The 0.18.0 daemon's re-stamp at 15:45:39Z replaced that bearer, before this build was running. After the 16:26 rotation the item still holds 7310cc49cc, which returns 401 ... has been revoked.

This affects:

  • every session that is already detached when a user upgrades to the release carrying this PR;
  • a stamp made in a short-lived process (the switch gate in clauth start, clauth switch, the TUI) that exits before its drain succeeds;
  • a process that can't reach the login Keychain, for example one started under an SSH session, where security exits 36;
  • a daemon restart between a stamp and its drain.

I'm writing a follow-up: a daemon sweep, at boot and every few minutes, that finds such items and feeds them into this PR's queue, so the write and all its checks stay as they are here. It recognizes a stale copy by where it came from rather than by "differs". The session's row launched on this member's session-token.json and never visited another member. The item is in that session's own runtime tree. Its claudeAiOauth classifies as Rolling. And it is not any other profile's current bearer. A session like that can only have got its bearer from this member's sidecar. I'll open it stacked on this PR unless you'd rather have it folded in here.

Smaller notes

  • The wiki text says the copy happens on Claude Code's "first save of credentials inside the session (an MCP sign-in or refresh)". Here it happened about 2 seconds after a plain start, with no sign-in. Two --resume starts earlier today did the same within 1 to 4 seconds. So in practice many sessions are detached from the start, which makes this PR matter more than the wording suggests.
  • Reading Claude Code-created items through /usr/bin/security raised no access dialog here (3 items read).
  • The conflict with mommy is only in tests/inline/runtime.rs. Both sides appended tests at the end of the file, and keeping both blocks resolves it. No function names clash between the two blocks, or with the rest of the file.

cc: @uwuclxdy

@uwuclxdy uwuclxdy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving on 1f3a41cd: def90718 rebased onto mommy 2e528ce8, plus one wiki sentence that now says the item copy can land seconds after a plain start instead of naming a sign-in or a refresh as the trigger, which is what harrisrobin's run showed. No line of the PR's own diff changed in the rebase.

Two tradeoffs accepted for this release:

  • the retry queue lives in memory per process: a daemon restart or an exiting stamper drops a pending pair, and that session keeps the revoked bearer until a later stamp queues it again. A durable record beside session-token.json was rejected as a second copy both sides would have to keep honest.
  • a non-locked item read that spends its full timeout settles that session, as a swap drops SwapItemLeg::Failed on one, since a Claude Code-created item can raise an access dialog on every read.

The older-bearer gap ships as its own sweep, stacked on this branch, for 0.18.1.

Landing it as a rebase merge.

@uwuclxdy

uwuclxdy commented Nov 1, 2026

Copy link
Copy Markdown
Owner

The branch conflicts with mommy again. I can push commits to your branch but not force, so the rebase has to come from you.

I rebased it and gated it: def90718 onto mommy 2e528ce8, keeping both sides' appended test blocks in tests/inline/runtime.rs, plus one wiki sentence (the item copy can land seconds after a plain start, not only on a sign-in, which is what harrisrobin's run showed). It sits on the base repo as pr146-rebased at 1f3a41cd; cargo.sh is green on it and no line of your own diff changed.

Could you put it on your branch?

git fetch https://github.com/uwuclxdy/clauth.git pr146-rebased
git push --force-with-lease origin FETCH_HEAD:fix/refresh-stale-session-item-bearers

Or rebase it yourself, as long as tests/inline/runtime.rs keeps both appended blocks and the wiki sentence lands. CI runs on the new head, and the release session merges it once the branch is mergeable.

@uwuclxdy

uwuclxdy commented Nov 1, 2026

Copy link
Copy Markdown
Owner

Stacked on this branch is right, and it goes into 0.18.1: the release session takes it from there once it is open.

Thanks for the 401/200 pair, that is the piece the earlier runs did not show.

Your run covers the re-point itself: the branch is now def90718 rebased onto current mommy plus one wiki sentence, and no line of the PR's own diff changed. The sentence now reads "its first save of credentials inside the session, sometimes seconds after a plain start, copies the whole blob it read from the token file, the bearer included, into the item", which is what your three starts showed.

koushikrajs and others added 6 commits November 1, 2026 21:33
A locked keychain, a failed write or a sidecar not ready yet keeps the pair
queued on the swap retry backoff; a denied or unparseable item read settles.
Also gives state_lock_held and armed_budget_remaining their own doc comments.
@uwuclxdy
uwuclxdy force-pushed the fix/refresh-stale-session-item-bearers branch from def9071 to 9c194ce Compare November 1, 2026 20:34
@uwuclxdy

uwuclxdy commented Nov 1, 2026

Copy link
Copy Markdown
Owner

Correcting my last comment: the force push was blocked by git-lfs's lock check on my side, not by your fork's permissions. git -c lfs.locksverify=false push does it.

The branch now carries the rebase, head 9c194ce5 on mommy cbc9e127: your five commits unchanged in content, plus the wiki sentence. The release session lands it together with the sweep in #156.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants