on master help sign in
fix: make Claude credentials strictly read-only — stop signing out the CLI

The app was refreshing Claude's OAuth token behind the CLI's back. The token endpoint rotates the refresh token on each refresh, so the app and the CLI raced — whichever refreshed second used a refresh token the server had already retired, signing the user out of their own terminal. This was the top user-facing bug. Removed all token refresh logic from ClaudeUsageClient: - refreshCredentials, requestTokenRefresh, tokenRefreshAttempt - inFlightRefresh dedup, isExpired, formURLEncoded - TokenRefreshResponse, tokenEndpoint/clientID/scope constants - save/rotatedJSON/rawJSON from the credential store - .tokenRefreshFailed error case - refreshToken and expiresAt from ClaudeOAuthCredentials (structural) The app now reads the CLI's current access token and spends it as-is. A spent token (401/403) surfaces as .invalidCredentials, telling the user to run claude. The next fetch (every 1-5 min, credentials re-read each time) picks up whatever the CLI refreshed on its own.

commit2e6d84546a8e0db53dc669c3ac37c559fc64b704 authoriko <mail@iko.soy> dateSun 12 Jul 2026, 12:37 (3 months ago) parent3276b6b
4 files changed+153−590
CLAUDE.md+16−7 README.md+4−4 UsageMenubar/ClaudeUsageClient.swift+56−307 UsageMenubarTests/ClaudeUsageClientTests.swift+77−272
CLAUDE.md+16−7
@@ -7,7 +7,7 @@ Claude Code's Pro/Max usage limits when the CLI is signed in on the machine.
77- `ViewModel.swift` — Observable state: balance, Claude usage, refresh, notifications, history
88- `MenuView.swift` — SwiftUI popover view (balance, sparkline, Claude limits, settings)
99- `CreditsChecker.swift` — API client for `GET https://hyper.charm.land/v1/credits`
10−- `ClaudeUsageClient.swift` — Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
10+- `ClaudeUsageClient.swift` — read-only Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
1111- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper)
1212- `VersionFormatter.swift` — Version string formatting
1313
@@ -15,14 +15,23 @@ The status bar title stays `⚡{balance}` (Hyper only); Claude usage lives in th
1515The two fetches are independent — `ViewModel.refresh()` runs them with `async let`, and
1616either can fail or be unconfigured without touching the other.
1717
18−## Claude credentials (read-only, with one exception)
18+## Claude credentials (strictly read-only)
1919`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
2020Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials.json`.
21−- Never modify them, except the token refresh, which writes back a rotated token to
22− whichever source it was read from, preserving fields we don't model (patched raw JSON).
23−- The refresh endpoint **rotates the refresh token** — refreshing twice with the same one
24− signs the user out of their own CLI. Hence the client is an `actor` and dedups
25− concurrent refreshes onto one in-flight, cancellation-immune `Task`.
21+- **Never write them. Never refresh the token.** `ClaudeCredentialStoring` has no `save`,
22+ and that is load-bearing, not an oversight. The app reads the current access token and
23+ spends it as-is.
24+- Why: the token endpoint **rotates the refresh token**, and the CLI refreshes on its own
25+ schedule. An app that also refreshed would race it — whoever went second would present a
26+ refresh token the server had already retired, signing the user out of their own terminal.
27+ This app *did* do that, and it was the top bug. Do not reintroduce it, however tempting a
28+ "just refresh it for the user" convenience looks.
29+- A spent or revoked token (401/403) is therefore terminal, not retried: it surfaces as
30+ `.invalidCredentials`, whose message tells the user to run `claude`. The CLI mints a new
31+ token; the next fetch (every 1–5 min, credentials re-read each time) picks it up for free.
32+- Consequently `ClaudeOAuthCredentials` models neither `refreshToken` nor `expiresAt` —
33+ nothing here has any business knowing them.
34+- Retries still apply to *transport* failures (timeouts, 5xx, 429). That's not auth.
2635- Missing credentials are **not an error** — the popover just omits the section.
2736- **The app cannot be sandboxed**: spawning `security` and reading `~/.claude` require it
2837 to be off. Distribution is ad-hoc signed via Homebrew, not the App Store.
README.md+4−4
@@ -1,5 +1,5 @@
11<p align="center">
2− <img src="UsageMenubar/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png" width="128" height="128" alt="Usage Menubar app icon">
2+ <img src="UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-1024.png" width="128" height="128" alt="Usage Menubar app icon">
33</p>
44
55# UsageMenubar
@@ -64,7 +64,7 @@ There is nothing to configure. The app reads the OAuth credentials Claude Code a
6464Two things worth knowing:
6565
6666- **macOS will ask once.** Those credentials belong to Claude Code, not to this app, so the first read prompts you to allow access to the `Claude Code-credentials` Keychain item. Choose **Always Allow** and you won't be asked again.
67−- **The credentials are read-only, with one exception.** When the access token has expired, the app refreshes it against Anthropic's token endpoint and writes the result back to wherever it read it from — exactly as Claude Code itself would. Nothing else about them is ever modified, and the fields the app doesn't model are preserved verbatim, so your CLI session keeps working.
67+- **The credentials are strictly read-only.** The app reads the access token Claude Code stored and spends it as-is. It never refreshes it, and never writes those credentials back — that is the CLI's job alone. (Refreshing rotates the refresh token, so an app doing it behind the CLI's back would race it and sign you out of your own terminal.) If the token has expired, the Claude section says so and asks you to run `claude`; once the CLI has refreshed on its own, the app picks the new token up on its next fetch.
6868
6969## Settings
7070
@@ -149,7 +149,7 @@ usage-menubar/
149149│ └── Assets.xcassets/ # App icon, accent color
150150├── UsageMenubarTests/
151151│ ├── CreditsCheckerTests.swift # API, retry, fractional balance, keychain tests
152−│ ├── ClaudeUsageClientTests.swift # Usage decoding, token refresh, retry, credential store
152+│ ├── ClaudeUsageClientTests.swift # Usage decoding, read-only credential store, retry
153153│ └── VersionFormatterTests.swift # Version formatting tests
154154├── .github/workflows/
155155│ └── release.yml # Build + release on push to master
@@ -168,7 +168,7 @@ usage-menubar/
1681686. **State**: `ViewModel` owns the balance, loading/error state, balance history (for the sparkline), refresh task (cancellable to prevent races), and the refresh interval, and is shared between the menu bar item and the popover.
1691697. **Notifications**: When the balance crosses below 10 credits — or is already below 10 on the first successful fetch — `ViewModel` posts a local `UNUserNotification`. Permission is requested only once.
1701708. **Agent app**: `LSUIElement=true` in `Info.plist` makes the app a background agent — no dock icon, no main window.
171−9. **Claude usage**: `ClaudeUsageClient` reads Claude Code's OAuth credentials (its `Claude Code-credentials` Keychain item via `/usr/bin/security`, falling back to `~/.claude/.credentials.json`), refreshes the access token when it has expired, and fetches `GET https://api.anthropic.com/api/oauth/usage` with `Authorization: Bearer …` and `anthropic-beta: oauth-2025-04-20`. It is an actor so that overlapping refreshes (timer, manual click, wake) join a single in-flight token exchange — the endpoint rotates the refresh token, and rotating it twice would sign Claude Code out. Missing credentials are reported as "not configured" rather than as an error, and the popover hides the section.
171+9. **Claude usage**: `ClaudeUsageClient` reads Claude Code's OAuth credentials (its `Claude Code-credentials` Keychain item via `/usr/bin/security`, falling back to `~/.claude/.credentials.json`) and fetches `GET https://api.anthropic.com/api/oauth/usage` with `Authorization: Bearer` and `anthropic-beta: oauth-2025-04-20`. It is strictly read-only — it never refreshes the token and never writes credentials back. The token endpoint rotates the refresh token, so an app that also refreshed would race the CLI and sign the user out of their own terminal. When the token is spent (401/403), the app says so and points at `claude`; the next fetch (every 1–5 min, credentials re-read each time) picks up whatever the CLI refreshed on its own. Missing credentials are reported as "not configured" rather than as an error, and the popover hides the section.
17217210. **Independent fetches**: `ViewModel.refresh()` runs the Hyper and Claude requests concurrently with `async let`. Neither can fail, stall, or be unconfigured in a way that affects the other.
17317311. **No sandbox**: Reading credentials that belong to another app means spawning `/usr/bin/security` and reading a file in the user's home directory, neither of which the App Sandbox permits. The app is distributed ad-hoc signed via Homebrew rather than through the App Store, where the sandbox would be mandatory.
174174
UsageMenubar/ClaudeUsageClient.swift+56−307
@@ -154,45 +154,32 @@ private enum ISO8601 {
154154
155155// MARK: - Credentials
156156
157−/// The OAuth credentials the Claude Code CLI owns. The key names are Claude Code's,
158−/// not ours — this is its blob, and we only ever read it and rotate its tokens.
159−struct ClaudeOAuthCredentials: Codable, Equatable {
160− var claudeAiOauth: OAuthToken
161−
162− struct OAuthToken: Codable, Equatable {
163− var accessToken: String
164− var refreshToken: String
165− /// Epoch milliseconds.
166− var expiresAt: Int64
167− var scopes: [String]?
157+/// The subset of Claude Code's OAuth blob this app reads. The key names are Claude
158+/// Code's, not ours — and reading is *all* we do with them.
159+///
160+/// Deliberately models neither `refreshToken` nor `expiresAt`. Both existed only to
161+/// serve a token refresh, and refreshing is precisely what this app must never do: the
162+/// token endpoint rotates the refresh token, so an app refreshing behind the CLI's back
163+/// leaves the CLI holding a token the server has already retired — signing the user out
164+/// of their own terminal. Not modelling the fields is the cheapest way to keep it that
165+/// way. Every field Claude Code stores that isn't named here is simply ignored.
166+struct ClaudeOAuthCredentials: Decodable, Equatable {
167+ let claudeAiOauth: OAuthToken
168+
169+ struct OAuthToken: Decodable, Equatable {
170+ let accessToken: String
171+ let scopes: [String]?
168172 /// `"pro"`, `"max"`, `"team"`, …
169− var subscriptionType: String?
170− var rateLimitTier: String?
173+ let subscriptionType: String?
174+ let rateLimitTier: String?
171175 }
172176}
173177
174−/// Where credentials were read from, so a rotated token is written back to the same
175−/// place. Writing to the other one would leave Claude Code holding a refresh token
176−/// the server has already retired — i.e. would sign the user out of their own CLI.
177−enum ClaudeCredentialSource: Equatable {
178− case keychain(account: String)
179− case file(URL)
180−}
181−
182−/// Credentials together with where they came from.
183−struct StoredClaudeCredentials: Equatable {
184− var credentials: ClaudeOAuthCredentials
185− var source: ClaudeCredentialSource
186−}
187−
188−/// Reads — and, after a token refresh, writes back — the credentials Claude Code owns.
178+/// Reads the credentials Claude Code owns. Read-only by design — there is no `save`,
179+/// because the CLI is the only thing allowed to rotate these tokens.
189180/// Injectable so tests never go near the real Keychain or the real credentials file.
190181protocol ClaudeCredentialStoring {
191− func load() -> StoredClaudeCredentials?
192−
193− /// Best effort: failing to persist a refreshed token is not worth failing the
194− /// fetch over, and the next launch will simply refresh again.
195− func save(_ stored: StoredClaudeCredentials)
182+ func load() -> ClaudeOAuthCredentials?
196183}
197184
198185/// The real store: Claude Code's login-Keychain item, falling back to
@@ -223,101 +210,14 @@ struct ClaudeCodeCredentialStore: ClaudeCredentialStoring {
223210 self.credentialsFile = credentialsFile
224211 }
225212
226− func load() -> StoredClaudeCredentials? {
213+ func load() -> ClaudeOAuthCredentials? {
227214 if let json = Self.runSecurity(["find-generic-password", "-s", keychainService, "-w"]),
228215 let credentials = Self.decode(Data(json.utf8)) {
229− return StoredClaudeCredentials(
230− credentials: credentials,
231− source: .keychain(account: keychainAccount())
232− )
233− }
234−
235− guard let data = try? Data(contentsOf: credentialsFile),
236− let credentials = Self.decode(data) else { return nil }
237−
238− return StoredClaudeCredentials(credentials: credentials, source: .file(credentialsFile))
239− }
240−
241− func save(_ stored: StoredClaudeCredentials) {
242− guard let json = rotatedJSON(for: stored) else { return }
243−
244− switch stored.source {
245− case .keychain(let account):
246− _ = Self.runSecurity([
247− "add-generic-password",
248− "-U", // update the existing item rather than adding a second one
249− "-s", keychainService,
250− "-a", account,
251− "-w", json,
252− ])
253−
254− case .file(let url):
255− guard let data = json.data(using: .utf8) else { return }
256− try? data.write(to: url, options: .atomic)
257− // An atomic write replaces the file, so the 0600 mode has to be reapplied.
258− try? FileManager.default.setAttributes(
259− [.posixPermissions: 0o600],
260− ofItemAtPath: url.path
261− )
262− }
263− }
264−
265− /// The stored blob with only the three token fields rotated.
266− ///
267− /// Patches the raw JSON rather than re-encoding `ClaudeOAuthCredentials`, because
268− /// a round-trip through our own type would silently drop any field Anthropic adds
269− /// to the blob that we don't model — and this is the file the user's CLI logs in
270− /// with. Re-encoding is the fallback for when the raw blob can't be read back.
271− private func rotatedJSON(for stored: StoredClaudeCredentials) -> String? {
272− let token = stored.credentials.claudeAiOauth
273−
274− if let raw = rawJSON(from: stored.source),
275− let parsed = try? JSONSerialization.jsonObject(with: raw),
276− var object = parsed as? [String: Any],
277− var oauth = object["claudeAiOauth"] as? [String: Any] {
278− oauth["accessToken"] = token.accessToken
279− oauth["refreshToken"] = token.refreshToken
280− oauth["expiresAt"] = token.expiresAt
281− object["claudeAiOauth"] = oauth
282−
283− if let patched = try? JSONSerialization.data(withJSONObject: object),
284− let json = String(data: patched, encoding: .utf8) {
285− return json
286− }
287− }
288−
289− guard let encoded = try? JSONEncoder().encode(stored.credentials) else { return nil }
290− return String(data: encoded, encoding: .utf8)
291− }
292−
293− private func rawJSON(from source: ClaudeCredentialSource) -> Data? {
294− switch source {
295− case .keychain:
296− return Self.runSecurity(["find-generic-password", "-s", keychainService, "-w"])
297− .map { Data($0.utf8) }
298− case .file(let url):
299− return try? Data(contentsOf: url)
300− }
301− }
302−
303− /// The account on Claude Code's Keychain item, so a write-back updates that item
304− /// instead of adding a second one under a different account.
305− private func keychainAccount() -> String {
306− guard let output = Self.runSecurity(["find-generic-password", "-s", keychainService]) else {
307− return NSUserName()
308− }
309−
310− // The attribute dump spells the account as: "acct"<blob>="someone"
311− for line in output.split(separator: "\n") {
312− let trimmed = line.trimmingCharacters(in: .whitespaces)
313− guard trimmed.hasPrefix("\"acct\""),
314− let range = trimmed.range(of: #"="[^"]+"#, options: .regularExpression)
315− else { continue }
316− // The match starts at `="` and stops before the closing quote.
317− return String(trimmed[range].dropFirst(2))
216+ return credentials
318217 }
319218
320− return NSUserName()
219+ guard let data = try? Data(contentsOf: credentialsFile) else { return nil }
220+ return Self.decode(data)
321221 }
322222
323223 private static func decode(_ data: Data) -> ClaudeOAuthCredentials? {
@@ -363,8 +263,11 @@ enum ClaudeUsageError: LocalizedError, Equatable {
363263 /// Claude Code has never signed in here. Not an error the user needs to see — the
364264 /// popover just leaves the section out.
365265 case noCredentials
266+
267+ /// The token the CLI last stored has expired or been revoked. The app cannot fix
268+ /// this itself — only the CLI may mint a new one — so the message sends the user
269+ /// there.
366270 case invalidCredentials
367− case tokenRefreshFailed
368271 case requestFailed(String)
369272 case decodingFailed(String)
370273
@@ -374,8 +277,6 @@ enum ClaudeUsageError: LocalizedError, Equatable {
374277 return "No Claude Code credentials found. Run `claude` in a terminal to sign in."
375278 case .invalidCredentials:
376279 return "Claude sign-in expired. Run `claude` in a terminal to sign in again."
377− case .tokenRefreshFailed:
378− return "Could not refresh Claude credentials. Run `claude` in a terminal to sign in again."
379280 case .requestFailed(let message):
380281 return "Claude request failed: \(message)"
381282 case .decodingFailed(let message):
@@ -399,33 +300,30 @@ protocol ClaudeUsageChecking {
399300
400301// MARK: - Client
401302
402−/// Client for Claude Code's subscription usage API, authenticating with the OAuth
403−/// credentials the CLI already stores on this machine.
303+/// Client for Claude Code's subscription usage API, authenticating with whatever OAuth
304+/// access token the CLI has most recently stored on this machine.
305+///
306+/// **A passenger, never a driver.** It reads the CLI's token and spends it as-is. It
307+/// never refreshes: the token endpoint *rotates* the refresh token, and the CLI refreshes
308+/// on its own schedule, so an app that also refreshed would race it — whichever of the two
309+/// went second would present a refresh token the server had already retired, and the user
310+/// would find themselves signed out of their own terminal. That bug is not worth a usage
311+/// readout. When the token is spent, we say so and point at `claude`.
312+///
313+/// Credentials are re-read on every fetch (the app polls every 1–5 minutes), so a token
314+/// the CLI refreshes is picked up on the next tick without the app lifting a finger.
404315///
405−/// An actor rather than a plain client, because the refresh path mutates state the
406−/// user's CLI depends on: the token endpoint rotates the refresh token, so two
407−/// concurrent refreshes would leave one of them holding a token the server has
408−/// already retired. Overlapping fetches (timer, manual click, wake) instead join a
409−/// single in-flight refresh.
316+/// Still an actor: it holds no mutable state now that the refresh is gone, but the
317+/// overlapping fetches (timer, manual click, wake) share one instance, and an actor keeps
318+/// that sharing trivially safe.
410319actor ClaudeUsageClient: ClaudeUsageChecking {
411320 static let usageEndpoint = URL(string: "https://api.anthropic.com/api/oauth/usage")!
412− static let tokenEndpoint = URL(string: "https://platform.claude.com/v1/oauth/token")!
413321
414− /// Claude Code's public OAuth client id, and the scopes it signs in with.
415− static let clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
416− static let scope = "user:inference user:profile user:sessions:claude_code"
417−
418− /// Cloudflare fronts the token endpoint and answers 403 `browser_signature_banned`
419− /// to anything without a browser User-Agent.
322+ /// Cloudflare fronts the API and answers 403 `browser_signature_banned` to anything
323+ /// without a browser User-Agent.
420324 static let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
421325 + "(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
422326
423− /// Refresh this far ahead of the stated expiry rather than racing it.
424− static let expiryBuffer: TimeInterval = 60
425−
426− /// Used when the token endpoint omits `expires_in`.
427− static let defaultTokenLifetime: TimeInterval = 8 * 60 * 60
428−
429327 /// Retries *after* the initial attempt, so at most 3 requests per call.
430328 static let maxRetries = 2
431329
@@ -452,24 +350,17 @@ actor ClaudeUsageClient: ClaudeUsageChecking {
452350 private let session: URLSession
453351 private let store: ClaudeCredentialStoring
454352 private let retryBaseDelay: Duration
455− private let now: @Sendable () -> Date
456−
457− /// The refresh currently in flight, if any. See `refreshCredentials(from:)`.
458− private var inFlightRefresh: Task<StoredClaudeCredentials, Error>?
459353
460354 /// - Parameters:
461355 /// - retryBaseDelay: Delay before the first retry; doubles on each subsequent
462356 /// one (2s, then 4s). Injectable so tests need not wait in real time.
463− /// - now: The current instant. Injectable so tests can drive token expiry.
464357 init(
465358 session: URLSession? = nil,
466359 store: ClaudeCredentialStoring = ClaudeCodeCredentialStore(),
467− retryBaseDelay: Duration = .seconds(2),
468− now: @escaping @Sendable () -> Date = { Date() }
360+ retryBaseDelay: Duration = .seconds(2)
469361 ) {
470362 self.store = store
471363 self.retryBaseDelay = retryBaseDelay
472− self.now = now
473364 if let session {
474365 self.session = session
475366 } else {
@@ -480,43 +371,26 @@ actor ClaudeUsageClient: ClaudeUsageChecking {
480371 }
481372 }
482373
483− /// Fetches current usage, refreshing the OAuth token first if it has expired.
374+ /// Fetches current usage with the CLI's current access token, exactly as stored.
375+ ///
376+ /// The token is not checked for expiry and not refreshed on rejection — see the type
377+ /// comment. A spent token surfaces as `.invalidCredentials`, which asks the user to
378+ /// run `claude`; doing so has the CLI mint a fresh token, which the next fetch picks
379+ /// up on its own.
484380 ///
485381 /// - Throws: `ClaudeUsageError`. `.noCredentials` means Claude Code was never
486382 /// signed in here, which callers should treat as "nothing to show" rather than
487383 /// as a failure.
488384 func fetchUsage() async throws -> ClaudeUsageReport {
489− guard let stored = store.load() else { throw ClaudeUsageError.noCredentials }
490−
491− var current = stored
492− var didRefresh = false
493− if isExpired(stored.credentials) {
494− current = try await refreshCredentials(from: stored)
495− didRefresh = true
496− }
497−
498− do {
499− return try await report(with: current)
500− } catch ClaudeUsageError.invalidCredentials where !didRefresh {
501− // A token can be revoked server-side well before its stated expiry, so a
502− // rejection is worth one refresh and one retry.
503− guard let refreshed = try? await refreshCredentials(from: current) else {
504− // A failed refresh (Cloudflare, a retired refresh token) says less
505− // than the rejection that sent us here, so surface that instead.
506− throw ClaudeUsageError.invalidCredentials
507− }
508− return try await report(with: refreshed)
509− }
510− }
385+ guard let credentials = store.load() else { throw ClaudeUsageError.noCredentials }
511386
512− // MARK: - Usage Request
513−
514− private func report(with stored: StoredClaudeCredentials) async throws -> ClaudeUsageReport {
515− let token = stored.credentials.claudeAiOauth
387+ let token = credentials.claudeAiOauth
516388 let usage = try await requestUsage(token: token.accessToken)
517389 return ClaudeUsageReport(usage: usage, subscriptionType: token.subscriptionType)
518390 }
519391
392+ // MARK: - Usage Request
393+
520394 private func requestUsage(token: String) async throws -> ClaudeUsage {
521395 var request = URLRequest(url: Self.usageEndpoint, cachePolicy: .reloadIgnoringLocalCacheData)
522396 request.httpMethod = "GET"
@@ -534,101 +408,6 @@ actor ClaudeUsageClient: ClaudeUsageChecking {
534408 }
535409 }
536410
537− // MARK: - Token Refresh
538−
539− /// Exchanges the refresh token for a fresh access token and persists the result.
540− ///
541− /// Callers join whatever refresh is already running rather than starting a second
542− /// one, and the exchange itself runs in an unstructured task so that a cancelled
543− /// caller — the view model cancels the previous refresh whenever a new one starts
544− /// — can never abandon a rotation half-done, with the new refresh token neither
545− /// used nor written back.
546− private func refreshCredentials(from stored: StoredClaudeCredentials) async throws -> StoredClaudeCredentials {
547− if let existing = inFlightRefresh {
548− return try await existing.value
549− }
550−
551− let task = Task { () throws -> StoredClaudeCredentials in
552− let response = try await self.requestTokenRefresh(
553− refreshToken: stored.credentials.claudeAiOauth.refreshToken
554− )
555−
556− var refreshed = stored
557− refreshed.credentials.claudeAiOauth.accessToken = response.accessToken
558− if let rotated = response.refreshToken {
559− refreshed.credentials.claudeAiOauth.refreshToken = rotated
560− }
561− let lifetime = response.expiresIn.map(TimeInterval.init) ?? Self.defaultTokenLifetime
562− refreshed.credentials.claudeAiOauth.expiresAt =
563− Int64((self.now().timeIntervalSince1970 + lifetime) * 1000)
564−
565− self.store.save(refreshed)
566− return refreshed
567− }
568− // Only the task's creator ever clears the slot, so a joiner resuming late can
569− // never wipe out a refresh that started after it.
570− inFlightRefresh = task
571−
572− do {
573− let refreshed = try await task.value
574− inFlightRefresh = nil
575− return refreshed
576− } catch {
577− inFlightRefresh = nil
578− throw error
579− }
580− }
581−
582− /// Posts the refresh grant as JSON, then — for token endpoints that reject a JSON
583− /// body — retries the same exchange form-encoded before giving up.
584− private func requestTokenRefresh(refreshToken: String) async throws -> TokenRefreshResponse {
585− let parameters = [
586− "grant_type": "refresh_token",
587− "refresh_token": refreshToken,
588− "client_id": Self.clientID,
589− "scope": Self.scope,
590− ]
591−
592− if let response = await tokenRefreshAttempt(parameters: parameters, formEncoded: false) {
593− return response
594− }
595− if let response = await tokenRefreshAttempt(parameters: parameters, formEncoded: true) {
596− return response
597− }
598− throw ClaudeUsageError.tokenRefreshFailed
599− }
600−
601− /// One refresh exchange in one encoding. Returns `nil` rather than throwing, so the
602− /// caller can fall through to the other encoding.
603− private func tokenRefreshAttempt(
604− parameters: [String: String],
605− formEncoded: Bool
606− ) async -> TokenRefreshResponse? {
607− var request = URLRequest(url: Self.tokenEndpoint)
608− request.httpMethod = "POST"
609− request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent")
610− request.setValue("application/json", forHTTPHeaderField: "Accept")
611−
612− if formEncoded {
613− request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
614− request.httpBody = Self.formURLEncoded(parameters).data(using: .utf8)
615− } else {
616− request.setValue("application/json", forHTTPHeaderField: "Content-Type")
617− request.httpBody = try? JSONSerialization.data(withJSONObject: parameters)
618− }
619−
620− guard let data = try? await send(request) else { return nil }
621− return try? JSONDecoder().decode(TokenRefreshResponse.self, from: data)
622− }
623−
624− /// Whether the access token is spent, or close enough that using it would be a race.
625− private func isExpired(_ credentials: ClaudeOAuthCredentials) -> Bool {
626− let expiry = Date(
627− timeIntervalSince1970: TimeInterval(credentials.claudeAiOauth.expiresAt) / 1000
628− )
629− return expiry.timeIntervalSince(now()) <= Self.expiryBuffer
630− }
631−
632411 // MARK: - Transport
633412
634413 /// Sends a request, retrying transient failures with exponential backoff.
@@ -689,39 +468,9 @@ actor ClaudeUsageClient: ClaudeUsageChecking {
689468 }
690469 }
691470
692− /// Encodes parameters as an `application/x-www-form-urlencoded` body. Percent-encodes
693− /// against RFC 3986's unreserved set, so the spaces and colons in the scope don't
694− /// travel raw.
695− private static func formURLEncoded(_ parameters: [String: String]) -> String {
696− var allowed = CharacterSet.alphanumerics
697− allowed.insert(charactersIn: "-._~")
698−
699− return parameters.map { key, value in
700− let encodedKey = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
701− let encodedValue = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
702− return "\(encodedKey)=\(encodedValue)"
703− }
704− .joined(separator: "&")
705− }
706−
707471 /// A failed attempt, carrying the error to surface plus whether retrying is worthwhile.
708472 private struct AttemptFailure: Error {
709473 let underlying: ClaudeUsageError
710474 let isRetryable: Bool
711475 }
712476}
713−
714−/// The token endpoint's answer to a refresh grant.
715−struct TokenRefreshResponse: Decodable, Equatable {
716− let accessToken: String
717− let refreshToken: String?
718−
719− /// Seconds. Omitted by some deployments, in which case the caller assumes a default.
720− let expiresIn: Int?
721−
722− private enum CodingKeys: String, CodingKey {
723− case accessToken = "access_token"
724− case refreshToken = "refresh_token"
725− case expiresIn = "expires_in"
726− }
727−}
UsageMenubarTests/ClaudeUsageClientTests.swift+77−272
@@ -2,8 +2,8 @@ import XCTest
22import Foundation
33@testable import UsageMenubar
44
5−/// The instant every test pretends it is. Injected into the client, so token expiry is
6−/// a decision the test makes rather than a race against the wall clock.
5+/// The instant the countdown tests reckon from, so "3h 20m" is a fact rather than a race
6+/// against the wall clock.
77private let fixedNow = Date(timeIntervalSince1970: 1_700_000_000)
88
99final class ClaudeUsageClientTests: XCTestCase {
@@ -33,22 +33,17 @@ final class ClaudeUsageClientTests: XCTestCase {
3333 return ClaudeUsageClient(
3434 session: URLSession(configuration: config),
3535 store: store,
36− retryBaseDelay: retryBaseDelay,
37− now: { fixedNow }
36+ retryBaseDelay: retryBaseDelay
3837 )
3938 }
4039
4140 private func credentials(
4241 accessToken: String = "access-1",
43− refreshToken: String = "refresh-1",
44− expiresAt: Date = fixedNow.addingTimeInterval(3600),
4542 subscriptionType: String? = "max"
4643 ) -> ClaudeOAuthCredentials {
4744 ClaudeOAuthCredentials(
4845 claudeAiOauth: .init(
4946 accessToken: accessToken,
50− refreshToken: refreshToken,
51− expiresAt: Int64(expiresAt.timeIntervalSince1970 * 1000),
5247 scopes: ["user:inference"],
5348 subscriptionType: subscriptionType,
5449 rateLimitTier: nil
@@ -56,11 +51,6 @@ final class ClaudeUsageClientTests: XCTestCase {
5651 )
5752 }
5853
59− /// Credentials whose access token is already spent, so a fetch has to refresh first.
60− private func expiredCredentials() -> ClaudeOAuthCredentials {
61− credentials(expiresAt: fixedNow.addingTimeInterval(-1))
62− }
63−
6454 private func usageJSON(fiveHour: Int = 68, sevenDay: Int = 41) -> Data {
6555 Data("""
6656 {
@@ -70,21 +60,6 @@ final class ClaudeUsageClientTests: XCTestCase {
7060 """.utf8)
7161 }
7262
73− private func tokenJSON(
74− accessToken: String = "access-2",
75− refreshToken: String? = "refresh-2",
76− expiresIn: Int? = 3600
77− ) -> Data {
78− var fields = [#""access_token": "\#(accessToken)""#]
79− if let refreshToken { fields.append(#""refresh_token": "\#(refreshToken)""#) }
80− if let expiresIn { fields.append(#""expires_in": \#(expiresIn)"#) }
81− return Data("{\(fields.joined(separator: ", "))}".utf8)
82− }
83−
84− private func requests(to url: URL) -> [URLRequest] {
85− StubURLProtocol.recordedRequests.filter { $0.url == url }
86− }
87−
8863 // MARK: - Usage decoding
8964
9065 func testDecodesUsagePayload() throws {
@@ -256,193 +231,44 @@ final class ClaudeUsageClientTests: XCTestCase {
256231 XCTAssertEqual(StubURLProtocol.requestCount, 0)
257232 }
258233
259− func testValidTokenIsUsedWithoutRefreshing() async throws {
234+ func testTheStoredTokenIsSpentExactlyAsItIs() async throws {
260235 let store = FakeClaudeCredentialStore(credentials: credentials())
261236 StubURLProtocol.enqueue(.status(200, usageJSON()))
262237
263238 _ = try await makeClient(store: store).fetchUsage()
264239
240+ // One request, to the usage endpoint. No token endpoint, no grant, no rotation:
241+ // refreshing behind the CLI's back is what signed the user out to begin with.
265242 XCTAssertEqual(StubURLProtocol.requestCount, 1)
266− XCTAssertTrue(requests(to: ClaudeUsageClient.tokenEndpoint).isEmpty)
267− XCTAssertTrue(store.savedCredentials.isEmpty, "Nothing was rotated, so nothing to write back")
268− }
269−
270− // MARK: - Token refresh
271−
272− func testExpiredTokenIsRefreshedBeforeTheUsageRequest() async throws {
273− let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
274− StubURLProtocol.enqueue(
275− .status(200, tokenJSON(accessToken: "access-2")),
276− .status(200, usageJSON())
277− )
278−
279− let report = try await makeClient(store: store).fetchUsage()
280−
281− XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
282− XCTAssertEqual(StubURLProtocol.requestCount, 2)
283−
284− let recorded = StubURLProtocol.recordedRequests
285− XCTAssertEqual(recorded[0].url, ClaudeUsageClient.tokenEndpoint)
286− XCTAssertEqual(recorded[0].httpMethod, "POST")
287− XCTAssertEqual(recorded[1].url, ClaudeUsageClient.usageEndpoint)
288− XCTAssertEqual(
289− recorded[1].value(forHTTPHeaderField: "Authorization"),
290− "Bearer access-2",
291− "The usage request has to carry the refreshed token, not the spent one"
292− )
293− }
294−
295− func testRefreshRotatesAndPersistsTheCredentials() async throws {
296− let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
297− StubURLProtocol.enqueue(
298− .status(200, tokenJSON(accessToken: "access-2", refreshToken: "refresh-2", expiresIn: 3600)),
299− .status(200, usageJSON())
300− )
301−
302− _ = try await makeClient(store: store).fetchUsage()
303−
304− let saved = try XCTUnwrap(store.savedCredentials.last)
305− let token = saved.credentials.claudeAiOauth
306− XCTAssertEqual(token.accessToken, "access-2")
307− XCTAssertEqual(
308− token.refreshToken,
309− "refresh-2",
310− "The server rotated the refresh token; failing to store it would sign Claude Code out"
311− )
312− XCTAssertEqual(token.expiresAt, Int64((fixedNow.timeIntervalSince1970 + 3600) * 1000))
313− XCTAssertEqual(saved.source, .keychain(account: "tester"), "Written back where it was read from")
243+ XCTAssertEqual(StubURLProtocol.recordedRequests[0].url, ClaudeUsageClient.usageEndpoint)
244+ XCTAssertEqual(StubURLProtocol.recordedRequests[0].httpMethod, "GET")
314245 }
315246
316− func testRefreshKeepsTheOldRefreshTokenWhenTheServerDoesNotRotateIt() async throws {
317− let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
318− StubURLProtocol.enqueue(
319− .status(200, tokenJSON(accessToken: "access-2", refreshToken: nil)),
320− .status(200, usageJSON())
321− )
322−
323− _ = try await makeClient(store: store).fetchUsage()
324−
325− let saved = try XCTUnwrap(store.savedCredentials.last)
326− XCTAssertEqual(saved.credentials.claudeAiOauth.refreshToken, "refresh-1")
327− }
328−
329− func testRefreshWithoutExpiresInAssumesEightHours() async throws {
330− let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
331− StubURLProtocol.enqueue(
332− .status(200, tokenJSON(accessToken: "access-2", expiresIn: nil)),
333− .status(200, usageJSON())
334− )
335−
336− _ = try await makeClient(store: store).fetchUsage()
337−
338− let saved = try XCTUnwrap(store.savedCredentials.last)
339− XCTAssertEqual(
340− saved.credentials.claudeAiOauth.expiresAt,
341− Int64((fixedNow.timeIntervalSince1970 + 8 * 3600) * 1000)
342− )
343− }
344−
345− func testRefreshSendsTheGrant() async throws {
346− let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
347− StubURLProtocol.enqueue(.status(200, tokenJSON()), .status(200, usageJSON()))
348−
349− _ = try await makeClient(store: store).fetchUsage()
350−
351− let body = try XCTUnwrap(StubURLProtocol.recordedBodies.first ?? nil)
352− let grant = try XCTUnwrap(try JSONSerialization.jsonObject(with: body) as? [String: String])
353− XCTAssertEqual(grant["grant_type"], "refresh_token")
354− XCTAssertEqual(grant["refresh_token"], "refresh-1")
355− XCTAssertEqual(grant["client_id"], ClaudeUsageClient.clientID)
356− XCTAssertEqual(grant["scope"], ClaudeUsageClient.scope)
357− }
358−
359− func testRefreshFallsBackToFormEncoding() async throws {
360− let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
361− StubURLProtocol.enqueue(
362− .status(400, Data()), // the JSON body is rejected
363− .status(200, tokenJSON(accessToken: "access-2")),
364− .status(200, usageJSON())
365− )
366−
367− _ = try await makeClient(store: store).fetchUsage()
368−
369− XCTAssertEqual(StubURLProtocol.requestCount, 3)
370− let contentTypes = StubURLProtocol.recordedRequests.prefix(2).map {
371− $0.value(forHTTPHeaderField: "Content-Type")
372− }
373− XCTAssertEqual(contentTypes, ["application/json", "application/x-www-form-urlencoded"])
374−
375− let form = try XCTUnwrap(StubURLProtocol.recordedBodies[1].flatMap { String(data: $0, encoding: .utf8) })
376− XCTAssertTrue(form.contains("grant_type=refresh_token"), "Got: \(form)")
377− XCTAssertTrue(form.contains("refresh_token=refresh-1"), "Got: \(form)")
378− XCTAssertTrue(
379− form.contains("user%3Ainference"),
380− "The colons in the scope have to be percent-encoded, not sent raw. Got: \(form)"
381− )
382− }
383−
384− func testRefreshFailureThrowsTokenRefreshFailed() async {
385− let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
386− StubURLProtocol.enqueue(.status(400, Data())) // repeats, so both encodings fail
387−
388− do {
389− _ = try await makeClient(store: store).fetchUsage()
390− XCTFail("Expected tokenRefreshFailed error")
391− } catch ClaudeUsageError.tokenRefreshFailed {
392− // expected
393− } catch {
394− XCTFail("Unexpected error: \(error)")
395− }
396−
397− // Both encodings tried, and no usage request made with a token we never got.
398− XCTAssertEqual(StubURLProtocol.requestCount, 2)
399− XCTAssertTrue(requests(to: ClaudeUsageClient.usageEndpoint).isEmpty)
400− }
401−
402− func testConcurrentFetchesShareASingleRefresh() async throws {
403− // Two refreshes at once would each spend the refresh token, and whichever
404− // finished second would be left holding one the server had already retired.
405− let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
406− StubURLProtocol.enqueue(.status(200, tokenJSON()), .status(200, usageJSON()))
247+ func testCredentialsAreReReadOnEveryFetch() async throws {
248+ // The CLI refreshes on its own schedule and we never do, so the only way to see
249+ // a new token is to go back to the store each time. Caching one would strand the
250+ // app on a token the CLI has long since replaced.
251+ let store = FakeClaudeCredentialStore(credentials: credentials(accessToken: "access-1"))
252+ StubURLProtocol.enqueue(.status(200, usageJSON()), .status(200, usageJSON()))
407253
408254 let client = makeClient(store: store)
409− async let first = client.fetchUsage()
410− async let second = client.fetchUsage()
411− _ = try await (first, second)
255+ _ = try await client.fetchUsage()
412256
413− XCTAssertEqual(requests(to: ClaudeUsageClient.tokenEndpoint).count, 1)
414− XCTAssertEqual(requests(to: ClaudeUsageClient.usageEndpoint).count, 2)
415− XCTAssertEqual(store.savedCredentials.count, 1)
257+ store.replace(with: credentials(accessToken: "access-2"))
258+ _ = try await client.fetchUsage()
259+
260+ XCTAssertEqual(store.loadCount, 2)
261+ let sent = StubURLProtocol.recordedRequests.map { $0.value(forHTTPHeaderField: "Authorization") }
262+ XCTAssertEqual(sent, ["Bearer access-1", "Bearer access-2"])
416263 }
417264
418265 // MARK: - Rejected tokens
419266
420− func testRejectedTokenIsRefreshedOnceThenRetried() async throws {
421− // A token can be revoked server-side well before its stated expiry.
267+ func testRejectedTokenSurfacesInvalidCredentialsWithoutRefreshing() async {
268+ // A spent or revoked token is the user's to fix by running `claude`. The app
269+ // attempting the refresh itself is the bug this whole client is shaped around.
422270 let store = FakeClaudeCredentialStore(credentials: credentials())
423− StubURLProtocol.enqueue(
424− .status(401, Data()),
425− .status(200, tokenJSON(accessToken: "access-2")),
426− .status(200, usageJSON())
427− )
428−
429− let report = try await makeClient(store: store).fetchUsage()
430−
431− XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
432− XCTAssertEqual(StubURLProtocol.requestCount, 3)
433− XCTAssertEqual(
434− StubURLProtocol.recordedRequests[2].value(forHTTPHeaderField: "Authorization"),
435− "Bearer access-2"
436− )
437− }
438−
439− func testRepeatedRejectionSurfacesInvalidCredentials() async {
440− let store = FakeClaudeCredentialStore(credentials: credentials())
441− StubURLProtocol.enqueue(
442− .status(401, Data()),
443− .status(200, tokenJSON(accessToken: "access-2")),
444− .status(401, Data())
445− )
271+ StubURLProtocol.enqueue(.status(401, Data()))
446272
447273 do {
448274 _ = try await makeClient(store: store).fetchUsage()
@@ -453,25 +279,11 @@ final class ClaudeUsageClientTests: XCTestCase {
453279 XCTFail("Unexpected error: \(error)")
454280 }
455281
456− // One refresh, and no attempt at a second: it plainly isn't the token.
457− XCTAssertEqual(StubURLProtocol.requestCount, 3)
458− XCTAssertEqual(requests(to: ClaudeUsageClient.tokenEndpoint).count, 1)
459− }
460−
461− func testFailedRefreshAfterRejectionSurfacesTheRejection() async {
462− // "Couldn't refresh" says less than "the server rejected your token", and both
463− // point at the same fix, so the rejection is the more useful of the two.
464− let store = FakeClaudeCredentialStore(credentials: credentials())
465− StubURLProtocol.enqueue(.status(401, Data()), .status(403, Data()))
466−
467− do {
468− _ = try await makeClient(store: store).fetchUsage()
469− XCTFail("Expected invalidCredentials error")
470− } catch ClaudeUsageError.invalidCredentials {
471− // expected
472− } catch {
473− XCTFail("Unexpected error: \(error)")
474− }
282+ XCTAssertEqual(
283+ StubURLProtocol.requestCount,
284+ 1,
285+ "A 401 must end the fetch — no refresh, and no retry of the request either"
286+ )
475287 }
476288
477289 func testForbiddenIsTreatedAsARejectedToken() async {
@@ -486,6 +298,15 @@ final class ClaudeUsageClientTests: XCTestCase {
486298 } catch {
487299 XCTFail("Unexpected error: \(error)")
488300 }
301+
302+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
303+ }
304+
305+ func testTheInvalidCredentialsMessagePointsAtTheCLI() {
306+ // The app can't mint a token, so the only useful thing it can say is where one
307+ // comes from.
308+ let message = ClaudeUsageError.invalidCredentials.errorDescription
309+ XCTAssertEqual(message, "Claude sign-in expired. Run `claude` in a terminal to sign in again.")
489310 }
490311
491312 // MARK: - Retries
@@ -606,7 +427,8 @@ final class ClaudeCodeCredentialStoreTests: XCTestCase {
606427
607428 private var directory: URL!
608429
609− /// A credentials blob carrying two fields we deliberately don't model.
430+ /// A credentials blob as Claude Code actually writes it: the two token fields we no
431+ /// longer model, and two more we never did.
610432 private let credentialsJSON = """
611433 {
612434 "claudeAiOauth": {
@@ -651,12 +473,11 @@ final class ClaudeCodeCredentialStoreTests: XCTestCase {
651473 func testReadsCredentialsFromTheFileWhenTheKeychainHasNothing() throws {
652474 let file = try writeCredentialsFile()
653475
654− let stored = try XCTUnwrap(makeStore(file: file).load())
476+ let credentials = try XCTUnwrap(makeStore(file: file).load())
655477
656− XCTAssertEqual(stored.credentials.claudeAiOauth.accessToken, "access-1")
657− XCTAssertEqual(stored.credentials.claudeAiOauth.refreshToken, "refresh-1")
658− XCTAssertEqual(stored.credentials.claudeAiOauth.subscriptionType, "max")
659− XCTAssertEqual(stored.source, .file(file))
478+ XCTAssertEqual(credentials.claudeAiOauth.accessToken, "access-1")
479+ XCTAssertEqual(credentials.claudeAiOauth.subscriptionType, "max")
480+ XCTAssertEqual(credentials.claudeAiOauth.scopes, ["user:inference", "user:profile"])
660481 }
661482
662483 func testReturnsNilWhenThereIsNeitherAKeychainItemNorAFile() {
@@ -672,81 +493,65 @@ final class ClaudeCodeCredentialStoreTests: XCTestCase {
672493 XCTAssertNil(makeStore(file: file).load())
673494 }
674495
675− func testWriteBackRotatesTheTokensAndKeepsEverythingElse() throws {
676− let file = try writeCredentialsFile()
677− let store = makeStore(file: file)
678−
679− var stored = try XCTUnwrap(store.load())
680− stored.credentials.claudeAiOauth.accessToken = "access-2"
681− stored.credentials.claudeAiOauth.refreshToken = "refresh-2"
682− stored.credentials.claudeAiOauth.expiresAt = 4_100_000_000_000
683− store.save(stored)
684−
685− let written = try XCTUnwrap(
686− try JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any]
687− )
688− let oauth = try XCTUnwrap(written["claudeAiOauth"] as? [String: Any])
496+ func testABlobWithoutTheFieldsWeDroppedStillLoads() throws {
497+ // `refreshToken` and `expiresAt` are no longer modelled, so their absence must
498+ // not fail the decode — nor must their presence, above.
499+ let file = directory.appendingPathComponent(".credentials.json")
500+ try Data(#"{"claudeAiOauth": {"accessToken": "access-1"}}"#.utf8).write(to: file)
689501
690− XCTAssertEqual(oauth["accessToken"] as? String, "access-2")
691− XCTAssertEqual(oauth["refreshToken"] as? String, "refresh-2")
692− XCTAssertEqual((oauth["expiresAt"] as? NSNumber)?.int64Value, 4_100_000_000_000)
502+ let credentials = try XCTUnwrap(makeStore(file: file).load())
693503
694− // This is the file the user's CLI signs in with. Round-tripping it through our
695− // own type would drop the fields we don't model, so the write patches the raw
696− // JSON instead — and these two have to survive it.
697− XCTAssertEqual(oauth["somethingWeDoNotModel"] as? String, "keep me")
698− XCTAssertEqual(written["anotherTopLevelKey"] as? String, "keep me too")
699− XCTAssertEqual(oauth["subscriptionType"] as? String, "max")
504+ XCTAssertEqual(credentials.claudeAiOauth.accessToken, "access-1")
505+ XCTAssertNil(credentials.claudeAiOauth.subscriptionType)
700506 }
701507
702− func testWriteBackLeavesTheFileReadableOnlyByItsOwner() throws {
508+ func testLoadingLeavesTheCredentialsFileByteForByteUntouched() throws {
509+ // The regression guard for the bug this store was built around: the app used to
510+ // write a rotated token back here, racing the CLI's own refresh and signing the
511+ // user out of their terminal. It must now be a reader and nothing else.
703512 let file = try writeCredentialsFile()
704− let store = makeStore(file: file)
513+ let before = try Data(contentsOf: file)
705514
706− let stored = try XCTUnwrap(store.load())
707− store.save(stored)
515+ _ = makeStore(file: file).load()
708516
709− let attributes = try FileManager.default.attributesOfItem(atPath: file.path)
710− XCTAssertEqual(
711− (attributes[.posixPermissions] as? NSNumber)?.int16Value,
712− 0o600,
713− "An atomic write replaces the file, so the mode has to be reapplied"
714− )
517+ XCTAssertEqual(try Data(contentsOf: file), before)
715518 }
716519}
717520
718521// MARK: - Fake credential store
719522
720523/// An in-memory `ClaudeCredentialStoring`. Never goes near the Keychain or the disk.
524+///
525+/// It cannot write, because the protocol no longer can: the CLI is the only thing that
526+/// may rotate these tokens. `replace(with:)` is the test standing in for the CLI having
527+/// done exactly that behind the app's back.
721528final class FakeClaudeCredentialStore: ClaudeCredentialStoring {
722529 private let lock = NSLock()
723− private var current: StoredClaudeCredentials?
724− private var writes: [StoredClaudeCredentials] = []
530+ private var current: ClaudeOAuthCredentials?
531+ private var loads = 0
725532
726− init(
727− credentials: ClaudeOAuthCredentials?,
728− source: ClaudeCredentialSource = .keychain(account: "tester")
729− ) {
730− current = credentials.map { StoredClaudeCredentials(credentials: $0, source: source) }
533+ init(credentials: ClaudeOAuthCredentials?) {
534+ current = credentials
731535 }
732536
733− /// Every write-back the client has made, oldest first.
734− var savedCredentials: [StoredClaudeCredentials] {
537+ /// How many times the client has gone back to the store — it should be once per fetch.
538+ var loadCount: Int {
735539 lock.lock()
736540 defer { lock.unlock() }
737− return writes
541+ return loads
738542 }
739543
740− func load() -> StoredClaudeCredentials? {
544+ /// Stands in for the CLI refreshing its own token between two fetches.
545+ func replace(with credentials: ClaudeOAuthCredentials?) {
741546 lock.lock()
742547 defer { lock.unlock() }
743− return current
548+ current = credentials
744549 }
745550
746− func save(_ stored: StoredClaudeCredentials) {
551+ func load() -> ClaudeOAuthCredentials? {
747552 lock.lock()
748553 defer { lock.unlock() }
749− current = stored
750− writes.append(stored)
554+ loads += 1
555+ return current
751556 }
752557}