Repository navigation
fix: say why the Keychain read failed, read every matching item, and add Reset connection - #274
Merged
Merged
Conversation
A Homebrew install that could not authorize Claude Code, while the same machine worked after a DMG reinstall (#273). The screenshot reads "OAuth token expired, relaunch Claude Code to refresh it", which is the one piece of advice that cannot work in that state. SecurityCLIReader swallowed every failure into a bare nil: exit 44 (no such item), exit 45 (access refused) and the 3s watchdog that exists because `security` can hang for a headless app on macOS 26+ all reached the caller as the same nothing. TokenProvider then fell through to ~/.claude/.credentials.json, which Claude Code stops refreshing on a machine whose live token lives in the Keychain, so the API answered 401 and the app called it an expired token. The reader now returns the failure, TokenProvider records which source answered alongside it, and the unauthorized paths (onboarding Connect, the dashboard banner) say what happened: macOS refused the Keychain item and the token in use came from a file. The diagnostic report carries the source and the Keychain outcome, so the next report of this arrives already diagnosed. notFound is deliberately not flagged. A machine that keeps its credentials in a file is not broken, and the plain expired-token message is the right one there.
A lookup by service name alone returns whichever item the Keychain hands back first, and several can carry the name "Claude Code-credentials". A second one holding only MCP OAuth discovery state shadows the real login: the read comes back with no claudeAiOauth in it, the app falls through to an older token on disk, and the API says 401. Nothing about it lives in TokenEater's own config, which is why reinstalling and redoing onboarding never helped (#268, diagnosed by @JasonFulksFamilySearch down to the item's creation date). The reader now enumerates the accounts under the service name first, through an attributes-only SecItemCopyMatching that asks for no data and so trips no ACL prompt. One item behaves exactly as before. Several, and it reads each and keeps the one that actually carries a login, preferring the one that lasts longest when more than one does. The shadowing item drops out instead of deciding the read, and the machine heals itself with no Keychain surgery from the user. The item count and the token's own expiry travel with the read into the diagnostic report, so a report from a shadowed machine says so even once the workaround has done its job, a machine whose token simply went stale because Claude Code has not run says that instead, and a machine where none of the items holds a login gets told so rather than being sent back to the ACL and reinstall path.
The ask the issue is named after: people were uninstalling TokenEater and redoing onboarding, losing every layout, theme and preference, because a connection the app had cached wrong could not be rebound any other way (#268). Each provider card gets a Reset connection action next to Check connection. It drops what the app cached about that provider and nothing else: the in-memory token, the cached Claude Desktop decryption key (which survives a relaunch and can outlive the account it was derived for), the shared-file snapshot, the stuck error state and its retry backoff. Then it reads every source again and tests them. No preference is touched, which is the whole point. It sits quieter than Check connection on purpose: it is the answer once checking has already failed, not the first thing to reach for.
AThevon
force-pushed
the
fix/keychain-source-diagnosis
branch
from
September 25, 2026 16:23
9a44080 to
6903c66
Compare
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.
Two reports, one mechanism: the Keychain read fails, nobody is told, the app runs on an older token from disk, and the resulting 401 is reported as "OAuth token expired, relaunch Claude Code to refresh it". That advice cannot work, because relaunching refreshes the item we failed to read.
Stacked on
codex-base(PR #267), which rewrote the surfaces this touches. Retarget tomainonce that lands.What changed
The failure is carried, not swallowed.
SecurityCLIReadercollapsed every outcome into a bare nil: exit 44 (no such item), exit 45 (access refused), the 3s watchdog that exists becausesecuritycan hang for a headless app on macOS 26+, and a payload with no login in it. They now reach the caller as themselves.TokenProviderrecords which source answered along with them, and the unauthorized paths say what actually happened instead of blaming the token.Every matching item is read. A lookup by service name alone returns whichever item the Keychain hands back first, and several can carry the name "Claude Code-credentials". One holding only MCP OAuth state shadows the real login. The reader now enumerates accounts under the service name through an attributes-only
SecItemCopyMatching(no data, so no ACL prompt), reads each, and keeps the one that actually carries aclaudeAiOauthlogin, preferring the one that expires latest. Affected machines heal themselves with no Keychain surgery.Reset connection, without losing your setup. Each provider card in Settings > Providers gets a Reset connection action next to Check connection. It drops what the app cached about that provider (the in-memory token, the cached Claude Desktop decryption key, the shared-file snapshot, the stuck error state and its backoff), reads every source again and tests them. No preference is touched: people were reinstalling the whole app, and losing every layout and theme, to recover a connection.
The diagnostic report carries it. Token source, Keychain outcome, how many items share the service name, and the token's own expiry. The next report of this arrives already diagnosed.
Deliberately unchanged:
notFoundis not flagged as a problem, because a machine that keeps its credentials in a file is not broken. And TokenEater still does not refresh the token. Refreshing means posting the refresh token to the OAuth endpoint and writing new credentials into Claude Code's Keychain item; this app only reads, and a bad write there breaks the user's actual Claude Code login.Verification
904 tests pass (11 new, covering the exit-code mapping, shadowed-item selection, freshest-expiry preference, millisecond expiry parsing, the four source-diagnostic cases, and the reset clearing a stuck state). Release build on Xcode 16.4,
check-docs.pyclean.Reported in #273 and #268, root cause of the shadowed item diagnosed by @JasonFulksFamilySearch, reset requested by @Fourcher.
Closes #273
Closes #268