diff --git a/CLAUDE.md b/CLAUDE.md index e62fb416..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, 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 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 a477bc43..bc4e938b 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. @@ -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()), @@ -380,8 +391,45 @@ enum ClaudeCredentials { return dates.max() } + /// 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] { - 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/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/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 bb19fdbc..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 @@ -58,11 +59,17 @@ 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() + 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() { @@ -124,6 +131,9 @@ final class UsageStore: ObservableObject { return } + let selection = ProviderVisibilityStore.shared.selected + updateCredentialStoreWatch(claudeSelected: selection.contains(.claude)) + loading = true refreshTask?.cancel() refreshTask = Task { @@ -134,13 +144,12 @@ 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 + 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 @@ -176,11 +185,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 @@ -198,13 +207,7 @@ 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. 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 +222,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 } } @@ -372,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 @@ -384,10 +385,12 @@ 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?.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,17 +536,36 @@ 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. + 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 baseline = ClaudeCredentials.credentialStoreFingerprint() + let watch = ClaudeCredentials.CredentialStoreWatch() credWatchTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 5_000_000_000) @@ -551,15 +575,11 @@ 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() - self.credWatchTask = nil - await self.waitOutWakeGrace() - if Task.isCancelled { return } - self.refreshTask?.cancel() - await self.refreshTask?.value - if !Task.isCancelled { self.refresh() } - return + if watch.invalidateCachedCredentialsIfStoreChanged() { + self.claudeCooldown.clear() + self.cooldownRetryTask?.cancel() + self.cooldownRetryTask = nil + self.scheduleClaudeRefreshAtSafeBoundary() } } // Deliberately no cleanup on the cancelled path: cancellers nil @@ -568,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/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/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/Tests/ResolveUsageTests.swift b/Tests/ResolveUsageTests.swift index 6e8b7dc0..07b9762b 100644 --- a/Tests/ResolveUsageTests.swift +++ b/Tests/ResolveUsageTests.swift @@ -414,6 +414,62 @@ struct ResolveUsageTests { expect(ClaudeCredentials.decodeClaudeKeychainBlob(Data("not json at all".utf8)) == nil, "T12 garbage yields nil") + // 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] } + 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 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 remains valid while the store switches") + } else { + expect(false, "T13 old account token remains valid while the store switches") + } + expect(ClaudeCredentials.cachedClaudeCreds?.accessToken == "old-account-token", + "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) + 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") diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 359b7859..3b778e2a 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -27,6 +27,22 @@ 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/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" \