Repository navigation
fix(claude): re-read the Keychain once the remembered token expires #168
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -296,40 +296,59 @@ public enum ClaudeCredentialSource { | |
| case .unknown: | ||
| return .temporarilyUnavailable | ||
| case .present: | ||
| if let data = rememberedCredential() { | ||
| // The item was authorized and read from Keychain into memory. | ||
| // Even if the token inside is expired, macOS Keychain access was | ||
| // already granted. Return .authorized(data) so LocalQuotaReader | ||
| // and ClaudeLoginState report it as signedOut/idle, not | ||
| // needsPermission. | ||
| // Memory is a permission fact, not a freshness fact. It records | ||
| // that macOS already granted `/usr/bin/security` this item, which | ||
| // is what stops the consent row from re-arming on every launch. | ||
| // It does NOT record the current token: Claude Code rewrites this | ||
| // same item every time it renews its own login, so a payload | ||
| // captured at launch goes stale within the hour. Answering from it | ||
| // forever (which is what shipped in #156) pinned the row to | ||
| // "login idle" while Claude Code sat right there signed in — the | ||
| // Keychain item held a fresh token the app simply never re-read. | ||
| // So: serve memory only while its own access token is unexpired. | ||
| let remembered = rememberedCredential() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The silent re-read has no throttle or failure memo, so once the remembered payload is stale — the normal state whenever Claude Code is idle or not running — every provider refresh tick forks a // Re-read at most once per interval: a stale remembered payload
// never becomes fresh on its own, so an unthrottled retry forks
// /usr/bin/security on every refresh tick and blocks the serial
// worker queue.
if lastSilentReadAt.map({ Date().timeIntervalSince($0) < silentReadRetryInterval }) ?? false {
if let remembered { return .authorized(remembered) }
return .temporarilyUnavailable
}
lastSilentReadAt = Date()
let outcome = probe.readViaSecurityCLI(attemptDeadline, { isAbandoned() })
log.notice("claude keychain silent security CLI: \(outcome.logLabel, privacy: .public)")
if case .found(let data) = outcome {
remember(data)
return .authorized(data)
}
if let remembered { return .authorized(remembered) }Prompt for LLMTalk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction. |
||
| if let data = remembered, rememberedGrantIsFresh(data) { | ||
| return .authorized(data) | ||
| } | ||
| // Nothing in memory, but the item is here. Before this was a bare | ||
| // `.unauthorized`, which re-armed the "needs permission" row on | ||
| // every single launch and made the owner re-click Allow Access | ||
| // forever — the grant was process-memory only, so it never | ||
| // survived a relaunch. | ||
| // | ||
| // Try the one read that can still succeed quietly. Once the owner | ||
| // has answered Always Allow, `/usr/bin/security` is on the item's | ||
| // access list and this returns the bytes without a panel, so the | ||
| // app reads itself for the rest of the session with no further | ||
| // prompting. The panel only reappears if the ACL is revoked, which | ||
| // is precisely when the owner should be asked again. | ||
| // | ||
| // A refusal here is not a failure: it is the first run on a fresh | ||
| // install that has never been granted, and the caller's | ||
| // `.unauthorized` is the correct answer for that case. | ||
| // Past that point, or with nothing remembered at all, read the | ||
| // bytes again. This is the one read that can succeed quietly: | ||
| // once the owner has answered Always Allow, `/usr/bin/security` is | ||
| // on the item's access list, so it returns without a panel and the | ||
| // app refreshes itself with no prompting. The panel only reappears | ||
| // if the ACL is revoked, which is precisely when the owner should | ||
| // be asked again. It is also what lets a renewal that Claude Code | ||
| // performed on our behalf actually be seen. | ||
| let outcome = probe.readViaSecurityCLI(attemptDeadline, { isAbandoned() }) | ||
| log.notice("claude keychain silent security CLI: \(outcome.logLabel, privacy: .public)") | ||
| if case .found(let data) = outcome { | ||
| remember(data) | ||
| return .authorized(data) | ||
| } | ||
| // The read produced nothing. With a grant already remembered that | ||
| // is not a permission problem — it is a slow or refused read on a | ||
| // loaded Mac, and reporting `.unauthorized` here is exactly the | ||
| // consent loop #156 closed. Serve what we already hold and let the | ||
| // row report signedOut/idle, which is the honest state. | ||
| if let remembered { return .authorized(remembered) } | ||
| // Nothing remembered and nothing readable: a fresh install that has | ||
| // never been granted, so `.unauthorized` is the correct answer. | ||
| return .unauthorized | ||
| } | ||
| } | ||
|
|
||
| /// Whether the remembered payload still carries an access token that has | ||
| /// not expired. Only then may the refresh loop answer from memory. | ||
| /// | ||
| /// This is deliberately stricter than `rememberedGrantStillUsable`, which | ||
| /// also accepts an expired-but-renewable record. That looser rule is right | ||
| /// for deciding whether to keep trusting the *grant*, and wrong for | ||
| /// deciding whether to keep sending the *bytes*: an expired token answers | ||
| /// 401 no matter how renewable it is. | ||
| static func rememberedGrantIsFresh(_ data: Data, now: Date = Date()) -> Bool { | ||
| guard let root = ClaudeOAuthParser.parse(data) else { return false } | ||
| return ClaudeOAuthParser.validOAuth(in: root, now: now) != nil | ||
| } | ||
|
|
||
| /// A remembered payload is usable while its access token is still | ||
| /// unexpired, or while it holds an expired access token that can be renewed | ||
| /// via its refresh token. Anything else, including a rotated or unreadable | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Throttle or memoize the failed security re-read in resolveSilently so stale remembered grants do not spawn a security child process on every refresh cycle while the activeRead semaphore is held.
Prompt for LLM
Talk to Kody by mentioning @kody
Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.