From 598c3286dcc8e971de100e25493bcc3558abcfae Mon Sep 17 00:00:00 2001 From: ericjypark Date: Thu, 17 Sep 2026 14:41:35 -0500 Subject: [PATCH 1/4] fix(usage): detect Claude account switches Keep the metadata-only credential-store watcher active after successful Claude usage fetches, so an external account switch invalidates a still-valid cached token and refetches with the new credential. Preserve Claude Code's ownership of OAuth refresh and credential writes, and cover the path with a regression test. --- CLAUDE.md | 2 +- Sources/Usage/ClaudeCredentials.swift | 16 ++++++-- Sources/Usage/UsageStore.swift | 36 ++++++++---------- Tests/ResolveUsageTests.swift | 53 +++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e62fb416..7ce65a78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ History — read before re-stepping on these rakes: - `Sources/Window/IslandWindowController.swift` — borderless overlay window. Listens to `NSApplication.didChangeScreenParametersNotification` to reposition on display changes; prefers the screen with `safeAreaInsets.top > 0` (the notched display). - `Sources/Update/UpdaterController.swift` — wraps Sparkle's `SPUStandardUpdaterController`. Reads `SUFeedURL` / `SUPublicEDKey` from Info.plist (injected by `build.sh`). Auto-check state is stored by Sparkle itself in `NSUserDefaults` under `SU*` keys. -- `Sources/Usage/UsageFetcher.swift` — Codex (`/wham/usage`) and Claude (`/api/oauth/usage`) fetchers. Claude requires the `claude-code/X.Y.Z` User-Agent + `oauth-2025-04-20` beta header. Claude token handling is STRICTLY READ-ONLY (`ClaudeCredentials`): the app never calls the OAuth refresh endpoint and never writes the keychain. Anthropic rotates the refresh token on every refresh call and revokes the whole token family on old-token reuse, so a second refresher racing Claude Code invalidates the user's CLI login (this happened — do not reintroduce refresh). A 401 on the cached access token re-reads the store and retries once in the same pass (Claude Code rotates the token ~8h and our in-memory copy goes stale); only when the store itself holds a dead token does the app surface "token expired — run claude" until Claude Code refreshes it. Desktop-app Claude Code never maintains the CLI store (it injects a host-refreshed `CLAUDE_CODE_OAUTH_TOKEN` into its embedded CLI; its own tokens live in Chromium Safe Storage the app must not read), so on desktop-only days that expiry is permanent — `UsageStore` then spawns ONE detached `claude -p "ok" --model haiku --strict-mcp-config` ping per expiry episode to make the CLI refresh + write back itself, and a metadata-only credential-store fingerprint watch (5s tick, never prompts) refetches the moment the store changes. The ping is the CLI refreshing its own family — it is NOT the app calling the refresh endpoint, which stays forbidden. Credential sources, in order: env token → keychain items DISCOVERED by attributes-only enumeration matching service `Claude Code-credentials` or `Claude Code-credentials-*` (the CLI hashes a suffix per custom `CLAUDE_CONFIG_DIR`; we match what exists instead of recomputing its private formula, and secret reads go through `/usr/bin/security` FIRST — see the rake table) → `$CLAUDE_CONFIG_DIR/.credentials.json` as fallback (Claude Code 2.x maintains the keychain as primary on macOS and deletes/strands the file when the keychain works, so a coexisting file is the stale store). The usage endpoint also requires the `user:profile` scope as of mid-2026 — tokens from older logins return 403 and the only fix is `claude /login`. +- `Sources/Usage/UsageFetcher.swift` — Codex (`/wham/usage`) and Claude (`/api/oauth/usage`) fetchers. Claude requires the `claude-code/X.Y.Z` User-Agent + `oauth-2025-04-20` beta header. Claude token handling is STRICTLY READ-ONLY (`ClaudeCredentials`): the app never calls the OAuth refresh endpoint and never writes the keychain. Anthropic rotates the refresh token on every refresh call and revokes the whole token family on old-token reuse, so a second refresher racing Claude Code invalidates the user's CLI login (this happened — do not reintroduce refresh). A 401 on the cached access token re-reads the store and retries once in the same pass (Claude Code rotates the token ~8h and our in-memory copy goes stale); only when the store itself holds a dead token does the app surface "token expired — run claude" until Claude Code refreshes it. Desktop-app Claude Code never maintains the CLI store (it injects a host-refreshed `CLAUDE_CODE_OAUTH_TOKEN` into its embedded CLI; its own tokens live in Chromium Safe Storage the app must not read), so on desktop-only days that expiry is permanent — `UsageStore` then spawns ONE detached `claude -p "ok" --model haiku --strict-mcp-config` ping per expiry episode to make the CLI refresh + write back itself. A metadata-only credential-store fingerprint watch (5s tick, never prompts) stays active after successful reads, invalidates the cached secret after external changes such as account switches, and refetches once. The ping is the CLI refreshing its own family — it is NOT the app calling the refresh endpoint, which stays forbidden. Credential sources, in order: env token → keychain items DISCOVERED by attributes-only enumeration matching service `Claude Code-credentials` or `Claude Code-credentials-*` (the CLI hashes a suffix per custom `CLAUDE_CONFIG_DIR`; we match what exists instead of recomputing its private formula, and secret reads go through `/usr/bin/security` FIRST — see the rake table) → `$CLAUDE_CONFIG_DIR/.credentials.json` as fallback (Claude Code 2.x maintains the keychain as primary on macOS and deletes/strands the file when the keychain works, so a coexisting file is the stale store). The usage endpoint also requires the `user:profile` scope as of mid-2026 — tokens from older logins return 403 and the only fix is `claude /login`. - `Sources/Usage/AppUsage.swift` — `plan` field carries Claude's `subscriptionType` (from keychain) or Codex's `plan_type` (from API top-level). Surfaced as the chip badge in `SettingsView` + `UsageView`. ## Build details diff --git a/Sources/Usage/ClaudeCredentials.swift b/Sources/Usage/ClaudeCredentials.swift index a477bc43..e47bc79f 100644 --- a/Sources/Usage/ClaudeCredentials.swift +++ b/Sources/Usage/ClaudeCredentials.swift @@ -217,9 +217,9 @@ enum ClaudeCredentials { /// Last successful keychain read, held so ordinary polls don't re-trigger /// the keychain ACL prompt every cycle. Only a successful read is cached /// (nil results retry on the next poll). Invalidation: an unauthorized or - /// scope-insufficient probe clears it in `resolveUsage` — the token was - /// rotated or re-minted externally and the cached copy is stale — and the - /// in-app re-auth poll loop clears it once the store fingerprint changes. + /// scope-insufficient probe clears it in `resolveUsage` - the token was + /// rotated or re-minted externally and the cached copy is stale - and the + /// credential-store watcher clears it once the fingerprint changes. /// Lock-guarded: the poll-timer fetch and the re-auth poll fetch run as /// separate tasks off the main actor and can interleave here. Internal /// (not private) so ResolveUsageTests can prime it and assert clearing. @@ -380,6 +380,16 @@ enum ClaudeCredentials { return dates.max() } + /// Invalidate a previously read secret only after a prompt-free metadata + /// snapshot proves Claude Code changed its store. The old access token can + /// remain valid after an account switch, so HTTP auth failures alone are + /// not a sufficient invalidation signal (issue #103). + static func invalidateCachedCredentialsIfStoreChanged(from baseline: Date?) -> Bool { + guard credentialStoreFingerprint() != baseline else { return false } + clearCache() + return true + } + private static func claudeKeychainModificationDates() -> [Date] { claudeKeychainItems().compactMap { $0.modified } } diff --git a/Sources/Usage/UsageStore.swift b/Sources/Usage/UsageStore.swift index bb19fdbc..0d83687c 100644 --- a/Sources/Usage/UsageStore.swift +++ b/Sources/Usage/UsageStore.swift @@ -198,13 +198,13 @@ final class UsageStore: ObservableObject { : UsageStore.seeded( AppUsage.merged(fetched: cl, retaining: priorClaude, at: now), prior: priorClaude, provider: .claude, fillUnreported: false) - // "token expired" outlives its cause by up to a full poll - // interval: Claude Code rotates the token seconds after the - // user runs it, but the next scheduled poll is 5–30 min out. - // Watch the credential store's metadata and refetch the - // moment it changes. + // Watch the credential store even after success: an account + // switch leaves the old account's token valid, so a 401/403 + // may never arrive to invalidate the in-memory credential. + // The watch is metadata-only and refetches only after an + // actual external store write. + self.watchCredentialStore() if terminal { - self.watchCredentialStore() // The one terminal failure a CLI ping can fix: an expired // token in a store nothing else maintains (desktop-app // Claude Code brings its own host-refreshed token and @@ -219,8 +219,6 @@ final class UsageStore: ObservableObject { ClaudeCredentials.spawnTokenRefreshPing() } } else if cl.fiveHour.error == nil || cl.weekly.error == nil { - self.credWatchTask?.cancel() - self.credWatchTask = nil self.tokenRefreshPingAttempted = false } } @@ -384,10 +382,11 @@ final class UsageStore: ObservableObject { self?.lastUpdated = Date() self?.claudeReauthInProgress = false // This poll owned the store write — retire the - // credential watch (its baseline is stale now) and - // re-arm the ping for the next expiry episode. + // credential watch with its stale baseline, then + // restart it from the new store state. self?.credWatchTask?.cancel() self?.credWatchTask = nil + self?.watchCredentialStore() self?.tokenRefreshPingAttempted = false } return @@ -531,14 +530,12 @@ final class UsageStore: ObservableObject { } } - /// Recover from a terminal auth failure the moment the credential store - /// actually changes, instead of at the next scheduled poll up to 30 min - /// out. The fingerprint is metadata-only (keychain attribute query + - /// file mtime — never trips the ACL prompt, no network), so the 5s tick - /// costs nothing; the secret read and the probe happen only once Claude - /// Code (or `claude /login`) has written new credentials. The baseline - /// is taken after the failing walk's own store re-read, so a rotation - /// landing in the microseconds between them is caught by the next poll. + /// Recover from any external credential-store change, including an account + /// switch whose old token remains valid. The fingerprint is metadata-only + /// (keychain attribute query + file mtime - never an ACL prompt or network + /// request), so the 5s tick does not lower the usage endpoint's five-minute + /// polling floor. A secret read and event-driven probe happen only after + /// Claude Code (or `claude /login`) writes new credentials. private func watchCredentialStore() { guard credWatchTask == nil else { return } let baseline = ClaudeCredentials.credentialStoreFingerprint() @@ -551,8 +548,7 @@ final class UsageStore: ObservableObject { // reacting here too would double-probe the usage endpoint // on the same store write. if self.claudeReauthInProgress { continue } - if ClaudeCredentials.credentialStoreFingerprint() != baseline { - ClaudeCredentials.clearCache() + if ClaudeCredentials.invalidateCachedCredentialsIfStoreChanged(from: baseline) { self.credWatchTask = nil await self.waitOutWakeGrace() if Task.isCancelled { return } diff --git a/Tests/ResolveUsageTests.swift b/Tests/ResolveUsageTests.swift index 6e8b7dc0..faa0c21e 100644 --- a/Tests/ResolveUsageTests.swift +++ b/Tests/ResolveUsageTests.swift @@ -414,6 +414,59 @@ struct ResolveUsageTests { expect(ClaudeCredentials.decodeClaudeKeychainBlob(Data("not json at all".utf8)) == nil, "T12 garbage yields nil") + // T13 - account switch while the old access token is still valid + // (issue #103). A successful old-token probe must not leave that + // credential cached after Claude Code rewrites its external store. + // The metadata watcher invalidates the cache without reading or + // writing secrets, so the next usage fetch selects the new account. + var t13Modified = Date(timeIntervalSince1970: 1_700_001_000) + var t13StoreToken = "old-account-token" + ClaudeCredentials.keychainModificationDatesProvider = { [t13Modified] } + ClaudeCredentials.keychainCandidatesProvider = { [ + ClaudeCredentials.KeychainCandidate(account: "active", blob: [ + "claudeAiOauth": ["accessToken": t13StoreToken, "subscriptionType": "max"], + ]), + ] } + ClaudeCredentials.cachedClaudeCreds = ClaudeCredentials.ClaudeCreds( + account: "old", accessToken: "old-account-token", subscriptionType: "max") + let t13Baseline = ClaudeCredentials.credentialStoreFingerprint() + var t13ProbedTokens: [String] = [] + let t13Old = await ClaudeCredentials.resolveUsage { token, _ in + t13ProbedTokens.append(token) + return token == "test-stub-token" ? .unauthorized : .success(fetched) + } + if case .usage = t13Old { + expect(t13ProbedTokens.last == "old-account-token", + "T13 old account token can remain valid before the store switch") + } else { + expect(false, "T13 old account token can remain valid before the store switch") + } + expect(!ClaudeCredentials.invalidateCachedCredentialsIfStoreChanged(from: t13Baseline), + "T13 unchanged store keeps the valid cached token") + expect(ClaudeCredentials.cachedClaudeCreds?.accessToken == "old-account-token", + "T13 metadata checks do not reread an unchanged secret") + + t13StoreToken = "new-account-token" + t13Modified = t13Modified.addingTimeInterval(60) + expect(ClaudeCredentials.invalidateCachedCredentialsIfStoreChanged(from: t13Baseline), + "T13 external credential-store change invalidates the valid old-token cache") + t13ProbedTokens = [] + let t13New = await ClaudeCredentials.resolveUsage { token, _ in + t13ProbedTokens.append(token) + return token == "test-stub-token" ? .unauthorized : .success(fetched) + } + if case .usage = t13New { + expect(t13ProbedTokens.last == "new-account-token", + "T13 next fetch selects the externally switched account") + } else { + expect(false, "T13 next fetch selects the externally switched account") + } + expect(ClaudeCredentials.cachedClaudeCreds?.accessToken == "new-account-token", + "T13 cache now holds the switched account token") + ClaudeCredentials.keychainCandidatesProvider = { [] } + ClaudeCredentials.keychainModificationDatesProvider = { [] } + ClaudeCredentials.clearCache() + // The store and views match these exact strings; a reword is a // breaking change for them, not a copy edit. expect(ClaudeCredentials.rateLimitedMessage == "rate limited", "rateLimitedMessage literal is stable") From efc6629ea5b43a9bb6ae2e6807161a62ad31c18c Mon Sep 17 00:00:00 2001 From: ericjypark Date: Thu, 17 Sep 2026 14:49:48 -0500 Subject: [PATCH 2/4] fix(usage): close account-switch watch race Capture the credential-store baseline before Claude credential resolution begins and keep one advancing watcher across event-driven refetches. This catches a store rewrite that lands while a still-valid old-account request is in flight. --- Sources/Usage/ClaudeCredentials.swift | 30 +++++++++++++++++------ Sources/Usage/UsageStore.swift | 21 ++++++++-------- Tests/ResolveUsageTests.swift | 35 +++++++++++++++------------ 3 files changed, 51 insertions(+), 35 deletions(-) diff --git a/Sources/Usage/ClaudeCredentials.swift b/Sources/Usage/ClaudeCredentials.swift index e47bc79f..1ba74463 100644 --- a/Sources/Usage/ClaudeCredentials.swift +++ b/Sources/Usage/ClaudeCredentials.swift @@ -380,14 +380,28 @@ enum ClaudeCredentials { return dates.max() } - /// Invalidate a previously read secret only after a prompt-free metadata - /// snapshot proves Claude Code changed its store. The old access token can - /// remain valid after an account switch, so HTTP auth failures alone are - /// not a sufficient invalidation signal (issue #103). - static func invalidateCachedCredentialsIfStoreChanged(from baseline: Date?) -> Bool { - guard credentialStoreFingerprint() != baseline else { return false } - clearCache() - return true + /// Metadata-only watch state whose baseline is captured synchronously + /// before a usage fetch can read the credential cache. Advancing the + /// baseline before clearing makes one watcher safe to keep across + /// event-driven refetches and later external account switches. + final class CredentialStoreWatch { + private var baseline: Date? + + init() { + baseline = ClaudeCredentials.credentialStoreFingerprint() + } + + /// Invalidate a previously read secret only after a prompt-free + /// metadata snapshot proves Claude Code changed its store. The old + /// access token can remain valid after an account switch, so HTTP auth + /// failures alone are not a sufficient invalidation signal (#103). + func invalidateCachedCredentialsIfStoreChanged() -> Bool { + let current = ClaudeCredentials.credentialStoreFingerprint() + guard current != baseline else { return false } + baseline = current + ClaudeCredentials.clearCache() + return true + } } private static func claudeKeychainModificationDates() -> [Date] { diff --git a/Sources/Usage/UsageStore.swift b/Sources/Usage/UsageStore.swift index 0d83687c..4d3d1f1e 100644 --- a/Sources/Usage/UsageStore.swift +++ b/Sources/Usage/UsageStore.swift @@ -124,6 +124,14 @@ final class UsageStore: ObservableObject { return } + let selection = ProviderVisibilityStore.shared.selected + if selection.contains(.claude) { + // Capture the metadata baseline before resolveUsage can read the + // cached token. A store rewrite during an otherwise successful + // old-token request must still differ from this baseline. + watchCredentialStore() + } + loading = true refreshTask?.cancel() refreshTask = Task { @@ -134,7 +142,6 @@ final class UsageStore: ObservableObject { self.refresh() } } - let selection = ProviderVisibilityStore.shared.selected async let codexResult: AppUsage? = selection.contains(.codex) ? UsageFetcher.fetchCodex() : nil async let codexResetCreditsResult = selection.contains(.codex) ? UsageFetcher.fetchCodexResetCredits() : nil let coolingDown = claudeCooldownUntil.map { Date() < $0 } ?? false @@ -198,12 +205,6 @@ final class UsageStore: ObservableObject { : UsageStore.seeded( AppUsage.merged(fetched: cl, retaining: priorClaude, at: now), prior: priorClaude, provider: .claude, fillUnreported: false) - // Watch the credential store even after success: an account - // switch leaves the old account's token valid, so a 401/403 - // may never arrive to invalidate the in-memory credential. - // The watch is metadata-only and refetches only after an - // actual external store write. - self.watchCredentialStore() if terminal { // The one terminal failure a CLI ping can fix: an expired // token in a store nothing else maintains (desktop-app @@ -538,7 +539,7 @@ final class UsageStore: ObservableObject { /// Claude Code (or `claude /login`) writes new credentials. private func watchCredentialStore() { guard credWatchTask == nil else { return } - let baseline = ClaudeCredentials.credentialStoreFingerprint() + let watch = ClaudeCredentials.CredentialStoreWatch() credWatchTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 5_000_000_000) @@ -548,14 +549,12 @@ final class UsageStore: ObservableObject { // reacting here too would double-probe the usage endpoint // on the same store write. if self.claudeReauthInProgress { continue } - if ClaudeCredentials.invalidateCachedCredentialsIfStoreChanged(from: baseline) { - self.credWatchTask = nil + if watch.invalidateCachedCredentialsIfStoreChanged() { await self.waitOutWakeGrace() if Task.isCancelled { return } self.refreshTask?.cancel() await self.refreshTask?.value if !Task.isCancelled { self.refresh() } - return } } // Deliberately no cleanup on the cancelled path: cancellers nil diff --git a/Tests/ResolveUsageTests.swift b/Tests/ResolveUsageTests.swift index faa0c21e..07b9762b 100644 --- a/Tests/ResolveUsageTests.swift +++ b/Tests/ResolveUsageTests.swift @@ -414,11 +414,11 @@ struct ResolveUsageTests { expect(ClaudeCredentials.decodeClaudeKeychainBlob(Data("not json at all".utf8)) == nil, "T12 garbage yields nil") - // T13 - account switch while the old access token is still valid - // (issue #103). A successful old-token probe must not leave that - // credential cached after Claude Code rewrites its external store. - // The metadata watcher invalidates the cache without reading or - // writing secrets, so the next usage fetch selects the new account. + // T13 - account switch while an old-token request is in flight and + // succeeds (issue #103). The watcher baseline must exist before the + // credential is read and requested. Otherwise a store rewrite during + // that request becomes the watcher's new baseline while the cache + // still holds the old account forever. var t13Modified = Date(timeIntervalSince1970: 1_700_001_000) var t13StoreToken = "old-account-token" ClaudeCredentials.keychainModificationDatesProvider = { [t13Modified] } @@ -429,27 +429,30 @@ struct ResolveUsageTests { ] } ClaudeCredentials.cachedClaudeCreds = ClaudeCredentials.ClaudeCreds( account: "old", accessToken: "old-account-token", subscriptionType: "max") - let t13Baseline = ClaudeCredentials.credentialStoreFingerprint() + let t13Watch = ClaudeCredentials.CredentialStoreWatch() + expect(!t13Watch.invalidateCachedCredentialsIfStoreChanged(), + "T13 unchanged store keeps the valid cached token") + expect(ClaudeCredentials.cachedClaudeCreds?.accessToken == "old-account-token", + "T13 metadata checks do not reread an unchanged secret") var t13ProbedTokens: [String] = [] let t13Old = await ClaudeCredentials.resolveUsage { token, _ in t13ProbedTokens.append(token) + if token == "old-account-token" { + t13StoreToken = "new-account-token" + t13Modified = t13Modified.addingTimeInterval(60) + } return token == "test-stub-token" ? .unauthorized : .success(fetched) } if case .usage = t13Old { expect(t13ProbedTokens.last == "old-account-token", - "T13 old account token can remain valid before the store switch") + "T13 old account token remains valid while the store switches") } else { - expect(false, "T13 old account token can remain valid before the store switch") + expect(false, "T13 old account token remains valid while the store switches") } - expect(!ClaudeCredentials.invalidateCachedCredentialsIfStoreChanged(from: t13Baseline), - "T13 unchanged store keeps the valid cached token") expect(ClaudeCredentials.cachedClaudeCreds?.accessToken == "old-account-token", - "T13 metadata checks do not reread an unchanged secret") - - t13StoreToken = "new-account-token" - t13Modified = t13Modified.addingTimeInterval(60) - expect(ClaudeCredentials.invalidateCachedCredentialsIfStoreChanged(from: t13Baseline), - "T13 external credential-store change invalidates the valid old-token cache") + "T13 successful old-token response does not invalidate itself") + expect(t13Watch.invalidateCachedCredentialsIfStoreChanged(), + "T13 in-flight credential-store change invalidates the valid old-token cache") t13ProbedTokens = [] let t13New = await ClaudeCredentials.resolveUsage { token, _ in t13ProbedTokens.append(token) From 77c4655b4b0ba1a22d34758c54c211380974f7a0 Mon Sep 17 00:00:00 2001 From: ericjypark Date: Thu, 17 Sep 2026 14:55:47 -0500 Subject: [PATCH 3/4] fix(usage): reset cooldown after account switch Treat an external Claude credential-store change as an account boundary: clear the prior token's 429 cooldown and cancel its pending retry before the switch-driven fetch. Add deterministic cooldown-state coverage. --- Sources/Usage/ClaudeUsageCooldown.swift | 17 +++++++++++++ Sources/Usage/UsageStore.swift | 11 +++++---- Tests/ClaudeUsageCooldownTests.swift | 32 +++++++++++++++++++++++++ scripts/run-tests.sh | 8 +++++++ 4 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 Sources/Usage/ClaudeUsageCooldown.swift create mode 100644 Tests/ClaudeUsageCooldownTests.swift diff --git a/Sources/Usage/ClaudeUsageCooldown.swift b/Sources/Usage/ClaudeUsageCooldown.swift new file mode 100644 index 00000000..c69bc8f7 --- /dev/null +++ b/Sources/Usage/ClaudeUsageCooldown.swift @@ -0,0 +1,17 @@ +import Foundation + +struct ClaudeUsageCooldown { + private(set) var deadline: Date? + + func isActive(at date: Date) -> Bool { + deadline.map { date < $0 } ?? false + } + + mutating func arm(now: Date, duration: TimeInterval) { + deadline = now.addingTimeInterval(duration) + } + + mutating func clear() { + deadline = nil + } +} diff --git a/Sources/Usage/UsageStore.swift b/Sources/Usage/UsageStore.swift index 4d3d1f1e..be576111 100644 --- a/Sources/Usage/UsageStore.swift +++ b/Sources/Usage/UsageStore.swift @@ -58,7 +58,7 @@ final class UsageStore: ObservableObject { /// After a rate-limited fetch, skip Claude fetches for this long. /// Deliberately in-memory only — a quit+relaunch retries immediately. private static let rateLimitCooldown: TimeInterval = 900 - private var claudeCooldownUntil: Date? + private var claudeCooldown = ClaudeUsageCooldown() func refreshForSelectionChange() { if loading { refreshRequestedAfterSelection = true } @@ -144,7 +144,7 @@ final class UsageStore: ObservableObject { } async let codexResult: AppUsage? = selection.contains(.codex) ? UsageFetcher.fetchCodex() : nil async let codexResetCreditsResult = selection.contains(.codex) ? UsageFetcher.fetchCodexResetCredits() : nil - let coolingDown = claudeCooldownUntil.map { Date() < $0 } ?? false + let coolingDown = claudeCooldown.isActive(at: Date()) var cl: AppUsage? if !coolingDown && selection.contains(.claude) { cl = await UsageFetcher.fetchClaude() @@ -183,11 +183,11 @@ final class UsageStore: ObservableObject { } if let cl { if UsageStore.isRateLimited(cl) { - self.claudeCooldownUntil = Date().addingTimeInterval(UsageStore.rateLimitCooldown) + self.claudeCooldown.arm(now: Date(), duration: UsageStore.rateLimitCooldown) NSLog("CodexIsland: Claude usage rate-limited; skipping Claude fetches for %.0fs", UsageStore.rateLimitCooldown) self.scheduleCooldownRetry() } else { - self.claudeCooldownUntil = nil + self.claudeCooldown.clear() } // A terminal auth failure (expired token / missing scope) // REPLACES the retained reading rather than carrying it: the @@ -550,6 +550,9 @@ final class UsageStore: ObservableObject { // on the same store write. if self.claudeReauthInProgress { continue } if watch.invalidateCachedCredentialsIfStoreChanged() { + self.claudeCooldown.clear() + self.cooldownRetryTask?.cancel() + self.cooldownRetryTask = nil await self.waitOutWakeGrace() if Task.isCancelled { return } self.refreshTask?.cancel() diff --git a/Tests/ClaudeUsageCooldownTests.swift b/Tests/ClaudeUsageCooldownTests.swift new file mode 100644 index 00000000..25f3c20a --- /dev/null +++ b/Tests/ClaudeUsageCooldownTests.swift @@ -0,0 +1,32 @@ +import Foundation + +@main +struct ClaudeUsageCooldownTests { + static var failures = 0 + + static func expect(_ condition: Bool, _ label: String) { + if condition { + print("PASS \(label)") + } else { + print("FAIL \(label)") + failures += 1 + } + } + + static func main() { + let now = Date(timeIntervalSince1970: 1_700_002_000) + var cooldown = ClaudeUsageCooldown() + cooldown.arm(now: now, duration: 900) + expect(cooldown.isActive(at: now.addingTimeInterval(60)), + "old account rate limit arms the Claude cooldown") + + cooldown.clear() + expect(!cooldown.isActive(at: now.addingTimeInterval(60)), + "credential-store account switch clears the old account cooldown") + expect(cooldown.deadline == nil, + "cleared account cooldown has no inherited retry deadline") + + if failures > 0 { exit(1) } + print("all ClaudeUsageCooldownTests passed") + } +} diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 359b7859..f8abecaa 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -27,6 +27,14 @@ swiftc \ CLAUDE_CODE_OAUTH_TOKEN="test-stub-token" "$OUT_DIR/resolve-usage-tests" +swiftc \ + -parse-as-library \ + -o "$OUT_DIR/claude-usage-cooldown-tests" \ + Sources/Usage/ClaudeUsageCooldown.swift \ + Tests/ClaudeUsageCooldownTests.swift + +"$OUT_DIR/claude-usage-cooldown-tests" + swiftc \ -parse-as-library \ -o "$OUT_DIR/notch-height-tests" \ From d986bc2d6ca8a4df164975e9e4dc5c2a91cf8034 Mon Sep 17 00:00:00 2001 From: ericjypark Date: Thu, 17 Sep 2026 15:22:16 -0500 Subject: [PATCH 4/4] fix(usage): bound Claude account-switch polling Stop credential monitoring when Claude is deselected, move broad Keychain discovery to normal refresh boundaries, and limit the 5-second watcher to targeted Claude item metadata. Gate every Claude usage request behind a five-minute minimum and coalesce switch-driven recovery at the next safe boundary. --- CLAUDE.md | 8 +- Sources/Usage/ClaudeCredentials.swift | 38 ++++++-- Sources/Usage/ClaudeUsageScheduling.swift | 35 ++++++++ Sources/Usage/UsageStore.swift | 103 +++++++++++++++++----- Tests/ClaudeUsageSchedulingTests.swift | 41 +++++++++ scripts/run-tests.sh | 8 ++ 6 files changed, 203 insertions(+), 30 deletions(-) create mode 100644 Sources/Usage/ClaudeUsageScheduling.swift create mode 100644 Tests/ClaudeUsageSchedulingTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 7ce65a78..65f7d475 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,13 @@ History — read before re-stepping on these rakes: - `Sources/Window/IslandWindowController.swift` — borderless overlay window. Listens to `NSApplication.didChangeScreenParametersNotification` to reposition on display changes; prefers the screen with `safeAreaInsets.top > 0` (the notched display). - `Sources/Update/UpdaterController.swift` — wraps Sparkle's `SPUStandardUpdaterController`. Reads `SUFeedURL` / `SUPublicEDKey` from Info.plist (injected by `build.sh`). Auto-check state is stored by Sparkle itself in `NSUserDefaults` under `SU*` keys. -- `Sources/Usage/UsageFetcher.swift` — Codex (`/wham/usage`) and Claude (`/api/oauth/usage`) fetchers. Claude requires the `claude-code/X.Y.Z` User-Agent + `oauth-2025-04-20` beta header. Claude token handling is STRICTLY READ-ONLY (`ClaudeCredentials`): the app never calls the OAuth refresh endpoint and never writes the keychain. Anthropic rotates the refresh token on every refresh call and revokes the whole token family on old-token reuse, so a second refresher racing Claude Code invalidates the user's CLI login (this happened — do not reintroduce refresh). A 401 on the cached access token re-reads the store and retries once in the same pass (Claude Code rotates the token ~8h and our in-memory copy goes stale); only when the store itself holds a dead token does the app surface "token expired — run claude" until Claude Code refreshes it. Desktop-app Claude Code never maintains the CLI store (it injects a host-refreshed `CLAUDE_CODE_OAUTH_TOKEN` into its embedded CLI; its own tokens live in Chromium Safe Storage the app must not read), so on desktop-only days that expiry is permanent — `UsageStore` then spawns ONE detached `claude -p "ok" --model haiku --strict-mcp-config` ping per expiry episode to make the CLI refresh + write back itself. A metadata-only credential-store fingerprint watch (5s tick, never prompts) stays active after successful reads, invalidates the cached secret after external changes such as account switches, and refetches once. The ping is the CLI refreshing its own family — it is NOT the app calling the refresh endpoint, which stays forbidden. Credential sources, in order: env token → keychain items DISCOVERED by attributes-only enumeration matching service `Claude Code-credentials` or `Claude Code-credentials-*` (the CLI hashes a suffix per custom `CLAUDE_CONFIG_DIR`; we match what exists instead of recomputing its private formula, and secret reads go through `/usr/bin/security` FIRST — see the rake table) → `$CLAUDE_CONFIG_DIR/.credentials.json` as fallback (Claude Code 2.x maintains the keychain as primary on macOS and deletes/strands the file when the keychain works, so a coexisting file is the stale store). The usage endpoint also requires the `user:profile` scope as of mid-2026 — tokens from older logins return 403 and the only fix is `claude /login`. +- `Sources/Usage/UsageFetcher.swift` - Codex (`/wham/usage`) and Claude (`/api/oauth/usage`) fetchers. Claude requires the `claude-code/X.Y.Z` User-Agent + `oauth-2025-04-20` beta header. + Claude token handling is STRICTLY READ-ONLY (`ClaudeCredentials`): the app never calls the OAuth refresh endpoint and never writes the keychain. Anthropic rotates the refresh token on every refresh call and revokes the whole token family on old-token reuse, so a second refresher racing Claude Code invalidates the user's CLI login. This happened before, so do not reintroduce refresh. + A 401 on the cached access token re-reads the store and retries once in the same pass. Claude Code rotates the token about every eight hours while our in-memory copy goes stale. Only when the store itself holds a dead token does the app surface the terminal expired-token state until Claude Code refreshes it. + Desktop-app Claude Code never maintains the CLI store. It injects a host-refreshed `CLAUDE_CODE_OAUTH_TOKEN` into its embedded CLI, while its own tokens live in Chromium Safe Storage that the app must not read. On desktop-only days that expiry is permanent, so `UsageStore` spawns ONE detached `claude -p "ok" --model haiku --strict-mcp-config` ping per expiry episode to make the CLI refresh and write back itself. + A metadata-only credential-store watch is active only while Claude is selected. Broad Claude-item discovery runs at normal refresh boundaries; its 5-second tick queries only the discovered service/account pairs. Store changes invalidate the cached secret promptly, reset any old-account cooldown, and coalesce the next network fetch at least five minutes after the prior Claude request. + The ping is the CLI refreshing its own family. It is NOT the app calling the refresh endpoint, which stays forbidden. Credential sources, in order: env token, discovered keychain items matching service `Claude Code-credentials` or `Claude Code-credentials-*`, then `$CLAUDE_CONFIG_DIR/.credentials.json` as fallback. The CLI hashes a suffix per custom `CLAUDE_CONFIG_DIR`; we match what exists instead of recomputing its private formula. Secret reads use `/usr/bin/security` first. See the rake table. + The usage endpoint also requires the `user:profile` scope as of mid-2026. Tokens from older logins return 403 and the only fix is `claude /login`. - `Sources/Usage/AppUsage.swift` — `plan` field carries Claude's `subscriptionType` (from keychain) or Codex's `plan_type` (from API top-level). Surfaced as the chip badge in `SettingsView` + `UsageView`. ## Build details diff --git a/Sources/Usage/ClaudeCredentials.swift b/Sources/Usage/ClaudeCredentials.swift index 1ba74463..bc4e938b 100644 --- a/Sources/Usage/ClaudeCredentials.swift +++ b/Sources/Usage/ClaudeCredentials.swift @@ -235,11 +235,21 @@ enum ClaudeCredentials { /// pops the ACL prompt on the machine running the tests. static var keychainCandidatesProvider: () -> [KeychainCandidate] = readClaudeKeychainCandidates static var keychainModificationDatesProvider: () -> [Date] = claudeKeychainModificationDates + private struct KeychainTarget: Hashable { + let service: String + let account: String + } + private static let keychainTargetsLock = NSLock() + private static var _keychainTargets: [KeychainTarget] = [] static func clearCache() { cachedClaudeCreds = nil } + static func refreshCredentialStoreTargets() { + _ = claudeKeychainItems() + } + /// Reads Claude Code's login from the keychain or file store, or nil if /// there isn't a usable one — the caller then falls through to the next /// token source. The KEYCHAIN comes first: Claude Code 2.x reads the @@ -352,7 +362,7 @@ enum ClaudeCredentials { var result: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let items = result as? [[String: Any]] else { return [] } - return items + let claudeItems = items .compactMap { item -> (service: String, account: String, modified: Date?)? in guard let service = item[kSecAttrService as String] as? String, isClaudeCredentialService(service), @@ -363,14 +373,15 @@ enum ClaudeCredentials { (lhs.service == claudeServiceBase ? 0 : 1, lhs.service) < (rhs.service == claudeServiceBase ? 0 : 1, rhs.service) } + let targets = claudeItems.map { KeychainTarget(service: $0.service, account: $0.account) } + keychainTargetsLock.withLock { _keychainTargets = targets } + return claudeItems } /// Prompt-free "has the credential store changed?" snapshot: the newest - /// of the credentials file's mtime and the keychain items' modification - /// dates, both from metadata-only reads that never trip the ACL prompt. - /// The re-auth poll loop compares snapshots so it pays the secret read - /// (and its possible prompt) only once `claude auth login` has actually - /// written new credentials — not on every 5s tick. + /// of the credentials file's mtime and targeted metadata queries for the + /// Claude items discovered at a normal refresh boundary. It never reads + /// every generic-password item on the watcher's 5-second tick. static func credentialStoreFingerprint() -> Date? { var dates = keychainModificationDatesProvider() if let attrs = try? FileManager.default.attributesOfItem(atPath: claudeCredentialsFilePath()), @@ -405,7 +416,20 @@ enum ClaudeCredentials { } private static func claudeKeychainModificationDates() -> [Date] { - claudeKeychainItems().compactMap { $0.modified } + let targets = keychainTargetsLock.withLock { _keychainTargets } + return targets.compactMap { target in + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: target.service, + kSecAttrAccount as String: target.account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + ] + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let item = result as? [String: Any] else { return nil } + return item[kSecAttrModificationDate as String] as? Date + } } /// Decoded JSON blob of one account's item, or nil on any read/parse error. diff --git a/Sources/Usage/ClaudeUsageScheduling.swift b/Sources/Usage/ClaudeUsageScheduling.swift new file mode 100644 index 00000000..40810ad3 --- /dev/null +++ b/Sources/Usage/ClaudeUsageScheduling.swift @@ -0,0 +1,35 @@ +import Foundation + +struct ClaudeRequestGate { + static let minimumInterval: TimeInterval = 300 + private(set) var lastRequestAt: Date? + + func delayUntilAllowed(at date: Date) -> TimeInterval { + guard let lastRequestAt else { return 0 } + return max(0, lastRequestAt.addingTimeInterval(Self.minimumInterval).timeIntervalSince(date)) + } + + mutating func claim(at date: Date) -> Bool { + guard delayUntilAllowed(at: date) == 0 else { return false } + lastRequestAt = date + return true + } +} + +enum ClaudeCredentialWatchAction: Equatable { + case start + case keep + case stop + case none +} + +enum ClaudeCredentialWatchPolicy { + static func action(claudeSelected: Bool, watchRunning: Bool) -> ClaudeCredentialWatchAction { + switch (claudeSelected, watchRunning) { + case (true, false): return .start + case (true, true): return .keep + case (false, true): return .stop + case (false, false): return .none + } + } +} diff --git a/Sources/Usage/UsageStore.swift b/Sources/Usage/UsageStore.swift index be576111..ad59b5b3 100644 --- a/Sources/Usage/UsageStore.swift +++ b/Sources/Usage/UsageStore.swift @@ -37,6 +37,7 @@ final class UsageStore: ObservableObject { private var wakeGraceUntil: Date? private var wakeRefreshTask: Task? private var credWatchTask: Task? + private var deferredClaudeRefreshTask: Task? private var cooldownRetryTask: Task? private var sleepWakeObservers: [NSObjectProtocol] = [] /// One CLI refresh ping per expiry episode: armed when the expired-token @@ -59,10 +60,16 @@ final class UsageStore: ObservableObject { /// Deliberately in-memory only — a quit+relaunch retries immediately. private static let rateLimitCooldown: TimeInterval = 900 private var claudeCooldown = ClaudeUsageCooldown() + private var claudeRequestGate = ClaudeRequestGate() func refreshForSelectionChange() { - if loading { refreshRequestedAfterSelection = true } - else { refresh() } + if loading { + updateCredentialStoreWatch( + claudeSelected: ProviderVisibilityStore.shared.selected.contains(.claude)) + refreshRequestedAfterSelection = true + } else { + refresh() + } } func refresh() { @@ -125,12 +132,7 @@ final class UsageStore: ObservableObject { } let selection = ProviderVisibilityStore.shared.selected - if selection.contains(.claude) { - // Capture the metadata baseline before resolveUsage can read the - // cached token. A store rewrite during an otherwise successful - // old-token request must still differ from this baseline. - watchCredentialStore() - } + updateCredentialStoreWatch(claudeSelected: selection.contains(.claude)) loading = true refreshTask?.cancel() @@ -147,7 +149,7 @@ final class UsageStore: ObservableObject { let coolingDown = claudeCooldown.isActive(at: Date()) var cl: AppUsage? if !coolingDown && selection.contains(.claude) { - cl = await UsageFetcher.fetchClaude() + cl = await fetchClaudeIfAllowed() } let c = await codexResult let codexResetCredits = await codexResetCreditsResult @@ -371,7 +373,7 @@ final class UsageStore: ObservableObject { ClaudeCredentials.clearCache() } guard sawStoreWrite else { continue } - let cl = await UsageFetcher.fetchClaude() + guard let cl = await self?.fetchClaudeIfAllowed() else { continue } if Task.isCancelled { return } // The usage limiter is sticky once tripped (see // rateLimitCooldown) — retrying every 5s only feeds it. Bail @@ -387,7 +389,8 @@ final class UsageStore: ObservableObject { // restart it from the new store state. self?.credWatchTask?.cancel() self?.credWatchTask = nil - self?.watchCredentialStore() + self?.updateCredentialStoreWatch( + claudeSelected: ProviderVisibilityStore.shared.selected.contains(.claude)) self?.tokenRefreshPingAttempted = false } return @@ -431,6 +434,8 @@ final class UsageStore: ObservableObject { wakeGraceUntil = nil credWatchTask?.cancel() credWatchTask = nil + deferredClaudeRefreshTask?.cancel() + deferredClaudeRefreshTask = nil cooldownRetryTask?.cancel() cooldownRetryTask = nil } @@ -531,12 +536,33 @@ final class UsageStore: ObservableObject { } } - /// Recover from any external credential-store change, including an account - /// switch whose old token remains valid. The fingerprint is metadata-only - /// (keychain attribute query + file mtime - never an ACL prompt or network - /// request), so the 5s tick does not lower the usage endpoint's five-minute - /// polling floor. A secret read and event-driven probe happen only after - /// Claude Code (or `claude /login`) writes new credentials. + private func updateCredentialStoreWatch(claudeSelected: Bool) { + let action = ClaudeCredentialWatchPolicy.action( + claudeSelected: claudeSelected, + watchRunning: credWatchTask != nil) + switch action { + case .start: + // Capture the baseline before broad discovery so a store rewrite + // cannot land between discovery and watcher creation. + watchCredentialStore() + ClaudeCredentials.refreshCredentialStoreTargets() + case .keep: + // Broad keychain discovery stays on the ordinary refresh path; + // the 5-second watcher only queries these Claude item identities. + ClaudeCredentials.refreshCredentialStoreTargets() + case .stop: + credWatchTask?.cancel() + credWatchTask = nil + deferredClaudeRefreshTask?.cancel() + deferredClaudeRefreshTask = nil + case .none: + break + } + } + + /// Detect external credential changes promptly without turning metadata + /// checks into network polling. The request is separately coalesced at the + /// endpoint's five-minute minimum. private func watchCredentialStore() { guard credWatchTask == nil else { return } let watch = ClaudeCredentials.CredentialStoreWatch() @@ -553,11 +579,7 @@ final class UsageStore: ObservableObject { self.claudeCooldown.clear() self.cooldownRetryTask?.cancel() self.cooldownRetryTask = nil - await self.waitOutWakeGrace() - if Task.isCancelled { return } - self.refreshTask?.cancel() - await self.refreshTask?.value - if !Task.isCancelled { self.refresh() } + self.scheduleClaudeRefreshAtSafeBoundary() } } // Deliberately no cleanup on the cancelled path: cancellers nil @@ -566,6 +588,43 @@ final class UsageStore: ObservableObject { } } + private func fetchClaudeIfAllowed() async -> AppUsage? { + guard claudeRequestGate.claim(at: Date()) else { + scheduleClaudeRefreshAtSafeBoundary() + return nil + } + // A timer or manual refresh reached the safe boundary first. It owns + // the new credential fetch, so retire the deferred duplicate. + deferredClaudeRefreshTask?.cancel() + deferredClaudeRefreshTask = nil + return await UsageFetcher.fetchClaude() + } + + private func scheduleClaudeRefreshAtSafeBoundary() { + deferredClaudeRefreshTask?.cancel() + deferredClaudeRefreshTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + let delay = self.claudeRequestGate.delayUntilAllowed(at: Date()) + if delay > 0 { + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + continue + } + await self.waitOutWakeGrace() + guard !Task.isCancelled, + ProviderVisibilityStore.shared.selected.contains(.claude) else { return } + // A timer or manual refresh may have claimed the gate while + // this task waited through wake grace. + if self.claudeRequestGate.delayUntilAllowed(at: Date()) > 0 { continue } + self.deferredClaudeRefreshTask = nil + self.refreshTask?.cancel() + await self.refreshTask?.value + if !Task.isCancelled { self.refresh() } + return + } + } + } + /// One-shot refresh just past the cooldown so "rate limited" clears at /// the earliest safe moment. Without it, recovery waits for the next /// timer tick to line up AFTER the cooldown — worst case diff --git a/Tests/ClaudeUsageSchedulingTests.swift b/Tests/ClaudeUsageSchedulingTests.swift new file mode 100644 index 00000000..ca71a9fb --- /dev/null +++ b/Tests/ClaudeUsageSchedulingTests.swift @@ -0,0 +1,41 @@ +import Foundation + +@main +struct ClaudeUsageSchedulingTests { + static var failures = 0 + + static func expect(_ condition: Bool, _ label: String) { + if condition { + print("PASS \(label)") + } else { + print("FAIL \(label)") + failures += 1 + } + } + + static func main() { + let start = Date(timeIntervalSince1970: 1_700_003_000) + var gate = ClaudeRequestGate() + expect(gate.claim(at: start), "first Claude request is allowed") + expect(!gate.claim(at: start.addingTimeInterval(1)), + "second Claude request inside five minutes is denied") + expect(gate.delayUntilAllowed(at: start.addingTimeInterval(299)) == 1, + "credential change waits only for the remaining safe interval") + expect(gate.claim(at: start.addingTimeInterval(300)), + "Claude request is allowed at the five-minute boundary") + expect(!gate.claim(at: start.addingTimeInterval(300)), + "coalesced timer and credential refresh cannot double-probe") + + expect(ClaudeCredentialWatchPolicy.action(claudeSelected: true, watchRunning: false) == .start, + "selecting Claude starts its credential watcher") + expect(ClaudeCredentialWatchPolicy.action(claudeSelected: true, watchRunning: true) == .keep, + "selected Claude keeps one watcher") + expect(ClaudeCredentialWatchPolicy.action(claudeSelected: false, watchRunning: true) == .stop, + "deselecting Claude stops its credential watcher") + expect(ClaudeCredentialWatchPolicy.action(claudeSelected: false, watchRunning: false) == .none, + "deselected Claude does not create a watcher") + + if failures > 0 { exit(1) } + print("all ClaudeUsageSchedulingTests passed") + } +} diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index f8abecaa..3b778e2a 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -35,6 +35,14 @@ swiftc \ "$OUT_DIR/claude-usage-cooldown-tests" +swiftc \ + -parse-as-library \ + -o "$OUT_DIR/claude-usage-scheduling-tests" \ + Sources/Usage/ClaudeUsageScheduling.swift \ + Tests/ClaudeUsageSchedulingTests.swift + +"$OUT_DIR/claude-usage-scheduling-tests" + swiftc \ -parse-as-library \ -o "$OUT_DIR/notch-height-tests" \